From 18fd42b72699a3517da793774f853111de08b862 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 22 Jun 2026 06:08:29 +0200 Subject: [PATCH] fix(schema): apply substitution-group block to a member with an inline type The substitution-group filter drops a member when the head element's {disallowed substitutions} (or the head type's {prohibited substitutions}) blocks the method by which the member's type derives from the head's type. It looked the member's type up in the named derivation backbone, so a member with an INLINE (anonymous) complex type had no entry and was never dropped, even when its inline type extended the head's type and the head blocked extension. Record an inline-typed element's derivation under a synthetic key (the element key plus a `#` marker no QName can contain, so it cannot collide with a real type and is unreachable as any other type's derivation base) so the filter can see the member's method. A member reaching the head's type by an unblocked method, a plain inline type with no derivation, and same-local-name elements in different namespaces are all unaffected. This catches W3C XSTS disallowedSubst00105m (and a sibling). XSTS invalid-instances-accepted 25 -> 23, valid-instances-rejected held at 0. --- CHANGELOG.md | 1 + Sources/Schema/XSDDerivationTables.swift | 18 ++++++++++++++++++ Tests/SchemaSubstitutionTypeTests.swift | 17 +++++++++++++++++ Tests/XSTSSuiteTests.swift | 14 +++++++------- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d6c24..b98b793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A substitution-group `block` on a head element now bars substituting a member whose INLINE (anonymous) type reaches the head's type by a blocked method. The substitution-group filter already dropped a member when the head's `{disallowed substitutions}` (or the head type's `{prohibited substitutions}`) blocked the member's derivation method, but only for members with a NAMED type recorded in the derivation backbone; an element with an inline complex type had no backbone entry, so a member extending the head's type was wrongly kept even when the head blocked `extension`. Inline-typed elements now record their derivation under a synthetic key (an element key plus a `#` marker no QName can contain, so it cannot collide with a real type and is unreachable as any other derivation base), so the filter sees the inline member's method. A member reaching the head's type by an UNblocked method, a plain inline type with no derivation, and same-local-name elements in different namespaces are all unaffected. Catches disallowedSubst00105m (and a sibling). XSTS invalid-instances-accepted 25 → 23, valid-instances-rejected held at 0. - An identity-constraint (`key`/`unique`/`keyref`) field that identifies a node with complex content is now rejected during instance validation. Per cvc-identity-constraint.3 / c-fields-xpaths a field must evaluate to a node with a simple type (a simple-typed element, an element with simple content, or an attribute); the cardinality clause (at most one node) was enforced, but the simple-content clause was not. The check is structural and false-positive-free: a field whose selected node is an element carrying element children has complex (element-only or mixed) content and can never be simple-typed, so it is flagged, while a simple-typed or simple-content element (text only), an empty or text-only node, and an attribute are left alone. Catches idK012. XSTS invalid-instances-accepted 26 → 25, valid-instances-rejected held at 0. - The attribute-use uniqueness check (`ag-props-correct.2` / `ct-props-correct.4`) now flattens a redefined attribute group's self-reference and excludes prohibited uses. When an attribute group flattens its uses through `` references, a reference to a redefined group whose redefinition self-references its former self previously stopped at the cycle guard, so the original's attributes were never reached and a clash they cause was missed. The self-reference now expands to the group's pre-redefine base definition (already retained for this purpose), so the original's attributes join the set; a genuine non-redefine cycle still terminates without contributing. Separately, a `use="prohibited"` attribute is now excluded from the duplicate-name and single-ID counts, since a prohibited use is a removal marker, not a member of `{attribute uses}` (XSD §3.4.2/§3.6.2); this also keeps a self-reference that re-imports an attribute and then prohibits it from reading as a duplicate. Catches attQ011 and attQ017 (an attribute group referencing both a global attribute and a redefined self-referencing group that supplies the same-named attribute). XSTS invalid-schemas-accepted 22 → 20, valid-schemas-rejected held at 0. - A redefinition of an `attributeGroup` that SELF-references the original and also directly re-declares one of its attributes is now rejected. The self-reference re-imports all of the original's attribute uses, so a same-named direct re-declaration produces two attribute uses of the same name, a duplicate forbidden by ag-props-correct.2. The check fires only for a re-declared, non-prohibited attribute the original directly declares, and only when both the original and the re-declaration land in no namespace (a qualified attribute's target namespace depends on cross-document `form`/chameleon resolution, so it is not compared, staying lenient); a self-referencing redefinition that adds genuinely new attributes (attgC007/attgC038/groupB018) stays valid. Catches schM8 (`c-g2` self-references and re-declares `a1`). XSTS invalid-schemas-accepted 23 → 22 (schM8), valid-schemas-rejected held at 0. diff --git a/Sources/Schema/XSDDerivationTables.swift b/Sources/Schema/XSDDerivationTables.swift index 6d753b3..2510ce5 100644 --- a/Sources/Schema/XSDDerivationTables.swift +++ b/Sources/Schema/XSDDerivationTables.swift @@ -102,9 +102,27 @@ extension PureXML.Schema.XSDParser { if let type = XSDDerivNode.attribute(element, "type") { tables.elementTypeNames[name] = XSDDerivNode.stripPrefix(type) tables.nsElementTypeNames[key] = resolvedKey(of: type, on: element, default: namespace) + } else { + recordInlineElementDerivation(element, key: key, in: namespace, into: &tables) } } + /// Records the derivation of an element's INLINE complex type, which carries no + /// `type` name and so is absent from the named backbone. The entry is keyed by a + /// synthetic key (the element key plus a `#` marker no QName can contain, so it + /// cannot collide with a real type and is unreachable as anyone else's derivation + /// base) so a substitution-group `block` on the head can drop a member whose inline + /// type reaches the head's type by a blocked method (cos-equiv-derived-ok-rec; XSTS + /// disallowedSubst00105m). A plain inline type with no derivation records nothing. + private static func recordInlineElementDerivation(_ element: XSDTree, key: String, in namespace: String?, into tables: inout DerivationTables) { + guard let inlineType = XSDDerivNode.firstChild(element, named: "complexType"), + let derivation = namespacedDerivationInfo(inlineType, in: namespace) + else { return } + let inlineKey = key + "#inline" + tables.nsElementTypeNames[key] = inlineKey + tables.nsTypeDerivation[inlineKey] = derivation + } + /// The namespace a local element's name takes at instance level: the target /// namespace when the element is qualified (its own `form`, else the schema's /// `elementFormDefault`), otherwise no namespace. diff --git a/Tests/SchemaSubstitutionTypeTests.swift b/Tests/SchemaSubstitutionTypeTests.swift index 79b9ab5..2c1b81c 100644 --- a/Tests/SchemaSubstitutionTypeTests.swift +++ b/Tests/SchemaSubstitutionTypeTests.swift @@ -9,6 +9,23 @@ struct SchemaSubstitutionTypeTests { (try? PureXML.Schema.Document("\(body)")) != nil } + @Test("a head's block=extension forbids substituting an extension-derived member, even an inline type (disallowedSubst00105m)") + func test_blockExtensionForbidsSubstitution() throws { + let schema = "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + let doc = try PureXML.Schema.Document(schema) + // Member3 derives by extension, which Head blocks: the substitution is forbidden. + #expect(try !doc.validate("en").isEmpty) + // The head itself, and a restriction-derived member, remain valid substitutes. + #expect(try doc.validate("e").isEmpty) + } + @Test("A member whose type is unrelated to the head's type is rejected") func test_unrelatedMemberType() { #expect(!compiles( diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 64801ee..8aaac2e 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -150,13 +150,13 @@ struct XSTSSuiteTests { private let knownSchemaValidRejected = 0 private let knownSchemaInvalidAccepted = 20 private let knownInstanceValidRejected = 0 - /// Instance bucket (133 -> 25), per-step deltas in CHANGELOG.md: xsi:type must derive from the - /// declared type (via the element-ref chain); anyType cannot stand in for anySimpleType; an untyped - /// substitutionGroup member inherits its head's type; a non-nillable element carries no xsi:nil; block/ - /// blockDefault bar substitutions; ur-type anyType wildcards are `lax`; identity fields compare in - /// decimal value space, taking a default/fixed when absent/empty; an attribute wildcard is the - /// INTERSECTION of its sources; year 0000 is invalid; an identity field must be simple-content (idK012). - private let knownInstanceInvalidAccepted = 25 + /// Instance bucket (133 -> 23), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared + /// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its + /// head's type; a non-nillable element carries no xsi:nil; block/blockDefault bar substitutions (incl. an + /// inline-typed member reached by a blocked method, disallowedSubst00105m); ur-type anyType wildcards are + /// `lax`; identity fields compare in decimal value space, with default/fixed when absent, and must be + /// simple-content (idK012); an attribute wildcard is the INTERSECTION of its sources; year 0000 is invalid. + private let knownInstanceInvalidAccepted = 23 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {