Skip to content

A refinement chain ending at an unresolved name is reported as a cycle that does not exist #171

Description

@matt-edmondson

What happens

ValidateRefinement infers "cycle" from Refines() never reaching a non-semantic type:

// Schema/Models/Schema.Validation.cs:563-568
// Refines() stops at the first type it has already seen, so a cycle shows up as a chain
// that never reaches a non-semantic type rather than as a hang.
if (!semanticType.Refines().Any(refined => refined.UnderlyingType is not Semantic))
{
    Report(issues, path, semanticType, $"Semantic type '{semanticType.Name}' refines itself, directly or through a cycle, so it is represented as nothing.");
}

But Refines() has two exits, not one. Its loop condition is:

// Schema/Models/SchemaSemanticType.cs:115
while (current is Semantic semantic && semantic.Declaration is SchemaSemanticType declaration)

so it also stops when a link's Declaration is null — an unresolved name. An unresolved tail is therefore indistinguishable from a cycle at the call site.

Failure scenario

A refines B; B refines Missing, which the schema does not declare. schema.Validate() returns:

[Error] A: Semantic type 'A' refines itself, directly or through a cycle, so it is represented as nothing.
[Error] B: Semantic type refines 'Missing', which this schema does not declare.

There is no cycle anywhere. The first message sends the author looking for one, and it names the wrong element — A is fine; B is the declaration to fix.

Why it matters

This is exactly the "one message rather than one per property" story that CLAUDE.md tells about unresolved semantic types:

That is what keeps a cycle a reported error instead of a recursion with no bottom, and it is why an unresolved semantic type is one message rather than one per property hanging off it.

Here the extra message is not merely redundant — it is factually wrong about what is broken, and points at the wrong declaration.

Missing coverage

Schema.Test/SchemaSemanticTypeTests.cs:181-190 (AnUnresolvedRefinementIsRejected) covers a depth-1 unresolved refinement only, which never reaches this branch. The bug needs a chain of at least two links to surface.

Suggested fix

Distinguish the two terminations. Either:

  • have Refines() expose why it stopped (revisited vs. unresolved); or
  • make the check "the chain revisited a type already seen" — a real cycle — rather than "the chain never reached anything real", and suppress the report when the tail is a Semantic with a null Declaration, since that case is already reported at the offending declaration.

Add a test for a two-link chain ending in an unresolved name, asserting exactly one issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions