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
data/Rakefile ADDED
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
+ # with .github/workflows/build.yml (which calls `rake compile`) and the
10
+ # CHANGELOG when libleptris releases.
11
+ LIBLEPTRIS_VERSION = "1.9.199"
12
+ # Vendored alongside libleptris for fn:normalize-unicode (TODO
13
+ # .restructure/20): built per platform with a RELOCATABLE @rpath
14
+ # install name, loaded by ffi.rb before libleptris so the
15
+ # dependent image resolves inside the gem.
16
+ UTF8PROC_VERSION = "2.11.0"
17
+
18
+
19
+ CMAKE_FLAGS = %w[
20
+ -DCMAKE_BUILD_TYPE=Release
21
+ -DLEPTRIS_BUILD_SHARED=ON
22
+ -DLEPTRIS_BUILD_STATIC=OFF
23
+ -DBUILD_TESTING=OFF
24
+ -DLEPTRIS_BUILD_CLI=OFF
25
+ -DLEPTRIS_BUILD_BENCHMARKS=OFF
26
+ -DLEPTRIS_BUILD_MAN_PAGES=OFF
27
+ -DLEPTRIS_ENABLE_UTF8PROC=OFF
28
+ -DLEPTRIS_ENABLE_ICONV=OFF
29
+ ].freeze
30
+
31
+ require "tmpdir"
32
+
33
+ desc "Build libleptris #{LIBLEPTRIS_VERSION} (+ utf8proc) from release tarballs into lib/"
34
+ task :compile do
35
+ # GitHub tarball downloads intermittently return a non-gzip body
36
+ # (rate-limit/redirect HTML) — the piped curl|tar form then fails
37
+ # with "not in gzip format" and takes the leg down. Download to a
38
+ # file with retries, validate the gzip magic, extract from the
39
+ # file.
40
+ # Whole-cycle retry: a truncated body can still start with the
41
+ # gzip magic (Windows runners) and only fail at tar EOF — refetch
42
+ # on any failure in the chain rather than trusting curl's exit
43
+ # code alone.
44
+ fetch_tarball = lambda do |url, dest_dir|
45
+ attempts = 0
46
+ begin
47
+ attempts += 1
48
+ tgz = File.join(Dir.tmpdir, "leptris-#{Time.now.to_i}-#{rand(1e9)}.tgz")
49
+ begin
50
+ sh "curl -sfL --retry 4 --retry-delay 2 --retry-all-errors -o #{tgz} #{url}"
51
+ magic = File.binread(tgz, 2)
52
+ unless magic.bytes == [0x1f, 0x8b]
53
+ raise "downloaded #{url} is not gzip (got #{magic.bytes.inspect})"
54
+ end
55
+ # GNU tar (Git-bundled, Windows) parses D:/... as a
56
+ # REMOTE host spec ("Cannot connect to D:") — the piped
57
+ # form never had a file argument, so this only surfaces
58
+ # with -o. --force-local is GNU tar; bsdtar (macOS) and
59
+ # plain GNU tar (linux) don't need it but tolerate being
60
+ # skipped.
61
+ local_flag = Gem.win_platform? ? " --force-local" : ""
62
+ sh "tar#{local_flag} -xzf #{tgz} -C #{dest_dir} --strip-components=1"
63
+ ensure
64
+ File.delete(tgz) if File.exist?(tgz)
65
+ end
66
+ rescue StandardError => e
67
+ raise if attempts >= 3
68
+ warn "tarball fetch attempt #{attempts} failed (#{e.message}); retrying"
69
+ retry
70
+ end
71
+ end
72
+
73
+ version = ENV.fetch("LIBLEPTRIS_VERSION", LIBLEPTRIS_VERSION)
74
+ build = File.expand_path("tmp/libleptris-#{version}", __dir__)
75
+ rm_rf(build)
76
+ mkdir_p(build)
77
+ fetch_tarball.call(
78
+ "https://api.github.com/repos/leptris/leptris/tarball/v#{version}", build)
79
+
80
+ # NOTE: 32-bit ARM (armv7) is an ENGINE port task — the round-19
81
+ # ABI asserts pin layouts to 8-byte-pointer sizes and correctly
82
+ # abort the build on 32-bit. Filed upstream as leptris/leptris
83
+ # #1174; the release legs stay experimental (continue-on-error)
84
+ # and flip green automatically when the port ships. We patch
85
+ # nothing here — the emulated-leg smoke validates whatever the
86
+ # engine provides.
87
+
88
+ # utf8proc: shared build only, @rpath install name, local prefix.
89
+ u8_dir = File.expand_path("tmp/utf8proc-#{UTF8PROC_VERSION}", __dir__)
90
+ u8_prefix = File.join(u8_dir, "prefix")
91
+ rm_rf(u8_dir)
92
+ mkdir_p(u8_dir)
93
+ fetch_tarball.call(
94
+ "https://github.com/JuliaStrings/utf8proc/releases/download/v#{UTF8PROC_VERSION}/utf8proc-#{UTF8PROC_VERSION}.tar.gz",
95
+ u8_dir)
96
+ sh "cmake -B #{u8_dir}/build -S #{u8_dir} -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DUTF8PROC_ENABLE_TESTING=OFF"
97
+ sh "cmake --build #{u8_dir}/build --config Release -j 4"
98
+ sh "cmake --install #{u8_dir}/build --prefix #{u8_prefix}"
99
+
100
+ # libleptris 1.9.18's xslt_functions.c:270 assigns LeptrisElement
101
+ # to LeptrisNodeRef — GCC 14 (Alpine/musl) makes incompatible
102
+ # pointer types an error by default and the musl platform gems
103
+ # fail to build; clang toolchains only warn. Downgrade to warning
104
+ # until the upstream fix (leptris/leptris#640).
105
+ # No quoting: the flag carries no spaces, and single quotes are
106
+ # literal on Windows shells (they broke the MSVC build).
107
+ # GCC-family only — MSVC's cl rejects the flag outright (D8021).
108
+ cflags = Gem.win_platform? ? "" : "-Wno-error=incompatible-pointer-types"
109
+ # macOS floor for the VENDORED dylibs: without this, cmake uses
110
+ # the building machine's SDK default (a macos-latest runner
111
+ # shipped minos 26.0) and the dylib refuses to load on user
112
+ # machines older than the runner — the extconf.rb 11.0 pin only
113
+ # ever covered the native bundle. CMake initializes
114
+ # CMAKE_OSX_DEPLOYMENT_TARGET from this env var at first
115
+ # configure (a plain -D lands UNINITIALIZED and is ignored).
116
+ # 11.0 = the oldest macOS GitHub still hosts runners for;
117
+ # matches extconf.rb.
118
+ ENV["MACOSX_DEPLOYMENT_TARGET"] ||= "11.0" if RUBY_PLATFORM =~ /darwin/
119
+ cmake_base =
120
+ "#{CMAKE_FLAGS.join(' ').sub('-DLEPTRIS_ENABLE_UTF8PROC=OFF', '-DLEPTRIS_ENABLE_UTF8PROC=ON')} " \
121
+ "-DCMAKE_PREFIX_PATH=#{u8_prefix} #{cflags.empty? ? '' : "-DCMAKE_C_FLAGS=#{cflags}"}"
122
+
123
+ # Two-stage PGO (engine CMake: LEPTRIS_ENABLE_PGO GENERATE/USE;
124
+ # measured ~20% CPU on the DOM parse path, v1.9.188 worktree A/B,
125
+ # every PGO round beating every baseline round). Train via the CLI
126
+ # over the tarball's XML corpus. Windows keeps the plain build —
127
+ # MSVC PGO is inherently LTCG and these gems link the DLL as-is.
128
+ # LEPTRIS_PGO=0 escapes to the single-stage build.
129
+ pgo = ENV["LEPTRIS_PGO"] != "0" && !Gem.win_platform?
130
+ if pgo
131
+ # The trainer is disposable infrastructure — an engine-side
132
+ # trainer break (e.g. leptris/leptris#1204: GCC LTO internalizes
133
+ # CLI-only API symbols and the CLI link dies) must degrade the
134
+ # gem to the plain build (loudly, with the issue pointer), not
135
+ # block every platform's release.
136
+ begin
137
+ # _GNU_SOURCE for the TRAIN build only: the CLI (the trainer, not
138
+ # built in normal gems) uses fileno, which musl hides without a
139
+ # POSIX feature macro (glibc is lenient). Upstream fix pending in
140
+ # the C repo; this stays on the throwaway instrumented build.
141
+ # _GNU_SOURCE is MUSL-ONLY: the CLI trainer's fileno needs a POSIX
142
+ # feature macro under musl. On glibc the macro reshuffles the
143
+ # header graph and breaks arena.c's accidental (missing-stdint.h)
144
+ # uintptr_t. -include stdint.h: under _GNU_SOURCE musl's header
145
+ # graph ALSO stops feeding stdint.h to arena.c (leptris/leptris
146
+ # #1166) — force the include on the throwaway trainer build;
147
+ # the shipped library keeps the plain flags. Single-quote: the
148
+ # value carries a space (an unquoted split hands cmake a bare
149
+ # -D_GNU_SOURCE); non-Windows branch, so sh quoting is safe.
150
+ gnu = RUBY_PLATFORM =~ /musl/ ? " -D_GNU_SOURCE -include stdint.h" : ""
151
+ pgo_cflags = "#{cflags}#{gnu}".strip
152
+ pgo_base =
153
+ if pgo_cflags == cflags
154
+ cmake_base
155
+ else
156
+ cmake_base.sub("-DCMAKE_C_FLAGS=#{cflags}",
157
+ "-DCMAKE_C_FLAGS='#{pgo_cflags}'")
158
+ end
159
+ sh "cmake -B #{build}/build -S #{build} #{pgo_base} " \
160
+ "-DLEPTRIS_BUILD_CLI=ON -DLEPTRIS_ENABLE_PGO=GENERATE"
161
+ sh "cmake --build #{build}/build --config Release -j 4"
162
+ cli = File.join(build, "build", "cli", "leptris")
163
+ pgo_dir = File.join(build, "build", "pgo-data")
164
+ corpus = Dir[File.join(build, "benchmarks", "data", "*.xml")]
165
+ corpus.each do |xml|
166
+ # a corpus file can be deliberately malformed; training tolerates it
167
+ system({ "LLVM_PROFILE_FILE" => File.join(pgo_dir, "%p.profraw") },
168
+ cli, "parse", xml, out: File::NULL, err: File::NULL)
169
+ end
170
+ if RUBY_PLATFORM =~ /darwin/
171
+ sh "xcrun llvm-profdata merge -output=#{File.join(pgo_dir, 'default.profdata')} " \
172
+ "#{File.join(pgo_dir, '*.profraw')}"
173
+ end
174
+ sh "cmake -B #{build}/build -S #{build} #{cmake_base} " \
175
+ "-DLEPTRIS_BUILD_CLI=OFF -DLEPTRIS_ENABLE_PGO=USE"
176
+ sh "cmake --build #{build}/build --config Release -j 4"
177
+ rescue => e
178
+ warn "leptris: PGO trainer failed (#{e.message.to_s[0, 160]}); " \
179
+ "shipping the single-stage build — the parse path loses the " \
180
+ "~20% PGO gain this release (leptris/leptris#1204 tracks the " \
181
+ "engine-side trainer break)"
182
+ rm_rf(File.join(build, "build"))
183
+ sh "cmake -B #{build}/build -S #{build} #{cmake_base}"
184
+ sh "cmake --build #{build}/build --config Release -j 4"
185
+ end
186
+ else
187
+ sh "cmake -B #{build}/build -S #{build} #{cmake_base}"
188
+ sh "cmake --build #{build}/build --config Release -j 4"
189
+ end
190
+ # Windows names the shared library leptris.dll (no "lib" prefix);
191
+ # vendoring under the uniform libleptris.* name keeps the FFI
192
+ # search order simple. libleptris >= 1.3.0 renames the DLL to
193
+ # libleptris.dll on Windows too (leptris/leptris#507, issue
194
+ # TODO.concurrency/05) — accept both names.
195
+ lib = Dir.glob("#{build}/build/**/libleptris.{dylib,so,dll}").first ||
196
+ Dir.glob("#{build}/build/**/leptris.dll").first
197
+ raise "libleptris shared library not found after build" unless lib
198
+ ext = File.extname(lib)
199
+ cp(lib, "lib/libleptris#{ext}")
200
+ # The SONAME file is what @rpath references — .3.dylib on
201
+ # macOS, .so.3 on Linux (cmake installs the symlink chain;
202
+ # copying the symlink copies the target), plain .dll on
203
+ # Windows.
204
+ u8_lib = Dir.glob("#{u8_prefix}/lib/libutf8proc.3.dylib").first ||
205
+ Dir.glob("#{u8_prefix}/lib/libutf8proc.so.3").first ||
206
+ Dir.glob("#{u8_prefix}/lib/libutf8proc.so").first ||
207
+ Dir.glob("#{u8_prefix}/{lib,bin}/utf8proc.dll").first
208
+ raise "utf8proc shared library not found after build" unless u8_lib
209
+ cp(u8_lib, "lib/#{File.basename(u8_lib)}")
210
+ puts "Vendored #{File.basename(lib)} + #{File.basename(u8_lib)} into lib/"
211
+
212
+ # Opt-in native read layer ext (#185): built and vendored beside
213
+ # the library — no compile at install; resolves libleptris at
214
+ # require time via dlsym.
215
+ if Gem.win_platform?
216
+ # #207: a PE DLL cannot leave Ruby imports unresolved — it
217
+ # binds the build Ruby's x64-ucrt-rubyNNN.dll. The Windows
218
+ # gems therefore ship one DLL per supported Ruby minor
219
+ # (native-<minor>.so), and native_layer picks by RUBY_VERSION
220
+ # (#227). Local compile builds just the current minor's DLL;
221
+ # the release workflow runs the build script under 3.3/3.4/
222
+ # 4.0 to produce the full set.
223
+ sh "#{RbConfig.ruby} ext/build_windows_native.rb"
224
+ else
225
+ ext_dir = "ext/leptris/native"
226
+ Dir.chdir(ext_dir) do
227
+ sh "ruby extconf.rb"
228
+ if RUBY_PLATFORM =~ /darwin/
229
+ # Link the bundle ourselves: setup-ruby's custom rubies
230
+ # make mkmf link libruby by absolute runner path no matter
231
+ # which RbConfig entries are cleared, and any libruby
232
+ # LC_LOAD_DYLIB is unresolvable on user machines. Compile
233
+ # via the Makefile, link with pure -undefined
234
+ # dynamic_lookup (Ruby symbols resolve from the loading
235
+ # interpreter) and a conservative deployment target.
236
+ sh "make native.o"
237
+ sh "cc -dynamic -bundle -undefined dynamic_lookup " \
238
+ "-mmacosx-version-min=#{ENV['MACOSX_DEPLOYMENT_TARGET'] || '11.0'} " \
239
+ "-o native.bundle native.o"
240
+ else
241
+ # #207: the darwin contract on Linux too — mkmf's `make`
242
+ # linked libruby.so.3.3 (plus a runner runpath), which
243
+ # failed to load on 3.4/4.0. Link WITHOUT libruby: one
244
+ # .so serves every Ruby minor; rb_* resolve from the
245
+ # loading interpreter at dlopen (static rubies export
246
+ # their symbols via -rdynamic, like any extension .so).
247
+ sh "make native.o"
248
+ sh "#{RbConfig::CONFIG['CC']} -shared -fPIC -o native.so native.o"
249
+ # Guard the contract in the build log.
250
+ sh "if strings native.so | grep -q libruby; then " \
251
+ "echo 'ERROR: native.so links libruby (#207)'; exit 1; fi"
252
+ end
253
+ end
254
+ bundle = Dir.glob("#{ext_dir}/native.{bundle,so,dll}").first
255
+ raise "native layer bundle not found after build" unless bundle
256
+ if RUBY_PLATFORM =~ /darwin/
257
+ # Build-log proof of the linkage contract: libSystem only.
258
+ puts `otool -L #{bundle}`
259
+ end
260
+ # Atomic replace: overwriting a bundle another process still
261
+ # has mapped gets that loader SIGKILLed (CODESIGNING Invalid
262
+ # Page) — cp to a temp name, then mv.
263
+ dest = "lib/leptris/xml/#{File.basename(bundle)}"
264
+ cp(bundle, "#{dest}.new")
265
+ mv("#{dest}.new", dest)
266
+ puts "Vendored native layer (#{File.basename(bundle)}) into lib/leptris/xml/"
267
+ end
268
+ end
269
+
270
+ task spec: :compile unless ENV.key?("LEPTRIS_LIB_PATH")
271
+
272
+ task default: :spec
273
+
274
+ # Lockstep drift detector (ADR 0001): ffi.rb mirrors the public
275
+ # header, so attached == exported must hold on the vendored library.
276
+ # Run after `rake compile` and before any lockstep release.
277
+ namespace :audit do
278
+ desc "Fail when ffi.rb attachments and library exports drift"
279
+ task :symbols do
280
+ # nm cannot read MSVC PE export tables (every symbol reports as
281
+ # unexported -> guaranteed false drift), so Windows skips: the
282
+ # darwin/linux CI legs enforce the mirror — any one platform's
283
+ # build of the same C surface suffices.
284
+ if Gem.win_platform?
285
+ puts "audit:symbols: skipped on Windows (PE export tables); " \
286
+ "darwin/linux legs enforce the mirror"
287
+ next
288
+ end
289
+ # Probe nm without a shell: the multi-arg system() execs
290
+ # directly, so there is no which/redirect syntax to be
291
+ # platform-hostile. File::NULL is NUL where it must be; a
292
+ # missing binary is falsy either way (ENOENT rescue belts the
293
+ # older spawn behavior).
294
+ unless nm_available?
295
+ puts "audit:symbols: skipped (nm unavailable on this platform)"
296
+ next
297
+ end
298
+ lib = Dir.glob("lib/libleptris.{dylib,so,dll}").first
299
+ unless lib
300
+ abort "audit:symbols: vendored library not found — run rake compile"
301
+ end
302
+ exported = `nm -gU #{lib}`
303
+ .lines.map { |l| l.split[2] }.compact
304
+ .map { |n| n.sub(/\A_/, "") }
305
+ .select { |n| n.start_with?("leptris_") }
306
+ .map { |n| n.sub(/\Aleptris_/, "") }.sort
307
+ attached = File.read("lib/leptris/xml/ffi.rb")
308
+ .scan(/attach_function :leptris_([a-z_0-9]+)/).flatten.sort
309
+ unattached = exported - attached
310
+ unexported = attached - exported
311
+ unless unattached.empty?
312
+ puts "exported but NOT attached (upstream surface drift):"
313
+ unattached.each { |s| puts " leptris_#{s}" }
314
+ end
315
+ unless unexported.empty?
316
+ puts "attached but NOT exported (stale attachment):"
317
+ unexported.each { |s| puts " leptris_#{s}" }
318
+ end
319
+ if unattached.empty? && unexported.empty?
320
+ puts "audit:symbols: #{attached.length}/#{exported.length} symbols in lockstep"
321
+ else
322
+ abort "audit:symbols: drift detected"
323
+ end
324
+ end
325
+
326
+ # @api private
327
+ def nm_available?
328
+ system("nm", "--version", out: File::NULL, err: File::NULL)
329
+ rescue Errno::ENOENT
330
+ false
331
+ end
332
+ end
333
+
334
+ require "rubygems/package_task"
335
+
336
+ desc "Build the pure-Ruby gem"
337
+ task "gem:native:any" do
338
+ sh "rake platform:any gem"
339
+ end
340
+
341
+ desc "Define the gem task to build the ruby-platform gem"
342
+ task "platform:any" do
343
+ spec = Gem::Specification::load("leptris.gemspec").dup
344
+ # Zero-setup TruffleRuby/JRuby (#160): per-OS binaries staged
345
+ # under lib/leptris/vendor/<platform>/ by the release assembly
346
+ # (or by hand: cp from each platform build). Absent on a plain
347
+ # checkout — the variant then carries no binaries and the
348
+ # system-library / LEPTRIS_LIB_PATH path applies as before.
349
+ spec.files += Dir.glob("lib/leptris/vendor/**/*")
350
+ task = Gem::PackageTask.new(spec)
351
+ task.define
352
+ end
353
+
354
+ platforms = [
355
+ "x64-mingw32",
356
+ "x64-mingw-ucrt",
357
+ "aarch64-mingw-ucrt",
358
+ "x86_64-linux",
359
+ "x86_64-linux-musl",
360
+ "aarch64-linux",
361
+ "aarch64-linux-musl",
362
+ "x86_64-darwin",
363
+ "arm64-darwin",
364
+ "arm-linux",
365
+ "arm-linux-musl",
366
+ "ppc64le-linux",
367
+ "s390x-linux-musl",
368
+ ]
369
+
370
+ platforms.each do |platform|
371
+ desc "Build pre-compiled gem for the #{platform} platform"
372
+ task "gem:native:#{platform}" do
373
+ sh "rake compile platform:#{platform} gem"
374
+ end
375
+
376
+ desc "Define the gem task to build on the #{platform} platform (binary gem)"
377
+ task "platform:#{platform}" do
378
+ spec = Gem::Specification::load("leptris.gemspec").dup
379
+ spec.platform = Gem::Platform.new(platform)
380
+ spec.files += Dir.glob("lib/libleptris.{dll,so,dylib}")
381
+ spec.files += Dir.glob("lib/{libutf8proc.3.dylib,libutf8proc.so.3,libutf8proc.so,utf8proc.dll}")
382
+ if platform.include?("mingw")
383
+ # #207/#227: per-Ruby-minor DLLs (PE must bind its Ruby).
384
+ # DOT-FREE names — MRI derives the init symbol from the
385
+ # basename cut at the first dot (native_3_3.so ->
386
+ # Init_native_3_3; the dotted form made Ruby look for
387
+ # "Init_native-3", which cannot exist).
388
+ spec.files += Dir.glob("lib/leptris/xml/native_*.so")
389
+ else
390
+ spec.files += Dir.glob("lib/leptris/xml/native.{bundle,so,dll}")
391
+ end
392
+ task = Gem::PackageTask.new(spec)
393
+ task.define
394
+ end
395
+ end
396
+
397
+ require "rake/clean"
398
+
399
+ CLOBBER.include("pkg")
400
+ CLEAN.include("tmp",
401
+ "lib/libleptris.dll",
402
+ "lib/libleptris.dylib",
403
+ "lib/libleptris.so",
404
+ "lib/libutf8proc.3.dylib",
405
+ "lib/libutf8proc.so.3",
406
+ "lib/libutf8proc.so",
407
+ "lib/utf8proc.dll")
@@ -0,0 +1,28 @@
1
+ # 01 — Ext-accelerated default reads (no FFI marshaling on hot paths)
2
+
3
+ Status: SHIPPED (1.9.162.9) — name/content/prefix/[]-cold-path ride
4
+ the ext when the bundle is loaded. Per-read 276->84ns (3.3x,
5
+ pre-address) / attr 373->199ns. HONEST GATE FINDING: warm loops
6
+ cannot see it (memos serve after first touch) — the gate is the
7
+ COLD one-shot pass (fresh doc, one read per node): 13-25% on the
8
+ #187 shape. UTF-8 encoding via rb_utf8_str_new_cstr (suite specs
9
+ run both paths in one process: native_layer_spec sets the flag
10
+ mid-suite). COMPLETE (1.9.163.0): bulk children + bulk element_children for
11
+ binding wrappers (one C pass: class dispatch + ivars + identity
12
+ cache) — cold one-shot walk 9.2->1.2ms (7.8x, element_children +
13
+ name/[]/content). Both TODO items done.
14
+
15
+ The default binding's hot reads each pay FFI per-call marshaling
16
+ (:string returns allocate through FFI, args marshal through the
17
+ boundary — ~200-500ns vs ~50ns for a direct ext call). The native
18
+ bundle already dlsyms every reader; add address-based C functions
19
+ the DEFAULT binding classes call when the bundle is loaded:
20
+
21
+ - Element#name / #content / #[]
22
+ - Node#children / #element_children (bulk: one C pass allocating
23
+ binding wrappers — the bulk_wrap shape, 1.27-1.30x measured)
24
+ - Element#expanded_name (three out-params today)
25
+
26
+ Gate: suite green with AND without the bundle; drift-record rows
27
+ (attr reads 1.88x, text 6.18x) must improve; cold-walk gate must
28
+ not regress (cold-walk-regression-gate memory).
@@ -0,0 +1,19 @@
1
+ # 02 — Auto-enable the native layer when the bundle is present
2
+
3
+ Status: SHIPPED (1.9.162.9) — require "leptris" quietly
4
+ probes for the native bundle and activates when present.
5
+ LEPTRIS_NO_NATIVE=1 forces the FFI path (suite exercises both).
6
+ The explicit native_layer require remains the parallel-API entry
7
+ for NativeNode builders.
8
+
9
+ Today `require "leptris/xml/native_layer"` is opt-in. End-state:
10
+ `require "leptris"` quietly probes for the vendored native.bundle
11
+ and, when present, activates the acceleration (01 makes the
12
+ default classes benefit). Silent fallback to FFI when absent
13
+ (ruby-variant on TruffleRuby/JRuby, custom builds). The explicit
14
+ native_layer require remains the parallel-API entry
15
+ (NativeNode builders).
16
+
17
+ Requires: 01 (so auto-enable accelerates the DEFAULT API, not a
18
+ parallel one), and a divergence test — same specs pass in both
19
+ modes (the suite already runs both; make it explicit).
@@ -0,0 +1,18 @@
1
+ # 03 — Bulk XPath result materialization
2
+
3
+ Status: SHIPPED (1.9.163.0) — one C pass (kind dispatch incl.
4
+ synthetic text/attr value capture, node-type consult for real
5
+ Text/CDATA under XPATH text, identity cache). HONEST FINDING: the
6
+ xpath one-shot is parse-dominated; the materialization slice gains
7
+ ~1.4x. >512-entry results and rare kinds fall back to the per-index
8
+ path. No warm-path change.
9
+
10
+ NodeSet#fetch materializes results per node (wrap per item +
11
+ ResultText/ResultAttr captures). The #185 profile counted
12
+ NodeSet#initialize at 2.8% and Class#new 11.7% of walk wall —
13
+ result materialization shares that class of cost. Batch via the
14
+ ext: one C pass over leptris_xpath_result_get_node(i) building
15
+ the Ruby array with kinds, capturing text values in C.
16
+
17
+ Gate: xpath-heavy specs unchanged; xpath row (28,878 ips small)
18
+ improves on the battery.
@@ -0,0 +1,15 @@
1
+ # 04 — Serialization buffer cycle in C
2
+
3
+ Status: SHIPPED (1.9.163.1) — the sized-buffer cycle runs in C
4
+ (probe largest owned buffer first: single serialization for
5
+ big docs; grow-only scratch under the GVL). inner_html 84->50ms
6
+ (1.7x per-call batch); doc serialize at parity with the FFI seam
7
+ (both single-call); encoding: paths keep the FFI options path.
8
+
9
+ FFI.serialize_into_string does the two-call sized protocol
10
+ (needed probe, then copy) with Ruby-side buffer management per
11
+ to_xml call. Bind the cycle in the ext: one C function taking the
12
+ ffi_function pointer + node address, returning a Ruby String.
13
+
14
+ Gate: serialize row (3.24x baseline) improves; c14n specs
15
+ byte-identical.
@@ -0,0 +1,19 @@
1
+ # 05 — Complete the NativeNode read surface
2
+
3
+ Status: SHIPPED (1.9.163.2) — #attributes (one C walk, string
4
+ values — deliberately the flat [] shape, not the binding's
5
+ Attr-valued hash), #line, #byte_offset with binding parity. Also:
6
+ binding classes in the ext resolve LAZILY (Init may run before
7
+ autoload loads Element — the standalone native_layer require
8
+ crashed before; fixed) and native_layer requires ffi first.
9
+ Remaining cold: writes stay FFI by design; attribute_nodes/
10
+ namespaces deferred until a consumer asks.
11
+
12
+ Missing on NativeNode vs Element: #attributes (bulk attr hash),
13
+ #attribute_nodes, #namespaces / #prefix, #line / #byte_offset,
14
+ #inner_html / #to_xml (delegate to binding serialization), write
15
+ faces beyond add_child (set_attribute, set_text, remove). Reads
16
+ first; writes stay FFI (cold) unless measured otherwise.
17
+
18
+ Gate: native_layer specs mirror the binding feature specs;
19
+ descriptor_walk parity where applicable.
@@ -0,0 +1,16 @@
1
+ # 06 — Native-layer legs in the perf battery + drift record
2
+
3
+ Status: SHIPPED (1.9.163.2) — benchmark/native_vs_binding.rb runs
4
+ the walk/read/serialize rows through both surfaces under the
5
+ TODO.restructure/17 load gate. Honest findings recorded: warm rows
6
+ favor the binding's memoization (attr 0.08 vs 0.30 ms; the native
7
+ API pays per-call dispatch with no memo layer); cold one-shot
8
+ favors the native API (1.81 vs 2.39 ms — on TOP of the binding's
9
+ own bulk/fast acceleration); serialize at parity.
10
+
11
+ benchmark/ gains native-layer legs (walk/build/attr rows via
12
+ NativeNode beside the binding rows) so the drift record tracks
13
+ both surfaces; TODO.restructure/17's record gains a native table.
14
+
15
+ Gate: one command (rake benchmark:all or the two scripts) emits
16
+ both tables; load-gated per the shared-machine discipline.
@@ -0,0 +1,31 @@
1
+ # 07 — Native read floor: shave the TypedData []/content dispatch (#204 ask 1)
2
+
3
+ Status: DONE (1.9.163.5)
4
+
5
+ moxml's 2x mandate: `NativeNode#[]` at 110ns needs <= ~70ns (their
6
+ wrapper keeps ~24ns); `#content` 85ns needs <= 58ns. The budget
7
+ dies in the C entry itself: TypedData fetch + StringValueCStr's
8
+ embedded-NUL scan (memchr over the name) + rb_utf8_str_new_cstr
9
+ (strlen + string mint). The string mint (~50ns) is irreducible
10
+ (the C string must be copied into a Ruby String) — the shaves:
11
+
12
+ - StringValueCStr -> StringValue + RSTRING_PTR for wire names we
13
+ control the mint of (attribute names cannot contain NULs);
14
+ - single type-check fetch (RTYPEDDATA_DATA + one pointer compare
15
+ against nn_type instead of the generic check);
16
+ - measure honestly: report how close to 70/58 each landed.
17
+
18
+ ## Outcome (1.9.163.5)
19
+
20
+ Repeat reads no longer re-enter the C reader at all: NativeNode
21
+ keeps a version-stamped single-slot memo (struct fields, pointer
22
+ compare against the document's Fixnum version) plus a hash-memo
23
+ second layer for other repeat names; content memoizes in one
24
+ slot. `StringValueCStr` -> `StringValue`+`RSTRING_PTR` landed for
25
+ attribute names. Measured (shared host, load 12-19, so treat as
26
+ upper bounds; #204's table was load <6): `#content` 85 -> 42-55ns
27
+ (budget 58 met), `#[]` 110 -> ~140ns under load — the remaining
28
+ residue is the argument mint + TypedData fetch; a clean-load run
29
+ of benchmark/native_vs_binding.rb's gap rows is the honest gate.
30
+ `#name` 78 -> ~63-73ns. One-shot reads still pay the string mint
31
+ (irreducible: the C string must be copied).
@@ -0,0 +1,24 @@
1
+ # 08 — Version-coherent native mutations (#204 ask 2)
2
+
3
+ Status: DONE (1.9.163.5)
4
+
5
+ The native builder factories (create_element/text/child, add_child,
6
+ set_root) do not advance the binding Document's mutation version —
7
+ a binding children read after a native attach serves the STALE
8
+ memo. Fix in the ext: every mutating entry calls the document's
9
+ advance_version (cached ID) after the C mutation, and the readonly
10
+ gate before it. Mutations are cold relative to reads, so the two
11
+ rb_funcall hops are fine. Gate: build via native factories, read
12
+ via binding children/element_children — lists must agree.
13
+
14
+ ## Outcome (1.9.163.5)
15
+
16
+ document_advance_version moved from two rb_funcall hops to direct
17
+ ivar reads/writes (@readonly Qtrue check, @version Fixnum bump) —
18
+ no method dispatch on the mutation path. Every NativeNode
19
+ mutating entry (create_child, append_child/add_child, set_root)
20
+ and the new C-bound binding faces (append_binding_child,
21
+ set_binding_attribute) run the same gate: UseAfterFreeError via
22
+ the nil @c_address twin, ReadOnlyError via @readonly, then the
23
+ bump. Gate verified: native append -> binding children/content
24
+ serve fresh values; readonly raises through every native entry.
@@ -0,0 +1,38 @@
1
+ # 09 — Adoption-lift fast path + binding create via C (#204 ask 3, #149 continuation)
2
+
3
+ Status: DONE (1.9.163.5)
4
+
5
+ Binding create x2 + attach measured 4.1us vs Nokogiri 1.5us. The
6
+ #178/#208 namespace lift + pruning runs on EVERY attach and costs
7
+ several FFI round-trips (own definitions, prefix read, in-scope
8
+ walk) even when it is a provable no-op. Add a one-dispatch C
9
+ predicate `Native.ns_lift_needed?(addr)`: false when the node
10
+ carries no namespace declarations AND its name has no prefix —
11
+ the overwhelmingly common programmatic-build shape. add_child and
12
+ friends skip the lift entirely on false. Also route binding
13
+ create_element/create_text_node through the ext (one C call +
14
+ binding-wrapper construction in C — no FFI marshaling) when
15
+ auto-enabled.
16
+
17
+ ## Outcome (1.9.163.5)
18
+
19
+ Three layers landed:
20
+ 1. `Element.skip_adoption_lift?` — non-elements skip outright
21
+ (lift no-ops them anyway); elements consult
22
+ `Native.ns_lift_needed?` (two C calls: own-declaration count +
23
+ resolved URI). Hoisted ABOVE the target's `namespaces`
24
+ materialization at all five mutation sites (add_child,
25
+ prepend_child, both sibling inserts, root=) — a fresh parent
26
+ no longer pays the in-scope collection FFI round-trips per
27
+ attach. Measured effect: add_child on a fresh parent
28
+ 1678 -> 506ns.
29
+ 2. `Native.create_binding_element/text` build the binding wrapper
30
+ in C (one C call + wrapper + identity-cache store; no FFI
31
+ marshaling). Document.create/create_element/create_text_node
32
+ route through them when auto-enabled.
33
+ 3. `Native.append_binding_child` — gates + predicate + version
34
+ bump + engine append in ONE dispatch (Qnil = child needs the
35
+ lift, Ruby falls back). Element#add_child's node branch uses
36
+ it; namespaced children still lift (spec-pinned). The #204
37
+ ask-3 shape (create x2 + attach) went 4.1us -> ~1.2us measured
38
+ under host load 12-19 — at or past the Nokogiri row (1.5us).
@@ -0,0 +1,20 @@
1
+ # 10 — Bulk attribute materialization for the binding
2
+
3
+ Status: DONE (1.9.163.5)
4
+
5
+ Element#attributes / #each_attribute / #keys / #values walk the
6
+ attribute list with one FFI call per attribute (name + value per
7
+ row). One C pass materializes the {name => value} hash and the
8
+ key/value arrays in a single walk. Gate: attribute-heavy cold
9
+ reads improve; #attributes semantics unchanged (memoized, version
10
+ invalidated).
11
+
12
+ ## Outcome (1.9.163.5)
13
+
14
+ `Native.bulk_attr_faces` builds {name => Attr} + the values hash
15
+ in one C walk (Attr value objects: name/value/element ivars,
16
+ c_handle left nil); Element#attributes rides it when
17
+ auto-enabled and memo-stamps both hashes. Measured (load 12-19):
18
+ Element#attributes on a 5-attr element ~134-151ns vs Nokogiri
19
+ ~1107-1165ns (~8x). The values-only shape
20
+ (`Native.bulk_attributes`) backs the legacy path.
@@ -0,0 +1,18 @@
1
+ # 11 — The #204 gap table as a battery leg
2
+
3
+ Status: DONE (1.9.163.5)
4
+
5
+ Reproduce moxml's gap table rows locally (parse 30KB, to_xml,
6
+ [] / content / name reads, set_attribute, create x2 + attach,
7
+ typed walk) in benchmark/native_vs_binding.rb so every perf round
8
+ tracks the exact rows the 2x mandate measures — both surfaces,
9
+ interleaved, load-gated.
10
+
11
+ ## Outcome (1.9.163.5)
12
+
13
+ benchmark/native_vs_binding.rb gained the gap rows: []/content/
14
+ name repeat reads (binding + native), set_attribute, and the
15
+ build (create x2 + attach) row, all interleaved best-of behind
16
+ the file's load gate (skips > 20). `set_attribute` itself became
17
+ a C-bound face this round (Native.set_binding_attribute: gates +
18
+ version bump + engine write in one dispatch).