fix(schema): reject a redefine whose self-reference duplicates a particle (UPA) (#253)

A redefined model group whose body references itself expands, in the compiled
content model, to the original group it redefines. Two gaps let an ambiguity
created that way slip through the Unique Particle Attribution check (cos-nonambig).

The compositional determinism engine treated the self-reference as contributing
an empty decision set, so the original group's particles were invisible to the
overlap test. It now expands one level to the redefine base, exactly as the
instance content-model path does, so a particle the original contributes is seen.
The expansion only surfaces genuinely reachable items, so the decision sets stay
a subset of the true model: the check can catch a real ambiguity but never invent
one, holding the false-positive direction at zero.

Separately, the UPA check walked only the root document, so an ambiguity a root
redefine creates in a complex type that lives in an included document was never
evaluated. It now runs over every schema root in the composition under the shared
redefine-effective context, mirroring the existing cross-document top-level
declaration check.

Closes XSTS schN10 (a three-level redefine chain). XSTS invalid-schemas-accepted
11 -> 10, valid-schemas-rejected held at 0. Full suite 1609 tests green in debug
and release.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 00:06:23 +02:00 committed by GitHub
parent 330e827262
commit 3a6aad0eb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 111 additions and 5 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A `redefine` whose group self-reference duplicates a particle across the redefine chain is now rejected as a Unique Particle Attribution (cos-nonambig) violation, instead of being silently accepted. Two gaps combined to miss it. First, the compositional determinism engine treated a `<group ref>` to the name being redefined (a src-redefine self-reference) as contributing an empty decision set, where instance validation expands it to the ORIGINAL group; it now expands one level to the redefine base exactly as the instance content-model path does, so the original's particles are visible to the overlap test. The expansion only surfaces genuinely reachable items, so the computed decision sets stay a subset of the true model and the change can catch a real ambiguity but never invent one. Second, the UPA check walked only the root document, so an ambiguity created by a root `redefine` in a complex type that lives in an INCLUDED document was never evaluated; it now runs over every schema root in the composition under the shared (redefine-effective) context, mirroring the existing cross-document top-level-declaration check. Closes XSTS `schN10` (a three-level redefine chain). XSTS invalid-schemas-accepted 11 -> 10, valid-schemas-rejected held at 0.
- A `complexContent` restriction whose base complex type has SIMPLE content is now rejected (cos-ct-restricts 5.2). A `complexContent` restriction's {content type} is element-only, mixed, or empty (derived from its model group), which can never validly restrict a simple content type, so restricting a simple-content base this way is invalid. Only a `.simple` base was flagged before; a complex type with `simpleContent` slipped through. The check is scoped to the restriction direction: the extension direction has a valid attribute-only idiom and stays handled by the existing simpleContent-extension rule. Catches particlesZ039. XSTS invalid-schemas-accepted 16 → 15, valid-schemas-rejected held at 0.
- A content-bearing derived group is no longer accepted as a restriction of a base single element. XSD 1.0 §3.9.6 "Particle Valid (Restriction)" defines a case for an element restricting a base group (RecurseAsIfGroup) but NONE for the reverse, so a derived `sequence`/`choice`/`all` that can contribute a child never validly restricts a single base element, regardless of how its leaves are named or counted. The oracle had a deliberate non-spec "mirror" extension that bounded such a derived group by the element's effective occurrence range and accepted it; that masked a real violation inside a `choice`-to-`choice` restriction, where a derived `sequence` branch was allowed to map onto a base element branch it cannot legally restrict. The case now returns invalid (a content-FREE derived group is still a valid restriction of any emptiable base, handled separately). Catches particlesHb011. XSTS invalid-schemas-accepted 17 → 16, valid-schemas-rejected held at 0.

View File

@ -180,9 +180,9 @@ private struct CompWalk {
private mutating func group(_ node: PureXML.Model.TreeNode, visiting: Set<String>) -> CompSummary {
guard let ref = PureXML.Schema.XSDNode.attribute(node, "ref") else { return CompSummary(nullable: true, first: [], lastFollow: []) }
let name = PureXML.Schema.XSDNode.stripPrefix(ref)
if visiting.contains(name) { return redefineSelfReference(name, visiting: visiting) }
if let cached = groupCache[name] { return cached }
guard !visiting.contains(name), let definition = context.groups[name],
let model = PureXML.Schema.XSDNode.elementChildren(definition).first(where: { ["sequence", "choice", "all"].contains(PureXML.Schema.XSDNode.localName($0) ?? "") })
guard let definition = context.groups[name], let model = modelChild(definition)
else { return CompSummary(nullable: true, first: [], lastFollow: []) }
// Summarize the group once: its internal decision sets are context-independent
// and emitted here; its cross-boundary follow is formed by each reference site.
@ -191,6 +191,33 @@ private struct CompWalk {
return summary
}
/// A `<group ref>` to the name currently being summarized is a src-redefine
/// self-reference: in the compiled (instance) content model it expands to the
/// ORIGINAL group it redefines (XSDParserParticles.groupReferenceParticle), not
/// to itself. The determinism check must see that same expanded model, else the
/// original's particles are invisible and a real UPA ambiguity goes unreported
/// (the redefine chain in XSTS schN10: an inner c-g2 contributes a `c24` that
/// overlaps the redefinition's own direct `c24`). Expand one level to the base; a
/// mark stops a redefine CHAIN from re-expanding the same name, so it terminates.
/// Every item this surfaces is genuinely reachable in the real model, so the
/// decision sets stay a SUBSET of the true ones: it can catch a real ambiguity,
/// never invent one (no false positive). A non-redefined recursive reference has
/// no base, so it still contributes nothing, exactly as before.
private mutating func redefineSelfReference(_ name: String, visiting: Set<String>) -> CompSummary {
let empty = CompSummary(nullable: true, first: [], lastFollow: [])
let mark = "\u{1}redefined:\(name)"
guard !visiting.contains(mark), context.redefinedGroups.contains(name),
let base = context.baseGroups[name], let model = modelChild(base)
else { return empty }
return summarize(model, visiting: visiting.union([mark]))
}
/// The `sequence`/`choice`/`all` model child of a group definition, if any.
private func modelChild(_ definition: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode? {
PureXML.Schema.XSDNode.elementChildren(definition)
.first { ["sequence", "choice", "all"].contains(PureXML.Schema.XSDNode.localName($0) ?? "") }
}
// MARK: Conflict
static func overlap(_ set: [CompItem]) -> String? {

View File

@ -98,7 +98,21 @@ extension PureXML.Validation {
static var contentModelsDeterministic: Validation<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
compileRule("Schema content models are deterministic (UPA)") { document in
PureXML.Schema.ContentModelDeterminism.violationFindings(in: document.schema, context: document.context)
// Run over every schema document in the composition, not just the
// root: a `redefine` in the root can make a complex type that lives
// in an INCLUDED/redefined document ambiguous (XSTS schN10: the root
// redefines a group so the base document's `b-ct` gains a duplicate
// particle). That model lives in the other root, so walking only
// `document.schema` missed it. The shared context carries the
// redefine-effective group definitions, so each root resolves them
// identically to instance validation. Each root uses its own
// namespace bindings (violationFindings reads them from the root).
let roots = [document.schema] + document.containers.filter {
$0 !== document.schema && PureXML.Schema.XSDNode.localName($0) == "schema"
}
return roots.flatMap {
PureXML.Schema.ContentModelDeterminism.violationFindings(in: $0, context: document.context)
}
}
}

View File

@ -153,4 +153,64 @@ struct ContentModelDeterminismTests {
<xs:group name="y"><xs:choice><xs:element name="y1"/><xs:element name="y2"/></xs:choice></xs:group>
"""#)
}
// MARK: - Redefine composition
@Test("a redefine whose self-reference duplicates a particle is ambiguous across documents (schN10)")
func test_redefineSelfReferenceDuplicatesParticle() {
// The base declares group g = choice(a, b) and a complex type that uses it.
// The redefinition expands g to choice(<self-ref to original g>, a): the
// self-reference brings the original's `a` back, so an `a` is attributable
// to two particles -> a UPA violation. The ambiguous content model lives in
// the BASE document's complex type, reached only through the redefined group,
// so this exercises both the self-reference expansion in the determinism
// engine and running the UPA check over every composition root.
let base = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:group name="g"><xs:choice><xs:element name="a" type="xs:int"/><xs:element name="b" type="xs:int"/></xs:choice></xs:group>
<xs:complexType name="ct"><xs:group ref="g"/></xs:complexType>
<xs:element name="root" type="ct"/>
</xs:schema>
"""
let redefining = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:redefine schemaLocation="base.xsd">
<xs:group name="g"><xs:choice><xs:group ref="g"/><xs:element name="a" type="xs:int"/></xs:choice></xs:group>
</xs:redefine>
</xs:schema>
"""
let rejected: Bool
do {
_ = try PureXML.Schema.Document(redefining, schemaLoader: { $0.hasSuffix("base.xsd") ? base : nil })
rejected = false
} catch { rejected = true }
#expect(rejected)
}
@Test("a redefine self-reference that does not duplicate a particle still compiles")
func test_redefineSelfReferenceWithoutDuplicateCompiles() {
// Same shape, but the redefinition adds a DISTINCT element `c`, so the
// expanded model choice(choice(a, b), c) has no overlap and stays valid:
// the FP-guard for the case above (the change must not over-reject).
let base = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:group name="g"><xs:choice><xs:element name="a" type="xs:int"/><xs:element name="b" type="xs:int"/></xs:choice></xs:group>
<xs:complexType name="ct"><xs:group ref="g"/></xs:complexType>
<xs:element name="root" type="ct"/>
</xs:schema>
"""
let redefining = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:redefine schemaLocation="base.xsd">
<xs:group name="g"><xs:choice><xs:group ref="g"/><xs:element name="c" type="xs:int"/></xs:choice></xs:group>
</xs:redefine>
</xs:schema>
"""
let rejected: Bool
do {
_ = try PureXML.Schema.Document(redefining, schemaLoader: { $0.hasSuffix("base.xsd") ? base : nil })
rejected = false
} catch { rejected = true }
#expect(!rejected)
}
}

View File

@ -36,8 +36,11 @@ struct XSTSSuiteTests {
/// XSD Appendix F (RegexTest_993, RegexTest_1477); 13 -> 12, an unknown
/// `\p{...}` category or block name is rejected now the block set is complete
/// (reK88, `\p{IsaA0-a9}`); 12 -> 11, an attribute-wildcard union in a type
/// extension that is not expressible per Errata E1-10 is rejected (wildZ013).
private let knownSchemaInvalidAccepted = 11
/// extension that is not expressible per Errata E1-10 is rejected (wildZ013);
/// 11 -> 10, a redefine self-reference's expansion is now seen by the UPA check
/// and that check runs over every composition root, so a redefined group that
/// duplicates a particle across the redefine chain is rejected (schN10).
private let knownSchemaInvalidAccepted = 10
private let knownInstanceValidRejected = 0
/// Instance bucket (133 -> 22), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared
/// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its head's