diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d61c4..08aa3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/Sources/Schema/ContentModelDeterminismCompositional.swift b/Sources/Schema/ContentModelDeterminismCompositional.swift index 89eae17..3aa723b 100644 --- a/Sources/Schema/ContentModelDeterminismCompositional.swift +++ b/Sources/Schema/ContentModelDeterminismCompositional.swift @@ -180,9 +180,9 @@ private struct CompWalk { private mutating func group(_ node: PureXML.Model.TreeNode, visiting: Set) -> 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 `` 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) -> 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? { diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index e48332e..3e801d8 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -98,7 +98,21 @@ extension PureXML.Validation { static var contentModelsDeterministic: Validation { 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) + } } } diff --git a/Tests/ContentModelDeterminismTests.swift b/Tests/ContentModelDeterminismTests.swift index ac5dcac..94010a3 100644 --- a/Tests/ContentModelDeterminismTests.swift +++ b/Tests/ContentModelDeterminismTests.swift @@ -153,4 +153,64 @@ struct ContentModelDeterminismTests { """#) } + + // 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(, 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 = """ + + + + + + """ + let redefining = """ + + + + + + """ + 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 = """ + + + + + + """ + let redefining = """ + + + + + + """ + let rejected: Bool + do { + _ = try PureXML.Schema.Document(redefining, schemaLoader: { $0.hasSuffix("base.xsd") ? base : nil }) + rejected = false + } catch { rejected = true } + #expect(!rejected) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 664a123..f479391 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -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