fix(xsd): locate identity-constraint errors at the offending field

Extend an identity error's coding path past the target element to the single
field the constraint names: an attribute (list/item[2]/@id) or a child element
(.../code), resolved from the field XPath. Multi-field or complex-path
constraints stay on the target. Also add a cross-scope keyref test confirming
a keyref resolves against a key declared on an ancestor element. Updates the
identity-path tests to the more precise locations.

Closes #104
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 18:58:27 +02:00
parent f29deec723
commit 963c7c74eb
4 changed files with 83 additions and 14 deletions

View File

@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- XSD identity-constraint errors now locate the offending field, not just the element (#104): a single-field `key`/`keyref`/`unique` error extends the coding path with the field itself, an attribute (`... at path: list/item[2]/@id`) or a child element (`.../code`), resolved from the constraint's field XPath. keyref scope resolution is confirmed to resolve a key declared on an ancestor element (cross-scope), with a test. Full XSD qualified-node-set conformance for pathological multi-level same-name nesting remains part of the conformance-corpus work.
- XSD identity-constraint errors (`key`, `keyref`, `unique`) now carry a coding path locating the actual offending element, instead of always rendering "at root of document". The `IdentityValidator` walk threads a `[PathKey]` the same way the content-model validator does, and each error is located at the selected target node, so a duplicate-key failure reports "... at path: list/item[2]" and a dangling keyref "... at path: orders/line" rather than an empty path. This brings the identity-constraint rule into line with the validation framework's path-carrying contract, which every other rule already honored. The "no element declaration for the root" XSD error is likewise located at the root element, matching the namespace check beside it.
- A `<template>` element's flow content is now kept nested inside it (`<template><div>x</div></template>`) instead of being split out into the body (#83). `template` is built as body flow content rather than routed to the head.

View File

@ -88,7 +88,7 @@ flowchart TB
OutEnc["#106 output encodings: complete"]:::done
StreamVal["#107 streaming validation: DTD, RELAX NG, XSD"]:::done
MoreEnc["#108 encodings: ISO-2022-JP, EUC-TW"]:::done
Depth["Tier 3 done (C14N 1.1, EXSLT, DTD PE depth); Tier 2 conformance corpora; frontier #104"]:::todo
Depth["Tier 3 done (C14N 1.1, EXSLT, DTD PE depth); frontier #104 done; Tier 2 conformance corpora"]:::todo
Epic105 --> OutEnc
OutEnc --> StreamVal
StreamVal --> MoreEnc
@ -99,7 +99,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **805 tests in 124 suites** (`swift test`).
currently runs **807 tests in 124 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -89,6 +89,24 @@ extension PureXML.Schema {
return steps
}
/// The coding-path step from a `target` down to the single field a
/// constraint names, so an error locates the offending value (`@id`, a
/// child element) rather than just the target. Empty for a multi-field
/// constraint or a complex field path, in which case the error stays on the
/// target.
private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] {
guard fields.count == 1 else { return [] }
let field = fields[0]
if field.hasPrefix("@"), !field.dropFirst().contains(where: { $0 == "/" || $0 == "[" }) {
return [.attribute(String(field.dropFirst()))]
}
let isSimpleName = !field.contains(where: { "/@[(".contains($0) })
if isSimpleName, let child = select(field, at: target).first, child.kind == .element {
return steps(from: target, to: child)
}
return []
}
private func isNonRef(_ constraint: IdentityConstraint) -> Bool {
if case .keyref = constraint.kind { return false }
return true
@ -106,13 +124,14 @@ extension PureXML.Schema {
var seen: [[String?]] = []
for target in select(constraint.selector, at: node) {
let tuple = fieldTuple(constraint.fields, at: target)
let targetPath = path + steps(from: node, to: target)
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: path + steps(from: node, to: target)))
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: targetPath + fieldStep(constraint.fields, at: target)))
continue
}
if tuple.contains(where: { $0 == nil }) { continue }
if seen.contains(where: { $0 == tuple }) {
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: path + steps(from: node, to: target)))
issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: targetPath + fieldStep(constraint.fields, at: target)))
} else {
seen.append(tuple)
}
@ -134,7 +153,8 @@ extension PureXML.Schema {
let tuple = fieldTuple(constraint.fields, at: target)
if tuple.contains(where: { $0 == nil }) { continue }
if !keyTuples.contains(where: { $0 == tuple }) {
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: path + steps(from: node, to: target)))
let targetPath = path + steps(from: node, to: target)
issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target)))
}
}
}

View File

@ -76,13 +76,13 @@ struct XSDIdentityTests {
let valid = "<orders><product code=\"A\"/><product code=\"B\"/><line product=\"A\"/></orders>"
let dangling = "<orders><product code=\"A\"/><line product=\"Z\"/></orders>"
#expect(try validate(xsd, valid).isEmpty)
// The dangling keyref is located at the offending <line>, not <orders>.
// The dangling keyref is located at the offending field @product.
let failure = try #require(validate(xsd, dangling).first)
#expect(failure.codingPath.map(\.stringValue) == ["orders", "line"])
#expect(String(describing: failure).hasSuffix("at path: orders/line"))
#expect(failure.codingPath.map(\.stringValue) == ["orders", "line", "@product"])
#expect(String(describing: failure).hasSuffix("at path: orders/line/@product"))
}
@Test("Identity-constraint errors are located at the constraining element")
@Test("Identity-constraint errors are located at the offending field")
func test_identityErrorsCarryPath() throws {
let xsd = """
\(head)
@ -97,12 +97,12 @@ struct XSDIdentityTests {
</xs:element>
</xs:schema>
"""
// The error locates the actual offending element (the duplicate item[2]),
// The error locates the offending field (the duplicate item[2]'s @id),
// not just the element that declares the key.
let errors = try validate(xsd, "<list><item id=\"1\"/><item id=\"1\"/></list>")
let failure = try #require(errors.first)
#expect(failure.codingPath.map(\.stringValue) == ["list", "item"])
#expect(String(describing: failure).hasSuffix("at path: list/item[2]"))
#expect(failure.codingPath.map(\.stringValue) == ["list", "item", "@id"])
#expect(String(describing: failure).hasSuffix("at path: list/item[2]/@id"))
}
@Test("A nested selector locates the offender at full depth with an intermediate index")
@ -127,7 +127,55 @@ struct XSDIdentityTests {
// list/group[2]/item: a grandchild, with the index on the intermediate group.
let xml = "<list><group><item id=\"1\"/></group><group><item id=\"1\"/></group></list>"
let failure = try #require(validate(xsd, xml).first)
#expect(failure.codingPath.map(\.stringValue) == ["list", "group", "item"])
#expect(String(describing: failure).hasSuffix("at path: list/group[2]/item"))
#expect(failure.codingPath.map(\.stringValue) == ["list", "group", "item", "@id"])
#expect(String(describing: failure).hasSuffix("at path: list/group[2]/item/@id"))
}
@Test("A keyref resolves against a key declared on an ancestor (cross-scope)")
func test_keyrefCrossScope() throws {
// pk is declared on <catalog>; the keyref is declared on the deeper
// <orders>, so resolution must find the ancestor's key in scope.
let xsd = """
\(head)
<xs:element name="catalog">
<xs:complexType><xs:sequence>
<xs:element name="products"><xs:complexType><xs:sequence>
<xs:element name="product" maxOccurs="unbounded"><xs:complexType><xs:attribute name="code" type="xs:string"/></xs:complexType></xs:element>
</xs:sequence></xs:complexType></xs:element>
<xs:element name="orders"><xs:complexType><xs:sequence>
<xs:element name="line" maxOccurs="unbounded"><xs:complexType><xs:attribute name="ref" type="xs:string"/></xs:complexType></xs:element>
</xs:sequence></xs:complexType>
<xs:keyref name="kr" refer="pk"><xs:selector xpath="line"/><xs:field xpath="@ref"/></xs:keyref>
</xs:element>
</xs:sequence></xs:complexType>
<xs:key name="pk"><xs:selector xpath="products/product"/><xs:field xpath="@code"/></xs:key>
</xs:element>
</xs:schema>
"""
let valid = "<catalog><products><product code=\"A\"/></products><orders><line ref=\"A\"/></orders></catalog>"
let dangling = "<catalog><products><product code=\"A\"/></products><orders><line ref=\"Z\"/></orders></catalog>"
#expect(try validate(xsd, valid).isEmpty)
#expect(try validate(xsd, dangling).contains { $0.reason.contains("no matching key 'pk'") })
}
@Test("A child-element field locates the error at that child")
func test_childElementField() throws {
let xsd = """
\(head)
<xs:element name="list">
<xs:complexType>
<xs:sequence>
<xs:element name="item" maxOccurs="unbounded">
<xs:complexType><xs:sequence><xs:element name="code" type="xs:string"/></xs:sequence></xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
<xs:key name="k"><xs:selector xpath="item"/><xs:field xpath="code"/></xs:key>
</xs:element>
</xs:schema>
"""
let xml = "<list><item><code>x</code></item><item><code>x</code></item></list>"
let failure = try #require(validate(xsd, xml).first)
#expect(String(describing: failure).hasSuffix("at path: list/item[2]/code"))
}
}