From 41b3a707c3611daa8dba55b07c315b4dc244d81d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 22 Jun 2026 03:41:24 +0200 Subject: [PATCH] fix(schema): flatten redefine self-reference and exclude prohibited uses in attribute uniqueness The attribute-use uniqueness check (ag-props-correct.2 / ct-props-correct.4) flattens a complex type's or attribute group's uses through attributeGroup references. A reference to a redefined group whose redefinition self-references its former self stopped at the cycle guard, so the original's attributes were never reached and a duplicate they cause was missed. Expand a redefinition's self-reference to the group's pre-redefine base definition (already retained in the context for this purpose) so the original's attributes join the set; a genuine non-redefine cycle still terminates without contributing. Separately, exclude use="prohibited" attributes from the duplicate-name and single-ID counts: a prohibited use is a removal marker, not a member of {attribute uses} (XSD 3.4.2 / 3.6.2). This is the spec-correct reading and keeps a self-reference that re-imports an attribute and then prohibits it from being counted as a duplicate. This catches W3C XSTS attQ011 and attQ017. XSTS schema invalid-accepted 22 -> 20, valid-rejected held at 0. --- CHANGELOG.md | 1 + .../Schema/SchemaAttributeUniqueness.swift | 38 ++++++++++++++++--- Tests/SchemaRedefineAttributeGroupTests.swift | 33 ++++++++++++++++ Tests/XSTSSuiteTests.swift | 4 +- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 239e526..36a67e9 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 +- 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. - A redefinition of an `attributeGroup` that WIDENS a re-declared attribute's type is now rejected. A redefinition is a restriction (src-redefine / cos-ct-restricts), so a re-declared attribute's type must derive by restriction from the original's. The check is built-in-only and therefore certain: it fires only when both the original and the re-declared attribute name a built-in type (resolved to the XSD namespace) and the re-declared built-in does not derive from the original's in the XSD derivation lattice (`int` does not derive from `boolean`). A user, inline, or absent type on either side is not compared (declines, staying lenient): an absent original type is `anySimpleType`, which any type validly restricts, a user/inline type's derivation is not resolvable here, and a user type whose local name coincides with a built-in (a `{urn:x}int`) resolves to a different namespace and is not mistaken for the built-in, so no valid restriction is rejected. Catches schM4 (`b1` re-typed `boolean` → `int`). XSTS invalid-schemas-accepted 24 → 23 (schM4), valid-schemas-rejected held at 0. - A redefinition of an `attributeGroup` that adds an attribute the original lacks through a NON-self `attributeGroup` reference is now rejected. A redefinition is a restriction of the original (src-redefine / cos-ct-restricts), so it may not introduce an attribute the original does not declare unless the original carries an attribute wildcard. The add-attribute check previously resolved only the redefinition's DIRECT declarations and was suppressed whenever the redefinition carried any `attributeGroup` reference, which conflated a self-reference (re-importing the original's own attributes, valid) with a non-self reference (injecting foreign attributes, invalid). The redefinition's contributed attribute names are now resolved through its non-self references and the suppression keys on a genuine self-reference (matched by namespace and local name); resolution declines, staying lenient, whenever the contributed set is not fully knowable (an unresolved group, an attribute-by-`ref`, an `anyAttribute` wildcard, or a further nested reference). This catches attgC028 (a redefinition whose `ref="car"` replaces the original's attributes with foreign ones) while keeping attgC007/attgC038/groupB018 (added attributes alongside a self-reference) valid. XSTS invalid-schemas-accepted 25 → 24 (attgC028), valid-schemas-rejected held at 0. diff --git a/Sources/Schema/SchemaAttributeUniqueness.swift b/Sources/Schema/SchemaAttributeUniqueness.swift index 7c3c308..286b5ad 100644 --- a/Sources/Schema/SchemaAttributeUniqueness.swift +++ b/Sources/Schema/SchemaAttributeUniqueness.swift @@ -7,6 +7,11 @@ private struct ReachedAttribute { let id: ObjectIdentifier let name: PureXML.Model.QualifiedName let isID: Bool + /// A `use="prohibited"` attribute is a removal marker, not a member of the + /// component's {attribute uses} (XSD §3.4.2 / §3.6.2), so it neither clashes by + /// name nor counts toward the single-ID limit. Excluding it keeps a self-reference + /// that re-imports an attribute and then prohibits it from reading as a duplicate. + let isProhibited: Bool } extension PureXML.Schema.XSDParser { @@ -151,15 +156,16 @@ extension PureXML.Schema.XSDParser { id: ObjectIdentifier(declaration), name: PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace), isID: use.type.base == .id, + isProhibited: AttrNode.attribute(child, "use") == "prohibited", )) } else if let use = attributeUse(child, context) { - result.append(ReachedAttribute(id: ObjectIdentifier(child), name: use.name, isID: use.type.base == .id)) + result.append(ReachedAttribute( + id: ObjectIdentifier(child), name: use.name, isID: use.type.base == .id, + isProhibited: AttrNode.attribute(child, "use") == "prohibited", + )) } case "attributeGroup": - guard let ref = AttrNode.attribute(child, "ref") else { break } - let name = AttrNode.stripPrefix(ref) - guard !visited.contains(name), let group = context.attributeGroups[name] else { break } - result += attributeDeclarations(under: group, context, visited: visited.union([name])) + result += referencedGroupDeclarations(child, context, visited: visited) default: break } @@ -167,11 +173,31 @@ extension PureXML.Schema.XSDParser { return result } + /// Attribute declarations reached through an ``. A first + /// visit recurses into X's active definition. A re-visit is a redefinition's + /// self-reference, which expands to X's pre-redefine base definition so the + /// original's attributes join the redefined set (ag-props-correct.2, XSTS attQ011); + /// a genuine non-redefine cycle has no base definition and contributes nothing. + private static func referencedGroupDeclarations( + _ child: XSDTree, _ context: PureXML.Schema.XSDContext, visited: Set, + ) -> [ReachedAttribute] { + guard let ref = AttrNode.attribute(child, "ref") else { return [] } + let name = AttrNode.stripPrefix(ref) + if visited.contains(name) { + if context.redefinedAttributeGroups.contains(name), let base = context.baseAttributeGroups[name] { + return attributeDeclarations(under: base, context, visited: visited) + } + return [] + } + guard let group = context.attributeGroups[name] else { return [] } + return attributeDeclarations(under: group, context, visited: visited.union([name])) + } + private static func attributeSetErrors(_ declarations: [ReachedAttribute], _ label: String) -> [String] { var seenIDs: Set = [] var counts: [PureXML.Model.QualifiedName: Int] = [:] var idCount = 0 - for declaration in declarations where seenIDs.insert(declaration.id).inserted { + for declaration in declarations where !declaration.isProhibited && seenIDs.insert(declaration.id).inserted { counts[declaration.name, default: 0] += 1 if declaration.isID { idCount += 1 } } diff --git a/Tests/SchemaRedefineAttributeGroupTests.swift b/Tests/SchemaRedefineAttributeGroupTests.swift index 0695c42..7fd2978 100644 --- a/Tests/SchemaRedefineAttributeGroupTests.swift +++ b/Tests/SchemaRedefineAttributeGroupTests.swift @@ -110,6 +110,39 @@ struct SchemaRedefineAttributeGroupTests { ) } + @Test("a duplicate attribute via a redefined self-referencing group is rejected (attQ011)") + func test_redefineSelfRefDuplicateAcrossReferences() throws { + // attG references the global `foo` AND the redefined `red`; `red` self-references, + // so its original (which declares `foo`) is re-imported. attG then holds two + // attribute uses named `foo`: a duplicate (ag-props-correct.2). + let main = "" + + "" + + "" + + "" + + "" + + "" + + "" + let baseDoc = "" + + "" + #expect(throws: (any Error).self) { try PureXML.Schema.Document(main, schemaLoader: { _ in baseDoc }) } + } + + @Test("a redefined self-referencing group supplying a distinct attribute is valid (no false duplicate)") + func test_redefineSelfRefDistinctAttributeAccepted() throws { + // Same shape, but `red`'s original declares `bar`, not `foo`: attG holds `foo` + // and `bar`, no duplicate, so the self-reference expansion must not over-flag. + let main = "" + + "" + + "" + + "" + + "" + + "" + + "" + let baseDoc = "" + + "" + _ = try PureXML.Schema.Document(main, schemaLoader: { _ in baseDoc }) + } + @Test("widening a re-declared attribute's built-in type is rejected (schM4)") func test_widenBuiltinTypeRejected() throws { // int does not derive by restriction from boolean, so re-typing the attribute diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 51d922c..03e0570 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -146,9 +146,9 @@ struct XSTSSuiteTests { /// model-group occurrence, identity-constraint content, derivation base kind, attribute/simple-type, /// composition, simpleContent inline-type, complexContent mixedness, and substitution-group anySimpleType checks. /// src-resolve (#185) 43->39; xsi:Not-Allowed (#183) 39->37; NameAndTypeOK restriction-only + cos-all-limited group-ref (#158) 37->35. - /// UPA `##other` 31; ext-model 29; XSD-ns-ref 27; unprefixed-type-ref 25; redefine non-self-add attgC028 24; type-widen schM4 23; self-ref dup schM8 22; valid-rejected 0. + /// UPA 31; ext-model 29; XSD-ns 27; unpref-type-ref 25; redefine-attrGroup attgC028/schM4/schM8 25->22, attQ011/017 ref-dup 22->20; valid-rejected 0. private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 22 + private let knownSchemaInvalidAccepted = 20 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 26), 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