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
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Executable reference semantics (TODO.sota/03): a pure-Ruby XML
5
+ # reader, writer, and normalizer that answer the processing model
6
+ # without any native engine. Adapters are differentially tested
7
+ # against these answers; the reference is also the lane for
8
+ # engine-free environments (the Opal line generalized).
9
+ #
10
+ # The reference is deliberately small and total over the model's
11
+ # declared input taxonomy — every well-formed document the model
12
+ # admits is parseable here. It is an oracle, not a production
13
+ # parser: no recovery, no diagnostics beyond a named ParseError.
14
+ module Reference
15
+ autoload :Parser, "moxml/reference/parser"
16
+ autoload :Writer, "moxml/reference/writer"
17
+ autoload :Normalizer, "moxml/reference/normalizer"
18
+
19
+ # A reference node: the information set of the processing model,
20
+ # with no engine-specific shape.
21
+ class Node
22
+ attr_accessor :kind, :name, :attributes, :children, :data, :parent
23
+
24
+ def initialize(kind:, name: nil, data: nil, attributes: [])
25
+ @kind = kind
26
+ @name = name
27
+ @data = data
28
+ @attributes = attributes
29
+ @children = []
30
+ @parent = nil
31
+ end
32
+
33
+ def element?
34
+ @kind == :element
35
+ end
36
+
37
+ def text?
38
+ @kind == :text
39
+ end
40
+
41
+ # The facade's name face: a bare local name for prefixed sources
42
+ # (Element#name; attribute pairs list bare names too).
43
+ def local_name
44
+ return @name unless @name&.include?(":")
45
+
46
+ @name.split(":", 2)[1]
47
+ end
48
+
49
+ # Concatenated descendant text (elements; text and CDATA only —
50
+ # comments and PI data are not text); own data for text-like
51
+ # nodes (the model's `text` face).
52
+ def text
53
+ case @kind
54
+ when :element
55
+ @children.select do |child|
56
+ child.kind == :text || child.kind == :cdata || child.element?
57
+ end.map(&:text).join
58
+ when :text, :cdata
59
+ @data
60
+ else
61
+ ""
62
+ end
63
+ end
64
+
65
+ # Element content is its text face; text-like nodes answer their
66
+ # data (PI data excludes the separator, per the model).
67
+ def content
68
+ element? ? text : @data
69
+ end
70
+
71
+ def attribute(name)
72
+ pair = @attributes.find do |(key, _)|
73
+ key == name || local_name_of(key) == name
74
+ end
75
+ pair && pair[1]
76
+ end
77
+
78
+ # The facade's listing face: bare names, source order, duplicates
79
+ # included, namespace declarations excluded (they are Namespace
80
+ # nodes, not attributes — uniform with every adapter).
81
+ def attribute_pairs
82
+ @attributes.filter_map do |(key, value)|
83
+ next if key == "xmlns" || key.start_with?("xmlns:")
84
+
85
+ [local_name_of(key), value]
86
+ end
87
+ end
88
+
89
+ def local_name_of(name)
90
+ return name unless name&.include?(":")
91
+
92
+ name.split(":", 2)[1]
93
+ end
94
+ end
95
+
96
+ # A reference document: declaration and doctype are state (the
97
+ # model's document-parts-are-state rule); children are the
98
+ # top-level nodes excluding them.
99
+ class Document
100
+ attr_accessor :children, :declaration, :doctype, :entities
101
+
102
+ def initialize
103
+ @children = []
104
+ @declaration = nil
105
+ @doctype = nil
106
+ @entities = {}
107
+ end
108
+
109
+ def root
110
+ @children.find(&:element?)
111
+ end
112
+
113
+ # Depth-first pre-order, excluding the document itself.
114
+ def each_node(&block)
115
+ @children.each { |child| walk(child, &block) }
116
+ end
117
+
118
+ def walk(node, &block)
119
+ yield node
120
+ node.children.each { |child| walk(child, &block) }
121
+ end
122
+ end
123
+
124
+ # Encodings the reference accepts: UTF-8 and US-ASCII sources.
125
+ # Transcoding is the adapter's business; the reference reads the
126
+ # UTF-8 string the facade hands it.
127
+ module_function
128
+
129
+ # Parse an XML string into a reference Document.
130
+ def parse(xml)
131
+ Parser.new(xml).parse
132
+ end
133
+
134
+ # Document-order element names (the ordering face; bare local
135
+ # names, matching Element#name).
136
+ def element_names(document)
137
+ names = []
138
+ document.each_node { |node| names << node.local_name if node.element? }
139
+ names
140
+ end
141
+
142
+ # Element children names, keyed by each element's index in
143
+ # pre-order (the children-listing face; avoids text segmentation,
144
+ # which is a registered engine divergence family).
145
+ def child_element_names(document)
146
+ rows = []
147
+ document.each_node do |node|
148
+ next unless node.element?
149
+
150
+ rows << node.children.select(&:element?).map(&:local_name)
151
+ end
152
+ rows
153
+ end
154
+
155
+ # The access truth table, one row per element in pre-order:
156
+ # [name, text, content].
157
+ def element_access_rows(document)
158
+ rows = []
159
+ document.each_node do |node|
160
+ next unless node.element?
161
+
162
+ rows << [node.local_name, node.text, node.content]
163
+ end
164
+ rows
165
+ end
166
+
167
+ # PI targets and data, in document order.
168
+ def processing_instructions(document)
169
+ rows = []
170
+ document.each_node do |node|
171
+ rows << [node.name, node.data] if node.kind == :processing_instruction
172
+ end
173
+ rows
174
+ end
175
+
176
+ # Comment data, in document order.
177
+ def comments(document)
178
+ rows = []
179
+ document.each_node { |node| rows << node.data if node.kind == :comment }
180
+ rows
181
+ end
182
+
183
+ # Serialize the reference document as XML (indent: 0 canonical
184
+ # facade shape: declaration if the source had one, escaped text,
185
+ # self-closing empty elements).
186
+ def to_xml(document)
187
+ Writer.new(document).to_xml
188
+ end
189
+
190
+ # Serialize as HTML (void elements unclosed, raw script/style).
191
+ def to_html(document)
192
+ Writer.new(document).to_html
193
+ end
194
+
195
+ # Serialize as XHTML (void elements self-closed).
196
+ def to_xhtml(document)
197
+ Writer.new(document).to_xhtml
198
+ end
199
+
200
+ # EOL normalization per XML 1.0 §2.11.
201
+ def normalize_eol(text)
202
+ Normalizer.normalize_eol(text)
203
+ end
204
+ end
205
+ 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.148"
4
+ VERSION = "0.5.150"
5
5
  end
@@ -30,9 +30,12 @@ module Moxml
30
30
  # Wildcard for node names/namespace prefixes
31
31
  STAR = "*"
32
32
 
33
- # Node types that require a NodeSet to push nodes into
33
+ # Node types that require a NodeSet to push nodes into.
34
+ # :union belongs here: a top-level union built its own NodeSet
35
+ # in on_pipe and bypassed the document-order wrapper — union
36
+ # results answered in expression order (M3).
34
37
  RETURN_NODESET = %i[path absolute_path relative_path axis
35
- predicate].freeze
38
+ predicate union].freeze
36
39
 
37
40
  # Compiles and caches an AST
38
41
  #
@@ -94,8 +97,14 @@ module Moxml
94
97
  [nodeset_class, "new", empty_array,
95
98
  context_var])
96
99
 
100
+ # Multi-step paths batch step N over every step-(N-1)
101
+ # node, concatenating subtree results after sibling
102
+ # results (//* answered a,c,b on <r><a><b/></a><c/></r>);
103
+ # XPath path results are conventionally document-ordered.
104
+ ordered = Ruby::Node.new(:send, [matched, "document_order"])
97
105
  body = matched.assign(nodeset_new)
98
106
  .followed_by(ruby_ast)
107
+ .followed_by(matched.assign(ordered))
99
108
  .followed_by(matched)
100
109
  else
101
110
  body = ruby_ast
@@ -293,6 +302,12 @@ module Moxml
293
302
  end
294
303
  end
295
304
  end
305
+
306
+ # Multi-step evaluation batches step N over every step-(N-1)
307
+ # node, so subtree results concatenate after sibling
308
+ # results (//* on <r><a><b/></a><c/></r> answered a,c,b).
309
+ # Path results are conventionally document-ordered: re-sort
310
+ # the union when the caller takes the node-set form.
296
311
  end
297
312
  end
298
313
 
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module XPath
5
+ # Document ordering for node-set results (TODO.sota/02, M3).
6
+ #
7
+ # XPath defines node-sets as unordered, but path-expression
8
+ # results are conventionally in document order (and consumers
9
+ # rely on it). Multi-step paths are evaluated by batching step N
10
+ # over every step-(N-1) node, which concatenates subtree results
11
+ # after sibling results — results must therefore be re-ordered.
12
+ #
13
+ # Ordering is a ONE-PASS position index per document, keyed by
14
+ # native identity: `traverse` yields nodes in document order, and
15
+ # the engines that re-order here (oga; the pure-Ruby XPath engine
16
+ # lanes) expose stable native objects per node. The ancestry-walk
17
+ # key (`key_for`) is quadratic on wide documents — every node
18
+ # compares its way through its siblings' structural equality —
19
+ # and is kept only as a fallback for natives the index does not
20
+ # know (attribute results, unstable identities).
21
+ module Ordering
22
+ module_function
23
+
24
+ INDEXED_ELEMENT_RANK = 1
25
+ ATTRIBUTE_RANK = 0
26
+ FALLBACK_RANK = 2
27
+
28
+ # Below this result size the ancestry walk is cheaper than one
29
+ # document traverse (the index only pays off on wide results).
30
+ INDEX_THRESHOLD = 16
31
+
32
+ # Sorts wrappers into document order. Attributes sort before
33
+ # the children of their element (XPath attribute-then-child
34
+ # ordering). Nodes the index cannot place keep their relative
35
+ # position at the end (stable sort).
36
+ def document_order(nodes)
37
+ return nodes if nodes.size < 2
38
+
39
+ indexed = if nodes.size < INDEX_THRESHOLD
40
+ nodes.each_with_index.map do |node, i|
41
+ [fallback_key(node), i, node]
42
+ end
43
+ else
44
+ positions = position_index(nodes.first)
45
+ nodes.each_with_index.map do |node, i|
46
+ [positions.fetch(node.native) { fallback_key(node) },
47
+ i, node]
48
+ end
49
+ end
50
+ indexed.sort_by { |key, i, _| [key, i] }.map(&:last)
51
+ end
52
+
53
+ # native => [rank, position] for every node in document order.
54
+ # compare_by_identity: the natives are the engine's own objects,
55
+ # and native equality may be structural (Oga compares subtrees).
56
+ def position_index(node)
57
+ doc = node
58
+ doc = doc.parent while doc && !doc.is_a?(Moxml::Document)
59
+ return {}.compare_by_identity unless doc
60
+
61
+ index = {}.compare_by_identity
62
+ position = 0
63
+ doc.traverse do |n|
64
+ index[n.native] = [INDEXED_ELEMENT_RANK, position]
65
+ position += 1
66
+ end
67
+ index
68
+ end
69
+
70
+ # Documents with unstable native wrappers (a fresh object per
71
+ # access) fall back to the ancestry path: correct, and only the
72
+ # answer for lanes whose engine already orders natively.
73
+ def fallback_key(node)
74
+ kind_rank = node.is_a?(Moxml::Attribute) ? ATTRIBUTE_RANK : FALLBACK_RANK
75
+ [kind_rank, ancestry_path(node)]
76
+ end
77
+
78
+ def ancestry_path(node)
79
+ path = []
80
+ current = node
81
+ while current && !current.is_a?(Moxml::Document)
82
+ parent = current.parent
83
+ break unless parent
84
+
85
+ siblings = parent.children.to_a
86
+ idx = siblings.index { |s| s.equal?(current) } ||
87
+ siblings.index(current)
88
+ return [2_000_000] if idx.nil?
89
+
90
+ path.unshift(idx)
91
+ current = parent
92
+ end
93
+ path
94
+ end
95
+ end
96
+ end
97
+ end
data/lib/moxml/xpath.rb CHANGED
@@ -9,6 +9,7 @@ module Moxml
9
9
  autoload :Lexer, "moxml/xpath/lexer"
10
10
  autoload :Parser, "moxml/xpath/parser"
11
11
  autoload :Compiler, "moxml/xpath/compiler"
12
+ autoload :Ordering, "moxml/xpath/ordering"
12
13
 
13
14
  autoload :Error, "moxml/xpath/errors"
14
15
  autoload :SyntaxError, "moxml/xpath/errors"
@@ -25,5 +26,33 @@ module Moxml
25
26
  autoload :Node, "moxml/xpath/ruby/node"
26
27
  autoload :Generator, "moxml/xpath/ruby/generator"
27
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
28
57
  end
29
58
  end
data/lib/moxml.rb CHANGED
@@ -102,8 +102,10 @@ module Moxml
102
102
  autoload :SAX, "moxml/sax"
103
103
  autoload :Signature, "moxml/signature"
104
104
  autoload :C14n, "moxml/c14n"
105
+ autoload :Reference, "moxml/reference"
105
106
  autoload :XSLT, "moxml/xslt"
106
107
  autoload :XSD, "moxml/xsd"
108
+ autoload :RelaxNG, "moxml/relaxng"
107
109
 
108
110
  # Error hierarchy — each subclass autoloads from the same file
109
111
  autoload :Error, "moxml/error"
@@ -1,8 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Document order and traversal (normative): pinned here.
4
+
3
5
  RSpec.describe "Adapter Examples" do
4
6
  let(:xml) { "<root><child>text</child></root>" }
5
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
+
6
30
  describe "Serialization consistency" do
7
31
  it "produces equivalent XML across adapters",
8
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
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/input_taxonomy"
5
+
6
+ # Whole-class differential (TODO.sota/01): for every taxonomy input,
7
+ # every available adapter answers the same observable results — or the
8
+ # difference cites a registered divergence (D-NN). Faces: node count,
9
+ # root name, text, element count via XPath. Plus M1 (parse∘serialize
10
+ # round-trip) per adapter over every well-formed input. Nothing is
11
+ # exempt without a D-ID: this is the generated-evidence face of the
12
+ # conformance relation.
13
+
14
+ RSpec.describe "cross-adapter taxonomy differential" do
15
+ def snapshot(doc)
16
+ {
17
+ nodes: doc.traverse.to_a.grep_v(Moxml::Document).count,
18
+ root: doc.root&.name,
19
+ text: doc.text,
20
+ xpath: doc.xpath("//*").to_a.count,
21
+ }
22
+ end
23
+
24
+ def answers(xml)
25
+ Moxml::Adapter::AVAILABLE_ADAPTERS.filter_map do |name|
26
+ doc = Moxml.new(name).parse(xml)
27
+ [name, snapshot(doc)]
28
+ rescue Moxml::Error, NotImplementedError, LoadError
29
+ nil # adapter unavailable in this environment
30
+ end.to_h
31
+ end
32
+
33
+ InputTaxonomy.well_formed.each do |input|
34
+ it "answers #{input.id} identically across adapters" do
35
+ per = answers(input.xml)
36
+ expect(per.keys.count).to be >= 2
37
+
38
+ base_name = per.keys.first
39
+ base = per[base_name]
40
+ per.each do |name, got|
41
+ base.except(:text).each_key do |face|
42
+ exempt = InputTaxonomy::EXEMPT.dig(input.id, face, name)
43
+ next if exempt
44
+
45
+ expect(got[face]).to eq(base[face]),
46
+ "#{input.id}/#{face} differs on #{name} vs " \
47
+ "#{base_name}: #{got[face].inspect} vs " \
48
+ "#{base[face].inspect} — fix it or register " \
49
+ "a divergence and exempt the cell"
50
+ end
51
+ text_exempt = InputTaxonomy::EXEMPT.dig(input.id, :text, name)
52
+ next if text_exempt
53
+
54
+ expect(got[:text]).to eq(base[:text]),
55
+ "#{input.id}/text differs on #{name} vs " \
56
+ "#{base_name}: #{got[:text].inspect} vs " \
57
+ "#{base[:text].inspect} — fix it or register " \
58
+ "a divergence and exempt the cell"
59
+ end
60
+ end
61
+ end
62
+
63
+ it "keeps the round trip stable per adapter (M1: parse∘serialize)" do # rubocop:disable RSpec/NoExpectationExample
64
+ InputTaxonomy.well_formed.each do |input|
65
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
66
+ begin
67
+ ctx = Moxml.new(name)
68
+ doc = ctx.parse(input.xml)
69
+ first = snapshot(doc)
70
+ # indent: 0 — default indentation synthesizes whitespace text
71
+ # nodes on re-parse (formatting, not information; D-09
72
+ # adapters ignore the knob and may embed their own pretty
73
+ # newlines regardless). M1 therefore compares the information
74
+ # set: root, element count, whitespace-collapsed text. Node
75
+ # COUNT identity is the cross-adapter face's job (same parse),
76
+ # not the round-trip's.
77
+ second = snapshot(ctx.parse(doc.to_xml(indent: 0)))
78
+ rescue Moxml::Error, NotImplementedError, LoadError
79
+ # Named guards (e.g. D-16's read cap on rexml) legitimately
80
+ # excuse an adapter from M1 for an input.
81
+ next
82
+ end
83
+ compact = ->(snap) {
84
+ snap.except(:nodes).merge(text: snap[:text].gsub(/\s+/, ""))
85
+ }
86
+ expect(compact.call(second)).to eq(compact.call(first)),
87
+ "#{input.id} round-trip drifted on " \
88
+ "#{name}: #{first.inspect} vs " \
89
+ "#{second.inspect}"
90
+ end
91
+ end
92
+ end
93
+
94
+ it "raises the named parse error on strict malformed input, everywhere" do
95
+ # Entity/reference and namespace-validation classes are
96
+ # engine-variant by design — moxml governs them with config
97
+ # (entity modes, namespace validation), not with a registered
98
+ # divergence.
99
+ engine_variant = %w[undefined reference undeclared prefix].freeze
100
+ inputs = InputTaxonomy.malformed.reject do |input|
101
+ engine_variant.any? { |term| input.id.include?(term) }
102
+ end
103
+
104
+ inputs.each do |input|
105
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
106
+ next if InputTaxonomy::STRICT_EXEMPT.dig(input.id, name)
107
+
108
+ # The strict lane, named: the model's default posture for
109
+ # malformed input is a named raise on every adapter.
110
+ expect { Moxml.new(name).parse(input.xml, strict: true) }
111
+ .to raise_error(Moxml::ParseError),
112
+ "#{input.id}/#{name}: strict parse accepted malformed " \
113
+ "input or raised outside the named ParseError contract"
114
+ end
115
+ end
116
+ end
117
+ end