fix(schema): detect UPA overlap of ##other wildcards

Unique Particle Attribution missed overlapping xs:any wildcards involving
##other: wildcardsOverlap returned false for any pair touching .other, so two
competing wildcards whose namespaces actually intersect were not flagged as
non-deterministic and the schema was wrongly accepted.

Compute the overlap correctly: ##other against ##other always overlaps (both
admit every namespace but their target and the absent one, infinite sets that
always intersect), and ##other against an enumerated set overlaps iff the set
names a namespace that is neither absent (##local) nor the target. The new
predicate matches Wildcard.admits exactly, so disjoint cases (##other vs
##local, or vs the target namespace) stay deterministic and no valid schema
is newly rejected.

Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted
35 -> 31 (wildI009/wildI013/wildI014 and one more), valid-schemas-rejected
held at 0 (no other bucket moved). Adds UPA unit tests for both rejection
shapes and a ##other-vs-##local determinism guard; updates the pinned XSTS
count 35 -> 31.
This commit is contained in:
Mihaela Mihaljevic 2026-06-21 21:16:43 +02:00
parent 97c07e91f4
commit 9a2cb7eab3
4 changed files with 42 additions and 6 deletions

View File

@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Unique Particle Attribution now detects overlapping `xs:any` wildcards involving `##other`. `wildcardsOverlap` returned false for any pair involving `##other`, so two competing wildcards whose namespaces actually intersect, two `##other` constraints (which always share infinitely many namespaces) or `##other` against an enumerated set naming a non-target, non-absent namespace, were not flagged as non-deterministic, and the schema was wrongly accepted. The overlap is now computed correctly: `##other`∩`##other` always overlaps, and `##other`∩enumerated overlaps iff the set names a namespace that is neither absent (`##local`) nor the target. Disjoint cases (`##other` against `##local` or against the target namespace) remain deterministic, so no valid schema is newly rejected. XSTS invalid-schemas-accepted 35 → 31 (wildI009/wildI013/wildI014 and one more), valid-schemas-rejected held at 0.
- The content-model matcher no longer blows up on a nested model group with a huge but
finite `maxOccurs` (#192, partial). The counted-automaton active set is bounded by the
product of the per-counter saturation caps; a finite `maximum` the input cannot reach

View File

@ -290,14 +290,30 @@ public extension PureXML.Schema {
private static func wildcardsOverlap(_ lhs: Wildcard, _ rhs: Wildcard) -> Bool {
switch (lhs.namespace, rhs.namespace) {
case (.any, _), (_, .any):
// `##any` admits every name, so it shares names with any other constraint.
true
case (.other, .other):
// Two `##other` constraints each admit every namespace but their own
// target (and the absent namespace); those infinite sets always
// intersect, so they overlap (XSTS wildI* non-deterministic choices).
true
case let (.enumerated(lhsSet), .enumerated(rhsSet)):
!lhsSet.isDisjoint(with: rhsSet)
default:
false
case let (.other, .enumerated(set)):
otherOverlapsEnumerated(set, target: lhs.targetNamespace)
case let (.enumerated(set), .other):
otherOverlapsEnumerated(set, target: rhs.targetNamespace)
}
}
/// Whether `##other` (any namespace but the target and the absent namespace)
/// shares a name with an enumerated set: it does iff the set names some
/// namespace that is neither absent (the empty string, `##local`) nor the
/// target namespace `##other` excludes.
private static func otherOverlapsEnumerated(_ set: Set<String>, target: String?) -> Bool {
set.contains { !$0.isEmpty && $0 != (target ?? "") }
}
private static func describe(_ label: TermLabel) -> String {
switch label {
case let .name(name): name.localName

View File

@ -46,6 +46,25 @@ struct ContentModelDeterminismTests {
#expect(rejects(complexType(#"<xs:sequence><xs:element name="a" maxOccurs="unbounded"/><xs:element name="a"/></xs:sequence>"#)))
}
@Test("a choice of ##other and an overlapping namespace wildcard is ambiguous")
func test_choiceOfOtherAndOverlappingWildcard() {
// `##other` admits namespace A (A is neither absent nor the target), and so
// does `namespace="A"`, so an A-namespaced element matches both (XSTS wildI009).
#expect(rejects(complexType("<xs:choice><xs:any namespace='##other'/><xs:any namespace='A'/></xs:choice>")))
}
@Test("a choice of two ##other wildcards is ambiguous")
func test_choiceOfTwoOtherWildcards() {
#expect(rejects(complexType("<xs:choice><xs:any namespace='##other'/><xs:any namespace='##other'/></xs:choice>")))
}
@Test("a choice of ##other and ##local is deterministic and compiles")
func test_choiceOfOtherAndLocalCompiles() {
// `##other` never admits the absent namespace and `##local` admits only it,
// so they are disjoint: the model is deterministic and must not be rejected.
#expect(!rejects(complexType("<xs:choice><xs:any namespace='##other'/><xs:any namespace='##local'/></xs:choice>")))
}
@Test("two same-name members of an all group are ambiguous")
func test_allWithSameName() {
#expect(rejects(complexType(#"<xs:all><xs:element name="a"/><xs:element name="a"/></xs:all>"#)))

View File

@ -143,12 +143,12 @@ struct XSTSSuiteTests {
/// full effective-total-range Particle-Valid-Restriction (tracked).
/// Further rules, each with valid-schemas-rejected held at 0 (see CHANGELOG and
/// docs/xsts-deviations.md), drove invalid-schemas-accepted 130 -> 45 across
/// model-group occurrence, identity-constraint content, derivation base kind,
/// attribute/simple-type rules, composition, simpleContent inline-type checks,
/// complexContent mixedness, and substitution-group anySimpleType checks.
/// 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 wildcard-overlap (`##other` was treated as overlapping nothing) 35->31 (wildI009/I013/I014 + one more), no other bucket moved.
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 35
private let knownSchemaInvalidAccepted = 31
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