moxml 0.5.146 → 0.5.147

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +35 -0
  3. data/README.adoc +19 -1
  4. data/docs/_pages/adapter-compatibility.adoc +7 -0
  5. data/docs/_pages/divergences.adoc +90 -0
  6. data/docs/_references/index.adoc +9 -0
  7. data/docs/_references/processing-model.adoc +211 -0
  8. data/docs/index.adoc +9 -0
  9. data/lib/moxml/adapter/leptris.rb +30 -2
  10. data/lib/moxml/adapter/libxml.rb +5 -0
  11. data/lib/moxml/adapter/oga.rb +10 -0
  12. data/lib/moxml/document.rb +9 -0
  13. data/lib/moxml/version.rb +1 -1
  14. data/spec/consistency/divergence_register_spec.rb +109 -0
  15. data/spec/consistency/document_text_spec.rb +30 -0
  16. data/spec/consistency/eol_normalization_spec.rb +8 -8
  17. data/spec/consistency/loud_degradation_spec.rb +44 -0
  18. data/spec/consistency/model_coverage_audit_spec.rb +113 -0
  19. data/spec/consistency/spec_constant_uniqueness_spec.rb +29 -0
  20. data/spec/consistency/xpath_idioms_spec.rb +1 -1
  21. data/spec/moxml/adapter/leptris_detached_text_spec.rb +34 -0
  22. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +35 -0
  23. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +3 -3
  24. data/spec/moxml/c14n/adapter_delegation_spec.rb +3 -3
  25. data/spec/moxml/c14n/comments_pis_spec.rb +2 -2
  26. data/spec/moxml/c14n/subset_spec.rb +4 -4
  27. data/spec/moxml/c14n/w3c_examples_spec.rb +3 -1
  28. data/spec/moxml/declaration_preservation_spec.rb +2 -2
  29. data/spec/moxml/element_add_element_spec.rb +3 -3
  30. data/spec/moxml/element_attribute_pairs_spec.rb +2 -2
  31. data/spec/moxml/node_spec.rb +1 -1
  32. data/spec/moxml/plan_conformance_spec.rb +3 -3
  33. data/spec/moxml/sax_entity_parity_spec.rb +3 -3
  34. data/spec/moxml/sax_spec.rb +7 -7
  35. data/spec/moxml/struct_plan_spec.rb +2 -2
  36. data/spec/moxml/xpath_capabilities_spec.rb +13 -13
  37. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b529948a14e67202c6ad6fc9acbe6c06c033ba022f93ada6bc9e4001554ea583
4
- data.tar.gz: 68a76e1a69397aef0d0356e3ddd7bc200737a41333a70d6688cfca34d44e4c27
3
+ metadata.gz: 2e5797a825682b992d84b5ab9155d74f81fdeca24293b40ad7b9e58860c407f0
4
+ data.tar.gz: 11accb2da9da7cf656d28ab7e128da698f1702120332d94d06b4a20ea39ad112
5
5
  SHA512:
6
- metadata.gz: 3775a27419325f8c764d6d686c547e88f75bdc073cbe354bdd97d2e0516e68a4f83896d4cd840c25be26f7d6aafdca24a596d3c6aa2893e8596ed95d646c16fa
7
- data.tar.gz: 84c2f0abec2a55d990cdc921f9abac0c9206def8190d609fd298256dd035d586334dedc04bb97af496a98fe9053b2fa3b65f716a11e22e94516f2f8b91837e1b
6
+ metadata.gz: 2b8980eb872815020ed2b6d582172ffd62c08a426c2fa2104bd891cd5e2f1e69392fd82d9573215383399087cb686852c47fb10e1d93e812c1641f8eb5e4eeef
7
+ data.tar.gz: 924b76844411af051e0a9b02ea55788dbc4f5d0e6ec0be31ade4ded2c17c6c6e58356f687ef42e2d7934a4a8f684720f77b0d7688e108437aa64c68a41b68d06
@@ -71,3 +71,38 @@ jobs:
71
71
  env:
72
72
  LEPTRIS_BENCH_LOCK: "1"
73
73
  run: bundle exec rake benchmark:plan
74
+
75
+ # Order-flake tripwire (TODO.model/06 checker integrity): the same
76
+ # example order every run - a red here reproduces locally with
77
+ # `rspec --bisect --order rand:424242`. The per-PR matrix legs keep
78
+ # their random seeds for breadth; this one is the deterministic twin.
79
+ battery-fixed-seed:
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: actions/checkout@v6
83
+ with:
84
+ submodules: "recursive"
85
+
86
+ - name: Set up Ruby
87
+ uses: ruby/setup-ruby@v1
88
+ with:
89
+ ruby-version: "3.4"
90
+ bundler-cache: false
91
+
92
+ - name: Install ragel
93
+ run: sudo apt-get update && sudo apt-get install -y ragel
94
+
95
+ - name: Install ruby-ll (for rake vendor:prepare)
96
+ run: gem install ruby-ll --no-document
97
+
98
+ - name: Generate ragel / ruby-ll outputs in vendored forks
99
+ run: rake vendor:prepare
100
+
101
+ - name: Install dependencies
102
+ run: bundle install
103
+
104
+ - name: Compile liboga / libll native extensions
105
+ run: rake vendor:compile
106
+
107
+ - name: Full battery at the fixed seed
108
+ run: bundle exec rspec --order rand:424242
data/README.adoc CHANGED
@@ -15,10 +15,28 @@ It offers a consistent API that abstracts away the underlying XML implementation
15
15
  details while maintaining high performance through efficient node mapping and
16
16
  native XPath querying.
17
17
 
18
+ Moxml's mission: **make "which XML engine am I running?" a deployment decision
19
+ instead of an API decision.** Libraries write once against a single XML
20
+ processing model — one object model, one name/namespace resolution model, one
21
+ XPath, one serialization and canonicalization contract — and the engine
22
+ underneath (Nokogiri, Leptris, REXML, Oga, Ox, LibXML) is a configuration
23
+ choice.
24
+
25
+ The invariant that makes this work: any program written against the facade
26
+ produces the same observable outcome on every adapter. Performance may vary
27
+ across engines — semantics may not. Where an engine cannot deliver a
28
+ capability, it fails loudly (a named error); where an engine genuinely
29
+ diverges, the divergence is registered and pinned — never silent. The model is
30
+ specified normatively in the
31
+ https://lutaml.github.io/moxml/_references/processing-model[Processing Model]
32
+ document, pinned as executable expectations in the consistency suite, and its
33
+ known engine differences are tracked in the
34
+ https://lutaml.github.io/moxml/_pages/divergences[divergence register].
35
+
18
36
  Key features:
19
37
 
20
38
  * Intuitive, Ruby-idiomatic API for XML manipulation
21
- * Consistent interface across different XML libraries
39
+ * One processing model, consistently implemented across different XML libraries
22
40
  * Efficient node mapping for XPath queries
23
41
  * Support for all XML node types and features
24
42
  * Easy switching between XML processing engines
@@ -8,6 +8,13 @@ toc::[]
8
8
 
9
9
  This document provides detailed compatibility information for all supported XML adapters in Moxml, explaining which features are fully supported, partially supported, or not supported by each adapter.
10
10
 
11
+ The authoritative sources for cross-adapter semantics are the
12
+ link:../_references/processing-model[Processing Model] (the normative
13
+ single model every adapter implements) and the
14
+ link:divergences[divergence register] (every known, pinned engine
15
+ difference, D-01..D-12). A capability an adapter cannot deliver raises
16
+ a named error — it never answers a divergent result silently.
17
+
11
18
  == Test suite results
12
19
 
13
20
  **Overall:** 2,182 examples, 0 failures, 97 pending
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: Divergences
3
+ nav_order: 4
4
+ ---
5
+
6
+ == Divergence register
7
+
8
+ Every known cross-adapter divergence from the
9
+ link:../_references/processing-model[processing model] is registered
10
+ here. Contract:
11
+
12
+ * A divergence is either **fixed**, **loud** (a named raise), or
13
+ **SPEC'D** (documented here and pinned with a skip in the
14
+ consistency suite, citing the entry ID).
15
+ * Consistency-suite exemptions must cite a register ID. An
16
+ undocumented divergence is a bug — report it against this register.
17
+
18
+ [cols="2,4,6"]
19
+ |===
20
+ | ID | Adapter(s) | Divergence
21
+
22
+ | D-01
23
+ | oga
24
+ | The `xmlns:` default-namespace XPath idiom is unsupported by oga's
25
+ own engine (explicit bindings work). Pinned in
26
+ `spec/consistency/xpath_idioms_spec.rb`.
27
+
28
+ | D-02
29
+ | oga
30
+ | Raw CR preserved in text content — §2.11 EOL normalization not
31
+ applied at parse. Pinned in `spec/consistency/eol_normalization_spec.rb`.
32
+
33
+ | D-03
34
+ | ox, headed_ox
35
+ | `
` character references collapse to LF. Pinned in
36
+ `spec/consistency/eol_normalization_spec.rb`.
37
+
38
+ | D-04
39
+ | oga
40
+ | The XML declaration surfaces as a `<?xml?>` PI; PI content keeps its
41
+ separator space. Pinned in `spec/moxml/c14n/adapter_delegation_spec.rb`.
42
+
43
+ | D-05
44
+ | ox, headed_ox
45
+ | PI content dropped at parse. Pinned in
46
+ `spec/moxml/c14n/adapter_delegation_spec.rb`.
47
+
48
+ | D-06
49
+ | ox, headed_ox
50
+ | Comment content padding stripped at parse. Pinned in
51
+ `spec/moxml/c14n/subset_spec.rb`.
52
+
53
+ | D-07
54
+ | rexml
55
+ | `text()` node tests return no text nodes (adapter XPath capability
56
+ gap, both axes). Pinned in `spec/moxml/c14n/subset_spec.rb`.
57
+
58
+ | D-08
59
+ | c14n subsystem
60
+ | Document-level PI/comment rendering not yet implemented (all
61
+ adapters; a subsystem TODO, not an engine divergence). Tracked in
62
+ `TODO.c14n/09-comments-pis-tests.md`.
63
+
64
+ | D-09
65
+ | oga, ox, headed_ox, libxml, leptris
66
+ | Element-level `to_xml(indent:)` silently ignored (libxml emits a
67
+ partial, unindented newline form). Document-level HTML/XHTML
68
+ indentation is honored on leptris/nokogiri and raises on incapable
69
+ engines. Pinned in
70
+ `spec/moxml/adapter/shared_examples/adapter_contract.rb`.
71
+
72
+ | D-10
73
+ | ox, headed_ox
74
+ | SAX emits no separate CDATA/comment/processing-instruction events
75
+ (sequences fold into text or are dropped). Pinned in
76
+ `spec/moxml/sax_spec.rb`.
77
+
78
+ | D-11
79
+ | oga
80
+ | SAX parse is more lenient with malformed input than the model's
81
+ error authority. Pinned in `spec/moxml/sax_spec.rb`.
82
+
83
+ | D-12
84
+ | ox, headed_ox
85
+ | The XPath translation covers a subset: no positional predicates,
86
+ attribute-value predicates, namespace/default-namespace tests,
87
+ `position()`/`count()`, `text()`, boolean expressions, `child::`/
88
+ `descendant::` axes, or unions. Pinned in
89
+ `spec/moxml/xpath_capabilities_spec.rb` and `spec/moxml/node_spec.rb`.
90
+ |===
@@ -7,6 +7,15 @@ nav_order: 1
7
7
 
8
8
  Detailed technical specifications and API documentation for Moxml.
9
9
 
10
+ === Processing model
11
+
12
+ link:processing-model[Processing Model]::
13
+ The normative statement of Moxml's single XML processing model — the
14
+ semantics every adapter implements.
15
+
16
+ link:../_pages/divergences[Divergences]::
17
+ The register of known, pinned cross-adapter divergences.
18
+
10
19
  === API reference
11
20
 
12
21
  link:document-api[Document API]::
@@ -0,0 +1,211 @@
1
+ ---
2
+ title: Processing Model
3
+ nav_order: 3
4
+ ---
5
+
6
+ == The processing model (normative)
7
+
8
+ This document is the **single normative statement of Moxml's XML
9
+ processing model**. Every adapter implements this model; no adapter's
10
+ native semantics leak through it. When implementations disagree, the
11
+ model wins — engines converge to it, never the reverse.
12
+
13
+ The model's executable form is the consistency suite
14
+ (`spec/consistency/`). Known, unfixable engine differences are
15
+ registered in link:../_pages/divergences[the divergence register] and
16
+ skip with their entry ID — an undocumented divergence is a bug.
17
+
18
+ === Status and precedence
19
+
20
+ 1. This document — the model.
21
+ 2. `spec/consistency/` — the model pinned as executable expectations.
22
+ 3. Adapter implementations and capability gates — representations.
23
+
24
+ A semantics change amends this document first (see
25
+ `TODO.model/05-model-evolution-protocol.md`).
26
+
27
+ === Mission and invariant
28
+
29
+ Moxml exists to make "which XML engine am I running?" a **deployment
30
+ decision instead of an API decision**. The invariant that enables it:
31
+ any program written against the facade produces the same observable
32
+ outcome on every adapter. The engine is unobservable except through
33
+ declared channels: capability gates (loud raises), registered
34
+ divergences, and performance.
35
+
36
+ * Performance may vary across engines — semantics may not.
37
+ * Absence of capability is legal (a named raise). Absence of
38
+ equivalence is not (a different answer).
39
+
40
+ === Object model
41
+
42
+ Node kinds:: `Document`, `Element`, `Attribute`, `Text`, `Comment`,
43
+ `Cdata`, `Declaration`, `Doctype`, `ProcessingInstruction`,
44
+ `Namespace`, `EntityReference`.
45
+
46
+ Identity:: Parsing and traversal may mint distinct wrapper objects for
47
+ the same underlying node; node identity is a property of the
48
+ underlying tree, not of wrapper object identity. Comparisons use node
49
+ equality semantics, never `equal?` on wrappers.
50
+
51
+ Ownership:: The `Document` owns the tree's lifetime; nodes are valid
52
+ within it. Deterministic release is available via `Document#free`.
53
+
54
+ Declarations are state:: `Declaration` (and the doctype token) are
55
+ document *state*, not child nodes: they never appear in `children`
56
+ listings or serialization child walks.
57
+
58
+ === Document order and traversal
59
+
60
+ * `children` lists nodes in document order.
61
+ * Walks (`each_node`, `materialize`) visit depth-first, pre-order.
62
+ * `Document#children` and `#root` never include the declaration;
63
+ doctype exposure follows the Nokogiri contract.
64
+ * `NodeSet` preserves document order for axis results; unions preserve
65
+ first-encounter document order.
66
+
67
+ === Names and namespaces
68
+
69
+ * Names are **expanded names**: a name test matches by namespace *URI*,
70
+ never by prefix spelling.
71
+ * Unprefixed element name tests match **no-namespace** elements only
72
+ (XPath 1.0). Unprefixed attribute name tests likewise match
73
+ no-namespace attributes.
74
+ * Reserved prefixes: `xml` is bound to the XML namespace always;
75
+ `xmlns:` in an expression addresses the **default namespace** (the
76
+ libxml2 idiom, adopted model-wide).
77
+ * Bindings hashes accept both string and symbol keys; explicit
78
+ bindings **win** over in-scope document declarations.
79
+ * `Element#name` returns the bare tag name; non-element DOM names are
80
+ `"text"` / `"comment"`; a PI's name is its target.
81
+
82
+ === Access semantics
83
+
84
+ `text`:: Concatenated descendant text (elements); a text node's own
85
+ content; entity references restored when the document is
86
+ entity-bearing.
87
+
88
+ `content`:: The node's content per its kind; on text-like nodes, with
89
+ entity-marker restoration; memoized against mutation.
90
+
91
+ `raw_content`:: The backing text **without** entity restoration.
92
+
93
+ `[]` / `attributes` / `attribute_pairs`:: `[]` reads an attribute by
94
+ bare name (no-namespace) or prefixed name (resolved by URI);
95
+ values are entity-restored when the document bears markers.
96
+ `attribute_pairs` is the read-only listing ([name, value] pairs,
97
+ document order, duplicates included). `remove_attribute` returns the
98
+ removed attribute or nil — including for namespace-prefixed targets
99
+ with an unambiguous local name.
100
+
101
+ `remove`/mutation returns:: Mutators return the contract object
102
+ (removed node, new node) — never a silent no-op self.
103
+
104
+ === Parsing
105
+
106
+ * Modes: strict (errors raise) and recover (errors surface via
107
+ `parse_diagnostics` / `parse_errors`, both `[{kind:, message:}]`).
108
+ * HUGE mode: over-cap text (> 10 MB) parses completely.
109
+ * EOL normalization (XML 1.0 §2.11): CRLF → LF; bare CR → LF in text,
110
+ CDATA, and parsed content; attribute-value CRLF collapses to one
111
+ space; `&#xD;` survives as a literal CR.
112
+ * Entity modes: `:expand` (default — built-ins resolved at parse),
113
+ `:keep` (reference nodes preserved with marker machinery),
114
+ `:disabled`, `:custom` (provider). Restoration of marker-bearing
115
+ values is marker-driven and generation-stamped.
116
+
117
+ === XPath evaluation
118
+
119
+ * XPath 1.0 semantics on every adapter. Engines without an evaluator
120
+ (Ox, HeadedOx; leptris for shapes its engine cannot evaluate) run
121
+ **moxml's own pure-Ruby engine** — the model's semantics do not
122
+ depend on the engine shipping one.
123
+ * Document-context **relative name-test paths** resolve laxly —
124
+ descendant-anchored at the root (`doc.at("a/b")` finds `//a/b`),
125
+ matching Nokogiri's `Document#at`/`#search`. Axes, node types, `.`
126
+ and `..` keep strict document context.
127
+ * Declared idioms (pinned in `spec/consistency/xpath_idioms_spec.rb`):
128
+ `xmlns:` default-namespace matching; attribute terminals in every
129
+ position (`//a/@id`, `//@id`, `r/a/@id`); symbol- and string-keyed
130
+ bindings resolve identically.
131
+
132
+ === Serialization
133
+
134
+ [cols="1,3"]
135
+ |===
136
+ | Shape | Contract
137
+
138
+ | `to_xml`
139
+ | Declaration per configuration; empty elements per `expand_empty`;
140
+ namespace declarations preserved.
141
+
142
+ | `to_html`
143
+ | HTML method: void elements unclosed (`<br>`), raw text
144
+ (script/style) verbatim, no XML declaration, DOCTYPE first.
145
+
146
+ | `to_xhtml`
147
+ | XML method on HTML trees: void elements self-closed (`<br/>`),
148
+ non-void elements paired, raw text XML-escaped, no declaration
149
+ unless asked. Also selected by `to_html(xhtml: true)`.
150
+
151
+ | Indentation
152
+ | Document-level `to_html`/`to_xhtml` indentation composes with the
153
+ `indent:`/`indent_text:` knobs and raises `Moxml::AdapterError` on
154
+ incapable engines. Element-level `to_xml(indent:)` is honored on
155
+ nokogiri/rexml; other engines currently ignore it — registered as
156
+ link:../_pages/divergences[D-09], not silently blessed.
157
+
158
+ | Line endings
159
+ | `line_ending:` normalizes output newlines on every shape.
160
+ |===
161
+
162
+ === Canonicalization
163
+
164
+ * Algorithms: Inclusive 1.0, Exclusive 1.0, Inclusive 1.1, plus
165
+ XPath-driven subset canonicalization (enveloped-signature oracle).
166
+ * Native delegations (leptris engine, libxml2) answer **byte-identical**
167
+ output to the Ruby reference path (byte-safety probed before
168
+ engagement).
169
+ * `Moxml::C14n.equivalent?` is the algorithm-aware comparison.
170
+
171
+ === XSLT
172
+
173
+ XSLT 1.0 through engines that ship a processor (libxslt; leptris
174
+ 1.0–3.0). Compilation and transform failures raise named
175
+ `Moxml::XsltError`s. Engines without a processor raise
176
+ `Moxml::NotImplementedError` — the face is never silently absent.
177
+
178
+ === SAX
179
+
180
+ * The callback stream (`on_start_element`, `on_characters`, `on_error`,
181
+ …) is the authoritative event sequence.
182
+ * The record-drain fast path (`on_sax_records`) replays **exactly** the
183
+ callback stream (spec-pinned equality). Documents whose parse would
184
+ emit an ERROR event are outside the drainable subset — the drain
185
+ refuses them so `on_error` retains authority.
186
+
187
+ === Materialization
188
+
189
+ `materialize`, `materialize_fields`, `Plan`, and `StructPlan` produce
190
+ **identical records across all adapters** (conformance spec). Plan name
191
+ matching is global; unmatched subtrees are dropped per the pinned
192
+ semantics.
193
+
194
+ === Errors and loud degradation
195
+
196
+ * `Moxml::Error` hierarchy; `Moxml::XPathError`, `Moxml::XsltError`,
197
+ `Moxml::AdapterError` (e.g. schema-handle/adapter misroute),
198
+ `Moxml::NotImplementedError` for unsupported capability contracts.
199
+ * Every gated surface answers the contract or raises the named error —
200
+ never returns a divergent answer.
201
+ * Adapter degradation is loud: a present-but-unusable preferred adapter
202
+ warns at configuration and misroutes fail at the wrap, not deep in
203
+ a call.
204
+
205
+ === Performance: a declared, non-semantic channel
206
+
207
+ Acceleration (native read layers, C minting, record drains, plan
208
+ streams) always lands behind version + face gates and degrades to the
209
+ spec-pinned fallback. Cross-adapter performance variance is sanctioned
210
+ exactly one place: the benchmark parity locks. Semantics never ride a
211
+ gate.
data/docs/index.adoc CHANGED
@@ -14,6 +14,15 @@ applications. It offers a consistent API that abstracts away the underlying
14
14
  XML implementation details while maintaining high performance through
15
15
  efficient node mapping and native XPath querying.
16
16
 
17
+ Moxml's mission: **make "which XML engine am I running?" a deployment
18
+ decision instead of an API decision.** Libraries write once against a single
19
+ XML processing model; the engine underneath is a configuration choice. Any
20
+ program written against the facade produces the same observable outcome on
21
+ every adapter — performance may vary, semantics may not. The model is
22
+ specified normatively in the
23
+ link:_references/processing-model[Processing Model] document; known engine
24
+ differences are tracked in the link:_pages/divergences[divergence register].
25
+
17
26
  == Key features
18
27
 
19
28
  * **Unified interface** - Consistent API across different XML libraries
@@ -1333,10 +1333,28 @@ module Moxml
1333
1333
  native_fragment.to_html
1334
1334
  end
1335
1335
 
1336
- def parse_html(html, _options = {}, _context = nil)
1336
+ # leptris#1771's parse-lint (§13.2.5): the binding's parse_html
1337
+ # accepts collect_errors (and mode) and records the error-token
1338
+ # stream into the document's diagnostic channel, which
1339
+ # parse_diagnostics reads. Face-probed — the zero-dep gem lets
1340
+ # consumers resolve bindings predating the kwarg.
1341
+ COLLECT_ERRORS_NATIVE = ::Leptris::XML
1342
+ .method(:parse_html).parameters.any? do |_, key|
1343
+ key == :collect_errors
1344
+ end
1345
+
1346
+ def parse_html(html, options = {}, _context = nil)
1337
1347
  html_string = html.is_a?(IO) || html.is_a?(StringIO) ? html.read : html.to_s
1338
1348
  native_doc = begin
1339
- ::Leptris::XML.parse_html(html_string)
1349
+ if COLLECT_ERRORS_NATIVE && options[:collect_errors]
1350
+ ::Leptris::XML.parse_html(
1351
+ html_string,
1352
+ mode: options.fetch(:mode, :whatwg),
1353
+ collect_errors: true,
1354
+ )
1355
+ else
1356
+ ::Leptris::XML.parse_html(html_string)
1357
+ end
1340
1358
  rescue ::Leptris::XML::ParseError => e
1341
1359
  raise Moxml::ParseError.new(e.message)
1342
1360
  end
@@ -2830,6 +2848,16 @@ module Moxml
2830
2848
  else
2831
2849
  node.content = content.to_s
2832
2850
  end
2851
+ rescue ::Leptris::XML::Error => e
2852
+ # Detached (parentless) nodes answer the engine's bare
2853
+ # "Invalid argument" (issue #401); created-unattached nodes
2854
+ # work, so only the failing detachment is re-raised, named.
2855
+ raise if node.parent
2856
+
2857
+ raise Moxml::Error,
2858
+ "cannot set text content on a detached (parentless) " \
2859
+ "node — attach it to a document first (issue #401): " \
2860
+ "#{e.message}"
2833
2861
  end
2834
2862
 
2835
2863
  def cdata_content(node)
@@ -768,6 +768,11 @@ module Moxml
768
768
 
769
769
  native_node = unpatch_node(node)
770
770
  return nil unless native_node
771
+ # Documents are container nodes (issue #400): their text is
772
+ # the root's concatenated descendant text; LibXML::XML::
773
+ # Document carries no content face of its own.
774
+ return native_node.root&.content.to_s if
775
+ native_node.is_a?(::LibXML::XML::Document)
771
776
 
772
777
  native_node.content
773
778
  end
@@ -452,6 +452,16 @@ module Moxml
452
452
  end
453
453
 
454
454
  def text_content(node)
455
+ # Documents are container nodes (issue #400): their text is
456
+ # the whole tree's text; Oga documents carry no text face
457
+ # (root_node answers the document itself), so fold the
458
+ # element children.
459
+ if node.is_a?(::Oga::XML::Document)
460
+ return node.children
461
+ .filter_map { |c| c.text if c.is_a?(::Oga::XML::Element) }
462
+ .join
463
+ end
464
+
455
465
  node.text
456
466
  end
457
467
 
@@ -73,6 +73,15 @@ module Moxml
73
73
  root_element ? Moxml::Node.wrap(root_element, context) : nil
74
74
  end
75
75
 
76
+ # Documents are container nodes: #text concatenates descendant
77
+ # text, Nokogiri's Document#text contract (issue #400) — the base
78
+ # Node#text answered "" and silently emptied consumers'
79
+ # content-preservation checks.
80
+ def text
81
+ val = adapter.text_content(@native)
82
+ entity_bearing? ? adapter.restore_entities(val) : val
83
+ end
84
+
76
85
  # Materialize the root subtree — see Moxml::Materializer.
77
86
  def materialize(&block)
78
87
  return to_enum(:materialize) unless block
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.146"
4
+ VERSION = "0.5.147"
5
5
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Divergence register linkage (TODO.model/02): the register
6
+ # (docs/_pages/divergences.adoc) and the consistency suite must stay
7
+ # mechanically consistent — every capability-related skip cites a
8
+ # register ID, every register entry is cited by at least one spec, and
9
+ # no spec cites an unregistered ID.
10
+ RSpec.describe "divergence register linkage" do
11
+ REGISTER_PATH = "docs/_pages/divergences.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
12
+ SCAN_GLOBS = %w[spec/consistency/**/*.rb spec/moxml/**/*.rb].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
13
+ NEXT_GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
+ /same engine as/, # engine dedup, not a divergence
15
+ /source_adapter == target_adapter/, # self round-trip
16
+ /MAX_SIZE/, # size guard
17
+ /AVAILABLE_ADAPTERS\.include/, # availability filter
18
+ /skip_shapes/, # shape routing (IDs cited above)
19
+ /leptris_fixed\?/, # version gate
20
+ /source_elements/, # data-presence guard
21
+ /next unless match/, # scanner-internal guard
22
+ ].freeze
23
+
24
+ # Version/availability gates are not divergences: a binding too old
25
+ # or an adapter not installed is a deployment fact, not an
26
+ # engine-vs-model difference at equal versions.
27
+ GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
28
+ /unavailable/i, /not available/, /predates/, /not installed/i,
29
+ /not present/i, /requires/i, /required\z/, /has no \S+ face/,
30
+ /lacks the /, /carries /, /degraded below/, /lockstep/i,
31
+ /unreleased/i, /stood down/, /recovered without/,
32
+ /Only \S+ available in CI/i, /adapter-specific/i, /noblanks/,
33
+ /SKIP_ parse flags/, /engine < \d/
34
+ ].freeze
35
+
36
+ def register_ids
37
+ File.readlines(REGISTER_PATH)
38
+ .filter_map { |line| line[/\A\|\s*(D-\d+)\s*$/, 1] }
39
+ .uniq
40
+ end
41
+
42
+ def spec_files
43
+ SCAN_GLOBS.flat_map { |g| Dir[g] }.uniq
44
+ end
45
+
46
+ def cited_ids
47
+ spec_files.flat_map { |path| File.readlines(path) }
48
+ .flat_map { |line| line.scan(/D-\d+/) }
49
+ .uniq
50
+ end
51
+
52
+ it "has a register with entries" do
53
+ expect(register_ids).not_to be_empty
54
+ end
55
+
56
+ it "requires capability-related skips to cite a register ID" do
57
+ offenders = []
58
+ spec_files.each do |path|
59
+ File.readlines(path).each_with_index do |line, i|
60
+ next unless (msg = line[/skip\s+[&(]?\s*["'](.*?)["']/, 1])
61
+
62
+ next if GATE.any? { |re| re.match?(msg) }
63
+ next if msg.match?(/D-\d+/)
64
+
65
+ offenders << "#{path}:#{i + 1} #{msg.strip[0, 60]}"
66
+ end
67
+ end
68
+ expect(offenders).to be_empty,
69
+ "capability skips must cite a divergence-register " \
70
+ "ID (D-NN): #{offenders.join('; ')}"
71
+ end
72
+
73
+ it "requires adapter-exempting next-guards to cite a nearby register ID" do
74
+ offenders = []
75
+ spec_files.each do |path|
76
+ next if path.end_with?("divergence_register_spec.rb")
77
+
78
+ lines = File.readlines(path)
79
+ lines.each_with_index do |line, i|
80
+ next unless line.match?(/^\s*next\s+(if|unless)\s/)
81
+ next if NEXT_GATE.any? { |re| re.match?(line) }
82
+ next if line.match?(/D-\d+/)
83
+
84
+ context = lines[[i - 4, 0].max...i] + lines[(i + 1)..(i + 4)].to_a
85
+ next if context.any? { |l| l.match?(/D-\d+/) }
86
+
87
+ offenders << "#{path}:#{i + 1} #{line.strip[0, 60]}"
88
+ end
89
+ end
90
+ expect(offenders).to be_empty,
91
+ "adapter-exempting next-guards must cite a " \
92
+ "divergence-register ID (D-NN) on or above the " \
93
+ "line: #{offenders.join('; ')}"
94
+ end
95
+
96
+ it "cites every register entry from at least one spec" do
97
+ missing = register_ids - cited_ids
98
+ expect(missing).to be_empty,
99
+ "register entries never cited by a spec: " \
100
+ "#{missing.join(', ')}"
101
+ end
102
+
103
+ it "never cites an unregistered divergence ID" do
104
+ orphans = cited_ids - register_ids
105
+ expect(orphans).to be_empty,
106
+ "specs cite IDs missing from the register: " \
107
+ "#{orphans.join(', ')}"
108
+ end
109
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #400: documents are container nodes — Document#text
6
+ # concatenates descendant text (Nokogiri's Document#text contract).
7
+ # The base Node#text answered "" and silently emptied consumers'
8
+ # content-preservation checks (canon's formatter asserted through
9
+ # doc.text).
10
+ RSpec.describe "Document#text concatenates descendant text" do
11
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
12
+ context "with #{adapter_name}" do
13
+ let(:ctx) { Moxml.new(adapter_name) }
14
+
15
+ it "answers the concatenated descendant text" do
16
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
17
+ expect(doc.text).to eq("xy")
18
+ end
19
+
20
+ it "matches root.text (both containers recurse)" do
21
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
22
+ expect(doc.text).to eq(doc.root.text)
23
+ end
24
+
25
+ it "answers an empty string for element-free documents" do
26
+ expect(ctx.parse("<r/>").text).to eq("")
27
+ end
28
+ end
29
+ end
30
+ end