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.
What happens
ValidateRefinementinfers "cycle" fromRefines()never reaching a non-semantic type:But
Refines()has two exits, not one. Its loop condition is:so it also stops when a link's
Declarationisnull— an unresolved name. An unresolved tail is therefore indistinguishable from a cycle at the call site.Failure scenario
ArefinesB;BrefinesMissing, which the schema does not declare.schema.Validate()returns:There is no cycle anywhere. The first message sends the author looking for one, and it names the wrong element —
Ais fine;Bis 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:
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:
Refines()expose why it stopped (revisited vs. unresolved); orSemanticwith a nullDeclaration, 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.