moxml 0.5.148 → 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 (55) 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 +110 -19
  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 +6 -6
  13. data/lib/moxml/node.rb +22 -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/ox_spec.rb +1 -1
  37. data/spec/moxml/attribute_resolver_spec.rb +2 -0
  38. data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
  39. data/spec/moxml/doctype_spec.rb +7 -0
  40. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  41. data/spec/moxml/entity_spec.rb +2 -0
  42. data/spec/moxml/plan_conformance_spec.rb +2 -0
  43. data/spec/moxml/processing_instruction_spec.rb +9 -0
  44. data/spec/moxml/reference_opal_spec.rb +29 -0
  45. data/spec/moxml/reference_spec.rb +162 -0
  46. data/spec/moxml/sax_spec.rb +2 -0
  47. data/spec/moxml/text_spec.rb +2 -0
  48. data/spec/moxml/to_html_spec.rb +2 -0
  49. data/spec/moxml/xhtml_output_spec.rb +2 -0
  50. data/spec/moxml/xpath/axes_spec.rb +2 -2
  51. data/spec/moxml/xsd_spec.rb +38 -0
  52. data/spec/support/input_generator.rb +154 -0
  53. data/spec/support/input_taxonomy.rb +212 -0
  54. data/spec/support/xml_generator.rb +54 -0
  55. metadata +16 -1
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/input_taxonomy"
5
+ require "support/input_generator"
6
+
7
+ # Metamorphic property suite (TODO.sota/02): generated evidence over
8
+ # the 01 taxonomy's vocabulary, seeded per run — SOTA_FUZZ_SEEDS
9
+ # (default fixed, identical cases every CI run; the nightly fuzz leg
10
+ # passes a random comma-separated list) and SOTA_FUZZ_CASES per seed
11
+ # (bounded per leg).
12
+ #
13
+ # M1 round trip, M2 c14n idempotence, M3 document-order/union order,
14
+ # M4 serialization shape, M5 cross-adapter answer equality modulo the
15
+ # register; the malformed class raises the named ParseError
16
+ # everywhere. Failures name the case id ("gen <seed>/<i>") —
17
+ # reproducible at that seed and index — and the cell.
18
+ RSpec.describe "metamorphic properties (generated, seeded)" do
19
+ let(:fuzz_seeds) do
20
+ if ENV.key?("SOTA_FUZZ_SEEDS")
21
+ ENV.fetch("SOTA_FUZZ_SEEDS").split(",").map(&:to_i)
22
+ else
23
+ [20261020]
24
+ end
25
+ end
26
+
27
+ let(:fuzz_count) { Integer(ENV.fetch("SOTA_FUZZ_CASES", "200")) }
28
+
29
+ let(:cases) do
30
+ fuzz_seeds.flat_map do |seed|
31
+ InputGenerator.cases(seed: seed, count: fuzz_count)
32
+ end
33
+ end
34
+
35
+ def snapshot(doc)
36
+ {
37
+ nodes: doc.traverse.to_a.grep_v(Moxml::Document).count,
38
+ root: doc.root&.name,
39
+ text: doc.text,
40
+ xpath: doc.xpath("//*").to_a.count,
41
+ }
42
+ end
43
+
44
+ def compact(snap)
45
+ snap.except(:nodes).merge(text: snap[:text].gsub(/\s+/, ""))
46
+ end
47
+
48
+ def exempt?(input, face, name)
49
+ input.features.any? do |feature|
50
+ InputGenerator::FEATURE_EXEMPT.dig(feature, face, name)
51
+ end
52
+ end
53
+
54
+ it "M1: keeps the round trip stable per adapter over generated inputs" do
55
+ cases.each do |input|
56
+ next unless input.well_formed
57
+
58
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
59
+ begin
60
+ ctx = Moxml.new(name)
61
+ first = snapshot(ctx.parse(input.xml))
62
+ second = snapshot(ctx.parse(ctx.parse(input.xml).to_xml(indent: 0)))
63
+ rescue Moxml::Error, NotImplementedError, LoadError
64
+ next # adapter unavailable or a named guard excuses the leg
65
+ end
66
+ expect(compact(second)).to eq(compact(first)),
67
+ "#{input.id} round-trip drifted on " \
68
+ "#{name}: #{first.inspect} vs " \
69
+ "#{second.inspect}"
70
+ end
71
+ end
72
+ end
73
+
74
+ it "M2: canonicalization is idempotent (c14n∘c14n == c14n)" do
75
+ c14n = Moxml::C14n::Inclusive10.new
76
+ cases.each do |input|
77
+ next unless input.well_formed
78
+
79
+ once = c14n.canonicalize(input.xml)
80
+ expect(c14n.canonicalize(once)).to eq(once),
81
+ "#{input.id}: c14n is not " \
82
+ "idempotent"
83
+ end
84
+ end
85
+
86
+ it "M3: XPath unions answer in first-encounter (document) order" do
87
+ # XPath 1.0 unprefixed name tests match no-namespace elements
88
+ # only (processing model, "Names and namespaces") — the union
89
+ # property is scoped to cases an unprefixed query can address.
90
+ addressed = cases.select do |input|
91
+ input.well_formed && !input.features.include?(:default_ns)
92
+ end
93
+ addressed.each do |input|
94
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
95
+ begin
96
+ doc = Moxml.new(name).parse(input.xml)
97
+ union = doc.xpath("//a | //b").to_a.map(&:name)
98
+ rescue Moxml::Error, NotImplementedError, LoadError
99
+ next # adapter unavailable in this environment
100
+ end
101
+ wanted = %w[a b]
102
+ doc_order = doc.traverse.to_a
103
+ .grep(Moxml::Element)
104
+ .map(&:name)
105
+ .select { |n| wanted.include?(n) }
106
+ expect(union).to eq(doc_order),
107
+ "#{input.id}: union order diverges from document " \
108
+ "order on #{name}: #{union.inspect} vs " \
109
+ "#{doc_order.inspect}"
110
+ end
111
+ end
112
+ end
113
+
114
+ it "M4: to_html and to_xhtml hold their serialization shape" do
115
+ cases.each do |input|
116
+ next if !input.well_formed || input.voids.empty?
117
+
118
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
119
+ begin
120
+ doc = Moxml.new(name).parse(input.xml)
121
+ html = doc.to_html
122
+ xhtml = doc.to_xhtml
123
+ rescue Moxml::Error, NotImplementedError, LoadError
124
+ next # the HTML face is adapter-optional (loud raise where absent)
125
+ end
126
+ expect(html).not_to include("<?xml"),
127
+ "#{input.id}: to_html declared XML on #{name}"
128
+ input.voids.each do |v|
129
+ expect(html).not_to match(%r{<#{v}\b[^>]*/>}),
130
+ "#{input.id}: to_html self-closed void <#{v}> " \
131
+ "on #{name}"
132
+ end
133
+ # "none of the HTML short forms": a bare <br> is not XML,
134
+ # so the strict re-parse is the rigorous check (self-closed
135
+ # and paired forms are both legal XHTML).
136
+ expect { Moxml.new(:leptris).parse(xhtml, strict: true) }
137
+ .not_to raise_error,
138
+ "#{input.id}: to_xhtml left an HTML short form on " \
139
+ "#{name}: #{xhtml[0, 120].inspect}"
140
+ end
141
+ end
142
+ end
143
+
144
+ it "M5: every adapter answers generated inputs identically, modulo the register" do
145
+ cases.each do |input|
146
+ next unless input.well_formed
147
+
148
+ per = Moxml::Adapter::AVAILABLE_ADAPTERS.filter_map do |name|
149
+ [name, snapshot(Moxml.new(name).parse(input.xml))]
150
+ rescue Moxml::Error, NotImplementedError, LoadError
151
+ nil # adapter unavailable in this environment
152
+ end.to_h
153
+ expect(per.keys.count).to be >= 2
154
+
155
+ base_name = per.keys.first
156
+ base = per[base_name]
157
+ per.each do |name, got|
158
+ base.except(:text).each_key do |face|
159
+ next if exempt?(input, face, name)
160
+
161
+ expect(got[face]).to eq(base[face]),
162
+ "#{input.id}/#{face} differs on #{name} vs " \
163
+ "#{base_name}: #{got[face].inspect} vs " \
164
+ "#{base[face].inspect} — fix it or register " \
165
+ "a divergence and exempt the cell"
166
+ end
167
+ next if exempt?(input, :text, name)
168
+
169
+ expect(got[:text]).to eq(base[:text]),
170
+ "#{input.id}/text differs on #{name} vs " \
171
+ "#{base_name}: #{got[:text].inspect} vs " \
172
+ "#{base[:text].inspect} — fix it or register " \
173
+ "a divergence and exempt the cell"
174
+ end
175
+ end
176
+ end
177
+
178
+ it "raises the named parse error on generated malformed input, everywhere" do
179
+ cases.each do |input|
180
+ next if input.well_formed
181
+
182
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
183
+ # oga's strict lane is lexer-strict, not
184
+ # well-formedness-strict (D-17) — structural malformations
185
+ # parse there; every other adapter must raise the named error.
186
+ next if name == :oga # D-17
187
+
188
+ expect { Moxml.new(name).parse(input.xml, strict: true) }
189
+ .to raise_error(Moxml::ParseError),
190
+ "#{input.id}: #{name} accepted malformed input or raised " \
191
+ "outside the named ParseError contract"
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/xml_generator"
5
+
6
+ # Metamorphic conformance (TODO.sota/02): properties over GENERATED
7
+ # documents — no expected outputs needed, only relations that must
8
+ # hold for every input. Seeded: a failing seed reproduces locally.
9
+ # Nightly legs sweep larger seeds (SOTA_FUZZ_SEEDS).
10
+ RSpec.describe "metamorphic relations over generated documents" do
11
+ SEEDS = (ENV.key?("SOTA_FUZZ_SEEDS") ? ENV.fetch("SOTA_FUZZ_SEEDS").split(",").map(&:to_i) : [1, 2, 3]) # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
12
+ DOCS_PER_SEED = 10 # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
13
+ def adapters
14
+ Moxml::Adapter::AVAILABLE_ADAPTERS - [:headed_ox]
15
+ end
16
+
17
+ def docs
18
+ @docs ||= SEEDS.flat_map do |seed|
19
+ (0...DOCS_PER_SEED).map do |i|
20
+ [:"#{seed}_#{i}", XmlGenerator.generate((seed * 1000) + i)]
21
+ end
22
+ end
23
+ end
24
+
25
+ def snapshot_structure(doc)
26
+ doc.xpath("//*").to_a.map(&:name)
27
+ end
28
+
29
+ def element_attr_set(doc)
30
+ doc.xpath("//*").to_a.map do |n|
31
+ n.attribute_pairs.map { |k, v| [k, v] }.sort
32
+ end
33
+ end
34
+
35
+ # M1: parse∘serialize identity on the information set
36
+ it "M1 round-trips every generated document on every adapter" do
37
+ docs.each do |seed_tag, xml|
38
+ adapters.each do |name|
39
+ begin
40
+ ctx = Moxml.new(name)
41
+ doc = ctx.parse(xml)
42
+ rescue Moxml::Error, NotImplementedError, LoadError
43
+ next
44
+ end
45
+ first = snapshot_structure(doc)
46
+ again = ctx.parse(doc.to_xml(indent: 0))
47
+ expect(snapshot_structure(again)).to eq(first),
48
+ "M1 drift #{seed_tag}/#{name}"
49
+ end
50
+ end
51
+ end
52
+
53
+ # M2: canonicalization idempotence (the Ruby reference + native
54
+ # delegations answer one canonical form; re-canonicalizing it is
55
+ # the identity).
56
+ it "M2 canonicalization is idempotent on every adapter" do
57
+ docs.each do |seed_tag, xml|
58
+ adapters.each do |name|
59
+ begin
60
+ doc = Moxml.new(name).parse(xml)
61
+ once = Moxml::C14n.canonicalize(doc)
62
+ twice = Moxml::C14n.canonicalize(Moxml.new(name).parse(once))
63
+ rescue Moxml::Error, NotImplementedError, LoadError
64
+ next
65
+ end
66
+ expect(twice).to eq(once), "M2 drift #{seed_tag}/#{name}"
67
+ end
68
+ end
69
+ end
70
+
71
+ # M3: document order — XPath element results equal the pre-order
72
+ # walk's element sequence.
73
+ it "M3 XPath results preserve document order on every adapter" do
74
+ docs.each do |seed_tag, xml|
75
+ adapters.each do |name|
76
+ begin
77
+ doc = Moxml.new(name).parse(xml)
78
+ via_xpath = doc.xpath("//*").to_a.map(&:name)
79
+ via_walk = []
80
+ doc.traverse { |n| via_walk << n.name if n.node_type == :element }
81
+ rescue Moxml::Error, NotImplementedError, LoadError
82
+ next
83
+ end
84
+ expect(via_xpath).to eq(via_walk), "M3 drift #{seed_tag}/#{name}"
85
+ end
86
+ end
87
+ end
88
+
89
+ # M4: serialization shape invariants (per shape).
90
+ it "M4 keeps the shape contracts on every adapter" do
91
+ docs.each do |seed_tag, xml|
92
+ adapters.each do |name|
93
+ begin
94
+ ctx = Moxml.new(name)
95
+ doc = ctx.parse(xml)
96
+ xhtml = doc.to_xhtml
97
+ rescue Moxml::Error, NotImplementedError, LoadError
98
+ next # loud contracts (no HTML engine) are pinned elsewhere
99
+ end
100
+ expect(xhtml).not_to include("<!DOCTYPE"),
101
+ "M4 #{seed_tag}/#{name}: xhtml grew a doctype"
102
+ expect(xhtml).not_to include("<?xml"),
103
+ "M4 #{seed_tag}/#{name}: xhtml grew a declaration"
104
+ # The XHTML shape re-parses with the same element set (the
105
+ # slash forms are well-formed XML).
106
+ expect(snapshot_structure(ctx.parse(xhtml)))
107
+ .to eq(snapshot_structure(doc)),
108
+ "M4 #{seed_tag}/#{name}: xhtml round-trip drifted"
109
+ end
110
+ end
111
+ end
112
+
113
+ # M5: adapter-adapter equivalence on the structural information set
114
+ # (element sequence + attribute sets). Text-level equivalence over
115
+ # generated docs rides the taxonomy differential's registered
116
+ # exemptions (D-13/D-14 whitespace/PI families).
117
+ it "M5 structural equivalence across adapters" do
118
+ docs.each do |seed_tag, xml|
119
+ per = adapters.filter_map do |name|
120
+ doc = Moxml.new(name).parse(xml)
121
+ [name, snapshot_structure(doc), element_attr_set(doc)]
122
+ rescue Moxml::Error, NotImplementedError, LoadError
123
+ nil
124
+ end
125
+ next if per.size < 2
126
+
127
+ base_name, base_struct, base_attrs = per.first
128
+ per.each do |name, struct, attrs|
129
+ expect(struct).to eq(base_struct),
130
+ "M5 structure drift #{seed_tag}/#{name} vs " \
131
+ "#{base_name}"
132
+ expect(attrs).to eq(base_attrs),
133
+ "M5 attribute drift #{seed_tag}/#{name} vs " \
134
+ "#{base_name}"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -12,47 +12,60 @@ require "spec_helper"
12
12
  RSpec.describe "model coverage matrix" do
13
13
  MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
14
  "document order and children listing" => {
15
+ section: "Document order and traversal",
15
16
  pin: "spec/consistency/adapter_parity_spec.rb",
16
17
  },
17
18
  "name resolution" => {
19
+ section: "Names and namespaces",
18
20
  pin: "spec/consistency/xpath_idioms_spec.rb",
19
21
  },
20
22
  "namespace resolution and bindings normalization" => {
23
+ section: "Names and namespaces",
21
24
  pin: "spec/consistency/xpath_idioms_spec.rb",
22
25
  },
23
26
  "attribute access" => {
27
+ section: "Access semantics",
24
28
  pin: "spec/moxml/attribute_resolver_spec.rb",
25
29
  },
26
30
  "text/content/raw_content" => {
31
+ section: "Access semantics",
27
32
  pin: "spec/moxml/text_spec.rb",
28
33
  },
29
34
  "parsing modes and diagnostics" => {
35
+ section: "Parsing",
30
36
  pin: "spec/moxml/document_parse_diagnostics_spec.rb",
31
37
  },
32
38
  "EOL normalization" => {
39
+ section: "Parsing",
33
40
  pin: "spec/consistency/eol_normalization_spec.rb",
34
41
  exempt: { oga: "D-02", ox: "D-03" },
35
42
  },
36
43
  "entity modes" => {
44
+ section: "Parsing",
37
45
  pin: "spec/moxml/entity_spec.rb",
38
46
  },
39
47
  "XPath evaluation and declared idioms" => {
48
+ section: "XPath evaluation",
40
49
  pin: "spec/consistency/xpath_idioms_spec.rb",
41
50
  exempt: { oga: "D-01" },
42
51
  },
43
52
  "serialization shapes (xml/html/xhtml)" => {
53
+ section: "Serialization",
44
54
  pin: "spec/moxml/xhtml_output_spec.rb",
45
55
  },
46
56
  "indentation knobs" => {
57
+ section: "Serialization",
47
58
  pin: "spec/moxml/to_html_spec.rb",
48
59
  exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
49
60
  libxml: "D-09", leptris: "D-09" },
50
61
  },
51
62
  "canonicalization" => {
63
+ section: "Canonicalization",
52
64
  pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
53
65
  exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
54
66
  },
55
67
  "XSLT" => {
68
+ section: "XSLT",
56
69
  pin: "spec/consistency/xslt_spec.rb",
57
70
  loud: { oga: "Moxml::NotImplementedError",
58
71
  ox: "Moxml::NotImplementedError",
@@ -61,12 +74,15 @@ RSpec.describe "model coverage matrix" do
61
74
  libxml: "Moxml::NotImplementedError" },
62
75
  },
63
76
  "SAX" => {
77
+ section: "SAX",
64
78
  pin: "spec/moxml/sax_spec.rb",
65
79
  },
66
80
  "materialization" => {
81
+ section: "Materialization",
67
82
  pin: "spec/moxml/plan_conformance_spec.rb",
68
83
  },
69
84
  "loud capability contracts" => {
85
+ section: "Errors and loud degradation",
70
86
  pin: "spec/consistency/loud_degradation_spec.rb",
71
87
  },
72
88
  }.freeze
@@ -110,4 +126,96 @@ RSpec.describe "model coverage matrix" do
110
126
  expect(bad).to be_empty,
111
127
  "loud cells must name Moxml errors: #{bad.join('; ')}"
112
128
  end
129
+
130
+ # Model-derived governance: the matrix is generated FROM the model
131
+ # doc's section headings, in both directions. A new section in
132
+ # processing-model.adoc without a matrix citation (or an explicit
133
+ # non-face declaration) fails this audit; a matrix citation whose
134
+ # section vanished fails too.
135
+ MODEL_DOC = "docs/_references/processing-model.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
136
+
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
143
+
144
+ def model_sections
145
+ File.readlines(MODEL_DOC).filter_map do |line|
146
+ line[/\A=== (\S.*)$/, 1]&.strip
147
+ end.uniq
148
+ end
149
+
150
+ it "cites an existing model section for every face family" do
151
+ sections = model_sections
152
+ unknown = MODEL_MATRIX.filter_map do |family, cell|
153
+ section = cell[:section]
154
+ next "#{family}: nil" if section.nil?
155
+
156
+ "#{family}: #{section.inspect}" unless sections.include?(section)
157
+ end
158
+ expect(unknown).to be_empty,
159
+ "matrix families citing missing model sections: #{unknown.join('; ')}"
160
+ end
161
+
162
+ it "covers or declares every model section" do
163
+ cited = MODEL_MATRIX.values.filter_map { |cell| cell[:section] }
164
+ uncovered = model_sections.reject do |section|
165
+ cited.include?(section) || NON_FACE_SECTIONS.include?(section)
166
+ end
167
+ expect(uncovered).to be_empty,
168
+ "model sections with no coverage matrix family " \
169
+ "(add a family or declare non-face): #{uncovered.join('; ')}"
170
+ end
171
+
172
+ it "declares non-face sections that actually exist in the model doc" do
173
+ stale = NON_FACE_SECTIONS.reject { |s| model_sections.include?(s) }
174
+ expect(stale).to be_empty,
175
+ "non-face declarations citing missing sections: #{stale.join('; ')}"
176
+ end
177
+
178
+ # Bidirectional linkage (TODO.sota/03): the model doc, the pins, and
179
+ # the reference form one checkable triangle. Every pin cites the
180
+ # section it enforces; every citation names a section that exists.
181
+ PIN_CITATION = /Processing model §(.+?) \(normative\)/ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
182
+
183
+ def pin_citations(path)
184
+ File.readlines(path).filter_map { |line| line[PIN_CITATION, 1]&.strip }
185
+ end
186
+
187
+ it "has every pin cite the model section it enforces" do
188
+ missing = MODEL_MATRIX.filter_map do |family, cell|
189
+ next unless cell[:pin] && File.exist?(cell[:pin])
190
+
191
+ "#{family}: #{cell[:pin]}" unless pin_citations(cell[:pin])
192
+ .include?(cell[:section])
193
+ end
194
+ expect(missing).to be_empty,
195
+ "pins must cite their model section " \
196
+ "(# Processing model §<section>): #{missing.join('; ')}"
197
+ end
198
+
199
+ it "never cites a model section that does not exist" do
200
+ sections = model_sections
201
+ orphans = MODEL_MATRIX.filter_map { |_, cell| cell[:pin] }.uniq.flat_map do |path|
202
+ next [] unless File.exist?(path)
203
+
204
+ pin_citations(path).reject { |section| sections.include?(section) }
205
+ .map { |section| "#{path}: #{section}" }
206
+ end
207
+ expect(orphans).to be_empty,
208
+ "pin citations naming missing sections: #{orphans.join('; ')}"
209
+ end
210
+
211
+ it "cites every face section from at least one pin" do
212
+ cited = MODEL_MATRIX.filter_map { |_, cell| cell[:pin] }.uniq.flat_map do |path|
213
+ File.exist?(path) ? pin_citations(path) : []
214
+ end
215
+ uncovered = model_sections.reject do |section|
216
+ cited.include?(section) || NON_FACE_SECTIONS.include?(section)
217
+ end
218
+ expect(uncovered).to be_empty,
219
+ "model sections no pin cites: #{uncovered.join('; ')}"
220
+ end
113
221
  end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/input_taxonomy"
5
+
6
+ # Executable-reference differential (TODO.sota/03): every adapter's
7
+ # answer for each taxonomy input is compared against the pure-Ruby
8
+ # reference implementation (Moxml::Reference), not against another
9
+ # adapter. Faces: document order, children listing, access truth
10
+ # table, attributes, PI/comment data, and serialization round-trip.
11
+ # Cells that differ cite a registered divergence (D-NN) or the
12
+ # adapter is fixed.
13
+ RSpec.describe "executable reference differential" do
14
+ let(:references) { {} }
15
+
16
+ def adapters
17
+ Moxml::Adapter::AVAILABLE_ADAPTERS.uniq
18
+ end
19
+
20
+ def exempt?(input, face, adapter)
21
+ InputTaxonomy::EXEMPT.dig(input.id, face, adapter)
22
+ end
23
+
24
+ def reference_for(input)
25
+ references[input.id] ||= Moxml::Reference.parse(input.xml)
26
+ end
27
+
28
+ def adapter_document(name, xml)
29
+ Moxml.new(name).parse(xml)
30
+ rescue Moxml::Error, NotImplementedError, LoadError
31
+ nil # unavailable in this environment
32
+ end
33
+
34
+ def elements(doc)
35
+ doc.xpath("//*").to_a
36
+ end
37
+
38
+ def reference_elements(reference)
39
+ rows = []
40
+ reference.each_node { |node| rows << node if node.element? }
41
+ rows
42
+ end
43
+
44
+ def pi_rows(doc)
45
+ doc.traverse.filter_map do |node|
46
+ [node.name, node.content] if node.node_type == :processing_instruction
47
+ end
48
+ end
49
+
50
+ def comment_rows(doc)
51
+ doc.traverse.filter_map do |node|
52
+ node.content if node.node_type == :comment
53
+ end
54
+ end
55
+
56
+ def compact(text)
57
+ text.to_s.gsub(/\s+/, "")
58
+ end
59
+
60
+ it "answers document order and children listing like the reference" do
61
+ InputTaxonomy.well_formed.each do |input|
62
+ ref = reference_for(input)
63
+ expected_order = Moxml::Reference.element_names(ref)
64
+ expected_children = Moxml::Reference.child_element_names(ref)
65
+ adapters.each do |name|
66
+ doc = adapter_document(name, input.xml)
67
+ next unless doc # unavailable
68
+ next if exempt?(input, :nodes, name)
69
+
70
+ got_order = elements(doc).map(&:name)
71
+ expect(got_order).to eq(expected_order),
72
+ "#{input.id}/#{name}: document order differs " \
73
+ "from the reference: #{got_order.inspect} vs " \
74
+ "#{expected_order.inspect}"
75
+
76
+ got_children = elements(doc).map do |element|
77
+ element.children.grep(Moxml::Element).map(&:name)
78
+ end
79
+ expect(got_children).to eq(expected_children),
80
+ "#{input.id}/#{name}: children listing " \
81
+ "differs from the reference: " \
82
+ "#{got_children.inspect} vs " \
83
+ "#{expected_children.inspect}"
84
+ end
85
+ end
86
+ end
87
+
88
+ it "answers element text like the reference" do
89
+ InputTaxonomy.well_formed.each do |input|
90
+ expected = Moxml::Reference.element_access_rows(reference_for(input))
91
+ .map { |(name, text, _content)| [name, text] }
92
+ adapters.each do |name|
93
+ doc = adapter_document(name, input.xml)
94
+ next unless doc
95
+ next if exempt?(input, :text, name)
96
+
97
+ got = elements(doc).map { |element| [element.name, element.text] }
98
+ expect(got).to eq(expected),
99
+ "#{input.id}/#{name}: text face differs from the " \
100
+ "reference: #{got.inspect} vs #{expected.inspect}"
101
+ end
102
+ end
103
+ end
104
+
105
+ it "answers attribute pairs like the reference" do
106
+ InputTaxonomy.well_formed.each do |input|
107
+ expected = reference_elements(reference_for(input)).map(&:attribute_pairs)
108
+ adapters.each do |name|
109
+ doc = adapter_document(name, input.xml)
110
+ next unless doc
111
+
112
+ got = elements(doc).map do |element|
113
+ element.attribute_pairs.map { |(key, value)| [key, value] }
114
+ end
115
+ expect(got).to eq(expected),
116
+ "#{input.id}/#{name}: attribute face differs from " \
117
+ "the reference: #{got.inspect} vs #{expected.inspect}"
118
+ end
119
+ end
120
+ end
121
+
122
+ it "answers PI and comment data like the reference" do
123
+ InputTaxonomy.well_formed.each do |input|
124
+ ref = reference_for(input)
125
+ expected_pis = Moxml::Reference.processing_instructions(ref)
126
+ expected_comments = Moxml::Reference.comments(ref)
127
+ adapters.each do |name|
128
+ doc = adapter_document(name, input.xml)
129
+ next unless doc
130
+
131
+ got_pis = pi_rows(doc)
132
+ expect(got_pis.map(&:first)).to eq(expected_pis.map(&:first)),
133
+ "#{input.id}/#{name}: PI targets " \
134
+ "differ from the reference"
135
+ unless exempt?(input, :pi_content, name)
136
+ expect(got_pis).to eq(expected_pis),
137
+ "#{input.id}/#{name}: PI data differs " \
138
+ "from the reference"
139
+ end
140
+ unless exempt?(input, :comment_content, name)
141
+ expect(comment_rows(doc)).to eq(expected_comments),
142
+ "#{input.id}/#{name}: comment data " \
143
+ "differs from the reference"
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+ it "serializes to XML that re-parses to the reference information set" do
150
+ InputTaxonomy.well_formed.each do |input|
151
+ ref = reference_for(input)
152
+ expected_order = Moxml::Reference.element_names(ref)
153
+ expected_children = Moxml::Reference.child_element_names(ref)
154
+ expected_text = Moxml::Reference.element_access_rows(ref)
155
+ .map { |(name, text, _content)| [name, compact(text)] }
156
+ adapters.each do |name|
157
+ doc = adapter_document(name, input.xml)
158
+ next unless doc
159
+ next if exempt?(input, :serialize, name)
160
+
161
+ begin
162
+ serialized = doc.to_xml(indent: 0)
163
+ rescue Moxml::Error, NotImplementedError, LoadError
164
+ next # named engine limit, pinned by its own spec
165
+ end
166
+ reparsed = begin
167
+ Moxml::Reference.parse(serialized)
168
+ rescue Moxml::ParseError => e
169
+ raise Moxml::ParseError,
170
+ "#{input.id}/#{name}: serialized XML is not well-formed: " \
171
+ "#{e.message}"
172
+ end
173
+
174
+ expect(Moxml::Reference.element_names(reparsed)).to eq(expected_order),
175
+ "#{input.id}/#{name}: serialized tree lost or reordered elements"
176
+ expect(Moxml::Reference.child_element_names(reparsed)).to eq(expected_children),
177
+ "#{input.id}/#{name}: serialized tree changed nesting"
178
+ got_text = Moxml::Reference.element_access_rows(reparsed)
179
+ .map { |(element, text, _content)| [element, compact(text)] }
180
+ expect(got_text).to eq(expected_text),
181
+ "#{input.id}/#{name}: serialized tree changed " \
182
+ "text: #{got_text.inspect} vs #{expected_text.inspect}"
183
+ end
184
+ end
185
+ end
186
+ end