An element restricting a base choice was always checked by the
effective-total-range approximation, which exists for one reason: the
literal section 3.9.6 pairing has no allowance for occurrence counts
accumulated across the choice's own repetitions and over-rejects
W3C-read-valid schemas (particlesZ001, whose manifest calls the rule
ambiguous). That ambiguity is impossible when the choice cannot repeat:
at {max occurs} = 1 every accepted child comes from one traversal of one
branch, so the literal rule governs. elementRestrictsChoice now applies
the literal pairing (the derived element, with its own occurrence range,
must validly restrict some non-pointless branch) when base maxOccurs is
1, and keeps the effective-range reading only for repeating choices.
Closes XSTS particlesHa161 (a derived a{0,1} is not a pointwise
NameAndTypeOK restriction of a required branch a{1,1}) and particlesM034
(a derived d1+ cannot map into the branch (d1*, d2+): the required d2 is
unmapped and not emptiable). The settled-valid neighbors that pin the
boundary from the other side all still pass: Ha160, M002, M003, M035,
and the Z001 shape, each now a regression test in
XSDParticleChoiceRestrictionTests. invalid-schemas-accepted 6 -> 4.
particlesIg004 is reclassified as spec-divergent (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; it is jointly unsatisfiable with the settled Ig001/002/003 (equal
sets, valid) and Ig005 (#all covers the triple, valid).
The remaining three schema deviations are documented divergences, not
open bugs: Ik025/Ik027 (cos-st-derived-ok 2.2.4 reading) and schN12,
newly adjudicated in docs/xsts-deviations.md: its chameleon redefine
fails src-redefine 6.2.2 on NameAndTypeOK clause 1 (no-namespace locals
vs qualified locals), but 6.2.2 cannot be enforced uniformly because
mgO013's settled-VALID schema also violates it; any uniform enforcement
trades a false negative for a valid-schema-rejected false positive.
Also corrects the stale deviations-doc row that listed the already-closed
particlesIk011 and omitted schN12; the failures file is the authority.
Gates: full XSTS release suite green at 0/3/0/4 (valid buckets unchanged
at zero), libxml2 differential green at 244 pinned entries (Ha161 and
M034 newly pinned, both compile under libxml2), full local gate green.
NameAndTypeOK clause 1 (XSD 1.0 section 3.9.6, Elt:Elt) pairs the declared
element declarations and requires the same {name} and {target namespace};
no clause grants substitution an allowance. The substitution-group
expansion rewrites the compiled term (an abstract head is dropped, members
join a synthetic choice), so the checker previously compared the expanded
names and a head-for-member swap looked name-identical.
Particle.declaredReference now preserves the pre-expansion identity of an
element-ref particle, and the restriction check pairs the declared
references before the structural pairing table (extracted as
structuralValid). Closes XSTS elemZ026 and particlesV020:
invalid-schemas-accepted 8 -> 6, valid buckets unchanged at zero.
Stricter than libxml2, which does not implement this edge; elemZ026 is
pinned in the differential baseline accordingly.
particlesZ008, byte-for-byte the same construct as elemZ026 but carrying
a settled valid expectation contradicting elemZ026's settled invalid, is
reclassified as a spec-divergent schema test with the adjudication
recorded in the suite (exclusion tables split into
Tests/XSTSSuiteExclusions.swift for file length).
Separately attributed: the differential baseline gains ten further pinned
entries (addB070a, addB108, addB177, attO025, elemZ027_c, errC008,
groupH021v, notatB005, notatE002, particlesEb040) that reproduce with
this change stashed. They are pre-existing drift from earlier main-line
fixes, not effects of this commit. Baseline 231 -> 242, differential
green.
The earlier release/0.4.8 branch stamped the changelog but the tag was
never cut, and main moved 60 commits past it. Fold the accumulated
unreleased entries into the still-unpublished 0.4.8 section, redate it,
repoint every remaining github.com/mihaelamj reference (badges, support
links, roadmap, the XSLT vendor-url system property), and track the
shared-rules baseline v1.1.2.
The rules-swift concurrency rule's acceptance check requires every `@unchecked
Sendable` and `nonisolated(unsafe)` to carry an adjacent comment explaining why
the race is acceptable; an unjustified one is a violation. An audit found six
escape hatches whose comment explained the type but not the race:
- The four XPath `FunctionTable` statics (`CoreFunctions`, `StringFunctions`,
`NodeFunctions`, `Evaluator.library`): each is an immutable, build-once lookup
table never mutated after initialization, so concurrent reads race on nothing;
`FunctionTable` is un-`Sendable` only because it stores closures.
- `Validation.Validator`: immutable after `init` (every stored validation list is
a `let`); the erased `AnyValidation` closures are simply not provably `Sendable`.
- The test `ResultBox`: written once by the worker thread before its semaphore
signals and read only after the wait, so the write happens-before the read.
Comments only; no behaviour change. `LoaderBox` and `ElementStorage` were already
justified. swiftformat/swiftlint --strict and the full suite stay green.
An xsi:type value 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 (W3C XSTS targetNS00101m; libxml2 rejects it identically).
xsiTypeReference now keys an unprefixed xsi:type to the in-scope default namespace
(none here) rather than returning the bare local name, so xsi:type="Test" names
no-namespace Test, which does not exist; the root then has neither a declaration
nor a usable type and the instance is invalid. A prefixed xsi:type and an
unprefixed one under a default namespace equal to the target namespace are
unaffected, and the child-element builtin-substitution path keeps its bare-name
fallback (xsiTypeName), so an unprefixed builtin still substitutes where valid.
Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
6 -> 5, valid-instances-rejected held at 0 across all 14383 groups, full suite
(1840 tests) and wasm green. An adversarial critic traced every resolveNamedType
branch, including the unprefixed-builtin-via-key case, and confirmed the only
path that no longer resolves corresponds to a spec-invalid reference, so no valid
instance is newly rejected.
XSD 1.0 §3.11.4 (cvc-identity-constraint.3) requires each key/keyref/unique
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 W3C
XSTS case idZ010 nests anyType fields several levels deep, e.g. a key field
./b/c where c has no type).
A compile-time pass (XSDParser.identityNonSimpleFieldKeys) 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, since cvc reads the node's governing type (a text-only anyType
element with xsi:type="xsd:int" is 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.
The one residual divergence path, two same-name child elements of differing
simplicity in one inline model, is an Element-Declarations-Consistent violation
the compiler already rejects.
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), full suite (1837 tests), wasm, and lint all green.
An adversarial critic that read every file and probed for a false positive could
not construct one; the failure mode is under-rejection, the safe direction.
XSD 1.0 (Datatypes Appendix F) defines the regex escape \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:
the enumerated, Unicode-2.0-era character classes of XML 1.0 Appendix B
(BaseChar, Ideographic, CombiningChar, Digit, Extender). \i and \c were wired
to PureXML.Parsing.XMLCharacter, which correctly implements the Fifth Edition
Name 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, e.g. the combining mark U+0346 (COMBINING BRIDGE ABOVE), which
Appendix B's CombiningChar excludes (its first range ends at U+0345).
Add XSDNameCharacter, transcribed clean-room from the Appendix B grammar
(identical across editions one through four) and stored as sorted, non-
overlapping code-point ranges with binary-search membership; the parser's own
name check is untouched. The two tables reproduce the W3C XSTS \i/\c
enumerations (reZ005, reZ006: 34516 and 35122 code points) exactly and in both
directions, so the more restrictive definition rejects no spec-valid input:
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), both false-positive
buckets at 0. Full suite (1834 tests) green; an adversarial critic brute-forced
the binary search over the whole code-point space (0 mismatches), confirmed the
tables are sorted and non-overlapping and equal the oracle sets, and that the
parser's Fifth Edition path is left intact. 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.
XSD 1.0 fixes the regular-expression repertoire at Unicode 3.2 (Datatypes
Appendix F), where U+1369..U+1371 (ETHIOPIC DIGIT ONE..NINE) are general
category Nd, each carrying 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 table omitted these nine, so \d used the modern No: a valid \d-constrained
Ethiopic digit was wrongly rejected (cardinal-rule false positive) and a
\D-constrained one wrongly accepted.
Pin the nine to Nd. This is the complete set of code points that were Nd at 3.2
and are not today; the reverse direction is empty (every later-Nd point was
unassigned at 3.2 and already reports Cn), verified by diffing the 3.2 and current
Unicode Nd sets. \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), and the matching \d-valid cases
reS17/reS38/reZ004 no longer need a suspect-test exclusion and pass as ordinary
agreements, with both false-positive buckets at 0. Full suite (1830 tests) green;
an adversarial over-rejection critic re-derived the Nd delta bidirectionally and
confirmed no false-positive surface. A regression-pinning test exercises the
production regex path, including the boundary that U+1372 (No even at 3.2) is
not \d.
OrderedValue.== compared doubles with IEEE equality (NaN != NaN), so a NaN value never matched a NaN enumeration member and was rejected: <v>NaN</v> against xs:double restricted by <xs:enumeration value="NaN"/> is valid, and PureXML rejected it (a cardinal-rule false positive). NaN is now treated as identical to itself in value-space equality, the identity the reference (libxml2) and XSD 1.1 use throughout; an adversarial critic flagged the shared comparison (also used by key/unique and fixed), so the key/fixed behaviour was verified against xmllint: libxml2 collides two NaN unique keys and matches fixed="NaN", i.e. the same identity, so one identity == matches the reference everywhere. Ordering (<) keeps NaN incomparable, so range facets are unaffected. Found by a new generated-value facet differential (FacetValidationDifferentialTests, opt-in via FACET_DIFF) over ~10000 datatype/facet values: one FP found, zero after; the four W3C XSTS buckets held, full suite passes, SchemaDoubleEnumerationTests locks all three relations.
A long <xs:attributeGroup ref> chain (ag0 refs ag1 refs ag2 ...) is flat in the document, so flattening a complex type's attributes through it recursed once per link: the stack overflowed past ~6000 links and it was near-cubic before that (~17s at 3000). attributeUses already threads a visited cycle-guard set whose count is the chain depth, so bound it against maximumContentModelExpansionDepth (512): a chain past the bound is rejected with a located error rather than crashed. Behaviour-preserving within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus and the full suite. Residual: many distinct over-bound chains still reject in O(n^2) per group (attribute flattening re-done per group); a memo is the linear follow-up.
A long <xs:complexType> simpleContent extension chain (T0 extends T1 extends T2 ...) is flat in the document, so each of the N complex types re-walked its whole base chain to resolve the simple type its simpleContent derives from (an O(n^3) compile; a 400-link chain took ~23s). The resolved simple type is now memoized by complex-type name (SimpleContentMemo, a reference type surviving the value-type context copies, mirroring GroupParticleMemo); the same chain compiles in ~1.2s, and past the 512 expansion-depth bound it is rejected with a located error as before. Behaviour-preserving (an acyclic resolution is intrinsic to the name; a derivation cycle is rejected elsewhere), verified conformance-neutral over the full W3C XSTS corpus, the determinism differential, and the full suite. The attribute flattening along the chain is inherently quadratic in the bounded length and is tracked.
A long <xs:simpleType> restriction chain is flat in the document, so the same base was resolved repeatedly: the named simple types were resolved by an N-pass fixpoint, and the facet-applicability variety check re-scanned the schema's children to find each base at every link of every type's chain (compiling a 600-link chain took ~4.4s). Named simple types are now resolved once in dependency order (XSDSimpleTypeOrder), and the variety check uses an O(1) name map and memoizes each type's variety (SchemaSimpleTypeVariety); the same chain now compiles in ~30ms. Behaviour-preserving, verified conformance-neutral over the full W3C XSTS corpus, the determinism differential, and the full suite.
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 and a complexContent derivation chain (the definitions are siblings). The particle compiler inlines each group reference and composes each derivation once per link, overflowing the stack on a long chain. The expanded depth is now bounded by one counter (XSDContext.expansionDepth, incremented at each compositor level, group-reference expansion, and derivation step) against maximumContentModelExpansionDepth (512); a model past the bound is rejected with a located error, not crashed. The counter lives in a value-type context so it tracks depth not width (no width false positive), and the rejection is harvested into the schema's errors (no silent truncation). Verified conformance-neutral over the full W3C XSTS corpus (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite; an adversarial critic found the derivation-chain gap, which this fixes with the same counter.
A flat chain of <xs:group> references is not bounded by the parser's maxDepth (the definitions are siblings, not nested), so the compositional UPA determinism check's lazy per-reference recursion overflowed the native 8 MB stack while compiling a legal schema. Group summaries form an acyclic graph, so they are now precomputed bottom-up in dependency order (GroupDependencyOrder): the walk's group lookup is a cache hit and recursion is bounded by a single group's own model nesting. The precomputed reachable set is identical to the lazy walk's, so the verdict is unchanged. Verified verdict-equivalent over the full W3C XSTS corpus, the libxml2 determinism differential (0 FP, same 3 FN), the full suite, and an adversarial critic. Residual, tracked: the particle compiler that inlines a group reference still overflows on a far longer chain; closing it needs a content-model nesting-depth limit with a located error.
The interactive-safety bar wants a proven worst-case bound, not a cap or a timeout. The
graph-structured-stack matcher shares stack suffixes, so on a model with S program states
and an input of length N the live-configuration set and the GSS node count stay O(S*N)
where an explicit return stack reaches O(distinct stacks): a nullable doubling chain that
gives an explicit stack ~3^depth configurations gives the GSS O(depth).
ContentRTNMatcher now exposes peakLiveConfigurations and gssNodeCount, and the differential
asserts they stay linear in the chain depth (measured gss = K + 2, peak configs = 3K + 3),
by count rather than a machine-dependent wall-clock timeout, so a regression to super-linear
fails the test. Native-only (the depth-80 RTN build recurses on nesting depth and overflows
WASI's small stack; that builder depth-safety is tracked separately). No behaviour change.
A repeating model whose optional trailing particle and leading particle share a name,
(c, b?, c?)*, is ambiguous, 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 (withRepeatEdge) 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 at 8/14), full suite on macOS/Linux/WASI, and 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> are two expanded occurrences for Unique Particle
Attribution, so when they overlap, (g?, g?), (g | g), (g*, g), the model is ambiguous,
exactly as inline (x?, x?) / (x | x) is, 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 the
conflict: PureXML accepted these (an invalid-schemas-accepted false negative). It also let
the per-instance content matcher run on a non-UPA model, where a multiply-referenced
nullable group is exponential, so a depth-18 optional doubling chain was accepted and then
hung instance validation: a keystroke-time 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; the repetition self-loop
(a*, (a*)*) keeps a non-flagging dedupe so one particle looping is not misread as two
occurrences. This also bounds the check, the deduped first/follow sets stay O(labels) not
2^K, so the depth-40 chain is now rejected at compile in 11 ms and no instance is matched
against it. A redefine self-extension lists a type's own content twice through
effectiveModelNodes; that artifact is deduped before the walk so it is not a false
conflict (addB094, schZ007).
The repeat-edge fold is kept in a separate method, not inline in summarize: summarize
recurses on nesting depth, so a local it holds multiplies the per-level stack frame and a
deep group chain overflows WASI's small stack (the recursive check's deeper WASI depth-
safety is tracked separately).
Verified over the full W3C XSTS corpus in release: valid-schemas-rejected and
valid-instances-rejected stay 0, invalid-*-accepted unchanged at 8 and 14; an adversarial
over-rejection critic found no valid schema newly rejected; the full suite passes on
macOS, Linux, and WASI (run locally under wasmtime).
* perf(schema): graph-structured-stack content matcher, sharing each xs:group (alongside)
The per-instance content-model build inlines every xs:group reference, so a
multiply-referenced nested group (g0=(g1,g1), g1=(g2,g2), ...) explodes to 2^K
states and reset actions: a depth-16 doubling chain already takes seconds, depth
20+ hangs. This compiles the same particle to a counted Recursive Transition
Network instead, where each definitionID group is compiled once and reached by a
call, and simulates it with a graph-structured stack.
ContentRTNBuilder mirrors ContentNFABuilder exactly except that a group with a
definitionID becomes a call into its shared, compiled-once fragment (memoized by
id; the fragment's accept is a return). The callee's counters are deliberately not
propagated to the caller: every counter is reset on its own particle's entry edge,
which is on the path from the callee's start, so a shared fragment resets itself on
every re-entry without the caller re-listing it. Re-listing was the residual
blow-up: it nested the reset-action lists to 2^K even when the states stayed O(K).
ContentRTNMatcher runs configurations (state, counters, gssNode): a call saves the
caller's counters on a GSS node interned by (returnState, savedCounters, position)
and a return restores them, so the shared callee never mixes counter state across
its callers; parents are a set and a popped node replays its pop to a parent added
later (the GLL completeness detail). The build drops to O(groups): a depth-24
doubling chain (2^24 inlined states) compiles to under 480 states in under a
millisecond.
It is gated by a differential (ContentRTNDifferentialTests) against the trusted
inlining matcher ContentNFA as the oracle: the two agree on the accept verdict and
the matched particle over the hand 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 shared groups, plus a bound
assertion. Built alongside the inlining matcher and not yet wired into validation,
so no behaviour change and the XSTS buckets are unchanged; the switch-over is gated
on this differential.
* perf(schema): add the streaming/located surface to the GSS matcher, lock it differentially
The content-model validator reads two things off the inlining matcher: matchedParticles
for per-child attribution, and the streaming startStates/step/admissible/isComplete for
located diagnostics (which child breaks the model, and the "expected a, b" hint). The GSS
matcher only had matchesWhole/matchedParticles, so it could not yet replace the inlining
matcher. This adds the streaming surface: admissible works because the closure already
expands call states into the callee's label states, so the follow set has the same shape
as ContentNFA's.
The differential gate now locks the whole surface, not just the accept verdict: driving
both matchers child by child, the first rejected child (step is nil) agrees, the
admissible expectation set agrees at the start and after every accepted child, and
isComplete agrees once the sequence is consumed. The fuzz now stamps group references the
way the parser does, a unique definitionID per distinct group (so a singly-referenced
named group also takes the call path, not only doubled groups), a shared id only for
genuinely shared structure, and some anonymous inline groups, over occurrences and
wildcards. Still alongside, not wired; no behaviour change, XSTS buckets unchanged.
* perf(schema): complete the GSS matcher surface with follow(after:), verified
follow(after:) is the last ContentNFA method the GSS matcher lacked: the completion
path (XSDCompletions) reads it for the acceptable-next labels and whether the content
may end. It composes the streaming surface, and the differential now locks its allowed
set and complete flag to ContentNFA too. The RTN matcher is now a complete drop-in for
the ContentNFA surface the validator and completions read, so the switch-over is a
mechanical builder swap gated on XSTS neutrality. Still alongside; no behaviour change.
* docs(design): record GSS Steps 1-3 done, the switch as a corpus-gated mechanical swap
Updates the counted-content-automaton Step 4 to the current state: the GSS matcher and
its full differential gate are built and green in-repo, and the production switch is now
a three-site builder swap blocked only on the XSTS neutrality measurement, which must run
on a machine that has the corpus. Records the exact swap sites and the gate so the corpus
machine can execute it mechanically.
* perf(schema): validate and complete on the GSS matcher, killing the last 2^K content-model hang
The three call sites that built the inlining content automaton once per validated
element, ComplexValidatorContentModel.sequenceStructureErrors (located structure
errors), ComplexValidator.validateParticle (per-child particle attribution), and
XSDCompletions (acceptable-next labels), now build ContentRTNBuilder and run
ContentRTNMatcher. A schema whose nested <xs:group> references inline 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 built in O(groups).
Validating a short instance against a depth-40 doubling schema (2^40 inlined positions),
the shape an editor sees mid-typing, now completes in milliseconds with a located error
instead of hanging; a new end-to-end test pins it.
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. The located diagnostics, per-child attribution, and completions are
behaviour-identical by the surface differential (ContentRTNDifferentialTests), which locks
the verdict, matched particle, rejection index, admissible set, isComplete, and follow to
the inlining ContentNFA. ContentNFABuilder/ContentNFA are retained as that oracle.
* docs(design): mark GSS content-matcher Step 4 done, XSTS-neutral in production
Records that the three production sites are switched to the graph-structured-stack
matcher and the switch was measured conformance-neutral on the full W3C XSTS corpus in
release (all four buckets held). Notes the local corpus path and that ContentNFABuilder
is kept as the differential oracle rather than deleted.
* test(schema): compare full matched-particle attribution; bound a cyclic group id
From the adversarial review of the GSS matcher:
- The differential compared only the matched-particle kind (element/wildcard), so it
could not observe the type/value metadata matchedParticles carries. Every fuzz and
hand element now carries a per-name fixed value constraint and the comparison projects
it, so the gate verifies the metadata is carried identically through call/return, not
only that the kind agrees. A name maps to one constraint, so the comparison does not
depend on the lowest-state-index tie-break (which only distinguishes same-name
particles, a UPA violation the compiler rejects before instance validation); the
matchedParticles doc now records that caveat.
- ContentRTNBuilder.groupFragment memoizes after compiling, so a (malformed) cyclic
definitionID would recur without bound, the interactive-safety hazard this matcher
removes. definitionID groups are acyclic by construction (the parser breaks cycles at
element boundaries, the inlining builder relies on the same), but bound it anyway:
a cycle degrades to a terminating empty fragment instead of recurring.
No production behaviour change; the differential, full suite, and the full XSTS corpus
in release (16/16, all four buckets held) stay green.
Step 1 of the GSS content-matcher build (docs/design/counted-content-automaton.md).
A multiply-referenced `<xs:group>` expands to one copy-on-write-shared `Particle`
(GroupParticleMemo), but `Group`/`Term` are value types with no exposed identity,
so a content-model compiler cannot tell two references apart and inlines the group
per reference (the 2^K build the GSS matcher will close).
Group now carries a `definitionID: Int?`, stamped from a per-compile counter on
GroupParticleMemo when an expansion is first compiled, so every reference sharing
the memo key reuses the same stamped term and distinct definitions differ. It is
metadata only: Group is not Equatable/Hashable, so it perturbs no determinism or
restriction comparison, and it is nil for an inline group.
FP-neutral by construction (no consumer yet). Full in-repo suite green; the XSTS
buckets are unchanged (valid-schemas-rejected 0, invalid-schemas-accepted 8,
valid-instances-rejected 0, invalid-instances-accepted 14); swiftformat/swiftlint
clean. Step 2 (a call/return content-model build keyed on this id, alongside the
inlining oracle) consumes it.
A particle restriction whose restricting element has a union-typed base element
was checked by structural member subsetting: PureXML accepted a separately
declared union whenever its members were a subset of the base union's. That is
not the spec's derivation relation. The NameAndTypeOK type clause (XSD 1.0
section 3.9.6) requires the restricting element's type to be validly derived
from the base element's type per Type Derivation OK (Simple), cos-st-derived-ok.
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. Scoped to the
element-restriction path, so no attribute or simple-type derivation check moved.
Catches particlesIk011 (a fresh union(integer) is not a restriction of
union(integer, string)): XSTS invalid-schemas-accepted 9 to 8, valid-schemas-
rejected held at 0 over the full corpus (the cardinal no-over-rejection check).
Disclosed: particlesIk025/Ik027, where the restricting type derives from a
union member, are accepted per cos-st-derived-ok 2.2.4, a contested reading
their XSTS invalid expectation does not share. An in-repo test that asserted
the old structural-subset behavior is corrected.
The XSLT engine was mutually recursive on template-application and result-tree
nesting (instantiate, evaluate, applyTemplates), so a recursive named template
whose depth comes from source data could overflow the native stack (SIGBUS) on
attacker-controllable input, and an identity transform of a deep source
overflowed on a small (Swift-concurrency Task) stack.
The engine is now 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 result-tree 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 were made
iterative too: XSLTNamespaceFixup (an explicit pre-order visit that keeps the
ns0/ns1 counter in document order), the text and string-value extractions
(iterative accumulators), and the content-type-meta and CDATA-section rewrites
(the shared Node.rebuildingBottomUp spine).
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 exhaust memory), defaulting to 40000
and throwing XSLTError.recursionLimitExceeded only past that.
Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance
corpus (1086 cases) and the in-repo XSLT/XPath suites pass identically; one
previously-failing Xalan case (mdocs07) now passes and was de-baselined. Full
suite green. Closes#356.
* fix(xslt): bound template recursion so it fails gracefully, not with a crash
A recursive named template whose depth is driven by source data (e.g.
`<xsl:with-param name="n" select="count(//x)"/>`) could recurse without bound
and overflow the native stack (SIGBUS) on attacker-controllable input. Every
XSLT processor guards this (libxslt's xsltMaxDepth); PureXML did not.
Measurement first: source *nesting* depth is not the exposure. `transform`
parses the source at the default maxDepth (256) and there is no XSLT API that
accepts a pre-built tree, so the engine never sees a source deeper than ~255;
an identity transform of a 254-deep source completes normally on a normal
stack. The reachable crash is data-driven template recursion, which overflows
an 8 MB stack between ~400 and 600 levels.
Template-instantiation nesting (instantiateTemplate and builtInRule) is now
counted and capped at maxTemplateDepth; past it the guard latches, the
instantiation loops unwind, and `transform` throws
XSLTError.recursionLimitExceeded instead of crashing. The default (300, exposed
as PureXML.XSLT.defaultMaxTemplateDepth, overridable via a new maxTemplateDepth
parameter on transform) clears the parser's max source depth so a deep-source
identity transform still runs, while staying within an 8 MB main-thread stack
with margin. A stylesheet with deeper legitimate recursion raises the limit on
a correspondingly larger stack.
copyOf moved to XSLTSorting.swift to keep XSLTTransformer.swift within the file
length limit; it is unchanged (a whole-subtree copy that does not recurse on
depth). Guarded by XSLTRecursionLimitTests (run on a large-stack thread, since
the limit is above what swift-testing's Task stack holds). Full suite 1802 pass.
Part of #356; making the engine fully iterative (deep recursion succeeds, and
is safe on a small Task stack) remains tracked there.
* test(xslt): exclude the large-stack recursion tests on WASI
WASI is single-threaded and has no Foundation Thread/DispatchSemaphore, which
the recursion-limit tests need to exercise the guard on a stack large enough for
the limit (300) to fire before the stack overflows. Guard the file with
#if !os(WASI); the recursion guard itself is platform-independent pure Swift,
covered by the macOS and Linux runs.
Two Canonical XML transforms still recursed one native frame per element level,
so a deeply-nested document overflowed the stack.
The C14N 2.0 sequential prefix rewrite walks the tree twice. Its assign pass
(which records one canonical prefix per namespace in document order) is now an
iterative pre-order walk; its rebuild pass is now a child-gathering accumulator
driven by an explicit work stack, threading the in-scope declarations and the
root flag down per element. The document-order prefix assignment and the
root-hoisted declarations are unchanged.
The node-subset serializer (`canonicalize(_:including:)`) now drives the same
deferred-close work stack as the main serializer, threading each element's
in-scope and rendered namespace context; an omitted element still passes its
declarations to descendants without rendering or a close tag.
The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset
corpora pass (1794 tests). A 50000-deep document now rewrites and
subset-canonicalizes without crashing, guarded by `CanonicalDepthSafetyTests`.
Part of #350.
`PureXML.XInclude.process` rebuilt the tree recursively (`process →
processElement → process`), so a deeply-nested document overflowed the stack
one native frame per element level, even with no includes present. It now
drives an explicit work stack: each branch gathers its processed children into
a reference accumulator, then a deferred build step assembles the node. The
`xml:base`, inclusion-chain depth (bounded by `maxDepth`), and cycle-detection
visited set thread down per context 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 all pass. A 50000-deep document,
with or without an include at its base, now processes without crashing, guarded
by `XIncludeDepthSafetyTests`. Part of #350.
`PureXML.HTML.serialize` walked an element's children recursively, so a
deeply-nested tree overflowed the stack (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.emit`: 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, so nested
elements still serialize by their own name.
The output is byte-identical. Void-element, raw-text, comment, and escaping
semantics are unchanged (a new micro-test pins them) and the HTML conformance
corpus passes. A 50000-deep tree now serializes without crashing, guarded by
`HTMLSerializeDepthSafetyTests`. Part of #350.
Computing a node's string-value walked its subtree recursively, so it
overflowed the stack on a deeply-nested node (around 2000 deep on a Swift
Task stack): `XPath.Query("/a/a").strings(over:)` over a 4000-deep chain
crashed. `TreeNode.textContent` and the value-Node string-value behind
`Selection.stringValue` now walk an explicit stack, pushing children reversed
to concatenate in document order.
The value-Node path also wrongly folded descendant comment and
processing-instruction data into an element's string-value, disagreeing with
the tree-node path and XPath 1.0 (5.1/5.5: only text and CDATA contribute to
an element's string-value; a comment or PI node's own string-value, 5.6/5.7,
is still its data). It now matches. No test depended on the old behaviour:
1789 pass, including the XPath and XSLT value corpora.
Closes the string-value sites of #350; the transform walks (canonicalize,
DTD defaults, XSLT result tree) remain tracked there.
* perf(model): rebuild DTD-default application iteratively, not recursively
applyingDefaults rebuilt the tree with a per-node recursive walk, so applying
DTD attribute defaults to a deeply-nested document overflowed the stack (around
2000 deep on a Swift Task stack). It now rebuilds bottom-up through a new shared
iterative spine, Node.rebuildingBottomUp, which transforms each node once its
children are rebuilt via an explicit work stack.
The same primitive will carry the other tree-to-tree transforms (canonical
prefix rewrite, XSLT namespace fixup) in follow-ups. Defaults are applied
identically: 1788 tests pass. Part of #350.
* perf(canonical): serialize C14N iteratively, not recursively
Canonicalizer.emit recursed on element children, so canonicalizing a
deeply-nested document overflowed the stack (crashed around 5000 deep).
The element serializer now drives a deferred-close work stack (the same
shape as the main serializer): each step carries the in-scope and
already-rendered namespace context, children push reversed to emit in
document order, and a close step emits the end tag. The shallow document
case and all namespace-rendering logic are unchanged.
The canonical bytes are byte-identical: the C14N 1.0, 1.1, and exclusive
conformance corpora pass, full suite 1789. A 50000-deep document now
canonicalizes without crashing. The node-subset helpers moved to their
own file to keep within the file-length limit. Part of #350.
Mapping an ordered node-set to selections rebuilt each result as an
independent value Node through TreeNode.node, projecting that node's whole
subtree. For a result set whose nodes nest within one another (`//a` over a
deep chain, where every `a` contains all deeper ones) this materialized the
sum of their subtrees: quadratic. `//a` over an 8000-deep chain took ~19s.
The projection now threads a memo keyed by tree-node identity across one
result set. A tree node is projected once; a result that nests a
deeper one reuses its already-built value Node by reference, which the
copy-on-write Element storage (from the value-Node depth-safety work) makes
a shared, not copied, subtree. The set is built in one pass over the
distinct nodes. The same query now returns in ~0.04s and scales linearly.
The non-memoized `node` projection (serialization, value comparison) is
unchanged. Results are identical: 1787 tests pass, including the XPath and
XSLT value corpora. Closes#348.
Sorting a node-set into document order keyed each node by its root path, an
array as long as the node's depth, computed with a per-node ancestor walk.
Ordering the descendants of a deeply-nested document was therefore
O(n log n x depth): `//a` over an 8000-deep document took ~19s through the
reference query path.
The key is now a single O(1) pre-order index from a per-root numbering,
built once per document and shared through the evaluation's navigation
cache, with a band ordering an element before its namespace nodes, its
attribute nodes, and its children. A node-set spanning several documents
(an XSLT document() result) is grouped by root, ranked by first appearance,
so only a foreign node walks its ancestors. `appendSubtree` (the numbering
walk) is now iterative, so it no longer recurses on depth either.
Document order is unchanged: 1786 tests pass, including the XPath, XSLT,
XPointer, and Schematron conformance corpora. `Query.nodes` over a
50000-deep document now orders in a fraction of a second.
Each open element resolved its name and attributes by looking up namespace
bindings through NamespaceContext.lookup, which walked a stack of scopes as
deep as the document. With one lookup per element over an O(depth) stack,
parsing a deeply-nested document was O(depth^2): a 40000-deep document took
~39s.
The context now maintains the current in-scope bindings incrementally: a
single prefix-to-URI map, plus a per-element undo list recording the
bindings each element overwrote so closing it restores the enclosing state.
A lookup is O(1) and entering/leaving an element is O(its xmlns
declarations), so parsing is O(n). The same document now parses in ~0.2s.
Namespace semantics are unchanged: the nearest in-scope binding still wins,
an empty default binding still undeclares, and 81 namespace tests plus the
full suite (1784) pass. Closes#345.
Element stored its children inline and Node 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
(signal 10) -- 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), so Node is a fixed-size value (no longer indirect) and:
- `==` and `hashValue` walk the tree iteratively through explicit work
stacks (the synthesized conformances recursed); equal trees still hash
equally.
- ElementStorage's `deinit` flattens the subtree into a heap worklist
before releasing, draining only storage it solely owns so a copy-on-write
sibling keeps its subtree.
- `children` is a computed copy-on-write view with a `_modify` accessor, so
in-place mutation (the parser's append loop) stays amortized O(1).
Value semantics are unchanged; Element/Node remain Equatable, Hashable,
Sendable. A 50000-deep tree builds, compares, hashes, and releases in a
fraction of a second; 1782 tests pass. Completes the value-Node half of #341.
Building a TreeNode from a value Node, projecting it back via `node`, and
releasing it each recursed one native frame per tree level, so a deeply
nested document overflowed the call stack (signal 10 around 30-60k deep,
and far shallower on a constrained stack such as a Swift Task). None of
these is fixed by the serializer, which was already iterative.
- `TreeNode(_:)` and `node`/`projectedChildren` now drive explicit
post-order work stacks (entity-reference splicing, doctype/namespace
dropping, and the root special-cases are unchanged).
- `deinit` flattens the subtree into a heap worklist before releasing, so
each node frees with empty children and the teardown stays flat. Only a
node the teardown solely owns (isKnownUniquelyReferenced) is flattened,
so a child still referenced elsewhere keeps its subtree.
- The validator walk and the XPath descendant axis were made iterative for
the same reason.
A 60000-deep tree now releases in well under 0.1s; 1778 tests pass and no
accepted output changes. Part of #341; the value Node's own recursive
release and XPath result ordering/dedup remain depth-bounded and tracked
there.
* perf(parsing): memoize the markup-producing entity check (billion-laughs)
splitAtMarkupEntity / producesMarkup recursed into every reference of
every replacement with no memoization, so a nested entity chain was
traversed O(references^depth) times: a classic eight-level billion-laughs
took ~18s of CPU to reject. The check is now memoized per entity name (the
property depends only on the fixed entity graph), so it is O(entities). The
same document now rejects in ~0.6s, bounded by the expansion budget. No
change to which documents are accepted; cyclic entities still rejected.
Found by adversarial probing; closes the no-markup case of #339.
* perf(parsing): three-colour DFS for the No-Recursion check
checkRecursion validated the entity reference graph with only an
in-progress (grey) set and no record of fully-verified entities, so a
billion-laughs graph was re-walked O(references^depth) times: a
markup-producing eight-level document took ~31s to reject. It is now a
three-colour DFS: an entity is added to a black set only after its whole
reachable subgraph returns acyclic, so it can never reach a chain node
that reaches it, and skipping it on revisit is sound. The walk becomes one
pass over the distinct edges (~31s -> ~11s for that document). Cyclic
entities are still rejected; no accepted document changes. Part of #339.
The lookahead buffer consumed from the front with Array.removeFirst() and
re-injected entity replacement text with insert(at: 0), both O(buffer), so
a single large entity (or a long content run pushed back by reference
inclusion) was consumed in O(n^2). The buffer now advances a head index
(front-consume O(1)) and re-injection reuses the freed prefix space, making
a single large expansion linear. An 800k-character internal text entity
drops from quadratic time to about 0.6 s. Same characters, same limits;
1775 tests unchanged. Found by adversarial robustness probing.
Repo-wide reference sweep: XSLTTransform.transformToNode (a transform-to-
tree variant nothing calls; the live string-returning transform is
independent), and ComplexValidatorHelpers.matchesAll plus its orphaned
helper label(of:). Zero references; build and 1775 tests confirm no
behavior change.
A private-scope sweep (a private/fileprivate function with zero in-file
callers is provably dead, with no name-conflation risk) found three:
selectNodes in XSLTTransformer (selectXPathNodes is the live sibling),
processingInstructionMatches in EvaluatorSteps, and parseExternalID in
DoctypeScanner. A doc comment referencing selectNodes was reworded.
Build and 1775 tests confirm no behavior change.
derivation-ok-restriction.2 (a base required attribute may not become
optional in a restriction) ran only over complexContent restrictions, so
a simpleContent restriction that relaxed a required attribute was accepted.
The attribute-use restriction check now runs over both derivation roots.
Closes XSTS particlesZ030_d, which libxml2 also rejects, so this aligns
with the reference rather than exceeding it. XSTS invalid-schemas-accepted
10 to 9 with valid-schemas-rejected held at 0 (full archive). Guarded by
SimpleContentAttrRestrictionTests; the XSTSSuiteTests baseline is updated.
XSLTNamespaceFixup.fixAttributeName and resolvedPrefix had zero callers.
The result-tree attribute fixup was inlined into fix(_ element:) and
evolved past them (the inlined path also shadows a carried prefix via
localPrefixes, which the helpers lacked), so the helpers were dead,
diverged duplicates. No behavior change; build green, 1759 tests pass.
* refactor(schema): remove unused [String] consistency wrappers
Drop four internal schema-consistency wrappers that had no callers
(idAttributeErrors, postNamedTypeErrors, structureErrors,
includeCompositionErrors). The located-diagnostic *Findings API
superseded them and the live call sites project reasons with
map(reason) directly, so these were dead code. No behavior change;
the *Findings functions are unchanged. Also correct a stale
doc-comment reference to the renamed userTypeValueConstraintFindings.
* critic-fix(schema): drop the dead consistencyErrors chain the first pass missed
The name-only dead-code sweep read XSDParser.consistencyErrors as used,
but its callers were the distinct Validation.XSDSchema.consistencyErrors
(a different signature returning ValidationError). The XSDParser overload
had zero callers. Removing it, plus the postCompileErrors(namedTypes:)
overload and the SchemaCompileNamedTypes struct that only the already-
removed postNamedTypeErrors reached, completes the dead chain. Build
green, 1759 tests pass, no behavior change.
id() filtered the entire document ID index by tokens.contains on every
call, O(index) per call, so a stylesheet calling id() across a wide
document was quadratic. Look each requested token up directly in the
index dictionary instead, O(tokens). The result is unchanged: the same
elements, in document order (the ID values are the keys, each mapping to
one element). key() already used direct lookups.
Resolving 4000 id() references against a 4000-entry index drops from
about 0.34 s to about 0.03 s (~13x), now linear.
xpointer(string-range(...)) matched the search string at every position
of each node's value and re-allocated the whole needle window per
position, an O(text * needle) search and a denial-of-service vector on a
long node value with a near-matching needle. Replace it with a
linear-time Knuth-Morris-Pratt search (failure function built once, since
the needle is fixed across nodes). 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), now linear.
xs:unique/xs:key/xs:keyref were quadratic over a wide list of targets.
The duplicate check compared each value tuple against every tuple already
seen (and a keyref against every referenced key), and the error-location
helper rescanned the parent's whole child list to position each target.
Key tuples in a Set by their raw string when field equality provably
reduces to a raw string comparison (whitespace-preserving lexical types,
no colon, which is 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. Position each
target through a per-parent, per-name index built once.
A 16000-item xs:unique document drops from about 4.0 s to about 0.12 s
(~34x), and the cost is now linear. The full local suite and the W3C
XSTS datatype/identity conformance corpus are unchanged.
siblingPosition ranked a numbered node among its parent's matching children by
scanning the child list up to the node, counting matches. Run for every node of
a wide list, numbering was O(n^2): the default xsl:number over 8000 list items
took ~3.5s.
The count test is the same for every candidate of one xsl:number call, so cache
per parent and count pattern the one-based rank of each matching child, built
once. Each node's rank is then an O(1) lookup. The transformer holds the cache
across the whole transform; the count test itself is already O(1) through the
match cache, so building a parent's ranks is linear in its children. The numbers
are identical (a node's rank among matching siblings is 1 + the matching
siblings before it).
The 8000-item numbering drops ~3.5s to ~0.04s (~90x), quadratic to linear. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberSingleTests pins single, partial-match, and multiple-level.
walkPreceding visits a node and everything before it in document order. It found
each preceding sibling with precedingSibling(of:), which rescans the parent's
whole child list, so visiting k preceding siblings was O(k^2) and numbering n
nodes under a wide parent was O(n^3): 1000 items took ~4.7s and 4000 did not
finish in minutes.
Find the candidate's index in the child list once, then walk the children before
it directly (parent.children[..<index].reversed()), visiting each preceding
subtree in reverse. The walk is now linear in the number of preceding nodes, so
the numbering is quadratic (the residual is inherent to level="any", which
counts the matching preceding nodes per numbered node). The numbers are
identical.
Numbering 1000 items drops ~4.7s to ~0.7s (~6x); cubic becomes quadratic. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberAnyTests pins document-order numbering across nesting.
translate(string, from, to) ran from.firstIndex(of: character) for every
character of the source, so a large from/to map made it O(string * from). And
contains/substring-before/substring-after matched the needle at every position
of the haystack (Array.starts(with:) per index), O(n*m) and quadratic on a
repetitive string, which is a denial-of-service vector on untrusted input.
translate() now builds a from-character to replacement map (and a delete set)
once, first occurrence winning, and translates each source character with an
O(1) lookup. The substring search is Knuth-Morris-Pratt, O(n+m), which never
rescans. The results are unchanged.
A 4000-character large-alphabet translate drops ~0.13s to ~0.002s; a 16000-
character repetitive contains drops ~0.054s to ~0.0002s. The full suite, the
W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
TranslateFunctionTests and SubstringSearchTests pin the semantics.
siblingPosition(of:) located the context node with
parent.children.firstIndex { $0 === tree } on every call: a linear scan of the
parent's child list, independent of the result. Over a wide parent evaluated
from many context siblings (//x/following-sibling::y) that scan was quadratic,
and the axes then materialized every sibling before the node test filtered it.
Look the index up through the shared per-evaluation SiblingIndexCache (O(1)
after the per-parent table is built), and fuse the step's node test into the
walk so a rejected sibling is never wrapped (the same fusion the descendant and
following/preceding axes use). The selected siblings and their order are
unchanged.
The gratuitous per-context scan and the non-match wrapping are gone (the
remaining per-context iteration over the siblings is inherent to the axis); a
wide-parent following-sibling query is ~1.2-1.6x faster. The full suite, the
W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
SiblingAxisTests pins the fused node test and document order.
An attribute node's document-order key bands by the attribute's index among its
owner's attributes, found with owner.attributes.firstIndex { $0.name == name }.
That linear scan ran for every attribute node, so sorting an element's K
attributes into document order was O(K^2): an 8000-attribute element took ~0.18s
and grew quadratically.
Cache a per-owner attribute-name-to-index map in SiblingIndexCache, built once
on the second lookup (a single lookup still scans), exactly as the sibling-index
cache already does for child positions. The index is the same first-occurrence
position; only its cost changes.
The 8000-attribute sort drops to ~0.01s (~16x) and is now linear. The full
suite, the W3C XPath and Apache Xalan corpora, the C14N suite (which orders
attributes byte-exactly), and the WASM build stay green; AttributeDocumentOrderTests
pins declaration order through the cached sort.
After #302 cached the document node list, the following and preceding axes still
materialized the entire following/preceding span (Array(slice), one Node copy
with a slow side-table retain per node) before the step's node test filtered it.
For a rare-target query that copy is almost entirely waste.
Fuse the node test into the walk: apply the test to the cached document-order
slice directly, 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 (the test is the same compiled node test the general axis path used).
The 200-context following:: query over a 20k-element document drops from ~9s
(post-#302) to ~6s, ~2.6x below the original ~16s. The full suite, the W3C
XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
FollowingPrecedingAxisTests gains a fused-name-test case.
following(of:) and preceding(of:) rebuilt the entire document node list and ran
a linear firstIndex search for the context node on every call. Evaluated over
many context nodes (//x/following::y), that rebuild-and-search was quadratic: a
200-context following:: query over a 20k-element document took ~16s.
Share a per-evaluation DocumentNavigationCache: each document's ordered node
list and a node-to-index map are built once (the document is immutable during
evaluation) and reused, so the position lookup is O(1). following:: also skips
the context's contiguous subtree by index arithmetic (start = index + 1 +
descendant count) instead of scanning the tail and filtering every descendant
out. The selected nodes and their document order are unchanged.
The 200-context query drops ~16s to ~9s; the residual is the produced
node-set's own size (materialization), which is inherent to the axis. The full
suite, the W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay
green; FollowingPrecedingAxisTests pins the subtree skip, ancestor exclusion,
and the shared-cache multi-context path.