fix(schema): reject redefined attribute group that self-references and re-declares an attribute

A redefinition of an attributeGroup that self-references the original re-imports
all of its attribute uses, so directly re-declaring one of those attributes
produces two attribute uses of the same name, a duplicate forbidden by
ag-props-correct.2.

Flag a non-prohibited direct re-declaration of an attribute the original directly
declares, when the redefinition has a self-reference. To stay false-positive-free
the check fires 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); a self-referencing redefinition
that adds genuinely new attributes (attgC007/attgC038/groupB018) stays valid.

Also extracts the use-transition restriction errors into a helper to keep the
function within the complexity limit.

This catches W3C XSTS schM8. XSTS schema invalid-accepted 23 -> 22, valid-rejected
held at 0.
This commit is contained in:
Mihaela Mihaljevic 2026-06-22 02:56:13 +02:00
parent b6e5cd8f3f
commit 64e30fe491
4 changed files with 84 additions and 18 deletions

View File

@ -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.

View File

@ -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<String>, 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

View File

@ -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: "<xs:attributeGroup ref='g'/><xs:attribute name='a' type='xs:string'/>",
base: "<xs:attribute name='a' type='xs:string'/>",
)
}
}
@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: "<xs:attributeGroup ref='g'/><xs:attribute name='a' use='prohibited' type='xs:string'/>",
base: "<xs:attribute name='a' type='xs:string'/>",
)
}
@Test("adding an attribute alongside a self-reference is valid")
func test_addWithSelfReferenceAccepted() throws {
_ = try redefine(

View File

@ -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