moxml 0.5.148 → 0.5.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/nightly-fuzz.yml +80 -0
- data/docs/_pages/divergences.adoc +58 -0
- data/docs/_references/processing-model.adoc +30 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/leptris.rb +110 -19
- data/lib/moxml/adapter/libxml.rb +145 -23
- data/lib/moxml/adapter/oga.rb +33 -3
- data/lib/moxml/adapter/ox.rb +26 -2
- data/lib/moxml/adapter/rexml.rb +65 -15
- data/lib/moxml/context.rb +5 -1
- data/lib/moxml/document.rb +6 -6
- data/lib/moxml/node.rb +22 -6
- data/lib/moxml/node_set.rb +11 -0
- data/lib/moxml/reference/normalizer.rb +63 -0
- data/lib/moxml/reference/parser.rb +417 -0
- data/lib/moxml/reference/writer.rb +121 -0
- data/lib/moxml/reference.rb +205 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +17 -2
- data/lib/moxml/xpath/ordering.rb +97 -0
- data/lib/moxml/xpath.rb +1 -0
- data/lib/moxml.rb +1 -0
- data/spec/consistency/adapter_parity_spec.rb +2 -0
- data/spec/consistency/differential_taxonomy_spec.rb +117 -0
- data/spec/consistency/divergence_register_spec.rb +9 -1
- data/spec/consistency/eol_normalization_spec.rb +2 -0
- data/spec/consistency/loud_degradation_spec.rb +2 -0
- data/spec/consistency/metamorphic_properties_spec.rb +195 -0
- data/spec/consistency/metamorphic_spec.rb +138 -0
- data/spec/consistency/model_coverage_audit_spec.rb +108 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +11 -0
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/attribute_resolver_spec.rb +2 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/plan_conformance_spec.rb +2 -0
- data/spec/moxml/processing_instruction_spec.rb +9 -0
- data/spec/moxml/reference_opal_spec.rb +29 -0
- data/spec/moxml/reference_spec.rb +162 -0
- data/spec/moxml/sax_spec.rb +2 -0
- data/spec/moxml/text_spec.rb +2 -0
- data/spec/moxml/to_html_spec.rb +2 -0
- data/spec/moxml/xhtml_output_spec.rb +2 -0
- data/spec/moxml/xpath/axes_spec.rb +2 -2
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +212 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +16 -1
|
@@ -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
|
data/lib/moxml/version.rb
CHANGED
data/lib/moxml/xpath/compiler.rb
CHANGED
|
@@ -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"
|
data/lib/moxml.rb
CHANGED
|
@@ -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
|
|
@@ -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
|