diff --git a/CHANGELOG.md b/CHANGELOG.md
index febcfa5..0feb7bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- XSTS invalid-instances-accepted (#147): an element may now carry at most one attribute whose type is `xs:ID` (XSD 1.0 §3.4.6). Two ID-typed attributes admitted through an `xs:anyAttribute` wildcard (the schema itself stays valid, as a type with two declared ID attribute uses is already a compile error) are rejected at instance time. The count covers both a matched declared use's type and the global declaration a matching wildcard admits, including a restriction of `xs:ID`; `xs:IDREF`/`xs:ENTITY` and a lone ID attribute are unaffected. invalid-instances-accepted 45 → 43, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
+
- XSTS invalid-instances-accepted (#146, #147): an identity-constraint (`unique`/`key`/`keyref`) field that selects an absent attribute or an empty element whose declaration carries a `default`/`fixed` now takes that value as its identity-tuple component, so two members can collide on the defaulted value (idG011/idG012 default, idF016/idF017 fixed). The schema's `default`/`fixed` value constraints on identity-field targets are gathered at compile time, keyed the same way as the field types, and threaded through to the identity validator. The substitution is exact about absent versus present-empty: an `@attr` field is defaulted only when the attribute is ABSENT (empty node-set), never when it is present with value `""`; a `.` element field is defaulted only when the selected element has no character content. Applying a default only adds an identity value where the schema declares one, so it can create a genuine duplicate-key detection but never reject a valid instance. invalid-instances-accepted 50 → 45, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
- XSTS invalid-instances-accepted (#147): a `block` on a substitution-group head's TYPE, not only on the head element, now bars a member from substituting. The substitution-group filter drops a member whose type reaches the head's type by a blocked derivation method, using the union of the head element's `{disallowed substitutions}` and the head type's `{prohibited substitutions}`; previously only the head element's own `block` was consulted, so a `block="restriction"` on the head's complex type let a restriction-derived member substitute. The element-level `block="substitution"` outright bar is unchanged, and a member derived by a method the block does not list still substitutes. invalid-instances-accepted 53 → 50, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
diff --git a/Sources/Schema/ComplexValidatorAttributes.swift b/Sources/Schema/ComplexValidatorAttributes.swift
index 9c709d2..9c408db 100644
--- a/Sources/Schema/ComplexValidatorAttributes.swift
+++ b/Sources/Schema/ComplexValidatorAttributes.swift
@@ -21,6 +21,42 @@ extension PureXML.Schema.ComplexValidator {
}
}
validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors)
+ if idTypedAttributeCount(present, type, on: element) > 1 {
+ errors.append(PureXML.Validation.ValidationError(
+ reason: "an element may carry at most one attribute of type ID",
+ at: path,
+ ))
+ }
+ }
+
+ /// The number of `present` attributes whose effective type is `xs:ID` (an
+ /// element may carry at most one; XSD 1.0 §3.4.6, cvc-complex-type). An
+ /// attribute's type is its declared use's type when it matches one, otherwise
+ /// the global declaration a matching wildcard admits.
+ private func idTypedAttributeCount(
+ _ present: [PureXML.Model.Attribute],
+ _ type: PureXML.Schema.ComplexType,
+ on element: PureXML.Model.Element,
+ ) -> Int {
+ present.reduce(into: 0) { count, attribute in
+ if isIDTyped(attribute, type, on: element) { count += 1 }
+ }
+ }
+
+ /// Whether `attribute`'s effective type is `xs:ID`: the type of the declared
+ /// use it matches, or the global declaration a matching wildcard admits.
+ private func isIDTyped(
+ _ attribute: PureXML.Model.Attribute,
+ _ type: PureXML.Schema.ComplexType,
+ on element: PureXML.Model.Element,
+ ) -> Bool {
+ if let use = type.attributes.first(where: { Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: element.name) }) {
+ return use.type.isID
+ }
+ guard let wildcard = type.attributeWildcard, wildcard.admits(attribute.name),
+ let use = globalAttributeUse(for: attribute.name)
+ else { return false }
+ return use.type.isID
}
/// Whether an instance attribute satisfies a declared use. Chameleon-included
diff --git a/Tests/SchemaTwoIDAttributesTests.swift b/Tests/SchemaTwoIDAttributesTests.swift
new file mode 100644
index 0000000..8f29ad1
--- /dev/null
+++ b/Tests/SchemaTwoIDAttributesTests.swift
@@ -0,0 +1,39 @@
+@testable import PureXML
+import Testing
+
+/// An element may carry at most one attribute whose type is `xs:ID` (XSD 1.0
+/// §3.4.6, cvc-complex-type). Two ID-typed attributes on one element, whether
+/// declared or admitted through an attribute wildcard, are invalid; one is fine.
+@Suite("at most one ID attribute per element")
+struct SchemaTwoIDAttributesTests {
+ /// The W3C attZ014 shape: the two ID-typed attributes are not declared on the
+ /// type (which would already be a schema error) but enter the instance through
+ /// an `anyAttribute` wildcard that admits two ID-typed global attributes, one
+ /// of them a restriction of `xs:ID`.
+ @Test("two ID-typed attributes admitted via a wildcard are rejected")
+ func test_twoWildcardIDsRejected() throws {
+ let schema = try PureXML.Schema.Document("""
+
+
+
+
+
+
+ """)
+ #expect(try !schema.validate(#""#).isEmpty)
+ }
+
+ @Test("a single ID attribute plus non-ID attributes is accepted")
+ func test_singleIDAccepted() throws {
+ let schema = try PureXML.Schema.Document("""
+
+
+
+
+
+
+
+ """)
+ #expect(try schema.validate(#""#).isEmpty)
+ }
+}
diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift
index 0c6d423..8e0231b 100644
--- a/Tests/XSTSSuiteTests.swift
+++ b/Tests/XSTSSuiteTests.swift
@@ -150,13 +150,13 @@ struct XSTSSuiteTests {
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 69
private let knownInstanceValidRejected = 0
- /// Instance bucket (133 -> 45), per-step deltas in CHANGELOG.md: xsi:type must derive from the
+ /// Instance bucket (133 -> 43), per-step deltas in CHANGELOG.md: xsi:type must derive from the
/// declared type (complex, then atomic and list/union, resolved through the element-ref chain);
/// block/blockDefault (incl. "substitution" and an untyped head) bar substitutions after the
/// derivation backbone was re-keyed by namespace; ur-type `xsd:anyType` wildcards are `lax`;
/// identity fields compare in the decimal value space and take a declared default/fixed when
/// absent or empty; a complex type's attribute wildcard is the INTERSECTION of its sources.
- private let knownInstanceInvalidAccepted = 45
+ private let knownInstanceInvalidAccepted = 43
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {