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.