moxml 0.5.166 → 0.5.168

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 932f907222ac657e72e53b9e5ac2e514bb6aa07a51da815caa7ee31c0b0a02fa
4
- data.tar.gz: 11c82e15a42a80adb5bb19b4b819e9b4cfa1e7e508c6088d383dfdd793a33956
3
+ metadata.gz: 5b55c7bbd0cbaa945ab4404c9fe40b354ae5b291672804f874b33318abd26dc3
4
+ data.tar.gz: 33ac60d77afebe105bf6ccfcd4f07482f1b33c42cf573c6e169a2b2aacc0b2df
5
5
  SHA512:
6
- metadata.gz: 2ef7a9115c29ad09a1b3cb91e3c1c7c8cae297529de98cc772b6939b1e3a893c9820889b85b61f82cfd52c6e9536f688b841551c6f125c09aea0fee61ae12d10
7
- data.tar.gz: c2822eb479197ddf8151820c1c28e3ce61f49b2a1b3263d1fa4ffc7bc581e387aa52799faef3fba303fb9113bed5ac51cb5b93b5736ed1f03884b398c5a6df15
6
+ metadata.gz: 33d2df43783702c43372335cfe88fb0449cb04984889f3932fcd085592c26f33ad68303c0d4019a6ed01831a325bd1070d2d261a47cfc604867b9b221452a69a
7
+ data.tar.gz: 5417a78c416bbbc737a13e9c369f7a7064e5946f44da6e80dad795fee279c1d355f86d10dd5f95bfcbe61b44eba1a7c7d3f6cd462197b7a00698a1f74f81fa35
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gemspec
9
9
  gem "benchmark"
10
10
  gem "benchmark-ips"
11
11
  gem "get_process_mem"
12
- gem "leptris", "~> 1.9.351"
12
+ gem "leptris", "~> 1.9.382"
13
13
  gem "libxml-ruby", ">= 5.0"
14
14
  gem "nokogiri", "~> 1.18"
15
15
  gem "openssl", "~> 3.0"
data/benchmark/README.md CHANGED
@@ -92,16 +92,16 @@ rewrites this block and `docs/_pages/performance.adoc`):
92
92
  <!-- SCOREBOARD:BEGIN -->
93
93
  | Row | Ratio | Kind | Status |
94
94
  | --- | ----- | ---- | ------ |
95
- | `parse` | 4.61x | parity | met |
96
- | `serialize` | 1.65x | parity | met |
97
- | `xpath //field (count)` | 3.39x | parity | met |
98
- | `xpath //field (each)` | 0.34x | structural | tracked (below parity) |
99
- | `attr pairs listing` | 8.48x | parity | met |
100
- | `build add_element` | 0.74x | structural | tracked (below parity) |
101
- | `walk+read (warm)` | 0.54x | structural | tracked (below parity) |
95
+ | `parse` | 3.05x | parity | met |
96
+ | `serialize` | 1.62x | parity | met |
97
+ | `xpath //field (count)` | 3.04x | parity | met |
98
+ | `xpath //field (each)` | 0.59x | structural | tracked (below parity) |
99
+ | `attr pairs listing` | 7.35x | parity | met |
100
+ | `build add_element` | 0.98x | structural | tracked (at parity) |
101
+ | `walk+read (warm)` | 0.67x | structural | tracked (below parity) |
102
102
  | `walk+read (cold)` | 0.37x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
103
- | `children recursion (cold)` | 0.45x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
104
- | `plan records (typed)` | 1.13x | data | informational |
103
+ | `children recursion (cold)` | 0.46x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
104
+ | `plan records (typed)` | 1.09x | data | informational |
105
105
  <!-- SCOREBOARD:END -->
106
106
 
107
107
  The open rows are the known structural gap: every node the facade
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "protocol": {
3
- "measured": "2026-10-24T05:26:53Z",
3
+ "measured": "2026-10-26T23:05:13Z",
4
4
  "ruby": "3.3.13",
5
5
  "adapter": "leptris",
6
- "adapter_version": "1.9.381.0",
6
+ "adapter_version": "1.9.383.0",
7
7
  "nokogiri": "1.19.4",
8
8
  "reps": 9,
9
9
  "warmup": 2,
10
10
  "clock": "CLOCK_PROCESS_CPUTIME_ID",
11
- "load": 1.74
11
+ "load": 1.83
12
12
  },
13
13
  "rows": [
14
14
  {
15
15
  "row": "parse",
16
16
  "kind": "parity",
17
17
  "parity": true,
18
- "baseline_us": 1075.7,
19
- "candidate_us": 233.6,
20
- "ratio": 4.606,
18
+ "baseline_us": 877.4,
19
+ "candidate_us": 287.4,
20
+ "ratio": 3.053,
21
21
  "baseline_allocs": 7,
22
22
  "candidate_allocs": 18
23
23
  },
@@ -25,9 +25,9 @@
25
25
  "row": "serialize",
26
26
  "kind": "parity",
27
27
  "parity": true,
28
- "baseline_us": 457.0,
29
- "candidate_us": 277.6,
30
- "ratio": 1.646,
28
+ "baseline_us": 501.5,
29
+ "candidate_us": 309.0,
30
+ "ratio": 1.623,
31
31
  "baseline_allocs": 30,
32
32
  "candidate_allocs": 14
33
33
  },
@@ -35,9 +35,9 @@
35
35
  "row": "xpath //field (count)",
36
36
  "kind": "parity",
37
37
  "parity": true,
38
- "baseline_us": 64.4,
39
- "candidate_us": 19.0,
40
- "ratio": 3.387,
38
+ "baseline_us": 220.8,
39
+ "candidate_us": 72.6,
40
+ "ratio": 3.041,
41
41
  "baseline_allocs": 13,
42
42
  "candidate_allocs": 8
43
43
  },
@@ -45,9 +45,9 @@
45
45
  "row": "xpath //field (each)",
46
46
  "kind": "structural",
47
47
  "parity": false,
48
- "baseline_us": 256.4,
49
- "candidate_us": 744.4,
50
- "ratio": 0.344,
48
+ "baseline_us": 356.2,
49
+ "candidate_us": 600.9,
50
+ "ratio": 0.593,
51
51
  "baseline_allocs": 1213,
52
52
  "candidate_allocs": 10
53
53
  },
@@ -55,9 +55,9 @@
55
55
  "row": "attr pairs listing",
56
56
  "kind": "parity",
57
57
  "parity": true,
58
- "baseline_us": 503.6,
59
- "candidate_us": 59.4,
60
- "ratio": 8.484,
58
+ "baseline_us": 460.6,
59
+ "candidate_us": 62.7,
60
+ "ratio": 7.349,
61
61
  "baseline_allocs": 3300,
62
62
  "candidate_allocs": 0
63
63
  },
@@ -65,9 +65,9 @@
65
65
  "row": "build add_element",
66
66
  "kind": "structural",
67
67
  "parity": false,
68
- "baseline_us": 5607.1,
69
- "candidate_us": 7620.4,
70
- "ratio": 0.736,
68
+ "baseline_us": 4340.4,
69
+ "candidate_us": 4453.1,
70
+ "ratio": 0.975,
71
71
  "baseline_allocs": 20358,
72
72
  "candidate_allocs": 10163
73
73
  },
@@ -75,9 +75,9 @@
75
75
  "row": "walk+read (warm)",
76
76
  "kind": "structural",
77
77
  "parity": false,
78
- "baseline_us": 1631.1,
79
- "candidate_us": 3005.5,
80
- "ratio": 0.543,
78
+ "baseline_us": 1547.7,
79
+ "candidate_us": 2314.2,
80
+ "ratio": 0.669,
81
81
  "baseline_allocs": 4503,
82
82
  "candidate_allocs": 2
83
83
  },
@@ -85,8 +85,8 @@
85
85
  "row": "walk+read (cold)",
86
86
  "kind": "structural",
87
87
  "parity": false,
88
- "baseline_us": 3094.4,
89
- "candidate_us": 8308.1,
88
+ "baseline_us": 2442.2,
89
+ "candidate_us": 6557.9,
90
90
  "ratio": 0.372,
91
91
  "baseline_allocs": 6011,
92
92
  "candidate_allocs": 10232
@@ -95,9 +95,9 @@
95
95
  "row": "children recursion (cold)",
96
96
  "kind": "structural",
97
97
  "parity": false,
98
- "baseline_us": 3414.5,
99
- "candidate_us": 7574.2,
100
- "ratio": 0.451,
98
+ "baseline_us": 2768.6,
99
+ "candidate_us": 6033.7,
100
+ "ratio": 0.459,
101
101
  "baseline_allocs": 5711,
102
102
  "candidate_allocs": 8732
103
103
  },
@@ -105,9 +105,9 @@
105
105
  "row": "plan records (typed)",
106
106
  "kind": "data",
107
107
  "parity": false,
108
- "baseline_us": 3056.3,
109
- "candidate_us": 2700.7,
110
- "ratio": 1.132,
108
+ "baseline_us": 2316.7,
109
+ "candidate_us": 2130.8,
110
+ "ratio": 1.087,
111
111
  "baseline_allocs": 8112,
112
112
  "candidate_allocs": 13232
113
113
  }
@@ -50,7 +50,18 @@ module Scoreboard
50
50
  # tracked issue closes — otherwise the 10% trend tolerance flaps on
51
51
  # the regressed value and the lock signal is lost. Remove an entry
52
52
  # when its issue lands.
53
+ # tiny-us rows: the ratio swings ~40% with the runner profile
54
+ # (nokogiri moves identically - 2026-10-26/27 investigation), so the
55
+ # 10% lock cannot hold on them; tracked, not gated.
53
56
  KNOWN_REGRESSIONS = {
57
+ "xpath //field (count)" =>
58
+ "runner-window variance (2026-10-26/27: 1.91x-3.39x across runs)",
59
+ "xpath //field (each)" =>
60
+ "runner-window variance (2026-10-26/27: 0.34x-0.59x across runs)",
61
+ "attr pairs listing" =>
62
+ "runner-window variance (2026-10-26/27: 6.17x-8.48x across runs)",
63
+ "walk+read (warm)" =>
64
+ "runner-window variance (2026-10-26/27: 0.54x-0.76x across runs)",
54
65
  "walk+read (cold)" => "leptris-ruby#527 (1.9.380 crossing regression)",
55
66
  "children recursion (cold)" =>
56
67
  "leptris-ruby#527 (1.9.380 crossing regression)",
@@ -146,3 +146,13 @@ no-op there (declaration nodes cannot occur). On such bindings the
146
146
  class is also absent for consumers. Pinned in
147
147
  `spec/moxml/adapter/leptris_document_children_older_binding_spec.rb`.
148
148
  |===
149
+
150
+ | D-22
151
+ | leptris (materialized-wrapper lane)
152
+ | A cross-document replace on a materialized wrapper keeps only the
153
+ installed copy — the UNREPLACED siblings of the replaced node are
154
+ dropped from the serialized tree. Reproduced on the stock adapter
155
+ before leptris#1866's fix (the fix itself is not the cause); not a
156
+ regression of the fix. Noted in
157
+ `spec/moxml/adapter/leptris_crossdoc_replace_spec.rb`.
158
+ |===
@@ -42,7 +42,9 @@ Generated by `bundle exec rake docs:coverage` from the coverage matrix and the d
42
42
  | validation | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
43
43
  | validation under-detections | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
44
44
  | validation version reporting | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
+ | validation engine metadata | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
46
  | validation regex dismissal | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
47
+ | validation builtin lexical table | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
46
48
  | validation substitution dismissal | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
47
49
  | loud capability contracts | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
48
50
  |===
@@ -14,21 +14,21 @@ the marked block below from a live measurement; see
14
14
  // SCOREBOARD:BEGIN
15
15
  Generated by `SCORE_PUBLISH=1 bundle exec ruby benchmark/scoreboard.rb`. Protocol: CPU-time medians of an interleaved A/B run (raw nokogiri vs moxml on leptris); the ratio is baseline / candidate, so > 1.0 means the facade is faster. Warmups discarded, GC between samples.
16
16
 
17
- Measurement: Ruby 3.3.13, leptris 1.9.381.0, nokogiri 1.19.4, 2026-10-24T05:26:53Z, 1-minute load 1.74, 9 repetitions.
17
+ Measurement: Ruby 3.3.13, leptris 1.9.383.0, nokogiri 1.19.4, 2026-10-26T23:05:13Z, 1-minute load 1.83, 9 repetitions.
18
18
 
19
19
  [cols="2,1,1,1",options="header"]
20
20
  |===
21
21
  | Row | Ratio | Kind | Status
22
- | `parse` | 4.61x | parity | met
23
- | `serialize` | 1.65x | parity | met
24
- | `xpath //field (count)` | 3.39x | parity | met
25
- | `xpath //field (each)` | 0.34x | structural | tracked (below parity)
26
- | `attr pairs listing` | 8.48x | parity | met
27
- | `build add_element` | 0.74x | structural | tracked (below parity)
28
- | `walk+read (warm)` | 0.54x | structural | tracked (below parity)
22
+ | `parse` | 3.05x | parity | met
23
+ | `serialize` | 1.62x | parity | met
24
+ | `xpath //field (count)` | 3.04x | parity | met
25
+ | `xpath //field (each)` | 0.59x | structural | tracked (below parity)
26
+ | `attr pairs listing` | 7.35x | parity | met
27
+ | `build add_element` | 0.98x | structural | tracked (at parity)
28
+ | `walk+read (warm)` | 0.67x | structural | tracked (below parity)
29
29
  | `walk+read (cold)` | 0.37x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
30
- | `children recursion (cold)` | 0.45x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
31
- | `plan records (typed)` | 1.13x | data | informational
30
+ | `children recursion (cold)` | 0.46x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
31
+ | `plan records (typed)` | 1.09x | data | informational
32
32
  |===
33
33
 
34
34
  *Parity rows* must stay at or above the 0.95 measurement floor. *Structural rows* are measured and trend-locked (10% regression tolerance) while their gaps remain open: per-node wrapper minting costs (#312 endgame) and engine construction. *Data API* rows are informational.
@@ -255,11 +255,18 @@ semantics.
255
255
 
256
256
  === Validation
257
257
 
258
- Pins:: `spec/moxml/xsd_regex_spec.rb`, `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_substitution_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
258
+ Pins:: `spec/moxml/xsd_builtin_lexical_spec.rb`, `spec/moxml/xsd_engine_meta_spec.rb`, `spec/moxml/xsd_regex_spec.rb`, `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_substitution_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
259
259
 
260
260
  XSD validation through engines that ship a validator (libxml2;
261
261
  leptris tier-1: recognized roots, one-level content descent, lexical
262
- checks). `validate` returns accumulated `ValidationError` entries —
262
+ checks). The built-in lexical answer is facade-owned:
263
+ `Moxml::XSD.builtin_valid?` evaluates the Datatypes Part 2 lexical
264
+ spaces (collapse for the token family, the unbounded integer family
265
+ with bignum bounds, calendar and duration grammars, binary, QName
266
+ with the reserved `xmlns` prefix) from the facade's own table on
267
+ every adapter — an engine table converges to it, never the reverse;
268
+ an unknown name raises `ArgumentError`. `validate` returns
269
+ accumulated `ValidationError` entries —
263
270
  message always; line/column/path where the engine reports them, nil
264
271
  where it does not; `[]` means valid. Documents outside the engine's
265
272
  scope validate laxly, except that strict `xs:any` /
@@ -286,8 +293,14 @@ registered member must realize a position — any choice/all,
286
293
  cardinality violation, or unknown name keeps the engine's answer.
287
294
  A compiled schema reports the
288
295
  version its source declares: root `vc:minVersion="1.1"` scopes the
289
- document to 1.1, anything else is 1.0 (consumers scope 1.0-only
290
- checks with it; applying 1.1 semantics is the engine-side program).
296
+ document to 1.1, anything else is 1.0 — on engines that serve their
297
+ own effective version the engine's answer can only UPGRADE the
298
+ reported value to 1.1, never downgrade the source annotation
299
+ (consumers scope 1.0-only checks with it; applying 1.1 semantics is
300
+ the engine-side program). Engines that expose a structured report
301
+ surface `Schema#structured_errors` — one row per finding with
302
+ kind/component/line/column/message; the face answers nil on
303
+ bindings without it and #validate keeps the flat shape.
291
304
  Unsupported adapters raise
292
305
  `Moxml::NotImplementedError` at compile; `Moxml::XSD.supported?`
293
306
  answers without raising. RELAX NG rides engines with an RNG face
@@ -373,11 +373,6 @@ module Moxml
373
373
  "XSD validation requires an adapter with an XSD engine"
374
374
  end
375
375
 
376
- def xsd_builtin_valid?(_builtin, _lexical)
377
- raise Moxml::NotImplementedError,
378
- "XSD validation requires an adapter with an XSD engine"
379
- end
380
-
381
376
  def xsd_declarations(_xsd_handle); end
382
377
 
383
378
  def xsd_compile_error(_xsd_handle); end
@@ -406,15 +406,6 @@ module Moxml
406
406
  handle.valid?(to_binding(document))
407
407
  end
408
408
 
409
- def xsd_builtin_valid?(builtin, lexical)
410
- unless XSD_SUPPORTED
411
- raise Moxml::NotImplementedError,
412
- "XSD validation requires an adapter with an XSD engine"
413
- end
414
-
415
- ::Leptris::XML::XSD.builtin_valid?(builtin, lexical)
416
- end
417
-
418
409
  def xsd_declarations(xsd_handle)
419
410
  xsd_handle.declaration_count
420
411
  end
@@ -425,6 +416,34 @@ module Moxml
425
416
  xsd_handle.release
426
417
  end
427
418
 
419
+ # moxml#460 item 2: the ENGINE's effective version (the
420
+ # authoritative source — it gates the 1.1 semantics). Nil
421
+ # when the binding predates the face (fall back to the
422
+ # rules heuristic on the facade side).
423
+ def xsd_version(xsd_handle)
424
+ return nil unless defined?(::Leptris::XML::XSD::Schema) &&
425
+ xsd_handle.is_a?(::Leptris::XML::XSD::Schema) &&
426
+ xsd_handle.respond_to?(:version)
427
+
428
+ xsd_handle.version
429
+ rescue ::Leptris::XML::Error
430
+ nil
431
+ end
432
+
433
+ # moxml#460/#1841: the engine's structured report — one
434
+ # {kind:, component:, line:, column:, message:} hash per
435
+ # finding. Nil when the binding predates the face.
436
+ def xsd_structured_errors(xsd_handle, doc)
437
+ return nil unless defined?(::Leptris::XML::XSD::Schema) &&
438
+ xsd_handle.is_a?(::Leptris::XML::XSD::Schema) &&
439
+ (xsd_handle.respond_to?(:structured_errors) ||
440
+ xsd_handle.respond_to?(:structured_errors, true))
441
+
442
+ xsd_handle.structured_errors(doc)
443
+ rescue ::Leptris::XML::Error
444
+ nil
445
+ end
446
+
428
447
  def xsd_compile_error(xsd_handle)
429
448
  xsd_handle.error
430
449
  end
@@ -911,9 +930,20 @@ module Moxml
911
930
  # Protocol-level node equality: one engine can hand out
912
931
  # two wrapper classes over the same C node (native layer +
913
932
  # binding), so raw == is not identity across the seam.
933
+ # Attributes are (name, value, element) triples without a
934
+ # node pointer; identity is the owning element plus the
935
+ # attribute name.
914
936
  def same_node?(one, other)
915
937
  return true if one.equal?(other)
916
938
 
939
+ if one.is_a?(::Leptris::XML::Attr) ||
940
+ other.is_a?(::Leptris::XML::Attr)
941
+ return one.is_a?(::Leptris::XML::Attr) &&
942
+ other.is_a?(::Leptris::XML::Attr) &&
943
+ one.name == other.name &&
944
+ same_node?(one.element, other.element)
945
+ end
946
+
917
947
  if NATIVE_READ_LAYER
918
948
  native_one = one.is_a?(::Leptris::XML::NativeNode)
919
949
  native_other = other.is_a?(::Leptris::XML::NativeNode)
@@ -3135,6 +3165,14 @@ module Moxml
3135
3165
  node = to_binding(node)
3136
3166
  new_node = to_binding(new_node)
3137
3167
  end
3168
+ # anchor the BINDING document: its handle owns the engine
3169
+ # pool — while it lives, no retained address can dangle
3170
+ if node.respond_to?(:document) && new_node.respond_to?(:document)
3171
+ target = node.document
3172
+ bsrc = new_node.document
3173
+ target.retain_document!(bsrc) if target.respond_to?(:retain_document!) &&
3174
+ bsrc.respond_to?(:freed?)
3175
+ end
3138
3176
  pin_foreign_owner_document(node, new_node)
3139
3177
  # A PI inserted before the root lives at document level in
3140
3178
  # libleptris's model, not in the element tree.
@@ -3284,6 +3322,27 @@ module Moxml
3284
3322
  end
3285
3323
 
3286
3324
  def replace(node, new_node)
3325
+ # leptris#1866: keep a cross-document source (a scratch doc
3326
+ # parsed only to build the replacement) anchored on the
3327
+ # target — wrapper caches on the target side may hold
3328
+ # references the engine copy cannot see. STRICTLY
3329
+ # best-effort: bare adapter-contract nodes carry no
3330
+ # context/document, and retention must never break a
3331
+ # replace.
3332
+ begin
3333
+ target = node.document if node.respond_to?(:document)
3334
+ source = new_node.document if new_node.respond_to?(:document)
3335
+ if target.respond_to?(:retain_document!) &&
3336
+ source.respond_to?(:retain_document!)
3337
+ target.retain_document!(source)
3338
+ # the scratch CONTEXT (config + adapter) is part of the
3339
+ # retained object graph — its death frees the native
3340
+ # registrations even when the document wrapper survives
3341
+ target.retain_document!(new_node.context)
3342
+ end
3343
+ rescue StandardError
3344
+ # context/document unreadable on some wrapper flavors
3345
+ end
3287
3346
  if NATIVE_READ_LAYER
3288
3347
  node = to_binding(node)
3289
3348
  new_node = to_binding(new_node)
@@ -3297,9 +3356,16 @@ module Moxml
3297
3356
  # tree cycles (standoc term cleanup does
3298
3357
  # t << n.replace(t2)). Insert + unlink instead: the unlink
3299
3358
  # path clears the sibling links.
3300
- node.add_next_sibling(new_node)
3359
+ # leptris#1866: return the INSTALLED sibling, not
3360
+ # new_node — a cross-document new_node belongs to its
3361
+ # scratch document's arena (the insert deep-copies), and
3362
+ # the temp wrapper returned here used to be stored in the
3363
+ # parent's children memo; once the scratch was collected
3364
+ # the memo handed out freed addresses (serializer
3365
+ # segfault + empty attribute names in the output).
3366
+ installed = node.add_next_sibling(new_node)
3301
3367
  node.unlink
3302
- return new_node
3368
+ return installed || new_node
3303
3369
  end
3304
3370
 
3305
3371
  # libleptris only offers element-anchored insertion, so a
@@ -3326,6 +3392,9 @@ module Moxml
3326
3392
  prev.c_ptr, new_node.c_ptr
3327
3393
  ),
3328
3394
  )
3395
+ # leptris#1866: the raw face deep-copies; the caller must
3396
+ # never keep the scratch wrapper (see the element branch).
3397
+ prev.next_sibling || new_node
3329
3398
  else
3330
3399
  advance_mutation_version(parent)
3331
3400
  ::Leptris::XML::FFI.check_status(
@@ -3333,8 +3402,8 @@ module Moxml
3333
3402
  parent.c_ptr, new_node.c_ptr
3334
3403
  ),
3335
3404
  )
3405
+ parent.children.last || new_node
3336
3406
  end
3337
- new_node
3338
3407
  end
3339
3408
 
3340
3409
  NATIVE_APPEND_CHILDREN =
@@ -3659,8 +3728,16 @@ module Moxml
3659
3728
  return nil if namespaces && !namespaces.empty?
3660
3729
  # Reverse axes answer reverse-ordered from the native
3661
3730
  # engine; route them to the Ruby engine (document order).
3662
- return nil if REVERSE_AXIS_CACHE.get_or_set(expression) do
3731
+ # The parent step is the exception on fixed bindings
3732
+ # (PARENT_AXIS_NATIVE): it answers the owner element and
3733
+ # the hot consumers are element-context paths (#474).
3734
+ if REVERSE_AXIS_CACHE.get_or_set(expression) do
3663
3735
  expression_uses_reverse_axis?(expression)
3736
+ end && !(PARENT_AXIS_NATIVE &&
3737
+ !NON_PARENT_REVERSE_CACHE.get_or_set(expression) do
3738
+ expression_uses_non_parent_reverse_axis?(expression)
3739
+ end)
3740
+ return nil
3664
3741
  end
3665
3742
 
3666
3743
  # The binding reports the root element parentless while moxml
@@ -3778,7 +3855,19 @@ module Moxml
3778
3855
  # expressions route to the Ruby engine, which orders them.
3779
3856
  REVERSE_AXES = %w[ancestor ancestor-or-self preceding
3780
3857
  preceding-sibling parent].freeze
3858
+ # Reverse axes answer reverse-ordered from the native engine
3859
+ # (document order is the model's contract), so they route to
3860
+ # the Ruby engine — EXCEPT the parent step on bindings
3861
+ # serving the fixed engine: 1.9.385 carries leptris#1869
3862
+ # (parent-of-attribute names the OWNER element) plus the
3863
+ # cross-doc adopt attribute-chain fix, and standoc's svg
3864
+ # id cleanup rides `./@id/..` hot (moxml#474).
3865
+ PARENT_AXIS_NATIVE =
3866
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.385")
3867
+ NON_PARENT_REVERSE_AXES = %w[ancestor ancestor-or-self
3868
+ preceding preceding-sibling].freeze
3781
3869
  REVERSE_AXIS_CACHE = XPath::Cache.new(200)
3870
+ NON_PARENT_REVERSE_CACHE = XPath::Cache.new(200)
3782
3871
 
3783
3872
  def expression_uses_parent_axis?(expression)
3784
3873
  ast = XPath::Parser.parse_with_cache(expression)
@@ -3805,6 +3894,25 @@ module Moxml
3805
3894
  end
3806
3895
  end
3807
3896
 
3897
+ def expression_uses_non_parent_reverse_axis?(expression)
3898
+ ast = XPath::Parser.parse_with_cache(expression)
3899
+ contains_non_parent_reverse_axis?(ast)
3900
+ rescue XPath::SyntaxError
3901
+ true
3902
+ end
3903
+
3904
+ def contains_non_parent_reverse_axis?(ast)
3905
+ if ast.type == :axis && NON_PARENT_REVERSE_AXES.include?(ast.children.first)
3906
+ return true
3907
+ end
3908
+ return false if ast.type == :parent
3909
+
3910
+ ast.children.any? do |child|
3911
+ child.is_a?(XPath::AST::Node) &&
3912
+ contains_non_parent_reverse_axis?(child)
3913
+ end
3914
+ end
3915
+
3808
3916
  def contains_parent_axis?(ast)
3809
3917
  return true if ast.type == :parent
3810
3918
  return true if ast.type == :axis && ast.children.first == "parent"
data/lib/moxml/config.rb CHANGED
@@ -92,7 +92,12 @@ module Moxml
92
92
 
93
93
  @leptris_preferred_available =
94
94
  begin
95
- require "leptris"
95
+ # Route the require through a non-literal send: Opal::Builder
96
+ # resolves literal requires statically and dies on native-only
97
+ # gems (leptris is FFI; lutaml/moxml#475) before this rescue
98
+ # can degrade the adapter. The runtime require still raises
99
+ # LoadError under Opal, which the rescue below handles.
100
+ __send__(:require, "leptris")
96
101
  Moxml::Adapter.load(:leptris)
97
102
  Gem::Version.new(::Leptris::VERSION) >=
98
103
  Gem::Version.new(Moxml::Adapter::Leptris::MINIMUM_BINDING_VERSION)
@@ -9,6 +9,23 @@ module Moxml
9
9
  def initialize(native, context, adapter = nil, node_type = nil)
10
10
  super
11
11
  @has_xml_declaration = false
12
+ @retained_documents = nil
13
+ end
14
+
15
+ # leptris#1866: cross-document adoption lifetime anchor. An
16
+ # adopted subtree is deep-copied by the native engine, but
17
+ # adapter-level wrapper caches can retain references into the
18
+ # SOURCE document's scratch arena (standoc svg cleanup: a
19
+ # per-replacement scratch doc was collected and the host's
20
+ # serializer then segfaulted / emitted empty attribute names).
21
+ # Retaining the source Moxml document keeps every layer of the
22
+ # scratch alive for as long as the target lives; the cost is
23
+ # bounded by the number of cross-document adoptions.
24
+ def retain_document!(other)
25
+ return if other.nil? || other.equal?(self)
26
+
27
+ (@retained_documents ||= []) << other
28
+ other
12
29
  end
13
30
 
14
31
  def document
@@ -165,7 +165,12 @@ module Moxml
165
165
  native_node = node.is_a?(Node) ? node.native : node
166
166
  # Materialize the buffer first so it allocates at the pre-append
167
167
  # size and stays in lockstep with the natives.
168
- wrapped_buffer << nil
168
+ # A pushed wrapper is kept: stripping it to the native and
169
+ # re-wrapping on iteration loses wrapper state — an attribute
170
+ # step's results carry their owner wiring (@parent_node), and
171
+ # the fresh wrap answers nil for it, so a following parent step
172
+ # (`./@id/..`) lost the owner element (#474).
173
+ wrapped_buffer << (node.is_a?(Node) ? node : nil)
169
174
  native_nodes << native_node
170
175
  self
171
176
  end
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.166"
4
+ VERSION = "0.5.168"
5
5
  end
@@ -75,7 +75,18 @@ module Moxml
75
75
  [kind_rank, ancestry_path(node)]
76
76
  end
77
77
 
78
+ # Attributes are not children of their element, so their
79
+ # document position is the parent's; same-parent attributes
80
+ # keep their relative order (stable sort). A parentless
81
+ # attribute stays unplaceable.
78
82
  def ancestry_path(node)
83
+ if node.is_a?(Moxml::Attribute)
84
+ parent = node.parent
85
+ return [2_000_000] unless parent
86
+
87
+ return ancestry_path(parent)
88
+ end
89
+
79
90
  path = []
80
91
  current = node
81
92
  while current && !current.is_a?(Moxml::Document)