fix(schema): enforce identity-constraint field cardinality (#147)

An identity-constraint field must evaluate to a node-set with at most one
member (cvc-identity-constraint.3). The validator built a value tuple per
target without checking how many nodes each field selected, so an instance
whose unique/key/keyref field matched several nodes was accepted.

fieldCardinalityError now rejects a target whose field selects more than one
node, in both the key/unique and keyref paths. XSTS invalid-instances-accepted
4 → fewer (147 to 143), no other bucket moved.
This commit is contained in:
Mihaela Mihaljevic 2026-06-16 23:41:30 +02:00
parent 4df9eda52e
commit 9769f022dd
3 changed files with 37 additions and 4 deletions

View File

@ -22,7 +22,9 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s
### Fixed
- XSTS valid-instances-rejected (#146): **173 → 3** on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/147). Regex patterns match Unicode scalars (not Swift grapheme clusters), fixing `\p{Is…}` block tests and combining-mark cases; empty patterns match only the empty string. The regex repertoire is pinned to Unicode 3.2, the version the corpus is consistent at: code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters, while later additions (the Tamil digit zero) stay unassigned and match `\D` (`XSDCategory`, `Unicode32Assigned`). Per-child content assessment is driven by the content-model particle the automaton matched, not a by-name lookup (#180), so two same-named particles keep their own `fixed` values and an element matching a `skip` wildcard is not validated against its global declaration. Schema documents that form an import chain are compiled as a union rather than merged as separately compiled documents, so cross-document substitution groups resolve (#161, `Document.init(composing:)`). Plus attribute-group `anyAttribute` inheritance, extension wildcard union, namespace-qualified type tables for multi-document `xsi:schemaLocation`, per-container `elementFormDefault`, strict/lax attribute wildcard validation, built-in type resolution, and `xs:redefine` simpleType overrides.
- XSTS invalid-instances-accepted (#147): identity-constraint fields now enforce cardinality (a `field` must select at most one node; cvc-identity-constraint.3), so an instance whose `unique`/`key`/`keyref` field matches several nodes is rejected rather than accepted. Caught four cases (idF005, idG005, and kin): invalid-instances-accepted 147 → 143, no other bucket moved.
- XSTS valid-instances-rejected (#146): **173 → 3** on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/143). Regex patterns match Unicode scalars (not Swift grapheme clusters), fixing `\p{Is…}` block tests and combining-mark cases; empty patterns match only the empty string. The regex repertoire is pinned to Unicode 3.2, the version the corpus is consistent at: code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters, while later additions (the Tamil digit zero) stay unassigned and match `\D` (`XSDCategory`, `Unicode32Assigned`). Per-child content assessment is driven by the content-model particle the automaton matched, not a by-name lookup (#180), so two same-named particles keep their own `fixed` values and an element matching a `skip` wildcard is not validated against its global declaration. Schema documents that form an import chain are compiled as a union rather than merged as separately compiled documents, so cross-document substitution groups resolve (#161, `Document.init(composing:)`). Plus attribute-group `anyAttribute` inheritance, extension wildcard union, namespace-qualified type tables for multi-document `xsi:schemaLocation`, per-container `elementFormDefault`, strict/lax attribute wildcard validation, built-in type resolution, and `xs:redefine` simpleType overrides.
- Enforce element, attribute, attributeGroup, and group child-content shape (schema-for-schemas cardinality and order): at most one inline type per element/attribute, inline type before identity constraints, at most one compositor per group, at most one `anyAttribute` per attributeGroup with attribute references preceding it (`SchemaStructureHelpers.swift`).

View File

@ -204,8 +204,12 @@ extension PureXML.Schema {
var tuples: [[FieldValue?]] = []
var seen: [[FieldValue?]] = []
for target in select(constraint.selector, at: node) {
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
let targetPath = path + steps(from: node, to: target)
if let error = fieldCardinalityError(constraint, at: target, path: targetPath) {
issues.append(error)
continue
}
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
if constraint.kind == .key, tuple.contains(where: { $0 == nil }) {
issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: targetPath + fieldStep(constraint.fields, at: target)))
continue
@ -231,15 +235,37 @@ extension PureXML.Schema {
guard case let .keyref(refer) = constraint.kind else { return }
let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? []
for target in select(constraint.selector, at: node) {
let targetPath = path + steps(from: node, to: target)
if let error = fieldCardinalityError(constraint, at: target, path: targetPath) {
issues.append(error)
continue
}
let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint)
if tuple.contains(where: { $0 == nil }) { continue }
if !keyTuples.contains(where: { $0 == tuple }) {
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)))
}
}
}
/// The error when a field of `constraint` selects more than one node at
/// `target`. A field must evaluate to a node-set with at most one member
/// (Identity-constraint Definition; cvc-identity-constraint.3); selecting
/// several is a violation, not a multi-valued key.
private func fieldCardinalityError(
_ constraint: IdentityConstraint,
at target: PureXML.Model.TreeNode,
path targetPath: [PureXML.Validation.PathKey],
) -> PureXML.Validation.ValidationError? {
for field in constraint.fields where select(field, at: target).count > 1 {
return .init(
reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must select at most one node",
at: targetPath + fieldStep(constraint.fields, at: target),
)
}
return nil
}
// MARK: XPath helpers
private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {

View File

@ -52,10 +52,15 @@ struct XSTSSuiteTests {
/// substitution group: instances rejected 4 → 3, no other bucket moved. The
/// remaining three are the Ethiopic-digit `\d` cases (reS17, reS38, reZ004v),
/// which contradict `\d` = `\p{Nd}` and are tracked as suspect tests.
///
/// Enforcing identity-constraint field cardinality (a field must select at most
/// one node; cvc-identity-constraint.3) then caught four invalid instances that
/// were accepted (idF005, idG005, and kin): invalid accepted 147 → 143, no
/// other bucket moved.
private let knownSchemaValidRejected = 1
private let knownSchemaInvalidAccepted = 262
private let knownInstanceValidRejected = 3
private let knownInstanceInvalidAccepted = 147
private let knownInstanceInvalidAccepted = 143
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {