moxml 0.5.149 → 0.5.151

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/performance-release.yml +79 -0
  5. data/.github/workflows/performance.yml +59 -0
  6. data/Rakefile +23 -0
  7. data/benchmark/README.md +106 -0
  8. data/benchmark/scoreboard.json +115 -0
  9. data/benchmark/scoreboard.rb +556 -0
  10. data/bin/generate-model-coverage +21 -0
  11. data/docs/_pages/adapter-compatibility.adoc +3 -3
  12. data/docs/_pages/divergences.adoc +32 -41
  13. data/docs/_pages/ecosystem.adoc +62 -0
  14. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  15. data/docs/_pages/headed-ox.adoc +2 -2
  16. data/docs/_pages/index.adoc +4 -0
  17. data/docs/_pages/model-coverage.adoc +57 -0
  18. data/docs/_pages/performance.adoc +29 -0
  19. data/docs/_pages/why-moxml.adoc +87 -0
  20. data/docs/_references/processing-model.adoc +42 -3
  21. data/lib/moxml/adapter/base.rb +80 -0
  22. data/lib/moxml/adapter/leptris.rb +238 -4
  23. data/lib/moxml/adapter/libxml.rb +34 -2
  24. data/lib/moxml/adapter/nokogiri.rb +10 -0
  25. data/lib/moxml/adapter/oga.rb +39 -7
  26. data/lib/moxml/adapter/ox.rb +8 -0
  27. data/lib/moxml/adapter/rexml.rb +17 -2
  28. data/lib/moxml/attribute_resolver.rb +4 -0
  29. data/lib/moxml/c14n/data_model.rb +8 -0
  30. data/lib/moxml/c14n/processor.rb +17 -8
  31. data/lib/moxml/context.rb +53 -2
  32. data/lib/moxml/document.rb +28 -4
  33. data/lib/moxml/element.rb +13 -1
  34. data/lib/moxml/node.rb +85 -11
  35. data/lib/moxml/node_set.rb +31 -1
  36. data/lib/moxml/relaxng.rb +62 -0
  37. data/lib/moxml/sax/block_handler.rb +7 -0
  38. data/lib/moxml/sax/handler.rb +20 -0
  39. data/lib/moxml/version.rb +1 -1
  40. data/lib/moxml/xpath.rb +28 -0
  41. data/lib/moxml.rb +1 -0
  42. data/spec/consistency/adapter_parity_spec.rb +22 -0
  43. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  44. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  45. data/spec/consistency/published_docs_spec.rb +49 -0
  46. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  47. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  48. data/spec/moxml/adapter/leptris_spec.rb +113 -3
  49. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  50. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  51. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  52. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  53. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  54. data/spec/moxml/c14n/subset_spec.rb +2 -12
  55. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  56. data/spec/moxml/element_indent_spec.rb +34 -0
  57. data/spec/moxml/node_spec.rb +0 -3
  58. data/spec/moxml/relax_ng_spec.rb +67 -0
  59. data/spec/moxml/sax_spec.rb +45 -74
  60. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  61. data/spec/support/input_taxonomy.rb +7 -0
  62. data/spec/support/model_coverage.rb +159 -0
  63. metadata +17 -2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "spec_helper"
4
+ require "support/model_coverage"
4
5
 
5
6
  # Model coverage matrix (TODO.model/03): every face family of the
6
7
  # processing model is governed for every adapter — covered by a pin
@@ -8,84 +9,13 @@ require "spec_helper"
8
9
  # a registered divergence (adapter => D-ID), or loud (adapter =>
9
10
  # the named error class). An unpinned family is an ungoverned cell:
10
11
  # this audit fails until it has one of the three.
11
- # See docs/_references/processing-model.adoc for the model itself.
12
+ #
13
+ # The matrix itself lives in spec/support/model_coverage.rb: the same
14
+ # data drives this audit, the model document's pin lists, and the
15
+ # published coverage page. See docs/_references/processing-model.adoc
16
+ # for the model itself.
12
17
  RSpec.describe "model coverage matrix" do
13
- MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
- "document order and children listing" => {
15
- section: "Document order and traversal",
16
- pin: "spec/consistency/adapter_parity_spec.rb",
17
- },
18
- "name resolution" => {
19
- section: "Names and namespaces",
20
- pin: "spec/consistency/xpath_idioms_spec.rb",
21
- },
22
- "namespace resolution and bindings normalization" => {
23
- section: "Names and namespaces",
24
- pin: "spec/consistency/xpath_idioms_spec.rb",
25
- },
26
- "attribute access" => {
27
- section: "Access semantics",
28
- pin: "spec/moxml/attribute_resolver_spec.rb",
29
- },
30
- "text/content/raw_content" => {
31
- section: "Access semantics",
32
- pin: "spec/moxml/text_spec.rb",
33
- },
34
- "parsing modes and diagnostics" => {
35
- section: "Parsing",
36
- pin: "spec/moxml/document_parse_diagnostics_spec.rb",
37
- },
38
- "EOL normalization" => {
39
- section: "Parsing",
40
- pin: "spec/consistency/eol_normalization_spec.rb",
41
- exempt: { oga: "D-02", ox: "D-03" },
42
- },
43
- "entity modes" => {
44
- section: "Parsing",
45
- pin: "spec/moxml/entity_spec.rb",
46
- },
47
- "XPath evaluation and declared idioms" => {
48
- section: "XPath evaluation",
49
- pin: "spec/consistency/xpath_idioms_spec.rb",
50
- exempt: { oga: "D-01" },
51
- },
52
- "serialization shapes (xml/html/xhtml)" => {
53
- section: "Serialization",
54
- pin: "spec/moxml/xhtml_output_spec.rb",
55
- },
56
- "indentation knobs" => {
57
- section: "Serialization",
58
- pin: "spec/moxml/to_html_spec.rb",
59
- exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
60
- libxml: "D-09", leptris: "D-09" },
61
- },
62
- "canonicalization" => {
63
- section: "Canonicalization",
64
- pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
65
- exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
66
- },
67
- "XSLT" => {
68
- section: "XSLT",
69
- pin: "spec/consistency/xslt_spec.rb",
70
- loud: { oga: "Moxml::NotImplementedError",
71
- ox: "Moxml::NotImplementedError",
72
- headed_ox: "Moxml::NotImplementedError",
73
- rexml: "Moxml::NotImplementedError",
74
- libxml: "Moxml::NotImplementedError" },
75
- },
76
- "SAX" => {
77
- section: "SAX",
78
- pin: "spec/moxml/sax_spec.rb",
79
- },
80
- "materialization" => {
81
- section: "Materialization",
82
- pin: "spec/moxml/plan_conformance_spec.rb",
83
- },
84
- "loud capability contracts" => {
85
- section: "Errors and loud degradation",
86
- pin: "spec/consistency/loud_degradation_spec.rb",
87
- },
88
- }.freeze
18
+ MODEL_MATRIX = ModelCoverage::MODEL_COVERAGE_MATRIX # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
89
19
 
90
20
  def register_ids
91
21
  File.readlines("docs/_pages/divergences.adoc")
@@ -134,12 +64,7 @@ RSpec.describe "model coverage matrix" do
134
64
  # section vanished fails too.
135
65
  MODEL_DOC = "docs/_references/processing-model.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
136
66
 
137
- NON_FACE_SECTIONS = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
138
- "Status and precedence",
139
- "Mission and invariant",
140
- "Object model",
141
- "Performance: a declared, non-semantic channel",
142
- ].freeze
67
+ NON_FACE = ModelCoverage::MODEL_COVERAGE_NON_FACE # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
143
68
 
144
69
  def model_sections
145
70
  File.readlines(MODEL_DOC).filter_map do |line|
@@ -147,6 +72,24 @@ RSpec.describe "model coverage matrix" do
147
72
  end.uniq
148
73
  end
149
74
 
75
+ # Document-side pin lists (TODO.sota/07): every section carries a
76
+ # `Pins::` line naming the specs that enforce it (`Pins:: none` for
77
+ # non-face sections). Parsed as section => [paths] or nil when the
78
+ # line is missing.
79
+ def model_pin_lists
80
+ lists = {}
81
+ current = nil
82
+ File.readlines(MODEL_DOC).each do |line|
83
+ if (heading = line[/\A=== (\S.*)$/, 1])
84
+ current = heading.strip
85
+ lists[current] = nil
86
+ elsif current && (entry = line[/\APins:: (.*)$/, 1])
87
+ lists[current] = entry.scan(/`([^`]+)`/).flatten
88
+ end
89
+ end
90
+ lists
91
+ end
92
+
150
93
  it "cites an existing model section for every face family" do
151
94
  sections = model_sections
152
95
  unknown = MODEL_MATRIX.filter_map do |family, cell|
@@ -162,7 +105,7 @@ RSpec.describe "model coverage matrix" do
162
105
  it "covers or declares every model section" do
163
106
  cited = MODEL_MATRIX.values.filter_map { |cell| cell[:section] }
164
107
  uncovered = model_sections.reject do |section|
165
- cited.include?(section) || NON_FACE_SECTIONS.include?(section)
108
+ cited.include?(section) || NON_FACE.include?(section)
166
109
  end
167
110
  expect(uncovered).to be_empty,
168
111
  "model sections with no coverage matrix family " \
@@ -170,11 +113,43 @@ RSpec.describe "model coverage matrix" do
170
113
  end
171
114
 
172
115
  it "declares non-face sections that actually exist in the model doc" do
173
- stale = NON_FACE_SECTIONS.reject { |s| model_sections.include?(s) }
116
+ stale = NON_FACE.reject { |s| model_sections.include?(s) }
174
117
  expect(stale).to be_empty,
175
118
  "non-face declarations citing missing sections: #{stale.join('; ')}"
176
119
  end
177
120
 
121
+ it "gives every model section a Pins line" do
122
+ missing = model_pin_lists.select { |_, pins| pins.nil? }.keys
123
+ expect(missing).to be_empty,
124
+ "model sections without a Pins line: #{missing.join('; ')}"
125
+ end
126
+
127
+ it "matches the model doc's face-section pin lists to the matrix" do
128
+ lists = model_pin_lists
129
+ drift = ModelCoverage.sections.filter_map do |section|
130
+ expected = ModelCoverage.pins_for(section)
131
+ got = lists[section]
132
+ "#{section}: doc #{got.inspect} vs matrix #{expected.inspect}" if got != expected
133
+ end
134
+ expect(drift).to be_empty,
135
+ "model-doc pin lists drift from the matrix: #{drift.join('; ')}"
136
+ end
137
+
138
+ it "leaves non-face sections without pins" do
139
+ lists = model_pin_lists
140
+ pinned = NON_FACE.select { |section| lists[section]&.any? }
141
+ expect(pinned).to be_empty,
142
+ "non-face sections must declare Pins:: none: #{pinned.join('; ')}"
143
+ end
144
+
145
+ it "points every model-doc pin at an existing spec" do
146
+ missing = model_pin_lists.values.compact.flatten.uniq.reject do |path|
147
+ File.exist?(path)
148
+ end
149
+ expect(missing).to be_empty,
150
+ "model-doc pins missing on disk: #{missing.join(', ')}"
151
+ end
152
+
178
153
  # Bidirectional linkage (TODO.sota/03): the model doc, the pins, and
179
154
  # the reference form one checkable triangle. Every pin cites the
180
155
  # section it enforces; every citation names a section that exists.
@@ -213,7 +188,7 @@ RSpec.describe "model coverage matrix" do
213
188
  File.exist?(path) ? pin_citations(path) : []
214
189
  end
215
190
  uncovered = model_sections.reject do |section|
216
- cited.include?(section) || NON_FACE_SECTIONS.include?(section)
191
+ cited.include?(section) || NON_FACE.include?(section)
217
192
  end
218
193
  expect(uncovered).to be_empty,
219
194
  "model sections no pin cites: #{uncovered.join('; ')}"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/model_coverage"
5
+ require "json"
6
+
7
+ # Published documentation is part of the specification apparatus
8
+ # (TODO.sota/07): generated blocks must be views of committed data. A
9
+ # data change without regeneration fails here, so the published
10
+ # coverage proof and scoreboard can never claim more than the tests do.
11
+ RSpec.describe "published documentation" do
12
+ it "keeps the model-coverage page in sync with the matrix" do
13
+ page = File.read("docs/_pages/model-coverage.adoc")
14
+ block = page[/MODEL-COVERAGE:BEGIN\n(.*?)\n?\/\/ MODEL-COVERAGE:END/m, 1]
15
+ expect(block).not_to be_nil, "coverage page is missing its markers"
16
+
17
+ expect(block).to eq(ModelCoverage.render_page_block),
18
+ "model-coverage page is stale: run rake docs:coverage"
19
+ end
20
+
21
+ it "keeps every published scoreboard row in sync with the JSON data" do
22
+ data = JSON.parse(File.read("benchmark/scoreboard.json"))
23
+ page = File.read("docs/_pages/performance.adoc")
24
+ block = page[/SCOREBOARD:BEGIN(.*?)SCOREBOARD:END/m, 1]
25
+ expect(block).not_to be_nil, "performance page is missing its markers"
26
+
27
+ data["rows"].each do |row|
28
+ expect(block).to include(
29
+ "| `#{row['row']}` | #{format('%.2f', row['ratio'])}x | " \
30
+ "#{row['kind']} |",
31
+ ), "scoreboard row #{row['row']} is stale against scoreboard.json"
32
+ end
33
+
34
+ expect(block.scan(/\| `[^`]+` \| \d+\.\d\dx \|/).size)
35
+ .to eq(data["rows"].size), "scoreboard row count differs"
36
+ end
37
+
38
+ it "names the measured engine version in the scoreboard metadata" do
39
+ data = JSON.parse(File.read("benchmark/scoreboard.json"))
40
+ protocol = data["protocol"]
41
+ page = File.read("docs/_pages/performance.adoc")
42
+ block = page[/SCOREBOARD:BEGIN(.*?)SCOREBOARD:END/m, 1]
43
+
44
+ expect(block).to include("#{protocol['adapter']} " \
45
+ "#{protocol['adapter_version']}"),
46
+ "scoreboard metadata does not name the measured engine"
47
+ expect(block).to include("nokogiri #{protocol['nokogiri']}")
48
+ end
49
+ end
@@ -42,11 +42,6 @@ RSpec.describe "XPath idioms across adapters" do
42
42
  Gem::Version.new(Leptris::VERSION) < Gem::Version.new("1.9.359.0")
43
43
  skip "binding #{Leptris::VERSION} predates the xmlns: fix (1.9.359)"
44
44
  end
45
- if adapter_name == :oga
46
- # oga's own Ruby XPath engine has no xmlns: default-ns
47
- # support (third-party; explicit bindings work).
48
- skip "D-01: oga's XPath engine does not implement the xmlns: idiom"
49
- end
50
45
  end
51
46
 
52
47
  it "matches default-namespaced elements via the xmlns: prefix" do
@@ -62,6 +57,14 @@ RSpec.describe "XPath idioms across adapters" do
62
57
  expect(targets).to be_empty
63
58
  end
64
59
 
60
+ it "addresses the no-namespace default with an empty xmlns binding" do
61
+ doc = ctx.parse(
62
+ '<root xmlns="urn:a"><child xmlns="urn:b"><leaf xmlns=""/></child></root>',
63
+ )
64
+ leaf = doc.at_xpath("//xmlns:leaf", "xmlns" => "")
65
+ expect(leaf&.name).to eq("leaf")
66
+ end
67
+
65
68
  it "resolves attribute-terminal steps in every path position" do
66
69
  doc = ctx.parse(plain_xml)
67
70
  expect(doc.xpath("//a/@id").first&.value).to eq("x")
@@ -118,6 +121,21 @@ RSpec.describe "XPath idioms across adapters" do
118
121
  expect(doc.xpath("boolean(//b)")).to be(true)
119
122
  expect(doc.at_xpath("count(//a)")).to eq(1.0)
120
123
  end
124
+
125
+ # The live-tree contract: a sibling inserted after an XPath
126
+ # evaluation must be visible to at_xpath and xpath. leptris's
127
+ # node-set lane caches a document index that sibling insertions
128
+ # do not rebuild (engine bug, tracked upstream); the adapter
129
+ # routes such documents to the reference engine until a child
130
+ # insertion rebuilds the index.
131
+ it "answers at_xpath and xpath from the live tree after a sibling insertion" do
132
+ doc = ctx.parse("<r><a><b/></a></r>")
133
+ doc.xpath("//a")
134
+ doc.at("//a").next = "<c/>"
135
+ expect(doc.at("//c")&.name).to eq("c")
136
+ expect(doc.xpath("//c").map(&:name)).to eq(["c"])
137
+ expect(doc.at_xpath("count(//c)")).to eq(1.0)
138
+ end
121
139
  end
122
140
  end
123
141
  end
@@ -80,15 +80,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
80
80
 
81
81
  describe "namespace edge cases" do
82
82
  it "handles default namespace changes" do
83
- if context.config.adapter_name == :headed_ox
84
- skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
85
- end
86
- if context.config.adapter_name == :libxml
87
- skip "LibXML cannot query empty default namespace with XPath (documented limitation)"
88
- end
89
- if context.config.adapter_name == :nokogiri
90
- skip "Nokogiri XPath does not support querying empty namespace with xmlns prefix mapping"
91
- end
92
83
  xml = <<~XML
93
84
  <root xmlns="http://default1.org">
94
85
  <child xmlns="http://default2.org">
@@ -103,10 +94,6 @@ RSpec.shared_examples "Moxml Edge Cases" do
103
94
  end
104
95
 
105
96
  it "handles recursive namespace definitions" do
106
- if context.config.adapter_name == :headed_ox
107
- skip "HeadedOx limitation: Namespace methods not implemented in adapter. Requires Ox namespace API enhancement. See docs/_pages/headed-ox-limitations.adoc"
108
- end
109
-
110
97
  xml = <<~XML
111
98
  <root xmlns:a="http://a.org">
112
99
  <a:child xmlns:a="http://b.org">
@@ -184,12 +184,14 @@ RSpec.describe Moxml::Adapter::Leptris do
184
184
  end
185
185
  end
186
186
 
187
- it "keeps comments in element serialization with tab units (leptris-ruby#115)" do
187
+ it "keeps comments in indented serialization with tab units (leptris-ruby#115)" do
188
188
  skip "requires the element unit fix (leptris 1.9.50+)" unless described_class::ELEMENT_UNIT_COMMENTS
189
189
 
190
+ # Element-level indentation is loud on this engine (D-09); the
191
+ # document lane exercises the same generator's unit handling.
190
192
  doc = ctx.parse(%(<r><a/><!-- c --></r>))
191
- expect(doc.root.to_xml(indent: 1, indent_text: "\t"))
192
- .to eq("<r>\n\t<a></a>\n\t<!-- c -->\n</r>")
193
+ expect(doc.to_xml(indent: 1, indent_text: "\t"))
194
+ .to eq("<r>\n\t<a></a>\n\t<!-- c -->\n</r>\n")
193
195
  end
194
196
 
195
197
  it "round-trips document-level PI mutations through serialization" do
@@ -846,4 +848,112 @@ RSpec.describe Moxml::Adapter::Leptris do
846
848
  expect(first.next_sibling.parent).to eq(doc.root)
847
849
  end
848
850
  end
851
+
852
+ describe "stale-index guard retirement (leptris-ruby#519)" do
853
+ let(:ctx) { Moxml.new(:leptris) }
854
+
855
+ it "auto-retires the guard when the engine invalidates its index" do
856
+ skip "requires an engine whose node-set index survives sibling insertion (leptris-ruby#519)" unless described_class.native_xpath_index_fixed?
857
+
858
+ doc = ctx.parse("<r><a/><b/></r>")
859
+ root = doc.root
860
+ _ = root.xpath("child::*") # populate the engine node-set index
861
+ b = root.children.to_a[1]
862
+ b.add_previous_sibling(doc.create_element("c"))
863
+
864
+ native = described_class.native_xpath(root.native, "child::*", nil)
865
+ expect(native).not_to be_nil
866
+ expect(native.to_a.map { |n| n.name.to_s }).to eq(%w[a c b])
867
+ end
868
+
869
+ it "never arms the guard on engines that invalidate on splice" do
870
+ skip "requires an engine whose node-set index survives sibling insertion (leptris-ruby#519)" unless described_class.native_xpath_index_fixed?
871
+
872
+ doc = ctx.parse("<root><anchor/></root>")
873
+ doc.xpath("//ext") # prime the engine index
874
+ doc.root.add_child(doc.create_element("ext"))
875
+ expect(doc.xpath("//ext").size).to eq(1)
876
+
877
+ expect(
878
+ described_class.attachments.get(
879
+ described_class.xpath_index_document(doc.root.native),
880
+ described_class.singleton_class.const_get(:XPATH_INDEX_STALE),
881
+ ),
882
+ ).to be_nil
883
+ end
884
+ end
885
+
886
+ describe "namespace scope-generation gate" do
887
+ let(:ctx) { Moxml.new(:leptris) }
888
+
889
+ # The per-attach scope-generation bump only matters once
890
+ # namespaces exist; the builder hot path must not pay it.
891
+ it "does not bump the scope generation while namespaces are absent" do
892
+ doc = ctx.create_document
893
+ root = doc.create_element("r")
894
+ doc.root = root
895
+ gen = ctx.namespace_scope_generation
896
+ root.add_child(doc.create_element("a"))
897
+ root.add_child(doc.create_element("b"))
898
+ expect(ctx.namespace_scope_generation).to eq(gen)
899
+ expect(ctx.namespace_scopes_active?).to be(false)
900
+ end
901
+
902
+ it "arms the gate when a write introduces a namespace" do
903
+ doc = ctx.create_document
904
+ root = doc.create_element("r")
905
+ doc.root = root
906
+ root["xmlns:foo"] = "urn:x"
907
+ expect(ctx.namespace_scopes_active?).to be(true)
908
+ expect(root.namespaces.map { |n| [n.prefix, n.uri] })
909
+ .to include(%w[foo urn:x])
910
+ end
911
+
912
+ it "is armed by parsed documents" do
913
+ doc = ctx.parse("<r xmlns:foo='urn:x'><foo:a/></r>")
914
+ expect(ctx.namespace_scopes_active?).to be(true)
915
+ expect(doc.xpath("//foo:a", "foo" => "urn:x").size).to eq(1)
916
+ end
917
+
918
+ it "imports the flag from a foreign context on adoption" do
919
+ donor_ctx = Moxml.new(:leptris)
920
+ donor = donor_ctx.parse("<x xmlns:foo='urn:x'/>")
921
+ doc = ctx.create_document
922
+ root = doc.create_element("r")
923
+ doc.root = root
924
+ expect(ctx.namespace_scopes_active?).to be(false)
925
+ root.add_child(donor.root)
926
+ expect(ctx.namespace_scopes_active?).to be(true)
927
+ expect(root.children.first.namespaces.map(&:uri))
928
+ .to include("urn:x")
929
+ end
930
+ end
931
+
932
+ # The README error-handling contract: an invalid element name must
933
+ # raise Moxml::ValidationError at CREATE time. The adapter's native
934
+ # fast paths (create_primed_element, create_element_with_attrs) mint
935
+ # from a name without passing through Base#create_element, where
936
+ # validate_element_name lives — the failure then surfaced late, at
937
+ # add_child, as Moxml::Error "Document already has a root element".
938
+ describe "element name validation on the create seam" do
939
+ let(:ctx) { Moxml.new(:leptris) }
940
+
941
+ it "raises ValidationError when create_element mints an invalid name" do
942
+ doc = ctx.parse("<root/>")
943
+ expect { doc.create_element("wrong,name") }
944
+ .to raise_error(Moxml::ValidationError, /Invalid XML element name/)
945
+ end
946
+
947
+ it "raises ValidationError when add_element mints an invalid name" do
948
+ doc = ctx.create_document
949
+ expect { doc.add_element("bad,name", id: "1") }
950
+ .to raise_error(Moxml::ValidationError, /Invalid XML element name/)
951
+ end
952
+
953
+ it "still mints valid names with punctuation allowed in XML Names" do
954
+ doc = ctx.parse("<root/>")
955
+ element = doc.create_element("ns:my-element.v2")
956
+ expect(element).to be_a(Moxml::Element)
957
+ end
958
+ end
849
959
  end
@@ -457,10 +457,6 @@ RSpec.shared_examples "xml adapter" do
457
457
  end
458
458
 
459
459
  it "preserves and correctly handles multiple namespaces" do
460
- if described_class.name.include?("Ox")
461
- skip("Ox does not support namespace-aware XPath queries (documented limitation)")
462
- end
463
-
464
460
  # Parse original XML
465
461
  doc = described_class.parse(xml).native
466
462
 
@@ -513,10 +509,6 @@ RSpec.shared_examples "xml adapter" do
513
509
  end
514
510
 
515
511
  it "preserves and correctly handles multiple namespaces" do
516
- if described_class.name.include?("Ox")
517
- skip("Ox does not support namespace-aware XPath queries (documented limitation)")
518
- end
519
-
520
512
  # Parse original XML
521
513
  doc = described_class.parse(xml).native
522
514
 
@@ -31,6 +31,39 @@ RSpec.describe Moxml::AttributeResolver do
31
31
  expect(described_class.resolve(element, "z:type")).to be_nil
32
32
  expect(described_class.resolve(element, "xmlns:q")).to be_nil
33
33
  end
34
+
35
+ # Namespaces 1.0 §5.2: the default namespace never applies to
36
+ # attributes. Engines that resolve unprefixed attributes against
37
+ # the default namespace must answer nil for it, or bare-name reads
38
+ # miss on every element under a default namespace.
39
+ it "never treats bare attributes as default-namespace members" do
40
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
41
+ d = Moxml.new(adapter_name).parse(%(<r xmlns="urn:a"><e id="1"/></r>))
42
+ e = d.root.children.find(&:element?)
43
+ expect(e["id"]).to eq("1"), "#{adapter_name}: bare read"
44
+ expect(e.attribute("id")&.value).to eq("1"),
45
+ "#{adapter_name}: resolver"
46
+ expect(e.attribute("id")&.namespace).to be_nil,
47
+ "#{adapter_name}: attribute namespace"
48
+ end
49
+ end
50
+
51
+ it "keeps bare reads on programmatically built default-namespaced trees" do
52
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
53
+ ctx = Moxml.new(adapter_name)
54
+ d = ctx.create_document
55
+ root = d.create_element("root")
56
+ root.add_namespace(nil, "urn:a")
57
+ d.add_child(root)
58
+ e = d.create_element("item")
59
+ e["id"] = "123"
60
+ root.add_child(e)
61
+
62
+ expect(e["id"]).to eq("123"), "#{adapter_name}: bare read"
63
+ expect(d.xpath('//xmlns:item[@id="123"]').to_a.size).to eq(1),
64
+ "#{adapter_name}: xmlns predicate"
65
+ end
66
+ end
34
67
  end
35
68
 
36
69
  describe ".assign" do
@@ -59,20 +59,47 @@ RSpec.describe "Moxml::C14n cross-adapter canonicalization" do
59
59
  end
60
60
 
61
61
  describe "document-level nodes keep document order" do
62
- # oga surfaces the XML declaration as a <?xml?> PI and keeps the
63
- # separator space in PI content (D-04); ox/headed_ox drop PI
64
- # content on parse (D-05). Both are pre-existing parse-layer
65
- # divergences, out of scope here.
66
- let(:adapters) { Moxml::Adapter::AVAILABLE_ADAPTERS - %i[oga ox headed_ox] }
62
+ # ox/headed_ox drop PI content at parse (D-05), so content-bearing
63
+ # document-level PIs are pinned on the remaining adapters; the
64
+ # separator/newline rule itself is pinned on every adapter below
65
+ # (comments and empty-data PIs are common across all of them).
66
+ let(:content_adapters) do
67
+ Moxml::Adapter::AVAILABLE_ADAPTERS - %i[ox headed_ox]
68
+ end
67
69
 
68
70
  it "canonicalizes a leading PI before the root element" do
69
- adapters.each do |adapter_name|
71
+ content_adapters.each do |adapter_name|
70
72
  doc = Moxml.new(adapter_name)
71
73
  .parse(%(<?xml version="1.0"?><?render mode="x"?><doc><e/></doc>))
72
74
  expect(Moxml::C14n.canonicalize(doc))
73
75
  .to eq(%(<?render mode="x"?>\n<doc><e></e></doc>))
74
76
  end
75
77
  end
78
+
79
+ it "uses exactly one newline between document-level nodes" do
80
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
81
+ doc = Moxml.new(adapter_name)
82
+ .parse(%(<?pi?><?pi2?><!--a--><doc/><!--b--><?pi3?>))
83
+ expect(Moxml::C14n.canonicalize(doc, with_comments: true))
84
+ .to eq(%(<?pi?>\n<?pi2?>\n<!--a-->\n<doc></doc>\n<!--b-->\n<?pi3?>))
85
+ end
86
+ end
87
+
88
+ it "never emits the XML declaration" do
89
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
90
+ doc = Moxml.new(adapter_name)
91
+ .parse(%(<?xml version="1.0" encoding="UTF-8"?><doc/>))
92
+ expect(Moxml::C14n.canonicalize(doc)).to eq("<doc></doc>")
93
+ end
94
+ end
95
+
96
+ it "collapses PI separator whitespace to one space" do
97
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
98
+ doc = Moxml.new(adapter_name).parse(%(<?pi a b?><doc/>))
99
+ expect(Moxml::C14n.canonicalize(doc))
100
+ .to eq(%(<?pi a b?>\n<doc></doc>))
101
+ end
102
+ end
76
103
  end
77
104
 
78
105
  describe "native delegation" do
@@ -12,25 +12,19 @@ LATIN1_XML = (+"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><r>caf\xE9</r>").
12
12
  BOM_XML = (+"\xEF\xBB\xBF<r/>").force_encoding("UTF-8")
13
13
  NFC_XML = (+"<r>caf\xC3\xA9</r>").force_encoding("UTF-8")
14
14
 
15
- # oga's parse layer leaks the declaration as a PI node and the BOM as
16
- # text (pre-existing, documented in the W3C examples spec).
17
- OGA_LEAK = %i[oga].freeze
18
-
19
15
  RSpec.describe "Moxml character encoding at the parse boundary" do
20
16
  Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
21
17
  context "with the #{adapter_name} adapter" do
22
18
  let(:ctx) { Moxml.new(adapter_name) }
23
19
 
24
- unless OGA_LEAK.include?(adapter_name)
25
- it "transcodes declared Latin-1 to UTF-8 bytes" do
26
- doc = ctx.parse(LATIN1_XML.dup)
27
- expect(Moxml::C14n.canonicalize(doc).bytes).to eq(CAF_E_UTF8)
28
- end
20
+ it "transcodes declared Latin-1 to UTF-8 bytes" do
21
+ doc = ctx.parse(LATIN1_XML.dup)
22
+ expect(Moxml::C14n.canonicalize(doc).bytes).to eq(CAF_E_UTF8)
23
+ end
29
24
 
30
- it "strips a leading UTF-8 BOM" do
31
- doc = ctx.parse(BOM_XML.dup)
32
- expect(Moxml::C14n.canonicalize(doc)).to eq("<r></r>")
33
- end
25
+ it "strips a leading UTF-8 BOM" do
26
+ doc = ctx.parse(BOM_XML.dup)
27
+ expect(Moxml::C14n.canonicalize(doc)).to eq("<r></r>")
34
28
  end
35
29
 
36
30
  it "passes NFC content through without normalization" do
@@ -48,18 +48,45 @@ RSpec.describe "C14N comments and PIs" do
48
48
  end
49
49
 
50
50
  describe "PIs at document level" do
51
- # TODO.c14n/09: Document-level PIs (outside the document element) are
52
- # not yet rendered correctly. The moxml PI parser mis-parses document-level
53
- # PIs, and the DataModel.from_document path drops them. Fixing requires
54
- # adapter-level work and is tracked separately.
55
- it "renders PIs outside the document element (KNOWN LIMITATION)" do
56
- skip "D-08: document-level PI rendering not yet implemented"
51
+ it "renders PIs outside the document element in document order" do
52
+ doc = ctx.parse(%(<?pi before?><root><?pi inner?></root><?pi after?>))
53
+ expect(Moxml::C14n.canonicalize(doc))
54
+ .to eq(%(<?pi before?>\n<root><?pi inner?></root>\n<?pi after?>))
55
+ end
56
+
57
+ it "uses exactly one newline between document-level nodes" do
58
+ doc = ctx.parse(%(<?pi a?><?pi b?><root/><?pi c?>))
59
+ expect(Moxml::C14n.canonicalize(doc))
60
+ .to eq(%(<?pi a?>\n<?pi b?>\n<root></root>\n<?pi c?>))
61
+ end
62
+
63
+ it "ignores whitespace between document-level nodes" do
64
+ # rexml retains the text node (D-15); the canonical form never
65
+ # includes whitespace outside the document element.
66
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
67
+ doc = Moxml.new(adapter_name).parse(%(<?pi?>\n<root/>\n<!--c-->))
68
+ expect(Moxml::C14n.canonicalize(doc, with_comments: true))
69
+ .to eq(%(<?pi?>\n<root></root>\n<!--c-->)), adapter_name.to_s
70
+ end
71
+ end
72
+
73
+ it "omits the XML declaration from the canonical form" do
74
+ doc = ctx.parse(%(<?xml version="1.0" encoding="UTF-8"?><root/>))
75
+ expect(Moxml::C14n.canonicalize(doc)).to eq("<root></root>")
57
76
  end
58
77
  end
59
78
 
60
79
  describe "comments at document level" do
61
- it "renders document-level comments when with_comments: true (KNOWN LIMITATION)" do
62
- skip "D-08: document-level comment rendering not yet implemented"
80
+ it "renders document-level comments when with_comments: true" do
81
+ doc = ctx.parse(%(<!--lead--><root><!--inner--></root><!--trail-->))
82
+ expect(Moxml::C14n.canonicalize(doc, with_comments: true))
83
+ .to eq(%(<!--lead-->\n<root><!--inner--></root>\n<!--trail-->))
84
+ end
85
+
86
+ it "keeps exactly one newline between a document-level PI and comment" do
87
+ doc = ctx.parse(%(<?pi x?><!--lead--><root/>))
88
+ expect(Moxml::C14n.canonicalize(doc, with_comments: true))
89
+ .to eq(%(<?pi x?>\n<!--lead-->\n<root></root>))
63
90
  end
64
91
  end
65
92
  end