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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/nightly-fuzz.yml +80 -0
  5. data/.github/workflows/performance-release.yml +79 -0
  6. data/.github/workflows/performance.yml +59 -0
  7. data/Rakefile +23 -0
  8. data/benchmark/README.md +106 -0
  9. data/benchmark/scoreboard.json +115 -0
  10. data/benchmark/scoreboard.rb +556 -0
  11. data/bin/generate-model-coverage +21 -0
  12. data/docs/_pages/adapter-compatibility.adoc +3 -3
  13. data/docs/_pages/divergences.adoc +88 -39
  14. data/docs/_pages/ecosystem.adoc +62 -0
  15. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  16. data/docs/_pages/headed-ox.adoc +2 -2
  17. data/docs/_pages/index.adoc +4 -0
  18. data/docs/_pages/model-coverage.adoc +57 -0
  19. data/docs/_pages/performance.adoc +29 -0
  20. data/docs/_pages/why-moxml.adoc +87 -0
  21. data/docs/_references/processing-model.adoc +69 -3
  22. data/lib/compat/opal/moxml_boot.rb +4 -0
  23. data/lib/moxml/adapter/base.rb +73 -0
  24. data/lib/moxml/adapter/leptris.rb +293 -23
  25. data/lib/moxml/adapter/libxml.rb +179 -25
  26. data/lib/moxml/adapter/nokogiri.rb +10 -0
  27. data/lib/moxml/adapter/oga.rb +71 -9
  28. data/lib/moxml/adapter/ox.rb +34 -2
  29. data/lib/moxml/adapter/rexml.rb +82 -17
  30. data/lib/moxml/c14n/data_model.rb +8 -0
  31. data/lib/moxml/c14n/processor.rb +17 -8
  32. data/lib/moxml/context.rb +33 -1
  33. data/lib/moxml/document.rb +18 -4
  34. data/lib/moxml/node.rb +75 -8
  35. data/lib/moxml/node_set.rb +42 -1
  36. data/lib/moxml/reference/normalizer.rb +63 -0
  37. data/lib/moxml/reference/parser.rb +417 -0
  38. data/lib/moxml/reference/writer.rb +121 -0
  39. data/lib/moxml/reference.rb +205 -0
  40. data/lib/moxml/relaxng.rb +62 -0
  41. data/lib/moxml/sax/block_handler.rb +7 -0
  42. data/lib/moxml/sax/handler.rb +20 -0
  43. data/lib/moxml/version.rb +1 -1
  44. data/lib/moxml/xpath/compiler.rb +17 -2
  45. data/lib/moxml/xpath/ordering.rb +97 -0
  46. data/lib/moxml/xpath.rb +29 -0
  47. data/lib/moxml.rb +2 -0
  48. data/spec/consistency/adapter_parity_spec.rb +24 -0
  49. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  50. data/spec/consistency/differential_taxonomy_spec.rb +117 -0
  51. data/spec/consistency/divergence_register_spec.rb +9 -1
  52. data/spec/consistency/eol_normalization_spec.rb +2 -0
  53. data/spec/consistency/loud_degradation_spec.rb +2 -0
  54. data/spec/consistency/metamorphic_properties_spec.rb +195 -0
  55. data/spec/consistency/metamorphic_spec.rb +138 -0
  56. data/spec/consistency/model_coverage_audit_spec.rb +144 -61
  57. data/spec/consistency/published_docs_spec.rb +49 -0
  58. data/spec/consistency/reference_differential_spec.rb +186 -0
  59. data/spec/consistency/xpath_idioms_spec.rb +34 -5
  60. data/spec/consistency/xslt_spec.rb +2 -0
  61. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  62. data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
  63. data/spec/moxml/adapter/leptris_spec.rb +23 -3
  64. data/spec/moxml/adapter/ox_spec.rb +1 -1
  65. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  66. data/spec/moxml/attribute_resolver_spec.rb +35 -0
  67. data/spec/moxml/c14n/adapter_delegation_spec.rb +35 -6
  68. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  69. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  70. data/spec/moxml/c14n/subset_spec.rb +2 -12
  71. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  72. data/spec/moxml/doctype_spec.rb +7 -0
  73. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  74. data/spec/moxml/element_indent_spec.rb +34 -0
  75. data/spec/moxml/entity_spec.rb +2 -0
  76. data/spec/moxml/node_spec.rb +0 -3
  77. data/spec/moxml/plan_conformance_spec.rb +2 -0
  78. data/spec/moxml/processing_instruction_spec.rb +9 -0
  79. data/spec/moxml/reference_opal_spec.rb +29 -0
  80. data/spec/moxml/reference_spec.rb +162 -0
  81. data/spec/moxml/relax_ng_spec.rb +67 -0
  82. data/spec/moxml/sax_spec.rb +47 -74
  83. data/spec/moxml/text_spec.rb +2 -0
  84. data/spec/moxml/to_html_spec.rb +2 -0
  85. data/spec/moxml/xhtml_output_spec.rb +2 -0
  86. data/spec/moxml/xpath/axes_spec.rb +2 -2
  87. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  88. data/spec/moxml/xsd_spec.rb +38 -0
  89. data/spec/support/input_generator.rb +154 -0
  90. data/spec/support/input_taxonomy.rb +219 -0
  91. data/spec/support/model_coverage.rb +159 -0
  92. data/spec/support/xml_generator.rb +54 -0
  93. 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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Parsing (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # XML 1.0 §2.11 End-of-Line Handling: literal CRLF and lone CR
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Errors and loud degradation (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # Loud degradation (processing model, "Errors and loud degradation"):
@@ -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
- # See docs/_references/processing-model.adoc for the model itself.
12
+ #
13
+ # The matrix itself lives in spec/support/model_coverage.rb: the same
14
+ # data drives this audit, the model document's pin lists, and the
15
+ # published coverage page. See docs/_references/processing-model.adoc
16
+ # for the model itself.
12
17
  RSpec.describe "model coverage matrix" do
13
- MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
- "document order and children listing" => {
15
- 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