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.