diff --git a/CHANGELOG.md b/CHANGELOG.md index 5587af1..239e526 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 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. - An unprefixed `type`/`base`/`itemType`/`memberTypes` reference to a type declared in a non-empty target namespace is now rejected as undeclared. With no default `xmlns`, an unprefixed QName resolves to the absent namespace, so `type="t"` is `{}t`, not `{urn:x}t` where the type lives; `isUndeclaredReferenceType` matched the type by local name and accepted it. It now mirrors `isUndeclaredReferenceRef`: a no-namespace reference is undeclared when the schema has a non-empty target namespace (a chameleon-included global, and an imported no-namespace type via the foreign pools, remain accepted). XSTS invalid-schemas-accepted 27 → 25 (addB009/test66059 and one more), valid-schemas-rejected held at 0. diff --git a/Sources/Schema/SchemaRedefineSelfReference.swift b/Sources/Schema/SchemaRedefineSelfReference.swift index 0778458..9a78d60 100644 --- a/Sources/Schema/SchemaRedefineSelfReference.swift +++ b/Sources/Schema/SchemaRedefineSelfReference.swift @@ -113,15 +113,12 @@ extension PureXML.Schema.XSDParser { } /// The non-prohibited attribute names a redefinition pulls in through its - /// `attributeGroup` references, with whether any of them is a SELF-reference - /// (resolving by NAMESPACE and local name to the redefinition's own name+target). - /// A self-reference re-imports the original and is reported via `hasSelfReference` - /// rather than counted. Returns nil (decline, stay lenient) when a referenced - /// group does not resolve from the base pool, carries an attribute wildcard, nests - /// a further `attributeGroup` reference, or names an attribute by `ref` (not - /// `name`): in each case the contributed set is not fully known, so the add-check - /// must not fire. A redefinition with no `attributeGroup` reference yields an empty - /// set and no self-reference, preserving the direct-declaration-only behavior. + /// `attributeGroup` references, with whether any is a SELF-reference (resolving by + /// NAMESPACE and local name to the redefinition's own name+target; reported via + /// `hasSelfReference` rather than counted, since it re-imports the original). Returns + /// nil (decline, stay lenient) when the contributed set is not fully known: a + /// referenced group that does not resolve, carries an attribute wildcard, nests a + /// further `attributeGroup` reference, or names an attribute by `ref` (not `name`). private static func referencedAttributeNames( of redefinition: XSDTree, ownName: String, base: [String: XSDTree], ) -> (names: Set, hasSelfReference: Bool)? { @@ -197,13 +194,16 @@ extension PureXML.Schema.XSDParser { if use(attrName) != "prohibited", widensBuiltinType(base: attribute, redefined: redefined[attrName]) { errors.append("a redefined attribute group '\(name)' may not widen the type of '\(attrName)'") } - switch RedefineNode.attribute(attribute, "use") { - case "required" where !hasReference && redefined[attrName] == nil: - errors.append("a redefined attribute group '\(name)' may not eliminate the required attribute '\(attrName)'") - case "prohibited" where !baseHasWildcard && use(attrName) != nil && use(attrName) != "prohibited": - errors.append("a redefined attribute group '\(name)' may not re-introduce the prohibited attribute '\(attrName)'") - default: - break + if redeclaresSelfSuppliedAttribute( + attribute, + redeclaration: redefined[attrName], + hasSelfReference: referenced?.hasSelfReference == true, + prohibited: use(attrName) == "prohibited", + ) { + errors.append("a redefined attribute group '\(name)' redeclares '\(attrName)', which its self-reference already supplies") + } + if let transition = useTransitionError(base: attribute, redefined: redefined[attrName], name: name, hasReference: hasReference, baseHasWildcard: baseHasWildcard) { + errors.append(transition) } } return errors @@ -233,6 +233,49 @@ extension PureXML.Schema.XSDParser { .map { "a redefined attribute group '\(name)' may not add the attribute '\($0)'" } } + /// The `use`-transition restriction errors for one base attribute: a redefinition + /// may not eliminate a REQUIRED attribute (unless a reference could re-supply it), + /// nor re-introduce a PROHIBITED attribute as usable (unless the original has an + /// attribute wildcard that could admit it). + private static func useTransitionError( + base: XSDTree, redefined: XSDTree?, name: String, hasReference: Bool, baseHasWildcard: Bool, + ) -> String? { + guard let attrName = RedefineNode.attribute(base, "name") else { return nil } + let use = redefined.map { RedefineNode.attribute($0, "use") ?? "optional" } + switch RedefineNode.attribute(base, "use") { + case "required" where !hasReference && redefined == nil: + return "a redefined attribute group '\(name)' may not eliminate the required attribute '\(attrName)'" + case "prohibited" where !baseHasWildcard && use != nil && use != "prohibited": + return "a redefined attribute group '\(name)' may not re-introduce the prohibited attribute '\(attrName)'" + default: + return nil + } + } + + /// schM8 / ag-props-correct.2: when a redefinition SELF-references the original it + /// re-imports all of the original's attributes, so directly re-declaring one of them + /// produces two attribute uses of the same name (a duplicate), which is invalid. 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). + private static func redeclaresSelfSuppliedAttribute( + _ attribute: XSDTree, redeclaration: XSDTree?, hasSelfReference: Bool, prohibited: Bool, + ) -> Bool { + guard hasSelfReference, let redeclaration, !prohibited else { return false } + return isNoNamespaceAttribute(attribute) && isNoNamespaceAttribute(redeclaration) + } + + /// Whether an attribute use lands in NO namespace: it is not `form="qualified"` and + /// its owning schema does not set `attributeFormDefault="qualified"`. A qualified + /// attribute's target namespace depends on cross-document `form`/chameleon + /// resolution, so the self-reference duplicate check (which compares by local name) + /// is sound only for no-namespace attributes; a qualified one is not compared. + private static func isNoNamespaceAttribute(_ attribute: XSDTree) -> Bool { + if RedefineNode.attribute(attribute, "form") == "qualified" { return false } + return RedefineNode.attribute(RedefineNode.schemaOwner(attribute), "attributeFormDefault") != "qualified" + } + /// Whether a redefined attribute WIDENS the original's type. A redefinition is a /// restriction, 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 diff --git a/Tests/SchemaRedefineAttributeGroupTests.swift b/Tests/SchemaRedefineAttributeGroupTests.swift index 02800f0..0695c42 100644 --- a/Tests/SchemaRedefineAttributeGroupTests.swift +++ b/Tests/SchemaRedefineAttributeGroupTests.swift @@ -80,6 +80,28 @@ struct SchemaRedefineAttributeGroupTests { ) } + @Test("re-declaring an attribute the self-reference already supplies is rejected (schM8)") + func test_selfRefRedeclareDuplicateRejected() throws { + // The self-reference re-imports the original's `a`, so directly re-declaring `a` + // produces two attribute uses of the same name: a duplicate (ag-props-correct.2). + #expect(throws: (any Error).self) { + try redefine( + group: "", + base: "", + ) + } + } + + @Test("a self-reference re-declaring a prohibited attribute is not flagged as duplicate (lenient)") + func test_selfRefRedeclareProhibitedLenient() throws { + // A `use="prohibited"` re-declaration removes rather than duplicates, so the + // duplicate check declines; the base attribute is optional, so this compiles. + _ = try redefine( + group: "", + base: "", + ) + } + @Test("adding an attribute alongside a self-reference is valid") func test_addWithSelfReferenceAccepted() throws { _ = try redefine( diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 71af5c9..51d922c 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; redefine type-widen schM4 23; valid-rejected 0. + /// 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. private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 23 + private let knownSchemaInvalidAccepted = 22 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