PureXML/CHANGELOG.md
Mihaela Mihaljevic d359208ff6 perf(serialize): precompute the attribute quote, bulk-copy comment/PI escaping
Two profile-guided serialize passes (#139, docs/benchmarks.md passes 29-30).
The attribute writer now precomputes the quote character as a String once
per element instead of interpolating a Character into every attribute,
avoiding the grapheme-breadcrumb machinery that repeats twice per attribute
for a value that never changes within an element. Escaping.comment and
processingInstruction's escaping fallback (taken whenever a comment has a
hyphen or PI data has a '?', a case pass 8 could skip past but not speed up)
is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a
value with one rare trigger no longer pays a full character-by-character
rebuild.

Both are same-observable-behavior changes. The quote change is byte-
identical by construction. The comment/PI rewrite is verified against a new
differential suite that reimplements the original character-loop as an
independent oracle and checks adjacency/trailing/combining-mark cases plus
two 20000-case seeded fuzzes, since a byte match inside a combining-mark
cluster is not, on its own, the bare trigger character. Full local suite
(1925 tests) unchanged.

Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item
corpus; pass 30 ~2x on a dedicated comment/PI micro-benchmark (the standard
corpus has no comment/PI content, so real documents with copyright banners
or hyphenated comment prose see this, not the headline ratio). A same-
session multi-append restructuring attempt measured a clear ~6% regression
and was reverted; a suspected hasInlineContent double-scan was profiled at
~0.2% of samples and not worth a pass. Both outcomes are recorded in
docs/benchmarks.md rather than dropped.
2026-07-15 04:05:29 +02:00

366 KiB

Changelog

All notable changes to PureXML are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Fixed

  • An element restricting a NON-repeating base choice ({max occurs} = 1) is now checked by the literal RecurseAsIfGroup pairing (XSD 1.0 section 3.9.6): the derived element, with its own occurrence range, must be a valid restriction of some branch. The effective-total-range approximation is kept only for a repeating base choice, where the particlesZ001 ambiguity actually lives (occurrence counts accumulated across the choice's own repetitions, which the literal rule cannot express); at {max occurs} = 1 no such accumulation exists and the spec text governs. Closes XSTS particlesHa161 (an optional derived element is not a pointwise restriction of a required branch) and particlesM034 (a derived d1+ cannot map into a branch (d1*, d2+) whose required d2 is unmapped and not emptiable; the settled-valid twin particlesM035, d2+ over the same branch, still passes), taking invalid-schemas-accepted 6 -> 4; stricter than libxml2, which implements neither edge. Separately, particlesIg004 is reclassified as a spec-divergent schema test (4 -> 3): section 3.3.2 maps block="#all" and the explicit substitution extension restriction list to the same {disallowed substitutions} set, so NameAndTypeOK clause 5's superset requirement holds and the invalid expectation has no spec path (jointly unsatisfiable with the settled Ig001/002/003 and Ig005 expectations). The remaining three schema deviations (Ik025, Ik027, schN12) are documented policy divergences in docs/xsts-deviations.md, including why src-redefine 6.2.2 cannot be enforced uniformly (mgO013's settled-valid schema violates it). Valid buckets are unchanged at zero.

  • 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

  • Two serialize-path optimization passes (#139, docs/benchmarks.md passes 29-30), found by sample-profiling the serialize loop: the attribute writer precomputes the quote character as a String once per element instead of interpolating a Character into every attribute (a Character interpolation goes through the string's grapheme-breadcrumb machinery, work repeated twice per attribute for a value that never changes within an element), and Escaping.comment/processingInstruction's escaping fallback (taken whenever a comment has a hyphen or PI data has a ?, a case pass 8 could only skip past, not speed up) is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a comment or PI value with one rare trigger no longer pays a full character-by-character rebuild. Both are same-observable-behavior changes: the quote change is byte-identical by construction (a Character and its single-scalar String render identically); the comment/PI rewrite is verified against a new differential suite (Tests/CommentAndPIEscapingDifferentialTests.swift) that reimplements the original character-loop as an independent oracle and checks hand-picked adjacency/trailing/combining-mark cases plus two 20000-case seeded fuzzes, since a byte match inside a combining-mark cluster is not, on its own, the bare trigger character. Full local suite (1925 tests) unchanged. Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item corpus (clean, two pairs); pass 30 ~2x on a dedicated comment/PI micro-benchmark (no comment/PI content exists in the standard corpus, so it does not move the headline ratio there, but real-world documents with copyright banners or hyphenated prose in comments exercise it directly). A same-session multi-append restructuring attempt (splitting a single interpolated output += into several direct appends) measured a clear ~6% regression and was reverted rather than landed; a suspected hasInlineContent double-scan of an element's children was profiled and found to contribute ~0.2% of samples, not worth a pass. Both outcomes are recorded in docs/benchmarks.md rather than silently dropped.

  • A fourth parse-path optimization pass (#139, docs/benchmarks.md pass 28): scanAttributes' duplicate-name check reuses one stored scratch Set<String> (cleared with removeAll(keepingCapacity: true) per element) instead of allocating fresh native Set storage for every element with attributes. Safe because scanAttributes cannot re-enter itself; duplicate detection, recovering-mode behavior, and thrown errors are unchanged (full local suite and full W3C XSTS pass unchanged). Measured ~2-4% on parse with an interleaved same-state A/B (both patched runs beat both baseline runs), the protocol chosen because the session had concurrent machine load. Two companion outcomes from the same profiling round, recorded rather than hidden: a children-capacity reservation candidate measured neutral and was reverted per the benchmarks log's ship-only-measured-wins rule, and the TreeNode weak-parent side-table cost (~9% of parse) is documented in docs/benchmarks.md as a structural finding that needs a design decision, not an optimization pass, since parent's nil-after-release semantics are documented public API.

  • A third parse-path optimization pass (#139, docs/benchmarks.md pass 27), found by sample-profiling scanContentMarkup() in isolation after passes 25-26 (it dominates parse time, ~46%, since it inlines scanStartTag/scanAttributes/scanAttributeValue/NamespaceContext.enterElement): scanAttributeValue's opening and closing quote checks now use the byte fast path (peekByte() + reader.consume(_:)/a byte peek before the Character peek) instead of the Character path, for every attribute value in the document; NamespaceContext.checkExpandedNames skips its Set<String> allocation and key-string building entirely when fewer than two attributes carry a namespace URI (the check can only ever throw on a collision between two such attributes, so 0 or 1 can never trigger it, and this is the overwhelming majority of elements); and EntityDecoder's raw.contains("&") guard is now a UTF-8 byte scan instead of a Character-based one, the same shape as NamespaceContext.checkNameShape's colon check in pass 26. All three are same-observable-behavior changes, verified against the full local suite (1858 tests, 6 new covering the quote-handling edge cases directly: empty value, single-quoted, a quote-kind nested inside the other, and unterminated-at-end-of-input, plus byte/Character-path agreement for each) and the full W3C XSTS suite (14383 groups), both unchanged. Measured parse ~6.00x -> ~4.5-4.75x versus libxml2 at the 20000-item/4.2MB corpus, best of 5; see docs/benchmarks.md for the full before/after and reasoning.

  • Two parse-path optimization passes (#139, docs/benchmarks.md passes 25-26), found by sample-profiling a 42MB generated corpus after re-measuring the parse/libxml2 ratio fresh rather than trusting the doc's last-recorded numbers: scanAttributeValue's closing-quote check now compares the already-buffered peek directly instead of re-deriving the same answer through Reader.consume's general String-iteration path (which allocated a fresh String(quote) per attribute value in the document), and NamespaceContext.enterElement scans a name's UTF-8 bytes instead of its Characters to find an illegal embedded colon, and skips its per-element attribute-array allocation entirely when no attribute actually needs namespace resolution (the common all-unprefixed-attributes case). Both are same-observable-behavior changes (verified with the full local suite, 1852 tests, and the full W3C XSTS suite, 14383 groups, both unchanged) that measured parse ~6.59x -> ~6.00x versus libxml2 at the 20000-item/4.2MB corpus, best of 5; see docs/benchmarks.md for the full before/after and the reasoning behind each change.

  • Update Codeberg links after the NodetteHQ transfer, including README badges, support and roadmap issue URLs, and the XSLT system-property('xsl:vendor-url') result.

  • Rebuild the issue tracker on Codeberg after the GitHub tracker loss: the production-readiness epic, both XSTS gate trackers, the performance epic, and the Xalan burn-down are recreated with current dated statuses, and docs/roadmap.md and README.md now cite the new numbers. The stale baseline figures in both documents are corrected to the enforced ratchet in Tests/XSTSSuiteTests.swift (0 / 8 / 0 / 4); shipped items (located diagnostics, the schema differential harness) no longer show as open.

[0.4.8] - 2026-07-05

Changed

  • A generated-value facet differential against libxml2 (FacetValidationDifferentialTests, opt-in via FACET_DIFF and xmllint) exercises the datatype and facet value space, the largest correctness surface the determinism and instance-content differentials do not touch: for each generated simple type restricting a built-in (xs:int, decimal, double, string, token, boolean, date, dateTime, nonNegativeInteger) by a subset of its facets (min/maxInclusive/Exclusive, length/min/maxLength, totalDigits/fractionDigits, pattern, enumeration, whiteSpace), it validates a pool of values chosen at and around the facet boundaries with PureXML and with libxml2 and flags every disagreement, asserting the cardinal rule (zero values PureXML rejects that libxml2 accepts). Over about ten thousand values it found one false positive, the NaN enumeration fixed below, and otherwise zero; the residual false negatives are contested datatype readings (whitespace collapse, NaN against a range facet) where PureXML matches the XSD spec and this libxml2 build is stricter. Test-only.

  • Compiling a schema with a long <xs:simpleType> restriction chain (S0 restricting S1 restricting S2, and so on) is no longer near-cubic in the chain length, so a deep chain no longer stalls the compiler, a keystroke-time interactive-safety hang on hostile or generated input. Such a chain is flat in the document, so the same base type was resolved over and over: the named simple types were resolved by an N-pass fixpoint that re-resolved every type on every pass, and the facet-applicability variety check re-scanned the schema's children to find each base at every link of every type's chain. Named simple types are now resolved once in dependency order (base before derived), and the variety check resolves a base through a precomputed name map in constant time and memoizes each type's variety, so a chain is walked once rather than per type. A 600-link restriction chain that took about 4.4 seconds to compile now takes about 30 milliseconds. Behaviour-preserving: the resolved types and the emitted findings are identical, verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite. The analogous repeated re-scan in attribute-group reference chains is milder and remains, tracked.

  • Compiling a schema with a long <xs:complexType> simpleContent extension chain (T0 extending T1 extending T2, and so on) is likewise no longer near-cubic. Each of the N types re-walked its whole base chain to resolve the simple type its simpleContent derives from, the same flat-document re-resolution. The resolved simple type of a complex type's simpleContent is now memoized by name (SimpleContentMemo, a reference type that survives the value-type context copies), so a chain is walked once rather than per type. A 400-link chain that took about 23 seconds to compile now takes about 1.2 seconds; past the content-model expansion-depth bound (512) such a chain is rejected with a located error as before. Behaviour-preserving: the resolved type is intrinsic to the name for an acyclic chain (a derivation cycle is rejected elsewhere), verified conformance-neutral over the full W3C XSTS corpus in release. The attribute flattening that accumulates each type's attributes along the chain is inherently quadratic in the bounded chain length and is tracked.

  • A generated-instance validation differential against libxml2 (InstanceValidationDifferentialTests, opt-in via INSTANCE_DIFF and xmllint) exercises the per-instance content matcher (the graph-structured-stack ContentRTNMatcher now in production) over the open input space: for every generated, both-engines-compile schema it validates many generated <doc> instances with PureXML and with libxml2 and compares the verdicts. Where the schema-compile determinism check has its own differential, this characterises instance validation, the cardinal rule's instance half, beyond the finite XSTS corpus. It asserts the cardinal rule (PureXML never rejects an instance libxml2 accepts) and reports false negatives for triage. Over 6568 generated validations (1357 both-valid, 5211 both-invalid) across sequences, choices, all groups, group references, occurrences, and wildcards it found zero false positives and zero false negatives, independent confirmation that the matcher agrees with libxml2. Test-only; no shipped behaviour change.

  • The graph-structured-stack content matcher's runtime resource use now has a proven, asserted bound: ContentRTNMatcher exposes peakLiveConfigurations (the largest live-configuration set in any closure) and gssNodeCount (the total graph-structured-stack nodes built), and ContentRTNDifferentialTests asserts they stay linear in a shared-group chain's depth (measured gss = K + 2, peak configurations = 3K + 3), where an explicit return stack would reach O(distinct stacks). The bound is checked by count, not by a machine-dependent wall-clock timeout, so a regression to super-linear fails the test rather than only slowing down. Test-and-metadata only; no shipped behaviour change.

  • A generated-schema determinism differential against libxml2 (DeterminismDifferentialTests, opt-in via DETERMINISM_DIFF and xmllint) fuzzes content models, elements, group references, choices, sequences, all groups, occurrences, and wildcards, and compares PureXML's Unique Particle Attribution accept/reject verdict to the reference's. The XSTS corpus is finite and did not exercise the multiply-referenced-group overlap that was a real gap; this characterises the UPA check over the open shape space instead, asserting the cardinal rule (zero schemas PureXML rejects as ambiguous that libxml2 accepts) and reporting false negatives for triage. It found zero false positives and a genuine, named under-rejection: a repeating model whose optional trailing particle and leading particle share a name ((c, b?, c?)*), where the compositional check forms the internal gap before the enclosing repetition's loop edge, so the combined decision set is never checked. Test-only; no shipped behaviour change.

  • A model Group now carries an internal definitionID, a stable identity stamped on each <xs:group> expansion (from the existing per-compile memo key) and shared by every reference to it. It is metadata only (Group is not Equatable/Hashable, so it perturbs no comparison) and has no consumer yet: it is the first step of replacing the per-instance content-model build's per-reference group inlining (the 2^K doubling-chain hang) with the graph-structured-stack matcher designed in docs/design/counted-content-automaton.md. No behaviour change; the XSTS buckets are unchanged.

  • The counted content matcher gains a shared-group form: ContentRTNBuilder compiles a particle to a Recursive Transition Network in which each definitionID group is compiled once and reached by a call, and ContentRTNMatcher simulates it with a graph-structured stack (configurations are (state, counters, gssNode); a call saves the caller's counters on an interned GSS node and a return restores them, so a multiply-referenced group never mixes counter state across its callers). This replaces the inlining matcher's 2^K state and reset-action blow-up with an O(groups) build: a depth-24 doubling chain (2^24 inlined states) compiles to under 480 states in under a millisecond. The matcher exposes the full surface the content-model validator reads: matchesWhole, matchedParticles, and the streaming startStates/step/admissible/isComplete used for located diagnostics and the "expected a, b" hints. It is verified against the trusted inlining matcher (ContentNFA) as the oracle by a differential gate (ContentRTNDifferentialTests) that locks not only the accept verdict and the matched particle but the located rejection index, the admissible expectation set at every prefix, and isComplete, over: the hand-written model cases, the named-over-wildcard tie-break, the shared-group doubling chains that exercise call and return, and a 4000-case fuzz of nested models with occurrences, wildcards, and group references stamped the way the parser stamps them (a unique id per distinct group, a shared id only for genuinely shared structure, some anonymous inline groups). It is built alongside the inlining matcher and is not yet wired into validation, so there is no behaviour change and the XSTS buckets are unchanged; the switch-over is gated on this differential plus XSTS neutrality.

  • Content-model validation and completion now run on the graph-structured-stack matcher: the three call sites that built the inlining automaton per validated element, ComplexValidatorContentModel.sequenceStructureErrors (located structure errors), ComplexValidator.validateParticle (per-child particle attribution), and XSDCompletions (the acceptable-next labels), now build ContentRTNBuilder and run ContentRTNMatcher. This removes the last per-instance 2^K content-model blow-up: a schema whose nested <xs:group> references inlined to 2^K positions (the g{i}=(g{i+1},g{i+1}) doubling shape) no longer makes each keystroke-time validation of an element exponential in the nesting depth; the automaton is now built in O(groups). Verified conformance-neutral on the full W3C XSTS corpus in release (valid-schemas-rejected and valid-instances-rejected stay 0, invalid-schemas-accepted and invalid-instances-accepted unchanged at 8 and 14) and behaviour-identical on the located diagnostics and completions by the surface differential above. ContentNFABuilder/ContentNFA are retained as the differential oracle.

  • The schema-validity differential harness (XSTSDifferentialTests, the opt-in libxml2 characterisation for production-readiness stopper 3) now reports each disagreeing schema by its path relative to the corpus root, not its bare basename. The same basename recurs across subdirectories (Facets/Schemas/gMonth_maxInclusive001.xsd, which carries value="" and is correctly rejected, versus Facets/gMonth/gMonth_maxInclusive001.xsd, which carries the contested 2001-REC --MM-- form), so a basename-only entry hid which file actually disagrees. The header now also records the triage of the "libxml2 stricter" queue: it is dominated by libxml2 over-strictness (a maxOccurs like 999999999999999 is a valid unbounded-precision integer libxml2 rejects on its machine-int limit; an unresolvable include/import it fails to load) and contested datatype-version readings (gMonth --MM--, accepted to keep valid XSTS instances; anyURI strictness), not clean false negatives. Characterisation only; no shipped behaviour changes.

Fixed

  • An unprefixed xsi:type value now resolves in the instance's namespace context rather than matching a namespaced type by local name alone. xsi:type is a QName: a prefix binds through that prefix, an unprefixed name through the in-scope default namespace, and with no default namespace an unprefixed name is in no namespace. PureXML stores each named type under both its namespaced key and a bare local-name alias (for lenient lookup), so an unprefixed xsi:type="Test" matched a {targetNS}Test through the alias even when the instance had no default namespace, wrongly typing an otherwise-undeclared root and accepting the document. The resolver now keys an unprefixed xsi:type to the in-scope default namespace (none here), so xsi:type="Test" names no-namespace Test, which does not exist; the root element then has neither a declaration nor a usable type and the instance is invalid (W3C XSTS targetNS00101m; libxml2 rejects it identically). A prefixed xsi:type and an unprefixed one under a matching default namespace are unaffected. Verified over the full W3C XSTS corpus in release: invalid-instances-accepted 6 -> 5, valid-instances-rejected held at 0 across all 14383 groups, and the full suite passes.

  • An XSD key/keyref/unique field that selects a node whose declared type is not simple is now rejected (cvc-identity-constraint.3), rather than wrongly accepted. The XSD identity-constraint rule requires each field to identify a node of simple type; a field that reaches an element declared with no type (the ur-type anyType) or an inline complex type without simple content does not, so the instance is invalid (the reference, libxml2, rejects it identically). PureXML accepted such instances because its identity validator could resolve a field target's type only for a global element reached by a direct-child name, not for a local element reached through a path (the failing W3C XSTS case idZ010 nests anyType fields several levels deep). A compile-time pass (XSDParser.identityNonSimpleFieldKeys) now resolves each field's target element through its selector and field paths, by element-name steps, and records the fields whose declared target is provably non-simple; the validator rejects those at runtime unless the instance gives the node a simple type through xsi:type (cvc reads the node's governing type, so a text-only anyType element with xsi:type="xsd:int" is still a valid field). The resolution is conservative: any wildcard, descendant axis, predicate, ref, or named non-builtin type leaves the field unflagged, so a valid field is never rejected. Verified over the full W3C XSTS corpus in release: invalid-instances-accepted 8 -> 7 (idZ010 now rejects), valid-instances-rejected held at 0 across all 14383 groups (no false positive introduced), and the full suite passes.

  • The XSD regex escapes \i and \c now use the XML 1.0 Second Edition name classes (Appendix B), not the parser's Fifth Edition Name production. XSD 1.0 (Datatypes Appendix F) defines \i as the characters that may begin an XML Name (Letter | '_' | ':') and \c as the characters that may appear in one (NameChar), citing the XML 1.0 Second Edition productions, which are the enumerated Unicode-2.0-era character classes of XML 1.0 Appendix B. \i/\c were wired to PureXML.Parsing.XMLCharacter, which correctly implements the Fifth Edition production used to parse live XML, but the two genuinely differ: the Fifth Edition admits whole blocks where the Second Edition has gaps, so \c over-accepted, for example the combining mark U+0346 (COMBINING BRIDGE ABOVE), which Appendix B's CombiningChar excludes (its first range ends at U+0345). A new XSDNameCharacter, transcribed clean-room from the Appendix B grammar (identical across editions one through four) and stored as sorted code-point ranges with binary-search membership, now backs both escapes; the parser's own name check is untouched. The two range tables are verified to reproduce the W3C XSTS \i/\c enumerations (reZ005, reZ006, 34516 and 35122 code points) exactly and in both directions, so there is no false-positive surface. Verified over the full W3C XSTS corpus in release: invalid-instances-accepted 12 -> 11 (reZ006i, whose value is U+0346, now rejects), with both false-positive buckets at 0. Found by triaging the invalid-instance tail to a single boundary code point (U+0345 valid vs U+0346 invalid) and reading the XML 1.0 Appendix B grammar.

  • The Ethiopic digits U+1369..U+1371 (ETHIOPIC DIGIT ONE..NINE) now match \d in an XSD pattern and not \D. XSD 1.0 fixes the regular-expression repertoire at Unicode 3.2 (Datatypes Appendix F), where these nine code points are general category Nd and each carries a decimal value; a later Unicode reclassified them to No. XSDCategory derives \d (== \p{Nd}) from the running platform's category, pinned to 3.2 by an assigned-in-3.2 gate plus a per-code-point override table, but the override table was missing these nine, so \d used the modern No and rejected them. This was a two-directional bug: a valid instance whose \d-constrained value was an Ethiopic digit was wrongly rejected (the cardinal rule: never reject a valid value), and a \D-constrained value of one was wrongly accepted. They are now pinned to Nd, which is the complete set of code points that were Nd at 3.2 and are not today (the reverse direction is empty: every code point that became Nd later was unassigned at 3.2 and so already reports Cn, verified by diffing the 3.2 and current Unicode Nd sets), so \w is unaffected (an Ethiopic numeral is category N either way, neither punctuation, separator, nor other). Verified over the full W3C XSTS corpus in release: invalid-instances-accepted 14 -> 12 (reT17, reT38, the \D cases, now reject), the matching \d valid cases (reS17, reS38, reZ004) no longer need their suspect-test exclusion and pass as ordinary agreements, and the two false-positive buckets stay 0. Found while triaging the invalid-instance tail against libxml2 (which rejects reT17/reT38, the reference-aligned reading) back to the authoritative UnicodeData-3.2.0; a prior note that had treated these as a disputed spec-divergence was the wrong call and is corrected.

  • A value of NaN is no longer wrongly rejected by an xs:double or xs:float enumeration that lists NaN. Value-space equality (OrderedValue.==) compared with IEEE equality, under which NaN != NaN, so a NaN value never matched a NaN enumeration member and was rejected, a false positive (the cardinal rule: never reject a valid value). The comparison now treats NaN as identical to itself, the value-space identity the reference (libxml2) and XSD 1.1 use throughout, so <v>NaN</v> validates against a type restricting xs:double by <xs:enumeration value="NaN"/>; INF/-INF already matched. This equality also backs the key/unique identity constraints and fixed, where the same identity holds (verified against xmllint): two NaN-valued unique fields collide as duplicates, and fixed="NaN" matches NaN. The ordering facets (minInclusive and friends) use the order relation, in which NaN stays incomparable, and are unaffected. Found by the new generated-value facet differential against libxml2, with the key/fixed behaviour confirmed after an adversarial critic flagged the shared comparison; the four W3C XSTS buckets held and the full suite passes, with regressions in SchemaDoubleEnumerationTests.

  • A long <xs:attributeGroup ref> chain (ag0 referencing ag1 referencing ag2, and so on) no longer overflows the stack or hangs the compiler. Such a chain is flat in the schema document, so flattening a complex type's attributes through it recursed once per link, overflowing the stack past about six thousand links and running near-cubic before that (about 17 seconds at three thousand links). The attribute-group reference depth is now bounded by maximumContentModelExpansionDepth (512, the same bound the content-model expansion uses), since the cycle-guard visited set already counts the chain depth: a chain past the bound is rejected with a located error (attribute group reference nesting exceeds the maximum depth of 512) rather than crashed. Behaviour-preserving for every schema within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged) and by the full suite. Disclosed residual: a schema with very many distinct over-bound attribute-group chains still rejects in time quadratic in the bounded depth per group, because the attribute flattening is re-done per group; memoizing it, as the simpleContent resolution now is, is the linear follow-up.

  • The schema compiler no longer overflows the stack on a content model whose expanded depth is unbounded. Two constructs are flat in the schema document, so they escape the parser's element-nesting maxDepth (256) yet make a content model arbitrarily deep once compiled: a <xs:group ref> chain (the group definitions are siblings, not nested) and a complexContent derivation chain (B0 extends B1 extends B2, and so on, the type definitions also siblings). The particle compiler inlines each group reference and composes each derivation once per link, so a long chain recursed once per link until the stack overflowed while compiling a legal, if hostile, schema, a keystroke-time interactive-safety crash. The expanded depth is now bounded by a single counter (XSDContext.expansionDepth, incremented at each compositor level, group-reference expansion, and derivation step) against maximumContentModelExpansionDepth (512, twice the element-nesting limit: room for a maximally nested model plus as much again for expansion). A model past the bound is rejected with a located error (content model nesting ... exceeds the maximum depth of 512, or the derivation form), not crashed: a bound, not a band-aid. The bound is on total expanded depth, so it covers both a long chain of shallow links and a short chain of internally deep ones. Together with the determinism-check fix below, this closes the group-reference-chain crash on every compiler pass that recursed on the chain. Verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged; no valid schema newly rejected, the corpus's content models nesting far below the bound), the libxml2 determinism differential unchanged, and the full suite. An adversarial over-rejection critic confirmed the counter lives in a value-type context whose copies make it track depth, not width, so a wide-but-shallow model (hundreds of sibling particles) is never falsely rejected, and that the rejection is harvested into the schema's errors rather than silently truncating the model.

  • A deeply nested chain of <xs:group> references (g0 referencing g1 referencing g2, and so on) no longer overflows the stack in the Unique Particle Attribution determinism check. Such a chain is flat in the schema document, the group definitions being siblings rather than nested, so it is not bounded by the parser's maxDepth; the compositional check summarised each group lazily by recursing through the reference at its use site, so a long chain recursed once per link and overflowed the stack while compiling a legal (if hostile) schema, a keystroke-time interactive-safety crash on the primary platform. Group summaries form an acyclic graph (a circular group reference is rejected elsewhere), so they are now precomputed bottom-up in dependency order: every reachable group is summarised once, in an order where each group's own references are already cached, so the walk's group lookup is always a cache hit and recursion is bounded by a single group's model nesting (which maxDepth bounds), not by the chain length. A group summary is context-independent, so this only reorders the work; the set of decision sets it produces is identical and the accept/reject verdict does not depend on their order. Verified verdict-equivalent over the full W3C XSTS corpus in release (all four buckets unchanged: valid-schemas-rejected and valid-instances-rejected stay 0, invalid-schemas-accepted 8, invalid-instances-accepted 14), the generated-schema determinism differential against libxml2 unchanged (zero false positives, the same three named false negatives), and the full 1816-test suite. The particle-compiler pass that inlines a group reference, which this left recursing on the same chain, is now bounded as well (see the content-model expansion-depth entry above).

  • A repeating model whose optional trailing particle and leading particle share a name, (c, b?, c?)*, is now correctly rejected as a Unique Particle Attribution violation, as libxml2/Xerces reject it: after b the next c could be the trailing c? or the looped leading c, two distinct particles. The compositional determinism check formed the internal sequence gap before the enclosing repetition's loop edge was added, so the combined {trailing, leading} followpos was never checked (an invalid-schemas-accepted false negative the finite XSTS corpus did not contain). The sequence now carries each trailing end-item's internal suffix in its lastFollow, so a parent gap or the loop edge forms the end-item's full followpos. The carried suffix is always a subset of the true followpos, so the check only catches more genuine conflicts and never over-rejects: the non-repeated (c, b?, c?) and disjoint shapes like (a, b?)* stay valid. Found by the new generated-schema determinism differential against libxml2; verified over the full W3C XSTS corpus in release (valid-schemas-rejected/valid-instances-rejected stay 0, invalid-*-accepted unchanged) with an adversarial over-rejection critic finding no valid schema newly rejected over 47 shapes. The related ambiguity through two references to one group in a repeating sequence stays an under-rejection (the cached group identity cannot distinguish a looped from a trailing occurrence without per-occurrence tracking), named in DeterminismDifferentialTests.

  • Two references to the same <xs:group> that overlap ((g?, g?), (g | g), (g*, g)) are now correctly rejected as a Unique Particle Attribution violation, as the inline forms (x?, x?) / (x | x) already were and as libxml2/Xerces reject them. The compositional determinism check summarises each group once and reused the group's node identity across references, so the distinct-identity overlap test could not see that two references are two expanded occurrences, and PureXML accepted these models (an invalid-schemas-accepted false negative). It also let the per-instance content matcher run on such a non-UPA model, where a multiply-referenced nullable group is exponential: a depth-18 optional doubling chain was accepted and then hung instance validation, a keystroke-time interactive-safety hang on a schema PureXML itself accepted. The fix dedupes each decision set (first set, sequence gap, all cross-follow) by particle identity and treats a same-identity collision as the conflict, while the repetition self-loop (a*, (a*)*) keeps its non-flagging dedupe so a single particle looping is not misread as two occurrences. This also bounds the check: the deduped first/follow sets stay O(labels) instead of 2^K, so the same depth-40 chain is now rejected at compile in 11 ms and no instance is ever matched against it. A redefine self-extension (<extension base="T"> inside the redefinition of T) lists a type's own content twice through effectiveModelNodes; that assembly artifact is deduped before the walk so it is not read as a conflict (addB094, schZ007). Verified over the full W3C XSTS corpus in release: valid-schemas-rejected and valid-instances-rejected stay 0 and invalid-*-accepted are unchanged (the corpus does not exercise this shape in its pinned buckets), with an adversarial over-rejection critic finding no valid schema newly rejected across the group-overlap shape space.

  • XSTS invalid-schemas-accepted (#145): a particle restriction whose restricting element has a union-typed base element now applies Type Derivation OK (Simple) (cos-st-derived-ok) rather than structural member subsetting. The NameAndTypeOK type clause (§3.9.6) requires the restricting element's type to be validly derived from the base element's type; PureXML accepted a separately declared union whenever its member types were a subset of the base union's members, which is not the spec's derivation relation. The restricting type is now valid over a union base only if it is the base type, derives from it through a declared restriction chain, or (clause 2.2.4) validly derives from one of the union's member types. Catches particlesIk011 (a fresh union(integer) is not a restriction of union(integer, string)): invalid-schemas-accepted 9 to 8, with valid-schemas-rejected held at 0 (verified over the full XSTS; the change is scoped to the element-restriction path, so no attribute or simple-type derivation check moved). Disclosed divergence: particlesIk025/Ik027, where the restricting type derives from a union member, are accepted per cos-st-derived-ok clause 2.2.4, a contested reading their XSTS invalid expectation does not share.

Removed

  • Five unused internal schema-consistency wrappers (idAttributeErrors, consistencyErrors, postNamedTypeErrors, structureErrors, includeCompositionErrors) that returned [String] reason lists, along with the now-orphaned postCompileErrors(schema:context:containers:namedTypes:) overload and the SchemaCompileNamedTypes struct it consumed. None had callers; the XSDParser.consistencyErrors overload was shadowed by the distinct, still-live Validation.XSDSchema.consistencyErrors (a different signature returning ValidationError), which is why a name-only search first read it as used. The located-diagnostic API (the *Findings functions returning SchemaLocatedFinding, and Validation.XSDSchema returning ValidationError) superseded all of them. Removing them deletes dead code with no behavior change; the live *Findings and Validation.XSDSchema functions are unchanged. A stale doc-comment reference to the renamed userTypeValueConstraintErrors (now userTypeValueConstraintFindings) was corrected at the same time.

  • Two unused private helpers in XSLTNamespaceFixup (fixAttributeName and resolvedPrefix). The result-tree attribute fixup they implemented was inlined into fix(_ element:) and evolved past them (the inlined version additionally shadows a carried prefix through localPrefixes, which the helpers did not), leaving both with zero callers. Removing them deletes dead, diverged duplicate logic with no behavior change; the live inlined path is unchanged.

  • Three more dead file-scoped functions found by a private-scope sweep: selectNodes in XSLTTransformer (its sibling selectXPathNodes is the live one; a doc comment that referenced it was reworded), processingInstructionMatches in EvaluatorSteps, and parseExternalID in DoctypeScanner. All three are private/fileprivate with zero callers, so removing them is provably behavior-neutral (the compiler confirms).

  • Three more dead functions found by a repo-wide reference sweep: XSLTTransform.transformToNode (a transform-to-tree variant nothing called; the live transform returning a string is independent), and ComplexValidatorHelpers.matchesAll together with its now-orphaned helper label(of:). All had zero references; build and 1775 tests confirm behavior is unchanged.

Fixed

  • The XSLT evaluator is now iterative, so template recursion and result-tree nesting no longer consume the native stack (#356). A recursive named template whose depth is driven by source data (for example <xsl:with-param name="n" select="count(//x)"/>) could previously recurse without bound and crash the process (SIGBUS) on attacker-controllable input; and even an identity transform of a deeply-nested source overflowed the stack on a small (Swift-concurrency Task) stack. The mutually-recursive instantiate/evaluate/applyTemplates engine is replaced by an explicit work-stack driver (XSLTDriver): each instruction appends its produced items to a sink or pushes sub-work, with the rest of the body pushed first so document order is preserved; template application, the built-in rule's source descent, and result-element nesting all run on the heap. Only stylesheet-bounded helper evaluation (a variable's or parameter's result-tree fragment, an attribute set, an xsl:message body) re-enters through a nested driver call, whose native depth is bounded by the stylesheet's own nesting. The result-tree serialization walks the engine now exposes at depth (the namespace fixup, the text and string-value extractions, and the content-type-meta and CDATA-section rewrites) were made iterative as well. Deep recursion now succeeds on any stack (a 5000-level recursive template builds and serializes on the test Task stack); maxTemplateDepth is retained as runaway protection (an unbounded result tree would otherwise exhaust memory), now defaulting to 40000 and tripping XSLTError.recursionLimitExceeded only past that. Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance corpus and the in-repo XSLT/XPath suites pass identically (one previously-failing Xalan case, mdocs07, now passes and was de-baselined). maxTemplateDepth and PureXML.XSLT.defaultMaxTemplateDepth (added for the earlier stack-based guard) keep their public shape.

  • Serializing a tree as HTML (PureXML.HTML.serialize, the html output method's serializer) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested tree (as shallow as a couple of thousand deep on a Swift Task stack). The serializer now drives a deferred-close work stack (the same shape as the XML serializer and Canonicalizer): an element pushes a close step then its children reversed, and a raw-text element (script, style) flags its direct text children to emit verbatim. The output is byte-identical: the void-element, raw-text, comment, and escaping semantics are unchanged, with the HTML conformance corpus passing. A 50000-deep tree now serializes without crashing. (#350)

  • Processing xi:include elements (PureXML.XInclude.process) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested document, even one with no includes (as shallow as a couple of thousand deep on a Swift Task stack). The pass now drives an explicit work stack: each branch gathers its processed children into a reference accumulator, then a deferred build step assembles the node, while the xml:base, inclusion-chain depth, and cycle-detection visited set thread down exactly as before. An include splices its (or its fallback's) content in place by pushing the loaded nodes back onto the stack. Behavior is unchanged: the XInclude/XPointer conformance corpus and the fallback, xpointer, cycle, and xml:base cases pass. A 50000-deep document, with or without an include at its base, now processes without crashing. (#350)

  • The two Canonical XML transforms that still recursed on a document's depth are now iterative, so they no longer overflow the stack on a deeply-nested tree (#350). The C14N 2.0 sequential prefix rewrite walks the tree twice (a document-order pass that assigns one canonical prefix per namespace, then a rebuild pass); both now drive explicit stacks, the assign pass a pre-order walk and the rebuild pass a child-gathering accumulator, with the in-scope declarations and the root flag threaded down per element. The node-subset serializer (canonicalize(_:including:), the C14N form over a selected subtree) now drives the same deferred-close work stack as the main serializer, threading each element's in-scope and rendered namespace context, with an omitted element still passing its declarations to descendants. The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset corpora pass. A 50000-deep document now rewrites and subset-canonicalizes without crashing. (#350)

  • Canonicalizing a document (Canonicalizer, the C14N form used for XML signatures) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document (it crashed around 5000 deep). The serializer now drives a deferred-close work stack that threads each element's namespace context to its children. The canonical bytes are unchanged: the C14N 1.0/1.1 and exclusive conformance corpora pass. (#350)

  • Applying DTD attribute defaults to a document (parseApplyingInternalDTDDefaults, and the defaults pass behind DTD validation) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document. The tree is rebuilt bottom-up through a new shared iterative spine (Node.rebuildingBottomUp), which the other tree-to-tree transforms will adopt. (#350)

  • String-value extraction no longer recurses on a node's depth, so computing the string-value of a deeply-nested node no longer overflows the stack (it crashed as shallow as a couple of thousand deep on a Swift Task stack). TreeNode.textContent and the value-Node string-value behind XPath.Query.strings/Selection.stringValue now walk the subtree iteratively. (#350)

  • The value-Node string-value (behind XPath.Query.strings and Selection.stringValue) no longer includes the data of descendant comment and processing-instruction nodes in an element's string-value, matching XPath 1.0 (sections 5.1 and 5.5, where only text and CDATA contribute) and the tree-node path. A comment or PI node's own string-value is still its data (5.6, 5.7).

  • The value-returning XPath result API (PureXML.xpath, XPath.Query.evaluate/elements/strings) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value Node subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value Node of a deeper one by reference (copy-on-write); each tree node is projected once. //a over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348)

  • Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (Query.nodes, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (appendSubtree) is now iterative too, so it no longer recurses. Document order is unchanged.

  • Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345)

  • The immutable value Model.Node tree type is now depth-safe. An Element previously stored its children inline (the enum was indirect), so a Node's recursive layout made building, comparing, hashing, and releasing a deeply-nested tree recurse one native frame per level and overflow the stack (on a constrained stack such as a Swift Task, as shallow as a few thousand deep). Element now holds its children behind a copy-on-write reference (ElementStorage): the Node enum is no longer indirect, so it is a fixed-size value, and ==, hashValue, and release all walk the tree iteratively through explicit work stacks. Release flattens only storage it solely owns, so a copy-on-write sibling keeps its subtree. Value semantics are unchanged (copying a node still copies its children, lazily) and Element/Node remain Equatable, Hashable, and Sendable. A 50000-deep tree now builds, compares, hashes, and releases in a fraction of a second with no crash. (Completes the value-Node half of #341.)

  • The mutable Model.TreeNode tree type is now depth-safe: building one from a value Node, projecting it back with node, and releasing it all run in bounded native stack instead of recursing one frame per level. Construction (TreeNode(_:)) and projection (node/projectedChildren) drive explicit post-order work stacks, and deinit flattens its subtree into a heap worklist before releasing, so a deeply-nested tree no longer overflows the stack through a recursive chain of child releases. The teardown only flattens nodes it solely owns (isKnownUniquelyReferenced), so a child still referenced elsewhere keeps its subtree. A 60000-deep tree releases in well under a tenth of a second; behaviour for ordinary trees is unchanged. The validator walk and the XPath descendant axis (AxisNavigation.appendDescendants) were made iterative for the same reason. (Part of #341. The immutable value Node's own recursive release, and XPath result ordering/dedup, are still depth-bounded and tracked there.)

  • Rejecting a billion-laughs document is no longer exponential in nesting depth. The "does this entity produce markup" check (splitAtMarkupEntity / producesMarkup) recursed into every reference of every replacement without memoizing, so a chain lol8 -> 10x lol7 -> 100x lol6 -> ... was traversed O(references^depth) times. The check is now memoized per entity name (the property depends only on the fixed entity graph), making it O(entities). A classic eight-level billion-laughs with DTD processing enabled drops from about 18 s to about 0.6 s (now bounded by the expansion budget, not the traversal), with no change to which documents are accepted; a cyclic entity is still rejected.

  • The No-Recursion check (checkRecursion) no longer re-traverses shared sub-chains. It validated the reference graph with only an in-progress (grey) set, so a billion-laughs graph was walked O(references^depth) times; a markup-producing eight-level document took about 31 s to reject. It is now a three-colour DFS that records each fully-verified entity (black set) and skips it on revisit, one pass over the distinct edges. The same document drops to about 11 s; cyclic entities are still rejected and no accepted document changes. (The residual cost is the re-injection re-read of the growing remainder on the markup-inclusion path, where Reader.inject re-maps the unconsumed tail each cycle; tracked in #339.)

  • Entity and content expansion is no longer quadratic in the size of a single expansion. The parser's lookahead buffer consumed from the front with Array.removeFirst() and re-injected entity replacement text with insert(at: 0), both O(buffer), so a large internal entity (or a long run pushed back by reference inclusion) was consumed in O(n^2). The buffer now advances a head index, so front-consume is O(1), and re-injection reuses the space the consumed prefix freed, so a single large expansion is linear. An 800000-character internal text entity that previously took quadratic time now parses in about 0.6 s. Behavior, characters, and limits are unchanged; guarded by the standing suite. (A separate quadratic in deeply-nested entity references, where the content scanner re-reads the un-expanded remainder, is tracked as its own issue.)

  • A complex type with simple content that restricts a base may no longer relax a base required attribute to optional (XSD 1.0 cos-ct-derived-ok / derivation-ok-restriction.2). The attribute-use restriction check ran only over complexContent restrictions, so a simpleContent restriction that relaxed a required attribute (or added a non-base attribute, or widened the attribute wildcard) was accepted; it now runs over both derivation roots. Closes XSTS particlesZ030_d, which libxml2 also rejects, so the fix aligns with the reference rather than exceeding it. XSTS invalid-schemas-accepted 10 -> 9 with valid-schemas-rejected held at 0 (full archive). Guarded by SimpleContentAttrRestrictionTests.

  • xpointer(string-range(...)) no longer uses a quadratic substring search. It matched the search string at every position of each node's value and re-allocated the whole needle-width window per position (O(text * needle), a denial-of-service vector on a long node value with a near-matching needle); it now uses a linear-time Knuth-Morris-Pratt search with the failure function built once. The matches are unchanged: same non-overlapping, left-to-right occurrences, same offset/length. A near-matching needle over a 16000-character node drops from about 0.18 s to about 0.002 s (~100x), and the cost is now linear. Guarded by the new non-overlapping and backtracking cases in XPointerRangeTests.

  • The XSLT id() function no longer scans the whole document ID index on every call. It filtered the entire index by tokens.contains(...) (O(index) per call), so a stylesheet calling id() across a wide document was quadratic; it now looks each requested token up directly (O(tokens)). The result is unchanged: the same elements in document order. A stylesheet resolving 4000 id() references against a 4000-entry index drops from about 0.34 s to about 0.03 s (~13x), and the cost is now linear. (key() already used direct lookups.)

[0.4.7] - 2026-06-26

Fixed

  • XSD identity-constraint validation (xs:unique, xs:key, xs:keyref) is no longer quadratic over a wide list of targets. Two scans drove the cost: the duplicate check compared each new value tuple against every tuple already seen (a keyref likewise scanned every referenced key), and the error-location helper rescanned the parent's whole child list to position each target. The first now keys tuples in a Set by their raw string, which is sound because field equality reduces to a raw string == string comparison for whitespace-preserving lexical types (most ids); value-space types (numeric, boolean, date), whitespace-collapsing types, and QName-bearing values keep the exact pairwise comparison, so which documents pass is unchanged. The second positions each target through a per-parent, per-name index built once. Validating a 16000-item document against an xs:unique drops from about 4.0 s to about 0.12 s (~34x), and the cost is now linear. Guarded by SchemaIdentityScaleDuplicateTests (#312).

[0.4.6] - 2026-06-26

Fixed

  • xsl:number levels single and multiple are no longer quadratic when numbering a wide list. Each numbered node was ranked among its siblings by rescanning the parent's child list, so numbering n siblings was O(n^2). The rank now comes from a per-parent, per-count-pattern cache built once (using the transformer's match cache, so a count test is O(1)), making the numbering linear. The numbers are unchanged. Numbering 8000 list items with the default xsl:number drops from about 3.5 s to about 0.04 s (~90x). Guarded by XSLTNumberSingleTests.

Fixed

  • xsl:number level="any" is no longer cubic over a wide fan-out. Numbering a node walks the nodes that precede it in document order, and that walk found each preceding sibling by rescanning the parent's whole child list, so visiting k preceding siblings cost O(k^2), and numbering n nodes was O(n^3). It now finds the context's index once and walks the children before it directly, so the walk is linear in the number of preceding nodes and the numbering is quadratic (inherent to counting preceding nodes per numbered node). The numbers are unchanged. Numbering 1000 items drops from about 4.7 s to about 0.7 s (~6x), and the scaling improves from cubic to quadratic. Guarded by XSLTNumberAnyTests.

Fixed

  • The translate() and contains()/substring-before()/substring-after() string functions are no longer quadratic. translate() scanned its from string for every source character (O(string * from)); it now builds the from-to map once and translates each character with an O(1) lookup. The substring functions matched the needle at every position of the haystack (O(n*m), quadratic and a denial-of-service vector on a repetitive untrusted string); they now use a linear-time Knuth-Morris-Pratt search. The results are unchanged (first-occurrence-wins translation, first-occurrence substring match). A large-alphabet translate() and a repetitive contains() each drop from hundreds of milliseconds to under a millisecond at 4k-16k characters. Guarded by TranslateFunctionTests and SubstringSearchTests.

[0.4.5] - 2026-06-25

Fixed

  • The following-sibling and preceding-sibling axes no longer scan the parent's child list to locate the context node on every call, and fuse the step's node test into the walk. Locating the context among a wide parent's children was a linear scan independent of the result, so the axes were quadratic over many context siblings; the index now comes from the shared per-evaluation sibling-index cache, and a sibling the node test rejects is never wrapped. The selected siblings and their order are unchanged. Over a wide parent the gratuitous per-context scan and the non-match wrapping are removed (the per-context iteration over the siblings themselves is inherent to the axis). Guarded by SiblingAxisTests.

  • Sorting many attribute nodes from the same element into document order is no longer quadratic in the attribute count. An attribute's document-order key bands by its index among its owner's attributes, which was found by a linear scan of the attribute list on every attribute, so sorting an element's K attributes was O(K^2). The index now comes from a cached per-owner name-to-index map (built once on the second lookup, mirroring the sibling-index cache). The order is unchanged. Sorting the attributes of an 8000-attribute element drops from about 0.18 s to about 0.01 s (~16x), and the cost is now linear. Guarded by AttributeDocumentOrderTests.

[0.4.4] - 2026-06-25

Fixed

  • The following and preceding axes no longer rebuild the whole document node list and linearly search it on every context node, which made them quadratic over many context nodes. They now share a per-evaluation cache of each document's ordered node list and a node-to-index map (the document does not change during evaluation), so the list is built once and a node's position is an O(1) lookup; following additionally skips the context's contiguous subtree by index arithmetic rather than filtering every descendant out. The step's node test is fused into the walk, so a node the test rejects is never copied out of the shared list (only the matches are materialized). The selected nodes and their order are unchanged. A 200-context following:: query over a 20k-element document drops from about 16 s to about 6 s. Guarded by FollowingPrecedingAxisTests.

[0.4.3] - 2026-06-25

Fixed

  • key(), id(), and the EXSLT set:leading/set:trailing functions no longer rescan a node's siblings per comparison, which made them quadratic over a wide flat fan-out. They sorted their result with sorted(by: Node.precedes), which recomputes both nodes' document-order keys on every comparison (each key a linear scan of a wide parent), and key() additionally deduplicated its matches with a linear membership scan. They now sort through sortedByDocumentOrder() (each key computed once, behind a shared sibling-index cache) and key() deduplicates through a set. The selected nodes, their document order, and the deduplication are unchanged. A key()-driven transform over a 20k-element document drops from about 1.15 s to about 0.43 s (~2.7x); the Apache Xalan gold-output corpus stays byte-identical. Guarded by XSLTKeyIdOrderTests.

  • Extracting the string-value of a node-set no longer scans the node's siblings, which made an XSLT transform (or any repeated string(@x) / value-of) over a wide flat fan-out quadratic. firstInDocumentOrder() computed a document-order key even for a zero- or one-node set, and that key walks the node up to the root taking its index among each parent's children, a linear scan of a wide parent. For a single node the order is irrelevant (the node is trivially first), so it now returns directly without computing the key. A transform that reads a couple of attributes and child values per element over a 20k-element document drops from about 2.3 s to about 0.65 s (~3.5x); the produced output is unchanged (the Apache Xalan gold-output corpus stays byte-identical). Guarded by FirstInDocumentOrderTests.

[0.4.2] - 2026-06-25

Changed

  • Output escaping locates the characters that need escaping by scanning bytes rather than graphemes. The bulk-copy escape loop still iterated Characters and compared each to &, <, > (a grapheme comparison per character). Every escaping trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead byte, so Escaping.text and Escaping.attribute now find the next one with value.utf8.firstIndex(where:), decoding no grapheme for the run between markers. The marker byte always lands on a grapheme boundary (an ASCII byte is its own scalar and forces a cluster break; the first byte at or above 0x80 after a boundary is a lead byte), so the string slice and subscript stay valid and a multi-scalar cluster escapes as a unit. The output is byte-identical. Measured serialize ~23% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by the oracle differential extended with combining-mark, ZWJ-sequence, and regional-indicator clusters (EscapingBulkCopyTests), the Apache Xalan gold-output and C14N suites, and an adversarial grapheme-alignment review.

  • Output escaping copies the verbatim runs between escapable characters in bulk instead of rebuilding the value one character at a time. Escaping.text and Escaping.attribute appended each character on its own (result += String(character) for every unescaped character) when a value needed escaping, so re-escaping a text node that holds a single & rebuilt it grapheme by grapheme. They now copy each run between escapable characters in a single append and rewrite only the markers (&, <, >, the attribute quote and whitespace, and, in ASCII-only mode, non-ASCII characters). The no-escaping-needed fast path is unchanged. The output is byte-identical. Measured serialize ~7% faster on the 20k-item generated corpus (whose decoded text re-escapes an &) via a same-machine-state A/B. Verified by a per-character-oracle differential with multibyte cases (EscapingBulkCopyTests), the Apache Xalan gold-output and C14N canonicalization suites, and an adversarial review including a 400k-input fuzz.

  • parseTree assembles the mutable TreeNode tree directly from the event stream instead of building the immutable value Node tree and converting it. The old path allocated two full trees: the parser built a value Node tree, then TreeNode.init(_:) walked it to build the reference tree. Parser.buildTree is the direct counterpart to the value-tree assembler, the same iterative event loop with the same element stack and the same well-formedness guards (it throws on an unmatched end tag and on a document with no element), but it accumulates TreeNode children per frame and adopts them when the element closes, so the tree is allocated once. The produced tree is identical, the same node kinds, names, attributes, values, child order, and parent links. Measured parse ~8% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a structural differential against the convert-from-Node path (ParseTreeDirectBuildTests), the full suite and the W3C XML conformance, RELAX NG, and Apache Xalan corpora (which all materialize trees through parseTree), and an adversarial equivalence review.

  • The entity decoder finds its markers at the byte level. EntityExpander.expand located the next &, <, and ; with String.firstIndex over graphemes, decoding every literal byte of the run into a grapheme just to look for a one-byte marker. All three markers are ASCII, so a UTF-8 index is a valid string index: the scan now runs over raw.utf8 and the found index still subscripts the string directly. The decoded text is unchanged, including text where the markers follow multibyte UTF-8 (precomposed, combining, and astral characters all keep the marker on a grapheme boundary). Measured parse ~5.5% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by the decode differential (EntityDecodeBulkTests, extended with multibyte-boundary cases) and the W3C XML conformance corpora.

  • Materializing the mutable TreeNode tree from a parsed value Node adopts each node's prepared children directly instead of appending them one at a time. Building a node ran append for every child, which detaches the child from any previous parent, rejects a cycle, and grows the children array element by element. The children produced by the value-Node conversion are freshly built and parentless, so those guards are always no-ops and the repeated growth is wasted; TreeNode.init(adopting:) adopts the already-built array and only wires each child's parent. The resulting tree is identical (same children in the same order, the same parent links). The adopting initializer is used only by the conversion; every public factory keeps the detaching, cycle-checking append path for externally supplied children. Measured parse ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a parent-wiring differential (TreeNodeAdoptingInitTests), the W3C XML conformance corpora, and an adversarial equivalence review.

  • Element and attribute names split their prefix without a second pass over the name. The byte scanner (takeASCIIName) already visits every byte of an all-ASCII name, so it now also records the offset of the name's first colon at the cost of one comparison per byte. QualifiedName(ascii:colonOffset:) then splits at the known offset, and the common unprefixed name (no colon) is built with no scan at all, where QualifiedName(_:) previously ran firstIndex(of:) grapheme by grapheme over the whole name on every element and attribute. The resulting names are identical for every colon placement (none, leading, trailing, middle, doubled). Measured parse ~12% faster on the 20k-item generated corpus via a same-machine-state A/B. Verified by a colon-placement differential (QualifiedNameColonHintTests), the W3C XML conformance corpora, and an adversarial equivalence review.

  • The entity decoder copies the literal text between references in bulk instead of one character at a time. EntityExpander.expand rebuilt every reference-bearing run grapheme by grapheme (result.append(character) with a per-character amplification-budget charge), so a text node holding a single &amp; was reconstructed character by character; it now scans to the next & or < and appends the run in one operation, charging the budget by the run length. The decoded text, the CDATA-verbatim handling, and the billion-laughs budget enforcement are unchanged (the same overrun throws at the same input). The element-content reference-findings pass, which scanned each text run for & and rendered the enclosing element's qualified name, now returns immediately when the document has no DTD content models, which is the common case. Measured parse ~5% faster on the 20k-item generated corpus (whose text uses &amp;) via a same-machine-state A/B. Verified by the W3C XML conformance corpora, a six-case decode differential (EntityDecodeBulkTests: references in runs, decimal and hex character references, declared-entity replacement with budget overrun, CDATA in replacement text, an unterminated reference), and an adversarial equivalence review.

[0.4.1] - 2026-06-25

Changed

  • XPath compiles a step's node test once per query instead of re-evaluating its fixed structure on every node. The descendant and attribute walks tested each candidate through a closure that called matchesTree/matchesAttribute with the step's NodeTest and the evaluation namespaces dictionary, so the dominant traversal of a //name-style query passed (and reference-counted) the namespace dictionary by value on every one of tens of thousands of nodes. compiledTreeTest, compiledAttributeTest, and compiledNodeTest resolve the parts that do not change as the walk moves (the axis principal kind, whether eval-time bindings are in effect, the prefix-wildcard shape of the test name) exactly once, returning a tight per-node closure that captures only the local name. The selected nodes are identical to the unfused matchesTree/matchesAttribute/matches path. Measured xpath ~35% faster on the 20k-item generated corpus via a same-machine-state A/B (from ~2.0x to ~1.3x slower than libxml2). Guarded by XPathCompiledNodeTestTests, an exhaustive differential over every node, test, axis, and binding asserting the compiled closures equal the functions they specialize, and confirmed by an adversarial equivalence review.

  • XPath evaluation shares the focus-independent state (the variable bindings, the function library, the namespace bindings, and the budget) through a single Environment reference instead of carrying it by value in every context. Re-focusing the context on each node of a node-set, which happens once per node when a predicate is applied, copied three dictionaries every time; it now copies only the node, position, and size and shares the environment by reference. The values and behavior are unchanged. Measured xpath ~16% faster on the 20k-item generated corpus via a same-machine-state A/B; covered by the full XPath suite (the change is internal to the evaluator).

  • The XPath attribute axis fuses the step's node test into the walk, the same way the descendant axes do, so an attribute the test rejects is never wrapped in an XPath.Node. A predicate like [@name='value'] previously built an XPath.Node for every attribute of the element (each wrap retains the owner element and copies the attribute's qualified name and value) and then filtered to the one named attribute; it now wraps only the matching attribute. matches delegates, for an attribute node, to a new matchesAttribute (the attribute axis always has the attribute principal kind, so this is exact), and the attribute walk applies it. The node-set, declaration order, and uniqueness are identical to the build-then-filter path. Measured xpath ~20% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by XPathFusedAttributeTests (name and namespaced-name tests, the principal-kind wildcard, node(), the kind tests that never select an attribute, and a predicate over the selection) and confirmed by an adversarial equivalence review.

  • The XPath descendant and descendant-or-self axes fuse the step's node test into the traversal, so a node the test rejects is never wrapped in an XPath.Node (and so never reference-counted). Previously the axis built the full subtree node list and then filtered it; for a //name query over a wide document, most of the wrapped nodes were immediately discarded. The node test now runs against the raw tree node during the walk: matches(_:_:on:namespaces:) delegates to a new tree-node matchesTree (one source of truth, exercised by the full XPath suite), and the descendant walk applies it. The node-set, document order, and uniqueness are identical to the build-then-filter path; only non-matching nodes are no longer materialized. Measured xpath ~11% faster on the 20k-item generated corpus via a same-machine-state A/B. Guarded by XPathFusedDescendantTests (every node-test kind through the fused path: element and namespaced names, the principal-kind wildcard, text()/comment()/processing-instruction()/node(), and a predicate on the result) and confirmed by an adversarial equivalence review that found no query whose result the change alters.

  • The XPath descendant and descendant-or-self axes build their node list through a single shared accumulator instead of concatenating a freshly built array at every node. descendants(of:) recursed on wrapped nodes and did result.append(contentsOf: descendants(of: child)) at each level, allocating and copying an array per node (roughly O(n x depth) work over the subtree); it now recurses on the raw tree node and appends into one inout accumulator (O(n)). For a //-style query over a wide document this is the dominant cost: measured xpath ~46% faster on the 20k-item generated corpus via a same-machine-state A/B, with the identical node-set in the identical order (the change is purely how the array is built).

[0.4.0] - 2026-06-25

Fixed

  • An xsi:type substitution blocked by block/blockDefault is now rejected even when the substitute reaches the declared type through a union member rather than along its own base chain (cvc-elt.4.3 with cos-st-derived-OK clause 2.2.4: a type validly derived from a union member is validly derived from the union, and the {disallowed substitutions} apply to the derivation method used to reach that member). Previously the block check followed only the named base chain, found no path from the substitute to the declared union, and stayed silent, so a substitution reaching a union member by a blocked method was wrongly accepted (W3C XSTS elemT074: block="restriction" on the element, the xsi:type restricting a union member). A name-keyed union-member table is recorded during schema compilation and the block check now follows a derivation through a member; it adds a rejection only on a positively confirmed path, so an unresolved or inline-anonymous member leaves the check silent rather than risk a false positive. This drops XSTS invalid-instances-accepted 15 -> 14 while holding valid-schemas-rejected and valid-instances-rejected at 0 (full XSTS archive, the false-positive direction the production bar guards hardest). The member clause is existential (cos-st-derived-OK 2.2.4): the substitution is barred only when EVERY confirmed derivation path to a member is blocked, so a substitute that is itself a union member, or reaches one member by an unblocked method, is accepted whatever other members it also reaches by a blocked method. Disclosed under-rejection (bounded, not silent): only a union's DIRECT memberTypes are recorded, not flattened, so a substitution reaching the declared union through a member that is ITSELF a union (a nested-union member-of-a-member path) is not blocked; this can only under-reject (accept an invalid substitution), never over-reject, because an unmodeled path yields no confirmed derivation and the check stays silent rather than guess. Guarded by SchemaXSITypeUnionMemberBlockTests, which checks the rejection, that a non-blocked method (and no block) still accepts the valid substitution, and that a substitute which is itself a member is accepted even when listed after another member it derives from by a blocked method (the over-rejection an adversarial review caught and this design rules out).

Changed

  • A single forward-axis location step from one context node no longer sorts its result into document order, because it is already in document order with no duplicates. AxisNavigation produces the structural forward axes (child, descendant, descendant-or-self, self, following, following-sibling) in document order, deriving sibling order from the same children array the order key compares, so a one-step path over any of them needs neither the document-order sort nor de-duplication. (This is the common shape after //x compiles and fuses to a single descendant::x step.) The attribute axis (whose order interleaves with namespace declarations) and the namespace axis (produced in dictionary/hash order), plus every reverse axis (nearest-first), are excluded and still sorted. Measured xpath ~7% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Guarded by XPathSingleStepOrderTests (document order for each included axis, reverse axes still normalized, multi-step paths still sorted) and confirmed by an adversarial order-correctness review that found no query whose order or uniqueness the change breaks.

  • A path expression's result is no longer re-de-duplicated before sorting. evaluateSteps already returns a duplicate-free node-set (each step de-duplicates across its context nodes, or uses an axis whose per-context results cannot overlap), so the Set-based de-duplication that ran before the document-order sort copied every node for nothing; each copy retains the wrapped tree node, whose weak parent reference forces a side table and the slow reference-counting path. Path and trailing-path evaluation now sort the steps' result directly; unions and filter primaries (which genuinely can carry duplicates) keep the de-duplicating pass. Measured xpath ~9% faster on the 20k-item generated corpus via a same-machine-state A/B, identical results. Covered by the existing node-set de-duplication and union tests (a path still yields each node once).

  • XPath compiles // (the abbreviation for descendant-or-self::node()/child::X) to a single descendant::X step when the child step's predicates are non-positional, avoiding the materialization of every node in the subtree as an intermediate context (measured xpath ~10x -> ~7x slower than libxml2 on the 20k-item generated corpus via a same-machine-state A/B, identical results; ~54% faster than before this cycle's two XPath changes combined). The two node-sets are equal, so the fusion is transparent, EXCEPT for a positional predicate: //x[1] is the first x child of EACH parent, not the first x in document order, which descendant::x[1] would wrongly select. The rewrite is therefore applied only when every predicate is provably non-positional: its value is a boolean, node-set, or string (never a number, which XPath reads as position() = value) and it nowhere references position() or last(). Anything not provably both is left unfused, so a result can never change. Guarded by XPathDescendantFusionTests (positional forms keep their per-parent meaning, non-positional forms select across the subtree, composition with trailing and nested steps) and confirmed by an adversarial differential review (fused versus a spec-literal unfused reference) that found no query whose result the fusion changes.

  • XPath location-step evaluation skips cross-context node-set de-duplication when no duplicate can arise (measured xpath ~13x -> ~10x slower than libxml2 on the 20k-item generated corpus via a same-machine-state A/B, ~28% faster, identical results). A step accumulated each context node's axis results into a Set<PureXML.XPath.Node> to drop nodes reached from more than one context, which hashed and copied every node (the wrapped TreeNode reference is reference-counted, so each copy is ARC traffic). De-duplication is needed only when two distinct context nodes can reach the same node, so it is now skipped on a single context and on the axes whose results are disjoint per context (child, attribute, namespace, self); those produce the identical node sequence the Set would (every insertion succeeds) without the hashing. The Set is kept on the axes that genuinely overlap (descendant, ancestor, parent, the siblings, following/preceding). Predicates are applied per context (unchanged) and the path result still passes through the final order-and-unique pass, so semantics are preserved. Guarded by XPathNodeSetDedupTests, which checks that a node shared by many contexts through ancestor/parent/descendant is still counted once, that the disjoint axes return every distinct result, and that an overlapping union still de-duplicates; the full XPath suite and the Apache Xalan XSLT gold-output corpus stay green.

  • Serialization roughly halved (measured serialize ~5.5x -> ~2.7x slower than libxml2 on the 20k-item generated corpus, bash scripts/benchmark.sh 20000 5, with byte-identical output). The escaping functions (text, attribute, comment, processingInstruction) rebuilt every value character by character even when nothing needed escaping, which is the common case. They now scan the value's UTF-8 bytes for the escapable bytes (markup characters, the active attribute quote, the whitespace characters, a comment hyphen, a PI question mark, and any non-ASCII byte in ASCII-only mode) and return the value unchanged when none is present, falling back to the original character path only when something must be escaped. Guarded by EscapingFastPathTests, which pins both the unchanged fast path and the escaped slow path for each function, and verified against the Apache Xalan XSLT gold-output corpus (a serialize-heavy differential). No output changed.

  • Attribute-value parsing skips the reference-decode pass when the value contains no &. The byte-run scanner already visits every value byte, so it now notes any &; an ampersand-free value (the common case) returns its normalized run directly instead of calling the entity decoder, which re-scanned the whole value for & before doing nothing. Measured against a same-machine-state baseline, every run came in below every baseline run (~4% parse improvement on the 20k-item generated corpus). The bulk byte-run scanners (contentRunBytes, attributeRunBytes) moved to ReaderByteRuns.swift with their cursor mutation centralized through a single advanceByteRun method, keeping Reader's position fields write-encapsulated while the file stays under the length cap. The byte and Character paths are held to identical decoded values by ParserPathDifferentialTests (extended with ampersand-free, entity, and character-reference attribute cases); no spec behavior changed.

  • Parser throughput on plain-ASCII documents roughly halved against the libxml2 reference (measured parse 20.1x -> 9.5x slower than libxml2 on the 20k-item generated corpus, bash scripts/benchmark.sh 20000 5; ~53% wall-clock reduction). The work was profile-driven (macOS sample, not guesswork): the scanner, not tree construction, dominated parse time (SAX is ~84% of parse), and within it the cost was per-Character String machinery (String.Iterator.next, _stringCompareWithSmolCheck, _uncheckedFromUTF8, bridge-object ARC). The root cause was that a single buffered lookahead Character defeated every existing byte fast path (each requires an empty buffer), and the content/markup/attribute dispatch all peeked a Character before scanning. The fix keeps the reader on the byte path: a non-buffering peekByte for dispatch, a byte-mode skipSpace, an all-ASCII takeASCIIName, an attributeRunBytes bulk scanner (with the 3.3.3 whitespace-to-space transform, deferring any carriage return to the Character path so CRLF is not double-counted), and a sawAmpersand flag so ampersand-free text skips reference decoding entirely. The single highest-leverage change was detecting the closing < of a text run via peekByte instead of buffering it, which had been cascading the next element's whole dispatch and name scan onto the slow path. The Character path remains the exact fallback for a streaming source, spliced entity text, and any non-ASCII byte; byte and Character paths are kept from diverging by scanContentMarkup (shared dispatch) and by ByteFastPathTests, which pins the fast-path/fallback boundaries (non-ASCII names, CRLF and character references in attribute values, ampersand-free vs entity-bearing text, over-long names, raw < in attributes). Verified against the full local suite (1668 tests) plus the Apache Xalan XSLT gold-output corpus, the W3C XML conformance corpora (xmltest, OASIS, Sun, Eduni, IBM, Japanese), and the RELAX NG spec suite, all green; no spec behavior changed.

  • Element-content markup dispatch now branches on the single byte after < (end tag, processing instruction, declaration, or start tag) instead of running up to five literal string comparisons per element before the start-tag branch wins, a per-element cost the profile indicted (Reader.matchesBytes). The Character path keeps the equivalent matches()-based dispatch for a streaming source or spliced entity text, and a <! that matches none of comment/CDATA/DOCTYPE still falls through to the start-tag scanner with the same expectedName error. Measured parse ~9.5x -> ~9.0x slower than libxml2 on the 20k-item corpus; the byte and Character paths are held identical by the differential oracle (ParserPathDifferentialTests). No spec behavior changed.

[0.3.0] - 2026-06-24

Added

  • Added an opt-in DEEP fuzz campaign (SchemaFuzzTests.test_deepFuzzCampaign, production-readiness stopper #3: correctness bounded, not sampled). The standing fuzz gate samples a fast, hang-guarded 2000/250/600 iterations every CI run; this runs the same streaming-vs-tree differential over PUREXML_FUZZ_ITERATIONS seeds when that variable is set (skipped otherwise), so a long campaign is reproducible without editing the bounds and a divergence reproduces from its seed. It carries no .timeLimit (a 60k-seed run legitimately exceeds a minute); termination stays guaranteed by the engine's own occurrence/state caps. A campaign over 60,000 seeds (plus 20,000 schema/instance and 80,000 parser-byte iterations) found zero streaming-vs-tree divergences, crashes, or hangs.

  • Added a standing streaming-vs-tree differential gate (SchemaFuzzTests.test_streamingMatchesTreeDifferential, related to #194). The streaming XSD validator is a separate implementation of the same semantics as the tree validator (the conformance oracle), so it must agree with it on every input. The gate fuzzes 2000 deterministic schema/instance pairs and asserts that streaming and the tree validator agree on whether each document is valid, and that every tree error appears in streaming's errors (streaming may add errors on an already-invalid document, issue #206, but may never reject a valid document or miss a real error). This runs in the standard test suite on all platforms, so a future change that reintroduces a streaming false positive (three were found and fixed this cycle: #186, #200, #202) fails CI with a reproducible seed, turning streaming-oracle agreement from a manually-hunted snapshot into an enforced invariant.

  • Added an opt-in differential schema-validity harness against libxml2 (XSTSDifferentialTests, production-readiness stopper #3: characterise correctness against an independent reference, not just the labelled suite). Over 9618 corpus schemas PureXML and libxml2 agree on 4912 (3161 valid, 1751 invalid); the 87 where PureXML is stricter were triaged as PureXML being more correct (libxml2 omits facet consistency, Element Declarations Consistent, whiteSpace restriction, au-props-correct, and notation rules), independently corroborating that PureXML does not over-reject. The 117 where libxml2 is stricter are a false-negative triage queue (stopper #2), written with the rest to /tmp/xsts-differential.txt. xmllint and the corpus are used only in the test harness, never the shipped package.

  • Added the counted-content-automaton design for the interactive resource-bound arc. The design separates the language-recognition, matched-particle attribution, and UPA/determinism claims; replaces occurrence unrolling with counter scopes; states the target O(N * S * D) runtime and O(S * D) live memory bounds; and makes deleting occursUnrollCap, totalStateCap, and positionCap the acceptance criterion.

  • OpenAPIKit-style validation framework completion: BuiltinValidation registry (23 rules), exhaustive fail/succeed isolation tests, configuration pins, public-type and field coverage gates (scripts/check-validation-coverage.sh, scripts/check-validation-fields.sh), warnings channel with strict: promotion, two-tier Validator defaults, XSD schema compile rules as composable validations (SchemaCompileValidation), and ValidationOutcome non-throwing accessor.

Changed

  • Attribute-wildcard union and intersection now follow XSD 1.0 §3.10.6 with Errata E1-10, including the "not expressible" cases (false-negative burndown, stopper #2). The {namespace constraint} model was generalised from any/##other/set to the four expressible XSD forms: any; a set (with absent); a pair of not and a namespace name; a pair of not and absent. Both not forms exclude absent, so a union admitting "every namespace except a name, plus absent" (E1-10 rule 5.1.3) has no expressible form. PureXML previously widened such a union to ##any and accepted the schema; the new SchemaWildcardExpressibility rule rejects a complexContent extension whose attribute-wildcard union with its base is not expressible (src-ct.5), closing XSTS wildZ013. The expressible unions are now computed exactly, so a derived type's wildcard admits the correct attributes at instance time, closing wildZ013a and wildZ013d. Net: invalid-schemas-accepted 12 -> 11 and invalid-instances-accepted 17 -> 15, with valid-schemas-rejected and valid-instances-rejected held at 0 (full XSTS archive); the generalised model is additive for element wildcards (which never produce the new forms). The union/intersection algebra was taken clean-room from Xerces-J XSWildcardDecl (factual rule numbers only). Guarded by SchemaWildcardUnionExpressibilityTests and the existing intersection tests. Disclosed under-rejection: the intersection-not-expressible case (src-ct.4, two not forms negating different namespace names within one type, e.g. a type intersecting its own ##other with a cross-imported attribute group's ##other) is not flagged as a schema error; the intersection stays the less-restrictive not(lhs) over-approximation so a valid cross-namespace attribute is never wrongly rejected (the prior behaviour, kept FP-safe), and src-ct.4 detection is a deferred rule.

  • A negated character-class subtraction now excludes both the negated base and the subtrahend (false-negative burndown, stopper #2). XSD charClassSub ::= (posCharGroup | negCharGroup) '-' charClassExpr is the set difference of the (possibly negated) group and the subtrahend, so for [^cde-[ag]] negation applies to the base first and the subtrahend is removed from that result: the class admits everything except {a, c, d, e, g}. CharClass.matches applied the subtraction to the positive membership and negated afterwards, so it excluded only {c, d, e} and wrongly matched a/g, accepting instances a correct engine rejects. Negation now applies to the base before subtraction. Only negated classes that also carry a subtraction change behaviour. This closes XSTS RegexTest_430 and RegexTest_422, dropping invalid-instances-accepted from 19 to 17 with valid-instances-rejected held at 0 (full XSTS archive). A regression is guarded by RegexCategoryTests.test_negatedSubtraction.

  • An unknown \p{...} category or block name in a pattern facet is now rejected at schema-compile time (false-negative burndown, stopper #2). XSD 1.0 charProp is IsCategory | IsBlock, an exhaustive set: every Unicode general category plus a fixed Unicode-3.1 block list. The block table (CategoryMatcher.blocks) was missing the nine supplementary-plane blocks (Old Italic, Gothic, Deseret, the Byzantine/Musical/Mathematical symbol blocks, CJK Unified Ideographs Extension B, CJK Compatibility Ideographs Supplement, Tags) and the three surrogate blocks, so parseCategory could not tell a real-but-untabulated block from a bogus name and stayed lenient (RegexError.unsupported), accepting \p{IsaA0-a9} and the schema declaring it. The table is now the complete XSD set (supplementary blocks added with their ranges; surrogate blocks added as valid names whose ranges hold no scalar, so they match nothing), making the recognised set authoritative, so an unrecognised name throws the rejecting RegexError.invalidProperty. The now-dead RegexError.unsupported case and the isMalformedProperty heuristic are removed. This closes XSTS reK88, dropping invalid-schemas-accepted from 13 to 12 with valid-schemas-rejected held at 0 (full XSTS archive) and the instance buckets unchanged (invalid-instances-accepted 19). As a side effect, a pattern naming a supplementary-plane block (e.g. \p{IsMusicalSymbols}) now matches its characters instead of failing to compile. Block names/ranges were taken from the XSD-supported list in Xerces-J Token.java (clean-room: factual data only). Regressions are guarded by RegexCategoryTests (supplementary/surrogate matching, unknown-name rejection) and SchemaPatternFacetTests.

  • A pattern facet whose value contains an unescaped [ inside a character class is now rejected at schema-compile time (false-negative burndown, stopper #2). XSD 1.0 Appendix F defines SingleCharNoEsc as [^\[\]] and admits a nested class only as a -[...] subtraction, so a [ not introduced by - is a syntax error; RegexParser was treating it as a literal scalar, so [a[:xyz:] compiled and the declaring schema was accepted. The parser now throws a dedicated RegexError.unescapedClassBracket (kept distinct from the generic, deliberately-lenient badClass so the change cannot regress a valid-but-unsupported pattern), which XSDSimpleParser.rejectsPattern treats as a genuine syntax error. This closes XSTS RegexTest_993 and RegexTest_1477, dropping invalid-schemas-accepted from 15 to 13 with valid-schemas-rejected held at 0 (full XSTS archive) and the instance buckets unchanged (invalid-instances-accepted 19). The escaped form [a\[c] and the -[...] subtraction [a-z-[aeiou]] stay valid; the unterminated case [a[:xyz: now faults on the inner [ (more precise than the prior unterminated-class diagnosis). Regressions are guarded by RegexTests.test_unescapedClassBracket and SchemaPatternFacetTests. Contested-corpus note: this also rejects siblings the MS suite marks valid (([[:]+), ([[=]+), ([[.]+), RegexTest_989/990/991/1473-1475, and the space-bearing subtraction [a - c - [ b ] ]+), but those entries are W3C-queried (Bugzilla #4118) because "valid" contradicts the Appendix F grammar, so they are excluded from the conformance gate; PureXML follows the grammar and the suite's settled position (993/1477 invalid).

  • Compiling a schema with deeply nested, multiply-referenced model groups is no longer exponential. XSDParserParticles.groupReferenceParticle inlined each <xs:group ref> into the compiled Particle content model, so g0=(g1,g1), g1=(g2,g2), … built 2^K particles (measured: depth 14 = 0.21s, depth 20 ~13-18s, growing ~4x per two levels). The expansion is now memoized per (group definition, cycle-visiting set) in a per-compile GroupParticleMemo, so sibling references share it and the build drops to O(groups) (depth 40 now compiles in ~0.016s; previously infeasible). The shared content term is reused by value (copy-on-write), so the compiled model is byte-for-byte the same and validation is unchanged: the XSTS gate is unchanged (invalid-schemas-accepted 15, valid-schemas-rejected 0, instance buckets held) and the streaming-vs-tree fuzz differential still passes. This closes the schema-compile half of the interactive-safety group-blowup (the companion to the positionCap determinism fix); a regression is guarded by a depth-40 compile test.

  • The Unique Particle Attribution (UPA / cos-nonambig) determinism check is now compositional and no longer caps or skips. ContentModelDeterminism delegates to a new CompositionalDeterminism engine that computes first/followlast over PARTICLE identities and summarizes each <xs:group> once, so a multiply-referenced nested group whose inlined Glushkov automaton would be 2^K positions is checked in O(nodes * particles^2) time instead of being silently skipped. This DELETES ContentModelDeterminism.positionCap (the last named interactive-safety resource-bound blocker, production-readiness stopper #4) and the silent under-rejection it caused (stopper #3). The compositional engine was proven verdict-equivalent to the prior inlining position automaton by a differential over the entire XSTS corpus (zero divergences) before replacing it; the XSTS conformance gate is unchanged (invalid-schemas-accepted 15, valid-schemas-rejected 0), and a new test compiles a 2^20-position nested-group schema without hanging and rejects an ambiguous one the old cap would have skipped. The per-particle model (a particle never conflicts with a repetition of itself) and the QName-only overlap (no substitution-group expansion) are preserved, so the false-positive direction stays at zero.

  • Schema compile diagnostics are now located: SchemaError.inconsistent carries [ValidationError] with coding paths that resolve to TreeNode.sourceRange on a ranged schema tree (#169). Parse-time facet and structure findings attach to their declaring nodes where possible; SchemaError.inconsistentFindings exposes the full list. Schematron reuses the shared validationCodingPath() helper. Value-constraint errors now locate to their declaring element or attribute: a default/fixed on an ID-derived type, a value that is invalid for its built-in, named user, or inline type, and an illegal combination of value constraints each underline the offending declaration rather than the schema root. The structure-pass checkers are likewise located: a facet inapplicable to a list or union variety, a derivation by restriction of xs:anySimpleType and a constraining facet on anySimpleType-based simple content, a top-level definition missing its name or carrying a local-only attribute, a nested named definition, an empty targetNamespace or import namespace, and an all-group reference with an illegal minOccurs/maxOccurs each underline their own node instead of the schema root. The simple-type-pass checkers join them: a list whose item type is itself a list or a union containing a list, a list declaring both or neither of itemType/inline simpleType, an empty union, a simpleType restriction whose base resolves to a complex type, an itemType/memberType referencing a type that is final for list/union derivation, and an attribute typed by a complex type each underline the offending list, union, restriction, or attribute node rather than the schema root. The attribute-pass checkers follow: an attribute declaration that lands in the XSI namespace underlines the attribute; a duplicate attribute use or a second ID-typed attribute underlines its declaring complexType or attributeGroup; an attribute reference that contradicts a global fixed value underlines the reference; and a complex-type restriction that relaxes a base attribute use (making a required attribute optional, narrowing its type unfaithfully, dropping a fixed value, or widening the attribute wildcard) underlines the restriction. The derivation/cycle-pass checkers, which report a cycle by component name over a name-keyed graph, now record the declaring node alongside that graph and underline it: a type derived from itself underlines its complexType/simpleType, an element that is a member of its own substitution group underlines its element, a model group or attribute group that references itself underlines its group/attributeGroup declaration, and an all-group referenced inside a sequence/choice/all underlines the offending group reference. The content-derivation-pass checkers (the cos-ct-extends/src-ct base-kind family) follow: a complexContent extension adding element content over a simpleContent base, a simpleContent restriction whose base is a built-in or user simple type, an unfaithful inline type in a simpleContent restriction, a complexContent base that is a simple type, a simpleContent extension whose base is xs:anyType, and a mismatched mixed setting on a complexContent extension each underline their extension/restriction derivation node, and an element carrying a default/fixed value over element-only or empty content underlines the element. The reference-resolution rule is now genuinely located: referenceFindings previously wrapped its whole aggregate in .unlocated, so every undeclared-reference error collapsed onto the schema root despite the rule being dispatched as located; each of its six sub-collectors (the core type/base/itemType/memberTypes/ref/substitutionGroup walk, the xs:-namespace builtin check, the simpleContent base-kind check, the un-imported-namespace check, the redefine-component-existence check, and the import-namespace-agreement check) now attaches to its referencing node, import, or redefine component. The extension/substitution-pass checkers follow: an all group reached through an extension over a base with content underlines the extension, an anonymous complex-type restriction that is not a subset of its base underlines the restriction, and a substitution-group member whose type is not validly derived from its head underlines the member element. The names/UPA/notation-pass checkers follow: a duplicate global component name underlines the second declaration, a duplicate identity-constraint name underlines the second constraint, a keyref whose refer names no key/unique (or whose field arity differs) underlines the keyref, a Unique Particle Attribution violation underlines the ambiguous content-model group, and a notation missing both public and system, a notation with forbidden content, or a NOTATION enumeration naming no declared notation underline the notation or enumeration. The composition/redefine-pass checkers complete the burndown (every schema compile checker now reports through SchemaLocatedFinding, with no .unlocated fallback left): a resolved schemaLocation whose content is not a schema underlines the include/import/redefine reference (its node is captured at the load-failure site), a redefined type that does not restrict or extend itself underlines the redefined type, a redefined group or attribute group with a malformed self-reference underlines the offending reference or redefinition, and a redefined attribute group or model group that does not restrict its original underlines the redefinition.

  • DTD parse-time validity findings are warnings until strict validation promotes them; validateAgainstInternalDTD returns full findings (errors and warnings).

Fixed

  • xsl:namespace-alias now keeps the literal (stylesheet) prefix on aliased result elements, attributes, and namespace declarations, remapping only the namespace URI (XSLT 1.0 7.1.1: the result namespace node keeps the prefix used in the stylesheet). The serializer was adopting the result-prefix as the output prefix, so axsl:foo aliased to the XSLT namespace came out as xsl:foo; the result-prefix now only selects the replacement namespace. Closes Apache Xalan conformance cases namespace19, namespace23, namespace24, namespace35, and namespace113.

  • xsl:key with a union match pattern now indexes every branch. The match was turned into a path by prepending //, but for a union like match="Level1 | Level2" the // applied only to the first branch (//Level1 | Level2), so the second branch became relative to the root and matched nothing, leaving nodes of that type out of the key. The // is now distributed over each top-level union branch (//Level1 | //Level2), with | inside a predicate or function call left intact. Closes Apache Xalan conformance cases idkey45, idkey46, idkey47, and idkey48.

  • xml:space="preserve" on a literal result element (or an ancestor) in the stylesheet now keeps that element's whitespace-only text instead of stripping it (XSLT 1.0 3.4). The stylesheet whitespace stripping dropped every whitespace-only run unconditionally, ignoring an in-scope xml:space="preserve"; the nearest ancestor-or-self xml:space is now consulted, so preserve retains the run while default (or none) still strips it. Closes Apache Xalan conformance case whitespace20.

  • xsl:number level="single" now counts the element that is the from boundary when that element also matches the count pattern (XSLT 1.0 7.7). The search for the count node stopped at the nearest from-matching ancestor before testing it against count, so a pattern like count="a|b" from="a" produced an empty number for an element inside an a (the count node is that a, which is also the boundary). The count pattern is now tested before the boundary stops the walk. Closes Apache Xalan conformance case numbering63.

  • The format attribute of xsl:number is now evaluated as an attribute value template (XSLT 1.0 7.7), so format="{$f}" uses the value of $f rather than the literal string. It was stored and used verbatim, so a computed or passed-in format was never applied. Closes Apache Xalan conformance case namedtemplate12.

  • A large integer-valued number now converts to a string with no decimal point (XPath 1.0 4.2). The number-to-string formatter only treated a value below 1e15 as an integer, so an integer such as 1234567890123456 (still exact in a double, which represents integers exactly up to 2^53) printed as 1234567890123456.0. The integer bound is now 2^63, the range in which the conversion is safe. Closes Apache Xalan conformance case string132.

  • A lone / (the root node) is now parsed correctly when it is followed by something other than the end of the expression, such as a ), ], |, or an operator. The XPath grammar makes the relative part after / optional, but the compiler only treated / as the root path at the very end of the expression, so count(/), generate-id(/), and / = . tried to parse a step from the following token and yielded an empty node-set. The compiler now recognizes a / as the root whenever no location step follows it. Closes Apache Xalan conformance cases position106 and mdocs15.

  • A template match pattern written with an explicit child:: or attribute:: axis now gets the default priority of its node test (XSLT 1.0 5.5), not 0. attribute::node() is a node() node test, so its default priority is -0.5 (the same as @*), but it was treated as a name test and given priority 0, which made it wrongly outrank @*. The axis is now stripped before the node test is classified. Closes Apache Xalan conformance cases conflictres29 and conflictres30.

  • xsl:strip-space and xsl:preserve-space now match their elements name tests by namespace rather than by prefix (XSLT 1.0 3.4). A specifier prefix:* or prefix:local resolves the prefix to its namespace URI at parse time, and a source element is matched on its expanded name in order of specificity (an exact {uri}local over a namespace wildcard {uri}* over *), so a stylesheet that strips ns1:* strips a source element in that namespace whatever prefix the source bound it to, while leaving another namespace untouched. The tests previously compared the raw prefixed name strings, so a differing prefix did not match. Closes Apache Xalan conformance cases whitespace06 and whitespace07.

  • disable-output-escaping is now ignored when an xsl:text or xsl:value-of produces the string value of an attribute, comment, or processing-instruction node (XSLT 1.0 16.4: disabling escaping for a value used as something other than a text node is an error, with the recovery being to ignore it). The attribute value built from such content kept its raw-output markers, so the serializer left < and > unescaped in the attribute; the markers are now stripped for these three node kinds (only when the stylesheet uses the feature, so a private-use character in ordinary data is untouched). Closes Apache Xalan conformance case output75.

  • The html output method now percent-escapes non-ASCII and control characters in URI-valued attributes (XSLT 1.0 16.2, HTML 4.01 appendix B.2.1). For the HTML 4 attributes of type %URI (href, src, cite, action, and the rest), each non-ASCII or control/space character in the value is written as the uppercase hex of its UTF-8 bytes (so cite="bë.xml" becomes cite="b%C3%AB.xml"), while " and & keep their entity form; other attributes are unaffected. Closes Apache Xalan conformance case output32.

  • Serializing a processing instruction whose data contains ?> now inserts a space (? >) so the instruction is not terminated early (XSLT 1.0 7.3: a processor recovers from a generated ?> by inserting a space after the ?). The serializer emitted the ?> raw, producing a malformed instruction. Closes Apache Xalan conformance case output72.

  • The XSLT conformance harness now decodes source and stylesheet files through the library's own encoding detection (a BOM or the XML declaration) rather than assuming UTF-8, so a non-UTF-8 corpus input is fed to the transform instead of refused. This confirms the library round-trips a UTF-16 source correctly and closes Apache Xalan conformance cases output80 (UTF-16) and attribvaltemplate08, which were unreadable by the harness, not library failures.

  • The html output method now serializes an element whose expanded-name has a non-null namespace URI as XML rather than HTML (XSLT 1.0 16.2). When the result's document element is namespaced, the tree is serialized through the XML path (empty elements self-close, the namespace is declared once and inherited) while the html method still omits the XML declaration; previously such a tree was emitted as HTML, leaving empty namespaced elements unclosed and repeating the xmlns declaration on every element, which is not well-formed XML. A null-namespace tree is unchanged. The rule is applied at document-element granularity rather than per element, so a tree mixing null- and non-null-namespace elements follows its document element's path (the realistic result is all-null or all-namespaced). (Apache Xalan output63 exercises this rule but remains baselined: its gold differs further only in attribute order, which is not normative.)

  • The html output method now names the document type declaration HTML rather than the document element name (XSLT 1.0 16.2: "The name following the <!DOCTYPE should be HTML or html"). With method="html" and a doctype-public or doctype-system, the emitted declaration is <!DOCTYPE HTML PUBLIC "..."> regardless of the result's root element; the xml method still uses the document element name (16.1). Closes Apache Xalan conformance cases output40, output48, and output60.

  • The html output method no longer escapes a < character in an attribute value (XSLT 1.0 16.2). The HTML output method escapes & and " in attribute values but leaves < and > literal, unlike the xml method; the serializer was escaping < to &lt; there. The xml output method is unchanged (it still escapes both). Closes Apache Xalan conformance cases output49 and output74.

  • A string-form document() reference now resolves against the base URI of the stylesheet element holding it (XSLT 1.0 12.1), which for a declaration in an xsl:included or xsl:imported file is that file's URI rather than the top stylesheet's. The parser already tracked each top-level declaration's source-file base; that base is now carried onto the compiled templates and global variables and threaded into expression evaluation, so a relative document('data.xml') (or document(''), the self-reference) in an included file resolves against the included file, not the importing one. Closes Apache Xalan conformance cases mdocs12 and mdocs13.

  • The document() function now resolves a relative URI reference against the correct base URI (XSLT 1.0 12.1). The two-argument form resolves the first argument against the base URI of the second argument's first node, and the node-set form resolves each reference against the base URI of the node that supplied it; previously every reference resolved against the single global base, so a reference relative to a document that document() itself had loaded from a subdirectory could not be found. Each loaded document now records the URI it came from as its base URI, and a relative reference is resolved against that base before the loader applies its own source-document base. Closes Apache Xalan conformance cases reluri09, reluri10, and reluri11.

  • Named templates, xsl:key names, xsl:attribute-set names, and xsl:decimal-format names are now matched by expanded QName, like mode and variable names already were. XSLT 1.0 compares all of these by expanded name, so two prefixes bound to the same namespace name the same thing, but they were keyed and compared as raw prefix strings. A declaration name now resolves to {uri}local at parse time, and the runtime references (call-template/use-attribute-sets at parse, key() and format-number's format name at evaluation) resolve their prefix against the in-scope namespaces the same way. Closes Apache Xalan conformance cases namedtemplate16, attribset48, and idkey53.

  • xsl:copy of the root node now honors use-attribute-sets. Copying the root produces no element of its own, so the attribute-set attributes have nowhere to attach on the copy and instead join the enclosing result element (XSLT 1.0 7.5); they were silently dropped. Closes Apache Xalan conformance cases attribset27 and attribset29.

  • xsl:element with an unusable name now passes its content through instead of creating a bad element. XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (pinned element-name-not-QName "pass-through") emits the element's content without the wrapper. The check rejected only an empty or multi-colon name, so an undeclared prefix (none:foo) or a name that is not an NCName (this is a string) still produced a malformed element. Closes Apache Xalan conformance cases namespace40, namespace42, namespace43, namespace60, namespace73, and namespace79.

  • xsl:copy-of now carries an element's inherited namespace nodes, not only the ones it declares itself. XSLT 1.0 11.3 copies a node together with its namespace nodes, so copying an element must reproduce its full in-scope set, including a declaration inherited from an ancestor; copy-of reproduced only the element's own xmlns attributes, dropping inherited ones. The inherited declarations are now added to the copied element and the serializer's namespace fixup drops any already in the result scope. Closes Apache Xalan conformance cases copy09, copy27, copy48, and mdocs09.

  • xsl:processing-instruction, xsl:comment, and xsl:attribute now use only the text-node content they instantiate, ignoring any element (or other) node together with its content. XSLT 1.0 sections 7.4 and 7.6 (and errata E27 for xsl:attribute) make it an error for the content to create a non-text node, with the recovery being to ignore the offending node and its content; PureXML instead took the string-value of all content, so a copied element leaked its text into the result. Closes Apache Xalan conformance cases copy60, copy56, and copy58.

  • Whitespace adjacent to a CDATA section in a template is no longer stripped. Text and CDATA are one text node in the data model, so <out> <![CDATA[test]]> </out> is the node " test ", whose surrounding spaces must survive whitespace stripping (XSLT 1.0 3.4). The template body dropped each whitespace-only text node independently, so the spaces around the CDATA vanished. Adjacent text and CDATA children now coalesce into one run, dropped only when the whole run is whitespace; a purely whitespace run between elements is still removed. Closes Apache Xalan conformance case whitespace13.

  • A serialized comment whose text ends with - or contains -- is now made well-formed by inserting a space after the offending hyphen. XML forbids -- inside a comment and a - immediately before -->, so a comment ending in - was emitted as <!--text---> (a malformed comment); the serializer now applies the XSLT 1.0 section 16 recovery, so text- becomes text- . Applied in the XML serializer, the streaming writer, and the HTML serializer (so method="html" output is covered too); the C14N canonicalizer keeps comments verbatim as its contract requires. Closes Apache Xalan conformance cases output89 and output90.

  • A match pattern's predicate now sees the top-level (global) variables. XSLT 1.0 section 5.2 evaluates a pattern predicate with the global variables in scope, but pattern matching ran with no variables bound, so a template like match="foo[. > $screen]" never matched (the predicate's $screen was undefined). The transformer now supplies the global bindings to the match cache, which are constant per transform and so do not affect caching. Closes Apache Xalan conformance cases match14 and idkey43.

  • A variable or parameter is now named by expanded QName, not by its literal prefix. XSLT 1.0 section 11.1 names a variable by expanded name, so two prefixes bound to the same namespace name the same variable, but the declared name and the $prefix:local reference were compared as raw strings, so $new:x did not see a variable declared txt:x even when new and txt share a namespace. A prefixed declared name now resolves to {uri}local at parse time, and a variable reference falls back to the namespace-resolved name when the raw key is unbound (so a caller-supplied raw binding still works and unprefixed names are unchanged). Closes Apache Xalan conformance case variable55.

  • A template mode is now matched by expanded name, not by its literal prefix. XSLT 1.0 section 5.7 compares a mode by expanded name, so two prefixes bound to the same namespace name the same mode, but the prefixed QName string was stored and compared verbatim, so apply-templates mode="foo:m" missed a template with mode="moo:m" even when foo and moo share a namespace. Mode names on xsl:template and xsl:apply-templates now resolve to {uri}local at parse time. Closes Apache Xalan conformance case modes16.

  • substring-after(s, '') now returns s instead of the empty string. Per the XPath 1.0 errata, an empty second argument matches at the start of the string, so everything after it is the whole string; the function bailed to empty on an empty separator. substring-before(s, '') stays empty, as the spec requires. Closes Apache Xalan conformance case string143.

  • unparsed-entity-uri now returns the URI of a DTD-declared unparsed (NDATA) entity instead of always the empty string. The function was a stub; it now reads the source document type's unparsed entities, threaded to the function table alongside the document loader. An unknown name still returns empty. Closes Apache Xalan conformance case expression02.

  • document('') now returns the stylesheet's own document. XSLT 1.0 section 12.1 makes an empty URI reference the document that contains the expression, so a stylesheet can read its own xsl:* nodes (a common idiom for embedded lookup tables and self-inspection), but the reference was passed straight to the document loader, which has no entry for it, so the call returned an empty node-set. The transformer now keeps its parsed stylesheet tree and the document function returns it for an empty reference (caching it like any loaded document; a #fragment still applies). Closes Apache Xalan conformance cases select67, select68, idkey50, mdocs17, and namespace20.

  • An XPath location step now accepts whitespace around the :: axis separator. XPath 1.0 allows whitespace between tokens, so child :: sub is the child axis with node test sub, but the parser only matched :: immediately after the axis name, so a spaced separator silently fell back to the child axis with the axis name read as an element test and selected nothing. Closes Apache Xalan conformance cases select16, select27, and select28.

  • A variable or parameter with empty content and no select is now the empty string, not a result tree fragment. XSLT 1.0 section 11.2 makes <xsl:variable name="x"/> equivalent to select="", so boolean() of it is false, but it was always built as a (non-empty) result-tree-fragment document node, which boolean() reports as true. Only non-empty content is still an RTF. Closes Apache Xalan conformance cases boolean43 and boolean87.

  • Top-level (global) variables and parameters now resolve references to one another regardless of declaration order. XSLT 1.0 section 11.4 lets a global reference any other global, but they were evaluated once top-to-bottom, so a forward reference (a variable declared before the one it uses) saw an undefined value and resolved to empty. Globals now evaluate to a fixpoint: each pass re-evaluates the unsettled ones with the latest bindings, so a dependency chain in any document order settles, while a genuine circular reference stops at the pass cap. Closes Apache Xalan conformance cases variable33, variable34, and variable35.

  • The XPath string-value of a comment or processing-instruction node is now that node's own character data, instead of the empty string. XPath 1.0 sections 5.6 and 5.7 define a comment's string-value as its text and a PI's as its data, so value-of select="comment()" (and matching, sorting, or comparing on such nodes) returned nothing. The node's stringValue was conflated with the text-only concatenation used for an element's string-value, where comments and PIs correctly contribute nothing; that concatenation now uses a separate textContent, so an element's string-value still excludes comment and PI text while a comment or PI reports its own. Closes 24 Apache Xalan conformance cases across the node, axes, position, select, modes, conflictres, and variable categories.

  • A named template (xsl:call-template) is now selected by import precedence, not document order. When an import and an include both define a template with the same name, the included one carries the including unit's (higher) precedence and must win (XSLT 1.0 section 6), but the lookup took the first match by position, which is the imported template (imports fold in first). The call now picks the highest-precedence definition. Closes Apache Xalan conformance cases namedtemplate17, namedtemplate18, namedtemplate19, and namespace13.

  • A literal result element brought in through xsl:include now keeps the namespace nodes it copies under XSLT 1.0 §7.1.1, instead of losing them in an optimized build. An included stylesheet's declarations are absorbed after collectUnit returns, and each literal result element resolves its 7.1.1 namespace nodes by walking the weak parent chain back to its own (included) stylesheet element. Those elements were retained only in a write-only retainedRoots array, so under optimization ARC released them before the deferred resolution ran and the ancestor chain (the xmlns declarations in scope) vanished, dropping every namespace node the included element should have carried. The result was correct in a debug build and wrong in release. The compose step now pins the retained roots across the resolution with withExtendedLifetime. Imports were unaffected because they compile eagerly while their tree is still a live local. Catches Apache Xalan conformance case lre/lre05 (exclude-result-prefixes scoping across an include), found by running the Apache xalan-test corpus.

  • 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.

  • A defaulted or fixed xs:IDREF/xs:IDREFS attribute now participates in document-scoped ID/IDREF resolution (cvc-id). When the instance omits the attribute its default/fixed value applies, so a defaulted IDREF must still resolve to a matching xs:ID; the reference was recorded only for attributes physically present, so a dangling defaulted IDREF was wrongly accepted. The supplied value is now recorded for ID/IDREF checking exactly as a present value would be (shared by the tree and streaming validators, which both call validateAttributes). A required absent attribute is still reported as missing, and a defaulted IDREF that does resolve to an ID stays valid. Catches idZ012. XSTS invalid-instances-accepted 20 → 19, valid-instances-rejected held at 0.

  • An xs:all group with minOccurs="0" now requires its members once the group is present in the instance. The group's minOccurs="0" makes the GROUP optional (it occurs zero or one times), not each member optional: an absent group (no matching children) is valid, but a present group (any matching child) must still satisfy every member's own minOccurs. The parser previously forced each member's minOccurs to 0 when the all-group was optional, so a non-empty but incomplete optional all-group (a required member missing) was wrongly accepted. Members now keep their declared occurrence, and the absent-group case is handled at validation time by skipping the required-member check only when the all-group contributes no children at all; a required all-group (minOccurs="1") is unchanged. The fix is applied identically on the tree and streaming paths (both call allStructureErrors). Because members now keep their true minOccurs, the particle-restriction check (cos-group-restrict) also sees them correctly: a complexContent restriction that relaxes a required member of an optional all-group base is now rejected too, so this catches mgZ001 (instance) and particlesK006 (schema). XSTS invalid-instances-accepted 21 → 20 and invalid-schemas-accepted 18 → 17, valid-instances-rejected and valid-schemas-rejected both held at 0.

  • A nilled element (an xsi:nil="true" on a nillable declaration) whose declaration carries a fixed {value constraint} is now rejected during instance validation (cvc-elt.3.2.2). A nillable element MAY declare a fixed value (the schema is valid); the conflict arises only when an instance actually nils such an element, since a nilled element has no value and so cannot take its fixed one. The check rides the existing nilErrors chokepoint (shared by the tree and streaming validators), using the matched particle's constraint with the by-local-name element-constraint map as a fallback, so it is enforced identically on both paths; a nillable element that supplies its fixed value, and a nilled element with no fixed value constraint, are both unaffected. Catches addB065. XSTS invalid-instances-accepted 22 → 21, valid-instances-rejected held at 0.

  • A top-level (global) element or attribute declaration carrying a form attribute is now rejected even when it lives in an INCLUDED or IMPORTED document. The form attribute is permitted only on a local declaration (XSD 1.0 schema-for-schemas: topLevelAttribute/topLevelElement have no form, since a global component's qualification is fixed); the check (topLevelDeclarationFindings, which also enforces a global needing a name and forbids the other local-only use/ref/minOccurs/maxOccurs) already existed but ran only over the document being compiled, so a violation in an xs:include/xs:import/xs:redefine target was missed and the composed schema wrongly accepted. The same global-component check now runs over the other schema-document roots in the composition (the main document is excluded as already covered, and redefine entries are skipped because each schema root walks its own redefine children). The constraint is absolute and document-position-independent, so no valid schema is newly rejected; libxml2 rejects both cases for the same reason. Catches attQ016 (import) and attQ018 (include). XSTS invalid-schemas-accepted 20 → 18, valid-schemas-rejected held at 0.

  • An identity-constraint field whose DECLARED target is a complex type with non-simple content is now rejected even when the instance node has no element children. The earlier fix rejected a field whose selected node carries element children (structural); this adds the case where the field's declared type is an empty or element-only/mixed complex type (an attributes-only element resolves to an empty-content complex type, so its instance node is childless yet not simple-typed). The field's target type is resolved through the schema type model (the selector target's type, then the field's same-named child element); a simple or simpleContent target, a type reached only through a named reference, a complex field path, or an unresolvable target is left alone, so no valid field is rejected. Catches idG006. XSTS invalid-instances-accepted 23 → 22, valid-instances-rejected held at 0.

  • A substitution-group block on a head element now bars substituting a member whose INLINE (anonymous) type reaches the head's type by a blocked method. The substitution-group filter already dropped a member when the head's {disallowed substitutions} (or the head type's {prohibited substitutions}) blocked the member's derivation method, but only for members with a NAMED type recorded in the derivation backbone; an element with an inline complex type had no backbone entry, so a member extending the head's type was wrongly kept even when the head blocked extension. Inline-typed elements now record their derivation under a synthetic key (an element key plus a # marker no QName can contain, so it cannot collide with a real type and is unreachable as any other derivation base), so the filter sees the inline member's method. A member reaching the head's type by an UNblocked method, a plain inline type with no derivation, and same-local-name elements in different namespaces are all unaffected. Catches disallowedSubst00105m (and a sibling). XSTS invalid-instances-accepted 25 → 23, valid-instances-rejected held at 0.

  • An identity-constraint (key/unique/keyref) field that identifies a node with complex content is now rejected during instance validation. Per cvc-identity-constraint.3 / c-fields-xpaths a field must evaluate to a node with a simple type (a simple-typed element, an element with simple content, or an attribute); the cardinality clause (at most one node) was enforced, but the simple-content clause was not. The check is structural and false-positive-free: a field whose selected node is an element carrying element children has complex (element-only or mixed) content and can never be simple-typed, so it is flagged, while a simple-typed or simple-content element (text only), an empty or text-only node, and an attribute are left alone. Catches idK012. XSTS invalid-instances-accepted 26 → 25, valid-instances-rejected held at 0.

  • The attribute-use uniqueness check (ag-props-correct.2 / ct-props-correct.4) now flattens a redefined attribute group's self-reference and excludes prohibited uses. When an attribute group flattens its uses through <attributeGroup ref> references, a reference to a redefined group whose redefinition self-references its former self previously stopped at the cycle guard, so the original's attributes were never reached and a clash they cause was missed. The self-reference now expands to the group's pre-redefine base definition (already retained for this purpose), so the original's attributes join the set; a genuine non-redefine cycle still terminates without contributing. Separately, a use="prohibited" attribute is now excluded from the duplicate-name and single-ID counts, since a prohibited use is a removal marker, not a member of {attribute uses} (XSD §3.4.2/§3.6.2); this also keeps a self-reference that re-imports an attribute and then prohibits it from reading as a duplicate. Catches attQ011 and attQ017 (an attribute group referencing both a global attribute and a redefined self-referencing group that supplies the same-named attribute). XSTS invalid-schemas-accepted 22 → 20, valid-schemas-rejected held at 0.

  • A redefinition of an attributeGroup that SELF-references the original and also directly re-declares one of its attributes is now rejected. The self-reference re-imports all of the original's attribute uses, so a same-named direct re-declaration produces two attribute uses of the same name, a duplicate forbidden by ag-props-correct.2. The check fires only for a re-declared, non-prohibited attribute the original directly declares, and only when both the original and the re-declaration land in no namespace (a qualified attribute's target namespace depends on cross-document form/chameleon resolution, so it is not compared, staying lenient); a self-referencing redefinition that adds genuinely new attributes (attgC007/attgC038/groupB018) stays valid. Catches schM8 (c-g2 self-references and re-declares a1). XSTS invalid-schemas-accepted 23 → 22 (schM8), valid-schemas-rejected held at 0.

  • A redefinition of an attributeGroup that WIDENS a re-declared attribute's type is now rejected. A redefinition is a restriction (src-redefine / cos-ct-restricts), so a re-declared attribute's type must derive by restriction from the original's. The check is built-in-only and therefore certain: it fires only when both the original and the re-declared attribute name a built-in type (resolved to the XSD namespace) and the re-declared built-in does not derive from the original's in the XSD derivation lattice (int does not derive from boolean). A user, inline, or absent type on either side is not compared (declines, staying lenient): an absent original type is anySimpleType, which any type validly restricts, a user/inline type's derivation is not resolvable here, and a user type whose local name coincides with a built-in (a {urn:x}int) resolves to a different namespace and is not mistaken for the built-in, so no valid restriction is rejected. Catches schM4 (b1 re-typed boolean → int). XSTS invalid-schemas-accepted 24 → 23 (schM4), valid-schemas-rejected held at 0.

  • A redefinition of an attributeGroup that adds an attribute the original lacks through a NON-self attributeGroup reference is now rejected. A redefinition is a restriction of the original (src-redefine / cos-ct-restricts), so it may not introduce an attribute the original does not declare unless the original carries an attribute wildcard. The add-attribute check previously resolved only the redefinition's DIRECT declarations and was suppressed whenever the redefinition carried any attributeGroup reference, which conflated a self-reference (re-importing the original's own attributes, valid) with a non-self reference (injecting foreign attributes, invalid). The redefinition's contributed attribute names are now resolved through its non-self references and the suppression keys on a genuine self-reference (matched by namespace and local name); resolution declines, staying lenient, whenever the contributed set is not fully knowable (an unresolved group, an attribute-by-ref, an anyAttribute wildcard, or a further nested reference). This catches attgC028 (a redefinition whose ref="car" replaces the original's attributes with foreign ones) while keeping attgC007/attgC038/groupB018 (added attributes alongside a self-reference) valid. XSTS invalid-schemas-accepted 25 → 24 (attgC028), valid-schemas-rejected held at 0.

  • An unprefixed type/base/itemType/memberTypes reference to a type declared in a non-empty target namespace is now rejected as undeclared. With no default xmlns, an unprefixed QName resolves to the absent namespace, so type="t" is {}t, not {urn:x}t where the type lives; isUndeclaredReferenceType matched the type by local name and accepted it. It now mirrors isUndeclaredReferenceRef: a no-namespace reference is undeclared when the schema has a non-empty target namespace (a chameleon-included global, and an imported no-namespace type via the foreign pools, remain accepted). XSTS invalid-schemas-accepted 27 → 25 (addB009/test66059 and one more), valid-schemas-rejected held at 0.

  • A schema reference (ref/substitutionGroup) that resolves to the XSD namespace is now rejected as undeclared. The XSD namespace declares no referenceable user component, so isUndeclaredReferenceType already rejected a type/base in it, but isUndeclaredReferenceRef fell through to a lenient accept. This is reached when an unprefixed reference picks up a default xmlns of the XSD namespace, so it is not in the target namespace where the schema's own components live: e.g. xmlns="http://www.w3.org/2001/XMLSchema" targetNamespace="foo" with <element ref="root"/> resolves to {XSD}root, not {foo}root, and is undeclared. A schema that itself targets the XSD namespace, and a genuinely loaded XSD-namespace document, are unaffected. XSTS invalid-schemas-accepted 29 → 27 (xsd013/xsd014), valid-schemas-rejected held at 0.

  • Unique Particle Attribution now checks a complexContent extension against its EFFECTIVE content model (the base type's content followed by the extension's), not just the extension's own particles. A base whose trailing particle can recur or end optionally competes with the extension's leading particles, so an element could match either across the extension boundary; that cross-boundary non-determinism was missed and the schema wrongly accepted. The base content is resolved and prepended only for a local (target-namespace), non-cyclic base chain; a foreign, built-in (xs:anyType), unresolved, or cyclic base leaves the check on the extension's own content, so no valid schema is newly rejected. XSTS invalid-schemas-accepted 31 → 29 (particlesZ022 and one more), valid-schemas-rejected held at 0.

  • 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 (maximum > inputLength + 1) is now saturated at its minimum, exactly as an unbounded counter is, because the value can never reach that maximum under any continuation (the cap is at most inputLength + 1), so belowMaximum always holds and every value at or above the minimum is matching-equivalent. A document that did not terminate within a minute against such a model (the Z035 worst case) now validates in linear time, with no change to any accept/reject decision (full XSTS suite still 0/35/0/26; an adversarial no-caps reference-matcher differential confirms no verdict changed, including nullable bodies and the occurrence boundary). Guarded by ContentMatcherWorstCaseTests. A model with several moderate (input-reachable) maxOccurs nested deeply still enumerates a large active set and is tracked under #192.

  • A reference to a model group whose content is an all group must have maxOccurs of 1 and minOccurs of 0 or 1 (XSD 1.0 cos-all-limited, #158). A <group ref> that resolves to an <xs:all> group with maxOccurs="2" is now rejected (corpus particlesEa025); the direct <all> element was already checked. XSTS invalid-schemas-accepted 36 -> 35, valid-schemas-rejected held at 0.

  • A complexContent restriction that narrows an element to a type derived from the base element's type by extension is now rejected (XSD 1.0 cos-particle-restrict NameAndTypeOK.2.2, #158): the element-type-derivation check follows the derivation chain across restriction steps only, so an extension-derived type is not a valid restriction (corpus particlesIj008). Built-in lattice and union-base derivation are unchanged. XSTS invalid-schemas-accepted 37 -> 36, valid-schemas-rejected held at 0.

  • A fixed value on an element whose type is a complex type with simpleContent is now compared in the simple type's value space (cvc-elt.5.2.2.1), not as a raw string (#202), so 05 satisfies an xs:int fixed="5". The complex-element fixed check on both the tree and streaming paths now passes the simpleContent type as the value type, in lockstep, as the simple-typed-element path already does.

  • The streaming XSD validator now applies an element's default/fixed value constraint to its simple content (#200): an empty element takes its constraint value, and that value (not the empty string) is validated against the type (cvc-elt.5.1.2 / cvc-elt.5.2.2.2). The constraint comes from the matched particle (keyed by namespaced identity), not the flat by-local-name map, so two same-local- name elements in different types do not collide. Both paths share one effectiveSimpleText(particleConstraint:) helper, so an empty element with a valid default is no longer falsely rejected and streaming agrees with the tree path.

  • The streaming XSD validator now agrees with the tree validator on xsi:type and type resolution (#186). Type resolution is keyed by namespaced identity ({ns}local) through in-scope prefix bindings accumulated down the element stack: the xsi:type override type, the undeclared-type check, and root/child element declarations (so two globals or types sharing a local name in different namespaces no longer collide; root resolution mirrors the tree path, with the bare local-name map used only for a no-namespace root). The streaming path now runs the full cvc-elt.4 gate the tree path runs (a blocked, abstract, not-validly-derived, list/union, or anyType-for-anySimpleType override, and an abstract declared type carrying no xsi:type, are rejected identically). And it resolves a type from an element's own xsi:type where there is no governing declaration: an undeclared root (the W3C Sun target-namespace fallback) and a strict-wildcard child, which the streaming path previously rejected as undeclared.

  • An attribute declaration may no longer land in the XSI namespace (XSD 1.0 Schema Component Constraint "xsi: Not Allowed", §3.2.6). A schema may target the XSI namespace and declare unqualified attributes (they land in no namespace), but a top-level attribute, or a local one made qualified by form="qualified" or attributeFormDefault="qualified", lands in XSI and is now rejected (corpus attKb018a, attKa015) while unqualified ones stay valid (attKb018, attKc018). XSTS invalid-schemas-accepted 39 -> 37, valid-schemas-rejected held at 0, no other bucket moved.

  • A prefixed reference (type/base/itemType/ref/substitutionGroup/memberTypes) whose namespace is never imported is now rejected on the cross-document skip path (XSD src-resolve). When a schema declares imports but loads no external document, the pool-based check stands down; a reference into a namespace that is neither the target, the XSD or XML namespace, nor named by any xs:import can resolve to no component and is flagged. To stay sound when the import closure is unknown, the check stands down whenever any external carries a schemaLocation (an unloaded located document may transitively import the referenced namespace), applying only when every external is location-less. XSTS invalid-schemas-accepted 43 -> 39 (schZ011_a-d), valid-schemas-rejected held at 0, no other bucket moved.

  • A non-nillable element may now carry no xsi:nil attribute at all, whatever its value (cvc-elt.3.1). Previously only xsi:nil="true" on a non-nillable element was rejected; xsi:nil="false" slipped through. ComplexValidator.nilErrors now flags the presence of any xsi:nil on a non-nillable element, while a nillable element still accepts xsi:nil="false" (explicitly not nilled). XSTS invalid-instances-accepted 28 -> 26, no other bucket moved (valid-rejected held at 0 across all 14383 groups).

  • An element with neither a type attribute nor an inline type, but a substitutionGroup, now takes the type definition of its head (XSD 1.0 3.3.2) instead of defaulting to anyType. XSDParser.elementType resolves the head declaration (transitively, guarding a substitutionGroup cycle) and uses its type, so an instance of such a member is validated against the head's type. XSTS invalid-instances-accepted 29 -> 28 (typeDef00204m), no other bucket moved (valid-rejected held at 0 across all 14383 groups).

  • xsi:type naming anyType is now rejected on an element whose declared type is anySimpleType (cvc-elt.4.3.2.1). anyType is the ur-supertype of every type, so it is never validly derived from anySimpleType (its own subtype). The general not-derived check stays silent on ur-type declared types to avoid false positives; this adds only the provably-invalid anyType-for-anySimpleType pairing, leaving anyType valid on an anyType-declared element. XSTS invalid-instances-accepted 30 -> 29 (stZ056), no other bucket moved.

  • dateTime, date, gYearMonth, and gYear now reject the year 0000. XSD 1.0 has no year zero (the year before 0001 is -0001), so 0000-01-01T00:00:00 and the other 0000 forms are not valid values. DateTimeParser.parseYearOnly rejects a zero magnitude for both signs, so -0001 and other negative years still parse. XSTS invalid-instances-accepted 31 -> 30 (dateTime011), no other bucket moved (valid-rejected held at 0).

  • Fixed a dead rootLocation fallback in XSDParser.containerLocationMap. The map value type is itself String?, so loc ?? rootLocation bound the coalescing at the outer optional and never used rootLocation (the compiler flagged it), leaving the compile root mapped to nil even when a circular import had loaded it under a schemaLocation. An explicit String? annotation keeps the coalescing at the String level so the root inherits its location. Conformance is unchanged (XSTS still settles at 0/43/0/31); the SchemaNameUniqueness root-index dedup case is retained, not removed, because removing it on the theory it was now redundant regressed three valid schemas to rejected, so it does real work beyond the fixed fallback.

  • Bounded the content-model matcher's active-configuration set per counter (ContentNFA/ContentMatcher). Each occurrence counter now saturates at min(maximum, inputLength + 1), and an unbounded counter at its minimum (every higher count is matching-equivalent, since canExit only tests the minimum and belowMaximum is vacuous), instead of every counter sharing one inputLength + 1 ceiling. Validating a document with a long run of one element against a content model with nested occurrence bounds (e.g. sequence{1,1000}(e1{1,unbounded}) over ~1000 elements) no longer expands the epsilon-closure toward inputLength raised to the counter count: the XSTS particlesQ005 instance went from a multi-minute near-hang to about two seconds, with identical validation results (the full XSTS suite still settles at 0/43/0/31, and the suite, which previously did not terminate, now completes).

  • Silenced an optional-interpolation warning in the union dedup signature (XSDParserUnion): the localName interpolation now guards with ?? "" to match the name-attribute interpolation on the same line. The signature is an internal dedup key compared only against same-run signatures, so the change is behavior-preserving (it removes the Optional("…") rendering, not any key collisions).

  • Documentation accuracy sweep: brought stale current-state figures into line with the code and the enforced gate. The README test count (986/169 -> 1448/258) and XSTS status quad, the roadmap gates table, and the Antigravity onboarding baselines now read the enforced 0 / 43 / 0 / 31; the encoding coverage (the README feature list and the Parsing.InputEncoding doc comment) now lists the full shipped set (the ISO-8859 family, Windows code pages 1250-1258, KOI8-R/U, and the CJK multi-byte encodings) instead of a three-entry subset; the "remaining parity" section now states that libxml2 documented-surface parity is complete (epics #71/#74/#105 closed) with production readiness (#167) as the remaining work; the README check-all.sh expansion lists the validation-coverage, validation-field, and changelog gates; and the SECURITY.md reporting address is corrected.

  • Replaced XSD content-model occurrence unrolling with a counted automaton. ContentMatcher now tracks occurrence counters in active configurations instead of copying particle bodies into states, preserving exact finite {m,n} behavior for high numeric bounds and removing the instance-time silent widening at the old state ceilings. XSD occurrence attributes are preserved as decimal magnitudes beyond Int range so maxOccurs="100000000000000000000" no longer falls back to the default 1.

  • XSTS invalid-schemas-accepted (#145): global element references now carry the referenced declaration's type name, fixed/default constraint, nillable flag, and block metadata into Particle-Valid-Restriction. A local element with an implicit xs:anyType therefore no longer satisfies NameAndTypeOK against a same-named global ref typed as xs:decimal, closing elemZ028e while preserving a valid narrowing to xs:short. invalid-schemas-accepted 44 -> 43, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): substitution-group closure now stops chaining through an intermediate member whose own block contains substitution. The blocked element itself may still substitute for its direct ancestor, but its descendants are no longer added to higher ancestor heads, so elemZ027_c (a restriction through a -> b(block=substitution) -> c -> d) is rejected while the valid sibling where the blocked member itself is used remains accepted. invalid-schemas-accepted 45 -> 44, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a substitution-group head typed xs:anySimpleType now admits simple type members and complex types with simpleContent, but rejects element-only, empty, and mixed complex-type members. The shared derivation oracle treats anySimpleType as a broad ur-type for other restriction checks, so the substitution-group rule now adds its own value-space guard before accepting the affiliation. stZ048 is rejected while the valid simple/simpleContent near-misses remain accepted. invalid-schemas-accepted 46 → 45, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexContent extension that explicitly changes mixedness, or that adds element content while defaulting to mixed="false", must match the base type's mixed setting. This rejects ctZ010d (mixed base extended with new element content by an omitted/default-false derived type) while preserving the valid ctZ012b near-miss (empty extension inherits the mixed base) and the invalid ctF008 explicit mixed mismatch. invalid-schemas-accepted 47 → 46, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a simpleContent restriction that supplies an inline simpleType must use a type that is itself a valid restriction of the base complex type's simple content. A list of xs:int is not a restriction of an atomic xs:decimal content type, so particlesZ018 is now rejected. Valid inline atomic restrictions become the effective simpleContent type for instance validation, including facets declared beside the inline type. invalid-schemas-accepted 48 → 47, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a constraining facet may not be applied where a simpleContent restriction's base chain resolves to xs:anySimpleType, the ur simple type, which carries no constraining facets (XSD Part 2 §3.2.1); stZ010 (a complex type extending xs:anySimpleType, restricted by another that adds minLength) is now rejected. The base chain is followed through local complex types' own simpleContent bases; a base that bottoms at any other built-in, a simple type, or an external/unresolved type is left alone, and a restriction supplying its own nested simpleType base is skipped (the facet applies to that), so a facet on a string-, int-, or other real-typed simpleContent stays accepted. invalid-schemas-accepted 49 → 48, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145, #161): a redefinition of an attribute group may not ADD an attribute the original does not declare (a superset is not a restriction, src-redefine / cos-ct-restricts); schM3 (redeclaring the original's attributes plus a new b4) is now rejected. The base is resolved by namespaced identity with a {}name chameleon fallback (a no-targetNamespace redefined schema is absorbed into the redefining namespace). To stay false-positive-free the added-attribute check runs only when the original's attribute set is fully known from its direct declarations (the original has no nested attributeGroup reference) AND the redefinition itself carries no attributeGroup reference: a self-reference brings in the original's attributes, so declaring further ones alongside it is a valid redefine (W3C attgC007/attgC038/groupB018, and test75083/xsd003b), not an addition. invalid-schemas-accepted 51 → 49, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; five self-reference over-rejections surfaced by the gate were fixed before commit), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145, #161): a non-self-referencing model group redefined inside xs:redefine must be a valid RESTRICTION of the group it redefines (cos-group-restrict / src-redefine 6.1.2), so its content model may not widen the original's language. The check reuses the same particle-restriction oracle as complex-type restriction, resolving the original group by namespaced identity (with a chameleon {}name fallback for a no-targetNamespace redefined schema). Cases now rejected include adding required content to an empty base (schZ006), dropping a required all member so documents without it become valid (schL1), reordering an all (schL5/L6, since the spec's Recurse rule is order-preserving even for all), and growing a group to a superset (schL8), plus the multi-document and chained redefines schN4/5/6/7 and schL10. To stay false-positive-free the check skips a redefinition carrying a self-reference (governed by the self-reference well-formedness rule and not re-parseable here) and declines to judge a base containing a maxOccurs=0 "pointless particle" (the contested normalization case where W3C accepts a redefinition re-enabling that member, mgO013); a genuine restriction (dropping an optional member, an unchanged redefinition) is accepted. invalid-schemas-accepted 64 → 51, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; an over-rejection of mgO013 surfaced by the gate was fixed before commit), no other bucket moved. Disclosed under-rejections: self-referencing redefinitions (schN10) and maxOccurs=0-base redefines.

  • XSTS invalid-schemas-accepted (#145, #161): a redefinition of an attribute group restricts the original, so it may neither eliminate a required attribute the original declares, nor re-introduce a prohibited one as usable, nor relax a fixed value the original sets (src-redefine / cos-ct-restricts). attgZ002 (dropping the required att1), attgZ003 (re-adding the prohibited att1 as optional), and schM10 (changing a fixed attribute to a default one) are now rejected. Re-declaring a prohibited attribute AS prohibited keeps it prohibited and stays valid, the drop-required check is skipped when the redefinition uses a nested attributeGroup reference (which could re-introduce the attribute), and the fixed-value check fires only when the redefinition declares the matching attribute as non-prohibited with a differing fixed value (keeping the same fixed value, prohibiting it, or omitting it is left alone), so no valid redefinition is rejected. invalid-schemas-accepted 67 → 64, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • A complex type whose simpleContent derives from a list or union simple type now keeps that variety, so its element content is validated as a list or union rather than collapsing to an atomic value space. Previously simpleContentType dropped the base's variety, so an invalid item in a list-typed simpleContent (e.g. a non-int in a list-of-int) was accepted. A valid list/union value is unaffected. (Under-rejection fix surfaced by the libxml2 differential; the W3C suite does not exercise it, so the conformance counts are unchanged.)

  • XSTS invalid-schemas-accepted (#145): a substitution-group member whose type is a list or union derives only from anySimpleType, so it may affiliate only to a head typed anySimpleType (or to the same type, or as a recorded restriction of the head's type). The substitution-type check skipped derivation entirely when either the member or head type was a list/union; a list/union-typed member affiliated to an unrelated head type (addB141: a union member of a list-typed head) is now rejected, while a list/union member of an anySimpleType head and a restriction of a list head remain valid. invalid-schemas-accepted 69 → 67, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): a block on a LOCAL element declaration is now keyed by the element's instance (qualified) name in the derivation backbone, so an xsi:type substitution blocked on an unqualified local element is barred. The namespaced backbone keyed every element by the target namespace, but a local element is in the target namespace only when qualified (its own form, else the schema's elementFormDefault); an unqualified local element's block went unfound (typeDef00801). invalid-instances-accepted 32 → 31, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): an instance xsi:type may not name an abstract type. An abstract type cannot be the type of an instance element (cvc-elt.4.3.2), so it is invalid as a substitution even when the declared type would otherwise admit it (typeDef01101); a concrete type is unaffected. This complements the existing check for an abstract DECLARED type with no xsi:type. invalid-instances-accepted 34 → 32, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): an element with a fixed value constraint and a mixed content type must carry its fixed character value with no element children (cvc-elt.5.2.2.2.1). The fixed check validated only the concatenated text, so a mixed fixed element whose text matched but which also contained child elements (ctZ013) was accepted; element children are now rejected. An empty element (which takes the fixed value) and text equal to the fixed value remain valid. invalid-instances-accepted 37 → 34, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): a simpleContent derivation whose base is another complex type with simpleContent now resolves through the chain to the underlying simple type, so the element's text is validated against it. A type restricting or extending a complex type that ultimately derives from a built-in (e.g. Test restricts Test2 which extends xs:int) validates its content as that built-in rather than as unconstrained string; baseTD00101's non-int text is now rejected. The base resolution recurses through complexTypeNodes, accumulating facets, with a cycle guard. invalid-instances-accepted 39 → 37, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): use="prohibited" on an attribute now excludes it from the complex type's effective {attribute uses} (XSD 1.0 §3.4.2) during instance validation, so a prohibited attribute is undeclared: its presence is rejected when no attribute wildcard admits it (attJ), and still admitted (and validated per the wildcard's processContents) when one does (attZ002). The prohibited declaration is kept in the type rather than dropped, so schema-validity checks still see it (the invalid-schemas-accepted count is unchanged). invalid-instances-accepted 43 → 39, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): an element may now carry at most one attribute whose type is xs:ID (XSD 1.0 §3.4.6). Two ID-typed attributes admitted through an xs:anyAttribute wildcard (the schema itself stays valid, as a type with two declared ID attribute uses is already a compile error) are rejected at instance time. The count covers both a matched declared use's type and the global declaration a matching wildcard admits, including a restriction of xs:ID; xs:IDREF/xs:ENTITY and a lone ID attribute are unaffected. invalid-instances-accepted 45 → 43, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#146, #147): an identity-constraint (unique/key/keyref) field that selects an absent attribute or an empty element whose declaration carries a default/fixed now takes that value as its identity-tuple component, so two members can collide on the defaulted value (idG011/idG012 default, idF016/idF017 fixed). The schema's default/fixed value constraints on identity-field targets are gathered at compile time, keyed the same way as the field types, and threaded through to the identity validator. The substitution is exact about absent versus present-empty: an @attr field is defaulted only when the attribute is ABSENT (empty node-set), never when it is present with value ""; a . element field is defaulted only when the selected element has no character content. Applying a default only adds an identity value where the schema declares one, so it can create a genuine duplicate-key detection but never reject a valid instance. invalid-instances-accepted 50 → 45, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): a block on a substitution-group head's TYPE, not only on the head element, now bars a member from substituting. The substitution-group filter drops a member whose type reaches the head's type by a blocked derivation method, using the union of the head element's {disallowed substitutions} and the head type's {prohibited substitutions}; previously only the head element's own block was consulted, so a block="restriction" on the head's complex type let a restriction-derived member substitute. The element-level block="substitution" outright bar is unchanged, and a member derived by a method the block does not list still substitutes. invalid-instances-accepted 53 → 50, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#181, #147): the xsi:type not-derived check now also flags a complex substitute that records no base of its own. Such a type derives only from anyType, so it cannot validly substitute for a non-ur complex declared type; naming the declared type's own complex ancestor, or an unrelated baseless complex type, is rejected. A complex substitute is in scope even when absent from the derivation backbone (its non-derivation from a non-ur type is certain), while an atomic substitute must still be in the backbone so a built-in stays silent. A genuine extension/restriction substitute is accepted, and a baseless complex substitute under an anyType declaration is admitted. invalid-instances-accepted 60 → 53, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#181, #147): the xsi:type not-derived check (cvc-elt.4.3.2.1) now covers ATOMIC simple substitutes, not only complex ones. An xsi:type naming an atomic type that is not derived from the element's declared type, such as a sibling restriction or the declared type's own ancestor, is rejected. The check still fires only when both the substitute and the declared type are recorded in the namespaced derivation backbone with no path between them, so a built-in substitute or declared type (absent from the backbone) and the ur-types stay silent (under-reject), avoiding any built-in-derivation false positive; a genuinely derived restriction substitute is accepted. invalid-instances-accepted 66 → 60, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#181, #147): an xsi:type naming a list or union simple type is now rejected unless the element's declared type is a ur-type (anySimpleType/anyType). A list or union type derives only from anySimpleType (XSD Part 2), so it cannot validly substitute for an element of any more specific atomic or complex type (cvc-elt.4.3.2.1); naming one on, say, an xs:int-declared element is invalid. A genuinely derived (restriction) substitute, a list/union under an anySimpleType declaration, and a declared type that is itself a list or union are all left alone, so no valid instance is newly rejected. The declared type is resolved through the element-ref / type-reference chain, so the check also fires for an element reached via <xs:element ref> whose global declaration is atomic. This widening became safe once the derivation backbone was keyed by namespaced identity (#186); the conservative atomic/built-in not-derived cases remain under-rejected pending the full type-identity relation. invalid-instances-accepted 81 → 66, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#186, #147): the instance-validity derivation and substitution tables are now keyed by namespaced identity ({ns}local) instead of bare local name, so two same-local-name types or elements in different namespaces no longer collide. DerivationTables keeps its bare-keyed fields for the schema-validity machinery (Particle-Valid-Restriction and the consistency rules) and gains parallel namespaced fields built in the same pass; only the instance consumers (the xsi:type blocked/not-derived/abstract checks, the substitution-group closure, and referenceParticle) read the namespaced tables, reducing a type:{ns}local reference, a bare name, or an element ref to a {ns}local key. A key miss stays permissive (no constraint), so the re-keying cannot over-reject; the single-document corpus behaviour is unchanged. With the namespaced backbone in place, a schema's blockDefault now supplies the {disallowed substitutions} (block) of every element and complex type that states none (own block overrides; block="" clears; intersected with the methods each component admits), without one namespace's defaults shadowing another's. invalid-instances-accepted 89 → 81, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0; an adversarial review exercised same- and cross-namespace substitutions, no-targetNamespace keys, transitive substitution groups, and blockDefault override cases), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): a complex type's effective attribute wildcard is now the INTERSECTION of the anyAttribute wildcards it draws from its own declaration and each referenced attribute group (XSD 1.0 cos-aw-intersect), not their union. An attribute is admitted only if every source admits it, so two attribute groups constraining disjoint namespaces (e.g. urn:a and urn:b) admit nothing and an attribute in either is rejected. A type with a single anyAttribute source is unchanged (intersection with no other source is the lone wildcard), and a complexContent extension's union with its base wildcard is unaffected. New Wildcard.intersection covers the namespace cases (##any identity, enumerated set intersection, enumerated-with-##other exclusion) and keeps the less restrictive result for the inexpressible ##other/##other and mismatched processContents cases, so the change never over-rejects. invalid-instances-accepted 94 → 89, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): identity-constraint (unique/key/keyref) field values are now compared across the decimal-derived numeric family, so two fields typed by different members of that family with the same number collide. A field typed xs:decimal with value 1 and a field typed xs:unsignedByte with value 1 (e.g. supplied via xsi:type) are the same value and violate a unique, where previously a difference in declared base type made them compare unequal. The unification is confined to xs:decimal and the integer types, which share one value space; distinct primitive spaces (a number versus a string, or float versus double) are never unified, so no valid instance is newly rejected. invalid-instances-accepted 95 → 94, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#146, #147): the ur-type xsd:anyType now processes its element and attribute wildcards as lax, not skip (XSD 1.0 §3.4.7). A child element or attribute of an untyped element (or an element typed xs:anyType) that has a global declaration is validated against it; undeclared content is still admitted. Previously such content was skipped entirely, so an invalid declared child (e.g. a duration-typed attribute with value "P" reached through <xsd:anyAttribute/>) was accepted. The same change required making the wildcard lookup of a global element declaration namespace-exact: a global is always in the schema target namespace, so an unqualified instance element matches only a no-namespace global (the qualified key element:{}local already covers it); the previous bare-local-name fallback conflated namespaces and would have wrongly validated an unqualified child against a same-named namespaced declaration. invalid-instances-accepted 101 → 95, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0; the spec's own errC007 anyType-lax case validates correctly), no other bucket moved.

  • XSTS invalid-instances-accepted (#147): block="substitution" (or a block set containing it, including #all) on a substitution-group head now bars every member from substituting even when the head is an abstract, untyped element. The substitution block is type-independent, but filterSubstitutions previously applied it only to a head that also had a type, so an untyped head's members still substituted. invalid-instances-accepted 107 → 101, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-instances-accepted (#181, #147): an instance xsi:type naming a complex type must be derived from the element's declared type (cvc-elt.4.3.2.1). A complex xsi:type on a different branch of the type hierarchy (not derived from the declared type) is now rejected, where previously only a derived-but-blocked substitution was caught. Conservatively scoped to substitutes that resolve to a complex type recorded in the derivation backbone, so simple/list/union substitutes and unresolvable references are left alone (no over-rejection). invalid-instances-accepted 133 → 107, no false positive (valid-instances-rejected held at 0, valid-schemas-rejected held at 0), no other bucket moved. Disclosed bound: the derivation backbone is keyed by bare local name (shared with the existing block-substitution rule), so a cross-namespace same-local-name collision could in principle be misjudged; the corpus exercises none, and the holistic fix is to key the backbone by namespaced type identity.

  • XSTS invalid-schemas-accepted (#145): an xs:include and xs:redefine require a schemaLocation attribute (only xs:import's is optional); one declared without it is rejected. invalid-schemas-accepted 71 → 69, no false positive (valid-schemas-rejected held at 0; a required-attribute check with no name resolution cannot over-reject), no other bucket moved.

  • XSTS invalid-schemas-accepted (#161): a schema referenced by xs:redefine must have the same targetNamespace as the redefining schema, or none (chameleon), exactly as for xs:include; redefining a different-namespace schema is rejected. The existing include-namespace-matching check now also covers redefine, selecting the loaded redefined schema document (not the redefine wrapper) so its real target namespace is compared. invalid-schemas-accepted 74 → 71, no false positive (valid-schemas-rejected held at 0; an adversarial review confirmed same-namespace, chameleon, and no-namespace redefines are not flagged), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an identity-constraint selector/field xpath may not use an undeclared namespace prefix. Every prefix in a name test must be a declared xmlns: prefix in scope (the implicit xml prefix excepted); a selector/field whose xpath names an unbound prefix is rejected. invalid-schemas-accepted 77 → 75, no false positive (valid-schemas-rejected held at 0; an adversarial review confirmed declared prefixes on the schema root or any ancestor resolve correctly), no other bucket moved.

  • XSTS invalid-schemas-accepted (#161): a group or attributeGroup inside xs:redefine may reference itself at most once, and a group self-reference must occur exactly once, minOccurs = maxOccurs = 1 (src-redefine.6.1.1/6.1.2/7.2.1). Two self-references, or a group self-reference with minOccurs="0", maxOccurs="unbounded", or any count above 1, is rejected. The self-reference is matched by namespace (not local name alone) and the search is bounded to the component's own model (stopping at nested element/complexType scopes), so a reference to an imported same-named component, or a recursive reference inside a nested element's content, is not flagged (no false positive). invalid-schemas-accepted 79 → 74, no false positive (valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a local element declaration must identify itself with a name or a ref; an element with neither is rejected. invalid-schemas-accepted 80 → 79, no false positive (valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexType nested in an element is a local, anonymous type and may not carry a name attribute; only a top-level complexType (or a redefine redefinition) is named. invalid-schemas-accepted 81 → 80, no false positive (valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#161): a type inside xs:redefine must restrict or extend the type it redefines, which lives in the redefining schema's own target namespace (src-redefine.5). A derivation base bound to a different namespace, such as an imported type that happens to share the redefined type's local name, is not a self-redefinition and is now rejected; the previous check compared local names only and accepted it. A base with no resolvable namespace is left alone, so an unprefixed or own-namespace self-reference is unaffected. invalid-schemas-accepted 84 → 81, no false positive (valid-schemas-rejected held at 0; differential PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#161): an import's namespace attribute must equal the targetNamespace of the schema document it resolves to (src-import.3.1/3.2): both present and equal, or both absent. Importing a no-namespace document under a declared namespace, or a document whose target namespace differs from the declared one, is now rejected. Checked only when the schemaLocation resolves to a loaded schema (an unresolved import is the processor's choice and not an error); imports keep their own target namespace, so no chameleon adjustment applies. invalid-schemas-accepted 87 → 84, no false positive (valid-schemas-rejected held at 0; differential PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145, #161): a resolved include/import/redefine schemaLocation must be a well-formed schema document (src-resolve). When the schema loader returns content for a referenced location, that content is now required to parse as an xs:schema; not-well-formed XML, or well-formed XML whose root is not xs:schema, makes the composing schema invalid. A location the loader does not resolve (returns nil) is not an error, since resolution is the processor's choice, so a self-contained schema is unaffected. invalid-schemas-accepted 95 → 87, no false positive (valid-schemas-rejected held at 0; differential PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an attribute's type must be a simple type (au-props-correct / src-attribute). An attribute whose type resolves to a complex type, or to the complex ur-type xs:anyType, is rejected; a built-in or user simple type, an inline simpleType, or no type (anySimpleType) stays valid. A locally-declared type takes precedence over the built-in reading. invalid-schemas-accepted 96 → 95, no false positive (valid-schemas-rejected held at 0; differential PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an element's default/fixed value constraint requires a content type that admits character data (cos-valid-default). An element whose type is, or whose inline complex type has, element-only or empty content may not carry a value constraint; a simple type, mixed content, the ur-type xs:anyType, and an untyped element (also anyType) stay valid. The check flags only a content type that is certainly element-only or empty, so it cannot over-reject. invalid-schemas-accepted 99 → 96, no false positive (valid-schemas-rejected held at 0; differential PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): the base-type kind of a content-model derivation is enforced. A complexContent restriction or extension base must be a complex type (src-ct.1), so a simpleType or a built-in simple type such as xs:string is rejected (the ur-type xs:anyType is complex and stays valid). A simpleContent extension base must be a simple type or a complex type with simple content (src-ct.2), so a complex type with element-only, mixed, or empty content, including xs:anyType, is rejected. A locally-declared type takes precedence over the built-in reading, so a schema that targets the XSD namespace and extends its own complex types (the schema-for-schemas) is not rejected. invalid-schemas-accepted 104 → 99, no false positive (valid-schemas-rejected held at 0, corroborated by the differential: PureXML-stricter unchanged at 87 over 9618 schemas), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an identity constraint's content model is enforced. unique, key, and keyref must contain exactly one selector followed by one or more fields (after an optional annotation); a field before the selector, a second selector, or no field is now rejected (previously only child-element membership was checked, not their cardinality or order). invalid-schemas-accepted 110 → 104, no false positive (valid-schemas-rejected held at 0), no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): the occurrence-range constraints on model groups are enforced. A particle's minOccurs may not exceed its maxOccurs, both defaulting to 1 when absent (p-props-correct.1), so <xs:sequence minOccurs="2"> (2 > the default 1) and <xs:all maxOccurs="0"> (the default 1 > 0) are rejected; and an all group's maxOccurs must be 1 and minOccurs 0 or 1, with every particle it contains limited to maxOccurs 0 or 1 (cos-all-limited.2). The previous check fired only when both attributes were present, missing the common default-maxOccurs cases. Triple-confirmed against the W3C-labelled suite and libxml2: invalid-schemas-accepted 130 → 110, with no false positive (valid-schemas-rejected held at 0) and no other bucket moved.

  • 2026-06-16

Second pre-release. Continues the W3C XSTS schema-validity campaign: invalid-schemas-accepted 394 → 266 (settled expectations), valid-instances-rejected 180 → 171, invalid-instances-accepted 158 → 155, valid-schemas-rejected held at 1 (particlesZ001, spec-ambiguous). Not yet 1.0: see docs/production-readiness.md.

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.

  • XSTS invalid-schemas-accepted (#145): a complexContent restriction may not add an attribute the base type never permitted. An attribute the restriction declares that has no matching base attribute use must be admitted by the base's anyAttribute wildcard (cos-ct-restricts.3); for example a no-namespace attribute is not admitted by a ##other wildcard. A prohibited declaration (which removes an attribute) stays valid. Caught one invalid schema: invalid-schemas-accepted 132 → 131, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a namespace value may not be the empty string. <schema targetNamespace=""> and <import namespace=""> are rejected, since a namespace name must be a non-empty anyURI (omitting the attribute, for no target namespace or a no-namespace import, stays valid). Caught two invalid schemas: invalid-schemas-accepted 134 → 132, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a wildcard restriction may not weaken processContents. When a complex type restricts another, a restricting xs:any must have a processContents at least as strong as the base wildcard's (strict > lax > skip; Wildcard Subset, §3.10.6), in addition to narrowing the namespace. Caught three invalid schemas: invalid-schemas-accepted 137 → 134, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a simpleContent restriction's base must be a complex type. Restricting a built-in (xs:integer, xs:anySimpleType) or a user simpleType through <simpleContent><restriction> is rejected (src-ct.2); a complex type with simple content is built by extending a simple type, not by restricting one. Caught five invalid schemas: invalid-schemas-accepted 142 → 137, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): Particle Valid (Restriction) now enforces the NameAndTypeOK nillable clause. A restricting element may not be nillable unless the base element it restricts is also nillable. Caught two invalid schemas: invalid-schemas-accepted 144 → 142, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): Particle Valid (Restriction) now enforces the NameAndTypeOK fixed-value clause. When the base element has a fixed value constraint, the restricting element must also be fixed to the same value (compared in the base type's value space, so a value-equal pair in different lexical forms is not wrongly flagged). Caught four invalid schemas: invalid-schemas-accepted 148 → 144, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): Particle Valid (Restriction) now also covers anonymous complex types. The named-type subset check was keyed by type name, so an inline complexType deriving by complexContent restriction (the common <element><complexType><complexContent><restriction base="..."> shape) was never validated against its base. Such restrictions are now compiled and checked with the same algorithm. Caught fifteen invalid schemas: invalid-schemas-accepted 163 → 148, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): Particle Valid (Restriction) now enforces the NameAndTypeOK block-superset clause. A restricting element's {disallowed substitutions} (its block, or the schema's blockDefault) must be a superset of the base element's, so a restriction may block more substitutions but never fewer. The element particle now carries this block set. Caught sixteen invalid schemas: invalid-schemas-accepted 179 → 163, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): the RecurseAsIfGroup case of Particle Valid (Restriction) now checks occurrence. An element restricting a base model group is treated as a synthetic group with minOccurs=maxOccurs=1 holding the element (XSD 1.0 §3.9.6), so a base group that must occur two or more times cannot be restricted to a single element. The element's own occurrence is still matched against the base branch it maps to, so an optional element restricting a once-or-more base group stays valid (no false rejection). Caught two invalid schemas: invalid-schemas-accepted 181 → 179, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a redefine is now checked against the schema it redefines once that schema is loaded: every component the redefine names (a complexType, simpleType, group, or attributeGroup) must have a same-kind component of the same name in the redefined schema (src-redefine.6/7.2.1), and the same component may not be redefined twice in one redefine (src-redefine.7.2.2). A redefinition naming a component that does not exist there, or a duplicate redefinition, is rejected. The check runs only when the redefined document was loaded through a schemaLoader, so a stand-alone schema is unaffected. Caught two invalid schemas: invalid-schemas-accepted 183 → 181, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an element or attribute default/fixed value constraint is now validated against an inline type (an inline simpleType, or an inline complex type with simpleContent) just as it already was against a named type reference, so a non-numeric default on a decimal-based inline type is rejected. Validated against the simpleContent base's value space, so only a clear value-space mismatch is flagged. Caught two invalid schemas: invalid-schemas-accepted 185 → 183, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complex type without its own final attribute now inherits the schema's finalDefault as its {final} property, so finalDefault="#all" or "restriction"/"extension" blocks a complexContent restriction or extension derived from it just as an explicit final would (an explicit final="" overrides the default back to no restriction). Complex types previously honoured only an explicit final. Caught four invalid schemas: invalid-schemas-accepted 189 → 185, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): the global-only element properties abstract, final, and substitutionGroup are now rejected on a local element declaration (an element particle, not a direct child of schema). They belong to the topLevelElement form only, so their presence on a local element is invalid at any value (abstract="false" is rejected as well as "true"). Caught five invalid schemas: invalid-schemas-accepted 194 → 189, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexContent extension that explicitly states mixed must agree with its base type's mixedness (cos-ct-extends.1.4.3.2.2.1): a mixed extension may extend only a mixed base, and an element-only extension only an element-only base. An extension that omits mixed inherits the base's setting and stays valid, so only an explicit conflict is rejected. Caught three invalid schemas: invalid-schemas-accepted 197 → 194, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexContent or simpleContent must contain exactly one restriction or extension. An empty <complexContent/> (or <simpleContent/>), which declares no derivation, is now rejected as a structural error. Caught three invalid schemas: invalid-schemas-accepted 200 → 197, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a pattern facet whose \p{...} or \P{...} property name can be no XSD charProp (IsCategory | IsBlock) under any repertoire is now rejected as a schema-validity error: an empty name, the bare prefix Is, a non-block name carrying a non-letter (\p{\L}), or Is followed by a non-block character. A well-formed but unrecognised category or block name (such as an unknown Is-block) stays a lenient engine limitation rather than an error, so only the unambiguously malformed forms reject. Caught three invalid schemas: invalid-schemas-accepted 203 → 200, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): two attribute-declaration validity rules. An attribute ref may not also carry form (the form comes from the referenced global declaration; src-attribute.3.2), and no attribute declaration may be named xmlns (that name is reserved for namespace declarations; no-xmlns). Caught four invalid schemas: invalid-schemas-accepted 207 → 203, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a referencing attributeGroup (one carrying a ref) is held to its (annotation?) content model. It names a definition elsewhere, so it may not itself declare attributes, nested attributeGroup references, or an anyAttribute; only a defining attributeGroup (with name) carries those (attgD). Caught three invalid schemas: invalid-schemas-accepted 210 → 207, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): notation names are now part of the component-name uniqueness check. A notation has its own symbol space, so two notations sharing a name and target namespace are a duplicate just as two global types or elements would be (notatB005). Caught one invalid schema: invalid-schemas-accepted 211 → 210, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an xsd:notation declaration is held to its (annotation?) content model. A notation may carry only an optional annotation child, so any other element child, or any non-whitespace character content, is now rejected (notatG). Caught three invalid schemas: invalid-schemas-accepted 214 → 211, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a list simple type must draw its item type from exactly one of an itemType attribute or an inline simpleType child (neither, or both, is invalid), and a union must declare at least one member type through a memberTypes attribute or an inline simpleType child (an empty union declares none), per src-simple-type.2/.3. Caught one invalid schema: invalid-schemas-accepted 215 → 214, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a simple type without its own final attribute now inherits the schema's finalDefault as its {final} property (XSD 1.0 Schemas §3.14.2), so finalDefault="list" or "union" blocks the matching list-item or union-member derivation just as an explicit final would. An explicit final="" still overrides the default back to no restriction. Caught two invalid schemas: invalid-schemas-accepted 217 → 215, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a pattern facet whose regular expression ends mid-token is now rejected as a schema-validity error. A trailing \ with no character to escape (a\) raises RegexError.incompleteEscape, and a [ character class the expression ends before closing with ] (a[, [a[:xyz:) raises RegexError.unterminatedClass; both are unambiguously invalid rather than an engine limitation on an otherwise-valid pattern, so they reject. Caught thirteen invalid schemas: invalid-schemas-accepted 230 → 217, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): an attribute use that references a global attribute carrying a fixed value constraint may not contradict it. A fixed on the reference whose value differs from the global's (compared in the attribute type's value space, so 1 and 01 are not wrongly flagged), or a default on a reference to a fixed global, is invalid (au-props-correct.2). A reference that omits its own value constraint inherits the global's fixed value and stays valid. Caught two invalid schemas: invalid-schemas-accepted 232 → 230, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexContent extension that adds element content may not extend a base whose whole content is a non-empty all group; the base's all would be nested in the sequence joining base and extension content (cos-all-limited). An empty-all base, or an attribute-only extension, stays valid. Caught one invalid schema: invalid-schemas-accepted 233 → 232, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): a complexContent extension that adds element content (a non-empty model group) may not extend a base whose content is simple (cos-ct-extends.1.4.2.2); a simple-content type is extended through simpleContent. An attribute-only complexContent extension of a simpleContent base stays valid (the complexContent-around-simpleContent idiom). Caught two invalid schemas: invalid-schemas-accepted 235 → 233, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): two more schema-for-schemas structural rules. A named model group's compositor (all/choice/sequence) may not carry minOccurs/maxOccurs (those belong on a group reference), and an attribute in the XML Schema namespace (such as xsd:type) is rejected since the schema vocabulary defines no namespaced attributes (a foreign attribute in any other namespace stays the author's own). Caught nine invalid schemas: invalid-schemas-accepted 244 → 235, no other bucket moved.

  • XSTS invalid-schemas-accepted (#145): unambiguously malformed regular expressions in pattern facets are now schema-invalid: a reversed character-class range ([b-a]), an empty class ([]), and a reversed bounded quantifier ({37,17}). The parser raises distinct errors for these, which the schema-validity check rejects, while constructs the engine merely does not support (an untabulated \p{Is…} block, the lenient {,m} form) stay non-rejecting so a valid pattern is never refused. Caught eighteen invalid schemas: invalid-schemas-accepted 262 → 244, no other bucket moved.

  • XSTS invalid-instances-accepted (#147): identity constraints reject more invalid instances. Fields now enforce cardinality (a field must select at most one node; cvc-identity-constraint.3), so an instance whose unique/key/keyref field matches several nodes is rejected. Selector and field XPath prefixes now resolve against the schema's namespace context (where the constraint is declared) rather than the instance document, so a constraint like .//v:vehicle works whatever prefix the instance uses; a QName field value still compares using the instance's bindings. An attribute field's type also resolves through the selector's target global element, so values compare in their value space (3.0 and 3 are the same xsd:decimal key). Together these caught fifteen cases (idF005, idG005, idF010, the idc004/5/6 cluster, idZ010, and kin): invalid-instances-accepted 147 → 132, no other bucket moved.

  • XSTS valid-instances-rejected (#146): 173 → 3 on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/143). Regex patterns match Unicode scalars (not Swift grapheme clusters), fixing \p{Is…} block tests and combining-mark cases; empty patterns match only the empty string. The regex repertoire is pinned to Unicode 3.2, the version the corpus is consistent at: code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters, while later additions (the Tamil digit zero) stay unassigned and match \D (XSDCategory, Unicode32Assigned). Per-child content assessment is driven by the content-model particle the automaton matched, not a by-name lookup (#180), so two same-named particles keep their own fixed values and an element matching a skip wildcard is not validated against its global declaration. Schema documents that form an import chain are compiled as a union rather than merged as separately compiled documents, so cross-document substitution groups resolve (#161, Document.init(composing:)). Plus attribute-group anyAttribute inheritance, extension wildcard union, namespace-qualified type tables for multi-document xsi:schemaLocation, per-container elementFormDefault, strict/lax attribute wildcard validation, built-in type resolution, and xs:redefine simpleType overrides.

  • Enforce element, attribute, attributeGroup, and group child-content shape (schema-for-schemas cardinality and order): at most one inline type per element/attribute, inline type before identity constraints, at most one compositor per group, at most one anyAttribute per attributeGroup with attribute references preceding it (SchemaStructureHelpers.swift).

  • Enforce facet applicability per base type, fractionDigits constraints on integer types, ordered duration range bounds, and built-in list non-emptiness constraints (XSD 1.0 Datatypes §4.3). Ensure that built-in list types (xs:IDREFS, xs:ENTITIES, xs:NMTOKENS) are validated as non-empty unless restricted with an explicit minLength. Resolve type references namespace-aware to avoid prefix conflation with user types of the same name. Ignore foreign elements matching facet names inside xs:restriction. XSTS: invalid schemas accepted 296 to 266, no other bucket raised.

  • Enforce schema element order (imports, includes, and redefines must precede other global declarations) and validate import constraints (namespace attribute must not match the schema's targetNamespace, and if missing, the schema must have a targetNamespace) (XSD 1.0 Structures §3.10.6, §3.15.2). XSTS: invalid schemas accepted 302 to 296, no other bucket raised.

  • Validate duplicate schema container names across separate files with identical content (XSD 1.0 Structures §3.14). Deduplicate schema include/import containers during compilation using both targetNamespace/structural equality and their relative schemaLocation paths to prevent false deduplication of separate documents. Keep circular self-imports/includes of the root schema valid. XSTS: invalid schemas accepted 304 to 302, no other bucket raised.

  • Validate standard xml:lang and xml:space attributes on XSD schema elements (like <xs:documentation> and <xs:appinfo>). Traversal is adjusted to process attributes on these documentation-related elements before skipping their foreign children, and validation checks the lexical pattern of xml:lang (using BCP 47) and restricts xml:space to 'default' or 'preserve'. XSTS: invalid schemas accepted 306 to 304, no other bucket raised.

  • Validate notation declarations and notation enumeration constraints on simpleTypes (XSD 1.0 Structures §3.12 / §3.14). Every <xs:notation> declaration must specify at least one of public or system attributes. For any simpleType restricting xs:NOTATION, all enumeration values must name a declared notation in the schema. Resolved prefix bindings are traversed recursively up the ancestor path to handle default namespaces and local namespace declarations. XSTS: invalid schemas accepted 309 to 306, no other bucket raised.

  • Enforce cross-container global component-name and identity-constraint uniqueness constraints (XSD 1.0 Structures §3.14). Within a schema (including all included and imported schemas), global type names (simpleType and complexType sharing one symbol space), global element names, global attribute names, named model groups, and named attribute groups must be unique within their respective symbol spaces and target namespaces. Identity constraints (unique, key, keyref) must have unique names across all target namespaces in the entire schema document. Circular and duplicate imports/includes are handled namespace-aware without causing false-positive validations on valid schemas. XSTS: invalid schemas accepted 316 to 309, no other bucket raised.

  • Enforce substitution group exclusions based on element final and schema finalDefault attributes (XSD 1.0 Structures §3.3.6 / Schema Component Constraint: Substitution Group OK (Transitive)), including support for element declarations with inline anonymous types. The entire derivation path from the member type to the head type is checked; if any step contains a derivation method prohibited by the head element's final or the schema's finalDefault, the substitution is rejected. XSTS: invalid schemas accepted 323 to 316, no other bucket raised.

  • Enforce targetNamespace scope matching on local schema references (XSD 1.0 Structures §3.14). If a schema has a targetNamespace declared, any local type, element, attribute, group, or attributeGroup reference without a namespace prefix (resolving to no namespace) is rejected as undeclared unless a default namespace matching the targetNamespace is declared on the element, since all global components defined in the document belong to the targetNamespace. XSTS: invalid schemas accepted 325 to 323, no other bucket raised.

  • Enforce variety constraints on list itemType and union memberTypes (XSD 1.0 Datatypes §3.3.1 / §3.4.1). A list's itemType must be atomic or union of atomic types, and cannot be a list or a union containing a list, and cannot resolve to 'anySimpleType'. Resolves namespace prefixes dynamically in scope to handle local prefix declarations without false positives. XSTS: invalid schemas accepted 327 to 325, no other bucket raised.

  • Implement namespace-aware schema reference resolution checks, validating type references, element/attribute/group references, and substitutionGroup headers using XML namespace prefix bindings (XSD 1.0 Structures §3.14). If a prefix is bound to a namespace that is neither the targetNamespace nor the XMLSchema namespace (when no external schemas are loaded), the reference is flagged as undeclared. Bypasses built-in validation checks on references in the XMLSchema namespace when the targetNamespace is the XMLSchema namespace itself. Validate simpleContent base restrictions namespace-aware. XSTS: invalid schemas accepted 333 to 327, no other bucket raised.

  • Enforce simpleType restriction structure and prefix binding constraints (XSD 1.0 Datatypes §3.2.1 / Structures §3.3.1 / §3.14.3). Verify that restriction under simpleType only admits annotation, simpleType and facets, disallowing attributes, wildcards, model groups, and multiple or misplaced inline simpleTypes. Reject a restriction base, list itemType, or union memberType resolving to a complex type (e.g. anyType). Enforce that all QName attributes have bound prefixes. XSTS: invalid schemas accepted 350 to 333, no other bucket raised.

  • A top-level group or attributeGroup definition may no longer carry local-use attributes (XSD 1.0 Structures §3.6.1 / §3.6.2). A global group definition (including one defined inside xs:redefine) must only have name and id attributes, and local-use attributes like ref, minOccurs, or maxOccurs are rejected; a global attributeGroup definition may not carry a ref attribute. XSTS: invalid schemas accepted 354 to 350, no other bucket raised.

  • Support attribute inheritance and prohibition in complexContent restriction derivations (XSD 1.0 Structures §3.4.6 / Schema Component Constraint: Derivation Valid (Restriction, Complex)). When a complexType restricts a base type, the restricted type inherits all base attributes that are not explicitly overridden or prohibited by the restriction. A prohibited attribute is excluded from the final type's attributes, preventing its use in instance validation. Resolves referenced attributes namespace-aware using prefix bindings. XSTS: valid instances rejected 180 to 171, invalid instances accepted 158 to 155, no other bucket raised.

  • A complexContent restriction must keep a base attribute's required use and its fixed value constraint (XSD 1.0 cos-ct-derived-ok / derivation-ok-restriction.2 / derivation-ok-restriction.4). A restriction redeclaring a base required attribute may not make it optional or prohibited, and if a base attribute use has a fixed value constraint, the restriction must also make it fixed with the same value. The fixed values are compared in the attribute type's value space (supporting lists and unions) to avoid over-rejecting valid schemas. A prohibited attribute is removed, so the fixed clause does not apply to it. XSTS: invalid schemas accepted 390 to 387, no other bucket raised.

  • Enforce top-level declaration naming, block xs:anySimpleType derivation, and restrict simpleType facets. Top-level definitions must specify a name attribute. Derivation by restriction or extension of xs:anySimpleType is disallowed. Restriction facets (length, minLength, maxLength, totalDigits, fractionDigits) must be tighter than or equal to the base type's facets. XSTS: invalid schemas accepted 387 to 383, no other bucket raised.

  • Enforce simpleType derivation constraints on restricted complexType attributes (XSD 1.0 cos-ct-derived-ok / derivation-ok-restriction.1). The type of a restricted attribute must be validly derived by restriction from its base counterpart, checking atomic built-in derivation, list item type restriction, and union membership or member-to-member restriction recursively. XSTS: invalid schemas accepted 367 to 365, no other bucket raised.

  • Enforce element type restriction constraints on restricted complexType particles (XSD 1.0 NameAndTypeOK / elementTypeRestrictionOK). Verify that the restricted particle's type validly derives by restriction from the base particle's type, checking union variety members structurally, prohibiting complex-restricting-simple and simple-restricting-complex (except where base is anyType), and falling back to name-based derivation. Prohibited attributes are exempted from type and value constraint checks. XSTS: invalid schemas accepted 365 to 357, no other bucket raised.

  • Enforce identity constraint keyref arity validation at schema compile time (XSD 1.0 c-identity-constraint). Verify that the number of fields in a keyref matches the arity of its referenced key or unique constraint. XSTS: invalid schemas accepted 357 to 354, no other bucket raised.

[0.1.0] - 2026-06-15

First tagged pre-release. A pure-Swift XML parser/emitter with an XSD 1.0 schema engine, XPath/XSLT support, and a WASI-compatible build. The W3C XSTS conformance campaign has driven invalid-schemas-accepted from 2461 to 394 with valid-schemas-rejected held at its floor of 1. Not yet 1.0: see docs/production-readiness.md for the remaining gates.

Added

  • A generative robustness fuzz harness for the schema engine and parser (#167, production-readiness stopper 4). A reproducible seeded generator builds structurally varied XSD schemas and XML instances that deliberately reach the crash- and hang-prone shapes (deep nesting, large/unbounded/inverted occurrences, pointless maxOccurs="0" particles, wildcards, element and group reference cycles, substitution-group chains, simpleType restriction cycles, complexContent restrictions, pathological regex patterns), and asserts the validator never crashes and always terminates. It is a regression gate against interactive-safety failures on hostile input, not a verdict-correctness oracle; counts are sized to stay fast on every swift test (the native .timeLimit hang guard is omitted under the single-threaded WASI runtime). No crashes or hangs were found over the current corpus.

Fixed

  • A nested simpleType or attributeGroup may no longer declare a name (XSD 1.0 schema-for-schemas localSimpleType / nested attributeGroup, #170). A simpleType inside a restriction/list/union/element/attribute is anonymous (the local form carries no name), and a nested attributeGroup is always a reference, never a named definition; named definitions are top-level only (a direct child of schema or redefine). Such schemas were accepted. A nested anonymous simpleType, an attributeGroup reference, and a top-level named definition are unaffected. XSTS: invalid schemas accepted 403 to 394, no other bucket raised.

  • An xs:all group may no longer appear in a complexContent extension of a base that already has element content (XSD 1.0 cos-all-limited.1.2, #170). The effective content model of such an extension is sequence(base content, extension content), which nests the all and violates the rule that an all must be the whole content model. Detected from the compiled base type: rejected only when the base resolves (in the schema's own target namespace) to a complex type with element-only or mixed content. An explicitly-empty base (an empty model group with mixed="false", whose content is effectively empty) may still be extended by an all, since the all becomes the whole content; a simple-content, empty, foreign, or unresolved base is left alone. Checked for a self-contained schema. XSTS: invalid schemas accepted 411 to 403, no other bucket raised.

  • A simple type whose final contains list (or #all) may no longer be used as a list itemType, and one whose final contains union (or #all) may no longer be a union memberType (XSD 1.0 simple-type final, #170). The restriction and extension directions were already enforced by finalRespected; the list and union directions were unchecked because final="list"/"union" is not modelled by the derivation-method set, so the final attribute is read directly. The reference is matched only when it resolves to the schema's own target namespace, so a list or union of a built-in (whose final is empty) is not confused with a user type of the same local name. Checked for a self-contained schema. XSTS: invalid schemas accepted 413 to 411, no other bucket raised.

  • A whiteSpace facet may only strengthen, never relax, its base type's effective whiteSpace (XSD Part 2 §4.3.6, #170). The order is preserve < replace < collapse, so a base fixed to collapse admits only collapse, and a replace base admits replace or collapse. A derivation that relaxes it (for example whiteSpace="replace" over a collapse base, or restricting xs:token to replace) was accepted and is now rejected. The base's effective whiteSpace is its own facet, falling back to its built-in intrinsic; the check is atomic-only and, when a base type's own whiteSpace has not yet resolved, can only under-detect, never reject a valid restriction. XSTS: invalid schemas accepted 422 to 413, no other bucket raised.

  • A top-level xs:element declaration may no longer carry ref, form, minOccurs, or maxOccurs (XSD 1.0 schema-for-schemas topLevelElement, #170). A global element is a declaration, never a particle or a reference, so those local-use attributes are invalid. The top-level-attribute applicability scan added earlier is generalized to cover both kinds; a local element particle inside a content model is unaffected. XSTS: invalid schemas accepted 424 to 422, no other bucket raised.

  • An element ref may no longer carry nillable, default, fixed, form, or block, and an element or attribute ref may no longer have an inline type definition (XSD 1.0 src-element.2.2, #170). A reference is a use of a global declaration, not a declaration, so beyond ref it carries only minOccurs, maxOccurs, and id; the applicability check already excluded name and type from a ref, and this extends the exclusion to the remaining declaration-only properties. A group/attributeGroup ref and an attribute ref's use/default/fixed (which override on the use) are unaffected. XSTS: invalid schemas accepted 439 to 424, no other bucket raised. Disclosed under-detection: a key/unique/keyref child under a ref is not yet flagged.

  • A top-level xs:attribute declaration may no longer carry use, form, or ref (XSD 1.0 schema-for-schemas topLevelAttribute, #170). Those belong to a local attribute use, not a global declaration; the per-component applicability table is the permissive global/local union and so could not draw the distinction, leaving such schemas accepted. A focused scan of the direct attribute children of schema (and of redefine) supplies the global-only constraint; a local attribute use inside a complex type or attribute group is unaffected. XSTS: invalid schemas accepted 454 to 439, no other bucket raised.

  • A default/fixed value on an element or attribute whose type names a user-declared type must now be a valid value of that type (XSD 1.0 e-props-correct.2 / a-props-correct.2, #170). This runs after the named types are compiled, complementing the existing built-in-only check: a default failing the type's pattern, a malformed value for a type restricting a built-in, or a value invalid against a complex type's simple content is now rejected. The type name is resolved namespace-aware; only a type in the schema's own target namespace is checked (built-ins are handled at structure time; imported, foreign, or inline anonymous types are left alone), and the compiled SimpleType validator (the one instance validation uses) performs the check, so whitespace and facet handling match. XSTS: invalid schemas accepted 462 to 454, no other bucket raised.

  • A simple-type restriction whose base is an inline anonymous <simpleType> (no base attribute) now resolves that inline child as its base, preserving its list or union variety. The base was read only from the base attribute, so an inline list/union base was dropped and the type collapsed to an atomic string: a length facet then counted characters instead of list items, and a list-valued default of the correct length was wrongly rejected once value constraints are checked. Resolving the inline base fixes both schema-time value checking and instance validation. XSTS: invalid instances accepted 160 to 158, no other bucket raised.

  • An xs:attribute declaration carrying a default value constraint must now have use="optional" (XSD 1.0 src-attribute.2, #170). A default makes the attribute optional, so use="required" or use="prohibited" alongside it is contradictory; such schemas were accepted. The check rides the existing value-constraint walk, gated to XSD-namespace attribute declarations reading the unprefixed default/use, and is per declaration. Only fixed (not default) may pair with use="required". XSTS: invalid schemas accepted 469 to 462, no other bucket raised. Disclosed under-rejection: a whitespace-padded use=" required " (whose NMTOKEN actual value is required) is not flagged.

  • An element or attribute declaration may no longer carry both a default and a fixed value constraint (XSD 1.0 src-element.1 / src-attribute.1, #170). The two are mutually exclusive; such schemas were accepted. The check rides the existing structure walk, gated to XSD-namespace declarations and reading the unprefixed default/fixed (a foreign same-local-name attribute is not the value constraint), and is per declaration, so a default on a global declaration and a fixed on a reference to it (two separate items) is correctly allowed. XSTS: invalid schemas accepted 479 to 469, no other bucket raised.

  • A substitution-group member's type must now be validly derived from the type of its head (XSD 1.0 e-props-correct.4, #170). An element declaring substitutionGroup="head" whose own type is unrelated to the head's (for example a member typed xs:int under a head typed xs:string) was accepted; it is now rejected. The check runs after named types resolve and reuses the type-derivation oracle (the restriction/extension chain, continued through the built-in lattice; an ur-type head admits any member). To never reject a valid member it is conservative: it runs only for a self-contained schema (no import/include/redefine, where the derivation table is complete and single-namespace names are unambiguous), reads the member's type from its own type attribute and the head's from the top-level element declarations only (so a local element of the same name supplies neither), and stands down when either type is a list or union (the cos-st-derived-ok clauses 2.3/2.4 union-member derivation is not modelled). XSTS: invalid schemas accepted 480 to 479, no other bucket raised. Disclosed under-rejections: schemas that compose other documents, list/union-typed members or heads, and inline/untyped members.

  • An xs:all group referenced through a <group ref> from inside a compositor is now rejected (XSD 1.0 cos-all-limited.1.2, #170): an all group may only be the whole content model of a complex type. A direct nested all was already rejected by the child-content table; this closes the group-reference path. The all-group pool is qualified by the defining schema's target namespace and a reference by its prefix, so an imported or redefined same-named group is not confused with a local one; a group redefined to a non-all content is exempt. XSTS: invalid schemas accepted 481 to 480, no other bucket raised.

  • An attribute or element whose type is or derives from xs:ID may no longer carry a default or fixed value constraint (XSD 1.0 a-props-correct.3 / e-props-correct.5, #170). Such schemas were accepted. The check runs after named simple types resolve, so a named user type derived from xs:ID is recognized; the type reference's namespace is resolved from its prefix, so a user type named ID or an imported foo:ID is not mistaken for the built-in. XSTS: invalid schemas accepted 489 to 481, no other bucket raised.

  • A type attribute and an inline anonymous type definition are now mutually exclusive on an element or attribute (XSD 1.0 src-element.3 / src-attribute.3.1, #170). An <xs:element> or <xs:attribute> carrying both a type= and an inline <simpleType> (or <complexType>, for an element) is rejected; previously it was accepted. Only an inline type in the XSD namespace counts, so a foreign-namespace child that happens to be named complexType/simpleType is not mistaken for the component's own type. XSTS: invalid schemas accepted 495 to 489, no other bucket raised.

  • Attribute-use uniqueness and the single-ID rule are now enforced (XSD 1.0 ct-props-correct.4 / ag-props-correct.2 / ct-props-correct.5, #162). A complex type's or attribute group's attribute-use set (flattened through attributeGroup references) may not contain two distinct declarations with the same name and namespace, nor more than one attribute whose type is or derives from xs:ID. Declarations are deduplicated by the identity of their declaring node, so the same declaration reached by two paths (a diamond of attribute-group references) is one use, not a clash; names carry their resolved namespace, so a local attribute and a reference to a same-local-name attribute in an imported namespace do not collide. XSTS: invalid schemas accepted 505 to 495, no other bucket raised. Disclosed under-rejections: the set inherited through extension is not composed in, and a duplicate that requires an unprefixed reference in a schema with no default namespace is not detected.

  • Circular references among model groups, attribute groups, and substitution-group affiliations are now rejected (XSD 1.0 mg-props-correct.2, ag-props-correct.3, e-props-correct.6, #168). A model group may not contain itself, an attribute group may not reference itself transitively, and a substitution chain may not loop. The model-group check follows references only within a definition's own compositor tree, stopping at element and type boundaries: a group referenced inside an element's content is a recursive data structure (permitted), not the group containing itself. A reference is followed only when it resolves to the schema's own target namespace, and a group or attribute group redefined in xs:redefine (whose single self-reference is the legal redefinition) ends the chain. XSTS: invalid schemas accepted 516 to 505, no other bucket raised. A genuine cycle introduced across two redefined definitions is a disclosed under-rejection.

  • Circular type derivation is now rejected at compile time (XSD 1.0 ct-props-correct.3 / st-props-correct.2, #168). A complex or simple type may not derive, transitively through its base, from itself; previously such a schema was accepted. Only the base chain is followed, so a type whose element content recurses (an element of its own type, the normal recursive-data-structure case) is correctly permitted, and a type redefined in xs:redefine (whose base legitimately names its former self) and the ur-types (anyType/anySimpleType/anyAtomicType, the roots the schema-for-schemas bootstraps) end the chain rather than appear as cycles. The base is followed only when it resolves to the schema's own target namespace, so a type whose base is a same-local-name type in another namespace is not mistaken for a self-cycle. XSTS: invalid schemas accepted 519 to 516, no other bucket raised. Union/list (itemType/memberTypes) and genuine cross-namespace derivation cycles are a disclosed under-rejection (the engine resolves by local name).

  • Particle Valid (Restriction) now implements the W3C MapAndSum rule for a sequence restricting a choice (#165). When a derived sequence restricts a base choice, the spec (XSD 1.0 §3.9.6) requires that each derived particle restrict some base branch AND that the derived sequence's occurrence count times its number of particles lie within the base choice's occurrence range. PureXML previously applied the same-compositor occurrence subsumption (comparing the sequence's own range directly to the choice's), which both rejected a valid restriction (sequence{2,4}(e1,e2) of choice{3,9}(e1,e2), whose 4-to-8 elements fit the choice's 3-to-9) and accepted an invalid one (a two-member sequence restricting a single-occurrence choice, which the count-product 2 > 1 now rejects). The count-product is the spec's measure, matched to the reference implementation. XSTS: valid schemas rejected 2 to 1, invalid schemas accepted 520 to 519, no other bucket raised. The one remaining valid-rejected case (particlesZ001) is a documented spec ambiguity (its own test notes the RecurseAsIfGroup rule "is ambiguous"; the strict reading the reference follows rejects it), recorded in docs/xsts-deviations.md.

  • Two more Particle Valid (Restriction) over-rejections closed (#148). A derived particle that can never contribute a child (its own maxOccurs="0", or a group whose every member is content-free) accepts only the empty sequence, so it is now recognized as a valid restriction of any emptiable base before the structural cases run; previously a maxOccurs="0" group whose members carried their own non-trivial occurrences was wrongly recursed into and rejected (particlesW006). And substitution-group expansion now lists members in declaration order: the closure walk used a popLast() stack that reversed them, so a reference to a head expanded to choice(head, m2, m1) and an order-preserving RecurseLax restriction by choice(m1, m2) could not map (elemZ027a). The choice/choice restriction stays order-preserving, as the W3C suite requires (a choice(b|a) is not a valid restriction of choice(a|b), particlesT002); only the expansion order is corrected. The expansion is shared with instance validation and the determinism check, both order-neutral, so neither is affected. XSTS: valid schemas rejected 4 to 2, no other bucket raised. (The pinned XSTS baselines, which the opt-in suite never asserts under a plain swift test, were also corrected to the measured 2/520/180/160.)

  • An <xs:element ref="p:foo"> now resolves to the namespace its prefix is bound to, not the schema's target namespace (#164). The reference's QName is resolved by standard XML namespace rules against the schema root's xmlns: bindings: a bound prefix gives its namespace (so a reference to an imported element lands in the imported namespace), an unprefixed name resolves through the default namespace, and a name with no applicable binding is in no namespace. Previously the prefix was stripped and the element was assumed to be in the target namespace, which mis-qualified every reference to an imported element: a restriction of an ##other wildcard by such a reference was wrongly rejected (the element looked like it was in the target namespace, which ##other forbids), and the same mis-qualification rejected valid instance documents whose elements were correctly in the imported namespace. Because the content model is shared between schema-restriction checks and instance validation, the fix improves both. XSTS: valid schemas rejected 32 to 4, invalid schemas accepted 526 to 520, valid instances rejected 214 to 180, invalid instances accepted held at 160. Disclosed limitations, unchanged by this fix: a prefix re-bound on an inner element (rather than the schema root) is not honored, substitution-group members are still resolved to the target namespace (cross-namespace substitution is not modeled), and a dangling reference is not yet flagged as a schema error.

  • Particle Valid (Restriction) now derives its pairwise check over the normalized content model, and applies occurrence subsumption only where the two particles' own ranges are comparable (#163). The W3C restriction algorithm is defined over a content model with pointless particles eliminated, so both the derived and base particles are first normalized (maxOccurs="0" members dropped, a {1,1} single-member group unwrapped to its member, a nested same-compositor {1,1} group spliced into its parent), all language-preserving. The outer occurrence-range check (rangeSubsumed) was being applied to every pairing, including the cross-kind ones where it compares mismatched ranges: a derived element's range against a base group's, or a derived group's against a base wildcard's. It is now applied only to the same-kind pairings (element/element, wildcard/wildcard, group/group) and element/wildcard; for element/group (RecurseAsIfGroup), group/element, and group/wildcard the occurrence is matched inside the case, against a member or as an effective total range. This unblocks a single-member derived sequence narrowing an occurrence (sequence(e1{2,3}) restricting sequence(e1{1,3}, ...), the e1{2,3} no longer compared to the base sequence's own {1,1}). A group restricting a wildcard (NSRecurseCheckCardinality) is now valid when every leaf it can contain is admitted by the wildcard and the group's effective total occurrence range lies within the wildcard's; a group restricting a single element is bounded the same way, by that element's name, type derivation, and occurrence range. XSTS: valid schemas rejected 45 to 32 (13 correct schemas no longer wrongly rejected, the particlesHa family of element-restricts-wildcard cardinality among them), invalid schemas accepted 528 to 526 (the cardinality bound also catches two over-wide restrictions), no other bucket raised.

  • Particle Valid (Restriction) no longer over-rejects valid restrictions (#148). Three corrections, all language-preserving: a particle that can never occur (maxOccurs="0", a "pointless particle") is removed from both the derived and base content before mapping, so it no longer consumes a base particle and starves the ones after it (e.g. (e1 maxOccurs="0", e2) restricting (any) is now valid); a content-free restriction (an empty <sequence/>, or a group whose members all cannot occur) is a valid restriction of an EMPTY base, since both accept only the empty sequence; and a derived group that reduces to empty after that removal is a valid restriction of its base only when the base is itself emptiable (by structure or its own occurrence), closing a vacuous-acceptance gap the removal would otherwise open. XSTS: valid schemas rejected 72 to 45 (27 correct schemas no longer wrongly rejected), with one schema (addB32) moving to invalid-accepted because its only invalidity is a malformed imported document PureXML does not yet load (tracked by #161), not the restriction.

  • A simpleType must contain exactly one of restriction, list, or union (#158). Per XSD 1.0 Datatypes its content is (annotation?, (restriction | list | union)), but the membership table admitted each name without the cardinality, so an empty simpleType or one combining two derivations (two unions, a union and a list, two restrictions) was accepted (the stB family). The exactly-one rule is now enforced. XSTS: invalid schemas accepted 537 to 527, no other bucket raised (valid schemas rejected held at 72).

  • The final and block derivation-control attributes are now checked against their value space (#158). Each is #all or a whitespace list of method tokens, but the admitted tokens depend on the component: an element's final is extension/restriction and its block adds substitution; a complexType's final/block are extension/restriction; a simpleType's final is list/union/restriction. The tokens were parsed leniently (unknown tokens ignored, substitution admitted everywhere), so final="substitution" on an element, a misspelled or miscased token, or #all mixed with other tokens was accepted (the elemF/st_final families). The list is now validated against the component's exact set; finalDefault/blockDefault (the wider schema-level value space) and foreign attributes are left alone. XSTS: invalid schemas accepted 580 to 537, no other bucket raised (valid schemas rejected held at 72).

  • A simpleContent derivation's content is now checked for order and disallowed children (#158). A restriction is (annotation?, (simpleType?, facet*), (attribute | attributeGroup)*, anyAttribute?) and an extension is (annotation?, (attribute | attributeGroup)*, anyAttribute?): simpleContent has no model group, an extension has no facets or simpleType, and the attributes precede a single trailing anyAttribute. The membership table admitted the names but not the model, so a model group inside simpleContent, a facet in an extension, a facet before its simpleType, an attribute after anyAttribute, or a second anyAttribute (the ctD family) was accepted. The derivation's children are matched against the context's ordered slot model, reusing the slot walk built for complexContent. XSTS: invalid schemas accepted 594 to 580, no other bucket raised (valid schemas rejected held at 72).

  • Particle Valid (Restriction) now checks an element's type by derivation, not just its name (NameAndTypeOK, #158). When a restriction maps a same-named element onto a base element, the restricting type must be, or derive by restriction from, the base element's type; renaming xs:string to xs:int, widening a concrete type to xs:anyType, or naming an unrelated type (a different user type, a wider ancestor, an absent/anyType type over a concrete base) was accepted because only the names were compared. Each element particle now carries its type's derivation identity (the resolved type name, anyType for an absent type, or nil for an inline anonymous type), named simpleType restriction derivations are recorded alongside complex ones, and the check walks the user restriction chain and then the built-in lattice to decide derivation by name; an ur-type base, a union base (membership is not a restriction chain), and an inline anonymous type are permitted, so no valid restriction is rejected. XSTS: invalid schemas accepted 621 to 594, invalid instances accepted 165 to 160 (more invalid schemas rejected up front), no bucket raised (valid schemas rejected held at 72).

  • An element/attribute default/fixed value must be a valid value of its type (Attribute/Element Locally Valid, XSD 1.0, #158). A fixed="abc" on an xs:integer or a default="1 2" on an xs:int was accepted because the value constraint was never checked at compile time. The value is now validated against its type when the type resolves to a built-in datatype (namespace-aware: only a type in the XSD namespace), normalising whitespace per the type as XSD requires (so default=" 5 " on an integer is valid); a named or inline user type is left to its own rules, so no valid value constraint is rejected. XSTS: invalid schemas accepted 638 to 621, no other bucket raised (valid schemas rejected held at 72).

  • A constraining facet must apply to its simple type's variety (XSD 1.0 Part 2, #158). A list may be constrained only by length/minLength/maxLength, pattern, enumeration, and whiteSpace; a union only by pattern and enumeration. A value-bound facet (maxInclusive, totalDigits, ...) constrains an atomic value space, so restricting a list (list of integer) by maxInclusive was wrongly accepted. The base's variety is resolved from the raw tree (the check runs before the types are compiled), namespace-aware so a user type that shares a built-in's local name does not flip the variety: a base in the XSD namespace is a built-in (only the list datatypes are list-variety), a base in the schema's own target namespace resolves to a declared global, and an external or unresolvable base is skipped, so no valid restriction is rejected. XSTS: invalid schemas accepted 664 to 638, no other bucket raised (valid schemas rejected held at 72).

  • A complexType's shorthand content (without simpleContent/complexContent) is now checked for order, not just cardinality (#158). Its model is (annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?): an optional model group, then attributes, then an optional anyAttribute. The existing content-shape check caught the wrong number of model groups and the exclusivity of a content spec, but not the order, so a model group after an attribute, an attribute after anyAttribute, or a second anyAttribute (the ctB family) was accepted. The complexType's direct children, excluding annotation and the content specs, are matched against the same ordered slot model used for a complexContent derivation. XSTS: invalid schemas accepted 687 to 664, no other bucket raised (valid schemas rejected held at 72).

  • An any/anyAttribute wildcard's namespace constraint is now validated at compile time (#158). Its value space is ('##any' | '##other') standing alone, or a list of namespace URIs and the ##targetNamespace/##local tokens; the wildcard parser mapped any unrecognised token to a literal URI, so a misspelled token (##all, ##target), an ##any/##other placed inside a list (##any ##other), or any other ##-prefixed token was accepted. The unprefixed namespace value is now tokenised and checked against that grammar, while ##any/##other are admitted only as the whole value; a prefixed (foreign) attribute that merely shares the local name is the author's own and is not checked, so no valid schema is rejected. XSTS: invalid schemas accepted 736 to 687, no other bucket raised (valid schemas rejected held at 72).

  • An identity constraint's selector/field xpath is now validated against the XSD 1.0 restricted-XPath subset at compile time (#158). That subset (Part 1, 3.11.6) is far narrower than full XPath: a path is an optional leading .// then /-separated steps, a step is ., a name test (QName, *, or NCName:*), or a child:: axis, and a field path may end in an attribute step (@x or attribute::x); there are no predicates ([...]), no other axes (self::, descendant::), no absolute or interior //. The xpath was stored raw and compiled lazily with try?, so a malformed one (a predicate, a bare /, an empty union branch, a spaced name-test colon) was silently accepted. It is now lexed into a whitespace-tolerant token stream and parsed against the grammar, so whitespace between tokens is allowed (child :: a, . //.) while // must be adjacent and a name test's own : may not be spaced; the name-test lexer uses the XML NameChar set, so a non-ASCII NCName is accepted. XSTS: invalid schemas accepted 785 to 736, no other bucket raised (valid schemas rejected held at 72).

  • A complexContent derivation's content is now checked for order and cardinality, not just child membership (#158). A restriction or extension inside complexContent has the content model (annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?): an optional model group, then any number of attribute/attributeGroup, then an optional anyAttribute. The allowedChildren membership table admitted the right child names but not their sequence or count, so a second model group (sequence then sequence), a model group after an attribute, an attribute after anyAttribute, or a second anyAttribute was accepted. The children are matched against an ordered slot model (one model group, unbounded attributes, one anyAttribute) by a forward-only walk; the check runs only in the complexContent context, where a model group is part of the model, so a simpleContent derivation (facets, no model group) keeps its own rules and no valid schema is rejected. XSTS: invalid schemas accepted 864 to 785, no other bucket raised (valid schemas rejected held at 72).

  • Unique Particle Attribution (cos-nonambig, the content-model determinism constraint) is now enforced at compile time (#156, #157), the keystone of the remaining schema-validity tail. A content model must be deterministic so each element in an instance is attributable to a single particle without lookahead; an ambiguous model (<choice> of two same-name elements, an optional or repeating element followed by a distinct same-name element, two same-name members of an all, an element competing with a wildcard that admits it) was accepted. The check runs a Glushkov position automaton over a literal, QName-resolved view of the raw schema tree built specifically for this rule, independent of the instance-validation content model: a ref is one position carrying its prefix-resolved QName (not the blanket target namespace), and substitution groups are not expanded, which removes the two false-overlap sources (cross-namespace refs, expanded substitution members) that a check over the compiled model produced. Determinism is per particle, the W3C rule's defining property: a particle competing with a repetition of itself is never a violation, so a fixed count {n,n} unrolls into copies in sequence (b{2,2}, b stays the deterministic b, b, b) while a true range or unbounded repeat uses a self-loop, and only two distinct source particles with overlapping labels conflict. The overlap test is QName-only (not substitution-group-aware), an accepted under-rejection that can never over-reject. XSTS: invalid schemas accepted 884 to 864, no other bucket raised (valid schemas rejected held at 72).

  • A keyref's refer must name a key or unique constraint (#147). A refer pointing at a non-existent (or non-key/unique) constraint was accepted; it is now resolved against the document's key/unique names, with a forward refer (textually before the key) handled by collecting all names first. The check is skipped when the document pulls in external definitions through import/include/redefine, which the default compile does not load. XSTS: invalid schemas accepted 891 to 884, no other bucket raised.

  • Component-name uniqueness is now enforced (#147). Within a schema, global type names (simpleType and complexType share one symbol space), global element names, global attribute names, named model-group names, and named attribute-group names must each be unique, and identity-constraint names must be unique across the document; a duplicate was accepted, with the later definition silently overwriting the earlier. Only the document's own globals are examined (an xs:redefine's same-named type is not a clash), and the symbol spaces are kept separate (a type and an element may share a name). XSTS: invalid schemas accepted 909 to 891, no other bucket raised.

  • Element Declarations Consistent (cos-element-consistent) is now enforced (#147). Within one complex type's content model, two element declarations with the same name must have the same type; two same-name elements with different types (e.g. a as xs:string and as xs:integer) were accepted. The check walks the content model (through model groups and content-derivation wrappers) but not into an element's own nested type, so a reused name in a nested anonymous type, a separate content model, is not flagged. Modeled on Xerces (libxml2 only partially enforces this); substitution-group expansion is not folded in (an accepted under-rejection). XSTS: invalid schemas accepted 925 to 909, no other bucket raised.

  • Schema-vocabulary attribute applicability is now enforced at compile time (#147), the attribute sibling of the content-model table. Each XSD component admits a fixed set of attributes (the schema for schemas); an unprefixed attribute outside that set (a typo like nullable, a stray foo, name on a sequence) is now rejected, and ref may not co-occur with name or type. Foreign (prefixed) attributes and namespace declarations are not the component's own and are not checked, and the element/attribute sets are the permissive union of global and local use, so no valid schema is rejected. XSTS: invalid schemas accepted 956 to 925, no other bucket raised.

  • A named model-group definition (xs:group name="...") must contain exactly one of all/choice/sequence (#147). XSD 1.0 gives a group's content as (annotation?, (all | choice | sequence)), but the allowedChildren set-membership check did not enforce the cardinality, so a group with two compositors or with none was accepted. A group reference (no name) is excluded. XSTS: invalid schemas accepted 977 to 956, no other bucket raised.

  • A particle whose minOccurs exceeds its maxOccurs is now rejected at compile time (#147). An occurrence range like minOccurs="5" maxOccurs="2" is empty and illegal, but was accepted; the two values are compared by canonical magnitude (independent of machine-integer range), with unbounded never exceeded and a single-point range (minOccurs="2" maxOccurs="2") allowed. XSTS: invalid schemas accepted 997 to 977, no other bucket raised.

  • A complexType's content-model shape is now enforced at compile time (#147). XSD 1.0 allows a complexType's content to be simpleContent, or complexContent, or a model group followed by attributes, but never a mix: so at most one of simpleContent/complexContent, neither beside a model group or a direct attribute (those belong inside the derivation), and at most one model group. The allowedChildren set-membership check did not catch these combinations, so a malformed complexType (two simpleContent, simpleContent plus a sequence, two model groups) was accepted. Only the complexType's direct children are examined, so attributes legitimately nested inside an extension/restriction are not flagged. XSTS: invalid schemas accepted 1051 to 997, no other bucket raised.

  • Unresolvable references are now rejected at compile time (#147). Every QName a schema names (a type/base/itemType/memberTypes type, an element/attribute/group/attributeGroup ref, an element substitutionGroup) must resolve to a declared component or a built-in; an undeclared reference was previously accepted and only surfaced (if at all) lazily during instance validation. The reference is matched by local name with whitespace trimmed, as the rest of the compiler resolves it. The check is skipped when the document pulls in external definitions through import/include/redefine, which the default compile does not load, so a reference into them is never flagged. XSTS: invalid schemas accepted 1092 to 1051, no other bucket raised.

  • pattern facet regular expressions are now validated at compile time (#147). Each pattern value is compiled when the schema is; a structurally malformed one (unbalanced parentheses ((a), a quantifier with nothing to repeat ?a) is rejected, where before it was only compiled lazily at instance time (try?) and so silently ignored. The check is conservative: a construct the engine merely does not support on an otherwise-valid pattern (the empty pattern, an untabulated \p{Is...} block such as a surrogate or historic block, the lenient {,m} quantifier) is tolerated, so no valid schema is rejected. XSTS: invalid schemas accepted 1129 to 1092, no other bucket raised.

  • Schema-vocabulary name and reference attribute values are now checked for lexical validity at compile time (#147). A component's name must be a valid NCName (name="", name="123", name="a:b" are rejected), and the reference attributes type/base/ref/itemType/refer/substitutionGroup must be lexical QNames (type=":_", type="a:b:c" rejected), with memberTypes a list of QNames; these were unchecked and the schema accepted. Only the lexical form is checked (a QName's prefix is resolved elsewhere), so no valid schema is rejected. XSTS: invalid schemas accepted 1190 to 1129, no other bucket raised.

  • Schema-vocabulary attribute values are now validated at compile time (#147), the attribute-model sibling of the structural content-model check. Enumerated attributes (form/elementFormDefault/attributeFormDefault to qualified/unqualified, use to optional/prohibited/required, processContents to skip/lax/strict, mixed/abstract/nillable to a boolean) and minOccurs/maxOccurs (a nonNegativeInteger, maxOccurs also unbounded) are checked against their fixed value space; an out-of-space value (use="foo", mixed="yes", minOccurs="-1") was previously ignored and the schema accepted. Only unprefixed attributes on XSD-namespace elements are checked, so a foreign attribute is never flagged. XSTS: invalid schemas accepted 1313 to 1190, no other bucket raised.

  • Schema documents are now structurally validated against the schema-for-schemas content model (#147), the general mechanism for the structural tail of the schema-validity gap. A data-driven table of the XSD 1.0 child content model rejects, at compile time, any component child the model does not admit, more than one annotation (or an annotation that is not first, except on schema/redefine), and an identity constraint missing its selector or field. The check walks only XSD-namespace elements and skips xs:appinfo/xs:documentation foreign content, so no valid schema is rejected; restriction/extension use the permissive union of their contexts (never over-rejecting). One table covers every component rather than a rule hand-coded per element. XSTS: invalid schemas accepted 1688 to 1313 (-375), no other bucket raised.

  • The id attribute on XSD components is now validated at compile time (#147). It is of type xs:ID, so each value must be a valid NCName and unique within the schema document; the values were never checked, so a malformed (id="", id="123") or duplicated id left the schema wrongly accepted. The schema-document tree is walked, skipping xs:appinfo/xs:documentation foreign content (whose id attributes are not xs:ID) and treating only the unprefixed id (not a foreign xml:id/pre:id) as the component identifier, so no valid schema is rejected. XSTS: invalid schemas accepted 1789 to 1688, no other bucket raised.

  • Value-bound facet definitions are now validated at compile time (#147), continuing the facet-validity work. Per XSD Part 2 Datatypes 4.3, each minInclusive/maxInclusive/minExclusive/maxExclusive and enumeration value must be a valid value of the base type; minInclusive excludes minExclusive and maxInclusive excludes maxExclusive; and the lower bound may not exceed the upper (nor equal it when either side is exclusive, an empty value space), compared in the base primitive's value space. XSTS: invalid schemas accepted 2082 to 1789, no other bucket raised.

  • xs:gMonth now accepts the XSD 1.0 lexical form --MM-- (the trailing -- is optional, the later --MM erratum form still accepted), with an optional timezone (#147). Only --MM was recognized, so valid gMonth values and the schemas whose facets used them were wrongly rejected. Surfaced by the value-bound facet check above (which validates a bound value against its base type). XSTS: valid instances rejected 233 to 214; it also removed 26 false schema rejections the bound-facet check would otherwise have caused.

  • Constraining-facet definitions are now validated at compile time (#147), the first cut at the schema-validity gap (invalid schemas the validator was silently accepting). Per XSD Part 2 Datatypes 4.3, each length-family facet value (length, minLength, maxLength, totalDigits, fractionDigits) must be a valid nonNegativeInteger (totalDigits a positiveInteger), length may not co-occur with minLength/maxLength, and the integer ranges must be ordered. Malformed values (length="", "-1", "a", "1e2") were dropped by numeric parsing and the schema accepted; the definition is now read raw and rejected at compile time, with well-formedness checked independently of machine-integer range so a huge but valid value is not misreported. The findings travel through a shared diagnostics collector on the parsing context and join the model-level consistency findings. XSTS: invalid schemas accepted 2461 to 2082, with no change to any other bucket.

  • A built-in datatype named by xsi:type (xsd:int, xsd:decimal, xsd:string) is now resolved, gated by substitution validity (#147). The override resolved only against schema-declared types, so a built-in xsi:type was reported "unknown" and the element wrongly rejected. The built-in is now resolved when it validly substitutes for the declared type per Schema-Validity Assessment: the ur-type (anyType/anySimpleType) admits any type, and a bare built-in declared type admits any built-in derived from it through the XSD Part 2 datatype lattice (new BuiltinType.derives(from:)). An invalid built-in substitution is a located error, not a silent acceptance, closing the gap the resolution exposed. XSTS: valid instances rejected 248 to 233 (with no regression in invalid instances accepted, which the two fixes below preserve).

  • A simple-typed element now rejects non-schema-instance attributes (#147). A simple type declares no attribute uses, so an element whose type is simple, including one whose simple type comes from an xsi:type override and including an xsi:nil element, may carry only the xsi:* and namespace attributes; any other attribute is a violation. The check was absent, so resolving a built-in xsi:type to a simple type would otherwise have accepted stray attributes on it.

  • Identity-constraint fields compare in their value space when the selected node carries an xsi:type (#147). unique/key/keyref tuples compared lexically, so two xsi:type="xsd:decimal" fields 3.0 and 3, the same value, were treated as distinct and a unique violation was missed. Each field value now carries the simple type read from the node's xsi:type; two values are equal in that type's value space when both share a built-in base (reusing SimpleType.valueMatches), otherwise by lexical form.

  • Simple-content values preserve significant whitespace per the type's whiteSpace facet (#147). textContent trimmed leading/trailing whitespace unconditionally before the facet ran, so an xs:string (whiteSpace="preserve") value that was whitespace-only or had significant edges (e.g. matching a \t pattern, or counted by a length facet) was wrongly emptied or shortened. Simple-content validation now reads the raw character content and lets the facet normalize it; the element-only/empty "no significant text" check still ignores indentation whitespace. XSTS: valid instances rejected 288 to 248.

Added

  • Schema.Document.validate(_:schemaLoader:) honors an instance's xsi:schemaLocation and xsi:noNamespaceSchemaLocation (#147). Each referenced schema document is loaded through the supplied loader, compiled, and its global declarations merged in (the primary schema's own declarations win on conflict), so a strict or lax wildcard can resolve an element declared in another document. The plain validate(_:) is unchanged. XSTS: valid instances rejected 308 to 288 (the particle clusters whose wildcard-matched elements live in a separate, instance-referenced schema).

Fixed

  • A mixed="true" complex type with no content model now permits character data (#147). Such a type (only attributes, no sequence/choice/all) was compiled to the empty content type, which forbids text, so a mixed element carrying only text was rejected. It is now mixed content over an empty particle: text is allowed, child elements are still not. XSTS: valid instances rejected 311 to 308.
  • <xs:attribute ref="..."> references to global attribute declarations are now resolved (#147). A reference has no name, so it was dropped and the referenced attribute looked undeclared on the instance; nested attribute-group references were already flattened, but the per-attribute reference was not. The reference now resolves to the global declaration's type, taking use/default/fixed from the reference site. XSTS: valid instances rejected 338 to 311.
  • Document-scoped xs:ID/xs:IDREF validation is now enforced (#147). xs:ID, xs:IDREF, and xs:IDREFS were checked only lexically (as NCNames); the document-wide constraints, that every ID is unique and every IDREF/IDREFS item matches some ID, were not. The tree validator now records ID and reference values during its typed walk (elements and attributes) and reports duplicate IDs and unresolved references once the whole document is seen, so a forward reference resolves against a later ID. XSTS: invalid instances accepted 179 to 166.
  • The regex \w and \W escapes now follow the XSD definition (#147). \w was the Perl class (alphabetic, decimal, underscore); XSD defines it as [#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}], so symbols and marks are word characters and the connector _ (Pc) is not. \W correspondingly matched symbols it should not. XSTS: invalid instances accepted 231 to 210. (Internal regex fixtures that assumed _ was a word character were corrected.)
  • block="substitution" on a substitution-group head is now enforced (#147). The substitution filter only dropped members whose type derived from the head's type by a blocked method (extension/restriction); the substitution keyword, which forbids substitution-group substitution outright regardless of the member's type, was ignored, so a same-type member still substituted under a substitution block. XSTS: invalid instances accepted 238 to 231, valid schemas rejected 75 to 74.
  • Element default and fixed values are honored on empty elements (#147). An empty element with a value constraint takes that value, so the empty content is valid even when the type would reject the empty string; the constraint value (not the empty string) is what is validated against the type, including any xsi:type override. A restriction's enumeration now also replaces the inherited enumeration rather than unioning with it (the new set is the value space; pattern still accumulates and is ANDed), which a value-constraint case under xsi:type exposed. XSTS: valid instances rejected 395 to 369, invalid instances accepted 240 to 238.
  • Element-level block is now enforced against xsi:type substitution (#147). The block on an element declaration (distinct from block on its type) was parsed but never threaded to the instance validator, so a document substituting a blocked-derivation type via xsi:type was wrongly accepted. The element block is now carried through to blockedSubstitutionError, which also resolves the element-ref type indirection (element:foo to its concrete type) so the check fires for referenced children, not only the root. XSTS: invalid instances accepted 261 to 240.
  • The regex engine now recognizes the full XSD \p{Is<Block>} Unicode block set (#147). Only about fifteen blocks were tabulated, so a pattern over any other block (\p{IsArmenian}, \p{IsTamil}, \p{IsHangulJamo}, and so on) failed to compile and rejected valid instances. The table now carries the Unicode 3.1 blocks the XSD regular-expression appendix fixes (surrogate blocks omitted, since surrogates are not scalar values); each range is verified bidirectionally by the XSTS corpus (valid in-block instances pass, invalid out-of-block instances still fail). XSTS: valid instances rejected 502 to 395.
  • Length facets on xs:QName no longer reject valid values (#147). XSD 1.0 Datatypes 4.3.1 does not define a unit of length for QName (only string, anyURI, binary, and list), so length/minLength/maxLength on QName are implementation-defined; like Xerces and the XSTS NIST set we treat them as non-constraining rather than counting characters. Net XSTS: valid instances rejected 547 to 502 (36+ NIST false rejections fixed); 4 Microsoft cases that read QName length as a character count remain in invalid instances accepted (257 to 261) and are documented as a deliberate spec-ambiguity interpretation in docs/xsts-deviations.md.
  • Ordering facets on xs:duration are now enforced (#147). minInclusive, maxInclusive, minExclusive, and maxExclusive on duration were ignored because duration has no total order in the generic comparator. A DurationValue now implements the XSD 1.0 Datatypes 3.2.6.2 partial order (compare by adding to the four reference dateTimes); a value incomparable to the bound (such as P1M against P30D) is outside the range and rejected. XSTS: invalid instances accepted 355 to 257.
  • Pattern and enumeration facets on a union type are now enforced (#147). A union admits a value valid for any member, but pattern and enumeration (the only constraining facets XSD allows on a union) had been ignored, so a member-valid value outside the enumeration or failing the pattern was wrongly accepted. Enumeration compares in the value space of the admitting member (so 01 equals an enumerated 1 for an integer member), matching the atomic path. XSTS #147: invalid instances accepted 555 to 355.
  • Length facets on the built-in list datatypes now count list items, not characters (#146). A restriction of NMTOKENS, IDREFS, or ENTITIES resolved its base through neither the BuiltinType table (which has only the singular NMTOKEN) nor the user-type table, so it collapsed to an atomic string and length/minLength/maxLength measured characters, rejecting valid instances. The restriction base now resolves through simpleTypeReference, preserving the list variety. Closes the XSTS NIST list-NMTOKENS/IDREFS/ENTITIES facet clusters: valid instances rejected 604 to 547, invalid instances accepted 582 to 555.
  • Three crash and hang classes uncovered by running the full XSTS archive to completion (#129). (1) Nested occurrence explosion: the content-model NFA capped each particle's maxOccurs unroll at 16384 but not the total state count, so nested high-maxOccurs particles multiplied (a sequence of 16384 holding a choice of 16384 is 2.7e8 states) and OOM-killed the process at 8 GB on the Microsoft Particles set; the builder now also caps total states (2^20), degrading further repetition to star. The regex {n,m} quantifier builder had the same unbounded unroll and the same cap is applied. (2) Quadratic content-model validation: sequenceStructureErrors called follow(after: prefix) once per child and follow re-walked the whole prefix from the start each time, so a content model over many children spun at 100% CPU; matching is now incremental (one active state-set advanced per child), turning O(children squared) into O(children). (3) Character-class subtraction trap: the regex pattern [abcd-[d]] (XSD class subtraction) was parsed as a d to [ range, and since d (100) exceeds [ (91) the reversed ClosedRange trapped (SIGBUS); -[ now always opens a subtraction, and any reversed range (such as [z-a]) is a clean rejection rather than a trap.

Changed

  • Scanner-side validity findings are now located structured values (validation-framework audit follow-up): DocumentType.validityFindings carries ValidityFinding values (reason plus the declared name they are about) instead of strings, DTDSchema.declarationErrors holds ready ValidationErrors, and every declaration-level finding renders at its declaration's coding path (x, r/@a) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged.

Added

  • W3C XSTS runner, first measurement (#129, #122): the W3C XML Schema Test Suite (2006-11-06 release, ~13k test groups across NIST, Sun, Microsoft, and Boeing contributions; W3C Document License, never vendored) runs opt-in via XSTS_ROOT: every schemaTest must compile when expected valid and be rejected when expected invalid, and every instanceTest validates against its group's schema with the expected outcome; hrefs resolve against the referencing file and include/import locations from disk relative to the schema. The four disagreement counts are asserted exactly; the first measurement landed 2026-06-13 and is baselined at 75 valid schemas rejected, 2467 invalid schemas accepted, 604 valid instances rejected, and 582 invalid instances accepted across 14383 groups, with per-case deviations written to /tmp/xsts-failures.txt for the burn-down. Building the runner immediately caught a denial-of-service bug: a schema with maxOccurs in the trillions (groupF009v) OOM-killed the process because the content-model automaton unrolled occurrence counts literally; unrolling is now capped at 16384 with larger repetitions treated as unbounded, the libxml2 posture. The roadmap mermaid records the closed behavioral-parity set (#141-#144, collation) and the #139 performance epic.

Added

  • The last parity items, to one hundred percent (#143, #144, #130): XPath gains a configurable protective evaluation budget (PureXML.XPath.Budget, with a libxml2Compatible preset matching its fixed 10M node-set cap), threaded through query evaluation and thrown as a located QueryError.budgetExceeded instead of libxml2's silent refusal, default unbounded; xsl:sort honors lang (an attribute value template) with tailored alphabet orders for Polish and Russian derived from the conformance golds (diacritics interleaved, Cyrillic Yo after Ye), case-folded, falling back to the default order for untailored languages; and the WASM gate executes the full test suite under wasmtime on an actual WASI runtime (build-only with a notice when wasmtime is absent), wired into CI. The first WASI run immediately caught a real wasm32 trap: format-number's digit extraction converted through Int, which is 32 bits on wasm32 and trapped at 2^31 despite the 2^53 guard; the internals are now explicitly Int64 and the xsl:number bound is platform-correct (Double(Int.max)). Xalan baseline 127 to 125.

Added

  • Public streaming surface (#142) and a public-API test target: EventReader.next(), both initializers, and the Event alias are public, so external consumers can pull events with bounded memory over arbitrarily large inputs (an internal typealias in a split file had been shadowing the public Parsing.Event, narrowing next() to internal; the benchmark harness found it). A new PureXMLPublicAPITests target compiles against the public surface without @testable, so any public facade over internal members fails CI; it pins parse/query/serialize, streaming pull, and the chunked pull source.

Fixed

  • XPath document-order sorting is benchmarked at scale across generated (4/42/210 MB) and real corpora (NASA ADC 25 MB, SwissProt 115 MB), all linear: the quadratic fix's own first attempt (an eager sibling table built per predicate evaluation) was caught by sampling the live run and replaced with a lazy table (single-node sets never touch order keys; a parent's table builds on its second lookup). XPath at 42 MB drops 27.1 s to 1.57 s (ratio 190x to 11.7x); at 210 MB PureXML completes in 9.4 s the query libxml2 refuses at its 10M node-set cap (the configurable protective budget is #143). The benchmark harness gains a size series, a CORPUS mode for real-world files, a SAX stage, and a refusal-aware comparison table.
  • XPath document-order sorting is no longer quadratic on flat fan-outs (#139, found by the large-corpus benchmark): document-order keys looked up each node's index by scanning its parent's children, so sorting a node-set drawn from one wide element (the 42 MB corpus: 200k siblings) cost 27 seconds. A per-operation sibling-index cache enumerates each distinct parent once, making key computation linear in the tree.

Changed

  • Byte-backed Reader, performance passes 2 and 3 (#139): String input now copies its UTF-8 once into owned storage (an unsafe pointer encapsulated in a class lifetime, never escaping the parser) and scalars decode straight off the pointer, eliminating the per-character closure chain; ASCII literals match and consume at the byte level; and plain-ASCII character data, the bulk of any document, scans as a single byte run with one String built per run. Cumulative parse time on the 4.2 MB benchmark corpus drops 32 percent (0.49 s to 0.34 s, libxml2 ratio 30x to 20x). The W3C conformance suite caught one boundary in the run scanner (a run starting with '>' could swallow the "]]>" check after the character path consumed "]]"; ibm14n01); the run scanner now leaves a leading '>' to the character path, with the case pinned as a unit regression. All suites (W3C xmlconf, RELAX NG, xalan), Linux, and WASM verified.
  • First measured performance pass (#139): Reader.matches() no longer materializes an Array of the literal per call (it runs on every scan decision and was the hottest comparison in the parse profile); parse time on the 4.2 MB benchmark corpus drops 16 percent (0.49 s to 0.41 s, libxml2 ratio 30x to 26x). The benchmark harness, baseline, profile findings, and the optimization log live in docs/benchmarks.md.

Fixed

  • Stylesheet composition cycles no longer crash (#130 critic loop): an include or import cycle (a.xsl includes b.xsl includes a.xsl) recursed without bound and killed the host with a stack overflow, a denial of service reachable from stylesheet data. The compile chain now tracks the active load chain by resolved href and drops re-entry, while diamond composition (two units legitimately loading the same sheet) keeps loading both copies with their own import precedences, per 2.6.2. id() also gains a per-document index built on first use instead of re-walking the whole document per call, matching key(). Cycle and diamond regression tests added.

Added

  • Top-level stylesheet parameters (#141), nineteenth xalan burn-down (#130): PureXML.XSLT.transform accepts parameters: [String: String], overriding xsl:param globals by name (xsl:variable globals are never overridable; the stylesheet records which names are parameters across imports and includes). The conformance runner honors the xalan harness contract, reading <case>.param files; en alphabet sorting passes, and the CRLF param files exposed a Swift grapheme gotcha (CRLF is one Character, so splitting on "\n" never splits; the runner splits on newline graphemes). Baseline 128 to 127; Polish/Russian alphabet tailoring stays baselined as the locale class.
  • Space-ignorable sort collation, eighteenth xalan burn-down (#130): xsl:sort's default text order now treats spaces as ignorable at the primary level, breaking ties by later-first-space (the Java Collator semantics the conformance golds encode), derived from the gold orderings rather than delegated to a locale library. Baseline 129 to 128.
  • DTD-typed id() and node-kind generate-id(), seventeenth xalan burn-down (#130): the XSLT layer's id() now follows the XPath definition exactly, resolving only attributes the source document's DTD declares as type ID (internal or external subset, parsed through the transform's entity resolver) and yielding the empty set without a DTD, replacing the name-based heuristic for transforms; and generate-id() produces non-empty, distinct identifiers for attribute and namespace nodes through their identity hash instead of returning the empty string. Two unit tests pin both. Baseline 131 to 129.
  • HTML 4 serialization corners, sixteenth xalan burn-down (#130): basefont and isindex join the void-element set (no end tags), and the html method leaves an ampersand unescaped when a brace follows, the historical &{...} attribute convention of 16.2. Baseline 134 to 131 (output 20 to 17).
  • Globals, prefix wildcards, fifteenth xalan burn-down (#130): a top-level variable now evaluates with every previously evaluated global in scope, so a variable whose body references an earlier top-level param (directly or via call-template) resolves; same-name globals resolve by import precedence (folds happen in precedence order and the last same-name entry wins, so a later sibling import beats an earlier one and the importing sheet beats both); the stylesheet fold uniformly favors later contributions for attribute-set definitions, decimal formats, namespace aliases, and output settings, matching the post-order precedence model; and the XPath compiler accepts the NCName:* name-test form, matching any name in the prefix's namespace (bound by URI, falling back to the in-document prefix string) at its -0.25 default priority, so @ped:* outranks @*. Three unit tests pin the behaviors. Baseline 144 to 134 (variable 10 to 5, conflictres 8 to 5).
  • Import precedence and relative composition, fourteenth xalan burn-down (#130): import precedences are assigned in post-order over the import tree, so sibling imports are distinct (later higher) and every unit sits above its own imports; apply-imports searches only the current template's stylesheet's own import subtree per 5.6 (templates carry their unit's precedence range), falling back to the built-in rule when that unit imports nothing; an included stylesheet's declarations join the including unit at its precedence while its imports join the import list; and include/import hrefs resolve against the including stylesheet's own URI, so nested relative chains load correctly. Also fixed along the way: an included document's tree was only weakly referenced once flattened, so its ancestor chain (xmlns declarations, exclusions in scope) could disappear before its templates were parsed. Two unit tests pin apply-imports scoping and relative chains. Baseline 159 to 144 (reluri 11 to 3, impincl 9 to 2).
  • Attribute sets, copied namespaces, html booleans, loader entities, thirteenth xalan burn-down (#130): same-name xsl:attribute-set declarations merge as ordered definitions per 7.1.4 (each expands its used sets before its own attributes, a later definition's attributes winning); xsl:copy of an element carries the source element's in-scope namespace nodes per 7.5 (the fixup pass drops result-redundant ones); the namespace fixup lets a carried attribute prefix shadow an inherited binding locally (matching the source document's shape) unless the element's own name relies on it; the html method minimizes boolean attributes whose value repeats their name (CHECKED="CHECKED" serializes as CHECKED); and external DTDs and entities referenced by the stylesheet or source now resolve through the transform's document loader, the same channel as document(). Four unit tests pin the new behaviors. Baseline 174 to 159 (attribset 13 to 4, copy 12 to 7).

Fixed

  • format-number and xsl:number no longer trap on extreme values (#130 critic loop): format-number of a value at or beyond 2^53 rendered through an unguarded Double-to-Int conversion, a fatal trap reachable from user data (format-number(99999999999999999999 * 99999999999999999999, '#,##0') crashed the host); a fraction picture with more than 15 places overflowed the scale conversion the same way; and xsl:number value= beyond 2^53 trapped identically. Digit extraction now clamps fraction places to 15 (the 2^53 exactness bound), pads back to the picture's requested minimum, renders out-of-range integers through the exponent expander (digits stay exact), and xsl:number falls back to plain XPath number rendering outside [1, 2^53). Regression tests cover all three paths.

Added

  • Critic-loop hardening of the xalan burn-down (#130): raw-output marker handling is now gated on the stylesheet actually mentioning disable-output-escaping, so a source document that happens to contain the private-use marker characters U+E000/U+E001 passes through every transform untouched (previously they were stripped, and text between them unescaped, unconditionally), and the text output method only removes markers without "unescaping" content that was never escaped; the pattern-matching function table is built once per transform instead of once per (template, node) membership test; and the d-o-e, CDATA "]]>" splitting, simplified-syntax, html default-method/META, per-document key(), and attribute-axes behaviors gain focused unit tests that run in CI without the opt-in corpus (eight new tests).

  • Following/preceding from attribute nodes, twelfth xalan burn-down (#130): the following and preceding axes now work from attribute and namespace starts, anchored at the owner element per the XPath 1.0 document order (an attribute follows its element and precedes its children, and has no descendants of its own). Baseline 182 to 174 (axes 16 to 8).

  • Keys per document, in document order, eleventh xalan burn-down (#130): key() resolves its index against the current node's own document (built lazily per document root, so keys work inside document() trees), a node-set use expression indexes the matched node under every member's string value, and key() results return in document order regardless of the lookup value order. Baseline 187 to 182.

  • Sort keys in context and cross-document template matching, tenth xalan burn-down (#130): xsl:sort keys are computed once per node with the node's own evaluation context (its position and size in the selection, the caller's variables, functions, and namespace bindings), so position()-based and variable-referencing keys work, and NaN keys order before every number under data-type="number" (the Xalan order); match patterns evaluate over the matched node's own document rather than the transform source, so templates match nodes loaded through document() (the match cache keys include the document identity). Baseline 204 to 187 (sort 18 to 11, mdocs 14 to 6).

  • Prolog and CDATA conformance, ninth xalan burn-down (#130): the XML declaration precedes the document type declaration; a public-only doctype is emitted by the html method (16.2) and ignored by the xml method, which requires doctype-system (16.1); the simplified stylesheet syntax of 2.3 (a literal result element carrying xsl:version) compiles as a single match="/" template; and cdata-section-elements text containing "]]>" splits across section boundaries so no section holds the terminator. Baseline 218 to 204 (output 34 to 20).

  • Stylesheet namespace resolution, eighth xalan burn-down (#130): a template carries the xmlns bindings in scope at its stylesheet element, and they flow into its match pattern (the match cache keys on pattern plus bindings) and every expression evaluated while it is instantiated, so match="baz2:doc" and select="local-name(baz2:b)" resolve the prefix against the stylesheet and match by namespace URI; with bindings supplied the XPath 1.0 rule applies exactly, an unprefixed name test selecting only the null namespace. The instruction-scope namespace capture includes the default declaration, so xsl:element honors an in-scope xmlns="uri" for its unprefixed created names, an in-scope xmlns="" keeps them namespace-free (undeclared in the result when an inherited default would capture them), and an unusable created name (:foo) drops the wrapper and keeps the content, the Xalan recovery. Baseline 263 to 218 (namespace 41 to 13, axes 16, sort 18, output unchanged at 34, the rest spread).

  • Keys, ids, and doctyped sources, seventh xalan burn-down (#130): key() and document() read the string value of attribute and namespace node arguments instead of dropping them (the Muenchian grouping idiom works); match patterns evaluate with the XSLT function table, so key('k', 'v') and id('x') patterns match (a key/id pattern branch is also no longer prefixed with //); and the XSLT entry points parse sources, stylesheets, and document() loads with doctypes allowed, which the conformance corpora use throughout. Baseline 292 to 263 (idkey 39 to 16, the rest spread).

  • Templates over attribute and namespace nodes, sixth xalan burn-down (#130): xsl:apply-templates and xsl:for-each now keep attribute and namespace nodes in their selections instead of dropping them at the tree-node conversion. The evaluation context carries the non-tree current node (its owner element backs the tree-only machinery), XPath evaluation and current() start from it, match patterns select attribute nodes (the match cache keeps attribute identities, owner plus name, alongside tree identities), the built-in rules copy an attribute's or namespace's value as text, xsl:copy copies the focused attribute or namespace declaration itself, and an attribute result arriving after element content is ignored (the XSLT recovery). Sorting and apply-imports work over the generalized node kind. Baseline 352 to 292 (axes 35 to 16, copy 29 to 16, attribset 18 to 13, the rest spread).

  • Output methods in depth, fifth xalan burn-down (#130): disable-output-escaping on xsl:value-of and xsl:text (raw text travels bracketed by private-use sentinels and the serializer's escaping is undone after the fact; a string extracted from a result tree fragment loses the markers per 16.4, so the variable round-trip case re-escapes); the default output method is html when the first result element is named html with a null namespace (16.1); the xml method writes the XML declaration unless omit-xml-declaration="yes" (the spec default, with the caller's emit options as fallback only on explicit stylesheet silence); the html method injects META http-equiv="Content-Type" into head (16.2), keeps SCRIPT/STYLE raw and void elements end-tag-free case-insensitively, and escapes U+00A0-U+00FF to the HTML 4.01 Latin-1 entity names. Whitespace-only detection in stylesheet/source stripping now means XML whitespace only, so an NBSP-only text node survives. The conf comparison treats whitespace adjacent to tag boundaries as layout for non-canonical comparisons. Baseline 371 to 352 (output 49 to 30).

  • Literal result element namespace copying, fourth xalan burn-down (#130): a literal result element now copies its in-scope namespace declarations to the result per 7.1.1, minus the XSLT namespace and the namespaces of the prefixes listed in exclude-result-prefixes/extension-element-prefixes in scope (#default included); an aliased stylesheet namespace declares its result prefix and URI instead. The namespace-fixup pass drops any declaration that repeats an inherited binding, so the copied nodes do not redeclare on every descendant, and created names now reuse the stylesheet's own prefixes (anamespace:Attr1 rather than ns0:Attr1). xsl:output indent="yes" switches to the corpus golds' Xalan indentation form: children on their own lines with no leading spaces. Two unit expectations updated to the spec behavior (a literal element under stylesheet-level declarations carries them; collapsed alias+result declarations). The xalan baseline drops from 446 to 371 (namespace 89 to 42, output 50 to 49 and others spread across categories).

  • format-number picture parsing in full, third xalan burn-down (#130): the picture now parses JDK DecimalFormat-style into literal prefix/suffix affixes around the number part, the negative subpattern after the pattern separator (its affixes apply; without distinguishing ones the minus sign still prefixes, the Xalan resolution, which also covers colliding decimal/grouping separators by reading every occurrence as grouping), per-mille (with a custom symbol via the new per-mille attribute) and percent as multipliers kept literally in the output, the grouping size taken from the last grouping separator's distance to the decimal point instead of a fixed 3, and the generic currency sign U+00A4 rendering as this processor's en-US $. Digit extraction is rewritten over one scaled rounding plus pure integer arithmetic, so 0.4812 renders 4812 rather than re-accumulating float error per digit. The xalan numberformat category drops 30 to 1 (the survivor is a namespace-copy case); baseline 475 to 446.

  • Full xsl:number per XSLT 1.0 section 7.7, second xalan burn-down (#130): level single/multiple/any with count and from as real patterns matched through the transform's match cache (default count: same node type and expanded name; single finds the nearest matching ancestor-or-self and numbers it among matching preceding siblings, multiple numbers every matching ancestor-or-self outermost first, any counts matching nodes at or before this one in document order with from as the boundary, always yielding a number so an unmatched count renders 0); the value expression evaluates and rounds, bypassing formatting below one; and the full format-token engine (leading/trailing punctuation, alternating alphanumeric tokens and separators with last-token and last-separator reuse, zero-padded decimal widths, a/A/i/I and the Greek alphabetic sequence, grouping-separator/grouping-size digit grouping). A single/multiple level whose count matches nothing renders as the empty string, punctuation included. The xalan baseline drops from 557 to 475 (numbering 84 to 7, the rest pattern-engine prefixed/key/id cases).

  • document() is stable per transform (#130): the same URI reference now returns the identical document object across calls within one transform, per the XSLT document() definition, via a per-transformer document cache; previously each call re-parsed, so node identity, and with it generate-id() uniqueness against live nodes, could be violated when an address was reused (the idkey49 flake).

  • XSLT namespaced name creation and result fixup, first xalan burn-down (#130): xsl:attribute and xsl:element honor the namespace attribute and resolve a prefixed name against the instruction's own in-scope stylesheet declarations (7.1.2/7.1.3, captured at parse time so AVT names resolve correctly); xsl:copy applies use-attribute-sets; and a namespace-fixup pass runs over the result tree before serialization, declaring every namespace a created element or attribute name carries, reusing an existing binding for the same URI or generating ns0, ns1, ... prefixes when the carried prefix is absent or taken, and undeclaring an inherited default namespace that would capture an unqualified name. The xalan baseline drops from 604 to 557 (attribset 52 to 20, namespace 123 to 92, axes 68 to 35); one namespace-alias unit test updated to expect the now-declared result namespace.

  • XSLT 1.0 conformance runner over the Apache xalan-test corpus (#122, #130): the de facto OASIS-era suite (1690 gold-bearing cases across 36 categories) runs opt-in via XALAN_TS_ROOT, never vendored. Comparison is deliberately normalized: outputs that parse as XML compare by canonical form (Xalan's indentation is not normative), others with whitespace runs collapsed. First measurement: 1086 pass; the 604 known failures are carried exactly in Tests/Fixtures/xalan-baseline.txt, asserted in both directions (a fixed case must leave the file, a regression shows as a new failure). The top burn-down classes by count: namespace handling (123, led by namespaced xsl:attribute creation), xsl:number depth (85), axes corners (68), output/serialization options (55), attribute sets (52), id()/key() (41).

  • RELAX NG schema-correctness validation (#131): a schema document is now validated before any pattern interpretation, in three layers. The grammar check (section 3) rejects unknown or misplaced elements, illegal unqualified and RELAX NG-namespace attributes, wrong child arity, stray text, malformed or undeclared-prefix QNames, non-URI datatypeLibrary values (absolute, fragment-free, valid percent escapes), fragment-carrying hrefs, the 4.16 except restrictions (anyName/nsName inside excepts, propagated through choice), and the xmlns name prohibitions including the ns-attribute and except-mention forms. The compiler checks reject combine conflicts and double combine-less defines (4.17), unresolved refs and parentRef without a parent, startless grammars, include/externalRef self-reference, include overrides with nothing to override, non-grammar include targets (loaded documents are grammar-checked too), unknown datatypes, and params against the built-in library. The restrictions checker (4.19, 7.1-7.4) runs over the compiled algebra after 4.20/4.21 normalization (notAllowed/empty operands simplify away first; recursion detection deliberately precedes it): prohibited paths under attribute, oneOrMore-group, list, and in start; computable content types; duplicate attributes by representative-name overlap; infinite-name-class attributes outside oneOrMore; and the interleave text/element constraints. The spec suite's incorrect class drops from 213 compiling to 7, all pre-Fifth-Edition name-class cases (the xmltest-141 class, count asserted), with zero correct-schema or instance regressions. Two leniency tests updated to the spec behavior (double combine-less defines and unknown datatypes are schema errors).

  • RELAX NG QName values, instance level clean (#131): <value type="QName"> compiles to its (namespace, local-name) value pair via the schema's xmlns/ns scope, and the derivative engine threads the instance element's in-scope namespace bindings down to value comparison (in both the tree walk and the streaming state, whose frames now carry their bindings), so an instance QName resolves its prefix, or the default namespace when unprefixed, against the instance document itself; an unbound prefix matches nothing. The streaming path also gains the tree walk's empty-element rule. With this the spec suite's instance level is fully clean: valid 289/289 accepted and invalid 291/291 rejected; only the asserted schema-correctness class remains.

  • RELAX NG grammar scoping and include depth, third burn-down (#131): each grammar element opens its own define scope (names are scope-mangled in the flat table, so nested grammars do not collide) and parentRef reaches the enclosing scope; include is transitive (an included grammar's own includes merge first) and a define carried by the include element replaces the included grammar's same-name define (the 4.7 override); div wrappers flatten into their grammar while still contributing ns/datatypeLibrary inheritance (4.11); value text is kept untrimmed so whitespace-preserving datatypes compare exactly (6.2.9); and dataExcept recurses through choice patterns. The spec suite is now clean except one class: valid 286/289 and invalid 287/291, with all six remaining instance cases the QName datatype (its value space needs prefix resolution in both the schema and instance contexts), plus the asserted schema-correctness item.

  • RELAX NG namespaces and semantics, second burn-down (#131): the compiler now implements ns inheritance and the QName form of names (4.9-4.10: ns walks ancestors and crosses include/externalRef document boundaries; name="eg:foo" and <name>eg:foo</name> resolve their prefix against the schema's in-scope xmlns declarations; the name= shorthand on an attribute stays namespace-less rather than inheriting), an include brings in the included grammar's start with an include-level override honored (4.7), multiple <start> elements combine like defines and a single define or start may omit combine while still merging by the named method (4.17), <data> supports <except> via a new dataExcept pattern in the derivative engine (4.12), an empty element matches a data/value pattern whose lexical space admits the empty string (Clark's empty-text rule), and a whitespace-only attribute value matches a pattern that matches empty. Spec-suite baselines tighten from valid 242/289 to 274/289 and invalid 278/291 to 279/291; the name-class and tree-helper sections split to their own files for the length caps.

  • RELAX NG simplification, first burn-down (#131): foreign-namespace elements are stripped before pattern interpretation (4.1: only RELAX NG-namespace elements are schema content; a namespace-less element counts only inside a fully unqualified schema), name/type/combine attribute values are whitespace-trimmed (4.2), and include/externalRef hrefs resolve against the xml:base attributes in scope and the loaded document's own URI (4.5, reusing the RFC 3986 resolver), with the document base threaded through nested loads and visited-tracking keyed on resolved hrefs. Spec-suite baselines tighten: valid 242/289 accepted (from 233), invalid 278/291 rejected.

  • RELAX NG spec test suite runner (#122, #131): James Clark's spectest.xml (385 cases, distributed with jing-trang) runs via an opt-in RNG_TS_ROOT runner, never vendored; the manifest is parsed with PureXML itself, nested testSuite scopes thread their resource/dir definitions into the compiler's loader, and each case asserts compile/reject for the schema plus accept/reject per instance. First measurement, carried as exact baselines: every correct schema compiles and most instances behave (valid 233/289 accepted, invalid 277/291 rejected), while schema-correctness validation is absent (all 213 incorrect schemas compile, count asserted exactly). The burn-down classes are recorded in the runner: schema validation against the RELAX NG grammar and the 4.16-4.18 restrictions, foreign-namespace stripping (4.1), xml:base-aware include/externalRef resolution (4.5), and the section 6 compatibility datatypes.

  • Canonical XML 1.0 spec-example vectors (#122, #132): the worked examples of the C14N 1.0 Recommendation's section 3 run as input/expected-output tests (cited per example; used under the W3C Software and Document License), all six passing: top-level PIs/comments with and without comments mode, whitespace preservation, start/end tags with attribute sorting and DTD defaults, character modifications and references, entity expansion, and UTF-8 output from legacy-encoded input. The vectors exposed three real conformance fixes: top-level siblings in the canonical form are now separated by single line feeds; an empty default-namespace declaration renders only when it undeclares a rendered non-empty default (superfluous xmlns="" is dropped, inclusive mode); and the text/attribute escape loops iterate Unicode scalars, so a carriage return that grapheme-clusters with a following line feed still escapes to &#xD; (the #135 class, surfacing in output rather than lexing).

  • Scalar-level lexing (#135): the reader now buffers exactly one Unicode scalar per element (multi-scalar graphemes from any source are split, string input iterates scalars directly), so a combining mark directly after an ASCII delimiter can no longer merge with it into one Swift grapheme cluster and XML's scalar-defined name productions classify correctly: U+06D6 lexes as a Fifth Edition PI-target NameStartChar, ZWNJ/ZWJ attribute names parse, and U+309A markup from entity expansion forms a real element. The replacement-text, content-model, and ATTLIST sub-scanners and the entity-literal validator go scalar-level the same way, while grapheme clusters reassemble naturally wherever scanned text is appended to a String (asserted by test). This clears the last eduni baseline (now empty), grows the IBM pre-5e class from 279 to 300 exactly as the Fifth Edition prescribes, and, combined with the EDITION="1 2 3 4" exclusions the xmltest manifest itself carries for cases 140/141, empties the xmltest deviation baseline entirely: every xmlconf case applicable to a Fifth Edition, namespace-aware processor now passes.

  • Strict internal-subset profile (#128): Limits(strictInternalSubset: true) holds the internal DTD subset to the letter of XML 1.0, rejecting conditional sections and parameter-entity references inside markup declarations (entity values, content models, ATTLIST bodies), both of which PureXML otherwise supports as features; DeclSep references between declarations and %name; inside quoted attribute defaults (AttValue, where % is literal) stay legal, and defaults are unchanged. The xmltest runner opts in, taking its baseline from 181/186 to 185/186 with one remaining documented deviation: case 141, which expects pre-Fifth-Edition name-character classes.

  • Per-entity base-URI tracking (#138): a relative system identifier declared inside an external entity now resolves against that entity's own URI (RFC 3986, reusing the C14N resolver), so nested external entities find their siblings; ExternalID gains an optional base and a resolvedSystemID, the DTD scanner threads the current entity's URI through external-subset and parameter-entity scans, and resolvers receive the already-merged identifier (raw identifiers unchanged when no base applies). Clears eduni errata-2e/E18; the eduni baseline now holds only the #135 grapheme-lexing class.

  • Encoding-declaration contradictions are fatal (#137, 4.3.3): a document whose XML declaration names a 16- or 32-bit encoding over BOM-less 8-bit bytes is rejected; a BOM followed by a declaration outside its family (a UTF-8 BOM with encoding='iso-8859-1', a UTF-16 BOM with an 8-bit name) is rejected, with the declaration read through the BOM's own byte pattern; and an external entity whose text declaration names a higher XML version than the document's is refused (errata E38), in both the external-subset and external-entity paths. The eduni not-wf baseline is now completely empty.

  • Namespaces 1.0 constraints (#136): the namespace layer now enforces the binding rules as located parse errors: malformed qualified names (foo:, :foo, a:b:c, xmlns:) are rejected; the xmlns prefix may never be declared; xml may be bound only to its own namespace name, which no other prefix (nor the default) may take, likewise the xmlns namespace name; a prefix may not be undeclared (Namespaces 1.0); attributes must be distinct by expanded name (the same URI+local name through two prefixes is a duplicate); PI targets and entity/notation names are NCNames; and a namespace declaration whose attribute is DTD-declared with a tokenized type binds its normalized value (so urn:x and urn:x are one namespace name, eduni ns 012). Clears all 18 namespace cases from the eduni baseline, leaving only the four #137 encoding-mismatch cases; xmltest valid/sa/012 (an attribute literally named :) joins the documented deviations per the suite's own NAMESPACE='no' flag, mirrored by the eduni runner's new NAMESPACE filter.

  • W3C xmlconf eduni sections (#121, #127): the Edinburgh errata suites (2e, 3e, and the 5th-edition 4e), Namespaces 1.0 with its errata, and the misc cases run via the XMLCONF_ROOT runner; XML 1.1 and Namespaces 1.1 are explicitly out of scope and EDITION-gated cases skip when they do not apply to the Fifth Edition. The burn-down fixed a real spec bug and several errata behaviors: attribute values now get the 3.3.3 CDATA normalization (literal whitespace becomes a space before reference decoding, so character-referenced whitespace survives, fixing errata E36 and E20 semantics) and tokenized normalization collapses only SPACE characters; an undeclared general entity in content or an attribute value is a deferred validity finding instead of a fatal error when unread external declarations might supply it (production 68; the reference stays literal, nothing is fetched), matching the scanner-side split; the errata E15 family is enforced (any reference, comment, PI, or whitespace inside an element declared EMPTY; direct or double-escaped character-reference whitespace as character data in element content, while entity-supplied whitespace stays legal); errata E2 reports duplicate tokens in enumerated and NOTATION declarations; errata E14's declaration-completed-inside-a-PE-replacement parses and reports VC: Proper Declaration/PE Nesting; the ID family must be NCNames under namespace validation; and DTD-strict mode requires namespace declarations to be declared like any attribute. Remaining deviations are exact and classified: 24+1 grapheme-cluster lexing cases (a combining mark after an ASCII delimiter merges into one Swift Character), 18 namespace-constraint cases, 4 encoding-declaration mismatches, and E18 (per-entity base-URI tracking), each filed as its own issue.

  • W3C xmlconf japanese section, fully clean (#121, #126): all 13 cases (the same prose and parameter-entity DTDs encoded as UTF-8, UTF-16 in both byte orders, Shift_JIS, EUC-JP, and ISO-2022-JP) decode through PureXML's own byte decoder, parse, and DTD-validate cleanly with zero deviations; the optional encodings the suite classifies as TYPE='error' are all supported, so they are held to the must-parse bar.

  • W3C xmlconf IBM section, clean to two documented classes (#121, #125): the deepest block, 1094 cases. Valid 220/220 (parse + strict DTD validation), invalid 64/64 reporting, not-wf 529/810 rejected with the 281 accepted cases being exactly two documented classes: 279 cases from productions 85-89, the 1998 character-class appendices deleted by XML 1.0 Fifth Edition (whose count is asserted exactly so drift in either direction is caught), and 2 parameter-entity-in-internal-subset feature cases (the xmltest 160-162 class, headed for #128's strict profile). Closing the section's real gaps: a document without a root element is rejected (production 1); an entity reference in an entity literal must be a lexical Name or character reference (production 68); WFC: No Recursion is enforced at declaration over the combined general/parameter reference graph (CDATA-shielded), so a recursive pair is rejected without being referenced; an undeclared parameter-entity reference is a well-formedness error when the document is standalone or has neither an external subset nor parameter entities, and a deferred validity finding otherwise (the production-68 WFC/VC split, also applied to entity references in attribute defaults); a declared external PE the resolver refused is neither; and a content-model group opening in one PE replacement and closing in another is reported (VC: Proper Group/PE Nesting).

  • Standalone validity constraints (#134): a document declaring standalone='yes' is now checked against its external-subset dependencies (2.9). The DTD scanner records declaration provenance (which entities, element models, and attribute lists came from the internal subset); the validator reports an externally-declared attribute default that would be supplied, an attribute value an externally-declared non-CDATA type would normalize, and whitespace inside externally-declared element content. The parser enforces the matching well-formedness side: a standalone document referencing an entity declared outside the internal subset reports it as undeclared (WFC: Entity Declared). This empties the entire Sun baseline: the section is fully clean at not-wf 56/56, valid 28/28, invalid 74/74, zero deviations, closing #124.

  • DTD validity constraints, second Sun burn-down (#121, #124): the validator now enforces VC Root Element Type (the root element must match the DOCTYPE name, recorded by the scanner), VC Unique Element Type Declaration (duplicate <!ELEMENT> reported, first stays in effect), VC No Duplicate Types in mixed content, VC One ID per Element Type, VC Notation Attributes (listed names must be declared) and VC Notation Declared (an NDATA entity's notation must exist), VC Attribute Default Legal across the tokenized types plus VC ID Attribute Default (an ID attribute must be #IMPLIED or #REQUIRED), lexical Name checks for ID/IDREF/IDREFS values, and CDATA sections as character data in element content even when empty. Strict mode additionally requires every attribute to be declared (VC Attribute Value Type; namespace declarations exempt). Declaration-level findings are computed once when the schema is built and reported at the document root. Sun invalid coverage moves from 43/74 to 62/74 reporting; the twelve remaining silent cases are one family, the standalone='yes' VCs, which need declaration-provenance tracking.

  • Entity content splicing (#133): a general entity whose replacement text contains markup, directly or through entities it references, is now reparsed as content at the point of reference (4.4.2 Included), so <p>text</p> inside an entity becomes a p element in the tree and in the pull-event stream, in both strict and recovering modes, instead of being included as escaped character data. The replacement is validated (the reference-time balanced-content WFC), checked for reference cycles, and budgeted against the amplification cap before being spliced into the reader's stream. Character references in internal entity literals now expand when the declaration is parsed for general entities too (4.4.5, previously parameter entities only), so &#60;foo> stores real markup that splices, and the Appendix D double escape &#38;#38; stores &#38; and renders as a literal ampersand; the reference-time WFC accordingly validates remaining references instead of re-expanding them. Text-only entities keep the single-pass expansion path and its event coalescing. Closes the last two Sun valid deviations: the section is now 28/28 valid.

  • Validity-layer conformance, first Sun burn-down (#121, #124): tokenized attribute values are normalized per 3.3.3 before validity checks (whitespace runs collapse, outer whitespace strips, CDATA untouched), so nonce satisfies an enumeration and an entity-expanded IDREF resolves; an external parsed entity's leading text declaration is stripped per 4.3.1 (and validated: version optional, encoding required, standalone forbidden) instead of being included in the replacement text; character references in parameter-entity literals are expanded at declaration time per 4.4.5, making the spec's Appendix D &#37;zz; example parse; and a PI target inside the DTD subset must be separated from its data (production 16). Sun baselines tighten to not-wf 55/56, valid 26/28, invalid 43 reporting; the two remaining valid failures share one cause, filed separately: general-entity replacement text containing markup is included as character data rather than reparsed into elements.

  • W3C xmlconf Sun section runner (#121, #124): the first validity-layer runner. Sun's manifests classify cases three ways and the runner checks all three: not-wf rejected by the strict parser (54/56), valid documents parse AND DTD-validate cleanly (19/28), invalid documents parse but must report at least one validity error (43/74). The exact deviation baselines are carried in the test with their causes: tokenized attribute-value normalization (3.3.3), external-entity text declarations (4.3.1), declaration-time character-reference expansion in PE literals (4.4.5), the standalone VCs, ID/IDREF corners, #REQUIRED enforcement, per-type attribute VCs, and the root/DOCTYPE name match, the work list this issue burns down.

  • W3C xmlconf OASIS/NIST section, fully clean (#121, #123): a second opt-in runner (XMLCONF_ROOT-gated, suite never vendored) drives the oasis section from its own manifest, parsed with PureXML itself; all 247 not-wf cases are rejected and all 100 well-formed (valid + invalid) cases parse, with zero deviations (the one excluded case is flagged NAMESPACE='no' by the manifest itself: pre-namespace colons in names, which a namespace-aware parser correctly refuses). Closing the section's last 24 gaps made the DTD subset grammar strict: unknown, lowercase, or space-separated markup-declaration keywords are rejected; a parameter-entity reference must be '%' Name ';' exactly; entity, parameter-entity, and notation names must start with a NameStartChar; NDATA requires whitespace and a strict name; a notation declaration requires its identifier and a clean tail; entity value literals admit no bare '%' (production 9); conditional-section keywords are case-sensitive INCLUDE/IGNORE and sections must balance to their ']]>'; junk between declarations is not well-formed; a grammar violation in the external subset now rejects the document (previously swallowed); the external subset may open with a text declaration (version optional, encoding required, standalone forbidden, production 77); and a processing-instruction target must be separated from its data by whitespace (production 16). The new strictness also tightened the xmltest baseline from 180/186 to 181/186: a CDATA section in the internal subset (case 107) is now correctly rejected by the conditional-keyword check, leaving five documented deviations.

Changed

  • Schema-consistency checks now run through the validation framework (#117 follow-up): the derivation final check and Particle Valid (Restriction) are composable Validation<SchemaTypeFact, CompiledSchemaFacts> rules (finalRespected, restrictionsAreSubsets) applied per named type, so a schema with several problems reports them ALL at once. SchemaError.finalViolation and .invalidRestriction are replaced by .inconsistent([String]) carrying every finding with its type's coding path; the finding texts are unchanged. checkRedefine, checkAllGroups, and notASchema stay fail-fast throws because they examine the raw schema source, the compilation analog of well-formedness.
  • Document-order sorts no longer recompute root paths per comparison (#113): node-set ordering (the evaluator's order-and-dedup, EXSLT's set functions, string-value coercion's first-node lookup) goes through decorate-sort-undecorate helpers (sortedByDocumentOrder(), firstInDocumentOrder()) that compute each node's order key once, turning O(n log n x depth) sorts into O(n log n). set:leading/set:trailing also reuse one precomputed pivot key. Behavior is unchanged.
  • XPath compilation moved out of the per-node loops (#112): XSLT template selection now resolves each match pattern once per transform through a per-run match cache (the pattern's node set is computed once over the source tree, then membership is an identity lookup), instead of recompiling and re-walking the tree for every (node, template) pair; and the XSD identity validator compiles each selector and field XPath once per run through a query cache instead of once per visited element. Schematron already stored compiled queries. Behavior is unchanged; a multi-template shape test guards the path.
  • The HTML document builder's body is now constructed as a live mutable TreeNode tree with a single open-elements stack (the HTML5 model), attaching nodes to their parent as they open rather than accumulating children on close (#83, internal). Behavior is unchanged; this is the foundation the adoption agency algorithm needs to reparent already-built subtrees.
  • DTD attribute validation is now decomposed into five named, independently composable Validation rules (#101): DTD.requiredAttributes, fixedAttributeValues, enumeratedAttributeValues, tokenizedAttributeTypes, and notationAttributes, replacing the single attributeDeclarations rule. Each is removable by identity and isolation-tested one constraint at a time, honoring the validation-framework idiom. Behavior is unchanged. (XSD content validation stays one recursive rule by a documented scope decision, since its constraints are interdependent through type resolution; Schematron already exposes its rules as composable Validation<Node, Void> values.)

Added

  • Parser strictness, first W3C-suite pass (#105 Tier 2, #120): the strict parser now rejects -- inside comments (including the ---> ending), a raw < in attribute values, the literal ]]> sequence in character data, attributes not separated by whitespace, and characters outside the XML Char production in content, five new located ParseError cases. Driven by a new opt-in runner for the W3C XML Conformance Test Suite's xmltest section (XMLTS_ROOT-gated; the suite is never vendored because its license permits redistribution only as the unmodified archive): valid/sa 118/119, not-wf/sa rejection improved from 80 to 180 of 186 and valid/sa to 119/119 across five strictness passes (the second adds character-reference validation, lowercase-x-only hex references and the referenced code point must be a valid XML Char; the reserved 'xml' PI target, legal only as the declaration at the document's very first bytes; and XML Char validation in comments and PI data; the third validates content models against the XML 1.0 grammar at DTD scan time, rejecting unbalanced parentheses, #PCDATA misplacement and nesting, mixed connectors, whitespace-detached or doubled quantifiers, empty groups, and SGML leftovers; the fourth makes the DTD declaration grammar strict: whitespace and name rules, junk after a value, NDATA spacing and its general-entities-only rule, entity-value literals checked for XML Chars and complete references, SYSTEM/PUBLIC identifiers parsed strictly with PubidChar validation, validated against productions 52-60, the reserved xml PI target rejected inside the subset, strict UTF-8 decoding, the XML declaration's version/encoding value grammar, and required whitespace between pseudo-attributes; the fifth implements the replacement-text well-formedness constraint: when a general entity is referenced, its replacement (character references expanded as at declaration) must reparse as balanced content in isolation, so a bare or incomplete reference, a raw '<' in an embedded attribute value, tags spanning the entity boundary, and a reserved-target PI inside the replacement are rejected at the point of use, while an unreferenced entity may carry any value and only the first declaration of a name binds, both per spec; references in attribute-list defaults must name already-declared internal entities with a non-recursive chain; and a CDATA section inside replacement text now shields its content from reference expansion. The six remaining suite deviations are deliberate: internal-subset conditional/CDATA sections and parameter-entity references are supported as features, and one case expects pre-Fifth-Edition name-character classes), with the exact remaining baseline carried in the test and the two remaining leniency classes (internal-subset DTD grammar, entity replacement-text well-formedness) tracked as #120.
  • XSD Particle Valid (Restriction) (#117): a complex type derived by restriction is now checked at schema compile to accept a structural subset of its base, covering element name matching, element-vs-wildcard admission, wildcard narrowing, occurrence-range subsumption, and the group recursions (sequence/all order-preserving with emptiable skips, choice mapping, sequence-into-choice, sequence-into-all, RecurseAsIfGroup). A structurally unfaithful restriction (widened occurrence, new element name, reordered sequence, content added to an EMPTY or required-into-empty base, an element outside a base wildcard's namespace) throws SchemaError.invalidRestriction with the type, base, and reason. The spec's effective-total-range arithmetic for MapAndSum is approximated by per-particle checks, a documented simplification.
  • Conformance corpus harness (#105, Tier 2): the start of conformance-suite checking, expressed through the validation framework (the OpenAPIKit idiom). A PureXML.Validation.ConformanceCase is a Validatable named expectation (actual vs spec-authoritative expected output), and Conformance.matchesExpected is a Validation<ConformanceCase, Void> rule that emits one located ValidationError per diverging case, so a whole suite reports every failure at once with the case name as its coding path. Seeded with a Canonical XML 1.0 corpus (attribute ordering, empty-element expansion, comment removal, CDATA-to-escaped-text, processing instructions, namespace rendering) an XPath 1.0 core-function corpus, and an XSD datatype/facet corpus (length, range, pattern, enumeration, lexical space), a RELAX NG pattern corpus (text, optional, repetition, choice, ordered group, attribute, interleave, empty), an XSLT 1.0 transformation corpus (value-of, for-each, if, choose, sort, variable, format-number, apply-templates, call-template), an HTML5 tree-construction corpus (void elements, implied tag closing, case normalization, the adoption agency, entities), a Schematron corpus (assert, report, rule context), a C14N exclusive/2.0 corpus (unused-namespace dropping, render-at-use, whitespace trimming), an XPath axes/predicates corpus (descendant, ancestor, sibling axes, positional and attribute predicates, union), a DTD content-model corpus (sequence, choice, occurrence indicators, mixed, EMPTY, ANY), an XInclude/XPointer corpus (substitution, text inclusion with escaping, fallback, relative href resolution; shorthand ids, element() navigation, xpointer() including the per-step predicate semantic), an XSD regex corpus (implicit whole-string anchoring, the character-class escapes, Unicode categories, quantifier ranges, alternation, class negation and subtraction), and an XML Catalog corpus (system/public/uri entries, rewrite prefixes, unmatched identifiers). The HTML corpus surfaced its first divergence (the fragment parser does not reconstruct active formatting elements like the document parser, tracked as #109). Growing the corpora against the official W3C/OASIS suites is the ongoing Tier-2 work.
  • EXSLT extension functions (#105, Tier 3): the common, math, and sets EXSLT modules, dispatched by resolving a function's prefix to its namespace (so any prefix bound to an EXSLT namespace works). math:min, math:max, math:highest, math:lowest, math:abs, math:sqrt (with the EXSLT NaN rule for non-numeric nodes); set:distinct, set:difference, set:intersection, set:has-same-node, set:leading, set:trailing (by node identity and document order); exsl:object-type. math:power and the other transcendentals are omitted because they need a C math library, which the pure-Swift, Foundation-free target excludes; exsl:node-set awaits result-tree-fragment support.
  • Canonical XML 1.1 (#105, Tier 3): a Canonical.Options.canonical11 preset and a mergeInheritedBase option. When canonicalizing a document subset, the xml:base values of the apex's omitted ancestors are merged into the apex by RFC 3986 reference resolution (vs. 1.0's nearest-ancestor rule), the apex's own relative xml:base is resolved against that chain, and xml:id is no longer inherited; xml:lang and xml:space still inherit the nearest. A self-contained RFC 3986 resolver (scheme/authority/path parsing, path merging, dot-segment removal) backs the merge.
  • Single-byte legacy encodings (#97): the byte decoder gains a table-based single-byte path and the full set of single-byte legacy encodings, selected by the XML declaration's encoding name for both the whole-buffer and streaming decoders: the complete ISO-8859 family (parts 2 through 16), Windows-1250/1251/1253/1254/1255/1256/1257/1258, and KOI8-R/U. Most are vendored verbatim from the authoritative unicode.org mapping files; ISO-8859-5/9/15 are derived exactly; Windows-1254 composes from Windows-1252. Multi-byte CJK encodings (#99) are tracked separately.

Fixed

  • XSD identity-constraint XPath compile errors are no longer swallowed (#111): a selector or field XPath that fails to compile is reported once up front as a located error ("identity constraint 'k': invalid field XPath '...'"), instead of try? silently turning a schema author's typo into a disabled constraint. The broken query still evaluates as no-match afterwards, so the rest of the document is validated.
  • XSD type resolution no longer accepts invalid input silently (#110): an xsi:type naming an undeclared type is now a located error ("unknown xsi:type '...'") instead of a silent fallback to the declared type, in both the tree and streaming validators; and typeReference chains are resolved through one shared cycle-detecting resolver, so a circular chain reports "circular type reference '...'" instead of silently truncating after 64 hops (and no longer recurses without bound in the streaming shallow check, a latent crash). Completions use the same resolver, best-effort.
  • The HTML fragment parser (HTML.parse) now reconstructs active formatting elements, matching the document parser and the HTML5 algorithm (#109, surfaced by the Tier-2 conformance corpus). When a formatting element is closed out of order it is re-opened for the following content, so <b><i></b>X</i> yields <b><i></i></b><i>X</i> (not <b><i></i></b>X), in both the fragment and document paths.
  • XSD identity-constraint errors now locate the offending field, not just the element (#104): a single-field key/keyref/unique error extends the coding path with the field itself, an attribute (... at path: list/item[2]/@id) or a child element (.../code), resolved from the constraint's field XPath. keyref scope resolution is confirmed to resolve a key declared on an ancestor element (cross-scope), with a test. Full XSD qualified-node-set conformance for pathological multi-level same-name nesting remains part of the conformance-corpus work.
  • XSD identity-constraint errors (key, keyref, unique) now carry a coding path locating the actual offending element, instead of always rendering "at root of document". The IdentityValidator walk threads a [PathKey] the same way the content-model validator does, and each error is located at the selected target node, so a duplicate-key failure reports "... at path: list/item[2]" and a dangling keyref "... at path: orders/line" rather than an empty path. This brings the identity-constraint rule into line with the validation framework's path-carrying contract, which every other rule already honored. The "no element declaration for the root" XSD error is likewise located at the root element, matching the namespace check beside it.
  • A <template> element's flow content is now kept nested inside it (<template><div>x</div></template>) instead of being split out into the body (#83). template is built as body flow content rather than routed to the head.

Added

  • ISO-2022-JP decoding (#108, in progress): the byte decoder gains the WHATWG iso-2022-jp algorithm, selected by the iso-2022-jp declaration name. Unlike the other CJK encodings this one is stateful, escape sequences switch between ASCII, JIS X 0201 Roman (with the yen sign and overline), JIS X 0201 katakana, and the JIS X 0208 plane, which is resolved through the shared jis0208 table (no new data). The streaming decoder now honors the declared encoding from the XML declaration (the libxml2 behavior), not only a byte-order mark, so the single-byte and multi-byte CJK decoders, including the stateful ISO-2022-JP whose mode is carried across reads, work over an incremental byte source; a test asserts streaming agrees with whole-buffer decode for a single-byte, a CJK, and an ISO-2022-JP document.
  • EUC-TW decoding (#108): the byte decoder gains EUC-TW (CNS 11643), selected by the euc-tw declaration name, for both the whole-buffer and streaming decoders. ASCII passes through; a lead in 0xA1-0xFE opens a two-byte plane-1 character; 0x8E opens a four-byte form whose second byte selects the plane. CNS planes 1 and 2, which carry essentially all real-world EUC-TW text, are vendored from the ICU euc-tw-2014 mapping (private-use fallbacks dropped); the rarely used planes 3-15 decode to the replacement character and are a documented follow-up.
  • Streaming (reader-driven) DTD validation (#107, in progress): PureXML.validate(streaming:dtd:) and PureXML.Validation.StreamingDTDValidator validate a document against a DTD while it is pulled event by event (the libxml2 xmlTextReader validation model), holding only the open-element stack rather than the whole tree. Each element's content model and attributes are checked the moment it closes, by applying the exact per-element DTD rules to a shallow element synthesized from the streamed child names. Document-scoped ID/IDREF integrity is tracked in bounded sets (the identifier values, not the tree): a duplicate ID is reported at its repeat occurrence, and forward IDREFs resolve at the end of the stream. Streaming and tree validation report the same problems (a test asserts matching reasons), and errors carry a libxml2 xmlGetNodePath-style coding path. RELAX NG streaming is also available via RelaxNG.validate(streaming:): the Brzozowski derivative is itself incremental, so it is driven directly from the event stream, retaining only the residual pattern (the after-threaded chain is the validation stack) and a light per-element frame for ignorable-whitespace handling; a differential test asserts it agrees with the tree engine across valid, invalid, whitespace, and mixed-content documents. XSD streaming is available via Schema.Document.validate(streaming:): each element's type is resolved from its parent's content model (and xsi:type / typeReference) as it opens, and its attributes and content-model structure are checked shallowly as it closes (ComplexValidator.validateShallow), with a differential test asserting agreement with the tree validator. Wildcard processContents and substitution-group resolution in the streaming form stay on the tree validator.
  • Output encoding (#106): Serializer.serialize(_:encoding:) and PureXML.serialize(_:encoding:) now emit bytes in a chosen encoding (the libxml2 xmlSaveToFilename(..., encoding) model), not only UTF-8. A new PureXML.Parsing.ByteEncoder covers the Unicode transformation formats, the single-byte legacy families (ISO-8859, Windows code pages, KOI8), and the multi-byte CJK encodings (Shift-JIS, EUC-JP, EUC-KR, GBK, GB18030, Big5). The CJK and single-byte inverses are built by running the existing, tested decoders over every valid byte sequence and recording the shortest sequence per scalar, so each encoder is by construction the exact inverse of its decoder (no new data, no separately maintained encode tables); GB18030's astral range uses the four-byte pointer formula. The declaration carries the encoding's canonical name, a UTF-16/32 stream is preceded by a byte-order mark, and any scalar the target encoding cannot represent is written as a decimal numeric character reference, so output always round-trips.
  • Big5 (#103): the byte decoder gains the WHATWG big5 algorithm, for both the whole-buffer and streaming decoders, selected by the big5 declaration name (and the big5-hkscs / cn-big5 aliases). A lead in 0x81-0xFE followed by a trail in 0x40-0x7E or 0xA1-0xFE resolves through the index, covering Traditional Chinese and the HKSCS extensions including the astral CJK ideographs (Extension B and the Compatibility Supplement). Four pointers decode to a base letter plus a combining mark (a two-scalar sequence). The 18,590-entry index is vendored verbatim from the WHATWG index-big5.txt. Big5 completes the major CJK families libxml2 reaches through iconv (Japanese, Korean, Simplified and Traditional Chinese).
  • Full GB18030 (#102): the byte decoder gains the GB18030 four-byte sequences on top of the GBK two-byte subset, for both the whole-buffer and streaming decoders, selected by the gb18030 declaration name. A lead in 0x81-0xFE followed by a digit opens a four-byte form whose pointer is mapped through the WHATWG index-gb18030-ranges linear ranges, reaching the supplementary planes (the 189000 range maps to U+10000 and beyond). The 207 ranges are vendored verbatim from the WHATWG index-gb18030-ranges.txt. GB18030 is China's mandatory standard, supported in libxml2 through iconv.
  • CJK multi-byte encodings (#99, in progress): the byte decoder gains a multi-byte path and the WHATWG Shift-JIS and EUC-JP algorithms, for both the whole-buffer and streaming decoders, selected by the XML declaration's encoding name. Shift-JIS covers ASCII, half-width katakana, and the JIS X 0208 plane; EUC-JP adds the 0x8E half-width katakana and 0x8F JIS X 0212 leads. EUC-KR (CP949/UHC) decodes Hangul and Hanja through the WHATWG index-euc-kr, and GBK (which also covers GB2312) decodes Chinese through the two-byte range of index-gb18030 with 0x80 as the euro sign. The JIS X 0208/0212, CP949, and GBK indexes are all vendored verbatim from the corresponding WHATWG index-*.txt files. This completes the CJK multi-byte encodings.
  • HTML named character references: the complete WHATWG named character reference set (#84), all 2,125 semicolon-form references including the astral mathematical alphanumerics and the 93 two-codepoint references, vendored verbatim from the authoritative entities.json (replacing the previous 16-entry stopgap). Decoding stays longest-match with an optional trailing semicolon, on top of the existing numeric-reference validation.
  • HTML5 select content model (#83, stage 10): a <select> now follows the in-select rules. <optgroup> closes an open <option>, a nested <select> closes the open one rather than nesting, and an <input>/<keygen>/<textarea> closes the select and is placed after it. In the "in select in table" case, a table-structural tag (caption/table/tbody/tfoot/thead/tr/td/th) closes a select that is inside a table before being processed.
  • HTML5 frameset documents (#83, stage 8): a <frameset> after the head now produces a frameset document whose frameset element replaces the body (<html><head></head><frameset>...), with nested framesets and <noframes> handled, and <frame> is now a void element (it no longer wrongly nests). An ordinary document still produces a body.
  • HTML5 integration points (#83, stage 7): HTML content inside an SVG integration point (foreignObject, desc, title) is now parsed in the HTML namespace rather than SVG, so <svg><foreignObject><div> puts the div (and its descendants) back in HTML; re-entering <svg> inside an integration point switches back to the SVG namespace.
  • HTML5 table foster-parenting (#83, stage 6): stray flow content inside a table is now moved out rather than nested in the table structure, matching the HTML standard. A non-table element or character data appearing while a table/tbody/thead/tfoot/tr is the open node is inserted immediately before the table (<table><b>x</table> -> <b>x</b><table></table>), while well-formed cell content is untouched.
  • HTML5 SVG attribute-name case adjustment (#83, stage 5): an SVG attribute written in mixed case is restored to its canonical camel case in the tree (viewbox -> viewBox, gradienttransform -> gradientTransform, and the rest), applying only to elements in the SVG namespace. HTML attribute names are left as the tokenizer lowercased them. With this, SVG/MathML foreign content carries correct element and attribute names.
  • HTML5 adoption agency algorithm and active formatting elements (#83, stage 4): misnested formatting tags are now recovered exactly as the HTML standard prescribes. Overlapping tags nest (<b><i></b></i> -> <b><i></i></b>), content after an out-of-order close is re-wrapped by reconstructing the active formatting elements (<b><i></b>X</i> -> <b><i></i></b><i>X</i>), and a block inside a formatting element reparents through the furthest-block path, including the canonical <p>1<b>2<i>3</b>4</i>5</p> -> <p>1<b>2<i>3</i></b><i>4</i>5</p>.
  • HTML5 SVG element-name case adjustment (#83, stage 3): an SVG element written in mixed case (which the tokenizer lowercases) is restored to its canonical camel case in the tree, so <foreignObject>, <linearGradient>, <clipPath>, and the rest carry their correct SVG names. Applies only in the SVG namespace; same-named HTML elements keep their lowercased name.
  • HTML5 foreign content (#83, stage 2): an <svg> or <math> element and all of its descendants now carry the SVG (http://www.w3.org/2000/svg) or MathML (http://www.w3.org/1998/Math/MathML) namespace on their QualifiedName, so foreign elements are distinguishable from same-named HTML; ordinary HTML content (including content after a closed <svg>) stays in no namespace. (SVG/MathML element- and attribute-name case adjustment, and HTML integration points, are a later refinement.)
  • HTML5 table tree construction (#83, stage 1): a <table> written without its section and row wrappers now nests correctly. A <tr> directly inside a <table> gets an implied <tbody>, a <td>/<th> gets an implied <tr> (and section), consecutive rows share one implied <tbody>, and an explicit <thead>/<tbody>/<tfoot> is not duplicated, all inside an open table only.
  • HTML tokenizer correctness (#84, partial): numeric character references are now range/surrogate-validated (zero, surrogates, and values above U+10FFFF yield U+FFFD) with the Windows-1252 C1 fixup table (&#x80; -> euro, etc.); named references are decoded by longest match with or without a trailing semicolon; a literal NUL byte becomes U+FFFD; RCDATA elements (title, textarea) decode character references while raw-text elements (script, style) take their content verbatim; and a single leading newline after <textarea> is stripped. (The full ~2,000-entry named-entity table remains deferred to vendoring from the authoritative WHATWG data, the same policy as the legacy encodings.)
  • HTML conformance validator in the composable Validation idiom. PureXML.HTML.validationErrors(in:) (backed by PureXML.Validation.HTML) judges a parsed tree against the intrinsic HTML5 content-model invariants as named, removable, isolation-testable Validation rules, the same idiom as the DTD/XSD/structural validators: voidElementsAreEmpty (void elements carry no content), requiredParent (li/td/option/... appear inside an allowed parent, with the allowed parents named in the finding), and uniqueIdentifiers (id values are unique). Failures are located ValidationErrors; this is the validation layer HTML previously lacked, so every parsing/schema subsystem now exposes its conformance checks as composable rules.
  • XInclude range inclusion (#87), completing the XInclude audit. An xi:include whose xpointer selects a range (xpointer(range(...)), range-to, or string-range) now includes that range's content: when no node-selecting scheme matches, the processor falls back to the XPointer range model and substitutes each range's spanned nodes (or the matched text for a string-range).
  • XPointer range() model (#88), completing the XPath/XPointer audit. PureXML.XPointer.evaluateRanges(_:over:) (and Pointer.evaluateRanges(over:)) evaluate the range forms inside an xpointer()/xpath1() scheme: range(expr) (the covering range of each location), start/range-to(end) (one range spanning from the first location of start to the first of end, the sibling run when they share a parent), and string-range(location, "search"[, offset[, length]]) (a character range per occurrence, with the XPointer offset/length adjustment). Each Range materializes to the whole nodes it spans and the text it covers, and xmlns() bindings apply to the range parts that follow, so this also unblocks XInclude range inclusion (#87).
  • C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. Canonicalizer.canonicalize(_ subtree: TreeNode) canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their xml:base/xml:lang/xml:space when it does not set them, so a signed fragment is independent of where it sat. canonicalize(_:including:) canonicalizes a node-set selected by a TreeNode predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. Options.prefixRewrite = .sequential rewrites every prefix to a canonical n0/n1/... in document order (eliminating the default namespace and placing declarations at first use), and Options.qnameAwareLabels extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes.
  • Serialization textEscaping round-trip option (#91), completing the serialization audit. Options.textEscaping defaults to .standard (escape only &, <, >); .roundTrip also escapes a carriage return as &#xD; in text and CDATA-as-text output, so a literal \r survives the parser's end-of-line normalization and the document round-trips byte for byte. (The CDATA-section vs escaped-text toggle, cdataAsText, was already present.)
  • DOM tree: first-class doctype, entity-reference, and namespace nodes, plus ownerDocument, adopt, and importNode (#90), completing the tree-model audit. TreeNodeKind gains .doctype, .entityReference, and .namespace, each with a factory (TreeNode.doctype(name:publicID:systemID:internalSubset:), .entityReference(_:children:), .namespace(prefix:uri:)) and typed accessors; doctype and namespace nodes drop out of the content value-Node projection while an entity reference splices its replacement into place. ownerDocument derives the owning document from the parent chain; adopt and importNode move or copy a subtree into another document, re-declaring every namespace it relied on from an outer scope so the result is self-contained.
  • Schematron key(), document(), and current() in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through xsl:key declarations, load an external document for cross-document checks (via an injected documentLoader), and refer to the rule context node with current().
  • RELAX NG compact >> follow-annotations (#79), completing the RNC syntax. A >> annotation after a pattern (a foreign element name with optional [ … ] content) is skipped, chaining and combining with leading [ … ] annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
  • XSLT document() fragments and base-URI resolution (#82), completing the XSLT function/output audit. A #fragment selects a subset of the loaded document via XPointer, a node-set first argument unions the documents named by each node, and a relative URI is resolved against a caller-supplied baseURI (also applied to xsl:include/xsl:import).
  • XSLT result-tree fragments usable as node-sets (#82). An xsl:variable with a body now binds a queryable document fragment instead of a plain string, so count($rtf/*), $rtf/child, and xsl:for-each select="$rtf/..." work; it still has its concatenated text as a string value.
  • XSLT xsl:output cdata-section-elements (#82). The text content of the named result elements is emitted in <![CDATA[…]]> sections instead of escaped, leaving nested elements and other elements unaffected.
  • XSLT xsl:output doctype-public/doctype-system (#82). When either is set, a <!DOCTYPE> for the result's root element (PUBLIC when both are given, SYSTEM otherwise) is emitted before the serialized output, for XML and HTML methods.
  • XSLT key() over a node-set second argument (#82). key(name, node-set) (including key('k', .)) now unions the matches for every node's string value and de-duplicates, rather than using only the first node's string value.
  • XSLT method="html" output (#82). An xsl:output method="html" result is now serialized by the HTML rules (void elements like <br> emitted without a self-closing slash, raw-text elements like <script>/<style> left unescaped) instead of as XML.
  • XSLT xsl:sort case-order (#82). When case-order="upper-first"/"lower-first" is set, the sort comparison is case-insensitive with the case used only to break ties among otherwise-equal strings; without it the default codepoint order is kept.
  • XSLT functions generate-id, system-property, element-available, function-available, and an unparsed-entity-uri stub (#82). generate-id returns a stable per-node identifier (the context node when no argument is given, the empty string for an empty node-set); system-property reports the XSLT version (1.0) and vendor; the *-available functions answer against the set of implemented instructions and functions.
  • XSLT xsl:namespace-alias (#81), completing the XSLT top-level-element audit. A literal result element (and its attributes) in a stylesheet namespace bound by stylesheet-prefix is rewritten on output to the namespace and prefix bound by result-prefix (#default supported), so a stylesheet can emit literal xsl: elements. Folded across xsl:include/xsl:import.
  • XSLT xsl:decimal-format (#81). format-number now honors named and default decimal formats: the decimal separator, grouping separator, percent, minus sign, zero-digit (so non-Latin digit sets render), NaN, and infinity symbols are taken from the chosen xsl:decimal-format, both in the picture and the output. Folded across xsl:include/xsl:import.
  • XSLT xsl:fallback (#81). An unrecognized XSLT element instantiates its xsl:fallback children in its place (forwards-compatible processing), or is dropped when it has none; a fallback under a supported instruction is ignored.
  • XSLT xsl:apply-imports (#81). Re-applies templates to the current node in the current mode, considering only templates of lower import precedence than the one being instantiated (so an overriding template can extend the imported one), falling back to the built-in rule when none match.
  • XSLT xsl:attribute-set (#81). Named attribute sets are applied to a literal result element or xsl:element via use-attribute-sets; sets may include other sets (recursively, with a cycle guard), set attributes are lowest precedence so the element's own attributes override them, and same-named attributes collapse to the last. Folded across xsl:include/xsl:import.
  • XSLT xsl:message (#81). The message body is instantiated as diagnostic text; terminate="yes" aborts the transformation with that text as an XSLTError.terminated, and processing stops at that point. A non-terminating message produces no result-tree output.
  • XSLT xsl:strip-space / xsl:preserve-space (#81). Whitespace-only text nodes are removed from the source tree for elements named by strip-space before transformation, with preserve-space and a more specific name test winning over *, and xml:space="preserve" on a source element or ancestor overriding the stripping. Folded across xsl:include/xsl:import.
  • DTD located content-model errors. An element whose children break its content model now names each stray child individually (element <x> is not allowed in <r>), and reports a pure order/count violation once with the allowed elements as a hint (… do not match its content model; allowed: <a>, <b>), instead of a single opaque message, matching the editor-grade diagnostics of the other validators.
  • XSD located content-model errors. A content-model violation is now placed at the offending child with a recovery hint naming what was expected there (element 'x' is not allowed here; expected <b>, content is incomplete; expected <b>), instead of one opaque "content does not match the content model" per element. An xs:all group recovers past each stray child and reports every missing required member, and well-placed children are still validated for their own content, so an editor sees every problem at once. This brings XSD content validation to the same located, recovering standard as the RELAX NG, DTD, and Schematron validators.
  • RELAX NG located, recovering validation errors (#79). Alongside the boolean validate(_:), RelaxNG.errors(in:) now reports every way a document fails the schema as individual located errors (each with a coding path), recovering past each failure so an editor can surface all of a faulty document's problems at once rather than only the first. Errors carry recovery hints (expected <a>, <b>) naming the element types accepted at the failure point, and distinguish unexpected elements, invalid or missing attributes, invalid text/datatype content, and missing required content. A datatype mismatch quotes the offending value and names the expected type ('abc' is not a valid integer), and a value mismatch quotes both the actual text and the required literal. An attribute failure distinguishes an undeclared attribute from a declared one with a bad value, quoting the value and naming its type. RelaxNG.validation() exposes the schema as a Validation<Node, Void>, so RELAX NG composes with the same validation framework as the XSD, DTD, and Schematron rules. The boolean engine remains the authority on validity; the walk only places and explains the failures.
  • XML catalog group-level prefer (#86). A prefer attribute on a group (or a nested catalog) now overrides the catalog-wide preference for the public entries inside it, inherited through nested groups. Resolution of an external identifier consults each public entry's own effective preference, so two entries in one catalog can prefer differently. A system match still always wins.
  • Schematron abstract patterns (#80). A <pattern abstract="true"> is a template whose rule queries carry $name references; a <pattern is-a="…"> with <param name= value=> children instantiates it, substituting each parameter into the template's context, test, <let> value, and <value-of select=> in a single pass that never rescans a substituted value or lets a short name capture a longer one. The template itself contributes no rules, and $name references that are not parameters (ordinary <let> variables) are preserved.
  • RELAX NG conformance: datatypeLibrary, value-space <value>, name-class subtraction, and compact-syntax breadth (#79). The in-scope datatypeLibrary is now inherited from the nearest ancestor; the default library defines only string and token, the W3C XML Schema library defines the full built-in set, and a type no in-scope library defines is an unknown datatype, so its <data> or <value> matches nothing rather than validating silently. A <value> carries its datatype and compares in that type's value space, so 1 equals 01 and 1.5 equals 1.50, booleans treat 1/true and 0/false as equal, and string/token compare by their whitespace-normalized form. A new nsNameExcept name class backs <nsName> with <except> and the compact prefix:* - name subtraction. The compact parser also skips [ … ] annotations, treats div { … } as transparent grouping, and folds ~ string concatenation.
  • XSLT template parameters and modes (toward full parity, #61). xsl:param declarations (with defaults), xsl:with-param on apply-templates and call-template, and mode on templates and apply-templates are now honored: a passed parameter overrides the declared default, and a mode routes nodes only to templates declared in that mode (the built-in rules recurse in the same mode).
  • Push / feed streaming API (#1), the last epic. A resumable scanner reports a token only once its terminator is fully buffered and otherwise signals need-more (the Expat XML_TOK_PARTIAL model), so PureXML.Parsing.PushParser parses input fed in arbitrary chunks with feed(_:)/finish(), driving a SAXHandler, while retaining only the current incomplete token plus the open-element stack. PureXML.events(feeding:) exposes an AsyncThrowingStream of events over any async sequence of text chunks. Splitting a document at any byte boundary, down to one character per feed, yields identical events.
  • HTML parser and serializer (#20, the libxml2 HTMLparser.h/HTMLtree.h model). PureXML.HTML.parse reads tag-soup HTML leniently (case-insensitive tags; quoted, unquoted, and boolean attributes; comments; doctype; character references) and builds a node tree handling void elements (no end tag), optional end tags (implicit close of li, p, table rows and cells, and the like), raw-text elements (script, style), and unmatched end tags. PureXML.HTML.serialize writes it back with void elements unclosed and raw-text content unescaped.
  • XSLT 1.0 transformation engine, completing #3. PureXML.XSLT.transform applies a stylesheet to a source document by the XSLT processing model: it matches each node to the highest-priority template (or the built-in rules) and instantiates the template's sequence constructor. Implements apply-templates (with template dispatch and sort), value-of, for-each, if, choose, element, attribute, copy, copy-of, call-template, variable, literal result elements, and attribute value templates, all over the existing XPath engine. The identity transform round-trips a document including its attributes.
  • XSLT 1.0 stylesheet model and parser (toward #3). PureXML.XSLT gains a stylesheet model (template rules with match patterns and computed default priorities, the instruction sequence constructor, attribute value templates) and a parser that compiles an xsl:stylesheet document into it. Recognizes the XSLT vocabulary by namespace or xsl prefix; other elements are literal result elements. Supports template, apply-templates, value-of, for-each, if, choose, element, attribute, copy, copy-of, call-template, variable, text, and sort.
  • Schema conformance fixtures, completing XSD and RELAX NG validation (#2). A cross-cutting suite validates realistic schemas (a patterned, bounded, ID-keyed XSD purchase order and a RELAX NG contact card using interleave, attribute value choices, and optional/repeatable elements) against conforming and non-conforming instances, plus datatype boundary fixtures.
  • RELAX NG validation by derivatives (toward #2). PureXML.Schema.RelaxNG parses a RELAX NG schema in the XML syntax into a pattern grammar (element, attribute, text, empty, notAllowed, group, choice, interleave, optional, zeroOrMore, oneOrMore, mixed, list, ref/define, data, value, and the name classes), and validate(_:) checks an instance document by James Clark's derivative algorithm: the pattern is derived through each start-tag, attribute, text, and end-tag event, and the document is valid when the residual pattern is nullable. Handles interleave, recursive grammars, and reuses the XSD datatype library for data/value.
  • XSD schema-document parser, completing the XSD validation pipeline (toward #2). PureXML.Schema.Document(xsd) compiles a schema document into its global element declarations and named-type table, and validate(_:) checks an instance document end to end. The parser matches the schema vocabulary by local name and supports global and local element declarations, named and inline simple and complex types, sequence/choice/all model groups with occurrence, attribute uses, simple content (restriction/extension), the full facet set, and recursive types (via named type references resolved during validation).
  • XSD complex types and particles (toward #2 schema validation). PureXML.Schema.ComplexValidator validates an element against a ComplexType: its attribute uses (required and typed, with unknown-attribute rejection), and its content model. Content is empty, simpleContent (text validated against a simple type), elementOnly, or mixed. Particles compile to a Thompson NFA over element names honoring minOccurs/maxOccurs, with sequence/choice compositors; all groups are validated order-independently by counting. Child elements are validated recursively against their declared types (the XSD Element-Declarations-Consistent rule makes the name-to-type map well defined).
  • XSD simple-type datatype library (toward #2 schema validation). The new PureXML.Schema namespace validates a lexical value against the XSD Part 2 built-in datatypes (string, boolean, decimal, the bounded integer family, float/double, duration, the eight date/time types, hexBinary, base64Binary, anyURI, QName, and the Name/NCName/NMTOKEN/language family) and the constraining facets (length/minLength/maxLength, pattern via the regex engine, enumeration, whiteSpace, min/maxInclusive/Exclusive, totalDigits, fractionDigits). Decimals are compared exactly with no floating-point loss, date/time fields are range-validated (leap years, 24:00:00, timezone bounds), and the integer types enforce their intrinsic bounds.
  • Regular-expression engine (#30, the XML Schema regex flavor, gating XSD). The new PureXML.Regex namespace compiles a pattern (literals, ., character classes with ranges and negation, the escapes \d \D \w \W \s \S \i \I \c \C, single-character escapes, grouping, alternation, and the quantifiers ?, *, +, {n}, {n,}, {n,m}) to a Thompson NFA and matches the whole string, as xs:pattern facets require. Matching is linear in the input with no backtracking blow-up. \p{...} Unicode category escapes are not yet supported.
  • Canonical XML (#26, the libxml2 c14n.h model). The new PureXML.Canonical namespace renders the C14N form of a node: namespace declarations sorted by prefix (default first) and attributes by namespace URI then local name, empty elements expanded to start/end pairs, CDATA folded into escaped text, and the C14N text/attribute escaping rules. Inclusive mode renders every in-scope namespace; exclusive mode renders only the namespaces an element and its attributes visibly use (with an InclusiveNamespaces prefix list). Comments are omitted unless requested. For whole-tree canonicalization C14N 1.0 and 1.1 coincide, so the single inclusive mode covers both.
  • XInclude, URI resolution, and xml:base (#24, the libxml2 xinclude.h/uri.h model). The new PureXML.XInclude namespace adds RFC 3986 reference resolution (URIReference.resolve(_:against:), with dot-segment removal) and process(_:base:loadingURI:), which replaces xi:include elements with their targets. hrefs resolve against the supplied base and the in-scope xml:base, parse="xml" includes the document element (or an xpointer() fragment via PureXML.XPointer), parse="text" includes raw text, and a failed load uses xi:fallback or errors. Includes nest. PureXML does no I/O itself: fetching is only through the injected loadingURI closure, so the default fetches nothing.
  • XML Catalog resolution (#27, the libxml2 catalog.h model). The new PureXML.Catalog namespace parses an OASIS XML catalog (matching public, system, uri, rewriteSystem, rewriteURI, and group/catalog by local name) into a Resolver that maps public/system identifiers and URI names to replacement URIs, with longest-prefix rewriting and system-over-public precedence. Resolver.entityResolver(loadingURI:) builds a Parsing.EntityResolver that resolves an external identifier through the catalog and then loads the URI via an injected closure, so a catalog plus a loader wires external entities and DTDs in while the default (no loader) keeps XXE closed.
  • Schematron validation (#25, the libxml2 schematron.h model). PureXML.Validation.Schematron(schema:) compiles a Schematron schema (namespace-agnostic over the ISO and legacy namespaces) and validate(_:) checks a document: each <rule>'s context XPath selects the nodes it fires on, and each <assert>/<report> test is evaluated relative to that node, a failed assert becoming an error issue and a matched report a warning. Within a pattern a node fires the first matching rule. Built on the XPath context-node evaluation, with a new XPath.Query.nodes(over:) that returns matched tree nodes for repeated querying of one tree.
  • XPointer (#23, the libxml2 xpointer.h schemes). The new PureXML.XPointer namespace resolves the shorthand bare-name form (name = id('name')), the element() scheme (child-element positions from an id or the document root, e.g. element(intro/2), element(/1/2)), and the xpointer() scheme (a full XPath expression). A pointer may chain scheme parts; they are tried in order and the first non-empty selection wins (the fallback rule). Built on PureXML.XPath.
  • Streaming pattern matching (#22, the libxml2 pattern.h model). The new PureXML.Pattern namespace compiles the streamable XPath subset (element names, *, prefix:*, /, //, a leading absolute /, and a trailing attribute step) into a Matcher, rejecting predicates and ./.. as outside the subset. PureXML.Pattern.matches(_:in:) streams a document through the pull parser and returns the paths of matching nodes (/a/b/c, /a/@id) in document order, deciding each match from the open-element stack alone, so no tree is built. Matcher.matchesElement(path:) reuses a compiled pattern.
  • The XPath evaluation context, completing full XPath 1.0 (#21). Query.value(at:position:size:variables:) evaluates an expression against an explicit context node already in a tree, with its proximity position, size, and variable bindings, so position(), last(), and the upward axes resolve from that node. This is the per-node entry point downstream engines (XSLT, Schematron) drive.
  • The full XPath 1.0 function library (toward #21 full XPath 1.0). Beyond the core functions, the string family (concat, starts-with, contains, substring-before, substring-after, substring, string-length, normalize-space, translate), the number family (sum, floor, ceiling, round), and the node family (local-name, name, namespace-uri, id, lang). All pure Swift with no Foundation. Without a DTD, id() matches by an attribute named id or xml:id.
  • The XPath expression engine and four-type model (toward #21 full XPath 1.0). PureXML.XPath.Query now compiles and evaluates full expressions, not just location paths: the operator grammar (or, and, =, !=, <, <=, >, >=, +, -, *, div, mod, unary -, |) with correct precedence, number and string literals, function calls, variable references, and filter expressions, all over PureXML.XPath.Value (node-set, boolean, number, string) with the spec's coercion rules and canonical number formatting. Predicates are now arbitrary expressions (a numeric predicate is a position test). Query gains value(over:variables:), number, string, and boolean accessors, and a binding map for $variables. The core functions last, position, count, not, true, false, boolean, number, and string ship with it.
  • All thirteen XPath axes (toward #21 full XPath 1.0). PureXML.XPath.Query now navigates child, descendant, parent, ancestor, following-sibling, preceding-sibling, following, preceding, attribute, namespace, self, descendant-or-self, and ancestor-or-self, with the ./../@/// abbreviations and the processing-instruction() node test. Evaluation runs over the parent-aware tree, so upward and sibling navigation are first-class; results come back de-duplicated and in document order regardless of axis direction. The attribute axis excludes xmlns declarations and the namespace axis surfaces in-scope namespace nodes.
  • Pull-cursor reader (the libxml2 xmlTextReader model). PureXML.Parsing.TextReader (via PureXML.reader(_:)) walks a document one node at a time with read(), exposing nodeKind, name, value, depth, attributes/attributeCount/attribute(_:), and isEmptyElement at each step. It layers on the streaming core, so it never holds the whole document. A childless element is reported once as an empty element (no separate end node), normalizing <a/> and <a></a>. documentType surfaces the DTD read so far as the validation hook.
  • Mutable, parent-aware document tree (the libxml2 tree.h model). PureXML.Model.TreeNode is a reference type that knows its parent and siblings, so a document can be navigated upward and sideways (parent, nextSibling, previousSibling, ancestors, root, elementChildren, stringValue) and edited in place (append, insert(before:), insert(after:), removeFromParent, replace(with:), copy). Children are held strongly and the parent weakly; attaching a node detaches it from any previous parent, and a node can never become its own ancestor. parseTree(_:) builds one from XML and TreeNode.node converts back to the value tree for serialization.
  • External and parameter entities (the libxml2 entities.h model), secure by default. Internal parameter entities (<!ENTITY % name "value">) are stored and expanded within the DTD, including bare %name; references that inject markup declarations, bounded by depth and the expansion budget. External general entities and the external DTD subset are recorded but never fetched by PureXML itself: they are loaded only through an injected PureXML.Parsing.EntityResolver (a struct of closures). The default resolver refuses every external reference, so XXE stays closed; an external entity then fails as undefined and the external subset is left unread. Internal declarations win over the external subset.
  • Serialization option parity for the XML declaration (the libxml2 save-option model). PureXML.Emitting.Options gains includeXMLDeclaration, xmlVersion, encodingName, and standalone; when requested, both the tree serializer and the incremental writer prepend an <?xml ...?> declaration from a single shared computation. Writer.writeStartDocument() emits it. Defaults stay off so fragment output is unchanged.
  • Spec-exact XML character classification (PureXML.Parsing.XMLCharacter, the libxml2 chvalid.h parity): the XML 1.0 (Fifth Edition) Char, NameStartChar, and NameChar productions as public predicates over Unicode scalars, plus isValidName(_:). The scanner's name recognition now uses these exact ranges (replacing a loose isLetter check) from a single source of truth, and handles both precomposed and combining-mark names.
  • Full encoding support. Byte input now decodes UTF-32 (both byte orders, BOM or sniffed) and the single-byte encodings ISO-8859-1 and Windows-1252 selected by the XML declaration's encoding name, in addition to UTF-8 and UTF-16. The streaming byte path also decodes UTF-32. Standard library only, no Foundation.
  • Incremental XML writer (PureXML.Emitting.Writer, the libxml2 xmlTextWriter model): emit a document with start/end and write calls without building a tree. Escaping matches the tree serializer (extracted into a shared Escaping helper), so compact output is byte-identical to serializing the equivalent tree.
  • SAX2 push-callback parsing (the libxml2 SAX2 model). PureXML.Parsing.SAXHandler is a struct of optional callbacks (start/end document, start/end element, characters, CDATA, comment, processing instruction); PureXML.parse(_:sax:) drives them from the streaming core. Element names are namespace-resolved, so the URI is available on each callback.
  • DTD ID/IDREF validation. Attributes typed ID must be unique across the document; IDREF and IDREFS values must each resolve to a declared ID (forward references included, via a second pass). Completes the structural DTD validation layer alongside content models and attribute rules.
  • DTD attribute validation (<!ATTLIST>). The parser surfaces attribute declarations alongside element models, and DTDSchema now checks #REQUIRED attributes are present, #FIXED attributes match when present, and enumerated attributes take a declared value. Tokenized types (ID/IDREF/NMTOKEN) are parsed but their value rules (uniqueness, references) are not yet enforced.
  • DTD content-model validation. The parser now surfaces the <!DOCTYPE> internal subset's <!ELEMENT> declarations (PureXML.Parsing.DocumentType, via Parser.parseWithDocumentType), and PureXML.Validation.DTDSchema validates a tree against them: EMPTY, ANY, (#PCDATA), mixed content, and element content models (sequence/choice with ?/*/+, matched as a regular language over child element names). PureXML.validateAgainstInternalDTD(_:) parses and validates in one call. <!ATTLIST> validation is not yet covered.
  • XPath query support (a practical subset). PureXML.XPath.Query compiles a location path and evaluates it over a node; PureXML.xpath(_:over:) is the one-shot convenience. Supports the forward axes (child, descendant //, self ., attribute @), the node tests (name, *, text(), node(), comment()), and the predicates [n], [@a], [@a='v'], [child], [child='v']. Returns a Selection node-set with element and stringValue accessors. Upward/sibling axes and the full expression language are out of scope (the model has no parent pointers).
  • Optional DTD with internal general entities, off by default. With Limits(allowDoctype: true) the parser reads a <!DOCTYPE> internal subset, honors internal <!ENTITY name "value"> declarations, and expands them in text and attribute values. Defenses are mandatory: expansion is bounded by Limits.maxEntityExpansion (billion-laughs amplification raises ParseError.amplificationLimitExceeded), recursive entities raise ParseError.recursiveEntity, undeclared entities raise ParseError.undefinedEntity, and external entities are never loaded (XXE stays closed). The default still rejects <!DOCTYPE> outright.
  • Streaming byte decode. PureXML.parse(pullingBytes:) and events(pullingBytes:) accept an incremental byte source (() -> UInt8?) and decode UTF-8 or UTF-16 on the fly, so the bytes are never fully buffered. Encoding is detected from the leading bytes; invalid sequences become the Unicode replacement character rather than failing the stream.
  • Namespace resolution. Qualified names now carry an optional resolved namespaceURI, populated by the parser from in-scope xmlns declarations at the start-element boundary (the libxml2 SAX2 model). A default namespace applies to unprefixed element names but not attributes, the xml prefix is built in, xmlns declarations are preserved as attributes for round-trip, and an unbound prefix raises ParseError.undefinedNamespacePrefix. Namespace-free documents are unchanged (URIs stay nil).
  • Byte input with encoding detection. PureXML.parse(bytes:) and PureXML.events(bytes:) accept raw [UInt8] and detect the encoding (UTF-8 or UTF-16, with or without a byte-order mark) following the XML sniff order, then decode with the Swift standard library (no Foundation). PureXML.Parsing. InputEncoding.detect(_:) exposes the detector. Malformed input (such as an odd-length UTF-16 stream) raises ParseError.malformedEncoding.
  • Configurable, bounded-by-default parser limits (PureXML.Parsing.Limits): maximum nesting depth (default 256), name length, and content length, enforced during scanning. Protects against pathological input and keeps the recursive node model safe to build and tear down. Threaded through parse/events.
  • Initial package scaffold: root Swift package, namespacing under PureXML, and the portability and verification gates carried over from PureYAML.
  • XML node model (PureXML.Model): Node, Element, Attribute, and QualifiedName, preserving document order and node kinds.
  • XML emitter (PureXML.Emitting.Serializer) with pretty-printed and compact output and correct text and attribute escaping.
  • Structural validation (PureXML.Validation.Validator) for duplicate attribute names.
  • Streaming, iterative XML parser. PureXML.Parsing.EventReader is a pull-based event core that consumes input through a character-source closure and emits one Event at a time, holding only bounded state (an open-element stack and a small lookahead buffer). It never requires the whole document in memory and can drive chunked input. PureXML.Parsing.Parser builds a Model.Node tree iteratively over the event core (no recursion). Handles elements, attributes, text, the five predefined entities and numeric character references, comments, CDATA, and processing instructions. Safe by default: <!DOCTYPE> is rejected (no DTD/XXE).
  • Top-level API: PureXML.parse(_:), PureXML.parse(pulling:), and PureXML.events(_:) / PureXML.events(pulling:) for streaming.

Changed

  • The emitter is now iterative (explicit work stack, not recursion), mirroring libxml2's approach, so deep trees do not overflow the call stack.
  • The emitter escapes tab/newline/CR (and >) in attribute values so attributes round-trip through spec-compliant parsers; mixed content is no longer reformatted (formatting is suppressed for elements with text/CDATA children).