feat(schema): locate derivation/cycle-pass schema diagnostics

Convert the three derivation/cycle-pass compile checkers to located findings
so each error attaches to its declaring node instead of the schema root
(#169): an all-group referenced inside a sequence/choice/all locates on the
offending group reference (allGroupReferencePlacementFindings); a type
derived from itself locates on its complexType/simpleType
(derivationCycleFindings); and an element that is a member of its own
substitution group, or a model group or attribute group that references
itself, locates on its declaration (circularReferenceFindings).

The cycle checkers report a cycle by component name over a name-keyed graph,
so each now records the declaring node in a parallel nodeOf map built in the
same loop as the graph; a name that yields a finding always has a node, so a
mutual cycle underlines each member on its own declaration. Every predicate,
key ordering, and reason string is unchanged; only the location each finding
carries differs. The three checkers had exactly one caller each, so each is
renamed in place and the .unlocated wrap dropped. Adds a located-diagnostics
test per checker family, covering both substitution and group-reference
cycles.
This commit is contained in:
Mihaela Mihaljevic 2026-06-21 16:35:59 +02:00
parent 49fe824601
commit ee5c46929c
5 changed files with 147 additions and 36 deletions

View File

@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`.
- Schema compile diagnostics are now located: `SchemaError.inconsistent` carries `[ValidationError]` with coding paths that resolve to `TreeNode.sourceRange` on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; `SchemaError.inconsistentFindings` exposes the full list. Schematron reuses the shared `validationCodingPath()` helper. Value-constraint errors now locate to their declaring element or attribute: a `default`/`fixed` on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of `xs:anySimpleType` and a constraining facet on `anySimpleType`-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty `targetNamespace` or `import` namespace, and an all-group reference with an illegal `minOccurs`/`maxOccurs` each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of `itemType`/inline `simpleType`, an empty union, a simpleType restriction whose base resolves to a complex type, an `itemType`/`memberType` referencing a type that is `final` for list/union derivation, and an attribute typed by a complex type each underline the offending `list`, `union`, `restriction`, or `attribute` node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the `attribute`; a duplicate attribute use or a second ID-typed attribute underlines its declaring `complexType` or `attributeGroup`; an attribute reference that contradicts a global `fixed` value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the `restriction`. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its `complexType`/`simpleType`, an element that is a member of its own substitution group underlines its `element`, a model group or attribute group that references itself underlines its `group`/`attributeGroup` declaration, and an all-group referenced inside a `sequence`/`choice`/`all` underlines the offending `group` reference.
- DTD parse-time validity findings are warnings until strict validation promotes them; `validateAgainstInternalDTD` returns full findings (errors and warnings).

View File

@ -8,7 +8,7 @@ extension PureXML.Schema.XSDParser {
/// where that reference appears inside a compositor (so the `all` is not the
/// whole content). The reference is followed only when it resolves to this
/// schema's own target namespace.
static func allGroupReferencePlacementErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] {
static func allGroupReferencePlacementFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] {
// A redefined group's effective content is the redefinition's, not the
// original loaded definition, so a redefined group name is exempt: its
// original `all` content is overridden (and the suite treats a reference to
@ -41,21 +41,30 @@ extension PureXML.Schema.XSDParser {
}
}
guard !allGroups.isEmpty else { return [] }
var errors: [String] = []
var findings: [PureXML.Schema.SchemaLocatedFinding] = []
for container in containers {
collectAllGroupRefPlacement(container, allGroups, bindings, target, into: &errors)
collectAllGroupRefPlacement(container, allGroups, bindings, target, into: &findings)
}
return errors
return findings
}
private static func collectAllGroupRefPlacement(_ node: XSDTree, _ allGroups: Set<String>, _ bindings: [String: String], _ target: String?, into errors: inout [String]) {
private static func collectAllGroupRefPlacement(
_ node: XSDTree,
_ allGroups: Set<String>,
_ bindings: [String: String],
_ target: String?,
into findings: inout [PureXML.Schema.SchemaLocatedFinding],
) {
let kind = AllNode.localName(node)
let insideCompositor = kind == "sequence" || kind == "choice" || kind == "all"
for child in AllNode.elementChildren(node) {
if insideCompositor, let referenced = allGroupReference(child, allGroups, bindings, target) {
errors.append("the all-group '\(referenced)' may not be referenced inside a '\(kind ?? "")'; an all group must be the whole content model")
findings.append(PureXML.Schema.SchemaLocatedFinding(
reason: "the all-group '\(referenced)' may not be referenced inside a '\(kind ?? "")'; an all group must be the whole content model",
node: child,
))
}
collectAllGroupRefPlacement(child, allGroups, bindings, target, into: &errors)
collectAllGroupRefPlacement(child, allGroups, bindings, target, into: &findings)
}
}

View File

@ -95,36 +95,30 @@ extension PureXML.Validation {
static var typeDerivationAcyclic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Type derivation chains contain no cycles") { document in
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.derivationCycleErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
PureXML.Schema.XSDParser.derivationCycleFindings(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
)
}
}
static var typeReferencesAcyclic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema type references contain no cycles") { document in
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.circularReferenceErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
PureXML.Schema.XSDParser.circularReferenceFindings(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
)
}
}
static var allGroupReferencesPlaced: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("xs:all group references appear only where permitted") { document in
PureXML.Schema.SchemaLocatedFinding.unlocated(
PureXML.Schema.XSDParser.allGroupReferencePlacementErrors(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
),
PureXML.Schema.XSDParser.allGroupReferencePlacementFindings(
document.containers,
document.context.namespaceBindings,
document.context.targetNamespace,
)
}
}

View File

@ -107,9 +107,10 @@ extension PureXML.Schema.XSDParser {
/// redefinition derives from its former self), so redefined types are treated as
/// chain ends rather than self-cycles, matching the suite's "circular ref is
/// allowed if parent is redefine".
static func derivationCycleErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] {
static func derivationCycleFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] {
let redefined = redefinedTypeNames(containers)
var graph: [String: String] = [:]
var nodeOf: [String: XSDTree] = [:]
for container in containers {
for kind in ["complexType", "simpleType"] {
for type in descendants(container, named: kind) {
@ -120,12 +121,13 @@ extension PureXML.Schema.XSDParser {
// local names collide (a `local:X` extending an imported `other:X`).
guard XSDDerivNode.referenceNamespace(base, bindings) == target else { continue }
graph[name] = XSDDerivNode.stripPrefix(base)
nodeOf[name] = type
}
}
}
return graph.keys.sorted().compactMap { name in
guard !redefined.contains(name), derivesFromItself(name, graph, redefined) else { return nil }
return "type '\(name)' must not be derived from itself"
return PureXML.Schema.SchemaLocatedFinding(reason: "type '\(name)' must not be derived from itself", node: nodeOf[name])
}
}
@ -189,36 +191,47 @@ extension PureXML.Schema.XSDParser {
/// chain may not loop. A reference is followed only when it resolves to this
/// schema's own target namespace, and a redefined group/attribute group is a
/// chain end (its single self-reference is the legal redefinition).
static func circularReferenceErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] {
substitutionCycleErrors(containers, bindings, target)
+ referenceCycleErrors(containers, "group", "model group", bindings, target)
+ referenceCycleErrors(containers, "attributeGroup", "attribute group", bindings, target)
static func circularReferenceFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] {
substitutionCycleFindings(containers, bindings, target)
+ referenceCycleFindings(containers, "group", "model group", bindings, target)
+ referenceCycleFindings(containers, "attributeGroup", "attribute group", bindings, target)
}
private static func substitutionCycleErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] {
private static func substitutionCycleFindings(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [PureXML.Schema.SchemaLocatedFinding] {
var graph: [String: String] = [:]
var nodeOf: [String: XSDTree] = [:]
for container in containers {
for element in descendants(container, named: "element") {
guard let name = XSDDerivNode.attribute(element, "name"),
let head = XSDDerivNode.attribute(element, "substitutionGroup"),
XSDDerivNode.referenceNamespace(head, bindings) == target else { continue }
graph[name] = XSDDerivNode.stripPrefix(head)
nodeOf[name] = element
}
}
return graph.keys.sorted().compactMap { name in
derivesFromItself(name, graph, []) ? "element '\(name)' must not be a member of its own substitution group" : nil
guard derivesFromItself(name, graph, []) else { return nil }
return PureXML.Schema.SchemaLocatedFinding(reason: "element '\(name)' must not be a member of its own substitution group", node: nodeOf[name])
}
}
/// A named-definition reference cycle: a `kind` definition (`group` /
/// `attributeGroup`) whose `kind`-references reach itself. Multi-edge, since a
/// definition may reference several others.
private static func referenceCycleErrors(_ containers: [XSDTree], _ kind: String, _ label: String, _ bindings: [String: String], _ target: String?) -> [String] {
private static func referenceCycleFindings(
_ containers: [XSDTree],
_ kind: String,
_ label: String,
_ bindings: [String: String],
_ target: String?,
) -> [PureXML.Schema.SchemaLocatedFinding] {
let redefined = redefinedNames(containers, kind)
var adjacency: [String: [String]] = [:]
var nodeOf: [String: XSDTree] = [:]
for container in containers {
for definition in descendants(container, named: kind) {
guard let name = XSDDerivNode.attribute(definition, "name") else { continue }
if nodeOf[name] == nil { nodeOf[name] = definition }
for ref in boundedReferences(definition, kind) where XSDDerivNode.referenceNamespace(ref, bindings) == target {
adjacency[name, default: []].append(XSDDerivNode.stripPrefix(ref))
}
@ -226,7 +239,7 @@ extension PureXML.Schema.XSDParser {
}
return adjacency.keys.sorted().compactMap { name in
guard !redefined.contains(name), reachesItself(name, adjacency, redefined) else { return nil }
return "\(label) '\(name)' must not reference itself"
return PureXML.Schema.SchemaLocatedFinding(reason: "\(label) '\(name)' must not reference itself", node: nodeOf[name])
}
}

View File

@ -0,0 +1,95 @@
import Testing
@testable import PureXML
/// Derivation/cycle-pass schema compile findings (a type derived from itself, an
/// element in its own substitution group, a model group referencing itself, and
/// an all-group referenced inside a compositor) carry validation coding paths that
/// resolve to source spans, the IDE underline flow for #169. The cycle checkers
/// report by component name over a name-keyed graph, so each finding now locates
/// on the declaring node recorded alongside that graph. Split from the other
/// located-diagnostics suites to keep each suite within the type-body length budget.
@Suite("Schema derivation/cycle-pass located compile diagnostics")
struct SchemaDerivationLocatedDiagnosticsTests {
private func inconsistentFindings(in xsd: String) throws -> [PureXML.Validation.ValidationError] {
do {
_ = try PureXML.Schema.Document(xsd)
Issue.record("expected schema compilation to fail")
return []
} catch let error as PureXML.Schema.SchemaError {
guard case let .inconsistent(findings) = error else {
Issue.record("expected .inconsistent, got \(error)")
return []
}
return findings
}
}
@Test("A type derived from itself is located on the type declaration")
func test_derivationCycleLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:complexType name="a">
<xs:complexContent>
<xs:extension base="t:a"/>
</xs:complexContent>
</xs:complexType>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let located = try #require(findings.first { $0.reason.contains("type 'a' must not be derived from itself") })
#expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType"])
let (tree, _) = PureXML.readTree(xsd)
#expect(tree.sourceRange(at: located.codingPath)?.start.line == 2)
}
@Test("An element in its own substitution group is located on the element")
func test_substitutionCycleLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:element name="a" type="xs:string" substitutionGroup="t:a"/>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let located = try #require(findings.first { $0.reason.contains("element 'a' must not be a member of its own substitution group") })
#expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:element"])
let (tree, _) = PureXML.readTree(xsd)
#expect(tree.sourceRange(at: located.codingPath)?.start.line == 2)
}
@Test("A model group referencing itself is located on the group declaration")
func test_groupReferenceCycleLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:group name="g">
<xs:sequence><xs:group ref="t:g"/></xs:sequence>
</xs:group>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let located = try #require(findings.first { $0.reason.contains("model group 'g' must not reference itself") })
#expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:group"])
let (tree, _) = PureXML.readTree(xsd)
#expect(tree.sourceRange(at: located.codingPath)?.start.line == 2)
}
@Test("An all-group referenced inside a compositor is located on the reference")
func test_allGroupReferencePlacementLocated() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:group name="g"><xs:all><xs:element name="a" type="xs:string"/></xs:all></xs:group>
<xs:complexType name="t">
<xs:sequence><xs:group ref="g"/></xs:sequence>
</xs:complexType>
</xs:schema>
"""
let findings = try inconsistentFindings(in: xsd)
let located = try #require(findings.first { $0.reason.contains("may not be referenced inside a 'sequence'") })
#expect(located.codingPath.map(\.stringValue) == ["xs:schema", "xs:complexType", "xs:sequence", "xs:group"])
let (tree, _) = PureXML.readTree(xsd)
#expect(tree.sourceRange(at: located.codingPath)?.start.line == 4)
}
}