moxml 0.5.147 → 0.5.149

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-fuzz.yml +80 -0
  3. data/docs/_pages/divergences.adoc +58 -0
  4. data/docs/_references/processing-model.adoc +30 -3
  5. data/lib/compat/opal/moxml_boot.rb +4 -0
  6. data/lib/moxml/adapter/leptris.rb +180 -20
  7. data/lib/moxml/adapter/libxml.rb +145 -23
  8. data/lib/moxml/adapter/oga.rb +33 -3
  9. data/lib/moxml/adapter/ox.rb +26 -2
  10. data/lib/moxml/adapter/rexml.rb +65 -15
  11. data/lib/moxml/context.rb +5 -1
  12. data/lib/moxml/document.rb +24 -10
  13. data/lib/moxml/node.rb +34 -6
  14. data/lib/moxml/node_set.rb +11 -0
  15. data/lib/moxml/reference/normalizer.rb +63 -0
  16. data/lib/moxml/reference/parser.rb +417 -0
  17. data/lib/moxml/reference/writer.rb +121 -0
  18. data/lib/moxml/reference.rb +205 -0
  19. data/lib/moxml/version.rb +1 -1
  20. data/lib/moxml/xpath/compiler.rb +17 -2
  21. data/lib/moxml/xpath/ordering.rb +97 -0
  22. data/lib/moxml/xpath.rb +1 -0
  23. data/lib/moxml.rb +1 -0
  24. data/spec/consistency/adapter_parity_spec.rb +2 -0
  25. data/spec/consistency/differential_taxonomy_spec.rb +117 -0
  26. data/spec/consistency/divergence_register_spec.rb +9 -1
  27. data/spec/consistency/eol_normalization_spec.rb +2 -0
  28. data/spec/consistency/loud_degradation_spec.rb +2 -0
  29. data/spec/consistency/metamorphic_properties_spec.rb +195 -0
  30. data/spec/consistency/metamorphic_spec.rb +138 -0
  31. data/spec/consistency/model_coverage_audit_spec.rb +108 -0
  32. data/spec/consistency/reference_differential_spec.rb +186 -0
  33. data/spec/consistency/xpath_idioms_spec.rb +11 -0
  34. data/spec/consistency/xslt_spec.rb +2 -0
  35. data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
  36. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +29 -0
  37. data/spec/moxml/adapter/leptris_detached_text_spec.rb +10 -3
  38. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +12 -0
  39. data/spec/moxml/adapter/ox_spec.rb +1 -1
  40. data/spec/moxml/attribute_resolver_spec.rb +2 -0
  41. data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
  42. data/spec/moxml/doctype_spec.rb +7 -0
  43. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  44. data/spec/moxml/entity_spec.rb +2 -0
  45. data/spec/moxml/plan_conformance_spec.rb +2 -0
  46. data/spec/moxml/processing_instruction_spec.rb +9 -0
  47. data/spec/moxml/reference_opal_spec.rb +29 -0
  48. data/spec/moxml/reference_spec.rb +162 -0
  49. data/spec/moxml/sax_spec.rb +2 -0
  50. data/spec/moxml/text_spec.rb +2 -0
  51. data/spec/moxml/to_html_spec.rb +2 -0
  52. data/spec/moxml/xhtml_output_spec.rb +2 -0
  53. data/spec/moxml/xpath/axes_spec.rb +2 -2
  54. data/spec/moxml/xsd_spec.rb +38 -0
  55. data/spec/support/input_generator.rb +154 -0
  56. data/spec/support/input_taxonomy.rb +212 -0
  57. data/spec/support/xml_generator.rb +54 -0
  58. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e5797a825682b992d84b5ab9155d74f81fdeca24293b40ad7b9e58860c407f0
4
- data.tar.gz: 11accb2da9da7cf656d28ab7e128da698f1702120332d94d06b4a20ea39ad112
3
+ metadata.gz: 3af023098a8dde5521f0297f240a1c6fa71625cc2b3cd6924eba638724e12517
4
+ data.tar.gz: 9fb194d059475ed0c4aea3471b9048195ce7ea0bef349c25bdc52f991944ca5f
5
5
  SHA512:
6
- metadata.gz: 2b8980eb872815020ed2b6d582172ffd62c08a426c2fa2104bd891cd5e2f1e69392fd82d9573215383399087cb686852c47fb10e1d93e812c1641f8eb5e4eeef
7
- data.tar.gz: 924b76844411af051e0a9b02ea55788dbc4f5d0e6ec0be31ade4ded2c17c6c6e58356f687ef42e2d7934a4a8f684720f77b0d7688e108437aa64c68a41b68d06
6
+ metadata.gz: 860ac49f2684d504273e22dcbc9c6ed43106ebf67d9ca093af74543ab284e89c7be5c02d3991421973674837ece981127ede74329bfce9a352236bf9b3c094ff
7
+ data.tar.gz: 24b50dee831eb6856dc3f24b7759e32aead60c98896427ed7776277cd69aca0db01f3521841cbc556ccfaaee8fca56a071f2f219276f0553fe919b712c87ef2b
@@ -0,0 +1,80 @@
1
+ name: nightly fuzz
2
+
3
+ # TODO.sota/02's nightly leg: the property suites swept at random
4
+ # seeds and larger case counts, with findings filed as issues carrying
5
+ # a minimal repro. PR legs run the same suites at a fixed, bounded
6
+ # seed (MOXML_FUZZ/CI defaults in the spec); this leg is the breadth
7
+ # pass, not a gate.
8
+
9
+ on:
10
+ schedule:
11
+ - cron: "23 3 * * *"
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+ issues: write
17
+
18
+ jobs:
19
+ fuzz:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v6
23
+ with:
24
+ submodules: "recursive"
25
+
26
+ - name: Set up Ruby
27
+ uses: ruby/setup-ruby@v1
28
+ with:
29
+ ruby-version: "3.4"
30
+ bundler-cache: false
31
+
32
+ - name: Install ragel
33
+ run: sudo apt-get update && sudo apt-get install -y ragel
34
+
35
+ - name: Install ruby-ll (for rake vendor:prepare)
36
+ run: gem install ruby-ll --no-document
37
+
38
+ - name: Generate ragel / ruby-ll outputs in vendored forks
39
+ run: rake vendor:prepare
40
+
41
+ - name: Install dependencies
42
+ run: bundle install
43
+
44
+ - name: Compile liboga / libll native extensions
45
+ run: rake vendor:compile
46
+
47
+ - name: Pick fuzz seeds
48
+ run: |
49
+ SEEDS=$(ruby -e 'srand; puts Array.new(3) { rand(1_000_000) }.join(",")')
50
+ echo "SOTA_FUZZ_SEEDS=$SEEDS" >> "$GITHUB_ENV"
51
+ echo "SOTA_FUZZ_CASES=1000" >> "$GITHUB_ENV"
52
+
53
+ - name: Fuzz (properties + structural suite)
54
+ run: |
55
+ bundle exec rspec \
56
+ spec/consistency/metamorphic_properties_spec.rb \
57
+ spec/consistency/metamorphic_spec.rb 2>&1 | tee fuzz.log
58
+
59
+ - name: File an issue on findings
60
+ if: failure()
61
+ env:
62
+ GH_TOKEN: ${{ github.token }}
63
+ run: |
64
+ {
65
+ echo "The nightly fuzz leg found findings at seeds \`$SOTA_FUZZ_SEEDS\` (\`SOTA_FUZZ_CASES=$SOTA_FUZZ_CASES\`)."
66
+ echo
67
+ echo "Reproduce:"
68
+ echo
69
+ echo '```'
70
+ echo "SOTA_FUZZ_SEEDS=$SOTA_FUZZ_SEEDS SOTA_FUZZ_CASES=$SOTA_FUZZ_CASES \\"
71
+ echo " bundle exec rspec spec/consistency/metamorphic_properties_spec.rb spec/consistency/metamorphic_spec.rb"
72
+ echo '```'
73
+ echo
74
+ echo '```'
75
+ grep -E "Failure/Error|gen [0-9]+/|rspec \./" fuzz.log | head -40
76
+ echo '```'
77
+ } > fuzz-issue.md
78
+ gh issue create \
79
+ --title "nightly fuzz findings (seeds $SOTA_FUZZ_SEEDS)" \
80
+ --body-file fuzz-issue.md
@@ -87,4 +87,62 @@ attribute-value predicates, namespace/default-namespace tests,
87
87
  `position()`/`count()`, `text()`, boolean expressions, `child::`/
88
88
  `descendant::` axes, or unions. Pinned in
89
89
  `spec/moxml/xpath_capabilities_spec.rb` and `spec/moxml/node_spec.rb`.
90
+
91
+ | D-13
92
+ | ox, headed_ox
93
+ | Inter-element whitespace-only text nodes are dropped at parse
94
+ (node counts and leading/trailing text differ from the other
95
+ adapters). Surfaced by the taxonomy differential. Pinned in
96
+ `spec/consistency/differential_taxonomy_spec.rb`.
97
+
98
+ | D-14
99
+ | oga
100
+ | Processing-instruction content leaks into text reads (oga's own
101
+ `text` folds PIs; the model says only text nodes contribute).
102
+ Surfaced by the taxonomy differential. Pinned in
103
+ `spec/consistency/differential_taxonomy_spec.rb`.
104
+
105
+ | D-15
106
+ | rexml
107
+ | The whitespace text node between a pre-root PI and the root is
108
+ retained (other adapters drop it). Surfaced by the taxonomy
109
+ differential. Pinned in `spec/consistency/differential_taxonomy_spec.rb`.
110
+
111
+ | D-16
112
+ | rexml
113
+ | Entity-expansion guard caps huge documents (~10k expansions) and
114
+ raised a bare engine error — the facade now names it a ParseError;
115
+ the cap itself remains (nokogiri's HUGE lane has no such cap).
116
+ Surfaced by the taxonomy differential. Pinned in
117
+ `spec/consistency/differential_taxonomy_spec.rb`.
118
+
119
+ | D-17
120
+ | oga
121
+ | The strict parse lane does not validate nesting well-formedness
122
+ (oga's strict is lexer-strict, not well-formedness-strict); bad
123
+ nesting parses where other adapters raise. Surfaced by the taxonomy
124
+ differential. Pinned in
125
+ `spec/consistency/differential_taxonomy_spec.rb`.
126
+
127
+ | D-18
128
+ | leptris, oga, ox, headed_ox
129
+ | Strict parse accepts invalid UTF-8 byte sequences (no encoding
130
+ validation at parse; the bytes flow through as binary). The
131
+ validating adapters (nokogiri, rexml, libxml) raise the named
132
+ ParseError. On leptris the facade raises the named ParseError in the
133
+ strict lane (default-UTF-8 rule; prolog-declared non-UTF-8 encodings
134
+ own their validity) pending engine-side validation —
135
+ leptris-ruby#514. Surfaced by the taxonomy differential. Pinned in
136
+ `spec/consistency/differential_taxonomy_spec.rb`.
137
+
138
+ | D-19
139
+ | nokogiri, libxml
140
+ | Adjacent text-like leaves (CDATA next to CDATA or text) coalesce
141
+ into a single text node on the libxml2 lane; oga, rexml, ox,
142
+ headed_ox, and leptris keep them as distinct nodes. The `text`
143
+ answer is identical — the node count and kinds differ. The model is
144
+ silent on text-node coalescing (an open decision). Surfaced by the
145
+ generated metamorphic differential (M5). Pinned in
146
+ `spec/consistency/metamorphic_properties_spec.rb` through
147
+ `InputGenerator::FEATURE_EXEMPT`.
90
148
  |===
@@ -18,9 +18,20 @@ skip with their entry ID — an undocumented divergence is a bug.
18
18
  === Status and precedence
19
19
 
20
20
  1. This document — the model.
21
- 2. `spec/consistency/` — the model pinned as executable expectations.
21
+ 2. `spec/consistency/` — the model pinned as executable expectations,
22
+ with `lib/moxml/reference/` as the **engine-free executable
23
+ reference** (a pure-Ruby reader, writer, and normalizer). Adapters
24
+ are differentially tested against the reference
25
+ (`spec/consistency/reference_differential_spec.rb`), not only
26
+ against each other; the reference is pure Ruby and transpiles to
27
+ the Opal lane.
22
28
  3. Adapter implementations and capability gates — representations.
23
29
 
30
+ A pin cites the model section it enforces
31
+ (`# Processing model §<section> (normative)`); the coverage audit
32
+ asserts both directions of that linkage, so removing any of the
33
+ document, a pin, or the reference fails CI.
34
+
24
35
  A semantics change amends this document first (see
25
36
  `TODO.model/05-model-evolution-protocol.md`).
26
37
 
@@ -48,6 +59,12 @@ the same underlying node; node identity is a property of the
48
59
  underlying tree, not of wrapper object identity. Comparisons use node
49
60
  equality semantics, never `equal?` on wrappers.
50
61
 
62
+ Kind:: Every node answers `node_type` — one of `:document`,
63
+ `:element`, `:attribute`, `:text`, `:comment`, `:cdata`,
64
+ `:processing_instruction`, `:doctype`, `:declaration`,
65
+ `:entity_reference`, `:namespace` — the predicates (`element?`,
66
+ `text?`, …) are its derivatives.
67
+
51
68
  Ownership:: The `Document` owns the tree's lifetime; nodes are valid
52
69
  within it. Deterministic release is available via `Document#free`.
53
70
 
@@ -61,6 +78,10 @@ listings or serialization child walks.
61
78
  * Walks (`each_node`, `materialize`) visit depth-first, pre-order.
62
79
  * `Document#children` and `#root` never include the declaration;
63
80
  doctype exposure follows the Nokogiri contract.
81
+ * Document-level parts (`Declaration`, `Doctype`) are state, not
82
+ nodes: walks (`traverse`, `each_node`, `materialize`) never yield
83
+ them, and walks over a DOCTYPE-bearing document are well-defined on
84
+ every adapter.
64
85
  * `NodeSet` preserves document order for axis results; unions preserve
65
86
  first-encounter document order.
66
87
 
@@ -86,7 +107,9 @@ content; entity references restored when the document is
86
107
  entity-bearing.
87
108
 
88
109
  `content`:: The node's content per its kind; on text-like nodes, with
89
- entity-marker restoration; memoized against mutation.
110
+ entity-marker restoration; memoized against mutation. A processing
111
+ instruction's content is its data **without** the separating
112
+ whitespace after the target (uniform across adapters).
90
113
 
91
114
  `raw_content`:: The backing text **without** entity restoration.
92
115
 
@@ -128,6 +151,9 @@ with an unambiguous local name.
128
151
  `xmlns:` default-namespace matching; attribute terminals in every
129
152
  position (`//a/@id`, `//@id`, `r/a/@id`); symbol- and string-keyed
130
153
  bindings resolve identically.
154
+ * `at_xpath` is sugar over `xpath`: it answers the first node of a
155
+ node-set and passes scalar results (`count()`, `string()`,
156
+ `boolean()`) through unwrapped, uniformly across adapters.
131
157
 
132
158
  === Serialization
133
159
 
@@ -137,7 +163,8 @@ with an unambiguous local name.
137
163
 
138
164
  | `to_xml`
139
165
  | Declaration per configuration; empty elements per `expand_empty`;
140
- namespace declarations preserved.
166
+ namespace declarations preserved; a parsed DOCTYPE is emitted with
167
+ its external identifiers and internal subset.
141
168
 
142
169
  | `to_html`
143
170
  | HTML method: void elements unclosed (`<br>`), raw text
@@ -62,3 +62,7 @@ require "moxml/xpath/errors"
62
62
  require "moxml/xpath/ast/node"
63
63
  require "moxml/xpath/ruby/node"
64
64
  require "moxml/xpath/ruby/generator"
65
+ require "moxml/reference"
66
+ require "moxml/reference/parser"
67
+ require "moxml/reference/writer"
68
+ require "moxml/reference/normalizer"
@@ -105,6 +105,9 @@ module Moxml
105
105
  ::Leptris::XML::Document.method(:parse)
106
106
  .parameters.any? { |_, key| key == :strict }
107
107
 
108
+ ADD_CHILD_NATIVE =
109
+ NATIVE_READ_LAYER && NN.method_defined?(:add_child_native)
110
+
108
111
  if NATIVE_READ_LAYER
109
112
  NN_ATTRIBUTE = NN.instance_method(:attribute)
110
113
  NN_NAME = NN.instance_method(:name)
@@ -345,9 +348,13 @@ module Moxml
345
348
 
346
349
  def xsd_content_valid?(xsd_handle, element_name, children)
347
350
  # The binding reads each child's name + effective
348
- # namespace through its own node API — pass the natives.
351
+ # namespace through its own node API (c_ptr) — bridge to
352
+ # the binding's nodes. 1.9.374+ mints registered klasses
353
+ # at create, so children arrive as Identity wrappers whose
354
+ # .native is self (no c_ptr); to_binding hands back the
355
+ # binding node whatever the wrapper flavor.
349
356
  xsd_handle.content_valid?(
350
- element_name, children.map(&:native)
357
+ element_name, children.map { |c| to_binding(c.native) }
351
358
  )
352
359
  end
353
360
 
@@ -522,12 +529,18 @@ module Moxml
522
529
  include ::Moxml::Node
523
530
  include ::Moxml::ProcessingInstruction
524
531
 
525
- # Same nil bridge as Comment (leptris-ruby#344).
532
+ # Same nil bridge as Comment (leptris-ruby#344); the
533
+ # separating whitespace after the target is not content
534
+ # (uniform PI semantics, TODO.sota/02).
526
535
  def content
527
536
  content = native_content
528
- return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
537
+ if content.nil?
538
+ return ::Moxml::Adapter::Leptris.to_binding(self)
539
+ .content.to_s
540
+ .sub(/\A\s+/, "")
541
+ end
529
542
 
530
- content
543
+ content.sub(/\A\s+/, "")
531
544
  end
532
545
  end
533
546
 
@@ -624,6 +637,31 @@ module Moxml
624
637
  end
625
638
  TEXT.include(TextContractReads)
626
639
  end
640
+
641
+ # leptris-ruby#507: children_read joins the alias set. The
642
+ # generation-checked memo STAYS — re-walks of the same
643
+ # document are the common harness/consumer shape and the
644
+ # memo serves them allocation-free (dropping it regressed
645
+ # the walk 2x in GC). Only the MISS gets cheaper: the
646
+ # klass-children face plain-dispatches and returns the
647
+ # already-minted wrappers, so the adapter crossing
648
+ # (bind_call + to_a + keep-refs probe) drops out, and
649
+ # entity-marker documents take super for the split.
650
+ if NN.method_defined?(:children_read)
651
+ module ChildReads
652
+ def children
653
+ @children = nil if @children_gen != context.children_generation
654
+ @children_gen = context.children_generation
655
+ @children ||= if doc_entity_bearing?
656
+ super
657
+ else
658
+ NodeSet.new(children_read, context, self)
659
+ end
660
+ end
661
+ end
662
+ ELEMENT.include(ChildReads)
663
+ TEXT.include(ChildReads)
664
+ end
627
665
  end
628
666
 
629
667
  # leptris-ruby#246 klass-propagating reads: the read
@@ -1146,8 +1184,36 @@ module Moxml
1146
1184
  true
1147
1185
  end
1148
1186
 
1187
+ # UTF-8 is the default effective encoding; a prolog declaring
1188
+ # another encoding hands byte validity to that encoding's lane.
1189
+ DECLARED_ENCODING_RE =
1190
+ /<\?xml\s[^>]*?encoding\s*=\s*["']([^"']+)["']/i
1191
+ private_constant :DECLARED_ENCODING_RE
1192
+
1193
+ def encoding_lane_owns_validity?(xml_string)
1194
+ # Binary-tagged head: the prolog is ASCII, and matching
1195
+ # never raises on binary-tagged bytes (invalid-UTF-8 input
1196
+ # is exactly the case this check exists for).
1197
+ declared = xml_string.b[0, 256].match(DECLARED_ENCODING_RE)
1198
+ declared &&
1199
+ !declared[1].match?(/\A(?:utf-?8|us-ascii|ascii)\z/i)
1200
+ end
1201
+
1149
1202
  def parse(xml, options = {}, _context = nil)
1150
1203
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
1204
+ # Strict parse raises on bytes invalid in the effective
1205
+ # encoding (default UTF-8; declared non-UTF-8 encodings own
1206
+ # their validity) — the validating engines do this at load
1207
+ # (libxml2: "Invalid bytes in character encoding"), the
1208
+ # leptris engine lane does not yet (leptris-ruby#514), so
1209
+ # the facade names it (taxonomy differential, D-18).
1210
+ if options[:strict] && !xml_string.valid_encoding? &&
1211
+ !encoding_lane_owns_validity?(xml_string)
1212
+ raise Moxml::ParseError.new(
1213
+ "Invalid bytes in character encoding (#{xml_string.encoding})",
1214
+ source: xml_string[0, 100],
1215
+ )
1216
+ end
1151
1217
  # The marker flag rides preprocess's own `&` scan — no
1152
1218
  # second full-buffer probe (issue #132 parse-side note).
1153
1219
  entity_mode_keep =
@@ -1185,7 +1251,11 @@ module Moxml
1185
1251
  options: parse_flags(options, recover: recover,
1186
1252
  context: _context),
1187
1253
  )
1188
- rescue ::Leptris::XML::ParseError => e
1254
+ rescue ::Leptris::XML::Error => e
1255
+ # Invalid encoding bytes surface as the base engine
1256
+ # error class, not ParseError — parse failures are all
1257
+ # named by the facade (taxonomy differential, malformed
1258
+ # class).
1189
1259
  raise Moxml::ParseError.new(e.message) if options[:strict]
1190
1260
 
1191
1261
  # Engines below the 1.9.344 recover floor (or the
@@ -1355,7 +1425,11 @@ module Moxml
1355
1425
  else
1356
1426
  ::Leptris::XML.parse_html(html_string)
1357
1427
  end
1358
- rescue ::Leptris::XML::ParseError => e
1428
+ rescue ::Leptris::XML::Error => e
1429
+ # ParseError is the common class; other engine errors
1430
+ # (invalid encoding bytes surface as the base class) are
1431
+ # parse failures too — the facade names them all
1432
+ # (taxonomy differential, malformed class).
1359
1433
  raise Moxml::ParseError.new(e.message)
1360
1434
  end
1361
1435
  attachments.set(native_doc, :entity_markers, false)
@@ -1464,6 +1538,14 @@ module Moxml
1464
1538
  count = ffi.leptris_document_parse_diag_count(doc)
1465
1539
  return [] if count.zero?
1466
1540
 
1541
+ # leptris#1771: one face carries the diag's machine-readable
1542
+ # code plus line/column with the entry (nil when the engine
1543
+ # has no token code — pre-#1771 records). The face probe
1544
+ # keeps older bindings on kind+message (issue #408).
1545
+ lint_code = if ffi.respond_to?(:leptris_document_parse_diag_code)
1546
+ ffi.method(:leptris_document_parse_diag_code)
1547
+ end
1548
+
1467
1549
  kind_ptr = ::FFI::MemoryPointer.new(:int)
1468
1550
  diagnostics = []
1469
1551
  count.times do |i|
@@ -1473,7 +1555,19 @@ module Moxml
1473
1555
  ) == 1
1474
1556
 
1475
1557
  kind = DIAG_KINDS[kind_ptr.read_int] || :unknown
1476
- diagnostics << { kind: kind, message: buffer.read_string }
1558
+ diag = { kind: kind, message: buffer.read_string }
1559
+ if lint_code
1560
+ code_buf = ::FFI::MemoryPointer.new(:char, 128)
1561
+ line_ptr = ::FFI::MemoryPointer.new(:int)
1562
+ column_ptr = ::FFI::MemoryPointer.new(:int)
1563
+ lint_code.call(doc, i, code_buf, 128, line_ptr, column_ptr)
1564
+ code = code_buf.read_string
1565
+ diag[:code] = code.empty? ? nil : code
1566
+ diag[:line] = line_ptr.read_int.zero? ? nil : line_ptr.read_int
1567
+ diag[:column] =
1568
+ column_ptr.read_int.zero? ? nil : column_ptr.read_int
1569
+ end
1570
+ diagnostics << diag
1477
1571
  end
1478
1572
  diagnostics
1479
1573
  end
@@ -2217,9 +2311,19 @@ module Moxml
2217
2311
  def attribute_pairs(element)
2218
2312
  binding_element = to_binding(element)
2219
2313
  return super unless binding_element.respond_to?(:attribute_pairs)
2220
- return binding_element.attributes_snapshot.to_a if ELEMENT_ATTR_SNAPSHOT
2221
2314
 
2222
- binding_element.attribute_pairs
2315
+ pairs = if ELEMENT_ATTR_SNAPSHOT
2316
+ binding_element.attributes_snapshot.to_a
2317
+ else
2318
+ binding_element.attribute_pairs
2319
+ end
2320
+ # The engine exposes qualified names; the facade's listing
2321
+ # face is bare (Attribute#name), uniform with every adapter.
2322
+ pairs.map { |(name, value)| [local_name(name), value] }
2323
+ end
2324
+
2325
+ def local_name(name)
2326
+ name.include?(":") ? name.split(":", 2)[1] : name
2223
2327
  end
2224
2328
 
2225
2329
  def attribute_element(attr)
@@ -2496,6 +2600,27 @@ module Moxml
2496
2600
  end
2497
2601
 
2498
2602
  def add_child(parent, child)
2603
+ # Same-document Identity append — the builder hot path. One
2604
+ # document pair decides the verdict: the pin, the absorb
2605
+ # checks and the binding bridges only exist for
2606
+ # cross-document work, and the plain C append (version bump
2607
+ # inside) is the whole job. bind_call, not dispatch — the
2608
+ # contract modules shadow add_child on Identity receivers
2609
+ # (moxml#2560 note below).
2610
+ if NATIVE_MUTATIONS_COHERENT && parent.is_a?(NN) && child.is_a?(NN) &&
2611
+ !child.is_a?(CustomizedLeptris::EntityReference)
2612
+ parent_doc = NN_DOCUMENT.bind_call(parent)
2613
+ if parent_doc
2614
+ child_doc = NN_DOCUMENT.bind_call(child)
2615
+ if child_doc && parent_doc.equal?(child_doc)
2616
+ # add_child_native (leptris-ruby#509) plain-dispatches
2617
+ # on Identity receivers — one frame, no bind_call.
2618
+ return parent.add_child_native(child) if ADD_CHILD_NATIVE
2619
+
2620
+ return NN_ADD_CHILD.bind_call(parent, child)
2621
+ end
2622
+ end
2623
+ end
2499
2624
  # 1.9.311: binding-family children (created PIs, texts...) —
2500
2625
  # a DIRECT absorb makes the binding add below move the node
2501
2626
  # by reference. A source already owned elsewhere would need
@@ -2890,16 +3015,20 @@ module Moxml
2890
3015
  end
2891
3016
 
2892
3017
  def processing_instruction_content(node)
3018
+ # The model's PI content excludes the separating whitespace
3019
+ # after the target (Nokogiri semantics; uniform across
3020
+ # adapters — TODO.sota/02).
2893
3021
  if NATIVE_READ_LAYER && node.is_a?(NN)
2894
3022
  content = NN_CONTENT.bind_call(node)
2895
3023
  # NativeNode#content answers nil for PI-kind nodes too
2896
3024
  # (leptris-ruby#344 covers comments); bridge for the data.
2897
- return to_binding(node).content if content.nil?
3025
+ return to_binding(node).content.to_s.sub(/\A\s+/, "") if
3026
+ content.nil?
2898
3027
 
2899
- return content
3028
+ return content.sub(/\A\s+/, "")
2900
3029
  end
2901
3030
 
2902
- node.content
3031
+ node.content.to_s.sub(/\A\s+/, "")
2903
3032
  end
2904
3033
 
2905
3034
  def set_processing_instruction_content(node, content)
@@ -3035,6 +3164,12 @@ module Moxml
3035
3164
  # namespace-bound queries to the Ruby engine until the
3036
3165
  # engine grows correct prefix resolution.
3037
3166
  return nil if namespaces && !namespaces.empty?
3167
+ # Reverse axes answer reverse-ordered from the native
3168
+ # engine; route them to the Ruby engine (document order).
3169
+ return nil if REVERSE_AXIS_CACHE.get_or_set(expression) do
3170
+ expression_uses_reverse_axis?(expression)
3171
+ end
3172
+
3038
3173
  # The binding reports the root element parentless while moxml
3039
3174
  # roots it at the document, so parent-axis queries from the
3040
3175
  # root element keep the Ruby engine.
@@ -3121,6 +3256,14 @@ module Moxml
3121
3256
  node.is_a?(::Leptris::XML::Element)
3122
3257
  end
3123
3258
 
3259
+ # Reverse axes answer in reverse document order from the
3260
+ # native engine; XPath path results are document-ordered (the
3261
+ # model's order contract — TODO.sota/02, M3), so these
3262
+ # expressions route to the Ruby engine, which orders them.
3263
+ REVERSE_AXES = %w[ancestor ancestor-or-self preceding
3264
+ preceding-sibling parent].freeze
3265
+ REVERSE_AXIS_CACHE = XPath::Cache.new(200)
3266
+
3124
3267
  def expression_uses_parent_axis?(expression)
3125
3268
  ast = XPath::Parser.parse_with_cache(expression)
3126
3269
  contains_parent_axis?(ast)
@@ -3128,6 +3271,24 @@ module Moxml
3128
3271
  true
3129
3272
  end
3130
3273
 
3274
+ def expression_uses_reverse_axis?(expression)
3275
+ ast = XPath::Parser.parse_with_cache(expression)
3276
+ contains_reverse_axis?(ast)
3277
+ rescue XPath::SyntaxError
3278
+ false
3279
+ end
3280
+
3281
+ def contains_reverse_axis?(ast)
3282
+ return true if ast.type == :parent
3283
+ if ast.type == :axis && REVERSE_AXES.include?(ast.children.first)
3284
+ return true
3285
+ end
3286
+
3287
+ ast.children.any? do |child|
3288
+ child.is_a?(XPath::AST::Node) && contains_reverse_axis?(child)
3289
+ end
3290
+ end
3291
+
3131
3292
  def contains_parent_axis?(ast)
3132
3293
  return true if ast.type == :parent
3133
3294
  return true if ast.type == :axis && ast.children.first == "parent"
@@ -3297,13 +3458,12 @@ module Moxml
3297
3458
 
3298
3459
  def record_source_declaration(native_doc, xml_string)
3299
3460
  head = xml_string[0, 256]
3300
- had = if head.match?(SOURCE_DECLARATION_RE)
3301
- true
3302
- elsif head.match?(/\A\s*\z/)
3303
- xml_string.match?(SOURCE_DECLARATION_RE)
3304
- else
3305
- false
3306
- end
3461
+ # invalid bytes never reach regexes: named parse errors own
3462
+ # malformed input (taxonomy differential, malformed class)
3463
+ had = xml_string.valid_encoding? &&
3464
+ (head.match?(SOURCE_DECLARATION_RE) ||
3465
+ (head.match?(/\A\s*\z/) &&
3466
+ xml_string.match?(SOURCE_DECLARATION_RE)))
3307
3467
  attachments.set(native_doc, :had_source_declaration, had)
3308
3468
  end
3309
3469
  end