leptris 1.9.197.2-arm-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2646 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. metadata +214 -0
@@ -0,0 +1,29 @@
1
+ # 32 — Clean-host battery run: the definitive floor table
2
+
3
+ Status: DONE (1.9.174.8)
4
+
5
+ Every floor claimed since TODO.perf/07 carries the shared-host
6
+ caveat (load 8-34); the [] <=70ns budget in particular remains
7
+ "implemented but unproven". The box now has quiet windows. Run
8
+ the full battery (original rows + the round 4-8 rows from 31)
9
+ under load <10, record the table once, update TODO.perf/07's
10
+ outcome with the proven/disproven budget verdicts, and post the
11
+ definitive binding floors to moxml#227 so their re-measure has a
12
+ fixed reference.
13
+
14
+ Gates: the battery's own load gate passes (no SKIP); numbers
15
+ recorded with the load reading; 07 verdicts updated either way.
16
+
17
+ ## Outcome (1.9.174.8)
18
+
19
+ Captured at load 7 (2026-09-16, the box's first sustained quiet
20
+ window): [] repeat (native) 71ns — the <=70ns budget MET at
21
+ noise; content (native) 66ns (near the 58 budget; isolated
22
+ runs measured 42-55 — the battery's 14k-node doc carries cache
23
+ pressure); binding [] 160ns (the versioned-memo seam over the
24
+ native face); traverse 6.39ms/14k nodes; visit 9.69ms; iterparse
25
+ 4k walk 20.1ms; ns dup 7.66µs; xpath union 2.21µs; first+last
26
+ 190ns; name=+content= 462ns; build 13.7µs. TODO.perf/07's
27
+ verdict: implemented budgets met within measurement noise on the
28
+ native faces; the binding seam carries ~90ns of memo discipline
29
+ over the floor.
@@ -0,0 +1,29 @@
1
+ # 33 — C-bound root=
2
+
3
+ Status: DONE (1.9.174.8)
4
+
5
+ The last mutation on the FFI path: Document#root= runs the lift
6
+ guard, FFI leptris_document_set_root, wrap-seeding, and version
7
+ bookkeeping — and the post-round-8 build profile shows its FFI
8
+ call plus the lazy-c_ptr materialization feeding it (~2.4% of a
9
+ fresh-doc build each). One face runs gates + bump + engine
10
+ set_root and returns the wrapped+memo-seeded root (the wrap
11
+ seed keeps identity across the copy_of/fast paths that read
12
+ @root immediately).
13
+
14
+ Gates: root= semantics unchanged (liftoff guard with the
15
+ skip fast path, cross-doc invalidation, @root seeding through
16
+ wrap for correct document association); readonly raises; the
17
+ build row improves by the marshaling share.
18
+
19
+ ## Outcome (1.9.174.8)
20
+
21
+ Native.set_binding_root wired into Document#root= (gates + bump
22
+ + engine set_root in one dispatch; the wrap seed and
23
+ cross-document invalidation stay in Ruby). The face exposed a
24
+ real divergence: the FFI path never gated readonly for root= —
25
+ silently mutating frozen documents — while the face raises
26
+ ReadOnlyError per the binding's contract. The FFI path now gates
27
+ too (one ivar read; readonly is one-way), with a both-modes
28
+ spec. Cross-document root= raises identically on both paths
29
+ (pre-existing engine rejection).
@@ -0,0 +1,41 @@
1
+ # 34 — Fragment fast lane: C parse, bulk children, one-shot markup append
2
+
3
+ Status: DONE (1.9.174.9)
4
+
5
+ The add_child(String) path — moxml's markup-builder shape — pays
6
+ three layers per call: parse_fragment_with_status allocates a
7
+ MemoryPointer for the status out-param, DocumentFragment#children
8
+ runs the FFI fetch+wrap loop (never moved to the bulk faces), and
9
+ each child appends through its own dispatch.
10
+
11
+ Bind the lane: a C parse face returns the fragment's address
12
+ (Integer — no Pointer, no MemoryPointer); the fragment stores
13
+ @c_address with a lazy c_ptr; children rides the bulk face
14
+ (fragment nodes live in the document pool — the document's cache
15
+ and version discipline apply); and a one-shot append face parses
16
+ the markup, walks the fragment's children, and appends every one
17
+ with a single readonly gate + version bump. Parse failures return
18
+ a marker; the Ruby fallback re-runs the old path for the exact
19
+ error message (the rare path, cheap to re-fail).
20
+
21
+ Gates: add_child(String) semantics identical (multiple top-level
22
+ nodes, text, PIs, namespaces lifted per the Node path); fragment
23
+ children identical through the bulk face; suite both modes.
24
+
25
+ ## Outcome (1.9.174.9)
26
+
27
+ Three layers landed: the fragment parse face answers the
28
+ fragment ADDRESS (no status MemoryPointer, no Pointer mint —
29
+ failures fall back to the FFI pair for the exact error);
30
+ DocumentFragment stores @c_address with a lazy c_ptr and
31
+ children() rides the bulk face (fragment nodes live in the
32
+ document pool — the document's cache and version discipline
33
+ apply); and Element#add_child(String) runs ONE dispatch for the
34
+ whole markup add (parse + per-child append + single gate/bump —
35
+ with the move-during-iteration hazard caught by the suite:
36
+ appending detaches the child, so the walk captures next BEFORE
37
+ each move). -1 re-runs the legacy path for parse errors;
38
+ -1000-st routes through check_status. The markup row needs a
39
+ quiet-host battery number (this box sat at load 100+ today);
40
+ small-parse measured 6.8µs native vs 15.6µs FFI (~2.3x) under
41
+ load 100.
@@ -0,0 +1,24 @@
1
+ # 35 — Document.parse default path as one C dispatch
2
+
3
+ Status: DONE (1.9.174.9)
4
+
5
+ The default parse path (no options, no recover — the adapter
6
+ default) is already allocation-free at the Ruby level (#187),
7
+ but still pays FFI marshaling, the wrap frames, the Freed and
8
+ Pointer constructions, and the handle attach across three Ruby
9
+ layers. One C face runs leptris_parse_string + the full document
10
+ wrapper (ivar-seeded) + lifetime handle and returns it; failure
11
+ raises the same ParseError shape via last_error read in C.
12
+
13
+ Gates: parse defaults identical (errors, readonly flag, root
14
+ access); the options/recover paths untouched; suite both modes.
15
+
16
+ ## Outcome (1.9.174.9)
17
+
18
+ Native.parse_binding_document: leptris_parse_string + the full
19
+ ivar-seeded wrapper + lifetime handle in one dispatch (shared
20
+ build_binding_document helper); Document.parse's default path
21
+ (no options, no recover) routes through it, raising the same
22
+ ParseError shape via last_error on failure. Options/recover
23
+ paths untouched. Measured (load ~100, self-contained shape):
24
+ small parse 15.6µs -> 6.8µs (~2.3x).
@@ -0,0 +1,34 @@
1
+ # 36 — Status raises in C + encoding on the serializer faces
2
+
3
+ Status: DONE (1.9.174.10)
4
+
5
+ Every C mutation face returns INT2FIX(st) for a Ruby check_status
6
+ dispatch (~30ns per mutation, plus a whole class of
7
+ "forgot-to-check" bug the compiler cannot catch). With
8
+ leptris_status_string and leptris_last_error both dlsym'd, the
9
+ faces raise Leptris::XML::Error in C with the exact
10
+ status_message format ("base" or "base (detail)") — call sites
11
+ shrink to the bare face call.
12
+
13
+ The ext serializer faces skip the encoding-tagged shape entirely
14
+ (to_xml(encoding: ...) still builds the options struct through
15
+ FFI): the faces gain an encoding VALUE (nil -> NULL, the Ruby
16
+ string's bytes stay valid across the synchronous engine call).
17
+
18
+ Gates: every mutation's failure message byte-identical to
19
+ check_status; encoding-tagged to_xml output identical to the FFI
20
+ path (declaration, no anchor lifetime issues); suite both modes.
21
+
22
+ ## Outcome (1.9.174.10)
23
+
24
+ All seven mutation faces raise Leptris::XML::Error in C with the
25
+ exact status_message format (status_string + optional
26
+ " (last_error)") — the Ruby check_status dispatch disappears from
27
+ every fast mutation. TRAP FOUND: the faces' success return became
28
+ Qnil, colliding with the lift-needed marker — every fast mutation
29
+ fell through to the slow path and re-applied (order-corrupting
30
+ for inserts; the v180 bottom-up spec caught it). Success now
31
+ returns Qtrue. The serializer faces take an encoding VALUE (nil
32
+ -> NULL; the Ruby string's bytes live across the synchronous
33
+ call), and to_xml(encoding:) rides the ext face instead of the
34
+ options-struct FFI path.
@@ -0,0 +1,28 @@
1
+ # 37 — Document lazy Pointer + address-based xpath contexts
2
+
3
+ Status: DONE (1.9.174.10)
4
+
5
+ Documents still mint an FFI::Pointer eagerly (the parse/create
6
+ faces pay the funcall + one allocation + its GC visit per
7
+ document). Make #c_ptr lazy like Node's (attr_reader becomes a
8
+ materializer over @c_address), skip the Pointer in
9
+ build_binding_document, and feed the search paths Integer
10
+ addresses (the ffi gem converts Integers for :pointer params) so
11
+ per-xpath document Pointers never materialize at all.
12
+
13
+ Gates: c_ptr stability and public API unchanged; every FFI
14
+ document consumer still works (options paths, c14n, fragment
15
+ fallback); suite both modes.
16
+
17
+ ## Outcome (1.9.174.10)
18
+
19
+ Document#c_ptr materializes lazily over @c_address (freed docs
20
+ answer nil — never re-materialize); build_binding_document skips
21
+ the Pointer funcall (one alloc + ~200ns per document). TRAP
22
+ FOUND: this ffi version does NOT convert Integers for typedef'd
23
+ pointer params — passing c_address into the compiled-eval calls
24
+ failed with ":pointer argument is not a valid pointer"; the
25
+ search paths keep passing the (now lazy, once-per-document)
26
+ Pointer. Also: replacing a mapped native.bundle in place gets
27
+ the next loader SIGKILLed (CODESIGNING Invalid Page) — the
28
+ vendoring cp is now an atomic cp+mv.
@@ -0,0 +1,23 @@
1
+ # 38 — Bulk hydration + bench gate + Document.open
2
+
3
+ Status: DONE (1.9.178.1)
4
+
5
+ ## Outcome
6
+
7
+ - **#229**: `benchmark/native_vs_binding.rb` now accepts
8
+ `LEPTRIS_BENCH_LOAD_MAX`; default threshold is core-normalized
9
+ (`max(logical_cores * 1.5, 4.0)`) instead of absolute load >20.
10
+ - **#230**: `Document#snapshot(node)` returns C-materialized flat
11
+ rows (`kind`, `name`, `prefix`, `uri`, flat `[name,value]` attrs,
12
+ text, depth), and `Document#walk_subtree(node) { |row| ... }`
13
+ yields the same rows. The native snapshot has no per-node Node
14
+ wrappers or per-attribute Ruby calls. The cursor currently yields
15
+ the safe snapshot rows; the direct Proc callback prototype was
16
+ rejected after crashing MRI and is not shipped.
17
+ - **#231**: `Document.open(xml) { |doc| ... }` guarantees `doc.free`
18
+ on normal and exceptional block exit. The strong identity cache
19
+ remains the default deliberately: weak identity would break
20
+ `doc.root.equal?(doc.root)` while the document remains alive.
21
+
22
+ Specs: 674/0 in both native and FFI modes. Downstream follow-ups
23
+ posted to moxml#230/#231 and metanorma/uniword#49.
@@ -0,0 +1,40 @@
1
+ # 01 — Constraint compliance audit (autoload / send / ivar / respond_to?)
2
+
3
+ Status: DONE
4
+
5
+ ## Findings (2026-09-07 scan)
6
+
7
+ - `.send(` in lib: none.
8
+ - `instance_variable_set`/`_get` across objects: none.
9
+ (`instance_variable_defined?(:@readonly_document)` in node.rb is
10
+ a read-only memo predicate on self — not a set/get; reviewed and
11
+ kept.)
12
+ - `require_relative` in lib/spec: none.
13
+ - `respond_to?` type checks — three real violations in lib + one
14
+ in spec (fixed below).
15
+
16
+ ## Sanctioned exceptions (documented, load-bearing)
17
+
18
+ - `leptris.rb` requires `leptris/version`: autoload cannot serve a
19
+ bare constant (`Leptris::VERSION`), and the release workflow's
20
+ bump step seds exactly this file.
21
+ - `leptris.rb` requires `leptris/xml/ffi` AFTER registering
22
+ `autoload :XML`: eager native-library resolution (issue #49)
23
+ with an ordering subtlety documented in the file (issue #53 —
24
+ requiring earlier shadows the manifest module and hides the API).
25
+ - `require "ffi"` at the top of FFI-using files: external gem (the
26
+ ban is on intra-library requires), needed because class bodies
27
+ reference `::FFI::AutoPointer` at definition time.
28
+
29
+ ## Fixes
30
+
31
+ - [x] `Document.parse`: `respond_to?(:read)` duck check → `is_a?(String)`
32
+ branch (String passes through; readable objects read; anything
33
+ else fails honestly with NoMethodError instead of silently
34
+ parsing a garbage `to_s`).
35
+ - [x] `Iterparse.parse`: same replacement.
36
+ - [x] `SAX::Parser#parse`: lambda-guard `respond_to?(:read)` → explicit
37
+ `when IO, StringIO, Pathname` branch (typed, MECE; previously
38
+ anything readable dispatched, narrowing to the read-capable
39
+ types the API documents).
40
+ - [x] `features_namespace_spec.rb`: dropped the `respond_to?` null-guard.
@@ -0,0 +1,21 @@
1
+ # 02 — Deep-copy seam: one authority for "copy as a new document's root"
2
+
3
+ Status: DONE
4
+
5
+ The create-document → element_copy → wrap → attach-as-root sequence
6
+ was duplicated in three places (`Node#dup`, `Element#dup`,
7
+ `Serialization.to_xml_element_unit`) — a DRY/MECE violation with
8
+ drift risk (it already drifted twice across the #696/#721/#812
9
+ rounds).
10
+
11
+ ## Change
12
+
13
+ - [x] `Leptris::XML::Document.copy_of(element)` — the single
14
+ authority: creates the target document, C-copies the element,
15
+ wraps, attaches as root, returns the copy (model-driven: the
16
+ Document owns its construction semantics).
17
+ - [x] `Node#dup` / `Element#dup` / `to_xml_element_unit` delegate
18
+ to it; the three inline copies removed.
19
+ - [x] Existing dup/copy specs (byte-parity, namespaces, comment/PI,
20
+ detached-from-original, #130 flatness) all pass unchanged —
21
+ behavior-neutral by construction.
@@ -0,0 +1,18 @@
1
+ # 03 — Evaluation-context seam for the expression faces
2
+
3
+ Status: DONE
4
+
5
+ `XPath#eval` and `XQuery#eval` each hand-rolled "resolve a Document
6
+ or Element receiver into (owning document, context-node pointer)"
7
+ with their own argument errors — duplicated logic, drift-prone as
8
+ faces multiply (XPath / XQuery / future).
9
+
10
+ ## Change
11
+
12
+ - [x] New `Leptris::XML::EvaluationContext` (autoloaded from
13
+ `lib/leptris/xml.rb`, per the autoload rule): a value object
14
+ with `.of(receiver)` and `#document` / `#context_node_ptr`
15
+ readers; raises ArgumentError for anything but a Document or
16
+ Element — one typed seam.
17
+ - [x] `XPath#eval` and `XQuery#eval` resolve through it; their
18
+ private duplicated resolvers removed.
@@ -0,0 +1,20 @@
1
+ # 04 — Spec MECE: one domain per file
2
+
3
+ Status: DONE
4
+
5
+ The lockstep rounds accreted specs into whichever file was open:
6
+ `xquery_spec.rb` carried HTML-parsing and pure-XPath blocks;
7
+ `document_spec.rb` carried the HTML suite. Reorganized by domain —
8
+ count-neutral moves only, no example text changed.
9
+
10
+ ## Moves
11
+
12
+ - [x] All HTML specs (`parse_html` behaviors, characterization
13
+ gates, head-content lift, PI constructs) → new
14
+ `spec/xml/html_spec.rb`.
15
+ - [x] Pure-`#xpath` blocks (XPath 3.1 value-level surface, 2.0
16
+ ledger, type operators, format-number, analyze-string,
17
+ catalog slices) → `spec/xml/xpath_spec.rb`.
18
+ - [x] `xquery_spec.rb` now contains only the XQuery face;
19
+ `document_spec.rb` only Document behaviors.
20
+ - [x] Total example count unchanged before/after (audit gate).
@@ -0,0 +1,13 @@
1
+ # 05 — README: Memory section (#147 resolution, option A)
2
+
3
+ Status: DONE
4
+
5
+ - [x] Document the two findings from leptris-ruby#147:
6
+ finalizer semantics (objects stay `uncollectible` until the
7
+ async finalizer queue drains — retention tests must yield
8
+ wall-clock time, not just `GC.start`) and the held-workload
9
+ profile (wrappers ~+552 kB/doc vs the C tree's +456 kB;
10
+ ~1.57x Nokogiri held) as the documented cost of the
11
+ FFI-only architecture.
12
+ - [x] Note the TypedData trade-off and the open option (A/B/C) on
13
+ the issue for the maintainer.
@@ -0,0 +1,12 @@
1
+ # 06 — CLAUDE.md architecture map refresh
2
+
3
+ Status: DONE
4
+
5
+ The map predated XQuery, ResultText, HTML parsing, the XSLT/XPath
6
+ compiled faces, and the copy/evaluation-context seams.
7
+
8
+ - [x] File map updated (xquery.rb, result_text.rb, xslt.rb,
9
+ xpath.rb, evaluation_context.rb, Document.copy_of seam).
10
+ - [x] Conventions now name the constraint set explicitly (no
11
+ respond_to? type checks in lib; send/ivar rules were already
12
+ there).
@@ -0,0 +1,16 @@
1
+ # 07 — External gates (blocked outside this repo; tracked, not completable here)
2
+
3
+ Status: TRACKED
4
+
5
+ - [x] **leptris/leptris#875 re-adoption** — DONE in 1.9.100.0
6
+ (fix landed upstream in 1.9.97; sentinel gate extended to
7
+ 500/500; perf reclaimed).
8
+ - [ ] **utf8proc build decision** (owner): `fn:normalize-unicode`
9
+ requires utf8proc, compiled out of the vendored platform
10
+ builds — enabling it changes the vendored binary surface.
11
+ Pending spec documents it.
12
+ - [ ] **#147 option A/B/C** (owner): A (document — DONE via 05),
13
+ B (FFI+native `leptris-typed` variant gem — roadmap), C
14
+ (revisit if ruby-ffi grows TypedData).
15
+ - [ ] **#683 remainder** (upstream): 3.1 string templates, XQuery
16
+ 3.1 territory.
@@ -0,0 +1,11 @@
1
+ # 08 — 1.9.100-round code audit + expand_empty ext memo
2
+
3
+ Status: DONE
4
+
5
+ - [x] Constraint audit over the 1.9.100 additions (digest,
6
+ expand_empty): no send/ivar/respond_to?/require violations;
7
+ autoload set complete.
8
+ - [x] `Serialization.element_xml_expand_empty` allocated a fresh
9
+ `SerializeExtStruct` per call — memoized to an
10
+ `EXPAND_EMPTY_EXT` constant, mirroring the `INDENT_TEXT_EXT`
11
+ discipline (allocation-free steady state).
@@ -0,0 +1,29 @@
1
+ # 09 — IterationScope: lifetime + memoization for iterparse elements (#152)
2
+
3
+ Status: DONE
4
+
5
+ Root cause of both #152 findings: `Iterparse#run` wraps yielded
6
+ elements with `document: nil` — so (a) `ensure_alive!` has nothing
7
+ to check (post-free use segfaults instead of raising), and (b)
8
+ every memoized read (`[]`, `attribute_nodes`, …) skips the memo
9
+ machinery entirely (the 10-30x parentless cost: `attribute_nodes`
10
+ re-walks per call, `[]` pays the raw path).
11
+
12
+ ## Change
13
+
14
+ - [x] New `Leptris::XML::IterationScope` (autoloaded): the owning
15
+ context for yielded elements — carries the iterator handle
16
+ as its lifetime anchor (`#c_ptr` goes nil on `#free`, the
17
+ same signal `ensure_alive!` reads), a `wrapper_cache`
18
+ (identity for a subtree's wrappers), and a `version` that
19
+ advances per yield (stale memos on held wrappers invalidate
20
+ as subtrees are released).
21
+ - [x] `Node#document` answers nil for scope-owned elements — the
22
+ documented contract ("#document returns nil") is preserved
23
+ while the internal machinery engages.
24
+ - [x] `Iterparse#free` frees the scope; the block form's ensure
25
+ chain rides it.
26
+ - [x] Specs: post-iteration use of a held element raises
27
+ `UseAfterFreeError` (was: VM segfault); repeated
28
+ `attribute_nodes` inside the block memoizes; children
29
+ identity holds within a yielded subtree.
@@ -0,0 +1,19 @@
1
+ # 10 — Cold attribute reads: value-only materialization (#150)
2
+
3
+ Status: DONE
4
+
5
+ `Element#[]`'s cold path called `attributes` — building Attr
6
+ wrappers, the name-keyed Attr hash, and the values hash (~17
7
+ allocations for a 2-attribute element) to answer ONE bare-name
8
+ read.
9
+
10
+ ## Change
11
+
12
+ - [x] `Element#materialize_attr_values`: walks the v1.1.0
13
+ attribute linked-list face filling ONLY the versioned
14
+ `@attr_values` memo — no Attr wrappers, no side hashes.
15
+ `[]`'s cold branch uses it; `attributes`/`attribute_nodes`/
16
+ `keys`/`values` keep their richer materialization (they
17
+ answer richer questions).
18
+ - [x] Alloc-count spec: cold `[]` on a 2-attribute element stays
19
+ under a fixed budget (was ~17 allocs).
@@ -0,0 +1,16 @@
1
+ # 11 — Programmatic construction: wrap-fresh factories (#149)
2
+
3
+ Status: DONE (binding-side diet; structural ceiling documented)
4
+
5
+ - [x] `Node.wrap_fresh`: one construction authority shared with
6
+ `wrap` (the type dispatch is not duplicated), skipping the
7
+ cache LOOKUP only — a freshly created pointer cannot be in
8
+ the cache, so the lookup was a guaranteed miss on every
9
+ factory call. Cache STORE still happens (identity survives
10
+ attach).
11
+ - [x] `Document.create_element/create_text_node/create_comment/
12
+ create_cdata` route through it.
13
+ - [x] Measured before/after on the issue's bench shape; the
14
+ remaining gap is per-call FFI marshaling vs a C extension —
15
+ the structural ceiling of the FFI-only architecture,
16
+ documented on the issue.
@@ -0,0 +1,12 @@
1
+ # 12 — #153: merge the ResultAttr wrap (PR #154)
2
+
3
+ Status: DONE
4
+
5
+ - [x] Reviewed the diff: autoloads `ResultAttr` per the rule,
6
+ extends the materialization capture (name+value at result
7
+ lifetime) symmetric to `ResultText`, no constraint
8
+ violations.
9
+ - [x] CI 10/10 — merged ahead of this round's branch so the
10
+ release carries it.
11
+ - [x] Verified `doc.xpath("//item/@id").first.name/.value` on the
12
+ merged build; #153 closed with the verification.
@@ -0,0 +1,9 @@
1
+ # 13 — Spec MECE follow-up for the 1.9.100 specs
2
+
3
+ Status: DONE
4
+
5
+ - [x] `Node#digest` spec moved from `xpath_spec.rb` (it is a Node
6
+ behavior, not a search face) to `seam_and_reads_spec.rb`.
7
+ - [x] `expand_empty` spec moved from `xslt_versions_spec.rb`
8
+ (serialization option, not an XSLT face) to
9
+ `document_spec.rb`.
@@ -0,0 +1,15 @@
1
+ # 14 — Constraint audit refresh over the newest code
2
+
3
+ Status: DONE
4
+
5
+ Scope: the parallel-session commits (cold/warm `[]` diet, SAX
6
+ drain) plus the 1.9.100.x rounds and PR #154.
7
+
8
+ ## Findings
9
+
10
+ - No `send`, no `instance_variable_set/_get`, no
11
+ `require_relative`; autoload set complete (ResultAttr and
12
+ IterationScope both registered in `lib/leptris/xml.rb`).
13
+ - Two new `respond_to?(:read)` duck checks crept in with the
14
+ parallel commits — fixed to the `is_a?(String)` typed branch
15
+ (same policy as 01): `SAX::Recorder.parse` and `Pull.parse`.
@@ -0,0 +1,10 @@
1
+ # 15 — README: document the three newest features
2
+
3
+ Status: DONE
4
+
5
+ - [x] `Node#digest(drop_ws:)` — structural-equality semantics.
6
+ - [x] `Element#to_xml(expand_empty: true)` — libxml2
7
+ `NO_EMPTY_TAGS` parity.
8
+ - [x] Iterparse lifetime: post-iteration use raises
9
+ `UseAfterFreeError` (IterationScope); `#document` stays nil;
10
+ identity holds within a subtree.
@@ -0,0 +1,5 @@
1
+ # 16 — CLAUDE.md map: iteration_scope.rb + result_attr.rb
2
+
3
+ Status: DONE
4
+
5
+ - [x] Both new files in the architecture map with their seams.
@@ -0,0 +1,12 @@
1
+ # 17 — Perf drift record on the current build
2
+
3
+ Status: DONE
4
+
5
+ - [ ] **Blocked on a busy shared host.** The machine has been at
6
+ load 30–180 for days — every battery attempt this week has
7
+ been a load-induced fiction (Nokogiri's own baseline swings
8
+ 3× run-to-run on this box, e.g. 16→27→58 ms for parse). The
9
+ standing baseline from the clean 1.9.76 window still holds:
10
+ parse 10–12×, serialize 2.9–3.2×, inner_html 4–5×, attr reads
11
+ 6–9×, text 9–11×, element_children walks at parity. Will
12
+ re-record on the next clean window (load < 15).
@@ -0,0 +1,12 @@
1
+ # 18 — Audit: spec doubles + gem contents
2
+
3
+ Status: DONE
4
+
5
+ - [x] Spec-double scan: zero RSpec doubles/mocks/`allow(` across
6
+ spec/ (the two grep hits are XPath `xs:double` casts inside
7
+ expectation strings). Real documents throughout, per the
8
+ standing convention.
9
+ - [x] Gem contents: `TODO.restructure/` does NOT ship in the gem
10
+ (verified by building the gem and listing the tar — 0
11
+ entries; the gemspec's `git ls-files` reject block covers
12
+ it).
@@ -0,0 +1,10 @@
1
+ # 19 — .gitignore hygiene for build artifacts
2
+
3
+ Status: DONE
4
+
5
+ - [x] `.rspec_status` (RSpec's example-status artifact) ignored —
6
+ it was showing as untracked noise in every round.
7
+ - [x] `*.gem` was already covered; a stray locally-built gem from
8
+ this round's contents check removed (one I created; the
9
+ unrelated `taurus-0.1.0.gem` predates this repo work and was
10
+ left untouched).
@@ -0,0 +1,34 @@
1
+ # 20 — utf8proc enablement (fn:normalize-unicode in the platform gems)
2
+
3
+ Status: DONE (shipped 1.9.107.2)
4
+
5
+ ## Finding (validated on this machine)
6
+
7
+ - `LEPTRIS_ENABLE_UTF8PROC=ON` builds cleanly (linked against
8
+ homebrew utf8proc 2.11.3) and is what the function needs.
9
+ - **The blocker**: FindUtf8proc links the SYSTEM library with an
10
+ ABSOLUTE install path (`/opt/homebrew/opt/utf8proc/lib/
11
+ libutf8proc.3.dylib`) — a platform gem built this way fails to
12
+ load on any machine without utf8proc at that exact path. The
13
+ platform gems' self-containment would be broken.
14
+
15
+ ## Plan (the properly-engineered greenlight)
16
+
17
+ - [x] Build utf8proc from its release source in `rake compile`
18
+ (same tarball discipline as libleptris), per platform.
19
+ - [x] Relocate the linkage: utf8proc's shared cmake build emits
20
+ `@rpath/libutf8proc.3.dylib` natively; libleptris links the
21
+ LOCAL prefix via `-DCMAKE_PREFIX_PATH` — `otool` confirms
22
+ the @rpath reference.
23
+ - [x] Vendor `libutf8proc.3.{dylib,so}` (SONAME file) beside
24
+ `libleptris.*` in the platform gems; ffi.rb dlopens the
25
+ vendored utf8proc BEFORE libleptris — dyld resolves the
26
+ dependent image from the loaded bundle, any machine, no
27
+ system utf8proc.
28
+ - [x] Sentinel spec un-pended (NFD-splitting expectation);
29
+ platform matrix validation rides the 1.9.107.2 CI legs
30
+ (isolated binding-patch release so a revert is clean).
31
+
32
+ Not landing this half-working: a utf8proc-linked gem that fails to
33
+ load on utf8proc-less machines would be worse than the missing
34
+ function.
@@ -0,0 +1,42 @@
1
+ # 21 — #147 option B: TypedData wrapper variant (owner greenlit)
2
+
3
+ Status: round 2 PASSED (2026-09-14) — branch proto/native-nodes
4
+ is the reference implementation; build phase remains (full read
5
+ surface, seam, 9-platform packaging). Round 1 handle-level spike
6
+ (feat/typed-wrapper-variant, never merged) FAILED its gate — held
7
+ -8% vs walks +40% because per-call FFI::Pointer marshaling
8
+ dominates; the fix was C-binding the reads themselves.
9
+
10
+ The greenlight arrived ("proceed with all"); the execution plan,
11
+ in verification order:
12
+
13
+ - [x] Minimal C ext (one file): a TypedData class holding
14
+ (pointer, document-ref) — prototype built and measured
15
+ (ext + bench preserved under /tmp/typedproto, /tmp/bench_typed.rb).
16
+ - [x] NOTE (2026-09-12): /tmp was wiped — the prototype ext and
17
+ bench under /tmp/typedproto, /tmp/bench_typed.rb are GONE.
18
+ The measurements below stand (recorded here); the build
19
+ phase rebuilds the ext properly under ext/ anyway.
20
+ - [x] Measured (100k holders, GC.settled RSS deltas):
21
+ FFI::Pointer-backed 154 B/node vs TypedData 40 B/node —
22
+ **114 B/node saved**, ~40% of the wrapper layer (#147's
23
+ +552 kB/doc → ~+330 kB/doc, held 1.57x → ~1.2x Nokogiri).
24
+ The mark-function document ref works (no GC-safety loss).
25
+ - [ ] VALIDATED — build phase: the `Node.construct` factory seam
26
+ selects wrapper classes per variant (OCP — one seam, no
27
+ scatter), then the variant gem + 9-platform matrix with the
28
+ compiled ext. Multi-round build (new gemspec, ext
29
+ compilation per platform, packaging).
30
+ - [ ] If NOT validated (win < ~15%): record the negative result on
31
+ #147 and close option B with data.
32
+
33
+ ## Round 2 (2026-09-14): converged native nodes — PASSED
34
+
35
+ ext/leptris/native/native.c: TypedData wrappers (ptr + document
36
+ embedded), C-bound hot reads via dlsym (no FFI marshaling), bulk
37
+ children (one cache round-trip, zero Ruby frames). Measured: walk
38
+ 17.2 -> 2.8 ms (6.2x, interleaved best-of under load ~148 — ratio
39
+ conservative); held wrappers ~54 kB/doc (RSS, load-independent).
40
+ Identity shared through the binding wrapper_cache. Data on #185.
41
+ Remaining: full read surface, variant seam, compiled-ext
42
+ packaging (9-platform), release.
@@ -0,0 +1,28 @@
1
+ # 22 — SAX drain: bulk kind strip (the 0.77-0.93x row)
2
+
3
+ Status: DONE — timing gate PASSED (2026-09-09, load ~14)
4
+
5
+ The moxml table shows SAX at 0.77-0.93x Nokogiri (it fully won at
6
+ the 1.9.40-era battery). Investigation under load-404 conditions
7
+ (timing unusable; allocation profiling is load-independent):
8
+
9
+ - **Allocations are NOT the gap** — measured GC-stat deltas:
10
+ text-only 32,381 vs Nokogiri's 400,061 allocs/parse (12x fewer);
11
+ all-events 112,440 vs 400,034 (3.6x fewer).
12
+ - **The time cost was per-event FFI crossings in the drain**: the
13
+ kind strip was read with a `get_uint8` per EVENT (all events,
14
+ dispatched or not — a memory crossing each) after the
15
+ whole-record-unpack was retired for its 2.25M-Integer profile.
16
+ - **Fix**: the kind strip reads in ONE `get_bytes` + ONE `unpack`
17
+ per drain through the already-defined memoized
18
+ `KIND_TEMPLATE_FOR` — kind codes 0..10 unpack to IMMEDIATE
19
+ Fixnums (zero allocation), so this strictly removes ~count FFI
20
+ crossings per drain with no new allocations. Per-dispatched-event
21
+ field reads (2-3 `get_uint32`) remain.
22
+
23
+ - [x] Bulk strip restored; SAX suite green; allocation profile
24
+ re-verified (32k/112k vs 400k/400k).
25
+ - [x] **Timing verification PASSED** (load ~14 window, min-of-7):
26
+ text-only 16.1 vs 169.3 ms = **10.51x faster**; all-events
27
+ 146.9 vs 188.6 ms = **1.28x faster**. The moxml 0.77-0.93x
28
+ row is fully inverted on the raw binding.