From d92b9149df6b8690b978c63fa30d3619f09ca95e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 17 Jun 2026 19:56:57 +0200 Subject: [PATCH] fix(schema): eliminate the last false positive (particlesZ001); false-positives = 0 (#148) Read element-vs-choice Particle Valid (Restriction) through effective total range: an element validly restricts a base choice when its leaf is a NameAndTypeOK restriction of an element the choice admits and its effective occurrence range is within the choice's. This fixes particlesZ001 (the RecurseAsIfGroup rule its own test calls ambiguous; W3C reads it valid) while keeping particlesM002/003 valid and particlesL rejected. Named, bounded under-rejection (exact in-branch sequencing unmodelled), the production-sanctioned direction: invalid-schemas-accepted 128->130, invalid-instances-accepted 132->133 (particlesZ001.i, a masked gap now exposed). XSTS valid-schemas-rejected 1->0. With valid-instances-rejected already 0, false positives are now 0 in both buckets (production stopper #1). --- CHANGELOG.md | 2 + Sources/Schema/ParticleRestriction.swift | 79 ++++++++++--------- .../Schema/ParticleRestrictionHelpers.swift | 51 ++++++++++++ Tests/XSTSSuiteTests.swift | 17 +++- 4 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 Sources/Schema/ParticleRestrictionHelpers.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a3eb3..45cd7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s ### Fixed +- XSTS valid-schemas-rejected (#148) reaches 0: FALSE POSITIVES ARE NOW 0 (both buckets). The last over-rejection, particlesZ001 (`element{0,∞}` restricting `choice{0,∞}` over that element; the test's own documentation calls the RecurseAsIfGroup rule "ambiguous"), is fixed by reading element-vs-choice restriction through effective total range: the element validly restricts the choice when its leaf is a NameAndTypeOK restriction of an element the choice admits and its effective occurrence range is within the choice's. This is a named, bounded under-rejection (exact in-branch sequencing is not modelled), the production-sanctioned direction since over-rejection is the non-starter: invalid-schemas-accepted 128 → 130 (two element-vs-choice cases) and, with particlesZ001 now compiling, its instance particlesZ001.i (a previously-masked invalid-instance gap) is accepted, 132 → 133. Recovered by the full effective-total-range Particle Valid (Restriction). + - XSTS valid-instances-rejected (#146) reaches 0. The three remaining rejections (reS17, reS38, reZ004v) are not validator defects: they assert that `\d` matches Ethiopic digits (U+1369, U+1371, and kin), which are Unicode general category `No` (Other Number), whereas the normative XSD definition is `\d` = `\p{Nd}` (Datatypes Appendix F). Our `\d` correctly matches `Nd` (including Khmer U+17E0) and rejects Ethiopic `No`, as every conformant processor does; faking it to match `No` would corrupt the definition. These three corpus entries are excluded as a named, bounded spec divergence, so the false-positive metric reflects spec correctness. valid-instances-rejected 3 → 0. - XSTS invalid-schemas-accepted (#145): a complexContent restriction's own attribute wildcard must be a subset of the base's (`cos-ct-restricts.4`). A base type with no attribute wildcard admits none, so the restriction may not introduce one, and a wider namespace constraint (such as `##any` over `##other`) or a weakened `processContents` is not a subset. Caught three invalid schemas: invalid-schemas-accepted 131 → 128, no other bucket moved. diff --git a/Sources/Schema/ParticleRestriction.swift b/Sources/Schema/ParticleRestriction.swift index 52d4b44..973e5ba 100644 --- a/Sources/Schema/ParticleRestriction.swift +++ b/Sources/Schema/ParticleRestriction.swift @@ -83,16 +83,7 @@ extension PureXML.Schema { case let (.wildcard(restrictedWildcard), .wildcard(baseWildcard)): return rangeSubsumed(restricted, base) && narrows(restrictedWildcard, baseWildcard) case let (.element, .group(baseGroup)): - // RecurseAsIfGroup (XSD 1.0 §3.9.6): the element is treated as a group - // with {min,max}=1,1 holding the element. That SYNTHETIC {1,1} - // occurrence must be within the base group's range, so a base group - // that must occur two or more times cannot be restricted to a single - // element (particlesL001). The element's OWN occurrence is matched - // against a base branch inside `groupValid`; comparing the element's - // own range to the base group's would wrongly reject e.g. an optional - // element restricting a once-or-more base group. - return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs) - && groupValid(Group(compositor: .sequence, particles: [restricted]), baseGroup, types, derivation) + return elementRestrictsGroup(restricted, base, baseGroup, types, derivation) case let (.group, .element(baseName, _, baseTypeName, _, _, _)): // The mirror of RecurseAsIfGroup, by the same cardinality argument as // group/wildcard: a base element accepts only its own name, its own @@ -295,36 +286,52 @@ extension PureXML.Schema { lhs.localName == rhs.localName && (lhs.namespaceURI ?? "") == (rhs.namespaceURI ?? "") } - /// Validation strength of a wildcard's `processContents`, for the Wildcard - /// Subset rule: `strict` is stronger than `lax` is stronger than `skip`. - private static func processContentsRank(_ processContents: ProcessContents) -> Int { - switch processContents { - case .skip: 0 - case .lax: 1 - case .strict: 2 + /// RecurseAsIfGroup against a CHOICE, read by effective total range. The + /// literal §3.9.6 rule under-approximates the ground truth (derived language ⊆ + /// base language) and over-rejects a repeating base choice (particlesZ001, whose + /// test doc calls the rule "ambiguous"; W3C reads it valid). First principles: + /// the element validly restricts the choice iff its leaf is a NameAndTypeOK + /// restriction of some element the choice admits AND its effective occurrence + /// range is within the choice's. Accepts `element{0,∞}` over `choice{0,∞}(...)` + /// (Z001) and `c1{2,2}` over a choice whose branch holds `c1` (M002/003); a + /// must-repeat choice (effective min ≥ 2) still rejects a single element + /// (particlesL). A documented, bounded under-rejection (exact in-branch + /// sequencing is not modelled); over-rejection is the non-starter. + private static func elementRestrictsGroup( + _ restricted: Particle, + _ base: Particle, + _ baseGroup: Group, + _ types: [String: ElementType], + _ derivation: [String: TypeDerivation], + ) -> Bool { + if baseGroup.compositor == .choice { + return elementRestrictsChoice(restricted, base, baseGroup, types, derivation) } + // RecurseAsIfGroup for a sequence/all base (§3.9.6): the element as a + // {1,1} synthetic group must be within the base group's range and map to + // a base member inside `groupValid`. + return rangeWithinWildcard(derivedMin: 1, derivedMax: 1, wildcardMin: base.minOccurs, wildcardMax: base.maxOccurs) + && groupValid(Group(compositor: .sequence, particles: [restricted]), baseGroup, types, derivation) } - /// NameAndTypeOK value-constraint clause: when the base element is `fixed`, - /// the restricting element must also be `fixed` to the same value (compared in - /// the base type's value space, so `1` and `01` match). A base with no fixed - /// value, or a `default`, imposes no constraint on the restriction. - private static func fixedValueOK(_ restrictedValue: ValueConstraint?, _ baseValue: ValueConstraint?, _ baseType: ElementType?, _ types: [String: ElementType]) -> Bool { - guard let baseFixed = baseValue?.fixedValue else { return true } - guard let restrictedFixed = restrictedValue?.fixedValue else { return false } - return atomicType(baseType, types)?.valueMatches(restrictedFixed, literal: baseFixed) ?? (restrictedFixed == baseFixed) - } - - /// The atomic simple type an element's type resolves to (following a - /// `typeReference` through the types map, bounded against cycles), or nil for - /// a complex, list, or union type. - private static func atomicType(_ type: ElementType?, _ types: [String: ElementType], _ depth: Int = 0) -> PureXML.Schema.SimpleType? { - guard depth < 32 else { return nil } - switch type { - case let .simple(simple): return simple - case let .typeReference(key): return atomicType(types[key], types, depth + 1) - default: return nil + private static func elementRestrictsChoice( + _ restricted: Particle, + _ base: Particle, + _ baseGroup: Group, + _ types: [String: ElementType], + _ derivation: [String: TypeDerivation], + ) -> Bool { + let neutral = Particle(minOccurs: 1, maxOccurs: 1, term: restricted.term) + let admitted = elementLeaves(of: baseGroup).contains { + valid(neutral, Particle(minOccurs: 1, maxOccurs: 1, term: $0), types, derivation) } + return admitted + && rangeWithinWildcard( + derivedMin: restricted.effectiveOccurrenceMin(), + derivedMax: restricted.effectiveOccurrenceMax(), + wildcardMin: base.effectiveOccurrenceMin(), + wildcardMax: base.effectiveOccurrenceMax(), + ) } /// Whether every leaf a particle can contain is admitted by `wildcard`: an diff --git a/Sources/Schema/ParticleRestrictionHelpers.swift b/Sources/Schema/ParticleRestrictionHelpers.swift new file mode 100644 index 0000000..44f91d2 --- /dev/null +++ b/Sources/Schema/ParticleRestrictionHelpers.swift @@ -0,0 +1,51 @@ +extension PureXML.Schema.ParticleRestriction { + /// Every element-declaration term reachable as a leaf of `group` (recursing + /// into nested groups; wildcards are not elements). Used to test whether a + /// restricting element names something the base group admits. + static func elementLeaves(of group: PureXML.Schema.Group) -> [PureXML.Schema.Term] { + group.particles.flatMap { particle -> [PureXML.Schema.Term] in + switch particle.term { + case .element: [particle.term] + case let .group(inner): elementLeaves(of: inner) + case .wildcard: [] + } + } + } + + /// Validation strength of a wildcard's `processContents`, for the Wildcard + /// Subset rule: `strict` is stronger than `lax` is stronger than `skip`. + static func processContentsRank(_ processContents: PureXML.Schema.ProcessContents) -> Int { + switch processContents { + case .skip: 0 + case .lax: 1 + case .strict: 2 + } + } + + /// NameAndTypeOK value-constraint clause: when the base element is `fixed`, the + /// restricting element must also be `fixed` to the same value (compared in the + /// base type's value space, so `1` and `01` match). A base with no fixed value, + /// or a `default`, imposes no constraint on the restriction. + static func fixedValueOK( + _ restrictedValue: PureXML.Schema.ValueConstraint?, + _ baseValue: PureXML.Schema.ValueConstraint?, + _ baseType: PureXML.Schema.ElementType?, + _ types: [String: PureXML.Schema.ElementType], + ) -> Bool { + guard let baseFixed = baseValue?.fixedValue else { return true } + guard let restrictedFixed = restrictedValue?.fixedValue else { return false } + return atomicType(baseType, types)?.valueMatches(restrictedFixed, literal: baseFixed) ?? (restrictedFixed == baseFixed) + } + + /// The atomic simple type an element's type resolves to (following a + /// `typeReference` through the types map, bounded against cycles), or nil for a + /// complex, list, or union type. + static func atomicType(_ type: PureXML.Schema.ElementType?, _ types: [String: PureXML.Schema.ElementType], _ depth: Int = 0) -> PureXML.Schema.SimpleType? { + guard depth < 32 else { return nil } + switch type { + case let .simple(simple): return simple + case let .typeReference(key): return atomicType(types[key], types, depth + 1) + default: return nil + } + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 9cca59a..3e78367 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -130,10 +130,21 @@ struct XSTSSuiteTests { /// would corrupt the definition and is refused; the tests are the outliers. private static let specDivergentInstances: Set = ["reS17.v", "reS38.v", "reZ004v.v"] - private let knownSchemaValidRejected = 1 - private let knownSchemaInvalidAccepted = 128 + /// FALSE POSITIVES ARE 0 (production stopper #1). The last over-rejection, + /// particlesZ001 (`element{0,∞}` restricting `choice{0,∞}` over that element; + /// the test's own doc calls the RecurseAsIfGroup rule "ambiguous"), is fixed by + /// reading element-vs-choice restriction via effective total range. That reading + /// is a bounded, NAMED under-rejection: element-vs-choice no longer models exact + /// in-branch sequencing, so invalid-schemas-accepted rose 128 → 130 (two + /// element-vs-choice restriction cases) and, with particlesZ001 now correctly + /// compiling, its instance particlesZ001.i (a previously-masked invalid-instance + /// gap) is accepted, 132 → 133. Per the production standard over-rejection is the + /// non-starter and under-rejection is recoverable; this debt is recovered by the + /// full effective-total-range Particle-Valid-Restriction (tracked). + private let knownSchemaValidRejected = 0 + private let knownSchemaInvalidAccepted = 130 private let knownInstanceValidRejected = 0 - private let knownInstanceInvalidAccepted = 132 + private let knownInstanceInvalidAccepted = 133 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {