fix(schema): reject substitution-member swaps in particle restriction

NameAndTypeOK clause 1 (XSD 1.0 section 3.9.6, Elt:Elt) pairs the declared
element declarations and requires the same {name} and {target namespace};
no clause grants substitution an allowance. The substitution-group
expansion rewrites the compiled term (an abstract head is dropped, members
join a synthetic choice), so the checker previously compared the expanded
names and a head-for-member swap looked name-identical.

Particle.declaredReference now preserves the pre-expansion identity of an
element-ref particle, and the restriction check pairs the declared
references before the structural pairing table (extracted as
structuralValid). Closes XSTS elemZ026 and particlesV020:
invalid-schemas-accepted 8 -> 6, valid buckets unchanged at zero.
Stricter than libxml2, which does not implement this edge; elemZ026 is
pinned in the differential baseline accordingly.

particlesZ008, byte-for-byte the same construct as elemZ026 but carrying
a settled valid expectation contradicting elemZ026's settled invalid, is
reclassified as a spec-divergent schema test with the adjudication
recorded in the suite (exclusion tables split into
Tests/XSTSSuiteExclusions.swift for file length).

Separately attributed: the differential baseline gains ten further pinned
entries (addB070a, addB108, addB177, attO025, elemZ027_c, errC008,
groupH021v, notatB005, notatE002, particlesEb040) that reproduce with
this change stashed. They are pre-existing drift from earlier main-line
fixes, not effects of this commit. Baseline 231 -> 242, differential
green.
This commit is contained in:
Mihaela Mihaljevic 2026-07-05 08:27:55 +02:00
parent f7d8fa16d1
commit 2e3d2e25f8
10 changed files with 244 additions and 66 deletions

View File

@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- A complex-type restriction that swaps an element reference for one of its
substitution-group members is now rejected (NameAndTypeOK, XSD 1.0 section
3.9.6 Elt:Elt clause 1: the declared names must match, and no clause grants
substitution an allowance in particle restriction). The substitution-group
expansion rewrites the compiled term (an abstract head is dropped, members
join a synthetic choice), so the checker previously compared the expanded
names and the swap looked name-identical; `Particle.declaredReference` now
preserves the pre-expansion identity and the restriction check pairs the
declared references first. Closes XSTS elemZ026 and particlesV020
(invalid-schemas-accepted 8 -> 6); stricter than libxml2, which does not
implement this edge. particlesZ008, byte-for-byte the same construct with a
settled `valid` expectation contradicting elemZ026's settled `invalid`, is
reclassified as a spec-divergent schema test with the adjudication recorded
in the suite. Valid buckets are unchanged at zero.
### Changed
- Rebuild the issue tracker on Codeberg after the GitHub tracker loss: the

View File

@ -224,6 +224,12 @@ public extension PureXML.Schema {
struct Particle: Sendable {
public var occurrenceRange: OccurrenceRange
public var term: Term
/// The declared global-element reference this particle was compiled from, when it
/// came from `<xs:element ref="..."/>`. Substitution-group expansion rewrites the
/// term (an abstract head is dropped, members join a synthetic choice), but XSD 1.0
/// section 3.9.6 defines particle restriction over the DECLARED particles, so the
/// restriction check needs the pre-expansion identity the term no longer carries.
public var declaredReference: PureXML.Model.QualifiedName?
public var minOccurs: Int {
get { occurrenceRange.minimum.clamped(to: Int.max) }
@ -235,14 +241,16 @@ public extension PureXML.Schema {
set { occurrenceRange.maximum = OccurrenceUpper(newValue) }
}
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term) {
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
occurrenceRange = OccurrenceRange(minimum: minOccurs, maximum: maxOccurs)
self.term = term
self.declaredReference = declaredReference
}
public init(occurrenceRange: OccurrenceRange, term: Term) {
public init(occurrenceRange: OccurrenceRange, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
self.occurrenceRange = occurrenceRange
self.term = term
self.declaredReference = declaredReference
}
}

View File

@ -65,6 +65,20 @@ extension PureXML.Schema {
if contentFree(restricted) {
return emptiable(base)
}
// NameAndTypeOK clause 1 over the DECLARED references: when both particles were
// compiled from `<xs:element ref="..."/>`, section 3.9.6 pairs the declared
// element declarations, and Elt:Elt requires the same {name} and {target
// namespace}. Substitution has no allowance here: a restriction that swaps a
// head for one of its substitution-group members names a different declaration
// and is invalid (elemZ026), even though the expanded terms would line up.
return declaredReferencesCompatible(restricted, base)
&& structuralValid(restricted, base, types, derivation)
}
/// The section 3.9.6 pairing table over the compiled terms, dispatched by the
/// (derived, base) term kinds once the outer gates (content-free, declared
/// references) have run.
private static func structuralValid(_ restricted: Particle, _ base: Particle, _ types: [String: ElementType], _ derivation: [String: TypeDerivation]) -> Bool {
switch (restricted.term, base.term) {
case let (.element(rName, _, rTypeName, rValue, rBlock, rNil), .element(bName, bType, bTypeName, bValue, bBlock, bNil)):
// NameAndTypeOK: beyond name, occurrence, and type derivation, the
@ -285,6 +299,19 @@ extension PureXML.Schema {
}
}
/// NameAndTypeOK clause 1 over the DECLARED references: when both particles were
/// compiled from `<xs:element ref="..."/>`, the declared names must match. A
/// particle without a declared reference (a local element, a group written out
/// in place) is unconstrained here and checked by its structural case instead.
private static func declaredReferencesCompatible(_ restricted: Particle, _ base: Particle) -> Bool {
guard let restrictedDeclared = restricted.declaredReference,
let baseDeclared = base.declaredReference
else {
return true
}
return sameName(restrictedDeclared, baseDeclared)
}
private static func sameName(_ lhs: PureXML.Model.QualifiedName, _ rhs: PureXML.Model.QualifiedName) -> Bool {
lhs.localName == rhs.localName && (lhs.namespaceURI ?? "") == (rhs.namespaceURI ?? "")
}

View File

@ -176,11 +176,22 @@ extension PureXML.Schema.XSDParser {
// and namespace so the choice over the head and its members keeps every
// member's own namespace, even across imported namespaces.
let alternatives = head + (context.substitutions[headKey] ?? []).map { PureXML.Schema.XSDParser.unpackElementName($0) }
// The declared identity survives the substitution expansion: section 3.9.6 checks
// particle restriction over the declared references, not the expanded terms.
let declared = PureXML.Model.QualifiedName(localName: name, namespaceURI: refNamespace)
if alternatives.count == 1 {
return PureXML.Schema.Particle(occurrenceRange: occurrence, term: elementReferenceTerm(alternatives[0].0, alternatives[0].1, context))
return PureXML.Schema.Particle(
occurrenceRange: occurrence,
term: elementReferenceTerm(alternatives[0].0, alternatives[0].1, context),
declaredReference: declared,
)
}
let members = alternatives.map { PureXML.Schema.Particle(term: elementReferenceTerm($0.0, $0.1, context)) }
return PureXML.Schema.Particle(occurrenceRange: occurrence, term: .group(.init(compositor: .choice, particles: members)))
return PureXML.Schema.Particle(
occurrenceRange: occurrence,
term: .group(.init(compositor: .choice, particles: members)),
declaredReference: declared,
)
}
/// The term for a reference to a global element, in the resolved `namespace`.

View File

@ -12,17 +12,20 @@ addB033:../msData/additional/test71774.xsd
addB036:../msData/additional/test71847.xsd
addB053:../msData/additional/test73457.xsd
addB064:../msData/additional/test73722.xsd
addB070a:../msData/additional/test64756.xsd
addB071:../msData/additional/test65026.xsd
addB082:../msData/additional/addB082.xsd
addB089:../msData/additional/addB089.xsd
addB099:../msData/additional/addB099.xsd
addB100:../msData/additional/addB100.xsd
addB106:../msData/additional/addB106.xsd
addB108:../msData/additional/addB108.xsd
addB113:../msData/additional/addB113.xsd
addB118:../msData/additional/test74966.xsd
addB121:../msData/additional/addB121.xsd
addB141:../msData/additional/test82919.xsd
addB153:../msData/additional/test86490_a.xsd
addB177:../msData/additional/test102850_2.xsd
addB178:../msData/additional/test102850_3.xsd
addB194:../msData/additional/addB194.xsd
addB32:../msData/additional/test67764.xsd
@ -42,6 +45,7 @@ attKb015:../msData/attribute/attKb015.xsd
attKb018a:../msData/attribute/attKb018a.xsd
attKc012:../msData/attribute/attKc012.xsd
attKc015:../msData/attribute/attKc015.xsd
attO025:../msData/attribute/attO025.xsd
attQ011:../msData/attribute/attQ011.xsd
attQ016:../msData/attribute/attQ016.xsd
attQ017:../msData/attribute/attQ017.xsd
@ -95,11 +99,14 @@ elemG004:../msData/element/elemG004.xsd
elemP005:../msData/element/elemP005.xsd
elemZ004:../msData/element/elemZ004.xsd
elemZ012:../msData/element/elemZ012.xsd
elemZ026:../msData/element/elemZ026.xsd
elemZ027_c:../msData/element/elemZ027c.xsd
elemZ028b:../msData/element/elemZ028b.xsd
elemZ028e:../msData/element/elemZ028e.xsd
elemZ032b:../msData/element/elemZ032b.xsd
errC005:../msData/errata10/errC005.xsd
errC006:../msData/errata10/errC006.xsd
errC008:../msData/errata10/errC008.xsd
errC009:../msData/errata10/errC009.xsd
gMonth_enumeration001_1275:../msData/datatypes/Facets/gMonth/gMonth_enumeration001.xsd
gMonth_enumeration002_1276:../msData/datatypes/Facets/gMonth/gMonth_enumeration002.xsd
@ -132,6 +139,7 @@ groupF009v:../msData/group/groupF009.xsd
groupG002:../msData/group/groupG002.xsd
groupH007v:../msData/group/groupH007.xsd
groupH009v:../msData/group/groupH009.xsd
groupH021v:../msData/group/groupH021.xsd
groupI002:../msData/group/groupI002.xsd
groupJ009v:../msData/group/groupJ009.xsd
groupK002:../msData/group/groupK002.xsd
@ -193,12 +201,14 @@ nonPositiveInteger_fractionDigits007_602:../msData/datatypes/Facets/Schemas/nonP
normalizedString_minLength006_334:../msData/datatypes/Facets/Schemas/normalizedString_minLength006.xsd
normalizedString_whitespace001_344:../msData/datatypes/Facets/Schemas/normalizedString_whitespace001.xsd
notatB001:../msData/notations/notatB001.xsd
notatB005:../msData/notations/notatB005.xsd
notatB008:../msData/notations/notatB008.xsd
notatB009:../msData/notations/notatB009.xsd
notatB010:../msData/notations/notatB010.xsd
notatB011:../msData/notations/notatB011.xsd
notatB012:../msData/notations/notatB012.xsd
notatB013:../msData/notations/notatB013.xsd
notatE002:../msData/notations/notatE002.xsd
notatE003:../msData/notations/notatE003.xsd
notatG001:../msData/notations/notatG001.xsd
notatG002:../msData/notations/notatG002.xsd
@ -207,6 +217,7 @@ particlesDa011:../msData/particles/particlesDa011.xsd
particlesEa023:../msData/particles/particlesEa023.xsd
particlesEa025:../msData/particles/particlesEa025.xsd
particlesEb015:../msData/particles/particlesEb015.xsd
particlesEb040:../msData/particles/particlesEb040.xsd
particlesEc009:../msData/particles/particlesEc009.xsd
positiveInteger_fractionDigits004_1014:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits004.xsd
positiveInteger_fractionDigits007_1017:../msData/datatypes/Facets/Schemas/positiveInteger_fractionDigits007.xsd

View File

@ -0,0 +1,61 @@
import Testing
@testable import PureXML
@Suite("XSD Particle Restriction over substitution groups")
struct XSDParticleSubstitutionRestrictionTests {
private let xsNamespace = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
@Test("A restriction may not swap an element ref for a substitution-group member")
func test_substitutionMemberSwapRejected() {
// elemZ026: NameAndTypeOK clause 1 pairs the DECLARED references, and requires
// the same name and target namespace; substitution has no allowance in Elt:Elt.
// Swapping the head ref for a member ref names a different declaration.
let schema = """
<xs:schema \(xsNamespace)>
<xs:complexType name="headType" abstract="true">
<xs:sequence><xs:element name="leaf" type="xs:token"/></xs:sequence>
</xs:complexType>
<xs:element name="head" type="headType" abstract="true"/>
<xs:element name="member" type="headType" substitutionGroup="head"/>
<xs:complexType name="B">
<xs:sequence><xs:element ref="head" maxOccurs="unbounded"/></xs:sequence>
</xs:complexType>
<xs:complexType name="R">
<xs:complexContent><xs:restriction base="B">
<xs:sequence><xs:element ref="member" maxOccurs="unbounded"/></xs:sequence>
</xs:restriction></xs:complexContent>
</xs:complexType>
<xs:element name="r" type="R"/>
</xs:schema>
"""
#expect(throws: (any Error).self) {
try PureXML.Schema.Document(schema)
}
}
@Test("A restriction that repeats the declared ref and narrows occurrence compiles")
func test_sameRefNarrowedOccurrenceCompiles() throws {
// The positive control for the declared-reference check: the same head ref with
// a narrowed range is the spec's idiom, and the substitution expansion behind
// both sides must not disturb it.
let schema = """
<xs:schema \(xsNamespace)>
<xs:complexType name="headType" abstract="true">
<xs:sequence><xs:element name="leaf" type="xs:token"/></xs:sequence>
</xs:complexType>
<xs:element name="head" type="headType" abstract="true"/>
<xs:element name="member" type="headType" substitutionGroup="head"/>
<xs:complexType name="B">
<xs:sequence><xs:element ref="head" minOccurs="0" maxOccurs="unbounded"/></xs:sequence>
</xs:complexType>
<xs:complexType name="R">
<xs:complexContent><xs:restriction base="B">
<xs:sequence><xs:element ref="head" maxOccurs="2"/></xs:sequence>
</xs:restriction></xs:complexContent>
</xs:complexType>
<xs:element name="r" type="R"/>
</xs:schema>
"""
_ = try PureXML.Schema.Document(schema)
}
}

View File

@ -0,0 +1,85 @@
import Testing
@testable import PureXML
/// The suite exclusions: tests whose expected validity is not authoritative,
/// mirroring how the suite's own `status="queried"` entries are skipped. Kept in
/// their own file so each entry's spec-grounded justification has room to be full.
extension XSTSSuiteTests {
/// Instance tests excluded from the counts because their expected validity is not
/// authoritative, mirroring how the suite's own `status="queried"` entries are
/// skipped. Each must carry a spec-grounded justification; this is never a place to
/// hide a real deviation.
///
/// - `anyURI_a004_1339.i`: the suite pairs a `valid` schemaTest with an `invalid`
/// instanceTest, but the two are mutually inconsistent. The schema's `include`,
/// `import`, and `redefine` have unresolvable schemaLocations; XSD 1.0 §4.2.1
/// makes an unresolvable `include` "not an error" (no inclusion performed), the
/// `import` likewise, and the empty `redefine` imposes no resolution requirement
/// (`src-redefine` clause 1 gates that on non-annotation children), so the schema
/// is conformantly valid (agreeing with the schemaTest). The only components the
/// instance exercises (`root`, `bar`, `st`, `att`) are fully locally defined, and
/// every instance value is byte-identical to one of the type's six anyURI
/// `enumeration` members, so a strict assessment of the document element is
/// valid. There is no spec path to valid-schema + invalid-instance. Confirmed by
/// two independent adversarial analyses; libxml2 is no authority here (it
/// non-conformantly fails to compile the schema on the unresolvable include).
/// Matching the suite would mean rejecting a valid instance whenever an include
/// cannot be resolved, a false positive an IDE editing loop hits constantly.
///
/// - `schA2.i`, `schA5.i`: schema-location precedence. Each group's documentation
/// states the inline `xsi:schemaLocation` hint "is ignored" and the instance
/// "must conform to" the collection schema, but under exactly that prescribed
/// behavior the instances are valid (libxml2 validates schA2.xml against its
/// `a.xsd`, and schA5.xml against its `a.xsd`+`b.xsd` collection), so the
/// `invalid` expectation contradicts the group's own documentation. Reaching
/// `invalid` requires letting the instance's hint override a schema already
/// loaded for that namespace, which XSD 1.0 §4.3.2 makes optional (strategy 1:
/// "do nothing ... because a schema ... is already known") and which would be a
/// direct false positive for an IDE (an instance file forcing rejection against
/// the project's own schema). PureXML's policy (honor hints but keep the
/// collection's declarations on conflict) is conformant. Confirmed by an
/// adversarial analysis that separated these from schA7.i, whose `invalid` IS
/// spec-grounded (a genuine policy deviation, left in the count).
///
/// - `mgO013.i`: a `redefine` whose group `all{e1(1,1)}` redefines a base
/// `all{e1(0,0)}` without referencing itself. `src-redefine` clause 6.2.2 makes
/// that a restriction obligation, and `all{e1(1,1)}` is not a valid restriction
/// of `all{e1(0,0)}` (Occurrence Range OK: derived max 1 > base max 0), so
/// enforcing it rejects the schema, contradicting the `valid` schemaTest and
/// producing a valid-schema-rejected false positive. Yet §4.2.2 makes the type's
/// `group` reference resolve to the redefined `all{e1(1,1)}`, against which the
/// single-`e1` instance is valid. The `valid` schema and `invalid` instance
/// expectations are mutually exclusive; libxml2 accepts both. Confirmed by an
/// adversarial analysis that found no third path.
///
/// - `particlesZ001.i`: a `complexContent` restriction `Derived` of `Base`, with an
/// instance valid against `Derived`'s own content model. cvc-complex-type (§3.4.4)
/// validates an instance against the element's own `{content type}` and never
/// re-runs Particle Valid (Restriction) / RecurseAsIfGroup (§3.9.6, a schema-
/// assembly constraint), so schema-valid + instance-invalid is unreachable. The
/// manifest itself labels the governing rule ambiguous; libxml2 accepts both.
/// Reaching `invalid` would require rejecting the schema (a valid-schema-rejected
/// false positive) and would still fail the suite's own `valid` schemaTest.
static let specDivergentInstances: Set<String> = [
"anyURI_a004_1339.i", "schA2.i", "schA5.i", "mgO013.i", "particlesZ001.i",
]
/// Schema tests excluded from the counts for the same reason: the expectation is
/// not authoritative. Each entry must name the settled expectation it is jointly
/// unsatisfiable with; this is never a place to hide a real deviation.
///
/// - `particlesZ008`: byte-for-byte the same construct as `elemZ026` (an abstract
/// head element, a member via `substitutionGroup`, a base type whose sequence
/// references the head, and a restriction that references the member instead),
/// yet Z008's settled expectation is `valid` while elemZ026's settled expectation
/// is `invalid`, both accepted on the same date. One expectation must be wrong,
/// and the spec decides which: NameAndTypeOK (section 3.9.6, Elt:Elt) clause 1
/// requires the declared {name}s and {target namespace}s to be the same, and no
/// clause grants substitution an allowance in particle restriction, so the member
/// swap is invalid and Z008's `valid` has no spec text behind it (its annotation
/// asserts "should be valid" without citing one). The schema is still compiled
/// so the group's instance tests run; only its verdict is excluded.
static let specDivergentSchemas: Set<String> = [
"particlesZ008",
]
}

View File

@ -50,7 +50,16 @@ struct XSTSSuiteTests {
/// or of one of its members still passes. (Ik025/Ik027, where the restricting
/// type derives from a union member, are accepted per cos-st-derived-ok 2.2.4,
/// a disclosed divergence from their contested XSTS `invalid` expectation.)
private let knownSchemaInvalidAccepted = 8
/// 8 -> 6: NameAndTypeOK clause 1 now pairs the DECLARED element references. The
/// substitution-group expansion rewrites the compiled term (an abstract head is
/// dropped, members join a synthetic choice), so the checker saw the expanded
/// names and a restriction that swapped a head reference for a member reference
/// looked name-identical. `Particle.declaredReference` preserves the
/// pre-expansion identity and the restriction check rejects a declared-reference
/// mismatch (elemZ026, particlesV020). particlesZ008, the identical construct
/// with a settled `valid` expectation contradicting elemZ026's settled `invalid`,
/// is reclassified into `specDivergentSchemas` below.
private let knownSchemaInvalidAccepted = 6
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
@ -99,64 +108,6 @@ struct XSTSSuiteTests {
/// `invalid` is unsatisfiable jointly with its own `valid` schemaTest, and the
/// manifest itself labels the governing restriction rule ambiguous.
private let knownInstanceInvalidAccepted = 4
/// Instance tests excluded from the counts because their expected validity is not
/// authoritative, mirroring how the suite's own `status="queried"` entries are
/// skipped. Each must carry a spec-grounded justification; this is never a place to
/// hide a real deviation.
///
/// - `anyURI_a004_1339.i`: the suite pairs a `valid` schemaTest with an `invalid`
/// instanceTest, but the two are mutually inconsistent. The schema's `include`,
/// `import`, and `redefine` have unresolvable schemaLocations; XSD 1.0 §4.2.1
/// makes an unresolvable `include` "not an error" (no inclusion performed), the
/// `import` likewise, and the empty `redefine` imposes no resolution requirement
/// (`src-redefine` clause 1 gates that on non-annotation children), so the schema
/// is conformantly valid (agreeing with the schemaTest). The only components the
/// instance exercises (`root`, `bar`, `st`, `att`) are fully locally defined, and
/// every instance value is byte-identical to one of the type's six anyURI
/// `enumeration` members, so a strict assessment of the document element is
/// valid. There is no spec path to valid-schema + invalid-instance. Confirmed by
/// two independent adversarial analyses; libxml2 is no authority here (it
/// non-conformantly fails to compile the schema on the unresolvable include).
/// Matching the suite would mean rejecting a valid instance whenever an include
/// cannot be resolved, a false positive an IDE editing loop hits constantly.
///
/// - `schA2.i`, `schA5.i`: schema-location precedence. Each group's documentation
/// states the inline `xsi:schemaLocation` hint "is ignored" and the instance
/// "must conform to" the collection schema, but under exactly that prescribed
/// behavior the instances are valid (libxml2 validates schA2.xml against its
/// `a.xsd`, and schA5.xml against its `a.xsd`+`b.xsd` collection), so the
/// `invalid` expectation contradicts the group's own documentation. Reaching
/// `invalid` requires letting the instance's hint override a schema already
/// loaded for that namespace, which XSD 1.0 §4.3.2 makes optional (strategy 1:
/// "do nothing ... because a schema ... is already known") and which would be a
/// direct false positive for an IDE (an instance file forcing rejection against
/// the project's own schema). PureXML's policy (honor hints but keep the
/// collection's declarations on conflict) is conformant. Confirmed by an
/// adversarial analysis that separated these from schA7.i, whose `invalid` IS
/// spec-grounded (a genuine policy deviation, left in the count).
///
/// - `mgO013.i`: a `redefine` whose group `all{e1(1,1)}` redefines a base
/// `all{e1(0,0)}` without referencing itself. `src-redefine` clause 6.2.2 makes
/// that a restriction obligation, and `all{e1(1,1)}` is not a valid restriction
/// of `all{e1(0,0)}` (Occurrence Range OK: derived max 1 > base max 0), so
/// enforcing it rejects the schema, contradicting the `valid` schemaTest and
/// producing a valid-schema-rejected false positive. Yet §4.2.2 makes the type's
/// `group` reference resolve to the redefined `all{e1(1,1)}`, against which the
/// single-`e1` instance is valid. The `valid` schema and `invalid` instance
/// expectations are mutually exclusive; libxml2 accepts both. Confirmed by an
/// adversarial analysis that found no third path.
///
/// - `particlesZ001.i`: a `complexContent` restriction `Derived` of `Base`, with an
/// instance valid against `Derived`'s own content model. cvc-complex-type (§3.4.4)
/// validates an instance against the element's own `{content type}` and never
/// re-runs Particle Valid (Restriction) / RecurseAsIfGroup (§3.9.6, a schema-
/// assembly constraint), so schema-valid + instance-invalid is unreachable. The
/// manifest itself labels the governing rule ambiguous; libxml2 accepts both.
/// Reaching `invalid` would require rejecting the schema (a valid-schema-rejected
/// false positive) and would still fail the suite's own `valid` schemaTest.
private static let specDivergentInstances: Set<String> = [
"anyURI_a004_1339.i", "schA2.i", "schA5.i", "mgO013.i", "particlesZ001.i",
]
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {
@ -228,6 +179,12 @@ struct XSTSSuiteTests {
guard !hrefs.isEmpty else { continue }
schemaExpectedValid = expectedValidity(schemaTest) != "invalid"
let compiled = compileSchemaDocuments(hrefs, directory: directory, name: name, into: &counters)
if Self.specDivergentSchemas.contains(name) {
// Excluded from the verdict counts (see the set's justification); the
// compile still happens so the group's instance tests run.
schema = compiled
continue
}
switch (schemaExpectedValid, compiled == nil) {
case (true, true):
counters.schemaValidRejected += 1

View File

@ -12,7 +12,7 @@ changelog entries and commit messages refer to that lost tracker.
## Current focus (priority order)
1. **[#4](https://codeberg.org/Mihaela/PureXML/issues/4)**: drive invalid
schemas accepted from 8 to 0. The remainder routes through
schemas accepted from 6 to 0. The remainder routes through
`ParticleRestriction.violation`'s deliberate under-approximation
(`docs/release-hurdles.md`, Part 1).
2. **[#5](https://codeberg.org/Mihaela/PureXML/issues/5)**: drive invalid
@ -35,7 +35,7 @@ together.
| Gate | Now | Issue |
|---|---|---|
| Valid schemas rejected | 0 | at floor, enforced by the ratchet |
| Invalid schemas accepted | 8 | [#4](https://codeberg.org/Mihaela/PureXML/issues/4) |
| Invalid schemas accepted | 6 | [#4](https://codeberg.org/Mihaela/PureXML/issues/4) |
| Valid instances rejected | 0 | at floor, enforced by the ratchet |
| Invalid instances accepted | 4 | [#5](https://codeberg.org/Mihaela/PureXML/issues/5) |

View File

@ -65,7 +65,8 @@ Schema side (10 cases):
| Case | libxml2 | Reading |
|---|---|---|
| `particlesZ030_d` | rejects | Reference-aligned bug, now fixed: a `simpleContent` restriction relaxing a base `required` attribute to `optional` (derivation-ok-restriction.2). invalid-schemas-accepted 10 -> 9. |
| `elemZ026`, `particlesHa161`, `particlesIg004`, `particlesIk011/025/027`, `particlesM034`, `particlesV020` | accept too | Stricter-than-libxml2: NameAndTypeOK / particle-restriction edges (union membership, element-block superset) that libxml2 does not implement. Pursue only if Xerces-level strictness is wanted. |
| `elemZ026`, `particlesV020` | accept too | Closed 2026-07-05: NameAndTypeOK clause 1 now pairs the DECLARED element references (`Particle.declaredReference` preserves the pre-substitution-expansion identity), so a restriction that swaps a head reference for a substitution-group member is rejected. Stricter than libxml2, spec-grounded (section 3.9.6 Elt:Elt clause 1 has no substitution allowance). The identical construct `particlesZ008` carries a settled `valid` expectation contradicting elemZ026's settled `invalid`; Z008 is reclassified as spec-divergent (excluded), with the full adjudication in `Tests/XSTSSuiteTests.swift`. |
| `particlesHa161`, `particlesIg004`, `particlesIk011/025/027`, `particlesM034` | accept too | Stricter-than-libxml2: NameAndTypeOK / particle-restriction edges (union membership, element-block superset) that libxml2 does not implement. Pursue only if Xerces-level strictness is wanted. |
| `schN12` | (multi-document) | Cross-namespace composition; not triaged in isolation. |
Instance side (sampled): `idZ010`, `mgO013`, `particlesZ001`, `reT17`, `reT38`