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.
- checksums.yaml +4 -4
- data/.github/workflows/nightly-fuzz.yml +80 -0
- data/docs/_pages/divergences.adoc +58 -0
- data/docs/_references/processing-model.adoc +30 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/leptris.rb +180 -20
- data/lib/moxml/adapter/libxml.rb +145 -23
- data/lib/moxml/adapter/oga.rb +33 -3
- data/lib/moxml/adapter/ox.rb +26 -2
- data/lib/moxml/adapter/rexml.rb +65 -15
- data/lib/moxml/context.rb +5 -1
- data/lib/moxml/document.rb +24 -10
- data/lib/moxml/node.rb +34 -6
- data/lib/moxml/node_set.rb +11 -0
- data/lib/moxml/reference/normalizer.rb +63 -0
- data/lib/moxml/reference/parser.rb +417 -0
- data/lib/moxml/reference/writer.rb +121 -0
- data/lib/moxml/reference.rb +205 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +17 -2
- data/lib/moxml/xpath/ordering.rb +97 -0
- data/lib/moxml/xpath.rb +1 -0
- data/lib/moxml.rb +1 -0
- data/spec/consistency/adapter_parity_spec.rb +2 -0
- data/spec/consistency/differential_taxonomy_spec.rb +117 -0
- data/spec/consistency/divergence_register_spec.rb +9 -1
- data/spec/consistency/eol_normalization_spec.rb +2 -0
- data/spec/consistency/loud_degradation_spec.rb +2 -0
- data/spec/consistency/metamorphic_properties_spec.rb +195 -0
- data/spec/consistency/metamorphic_spec.rb +138 -0
- data/spec/consistency/model_coverage_audit_spec.rb +108 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +11 -0
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/leptris_contract_reads_spec.rb +29 -0
- data/spec/moxml/adapter/leptris_detached_text_spec.rb +10 -3
- data/spec/moxml/adapter/leptris_parse_lint_spec.rb +12 -0
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/attribute_resolver_spec.rb +2 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/plan_conformance_spec.rb +2 -0
- data/spec/moxml/processing_instruction_spec.rb +9 -0
- data/spec/moxml/reference_opal_spec.rb +29 -0
- data/spec/moxml/reference_spec.rb +162 -0
- data/spec/moxml/sax_spec.rb +2 -0
- data/spec/moxml/text_spec.rb +2 -0
- data/spec/moxml/to_html_spec.rb +2 -0
- data/spec/moxml/xhtml_output_spec.rb +2 -0
- data/spec/moxml/xpath/axes_spec.rb +2 -2
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +212 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +17 -2
|
@@ -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
|