From 535f3d65a30da492a719a45aa311b189d5a12b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 00:25:31 +0000 Subject: [PATCH] Stop an unresolved refinement being reported as a cycle [minor] Refines() stops walking for two reasons - a type it has already seen, and a link naming something the schema does not declare - and ValidateRefinement could tell them apart only by whether the chain reached a non-semantic type, which both answer the same way. So `A` refines `B` refines `Missing` reported a cycle that does not exist, on `A`, while `B` - the declaration with the typo in it - was named only by the second message. RefinesItself() asks the question the message makes: the chain ended on a cycle exactly when the deepest type it reached still refines something that resolves, since that is the link Refines() refused to follow twice. An unresolved tail is left to the declaration that names it, which is where the fix is, keeping it one message rather than two. Fixes #171 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CCVJEzptkDVM2wWtGTY7Av --- Schema.Test/SchemaSemanticTypeTests.cs | 45 ++++++++++++++++++++++++++ Schema/Models/Schema.Validation.cs | 9 ++++-- Schema/Models/SchemaSemanticType.cs | 21 ++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Schema.Test/SchemaSemanticTypeTests.cs b/Schema.Test/SchemaSemanticTypeTests.cs index 7cf785f..af00956 100644 --- a/Schema.Test/SchemaSemanticTypeTests.cs +++ b/Schema.Test/SchemaSemanticTypeTests.cs @@ -190,6 +190,51 @@ public void AnUnresolvedRefinementIsRejected() Assert.Contains(i => i.Message.Contains("does not declare", StringComparison.Ordinal), schema.Validate()); } + /// + /// A chain of refinement ending at a name the schema does not declare is that one unresolved + /// name, and nothing else. + /// + /// + /// Walking the chain stops both at a type already seen and at a link naming nothing, so the two + /// used to be indistinguishable to whoever asked: A was told it refined itself through a + /// cycle that does not exist, and the declaration to fix - B, which names the missing + /// type - was the one the message did not name. Depth one never reaches that branch, which is + /// why the case above did not catch it. + /// + [TestMethod] + public void AnUnresolvedRefinementDeeperInAChainIsNotACycle() + { + Schema schema = new(); + SchemaSemanticType weight = schema.AddSemanticType("Weight".As())!; + SchemaSemanticType force = schema.AddSemanticType("ForceMagnitude".As())!; + weight.SetUnderlyingType(new Semantic { SemanticTypeName = "ForceMagnitude".As() }); + force.SetUnderlyingType(new Semantic { SemanticTypeName = "Missing".As() }); + + SchemaValidationIssue issue = schema.Validate().Single(); + + Assert.Contains("does not declare", issue.Message, StringComparison.Ordinal); + Assert.AreSame(force, issue.Element); + Assert.IsFalse(weight.RefinesItself()); + } + + /// + /// A cycle is still a cycle when the type that closes it is not the one asked. + /// + [TestMethod] + public void ARefinementChainThatRevisitsATypeStillReportsItself() + { + Schema schema = new(); + SchemaSemanticType a = schema.AddSemanticType("A".As())!; + SchemaSemanticType b = schema.AddSemanticType("B".As())!; + SchemaSemanticType c = schema.AddSemanticType("C".As())!; + a.SetUnderlyingType(new Semantic { SemanticTypeName = "B".As() }); + b.SetUnderlyingType(new Semantic { SemanticTypeName = "C".As() }); + c.SetUnderlyingType(new Semantic { SemanticTypeName = "B".As() }); + + Assert.IsTrue(a.RefinesItself()); + Assert.Contains(i => i.Message.Contains("refines itself", StringComparison.Ordinal), schema.Validate()); + } + /// /// A semantic type with no underlying type chosen is not generatable. /// diff --git a/Schema/Models/Schema.Validation.cs b/Schema/Models/Schema.Validation.cs index 8935a2b..bc3f2f0 100644 --- a/Schema/Models/Schema.Validation.cs +++ b/Schema/Models/Schema.Validation.cs @@ -585,9 +585,12 @@ private void ValidateRefinement(Collection issues, Schema return; } - // 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)) + // Asked whether the chain reached anything real, a chain ending at a name the schema does + // not declare answers the same as a cycle, and the author is sent looking for one that is + // not there. RefinesItself() asks the question this message makes: whether the chain came + // back to a type it had already seen. The unresolved tail is reported at the declaration + // naming it, which is the declaration to fix. + if (semanticType.RefinesItself()) { Report(issues, path, semanticType, $"Semantic type '{semanticType.Name}' refines itself, directly or through a cycle, so it is represented as nothing."); } diff --git a/Schema/Models/SchemaSemanticType.cs b/Schema/Models/SchemaSemanticType.cs index 5bc3a76..344e26e 100644 --- a/Schema/Models/SchemaSemanticType.cs +++ b/Schema/Models/SchemaSemanticType.cs @@ -124,6 +124,27 @@ public IEnumerable Refines() } } + /// + /// Gets whether the chain of refinement from this type comes back to a type already seen. + /// + /// + /// stops for two reasons, and the chain looks the same from the outside + /// either way: a type it has already seen, which is a cycle, and a link naming something this + /// schema does not declare, which is not. Only the first is this type's to answer for — an + /// unresolved name is reported at the declaration that names it, which is where the fix is — so + /// telling the two apart is what stops a typo being reported as a cycle that does not exist. + /// + /// The chain ended on a cycle exactly when the deepest type it reached still refines something + /// that resolves, since that is the link refused to follow a second time. + /// + /// + /// when the chain revisits a type. + public bool RefinesItself() + { + SchemaSemanticType deepest = Refines().LastOrDefault() ?? this; + return deepest.UnderlyingType is Semantic semantic && semantic.Declaration is not null; + } + /// /// Gets the type values of this one are ultimately stored as, following any chain of /// refinement down to the first type that is not itself semantic.