moxml 0.5.149 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/performance-release.yml +79 -0
  5. data/.github/workflows/performance.yml +59 -0
  6. data/Rakefile +23 -0
  7. data/benchmark/README.md +106 -0
  8. data/benchmark/scoreboard.json +115 -0
  9. data/benchmark/scoreboard.rb +556 -0
  10. data/bin/generate-model-coverage +21 -0
  11. data/docs/_pages/adapter-compatibility.adoc +3 -3
  12. data/docs/_pages/divergences.adoc +32 -41
  13. data/docs/_pages/ecosystem.adoc +62 -0
  14. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  15. data/docs/_pages/headed-ox.adoc +2 -2
  16. data/docs/_pages/index.adoc +4 -0
  17. data/docs/_pages/model-coverage.adoc +57 -0
  18. data/docs/_pages/performance.adoc +29 -0
  19. data/docs/_pages/why-moxml.adoc +87 -0
  20. data/docs/_references/processing-model.adoc +42 -3
  21. data/lib/moxml/adapter/base.rb +73 -0
  22. data/lib/moxml/adapter/leptris.rb +183 -4
  23. data/lib/moxml/adapter/libxml.rb +34 -2
  24. data/lib/moxml/adapter/nokogiri.rb +10 -0
  25. data/lib/moxml/adapter/oga.rb +39 -7
  26. data/lib/moxml/adapter/ox.rb +8 -0
  27. data/lib/moxml/adapter/rexml.rb +17 -2
  28. data/lib/moxml/c14n/data_model.rb +8 -0
  29. data/lib/moxml/c14n/processor.rb +17 -8
  30. data/lib/moxml/context.rb +28 -0
  31. data/lib/moxml/document.rb +17 -3
  32. data/lib/moxml/node.rb +56 -5
  33. data/lib/moxml/node_set.rb +31 -1
  34. data/lib/moxml/relaxng.rb +62 -0
  35. data/lib/moxml/sax/block_handler.rb +7 -0
  36. data/lib/moxml/sax/handler.rb +20 -0
  37. data/lib/moxml/version.rb +1 -1
  38. data/lib/moxml/xpath.rb +28 -0
  39. data/lib/moxml.rb +1 -0
  40. data/spec/consistency/adapter_parity_spec.rb +22 -0
  41. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  42. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  43. data/spec/consistency/published_docs_spec.rb +49 -0
  44. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  45. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  46. data/spec/moxml/adapter/leptris_spec.rb +23 -3
  47. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  48. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  49. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  50. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  51. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  52. data/spec/moxml/c14n/subset_spec.rb +2 -12
  53. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  54. data/spec/moxml/element_indent_spec.rb +34 -0
  55. data/spec/moxml/node_spec.rb +0 -3
  56. data/spec/moxml/relax_ng_spec.rb +67 -0
  57. data/spec/moxml/sax_spec.rb +45 -74
  58. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  59. data/spec/support/input_taxonomy.rb +7 -0
  60. data/spec/support/model_coverage.rb +159 -0
  61. metadata +17 -2
data/lib/moxml/node.rb CHANGED
@@ -36,6 +36,11 @@ module Moxml
36
36
  self
37
37
  end
38
38
 
39
+ # Document-derived wrapper state (the entity-marker verdict) flows
40
+ # parent -> child during prewrapped traversal; adapters that keep
41
+ # no per-wrapper document state carry nothing.
42
+ def inherit_document_state(_parent); end
43
+
39
44
  def refresh_native!(new_native)
40
45
  unless new_native.equal?(@native)
41
46
  # Cache-stable binding nodes carry the wrapper in an ivar
@@ -226,7 +231,8 @@ module Moxml
226
231
  def attach_previous_sibling(node)
227
232
  context.bump_children_generation
228
233
  node = prepare_node(node)
229
- adapter.add_previous_sibling(@native, node.native)
234
+ inserted = adapter.add_previous_sibling(@native, node.native)
235
+ refresh_inserted_native(node, inserted)
230
236
  # Invalidate the parent's memoized children list. The wrapper-side
231
237
  # @parent_node link is only set when this node was yielded through a
232
238
  # parent-aware NodeSet; nodes obtained via at()/xpath() carry no such
@@ -239,12 +245,20 @@ module Moxml
239
245
  def attach_next_sibling_to(anchor, node)
240
246
  context.bump_children_generation
241
247
  node = prepare_node(node)
242
- adapter.add_next_sibling(anchor.native, node.native)
248
+ inserted = adapter.add_next_sibling(anchor.native, node.native)
249
+ refresh_inserted_native(node, inserted)
243
250
  anchor.parent&.invalidate_children_cache!
244
251
  anchor.invalidate_parent_children_cache!
245
252
  self
246
253
  end
247
254
 
255
+ def refresh_inserted_native(node, inserted)
256
+ return unless adapter.add_sibling_returns_inserted?
257
+ return if inserted.nil? || inserted.equal?(node.native)
258
+
259
+ node.refresh_native!(inserted)
260
+ end
261
+
248
262
  def remove
249
263
  # Nokogiri parity: unlinking an already-detached node is a
250
264
  # no-op returning self. Re-attach idioms like
@@ -296,6 +310,21 @@ module Moxml
296
310
  end
297
311
 
298
312
  def to_xml(options = {})
313
+ # Element-level indentation is a declared capability: engines
314
+ # that ignore it must not answer unindented output silently
315
+ # (D-09). indent: 0 and the no-option path stay common.
316
+ indent = options[:indent]
317
+ if !is_a?(Document) && indent.is_a?(Integer) && indent.positive? &&
318
+ !adapter.element_indent_supported?
319
+ raise Moxml::AdapterError.new(
320
+ "element-level indentation is not supported by the " \
321
+ "#{adapter.name.split('::').last} adapter; serialize the " \
322
+ "document or use indent: 0",
323
+ adapter: adapter.name,
324
+ operation: "serialize",
325
+ )
326
+ end
327
+
299
328
  # Determine if we should include XML declaration
300
329
  # For Document nodes: check native then wrapper, unless explicitly overridden
301
330
  # For other nodes: default to no declaration unless explicitly set
@@ -367,14 +396,26 @@ module Moxml
367
396
  Materializer.materialize_fields(self, &block)
368
397
  end
369
398
 
370
- # Sugar over xpath: the first node of a node-set; scalar results
371
- # (count(), string(), boolean()) pass through unwrapped and
372
- # uniform across adapters.
399
+ # The first node of a node-set; scalar results (count(), string(),
400
+ # boolean()) pass through unwrapped and uniform across adapters.
401
+ # Adapters with a native first-only face use it (see
402
+ # Document#at_xpath).
373
403
  def at_xpath(expression, namespaces = {})
404
+ if adapter.native_at_xpath?
405
+ result = adapter.at_xpath(@native, expression, namespaces)
406
+ return result if scalar_result?(result)
407
+
408
+ return Moxml::Node.wrap(result, context)
409
+ end
374
410
  result = xpath(expression, namespaces)
375
411
  result.is_a?(NodeSet) ? result.first : result
376
412
  end
377
413
 
414
+ def scalar_result?(result)
415
+ result.is_a?(Numeric) || result.is_a?(String) ||
416
+ result == true || result == false || result.nil?
417
+ end
418
+
378
419
  # Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
379
420
  # at/search auto-detect XPath vs CSS; the XPath-first fallback
380
421
  # covers the dominant caller shape (XPath expression strings)
@@ -906,6 +947,16 @@ module Moxml
906
947
  # input native keeps its registration so later wraps of the
907
948
  # same base object hit the cache instead of re-minting.
908
949
  if (extended = adapter.wrap_native(node, type, context))
950
+ # A native that already carries the contract IS the wrapper
951
+ # (leptris C mint): prime it in place and skip the WeakMap
952
+ # round trip — the primed @native-identity answers every
953
+ # later wrap through the fast path above.
954
+ if extended.equal?(node) && extended.is_a?(Node)
955
+ extended.prime_contract(extended, context, adapter, type) unless
956
+ extended.instance_variable_defined?(:@context)
957
+ return extended
958
+ end
959
+
909
960
  extended.prime_contract(extended, context, adapter, type)
910
961
  context.register_wrapper(node, extended)
911
962
  context.register_wrapper(extended, extended) unless extended.equal?(node)
@@ -39,12 +39,18 @@ module Moxml
39
39
  # nodes: Array of natives, or an adapter's LazyNodeSet — the
40
40
  # native set is held unmaterialized until an operation needs an
41
41
  # Array (#+, #<<, #delete, Range slices).
42
- def initialize(nodes, context, parent_node = nil)
42
+ #
43
+ # prewrapped: the adapter vouches the entries are already
44
+ # contract-carrying wrappers (the leptris C-mint children face),
45
+ # so the walk path skips the wrapped-buffer allocation and the
46
+ # per-child wrap dispatch.
47
+ def initialize(nodes, context, parent_node = nil, prewrapped: false)
43
48
  @nodes = nodes.is_a?(Array) ? nodes : nil
44
49
  @lazy = @nodes ? nil : nodes
45
50
  @context = context
46
51
  @wrapped = nil
47
52
  @parent_node = parent_node
53
+ @prewrapped = prewrapped
48
54
  end
49
55
 
50
56
  # The raw native nodes — adapter-internal work only (issue #26).
@@ -59,6 +65,13 @@ module Moxml
59
65
  def each
60
66
  return to_enum(:each) unless block_given?
61
67
 
68
+ if @prewrapped
69
+ (@nodes || @lazy).each do |node|
70
+ yield adopt_prewrapped(node)
71
+ end
72
+ return self
73
+ end
74
+
62
75
  wrapped = wrapped_buffer
63
76
  index = 0
64
77
  (@nodes || @lazy).each do |node|
@@ -227,5 +240,22 @@ module Moxml
227
240
  end
228
241
  wrapped
229
242
  end
243
+
244
+ # The prewrapped entry path: prime a not-yet-primed C-minted
245
+ # wrapper in place (one ivar check), attach the traversal parent,
246
+ # and hand the document's marker verdict down the tree.
247
+ def adopt_prewrapped(node)
248
+ unless node.is_a?(Moxml::Node)
249
+ node = Moxml::Node.wrap(node, @context)
250
+ end
251
+ unless node.instance_variable_defined?(:@context)
252
+ node.prime_contract(node, @context, Moxml::Node.adapter(@context))
253
+ end
254
+ if @parent_node
255
+ node.parent_node = @parent_node
256
+ node.inherit_document_state(@parent_node)
257
+ end
258
+ node
259
+ end
230
260
  end
231
261
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # RELAX NG schema validation (TODO.sota/05): a facade for engines
5
+ # that ship an RNG validator. Engines without one raise the named
6
+ # Moxml::NotImplementedError — the face is never silently absent.
7
+ #
8
+ # ctx = Moxml.new(:leptris)
9
+ # schema = ctx.relaxng_file("schema.rng")
10
+ # schema.valid?(doc) # => true/false
11
+ # schema.validate_errors(doc) # => [] or [{line:, column:, message:}]
12
+ # schema.validate(doc) # => [] or ["line:col: error: message"]
13
+ #
14
+ # Adapters without an engine raise at compile; RelaxNG.supported?
15
+ # answers without raising (the XSD facade's pattern).
16
+ module RelaxNG
17
+ class << self
18
+ # Whether the context's adapter ships a RELAX NG validator.
19
+ def supported?(context)
20
+ adapter = context.config.adapter
21
+ adapter.const_defined?(:RELAXNG_SUPPORTED) &&
22
+ adapter.const_get(:RELAXNG_SUPPORTED)
23
+ end
24
+ end
25
+
26
+ # A compiled schema (adapter-owned handle).
27
+ class Schema
28
+ def initialize(handle, context)
29
+ @handle = handle
30
+ @context = context
31
+ end
32
+
33
+ def valid?(document)
34
+ @context.config.adapter.relaxng_valid?(@handle, native(document))
35
+ end
36
+
37
+ # Structured report: [{line:, column:, message:}] (empty = valid).
38
+ def validate_errors(document)
39
+ @context.config.adapter
40
+ .relaxng_validate_errors(@handle, native(document))
41
+ end
42
+
43
+ # Nokogiri shape: error strings (empty = valid).
44
+ def validate(document)
45
+ validate_errors(document).map do |error|
46
+ line = error[:line]
47
+ if line&.to_i&.positive?
48
+ "#{line}:#{error[:column]}: error: #{error[:message]}"
49
+ else
50
+ error[:message]
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def native(document)
58
+ document.is_a?(Moxml::Document) ? document.native : document
59
+ end
60
+ end
61
+ end
62
+ end
@@ -191,6 +191,13 @@ module Moxml
191
191
  def on_warning(message)
192
192
  @handlers[:warning]&.call(message)
193
193
  end
194
+
195
+ # A block handler subscribes to a family only when its DSL block
196
+ # was registered — it overrides every callback, so method
197
+ # ownership cannot answer interest here.
198
+ def handles?(family)
199
+ @handlers.key?(family)
200
+ end
194
201
  end
195
202
  end
196
203
  end
@@ -98,6 +98,26 @@ module Moxml
98
98
  raise error
99
99
  end
100
100
 
101
+ # SAX event families that engines may or may not report as
102
+ # distinct events, with their handler methods. An engine that
103
+ # cannot report a family must raise the named error when the
104
+ # handler subscribes — never fold the events silently.
105
+ EVENT_METHODS = {
106
+ cdata: :on_cdata,
107
+ comment: :on_comment,
108
+ processing_instruction: :on_processing_instruction,
109
+ }.freeze
110
+
111
+ # Whether this handler subscribes to an event family: the
112
+ # callback was overridden (class handlers) or registered (block
113
+ # handlers).
114
+ #
115
+ # @param family [Symbol] one of EVENT_METHODS keys
116
+ # @return [Boolean]
117
+ def handles?(family)
118
+ method(EVENT_METHODS.fetch(family)).owner != Moxml::SAX::Handler
119
+ end
120
+
101
121
  # Called when a non-fatal warning occurs
102
122
  #
103
123
  # Default implementation ignores warnings.
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.149"
4
+ VERSION = "0.5.150"
5
5
  end
data/lib/moxml/xpath.rb CHANGED
@@ -26,5 +26,33 @@ module Moxml
26
26
  autoload :Node, "moxml/xpath/ruby/node"
27
27
  autoload :Generator, "moxml/xpath/ruby/generator"
28
28
  end
29
+
30
+ module_function
31
+
32
+ # Evaluate an expression through the pure-Ruby XPath 1.0 engine —
33
+ # the model's reference lane. Adapters route shapes their native
34
+ # engine cannot answer here (the `xmlns:` idiom, `text()` node
35
+ # tests, ...). Returns the engine's answer: an Array of wrapped
36
+ # nodes in document order, a NodeSet, or a scalar.
37
+ def evaluate(node, expression, namespaces = {}, adapter: nil)
38
+ context = node.is_a?(Moxml::Node) ? node.context : ::Moxml::Context.new(adapter)
39
+ wrapped = node.is_a?(Moxml::Node) ? node : Moxml::Node.wrap(node, context)
40
+ compiled = Compiler.compile_with_cache(Parser.parse(expression),
41
+ namespaces: namespaces)
42
+ compiled.call(wrapped)
43
+ end
44
+
45
+ # The adapter-contract form of #evaluate: Array<native> | scalar.
46
+ def evaluate_natives(node, expression, namespaces = {}, adapter: nil)
47
+ result = evaluate(node, expression, namespaces, adapter: adapter)
48
+ case result
49
+ when Array
50
+ result.map { |n| n.is_a?(Moxml::Node) ? n.native : n }
51
+ when NodeSet
52
+ result.to_a.map(&:native)
53
+ else
54
+ result
55
+ end
56
+ end
29
57
  end
30
58
  end
data/lib/moxml.rb CHANGED
@@ -105,6 +105,7 @@ module Moxml
105
105
  autoload :Reference, "moxml/reference"
106
106
  autoload :XSLT, "moxml/xslt"
107
107
  autoload :XSD, "moxml/xsd"
108
+ autoload :RelaxNG, "moxml/relaxng"
108
109
 
109
110
  # Error hierarchy — each subclass autoloads from the same file
110
111
  autoload :Error, "moxml/error"
@@ -5,6 +5,28 @@
5
5
  RSpec.describe "Adapter Examples" do
6
6
  let(:xml) { "<root><child>text</child></root>" }
7
7
 
8
+ describe "Document children listing" do
9
+ it "never lists the XML declaration as a child" do
10
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
11
+ doc = Moxml.new(adapter)
12
+ .parse(%(<?xml version="1.0"?><r/>))
13
+ expect(doc.children.map(&:node_type)).to eq([:element]),
14
+ "#{adapter}: declaration leaked " \
15
+ "into children (model: state, not a node)"
16
+ expect(doc.has_xml_declaration).to be(true),
17
+ "#{adapter}: declaration state lost"
18
+ end
19
+ end
20
+
21
+ it "lists the doctype per the Nokogiri contract" do
22
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
23
+ doc = Moxml.new(adapter).parse(%(<!DOCTYPE r><r/>))
24
+ expect(doc.children.map(&:node_type)).to eq(%i[doctype element]),
25
+ "#{adapter}: doctype listing"
26
+ end
27
+ end
28
+ end
29
+
8
30
  describe "Serialization consistency" do
9
31
  it "produces equivalent XML across adapters",
10
32
  skip: "No easy way to exclude the declaration from Nokogiri documents" do
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Citation hygiene (TODO.sota/07): a spec or doc that calls a
6
+ # divergence "pre-existing" or "documented" must resolve it to a
7
+ # register ID (D-NN) on the same line. The register is the only place
8
+ # such a claim is allowed to stand on its own.
9
+ CITATION_PHRASE = /(?:\bpre-existing\b|\bpreexisting\b|\bdocumented (?:divergence|limitation)\b)/i
10
+
11
+ RSpec.describe "divergence citations" do
12
+ def offenses(paths)
13
+ paths.flat_map do |path|
14
+ File.readlines(path).each_with_index.filter_map do |line, idx|
15
+ claim = line.match?(CITATION_PHRASE)
16
+ cited = line.match?(/D-\d+/)
17
+ "#{path}:#{idx + 1}: #{line.strip}" if claim && !cited
18
+ end
19
+ end
20
+ end
21
+
22
+ it "resolves every pre-existing/documented divergence claim to a register ID" do
23
+ paths = (Dir.glob("spec/**/*.rb") + Dir.glob("docs/**/*.adoc"))
24
+ .reject { |path| path.end_with?("citation_hygiene_spec.rb") }
25
+ found = offenses(paths)
26
+ expect(found).to be_empty,
27
+ "name a D-NN (docs/_pages/divergences.adoc) or drop " \
28
+ "the claim:\n#{found.join("\n")}"
29
+ end
30
+ 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,84 +9,13 @@ require "spec_helper"
8
9
  # a registered divergence (adapter => D-ID), or loud (adapter =>
9
10
  # the named error class). An unpinned family is an ungoverned cell:
10
11
  # this audit fails until it has one of the three.
11
- # See docs/_references/processing-model.adoc for the model itself.
12
+ #
13
+ # The matrix itself lives in spec/support/model_coverage.rb: the same
14
+ # data drives this audit, the model document's pin lists, and the
15
+ # published coverage page. See docs/_references/processing-model.adoc
16
+ # for the model itself.
12
17
  RSpec.describe "model coverage matrix" do
13
- MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
- "document order and children listing" => {
15
- section: "Document order and traversal",
16
- pin: "spec/consistency/adapter_parity_spec.rb",
17
- },
18
- "name resolution" => {
19
- section: "Names and namespaces",
20
- pin: "spec/consistency/xpath_idioms_spec.rb",
21
- },
22
- "namespace resolution and bindings normalization" => {
23
- section: "Names and namespaces",
24
- pin: "spec/consistency/xpath_idioms_spec.rb",
25
- },
26
- "attribute access" => {
27
- section: "Access semantics",
28
- pin: "spec/moxml/attribute_resolver_spec.rb",
29
- },
30
- "text/content/raw_content" => {
31
- section: "Access semantics",
32
- pin: "spec/moxml/text_spec.rb",
33
- },
34
- "parsing modes and diagnostics" => {
35
- section: "Parsing",
36
- pin: "spec/moxml/document_parse_diagnostics_spec.rb",
37
- },
38
- "EOL normalization" => {
39
- section: "Parsing",
40
- pin: "spec/consistency/eol_normalization_spec.rb",
41
- exempt: { oga: "D-02", ox: "D-03" },
42
- },
43
- "entity modes" => {
44
- section: "Parsing",
45
- pin: "spec/moxml/entity_spec.rb",
46
- },
47
- "XPath evaluation and declared idioms" => {
48
- section: "XPath evaluation",
49
- pin: "spec/consistency/xpath_idioms_spec.rb",
50
- exempt: { oga: "D-01" },
51
- },
52
- "serialization shapes (xml/html/xhtml)" => {
53
- section: "Serialization",
54
- pin: "spec/moxml/xhtml_output_spec.rb",
55
- },
56
- "indentation knobs" => {
57
- section: "Serialization",
58
- pin: "spec/moxml/to_html_spec.rb",
59
- exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
60
- libxml: "D-09", leptris: "D-09" },
61
- },
62
- "canonicalization" => {
63
- section: "Canonicalization",
64
- pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
65
- exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
66
- },
67
- "XSLT" => {
68
- section: "XSLT",
69
- pin: "spec/consistency/xslt_spec.rb",
70
- loud: { oga: "Moxml::NotImplementedError",
71
- ox: "Moxml::NotImplementedError",
72
- headed_ox: "Moxml::NotImplementedError",
73
- rexml: "Moxml::NotImplementedError",
74
- libxml: "Moxml::NotImplementedError" },
75
- },
76
- "SAX" => {
77
- section: "SAX",
78
- pin: "spec/moxml/sax_spec.rb",
79
- },
80
- "materialization" => {
81
- section: "Materialization",
82
- pin: "spec/moxml/plan_conformance_spec.rb",
83
- },
84
- "loud capability contracts" => {
85
- section: "Errors and loud degradation",
86
- pin: "spec/consistency/loud_degradation_spec.rb",
87
- },
88
- }.freeze
18
+ MODEL_MATRIX = ModelCoverage::MODEL_COVERAGE_MATRIX # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
89
19
 
90
20
  def register_ids
91
21
  File.readlines("docs/_pages/divergences.adoc")
@@ -134,12 +64,7 @@ RSpec.describe "model coverage matrix" do
134
64
  # section vanished fails too.
135
65
  MODEL_DOC = "docs/_references/processing-model.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
136
66
 
137
- NON_FACE_SECTIONS = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
138
- "Status and precedence",
139
- "Mission and invariant",
140
- "Object model",
141
- "Performance: a declared, non-semantic channel",
142
- ].freeze
67
+ NON_FACE = ModelCoverage::MODEL_COVERAGE_NON_FACE # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
143
68
 
144
69
  def model_sections
145
70
  File.readlines(MODEL_DOC).filter_map do |line|
@@ -147,6 +72,24 @@ RSpec.describe "model coverage matrix" do
147
72
  end.uniq
148
73
  end
149
74
 
75
+ # Document-side pin lists (TODO.sota/07): every section carries a
76
+ # `Pins::` line naming the specs that enforce it (`Pins:: none` for
77
+ # non-face sections). Parsed as section => [paths] or nil when the
78
+ # line is missing.
79
+ def model_pin_lists
80
+ lists = {}
81
+ current = nil
82
+ File.readlines(MODEL_DOC).each do |line|
83
+ if (heading = line[/\A=== (\S.*)$/, 1])
84
+ current = heading.strip
85
+ lists[current] = nil
86
+ elsif current && (entry = line[/\APins:: (.*)$/, 1])
87
+ lists[current] = entry.scan(/`([^`]+)`/).flatten
88
+ end
89
+ end
90
+ lists
91
+ end
92
+
150
93
  it "cites an existing model section for every face family" do
151
94
  sections = model_sections
152
95
  unknown = MODEL_MATRIX.filter_map do |family, cell|
@@ -162,7 +105,7 @@ RSpec.describe "model coverage matrix" do
162
105
  it "covers or declares every model section" do
163
106
  cited = MODEL_MATRIX.values.filter_map { |cell| cell[:section] }
164
107
  uncovered = model_sections.reject do |section|
165
- cited.include?(section) || NON_FACE_SECTIONS.include?(section)
108
+ cited.include?(section) || NON_FACE.include?(section)
166
109
  end
167
110
  expect(uncovered).to be_empty,
168
111
  "model sections with no coverage matrix family " \
@@ -170,11 +113,43 @@ RSpec.describe "model coverage matrix" do
170
113
  end
171
114
 
172
115
  it "declares non-face sections that actually exist in the model doc" do
173
- stale = NON_FACE_SECTIONS.reject { |s| model_sections.include?(s) }
116
+ stale = NON_FACE.reject { |s| model_sections.include?(s) }
174
117
  expect(stale).to be_empty,
175
118
  "non-face declarations citing missing sections: #{stale.join('; ')}"
176
119
  end
177
120
 
121
+ it "gives every model section a Pins line" do
122
+ missing = model_pin_lists.select { |_, pins| pins.nil? }.keys
123
+ expect(missing).to be_empty,
124
+ "model sections without a Pins line: #{missing.join('; ')}"
125
+ end
126
+
127
+ it "matches the model doc's face-section pin lists to the matrix" do
128
+ lists = model_pin_lists
129
+ drift = ModelCoverage.sections.filter_map do |section|
130
+ expected = ModelCoverage.pins_for(section)
131
+ got = lists[section]
132
+ "#{section}: doc #{got.inspect} vs matrix #{expected.inspect}" if got != expected
133
+ end
134
+ expect(drift).to be_empty,
135
+ "model-doc pin lists drift from the matrix: #{drift.join('; ')}"
136
+ end
137
+
138
+ it "leaves non-face sections without pins" do
139
+ lists = model_pin_lists
140
+ pinned = NON_FACE.select { |section| lists[section]&.any? }
141
+ expect(pinned).to be_empty,
142
+ "non-face sections must declare Pins:: none: #{pinned.join('; ')}"
143
+ end
144
+
145
+ it "points every model-doc pin at an existing spec" do
146
+ missing = model_pin_lists.values.compact.flatten.uniq.reject do |path|
147
+ File.exist?(path)
148
+ end
149
+ expect(missing).to be_empty,
150
+ "model-doc pins missing on disk: #{missing.join(', ')}"
151
+ end
152
+
178
153
  # Bidirectional linkage (TODO.sota/03): the model doc, the pins, and
179
154
  # the reference form one checkable triangle. Every pin cites the
180
155
  # section it enforces; every citation names a section that exists.
@@ -213,7 +188,7 @@ RSpec.describe "model coverage matrix" do
213
188
  File.exist?(path) ? pin_citations(path) : []
214
189
  end
215
190
  uncovered = model_sections.reject do |section|
216
- cited.include?(section) || NON_FACE_SECTIONS.include?(section)
191
+ cited.include?(section) || NON_FACE.include?(section)
217
192
  end
218
193
  expect(uncovered).to be_empty,
219
194
  "model sections no pin cites: #{uncovered.join('; ')}"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/model_coverage"
5
+ require "json"
6
+
7
+ # Published documentation is part of the specification apparatus
8
+ # (TODO.sota/07): generated blocks must be views of committed data. A
9
+ # data change without regeneration fails here, so the published
10
+ # coverage proof and scoreboard can never claim more than the tests do.
11
+ RSpec.describe "published documentation" do
12
+ it "keeps the model-coverage page in sync with the matrix" do
13
+ page = File.read("docs/_pages/model-coverage.adoc")
14
+ block = page[/MODEL-COVERAGE:BEGIN\n(.*?)\n?\/\/ MODEL-COVERAGE:END/m, 1]
15
+ expect(block).not_to be_nil, "coverage page is missing its markers"
16
+
17
+ expect(block).to eq(ModelCoverage.render_page_block),
18
+ "model-coverage page is stale: run rake docs:coverage"
19
+ end
20
+
21
+ it "keeps every published scoreboard row in sync with the JSON data" do
22
+ data = JSON.parse(File.read("benchmark/scoreboard.json"))
23
+ page = File.read("docs/_pages/performance.adoc")
24
+ block = page[/SCOREBOARD:BEGIN(.*?)SCOREBOARD:END/m, 1]
25
+ expect(block).not_to be_nil, "performance page is missing its markers"
26
+
27
+ data["rows"].each do |row|
28
+ expect(block).to include(
29
+ "| `#{row['row']}` | #{format('%.2f', row['ratio'])}x | " \
30
+ "#{row['kind']} |",
31
+ ), "scoreboard row #{row['row']} is stale against scoreboard.json"
32
+ end
33
+
34
+ expect(block.scan(/\| `[^`]+` \| \d+\.\d\dx \|/).size)
35
+ .to eq(data["rows"].size), "scoreboard row count differs"
36
+ end
37
+
38
+ it "names the measured engine version in the scoreboard metadata" do
39
+ data = JSON.parse(File.read("benchmark/scoreboard.json"))
40
+ protocol = data["protocol"]
41
+ page = File.read("docs/_pages/performance.adoc")
42
+ block = page[/SCOREBOARD:BEGIN(.*?)SCOREBOARD:END/m, 1]
43
+
44
+ expect(block).to include("#{protocol['adapter']} " \
45
+ "#{protocol['adapter_version']}"),
46
+ "scoreboard metadata does not name the measured engine"
47
+ expect(block).to include("nokogiri #{protocol['nokogiri']}")
48
+ end
49
+ end