moxml 0.5.148 → 0.5.150
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/dependent-repos.json +7 -2
- data/.github/workflows/dependent-tests.yml +4 -1
- data/.github/workflows/nightly-fuzz.yml +80 -0
- data/.github/workflows/performance-release.yml +79 -0
- data/.github/workflows/performance.yml +59 -0
- data/Rakefile +23 -0
- data/benchmark/README.md +106 -0
- data/benchmark/scoreboard.json +115 -0
- data/benchmark/scoreboard.rb +556 -0
- data/bin/generate-model-coverage +21 -0
- data/docs/_pages/adapter-compatibility.adoc +3 -3
- data/docs/_pages/divergences.adoc +88 -39
- data/docs/_pages/ecosystem.adoc +62 -0
- data/docs/_pages/headed-ox-limitations.adoc +1 -1
- data/docs/_pages/headed-ox.adoc +2 -2
- data/docs/_pages/index.adoc +4 -0
- data/docs/_pages/model-coverage.adoc +57 -0
- data/docs/_pages/performance.adoc +29 -0
- data/docs/_pages/why-moxml.adoc +87 -0
- data/docs/_references/processing-model.adoc +69 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/base.rb +73 -0
- data/lib/moxml/adapter/leptris.rb +293 -23
- data/lib/moxml/adapter/libxml.rb +179 -25
- data/lib/moxml/adapter/nokogiri.rb +10 -0
- data/lib/moxml/adapter/oga.rb +71 -9
- data/lib/moxml/adapter/ox.rb +34 -2
- data/lib/moxml/adapter/rexml.rb +82 -17
- data/lib/moxml/c14n/data_model.rb +8 -0
- data/lib/moxml/c14n/processor.rb +17 -8
- data/lib/moxml/context.rb +33 -1
- data/lib/moxml/document.rb +18 -4
- data/lib/moxml/node.rb +75 -8
- data/lib/moxml/node_set.rb +42 -1
- 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/relaxng.rb +62 -0
- data/lib/moxml/sax/block_handler.rb +7 -0
- data/lib/moxml/sax/handler.rb +20 -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 +29 -0
- data/lib/moxml.rb +2 -0
- data/spec/consistency/adapter_parity_spec.rb +24 -0
- data/spec/consistency/citation_hygiene_spec.rb +30 -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 +144 -61
- data/spec/consistency/published_docs_spec.rb +49 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +34 -5
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/integration/shared_examples/edge_cases.rb +0 -13
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/leptris_spec.rb +23 -3
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
- data/spec/moxml/attribute_resolver_spec.rb +35 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +35 -6
- data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
- data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
- data/spec/moxml/c14n/subset_spec.rb +2 -12
- data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/element_indent_spec.rb +34 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/node_spec.rb +0 -3
- 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/relax_ng_spec.rb +67 -0
- data/spec/moxml/sax_spec.rb +47 -74
- 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/xpath_capabilities_spec.rb +11 -38
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +219 -0
- data/spec/support/model_coverage.rb +159 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +32 -2
|
@@ -9,7 +9,7 @@ require "spec_helper"
|
|
|
9
9
|
# no spec cites an unregistered ID.
|
|
10
10
|
RSpec.describe "divergence register linkage" do
|
|
11
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
|
|
12
|
+
SCAN_GLOBS = %w[spec/consistency/**/*.rb spec/moxml/**/*.rb spec/support/*.rb].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
13
13
|
NEXT_GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
14
14
|
/same engine as/, # engine dedup, not a divergence
|
|
15
15
|
/source_adapter == target_adapter/, # self round-trip
|
|
@@ -18,7 +18,15 @@ RSpec.describe "divergence register linkage" do
|
|
|
18
18
|
/skip_shapes/, # shape routing (IDs cited above)
|
|
19
19
|
/leptris_fixed\?/, # version gate
|
|
20
20
|
/source_elements/, # data-presence guard
|
|
21
|
+
/next unless doc/, # adapter-availability guard (not a
|
|
22
|
+
# divergence: the adapter is not installed in this environment)
|
|
23
|
+
/next unless cell\[:pin\]/, # matrix data-presence guard
|
|
21
24
|
/next unless match/, # scanner-internal guard
|
|
25
|
+
/well_formed|voids\.empty\?|default_ns|per\.size/, # property-scope
|
|
26
|
+
# and availability guards (input scoping, not adapter exemptions)
|
|
27
|
+
/exempt/i, # value-routed exemption table: the
|
|
28
|
+
# table's VALUES are D-IDs, enforced by
|
|
29
|
+
# the coverage audit's exempt rule
|
|
22
30
|
].freeze
|
|
23
31
|
|
|
24
32
|
# Version/availability gates are not divergences: a binding too old
|
|
@@ -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
|
|
@@ -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,68 +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
|
-
#
|
|
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 =
|
|
14
|
-
"document order and children listing" => {
|
|
15
|
-
pin: "spec/consistency/adapter_parity_spec.rb",
|
|
16
|
-
},
|
|
17
|
-
"name resolution" => {
|
|
18
|
-
pin: "spec/consistency/xpath_idioms_spec.rb",
|
|
19
|
-
},
|
|
20
|
-
"namespace resolution and bindings normalization" => {
|
|
21
|
-
pin: "spec/consistency/xpath_idioms_spec.rb",
|
|
22
|
-
},
|
|
23
|
-
"attribute access" => {
|
|
24
|
-
pin: "spec/moxml/attribute_resolver_spec.rb",
|
|
25
|
-
},
|
|
26
|
-
"text/content/raw_content" => {
|
|
27
|
-
pin: "spec/moxml/text_spec.rb",
|
|
28
|
-
},
|
|
29
|
-
"parsing modes and diagnostics" => {
|
|
30
|
-
pin: "spec/moxml/document_parse_diagnostics_spec.rb",
|
|
31
|
-
},
|
|
32
|
-
"EOL normalization" => {
|
|
33
|
-
pin: "spec/consistency/eol_normalization_spec.rb",
|
|
34
|
-
exempt: { oga: "D-02", ox: "D-03" },
|
|
35
|
-
},
|
|
36
|
-
"entity modes" => {
|
|
37
|
-
pin: "spec/moxml/entity_spec.rb",
|
|
38
|
-
},
|
|
39
|
-
"XPath evaluation and declared idioms" => {
|
|
40
|
-
pin: "spec/consistency/xpath_idioms_spec.rb",
|
|
41
|
-
exempt: { oga: "D-01" },
|
|
42
|
-
},
|
|
43
|
-
"serialization shapes (xml/html/xhtml)" => {
|
|
44
|
-
pin: "spec/moxml/xhtml_output_spec.rb",
|
|
45
|
-
},
|
|
46
|
-
"indentation knobs" => {
|
|
47
|
-
pin: "spec/moxml/to_html_spec.rb",
|
|
48
|
-
exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
|
|
49
|
-
libxml: "D-09", leptris: "D-09" },
|
|
50
|
-
},
|
|
51
|
-
"canonicalization" => {
|
|
52
|
-
pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
|
|
53
|
-
exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
|
|
54
|
-
},
|
|
55
|
-
"XSLT" => {
|
|
56
|
-
pin: "spec/consistency/xslt_spec.rb",
|
|
57
|
-
loud: { oga: "Moxml::NotImplementedError",
|
|
58
|
-
ox: "Moxml::NotImplementedError",
|
|
59
|
-
headed_ox: "Moxml::NotImplementedError",
|
|
60
|
-
rexml: "Moxml::NotImplementedError",
|
|
61
|
-
libxml: "Moxml::NotImplementedError" },
|
|
62
|
-
},
|
|
63
|
-
"SAX" => {
|
|
64
|
-
pin: "spec/moxml/sax_spec.rb",
|
|
65
|
-
},
|
|
66
|
-
"materialization" => {
|
|
67
|
-
pin: "spec/moxml/plan_conformance_spec.rb",
|
|
68
|
-
},
|
|
69
|
-
"loud capability contracts" => {
|
|
70
|
-
pin: "spec/consistency/loud_degradation_spec.rb",
|
|
71
|
-
},
|
|
72
|
-
}.freeze
|
|
18
|
+
MODEL_MATRIX = ModelCoverage::MODEL_COVERAGE_MATRIX # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
73
19
|
|
|
74
20
|
def register_ids
|
|
75
21
|
File.readlines("docs/_pages/divergences.adoc")
|
|
@@ -110,4 +56,141 @@ RSpec.describe "model coverage matrix" do
|
|
|
110
56
|
expect(bad).to be_empty,
|
|
111
57
|
"loud cells must name Moxml errors: #{bad.join('; ')}"
|
|
112
58
|
end
|
|
59
|
+
|
|
60
|
+
# Model-derived governance: the matrix is generated FROM the model
|
|
61
|
+
# doc's section headings, in both directions. A new section in
|
|
62
|
+
# processing-model.adoc without a matrix citation (or an explicit
|
|
63
|
+
# non-face declaration) fails this audit; a matrix citation whose
|
|
64
|
+
# section vanished fails too.
|
|
65
|
+
MODEL_DOC = "docs/_references/processing-model.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
66
|
+
|
|
67
|
+
NON_FACE = ModelCoverage::MODEL_COVERAGE_NON_FACE # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
68
|
+
|
|
69
|
+
def model_sections
|
|
70
|
+
File.readlines(MODEL_DOC).filter_map do |line|
|
|
71
|
+
line[/\A=== (\S.*)$/, 1]&.strip
|
|
72
|
+
end.uniq
|
|
73
|
+
end
|
|
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
|
+
|
|
93
|
+
it "cites an existing model section for every face family" do
|
|
94
|
+
sections = model_sections
|
|
95
|
+
unknown = MODEL_MATRIX.filter_map do |family, cell|
|
|
96
|
+
section = cell[:section]
|
|
97
|
+
next "#{family}: nil" if section.nil?
|
|
98
|
+
|
|
99
|
+
"#{family}: #{section.inspect}" unless sections.include?(section)
|
|
100
|
+
end
|
|
101
|
+
expect(unknown).to be_empty,
|
|
102
|
+
"matrix families citing missing model sections: #{unknown.join('; ')}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "covers or declares every model section" do
|
|
106
|
+
cited = MODEL_MATRIX.values.filter_map { |cell| cell[:section] }
|
|
107
|
+
uncovered = model_sections.reject do |section|
|
|
108
|
+
cited.include?(section) || NON_FACE.include?(section)
|
|
109
|
+
end
|
|
110
|
+
expect(uncovered).to be_empty,
|
|
111
|
+
"model sections with no coverage matrix family " \
|
|
112
|
+
"(add a family or declare non-face): #{uncovered.join('; ')}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "declares non-face sections that actually exist in the model doc" do
|
|
116
|
+
stale = NON_FACE.reject { |s| model_sections.include?(s) }
|
|
117
|
+
expect(stale).to be_empty,
|
|
118
|
+
"non-face declarations citing missing sections: #{stale.join('; ')}"
|
|
119
|
+
end
|
|
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
|
+
|
|
153
|
+
# Bidirectional linkage (TODO.sota/03): the model doc, the pins, and
|
|
154
|
+
# the reference form one checkable triangle. Every pin cites the
|
|
155
|
+
# section it enforces; every citation names a section that exists.
|
|
156
|
+
PIN_CITATION = /Processing model §(.+?) \(normative\)/ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
|
|
157
|
+
|
|
158
|
+
def pin_citations(path)
|
|
159
|
+
File.readlines(path).filter_map { |line| line[PIN_CITATION, 1]&.strip }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "has every pin cite the model section it enforces" do
|
|
163
|
+
missing = MODEL_MATRIX.filter_map do |family, cell|
|
|
164
|
+
next unless cell[:pin] && File.exist?(cell[:pin])
|
|
165
|
+
|
|
166
|
+
"#{family}: #{cell[:pin]}" unless pin_citations(cell[:pin])
|
|
167
|
+
.include?(cell[:section])
|
|
168
|
+
end
|
|
169
|
+
expect(missing).to be_empty,
|
|
170
|
+
"pins must cite their model section " \
|
|
171
|
+
"(# Processing model §<section>): #{missing.join('; ')}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "never cites a model section that does not exist" do
|
|
175
|
+
sections = model_sections
|
|
176
|
+
orphans = MODEL_MATRIX.filter_map { |_, cell| cell[:pin] }.uniq.flat_map do |path|
|
|
177
|
+
next [] unless File.exist?(path)
|
|
178
|
+
|
|
179
|
+
pin_citations(path).reject { |section| sections.include?(section) }
|
|
180
|
+
.map { |section| "#{path}: #{section}" }
|
|
181
|
+
end
|
|
182
|
+
expect(orphans).to be_empty,
|
|
183
|
+
"pin citations naming missing sections: #{orphans.join('; ')}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "cites every face section from at least one pin" do
|
|
187
|
+
cited = MODEL_MATRIX.filter_map { |_, cell| cell[:pin] }.uniq.flat_map do |path|
|
|
188
|
+
File.exist?(path) ? pin_citations(path) : []
|
|
189
|
+
end
|
|
190
|
+
uncovered = model_sections.reject do |section|
|
|
191
|
+
cited.include?(section) || NON_FACE.include?(section)
|
|
192
|
+
end
|
|
193
|
+
expect(uncovered).to be_empty,
|
|
194
|
+
"model sections no pin cites: #{uncovered.join('; ')}"
|
|
195
|
+
end
|
|
113
196
|
end
|
|
@@ -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
|