feat(schema): enforce Element Declarations Consistent (cos-element-consistent)

Within one complex type's content model, two element declarations with the same
name must have the same type definition. Two same-name elements with different
types were accepted. elementDeclsConsistentErrors collects element particles
across the content model -- descending through model groups and the content-
derivation wrappers, but never into an element's own nested type, which is a
separate content model -- and flags a name seen with more than one distinct type
key (a type reference by local name, a distinct token per inline type, or the
untyped ur-type).

Modeled clean-room on Xerces (libxml2 enforces this only partially). Substitution-
group expansion is not folded in, an accepted under-rejection.

XSTS: invalid schemas accepted 925 to 909 (-16); valid schemas rejected held at
72; instance buckets unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-14 01:47:43 +02:00
parent 39222ca760
commit 243a6dbc9b
6 changed files with 94 additions and 5 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Element Declarations Consistent (`cos-element-consistent`) is now enforced (#147). Within one complex type's content model, two element declarations with the same name must have the same type; two same-name elements with different types (e.g. `a` as `xs:string` and as `xs:integer`) were accepted. The check walks the content model (through model groups and content-derivation wrappers) but not into an element's own nested type, so a reused name in a nested anonymous type, a separate content model, is not flagged. Modeled on Xerces (libxml2 only partially enforces this); substitution-group expansion is not folded in (an accepted under-rejection). XSTS: invalid schemas accepted 925 to 909, no other bucket raised.
- Schema-vocabulary attribute applicability is now enforced at compile time (#147), the attribute sibling of the content-model table. Each XSD component admits a fixed set of attributes (the schema for schemas); an unprefixed attribute outside that set (a typo like `nullable`, a stray `foo`, `name` on a `sequence`) is now rejected, and `ref` may not co-occur with `name` or `type`. Foreign (prefixed) attributes and namespace declarations are not the component's own and are not checked, and the element/attribute sets are the permissive union of global and local use, so no valid schema is rejected. XSTS: invalid schemas accepted 956 to 925, no other bucket raised.
- A named model-group definition (`xs:group name="..."`) must contain exactly one of `all`/`choice`/`sequence` (#147). XSD 1.0 gives a group's content as `(annotation?, (all | choice | sequence))`, but the `allowedChildren` set-membership check did not enforce the cardinality, so a group with two compositors or with none was accepted. A `group` reference (no `name`) is excluded. XSTS: invalid schemas accepted 977 to 956, no other bucket raised.
- A particle whose `minOccurs` exceeds its `maxOccurs` is now rejected at compile time (#147). An occurrence range like `minOccurs="5" maxOccurs="2"` is empty and illegal, but was accepted; the two values are compared by canonical magnitude (independent of machine-integer range), with `unbounded` never exceeded and a single-point range (`minOccurs="2" maxOccurs="2"`) allowed. XSTS: invalid schemas accepted 997 to 977, no other bucket raised.

View File

@ -145,6 +145,9 @@ extension PureXML.Schema.XSDParser {
if local == "group", PureXML.Schema.XSDNode.attribute(node, "name") != nil {
errors += namedGroupErrors(names)
}
if local == "complexType" {
errors += elementDeclsConsistentErrors(node)
}
}
for child in children {
collectStructure(child, into: &errors)
@ -304,4 +307,62 @@ extension PureXML.Schema.XSDParser {
let compositors = children.count { $0 == "all" || $0 == "choice" || $0 == "sequence" }
return compositors == 1 ? [] : ["a named group must contain exactly one of all, choice, or sequence"]
}
/// The model-group and content-derivation elements through which a complex
/// type's content model extends; the walk descends through these but never
/// into an element's own type definition (a nested type is a separate model).
private static let contentModelContainers: Set<String> = [
"sequence", "choice", "all", "group", "complexContent", "simpleContent", "restriction", "extension",
]
/// Findings for Element Declarations Consistent (cos-element-consistent): in a
/// single complex type's content model, two element declarations with the same
/// name must have the same type. Collects element particles across the content
/// model (descending through model groups and content-derivation wrappers, not
/// into a nested type), keyed by name; a name seen with more than one distinct
/// type definition is a violation. (Substitution-group expansion is not folded
/// in, an accepted under-rejection.)
private static func elementDeclsConsistentErrors(_ complexType: XSDTree) -> [String] {
var byName: [String: Set<String>] = [:]
var inlineCount = 0
collectElementDecls(complexType, into: &byName, inlineCount: &inlineCount)
return byName.keys.sorted().compactMap { name in
(byName[name]?.count ?? 0) > 1
? "element '\(name)' has inconsistent type definitions in the same content model"
: nil
}
}
private static func collectElementDecls(_ node: XSDTree, into byName: inout [String: Set<String>], inlineCount: inout Int) {
for child in PureXML.Schema.XSDNode.elementChildren(node) where child.name?.namespaceURI == xsdNamespace {
guard let local = PureXML.Schema.XSDNode.localName(child) else { continue }
if local == "element" {
if let name = PureXML.Schema.XSDNode.attribute(child, "name") {
byName[name, default: []].insert(elementTypeKey(child, inlineCount: &inlineCount))
}
// Do not descend into the element's own type definition.
} else if contentModelContainers.contains(local) {
collectElementDecls(child, into: &byName, inlineCount: &inlineCount)
}
}
}
/// A key identifying an element particle's type definition: its `type`
/// reference by local name, a distinct token per inline (anonymous) type (two
/// inline types are never the same definition), or a shared token when the
/// element is untyped (the ur-type).
private static func elementTypeKey(_ element: XSDTree, inlineCount: inout Int) -> String {
if let type = PureXML.Schema.XSDNode.attribute(element, "type") {
return "type:" + PureXML.Schema.XSDNode.stripPrefix(type.trimmingXMLWhitespace())
}
let hasInlineType = PureXML.Schema.XSDNode.elementChildren(element).contains {
let local = PureXML.Schema.XSDNode.localName($0)
return local == "complexType" || local == "simpleType"
}
if hasInlineType {
inlineCount += 1
return "inline:\(inlineCount)"
}
return "untyped"
}
}

View File

@ -195,6 +195,33 @@ struct SchemaStructureTests {
#expect(rejects(#"<xs:complexType name="t"><xs:sequence><xs:element ref="g" type="xs:string"/></xs:sequence></xs:complexType><xs:element name="g" type="xs:string"/>"#))
}
@Test("element declarations in one content model must be consistent")
func test_elementDeclsConsistent() throws {
// Same name, different type in one content model is invalid.
#expect(rejects(#"""
<xs:complexType name="t"><xs:sequence>
<xs:element name="a" type="xs:string"/>
<xs:element name="a" type="xs:integer"/>
</xs:sequence></xs:complexType>
"""#))
// Same name, same type is fine.
try compile(#"""
<xs:complexType name="t"><xs:sequence>
<xs:element name="a" type="xs:string" minOccurs="0"/>
<xs:element name="a" type="xs:string"/>
</xs:sequence></xs:complexType>
"""#)
// A same name in a NESTED complex type is a different content model: allowed.
try compile(#"""
<xs:complexType name="t"><xs:sequence>
<xs:element name="a" type="xs:string"/>
<xs:element name="b">
<xs:complexType><xs:sequence><xs:element name="a" type="xs:integer"/></xs:sequence></xs:complexType>
</xs:element>
</xs:sequence></xs:complexType>
"""#)
}
@Test("an identity constraint requires a selector and field")
func test_identityConstraintRequiresParts() {
#expect(rejects(#"""

View File

@ -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 = 925
private let knownSchemaInvalidAccepted = 909
private let knownInstanceValidRejected = 214
private let knownInstanceInvalidAccepted = 165

View File

@ -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 -> 925** | we rarely catch a bad one (facet, id, and structural content-model validity landed) |
| **invalid-schemas-accepted** | **2461 -> 909** | we rarely catch a bad one (facet, id, and structural content-model validity landed) |
| valid-instances-rejected | 233 | (instance side, separately tracked) |
| invalid-instances-accepted | 165 | |
@ -75,7 +75,7 @@ Each iteration targets one self-contained rule family with a clean root cause.
(1688 -> 1313, -375)._ A data-driven `allowedChildren` table (the schema-for-
schemas child content model) rejects disallowed children, multi/misplaced
`annotation`, and identity constraints missing selector/field, via one general
mechanism (`SchemaStructure.swift`). Attribute **value** validity also landed (enumerated attributes, `minOccurs`/`maxOccurs`; 1313 -> 1190), plus **name/reference lexical validity** (`name` an NCName; `type`/`base`/`ref`/`itemType`/`refer`/`substitutionGroup` QNames; 1190 -> 1129), plus **`pattern` regex validity** (compile each pattern; reject only unambiguous syntax errors, tolerating engine gaps; 1129 -> 1092), plus **complexType content-model shape** (one simpleContent/complexContent, exclusive with model groups/attributes; one model group; 1051 -> 997), plus **particle minOccurs <= maxOccurs** (997 -> 977), plus **named-group exactly-one-compositor** (977 -> 956), plus **attribute applicability** (allowed-attributes-per-component table + ref/name/type exclusion; 956 -> 925). **Still open:** attribute **applicability** (which attributes each component admits, e.g. `form` is prohibited on a global declaration, `ref` excludes `name`); child *order* beyond the leading annotation; and particle / model-group semantic rules (Unique Particle Attribution, Particle-Valid-Restriction completeness).
mechanism (`SchemaStructure.swift`). Attribute **value** validity also landed (enumerated attributes, `minOccurs`/`maxOccurs`; 1313 -> 1190), plus **name/reference lexical validity** (`name` an NCName; `type`/`base`/`ref`/`itemType`/`refer`/`substitutionGroup` QNames; 1190 -> 1129), plus **`pattern` regex validity** (compile each pattern; reject only unambiguous syntax errors, tolerating engine gaps; 1129 -> 1092), plus **complexType content-model shape** (one simpleContent/complexContent, exclusive with model groups/attributes; one model group; 1051 -> 997), plus **particle minOccurs <= maxOccurs** (997 -> 977), plus **named-group exactly-one-compositor** (977 -> 956), plus **attribute applicability** (allowed-attributes-per-component table + ref/name/type exclusion; 956 -> 925), plus **Element Declarations Consistent** (same-name elements in one content model share a type; 925 -> 909). **Still open:** attribute **applicability** (which attributes each component admits, e.g. `form` is prohibited on a global declaration, `ref` excludes `name`); child *order* beyond the leading annotation; and particle / model-group semantic rules (Unique Particle Attribution, Particle-Valid-Restriction completeness).
## Per-iteration protocol (the PR-critic-loop)

View File

@ -47,7 +47,7 @@ and to document the remainder as spec-justified, deliberate exclusions.
## Category A: genuine correctness bugs
### Fixed (twenty-six root causes, ~2210 deviations cleared)
### Fixed (twenty-seven root causes, ~2226 deviations cleared)
list-datatype length facets (#146) · union pattern/enumeration facets · duration
partial-order facets · QName length non-constraining · the full XSD `\p{Is...}`
@ -65,7 +65,7 @@ validity (`min`/`maxInclusive`, `min`/`maxExclusive`, `enumeration` values valid
in the base space; inclusive/exclusive exclusions; bound ordering) · `gMonth`
`--MM--` lexical form · schema `id` attribute validity (`xs:ID`: NCName and
unique within the document). The schema-validity work brought invalid schemas
accepted 2461 to 925 (schema-document structural validity: content model + complexType content shape, attribute values, name/reference + pattern-regex lexical validity, and reference resolution, against the schema-for-schemas); see `schema-validity-burndown.md`.
accepted 2461 to 909 (schema-document structural validity: content model + complexType content shape, attribute values, name/reference + pattern-regex lexical validity, and reference resolution, against the schema-for-schemas); see `schema-validity-burndown.md`.
Plus the measurement fix (exclude W3C-disputed `status="queried"` entries).
### Remaining, root-caused (the hard tail)