From 8a7445c5bb5df41f537c62d35131ff05c08d5344 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 14 Jun 2026 09:56:04 +0200 Subject: [PATCH] feat(schema): enforce complexContent derivation content order A restriction or extension inside complexContent has the content model (annotation?, (group|all|choice|sequence)?, (attribute|attributeGroup)*, anyAttribute?). The allowedChildren membership table admitted the right child names but not their order or cardinality, so a second model group, a model group after an attribute, an attribute after anyAttribute, or a second anyAttribute was accepted. A forward-only walk matches the children against an ordered slot model (one model group, unbounded attributes, one anyAttribute). The check runs only in the complexContent context, where a model group belongs to the model, so a simpleContent derivation (facets, no model group) keeps its own rules and no valid schema is rejected. XSTS: invalid schemas accepted 864 to 785, valid schemas rejected held at 72, instance buckets unchanged. --- CHANGELOG.md | 1 + Sources/Schema/SchemaContentOrder.swift | 60 +++++++++++++++++++++++++ Sources/Schema/SchemaStructure.swift | 3 ++ Tests/SchemaStructureTests.swift | 39 ++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 Sources/Schema/SchemaContentOrder.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ef85f..5d18ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A `complexContent` derivation's content is now checked for order and cardinality, not just child membership (#158). A `restriction` or `extension` inside `complexContent` has the content model `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`: an optional model group, then any number of `attribute`/`attributeGroup`, then an optional `anyAttribute`. The `allowedChildren` membership table admitted the right child names but not their sequence or count, so a second model group (`sequence` then `sequence`), a model group after an attribute, an attribute after `anyAttribute`, or a second `anyAttribute` was accepted. The children are matched against an ordered slot model (one model group, unbounded attributes, one `anyAttribute`) by a forward-only walk; the check runs only in the `complexContent` context, where a model group is part of the model, so a `simpleContent` derivation (facets, no model group) keeps its own rules and no valid schema is rejected. XSTS: invalid schemas accepted 864 to 785, no other bucket raised (valid schemas rejected held at 72). - Unique Particle Attribution (`cos-nonambig`, the content-model determinism constraint) is now enforced at compile time (#156, #157), the keystone of the remaining schema-validity tail. A content model must be deterministic so each element in an instance is attributable to a single particle without lookahead; an ambiguous model (`` of two same-name elements, an optional or repeating element followed by a distinct same-name element, two same-name members of an `all`, an element competing with a wildcard that admits it) was accepted. The check runs a Glushkov position automaton over a literal, QName-resolved view of the raw schema tree built specifically for this rule, independent of the instance-validation content model: a `ref` is one position carrying its prefix-resolved QName (not the blanket target namespace), and substitution groups are not expanded, which removes the two false-overlap sources (cross-namespace refs, expanded substitution members) that a check over the compiled model produced. Determinism is per particle, the W3C rule's defining property: a particle competing with a repetition of itself is never a violation, so a fixed count `{n,n}` unrolls into copies in sequence (`b{2,2}, b` stays the deterministic `b, b, b`) while a true range or unbounded repeat uses a self-loop, and only two distinct source particles with overlapping labels conflict. The overlap test is QName-only (not substitution-group-aware), an accepted under-rejection that can never over-reject. XSTS: invalid schemas accepted 884 to 864, no other bucket raised (valid schemas rejected held at 72). - A `keyref`'s `refer` must name a `key` or `unique` constraint (#147). A `refer` pointing at a non-existent (or non-key/unique) constraint was accepted; it is now resolved against the document's `key`/`unique` names, with a forward `refer` (textually before the key) handled by collecting all names first. The check is skipped when the document pulls in external definitions through `import`/`include`/`redefine`, which the default compile does not load. XSTS: invalid schemas accepted 891 to 884, no other bucket raised. - Component-name uniqueness is now enforced (#147). Within a schema, global type names (`simpleType` and `complexType` share one symbol space), global element names, global attribute names, named model-group names, and named attribute-group names must each be unique, and identity-constraint names must be unique across the document; a duplicate was accepted, with the later definition silently overwriting the earlier. Only the document's own globals are examined (an `xs:redefine`'s same-named type is not a clash), and the symbol spaces are kept separate (a type and an element may share a name). XSTS: invalid schemas accepted 909 to 891, no other bucket raised. diff --git a/Sources/Schema/SchemaContentOrder.swift b/Sources/Schema/SchemaContentOrder.swift new file mode 100644 index 0000000..3e12afe --- /dev/null +++ b/Sources/Schema/SchemaContentOrder.swift @@ -0,0 +1,60 @@ +extension PureXML.Schema.XSDParser { + /// Content-model order and cardinality for a `complexContent` derivation, the + /// ordering complement to the set-membership `allowedChildren` check (which + /// admits the right child names but not their sequence or count). + /// + /// A `restriction`/`extension` inside `complexContent` has the model + /// `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)` + /// (XSD 1.0 Structures): an optional model group, then any number of + /// `attribute`/`attributeGroup`, then an optional `anyAttribute`. A second model + /// group, a model group after an attribute, an attribute after `anyAttribute`, or + /// a second `anyAttribute` (the ctG/ctH families) was previously accepted. + /// Only the `complexContent` context is checked, where a model group is part of + /// the model; a `simpleContent` derivation (facets, no model group) has a + /// different model and is left to its own rules. + static func complexContentOrderErrors(_ complexContent: XSDTree) -> [String] { + var errors: [String] = [] + for derivation in PureXML.Schema.XSDNode.elementChildren(complexContent) { + guard let local = PureXML.Schema.XSDNode.localName(derivation), + local == "restriction" || local == "extension" + else { continue } + let names = PureXML.Schema.XSDNode.elementChildren(derivation) + .filter { $0.name?.namespaceURI == xsdNamespace } + .compactMap(PureXML.Schema.XSDNode.localName) + .filter { $0 != "annotation" } + if let reason = slotOrderViolation(names, slots: complexDerivationSlots) { + errors.append("the content of a complexContent '\(local)' must be a model group, then attributes, then anyAttribute: \(reason)") + } + } + return errors + } + + /// The ordered slots of a `complexContent` derivation: each lists its admitted + /// names and how many children may fill it (nil is unbounded). + private static let complexDerivationSlots: [(members: Set, max: Int?)] = [ + (["group", "all", "choice", "sequence"], 1), + (["attribute", "attributeGroup"], nil), + (["anyAttribute"], 1), + ] + + /// The first child that does not fit the ordered slot model, or nil. Each child + /// must fill the current slot or one after it (order is forward-only), and a slot + /// with a finite `max` may not be overfilled. + private static func slotOrderViolation(_ children: [String], slots: [(members: Set, max: Int?)]) -> String? { + var slotIndex = 0 + var counts = [Int](repeating: 0, count: slots.count) + for child in children { + var index = slotIndex + while index < slots.count, !slots[index].members.contains(child) { + index += 1 + } + guard index < slots.count else { return "'\(child)' is out of order" } + counts[index] += 1 + if let maximum = slots[index].max, counts[index] > maximum { + return "'\(child)' appears too many times" + } + slotIndex = index + } + return nil + } +} diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index a1aa9cb..5307fa6 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -148,6 +148,9 @@ extension PureXML.Schema.XSDParser { if local == "complexType" { errors += elementDeclsConsistentErrors(node) } + if local == "complexContent" { + errors += complexContentOrderErrors(node) + } } for child in children { collectStructure(child, into: &errors) diff --git a/Tests/SchemaStructureTests.swift b/Tests/SchemaStructureTests.swift index 86747fc..206dfae 100644 --- a/Tests/SchemaStructureTests.swift +++ b/Tests/SchemaStructureTests.swift @@ -233,4 +233,43 @@ struct SchemaStructureTests { """#)) } + + @Test("a complexContent derivation's content must be ordered: model group, attributes, anyAttribute") + func test_complexContentDerivationOrder() { + func derive(_ kind: String, _ body: String) -> String { + #""" + + \#(body) + """# + } + // Two model groups (ctG/ctH "X then Y"). + #expect(rejects(derive("restriction", #""#))) + #expect(rejects(derive("extension", #""#))) + // A model group after an attribute. + #expect(rejects(derive("restriction", #""#))) + // An attribute after anyAttribute, and two anyAttributes. + #expect(rejects(derive("extension", #""#))) + #expect(rejects(derive("restriction", #""#))) + } + + @Test("a well-ordered complexContent derivation compiles") + func test_complexContentDerivationValid() throws { + try compile(#""" + + + + + + + + + """#) + // No model group is fine (an extension that only adds attributes). + try compile(#""" + + + + + """#) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 4d6d85a..447d569 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -29,7 +29,7 @@ struct XSTSSuiteTests { /// XSTS` (debug is far slower and the corpus is large). Per-case deviations /// are written to /tmp/xsts-failures.txt for the burn-down. private let knownSchemaValidRejected = 72 - private let knownSchemaInvalidAccepted = 864 + private let knownSchemaInvalidAccepted = 785 private let knownInstanceValidRejected = 214 private let knownInstanceInvalidAccepted = 165 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 4d5e6a1..732d2ac 100644 --- a/docs/schema-validity-burndown.md +++ b/docs/schema-validity-burndown.md @@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open. | Bucket | Count | Reading | |---|---|---| | valid-schemas-rejected | 72 | we rarely reject a good schema | -| **invalid-schemas-accepted** | **2461 -> 864** | we rarely catch a bad one (facet, id, structural content-model, and UPA determinism validity landed) | +| **invalid-schemas-accepted** | **2461 -> 785** | we rarely catch a bad one (facet, id, structural content-model, UPA determinism, and complexContent-derivation order validity landed) | | valid-instances-rejected | 233 | (instance side, separately tracked) | | invalid-instances-accepted | 165 | |