From 45f2e7dbf5d7e6f783d93f3bd3ab1cd0190d2642 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 20 Jun 2026 02:25:04 +0200 Subject: [PATCH] fix(schema): an untyped substitutionGroup member inherits its head's type XSD 1.0 3.3.2: an element with neither a `type` attribute nor an inline type, but a `substitutionGroup`, takes the {type definition} of its head. PureXML defaulted such an element to `anyType`, so an instance of it accepted any content. `XSDParser.elementType` now resolves the head declaration through `context.globalElements` (transitively, with a cycle guard) and uses its type. XSTS invalid-instances-accepted 29 -> 28 (typeDef00204m: Yes is no longer accepted where root substitutes a boolean head), no other bucket moved (valid-schemas-rejected and valid-instances-rejected held at 0 across all 14383 groups). 1451 unit tests pass. --- CHANGELOG.md | 8 ++++++++ Sources/Schema/XSDParserTypeReferences.swift | 16 +++++++++++++++- Tests/SchemaSubstitutionTypeTests.swift | 14 ++++++++++++++ Tests/XSTSSuiteTests.swift | 14 +++++++------- docs/plans/2026-06-18-false-negative-tail.md | 2 +- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545eaf0..052ddf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An element with neither a `type` attribute nor an inline type, but a + `substitutionGroup`, now takes the type definition of its head (XSD 1.0 3.3.2) + instead of defaulting to `anyType`. `XSDParser.elementType` resolves the head + declaration (transitively, guarding a `substitutionGroup` cycle) and uses its + type, so an instance of such a member is validated against the head's type. XSTS + invalid-instances-accepted 29 -> 28 (`typeDef00204m`), no other bucket moved + (valid-rejected held at 0 across all 14383 groups). + - `xsi:type` naming `anyType` is now rejected on an element whose declared type is `anySimpleType` (cvc-elt.4.3.2.1). `anyType` is the ur-supertype of every type, so it is never validly *derived* from `anySimpleType` (its own subtype). The diff --git a/Sources/Schema/XSDParserTypeReferences.swift b/Sources/Schema/XSDParserTypeReferences.swift index 8e3c23a..8fba062 100644 --- a/Sources/Schema/XSDParserTypeReferences.swift +++ b/Sources/Schema/XSDParserTypeReferences.swift @@ -11,7 +11,11 @@ private typealias SimpleType = PureXML.Schema.SimpleType extension PureXML.Schema.XSDParser { // MARK: Element and type references - static func elementType(_ node: PureXML.Model.TreeNode, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.ElementType { + static func elementType( + _ node: PureXML.Model.TreeNode, + _ context: PureXML.Schema.XSDContext, + resolvingHeads: Set = [], + ) -> PureXML.Schema.ElementType { if let typeName = XSDNode.attribute(node, "type") { return typeReference(typeName, context, at: node) } @@ -21,6 +25,16 @@ extension PureXML.Schema.XSDParser { if let inline = XSDNode.firstChild(node, named: "complexType") { return .complex(complexType(inline, context)) } + // XSD 1.0 3.3.2: an element with neither `type` nor an inline type, but a + // `substitutionGroup`, takes the {type definition} of its head. Resolve the + // head declaration and use its type, guarding against a substitutionGroup cycle. + if let headReference = XSDNode.attribute(node, "substitutionGroup") { + let bindings = PureXML.Schema.XSDParser.namespaceBindingsInScope(of: node, defaultBindings: context.namespaceBindings) + let headKey = derivationKey(XSDNode.stripPrefix(headReference), in: XSDNode.referenceNamespace(headReference, bindings)) + if !resolvingHeads.contains(headKey), let headNode = context.globalElements[headKey] { + return elementType(headNode, context, resolvingHeads: resolvingHeads.union([headKey])) + } + } return .complex(anyType) } diff --git a/Tests/SchemaSubstitutionTypeTests.swift b/Tests/SchemaSubstitutionTypeTests.swift index 327560d..79b9ab5 100644 --- a/Tests/SchemaSubstitutionTypeTests.swift +++ b/Tests/SchemaSubstitutionTypeTests.swift @@ -264,4 +264,18 @@ struct SchemaSubstitutionTypeTests { + "", )) } + + @Test("an untyped substitutionGroup member inherits its head's type for instance validation") + func test_untypedMemberInheritsHeadType() throws { + let schema = try PureXML.Schema.Document(""" + + + + + """) + // `root` has no type of its own but substitutes `Head` (boolean), so it + // inherits boolean: a valid boolean is accepted, a non-boolean rejected. + #expect(try schema.validate("true").isEmpty) + #expect(try !schema.validate("Yes").isEmpty) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 2476424..a03fd5c 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -150,13 +150,13 @@ struct XSTSSuiteTests { private let knownSchemaValidRejected = 0 private let knownSchemaInvalidAccepted = 43 private let knownInstanceValidRejected = 0 - /// Instance bucket (133 -> 29), per-step deltas in CHANGELOG.md: xsi:type must derive from the - /// declared type (complex/atomic/list/union via the element-ref chain), and anyType cannot stand - /// in for an anySimpleType element (its supertype); block/blockDefault bar substitutions on the - /// namespace-keyed backbone; ur-type anyType wildcards are `lax`; identity fields compare in - /// decimal value space and take a declared default/fixed when absent/empty; an attribute wildcard - /// is the INTERSECTION of its sources; dateTime/date/gYear* reject year 0000. - private let knownInstanceInvalidAccepted = 29 + /// Instance bucket (133 -> 28), per-step deltas in CHANGELOG.md: xsi:type must derive from the + /// declared type (complex/atomic/list/union via the element-ref chain); anyType cannot stand in + /// for an anySimpleType element; an untyped substitutionGroup member inherits its head's type; + /// block/blockDefault bar substitutions on the namespace-keyed backbone; ur-type anyType wildcards + /// are `lax`; identity fields compare in decimal value space, taking a declared default/fixed when + /// absent/empty; an attribute wildcard is the INTERSECTION of its sources; year 0000 is invalid. + private let knownInstanceInvalidAccepted = 28 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws { diff --git a/docs/plans/2026-06-18-false-negative-tail.md b/docs/plans/2026-06-18-false-negative-tail.md index 753f629..8e57474 100644 --- a/docs/plans/2026-06-18-false-negative-tail.md +++ b/docs/plans/2026-06-18-false-negative-tail.md @@ -92,7 +92,7 @@ classification is wrong. | Target namespace / form matching | `targetns00101m/targetNS00101m1_n` | `ComplexValidator*`, reference resolution | | Disallowed substitution | `disallowedsubst00105m/disallowedSubst00105m1_n`, `disallowedsubst00106m2/Negative` | `XSDSubstitutionMembers`, `ComplexValidatorXSIType` | | Nillability | `nillable00201m/nillable00201m2_n` | `XSDParserHelpers`, instance validator | -| Type derivation | `typedef00204m/typeDef00204m1_n` | `ComplexValidatorXSIType`, derivation tables | +| Type derivation | ~~`typedef00204m/typeDef00204m1_n`~~ closed: an untyped `substitutionGroup` member inherits its head's type (XSD 1.0 3.3.2) | `XSDParser.elementType` | | Additional-suite instance triage | `addB065/addB065.i` | to classify from fixture | | Datatype value-space edges | `anyURI_a004_1339/anyURI_a004_1339.i` (open); ~~`dateTime011_2008/dateTime011_2008.i`~~ closed: XSD 1.0 has no year zero, `0000` now rejected | `SimpleType`, `DateTimeParser` | | Element constraints | `elemO011/elemO011.i`, `elemT074/elemT074.i` | instance validator, element declarations |