moxml 0.5.157 → 0.5.158

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87300d8760e99b65ddda1dd2163c09eeef602885700b8bf7159ee8cb06f708f5
4
- data.tar.gz: 2bcf6f9deb2fd3bef73234ca9d2e9cac11771efc37e8339ecd2cbc82a2a9cb15
3
+ metadata.gz: a173c6faca10ad59c37096bc873999f16f7e5b05ae7afb9f2eba6041ec8147a2
4
+ data.tar.gz: e430658fbe932063794a0a04d2c05224a367da83ebc2f55c9eef8d7e67e431af
5
5
  SHA512:
6
- metadata.gz: bfa9a5c9582fc99bae9aed8888c653ce3bdca81a3a5a3b32ed5be598eea5f0900793d9ef1f453cf8185a6838912a4f6393ae43038f1db2e6a9d1ca3a99c34dd6
7
- data.tar.gz: e07cd31013b48ec143ef5840097c32cba6f00dff10141ca6f2e6371c98bde6ab98bdf920dc1e8701d5d2bea26d961d09d5c35d3229864beffa26e5066dc98e7c
6
+ metadata.gz: a68cad1dab561fa57921867cf856f9222e21f0413e7e4611dce3fa7f91e23b6ae52dc260921e03c8a0f83185ce1d7a6bd66f26e394151c55d08fe63b1b310686
7
+ data.tar.gz: 63ffc8a538f79c85bbc3179875d489f0ee20b8c36ae2498eac1694f0e1538e0ecf07e796a8364613fbe54d9a656da34965e87e9c1165960bd7815c19939e0669
data/benchmark/README.md CHANGED
@@ -92,16 +92,16 @@ rewrites this block and `docs/_pages/performance.adoc`):
92
92
  <!-- SCOREBOARD:BEGIN -->
93
93
  | Row | Ratio | Kind | Status |
94
94
  | --- | ----- | ---- | ------ |
95
- | `parse` | 4.62x | parity | met |
96
- | `serialize` | 1.72x | parity | met |
97
- | `xpath //field (count)` | 0.70x | structural | tracked (below parity) |
98
- | `xpath //field (each)` | 0.36x | structural | tracked (below parity) |
99
- | `attr pairs listing` | 7.92x | parity | met |
100
- | `build add_element` | 0.60x | structural | tracked (below parity) |
101
- | `walk+read (warm)` | 0.54x | structural | tracked (below parity) |
102
- | `walk+read (cold)` | 0.21x | structural | tracked (below parity) |
103
- | `children recursion (cold)` | 0.18x | structural | tracked (below parity) |
104
- | `plan records (typed)` | 1.13x | data | informational |
95
+ | `parse` | 3.59x | parity | met |
96
+ | `serialize` | 1.83x | parity | met |
97
+ | `xpath //field (count)` | 4.79x | parity | met |
98
+ | `xpath //field (each)` | 0.44x | structural | tracked (below parity) |
99
+ | `attr pairs listing` | 7.46x | parity | met |
100
+ | `build add_element` | 0.57x | structural | tracked (below parity) |
101
+ | `walk+read (warm)` | 0.64x | structural | tracked (below parity) |
102
+ | `walk+read (cold)` | 0.17x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
103
+ | `children recursion (cold)` | 0.13x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
104
+ | `plan records (typed)` | 1.14x | data | informational |
105
105
  <!-- SCOREBOARD:END -->
106
106
 
107
107
  The open rows are the known structural gap: every node the facade
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "protocol": {
3
- "measured": "2026-10-22T09:33:49Z",
4
- "ruby": "3.3.12",
3
+ "measured": "2026-10-23T15:54:45Z",
4
+ "ruby": "3.3.13",
5
5
  "adapter": "leptris",
6
- "adapter_version": "1.9.380.1",
6
+ "adapter_version": "1.9.381.0",
7
7
  "nokogiri": "1.19.4",
8
8
  "reps": 9,
9
9
  "warmup": 2,
10
10
  "clock": "CLOCK_PROCESS_CPUTIME_ID",
11
- "load": 1.81
11
+ "load": 1.87
12
12
  },
13
13
  "rows": [
14
14
  {
15
15
  "row": "parse",
16
16
  "kind": "parity",
17
17
  "parity": true,
18
- "baseline_us": 1051.8,
19
- "candidate_us": 227.7,
20
- "ratio": 4.619,
18
+ "baseline_us": 1027.8,
19
+ "candidate_us": 286.6,
20
+ "ratio": 3.586,
21
21
  "baseline_allocs": 7,
22
22
  "candidate_allocs": 18
23
23
  },
@@ -25,39 +25,39 @@
25
25
  "row": "serialize",
26
26
  "kind": "parity",
27
27
  "parity": true,
28
- "baseline_us": 469.2,
29
- "candidate_us": 272.1,
30
- "ratio": 1.724,
28
+ "baseline_us": 517.0,
29
+ "candidate_us": 281.9,
30
+ "ratio": 1.834,
31
31
  "baseline_allocs": 30,
32
32
  "candidate_allocs": 14
33
33
  },
34
34
  {
35
35
  "row": "xpath //field (count)",
36
- "kind": "structural",
37
- "parity": false,
38
- "baseline_us": 69.1,
39
- "candidate_us": 98.0,
40
- "ratio": 0.705,
36
+ "kind": "parity",
37
+ "parity": true,
38
+ "baseline_us": 121.9,
39
+ "candidate_us": 25.4,
40
+ "ratio": 4.791,
41
41
  "baseline_allocs": 13,
42
- "candidate_allocs": 3
42
+ "candidate_allocs": 8
43
43
  },
44
44
  {
45
45
  "row": "xpath //field (each)",
46
46
  "kind": "structural",
47
47
  "parity": false,
48
- "baseline_us": 263.3,
49
- "candidate_us": 734.3,
50
- "ratio": 0.359,
48
+ "baseline_us": 361.9,
49
+ "candidate_us": 822.7,
50
+ "ratio": 0.44,
51
51
  "baseline_allocs": 1213,
52
- "candidate_allocs": 4
52
+ "candidate_allocs": 10
53
53
  },
54
54
  {
55
55
  "row": "attr pairs listing",
56
56
  "kind": "parity",
57
57
  "parity": true,
58
- "baseline_us": 488.8,
59
- "candidate_us": 61.7,
60
- "ratio": 7.92,
58
+ "baseline_us": 502.0,
59
+ "candidate_us": 67.3,
60
+ "ratio": 7.458,
61
61
  "baseline_allocs": 3300,
62
62
  "candidate_allocs": 0
63
63
  },
@@ -65,9 +65,9 @@
65
65
  "row": "build add_element",
66
66
  "kind": "structural",
67
67
  "parity": false,
68
- "baseline_us": 5572.7,
69
- "candidate_us": 9372.9,
70
- "ratio": 0.595,
68
+ "baseline_us": 5532.8,
69
+ "candidate_us": 9658.2,
70
+ "ratio": 0.573,
71
71
  "baseline_allocs": 20358,
72
72
  "candidate_allocs": 11363
73
73
  },
@@ -75,9 +75,9 @@
75
75
  "row": "walk+read (warm)",
76
76
  "kind": "structural",
77
77
  "parity": false,
78
- "baseline_us": 1548.6,
79
- "candidate_us": 2867.0,
80
- "ratio": 0.54,
78
+ "baseline_us": 1805.4,
79
+ "candidate_us": 2797.9,
80
+ "ratio": 0.645,
81
81
  "baseline_allocs": 4503,
82
82
  "candidate_allocs": 2
83
83
  },
@@ -85,9 +85,9 @@
85
85
  "row": "walk+read (cold)",
86
86
  "kind": "structural",
87
87
  "parity": false,
88
- "baseline_us": 2913.9,
89
- "candidate_us": 13771.8,
90
- "ratio": 0.212,
88
+ "baseline_us": 2938.6,
89
+ "candidate_us": 17354.2,
90
+ "ratio": 0.169,
91
91
  "baseline_allocs": 6011,
92
92
  "candidate_allocs": 10232
93
93
  },
@@ -95,9 +95,9 @@
95
95
  "row": "children recursion (cold)",
96
96
  "kind": "structural",
97
97
  "parity": false,
98
- "baseline_us": 3381.3,
99
- "candidate_us": 18658.1,
100
- "ratio": 0.181,
98
+ "baseline_us": 3346.5,
99
+ "candidate_us": 25949.4,
100
+ "ratio": 0.129,
101
101
  "baseline_allocs": 5711,
102
102
  "candidate_allocs": 8732
103
103
  },
@@ -105,9 +105,9 @@
105
105
  "row": "plan records (typed)",
106
106
  "kind": "data",
107
107
  "parity": false,
108
- "baseline_us": 3063.0,
109
- "candidate_us": 2721.5,
110
- "ratio": 1.126,
108
+ "baseline_us": 2901.1,
109
+ "candidate_us": 2554.9,
110
+ "ratio": 1.136,
111
111
  "baseline_allocs": 8112,
112
112
  "candidate_allocs": 13232
113
113
  }
@@ -12,28 +12,26 @@ the marked block below from a live measurement; see
12
12
  `benchmark/README.md` for the protocol and row definitions.
13
13
 
14
14
  // SCOREBOARD:BEGIN
15
- Generated from the gate-passed CI measurement (pinned ubuntu-24.04, settled runner, `benchmark:lock`). Protocol: CPU-time medians of an interleaved A/B run (raw nokogiri vs moxml on leptris); the ratio is baseline / candidate, so > 1.0 means the facade is faster. Warmups discarded, GC between samples.
15
+ Generated by `SCORE_PUBLISH=1 bundle exec ruby benchmark/scoreboard.rb`. Protocol: CPU-time medians of an interleaved A/B run (raw nokogiri vs moxml on leptris); the ratio is baseline / candidate, so > 1.0 means the facade is faster. Warmups discarded, GC between samples.
16
16
 
17
- Measurement: Ruby 3.3.12, leptris leptris 1.9.380.1, nokogiri 1.19.4, 2026-10-22T09:33:49Z, 1-minute load 1.81, 9 repetitions.
17
+ Measurement: Ruby 3.3.13, leptris 1.9.381.0, nokogiri 1.19.4, 2026-10-23T15:54:45Z, 1-minute load 1.87, 9 repetitions.
18
18
 
19
19
  [cols="2,1,1,1",options="header"]
20
20
  |===
21
21
  | Row | Ratio | Kind | Status
22
- | `parse` | 4.62x | parity | met
23
- | `serialize` | 1.72x | parity | met
24
- | `xpath //field (count)` | 0.70x | structural | tracked (below parity)
25
- | `xpath //field (each)` | 0.36x | structural | tracked (below parity)
26
- | `attr pairs listing` | 7.92x | parity | met
27
- | `build add_element` | 0.60x | structural | tracked (below parity)
28
- | `walk+read (warm)` | 0.54x | structural | tracked (below parity)
29
- | `walk+read (cold)` | 0.21x | structural | tracked (below parity)
30
- | `children recursion (cold)` | 0.18x | structural | tracked (below parity)
31
- | `plan records (typed)` | 1.13x | data | informational
22
+ | `parse` | 3.59x | parity | met
23
+ | `serialize` | 1.83x | parity | met
24
+ | `xpath //field (count)` | 4.79x | parity | met
25
+ | `xpath //field (each)` | 0.44x | structural | tracked (below parity)
26
+ | `attr pairs listing` | 7.46x | parity | met
27
+ | `build add_element` | 0.57x | structural | tracked (below parity)
28
+ | `walk+read (warm)` | 0.64x | structural | tracked (below parity)
29
+ | `walk+read (cold)` | 0.17x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
30
+ | `children recursion (cold)` | 0.13x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
31
+ | `plan records (typed)` | 1.14x | data | informational
32
32
  |===
33
33
 
34
- *Parity rows* must stay at or above the 0.95 measurement floor. *Structural rows* are measured and trend-locked (10% regression tolerance) while their gaps remain open. *Data API* rows are informational.
35
-
36
- NOTE: the 1.9.380 engine line regressed the crossing-bound rows ~2x (and the XPath evaluator itself) against 1.9.37x — tracked as leptris-ruby#527; this baseline records the 380.1 reality the lock now guards.
34
+ *Parity rows* must stay at or above the 0.95 measurement floor. *Structural rows* are measured and trend-locked (10% regression tolerance) while their gaps remain open: per-node wrapper minting costs (#312 endgame) and engine construction. *Data API* rows are informational.
37
35
  // SCOREBOARD:END
38
36
 
39
37
  === Memory management
data/lib/moxml/context.rb CHANGED
@@ -207,15 +207,19 @@ module Moxml
207
207
  # @raise [Moxml::NotImplementedError] on adapters without a validator
208
208
  def xsd(schema_source)
209
209
  handle = config.adapter.xsd_compile(schema_source.to_s)
210
- Moxml::XSD::Schema.new(handle, self)
210
+ Moxml::XSD::Schema.new(handle, self, schema_source.to_s)
211
211
  end
212
212
 
213
213
  # Compile an XSD schema from a file path — <xs:include href>
214
214
  # resolves relative to the file's directory (the engine
215
215
  # resolves it; reading the file into #xsd would lose that).
216
+ # The #452 rule index reads this document only; included
217
+ # schema parts stay engine-validated (the index can only
218
+ # under-report).
216
219
  def xsd_file(path)
217
220
  handle = config.adapter.xsd_compile_file(path.to_s)
218
- Moxml::XSD::Schema.new(handle, self)
221
+ source = File.read(path.to_s)
222
+ Moxml::XSD::Schema.new(handle, self, source)
219
223
  end
220
224
 
221
225
  # Compile a RELAX NG schema from source or from a file path (the
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.157"
4
+ VERSION = "0.5.158"
5
5
  end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module XSD
5
+ # Ruby-side post-validation rules closing the tier-1
6
+ # under-detections tracked in #452: an engine may accept, inside
7
+ # its recognized scope, instances that violate
8
+ #
9
+ # * a strict `xs:any` wildcard with an undeclared element,
10
+ # * a strict `xs:anyAttribute` with an undeclared attribute,
11
+ # * `xsi:nil` on an element whose declaration is not nillable.
12
+ #
13
+ # The rules run from an index of the schema SOURCE (this gem's
14
+ # own XML machinery — no engine involvement) and merge with the
15
+ # engine's findings; exact duplicates are dropped. Findings are
16
+ # one-directional: the index covers one schema document at
17
+ # tier-1's one-level content depth, so a missing piece can only
18
+ # under-report, never false-report — the same lax-scope posture
19
+ # tier-1 already has.
20
+ module SchemaRules
21
+ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
22
+
23
+ # A wildcard's namespace attribute, per XML Schema
24
+ # Structures §3.10.2 (##any default; processContents default
25
+ # strict). :tns / :local are resolved against the indexed
26
+ # schema at check time.
27
+ class Wildcard
28
+ def initialize(value, process)
29
+ @process = (process || "strict").to_sym
30
+ case value
31
+ when nil, "##any", ""
32
+ @mode = :any
33
+ when "##other"
34
+ @mode = :other
35
+ else
36
+ @mode = :list
37
+ @namespaces = value.split(/\s+/).reject(&:empty?).freeze
38
+ end
39
+ end
40
+
41
+ def strict?
42
+ @process == :strict
43
+ end
44
+
45
+ def covers?(namespace)
46
+ case @mode
47
+ when :any then true
48
+ when :other
49
+ namespace != :local && namespace != :tns
50
+ else
51
+ @namespaces.any? { |entry| entry_covers?(entry, namespace) }
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def entry_covers?(entry, namespace)
58
+ namespace == case entry
59
+ when "##targetNamespace" then :tns
60
+ when "##local" then :local
61
+ else entry
62
+ end
63
+ end
64
+ end
65
+
66
+ ContentModel = Struct.new(:declared_children, :any,
67
+ :declared_attributes, :any_attribute)
68
+ SchemaIndex = Struct.new(:target_namespace, :element_default,
69
+ :attribute_default, :elements,
70
+ :attributes, :contents)
71
+
72
+ class << self
73
+ # Index one schema document. Returns nil when the source
74
+ # cannot be indexed — the engine's answer then stands alone
75
+ # (today's behavior).
76
+ def index(schema_source, context)
77
+ root = context.parse(schema_source).root
78
+ return nil unless root&.name == "schema"
79
+
80
+ index = SchemaIndex.new(
81
+ root["targetNamespace"].to_s,
82
+ root["elementFormDefault"] == "qualified",
83
+ root["attributeFormDefault"] == "qualified",
84
+ {}, {}, {}
85
+ )
86
+ child_elements(root).each do |decl|
87
+ case decl.name
88
+ when "element" then index_global_element(decl, index)
89
+ when "attribute" then index_global_attribute(decl, index)
90
+ end
91
+ end
92
+ index
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ # Post-validation findings for one instance document.
98
+ def findings(doc, index)
99
+ return [] unless index
100
+
101
+ root = doc.root
102
+ return [] unless root
103
+
104
+ root_qname = qname(root)
105
+ declaration = index.elements[root_qname]
106
+ model = index.contents[root_qname]
107
+ findings = []
108
+ findings << nil_finding(root, declaration) if declaration
109
+ findings.concat(attribute_findings(root, model, index))
110
+ if model
111
+ child_elements(root).each do |child|
112
+ findings.concat(child_findings(child, model, index))
113
+ end
114
+ end
115
+ findings.compact
116
+ end
117
+
118
+ private
119
+
120
+ def index_global_element(decl, index)
121
+ name = decl["name"] or return
122
+ qname = "{#{index.target_namespace}}#{name}"
123
+ index.elements[qname] = { nillable: decl["nillable"] == "true" }
124
+ type_attr = decl.attribute("type")
125
+ model = if type_attr.nil? || type_attr.value.empty?
126
+ inline_model(decl, index)
127
+ else
128
+ named_model(decl, index)
129
+ end
130
+ index.contents[qname] = model if model
131
+ end
132
+
133
+ def index_global_attribute(decl, index)
134
+ name = decl["name"] or return
135
+ index.attributes["{#{index.target_namespace}}#{name}"] = true
136
+ end
137
+
138
+ def inline_model(decl, index)
139
+ type_node = child_elements(decl).find do |child|
140
+ child.name == "complexType"
141
+ end
142
+ content_model(type_node, index) if type_node
143
+ end
144
+
145
+ # A named complexType reference, indexed only when the
146
+ # definition sits directly under this schema element.
147
+ def named_model(decl, index)
148
+ type_name = decl.attribute("type")&.value.to_s.split(":").last
149
+ return nil unless type_name
150
+
151
+ type_node = child_elements(decl.parent).find do |child|
152
+ child.name == "complexType" && child["name"] == type_name
153
+ end
154
+ content_model(type_node, index) if type_node
155
+ end
156
+
157
+ def content_model(type_node, index)
158
+ model = ContentModel.new([], nil, [], nil)
159
+ collect_model(type_node, index, model)
160
+ model
161
+ end
162
+
163
+ # Particle presence across the type's subtree (sequence /
164
+ # choice / all / complexContent wrappers flattened): we only
165
+ # ADD findings for strict-wildcard matches, so flattening
166
+ # can only under-report, never over-report. Cardinality and
167
+ # order stay the engine's job.
168
+ def collect_model(node, index, model)
169
+ child_elements(node).each do |child|
170
+ case child.name
171
+ when "element"
172
+ add_particle(child, index, model,
173
+ model.declared_children)
174
+ when "attribute"
175
+ add_particle(child, index, model,
176
+ model.declared_attributes)
177
+ when "any"
178
+ model.any ||= Wildcard.new(child["namespace"],
179
+ child["processContents"])
180
+ when "anyAttribute"
181
+ model.any_attribute ||=
182
+ Wildcard.new(child["namespace"],
183
+ child["processContents"])
184
+ else
185
+ collect_model(child, index, model)
186
+ end
187
+ end
188
+ end
189
+
190
+ def add_particle(particle, index, _model, sink)
191
+ ref = particle["ref"]
192
+ name = ref&.split(":")&.last || particle["name"]
193
+ return unless name
194
+
195
+ sink << if ref
196
+ "{#{index.target_namespace}}#{name}"
197
+ else
198
+ "{#{form_namespace(particle, index)}}#{name}"
199
+ end
200
+ end
201
+
202
+ # Particles declare with form="qualified"/"unqualified",
203
+ # else the schema's elementFormDefault (elements) /
204
+ # attributeFormDefault (attributes) — both default
205
+ # unqualified.
206
+ def form_namespace(particle, index)
207
+ default = if particle.name == "element"
208
+ index.element_default
209
+ else
210
+ index.attribute_default
211
+ end
212
+ case particle["form"]
213
+ when "qualified" then index.target_namespace
214
+ when "unqualified" then ""
215
+ else default ? index.target_namespace : ""
216
+ end
217
+ end
218
+
219
+ def child_findings(child, model, index)
220
+ qname = qname(child)
221
+ findings = []
222
+ if model.declared_children.include?(qname)
223
+ declaration = index.elements[qname]
224
+ findings << nil_finding(child, declaration) if declaration
225
+ elsif undeclared_strict_wildcard_match?(child, model, index,
226
+ qname)
227
+ findings << Moxml::XSD::ValidationError.new(
228
+ message: "element <#{local(qname)}> matches a strict " \
229
+ "wildcard but is not declared",
230
+ path: path_of(child),
231
+ )
232
+ end
233
+ findings.concat(
234
+ attribute_findings(child, index.contents[qname], index),
235
+ )
236
+ findings
237
+ end
238
+
239
+ def undeclared_strict_wildcard_match?(_child, model, index, qname)
240
+ wildcard = model.any
241
+ return false unless wildcard&.strict?
242
+ return false if index.elements.key?(qname)
243
+
244
+ namespace = resolve_namespace(qname, index)
245
+ wildcard.covers?(namespace) && !index.elements.key?(qname)
246
+ end
247
+
248
+ def attribute_findings(element, model, index)
249
+ return [] unless model
250
+
251
+ any_attribute = model.any_attribute
252
+ element.attributes.filter_map do |attribute|
253
+ next if attribute.namespace&.uri == XSI_NS
254
+
255
+ qname = attribute_qname(attribute)
256
+ declared = model.declared_attributes.include?(qname) ||
257
+ index.attributes.key?(qname)
258
+ next if declared
259
+ next unless any_attribute&.strict?
260
+ next if index.attributes.key?(qname)
261
+ next unless any_attribute.covers?(
262
+ resolve_namespace(qname, index),
263
+ )
264
+
265
+ Moxml::XSD::ValidationError.new(
266
+ message: "attribute #{local(qname)} matches a strict " \
267
+ "wildcard but is not declared",
268
+ path: path_of(element),
269
+ )
270
+ end
271
+ end
272
+
273
+ def nil_finding(element, declaration)
274
+ return if declaration[:nillable]
275
+
276
+ value = element.attributes.find do |attribute|
277
+ attribute.namespace&.uri == XSI_NS &&
278
+ attribute.name == "nil"
279
+ end&.value
280
+ return unless %w[true 1].include?(value)
281
+
282
+ Moxml::XSD::ValidationError.new(
283
+ message: "element <#{element.name}> is not nillable but " \
284
+ "carries xsi:nil=\"#{value}\"",
285
+ path: path_of(element),
286
+ )
287
+ end
288
+
289
+ # {uri}local → the wildcard vocabulary: :tns when the
290
+ # namespace is the schema's target, :local when empty, the
291
+ # URI otherwise.
292
+ def resolve_namespace(qname, index)
293
+ namespace = namespace_of(qname)
294
+ if namespace.empty?
295
+ :local
296
+ elsif namespace == index.target_namespace
297
+ :tns
298
+ else
299
+ namespace
300
+ end
301
+ end
302
+
303
+ def qname(node)
304
+ "{#{node.namespace&.uri}}#{node.name}"
305
+ end
306
+
307
+ def attribute_qname(attribute)
308
+ "{#{attribute.namespace&.uri}}#{attribute.name}"
309
+ end
310
+
311
+ def namespace_of(qname)
312
+ qname[/^\{(.*)\}/, 1].to_s
313
+ end
314
+
315
+ def local(qname)
316
+ qname[/\}(.*)$/, 1].to_s
317
+ end
318
+
319
+ def child_elements(node)
320
+ node.children.select(&:element?)
321
+ end
322
+
323
+ def path_of(node)
324
+ segments = [node.name]
325
+ current = node
326
+ while (parent = current.parent)&.element?
327
+ segments.unshift(parent.name)
328
+ current = parent
329
+ end
330
+ segments.join("/")
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
data/lib/moxml/xsd.rb CHANGED
@@ -28,6 +28,8 @@ module Moxml
28
28
  module XSD
29
29
  SUPPORTED_ADAPTERS = %i[leptris nokogiri].freeze
30
30
 
31
+ autoload :SchemaRules, "moxml/xsd/schema_rules"
32
+
31
33
  # One validation finding, harmonized across adapters (#362.2):
32
34
  # +message+ always present; +line+ / +column+ when the engine
33
35
  # reports them; +path+ (the element path) when the engine
@@ -89,9 +91,11 @@ module Moxml
89
91
  "schema released (deterministic drop); compile a new one"
90
92
  end
91
93
 
92
- def initialize(handle, context)
94
+ def initialize(handle, context, schema_source = nil)
93
95
  @handle = handle
94
96
  @context = context
97
+ @rules_index =
98
+ schema_source && SchemaRules.index(schema_source, context)
95
99
  assert_adapter_matches_handle!
96
100
  end
97
101
 
@@ -143,7 +147,16 @@ module Moxml
143
147
  parsed
144
148
  end
145
149
  end
146
- @context.config.adapter.xsd_validate(@handle, doc.native)
150
+ engine_errors = @context.config.adapter.xsd_validate(
151
+ @handle, doc.native
152
+ )
153
+ return engine_errors unless @rules_index
154
+
155
+ # The #452 post-validation rules only ever add findings for
156
+ # documents the engine accepted; exact duplicates are
157
+ # dropped for engines that already detect them.
158
+ (engine_errors +
159
+ SchemaRules.findings(doc, @rules_index)).uniq
147
160
  end
148
161
 
149
162
  def valid?(node_or_xml)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #452: tier-1 engines can accept, inside their recognized scope,
6
+ # instances that violate strict wildcards or xsi:nil. The
7
+ # Ruby-side SchemaRules post-validation closes those gaps from the
8
+ # schema source; findings merge with the engine's (duplicates
9
+ # dropped), so every adapter answers invalid for the four
10
+ # corpus shapes.
11
+ RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
12
+ %i[nokogiri leptris].each do |adapter_name|
13
+ describe "on #{adapter_name}" do
14
+ let(:ctx) { Moxml.new(adapter_name) }
15
+
16
+ before do
17
+ skip "#{adapter_name} requires an XSD-capable engine" unless
18
+ Moxml::XSD.supported?(ctx)
19
+ end
20
+
21
+ describe "strict xs:any with an undeclared element" do
22
+ let(:schema) do
23
+ ctx.xsd(<<~XSD)
24
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
25
+ targetNamespace="http://example.com/w"
26
+ elementFormDefault="qualified">
27
+ <xs:element name="container">
28
+ <xs:complexType>
29
+ <xs:sequence>
30
+ <xs:any namespace="##targetNamespace"
31
+ processContents="strict" minOccurs="0"/>
32
+ </xs:sequence>
33
+ </xs:complexType>
34
+ </xs:element>
35
+ <xs:element name="known" type="xs:string"/>
36
+ </xs:schema>
37
+ XSD
38
+ end
39
+
40
+ it "rejects the undeclared wildcard match" do
41
+ doc = ctx.parse(
42
+ '<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
43
+ )
44
+ expect(schema.valid?(doc)).to be(false)
45
+ expect(schema.validate(doc).map(&:message)).to include(
46
+ /matches a strict wildcard but is not declared/,
47
+ )
48
+ end
49
+
50
+ it "accepts the declared wildcard match" do
51
+ doc = ctx.parse(
52
+ '<container xmlns="http://example.com/w"><known>x</known></container>',
53
+ )
54
+ expect(schema.valid?(doc)).to be(true)
55
+ end
56
+
57
+ it "leaves lax wildcards to the engine" do
58
+ lax = ctx.xsd(<<~XSD)
59
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
60
+ targetNamespace="http://example.com/w"
61
+ elementFormDefault="qualified">
62
+ <xs:element name="container">
63
+ <xs:complexType>
64
+ <xs:sequence>
65
+ <xs:any namespace="##targetNamespace"
66
+ processContents="lax" minOccurs="0"/>
67
+ </xs:sequence>
68
+ </xs:complexType>
69
+ </xs:element>
70
+ </xs:schema>
71
+ XSD
72
+ doc = ctx.parse(
73
+ '<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
74
+ )
75
+ expect(lax.valid?(doc)).to be(true)
76
+ end
77
+ end
78
+
79
+ describe "strict xs:anyAttribute with an undeclared attribute" do
80
+ let(:schema) do
81
+ ctx.xsd(<<~XSD)
82
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
83
+ targetNamespace="http://example.com/aa"
84
+ elementFormDefault="qualified">
85
+ <xs:element name="settings">
86
+ <xs:complexType>
87
+ <xs:complexContent>
88
+ <xs:restriction base="xs:anyType">
89
+ <xs:attribute name="lang"/>
90
+ <xs:anyAttribute namespace="##targetNamespace ##local"
91
+ processContents="strict"/>
92
+ </xs:restriction>
93
+ </xs:complexContent>
94
+ </xs:complexType>
95
+ </xs:element>
96
+ </xs:schema>
97
+ XSD
98
+ end
99
+
100
+ it "rejects the undeclared wildcard match" do
101
+ doc = ctx.parse(
102
+ '<settings xmlns="http://example.com/aa" lang="en" bogus="1">v</settings>',
103
+ )
104
+ expect(schema.valid?(doc)).to be(false)
105
+ expect(schema.validate(doc).map(&:message)).to include(
106
+ /attribute bogus matches a strict wildcard but is not declared/,
107
+ )
108
+ end
109
+
110
+ it "accepts the declared attribute" do
111
+ # self-closing: the empty-content-type reading of the
112
+ # restriction is the engine's answer, not this rule's
113
+ doc = ctx.parse(
114
+ '<settings xmlns="http://example.com/aa" lang="en"/>',
115
+ )
116
+ expect(schema.valid?(doc)).to be(true)
117
+ end
118
+ end
119
+
120
+ describe "xsi:nil on a non-nillable element" do
121
+ let(:schema) do
122
+ ctx.xsd(<<~XSD)
123
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
124
+ elementFormDefault="qualified">
125
+ <xs:element name="memo" type="xs:string" nillable="true"/>
126
+ <xs:element name="strict" type="xs:string"/>
127
+ </xs:schema>
128
+ XSD
129
+ end
130
+
131
+ it "rejects xsi:nil on the non-nillable declaration" do
132
+ doc = ctx.parse(
133
+ '<strict xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>',
134
+ )
135
+ expect(schema.valid?(doc)).to be(false)
136
+ expect(schema.validate(doc).map(&:message)).to include(
137
+ /is not nillable but carries xsi:nil/,
138
+ )
139
+ end
140
+
141
+ it "accepts xsi:nil on the nillable declaration" do
142
+ doc = ctx.parse(
143
+ '<memo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>',
144
+ )
145
+ expect(schema.valid?(doc)).to be(true)
146
+ end
147
+
148
+ it "accepts the element without xsi:nil" do
149
+ doc = ctx.parse("<strict>text</strict>")
150
+ expect(schema.valid?(doc)).to be(true)
151
+ end
152
+ end
153
+
154
+ describe "wrong-namespace root" do
155
+ let(:schema) do
156
+ ctx.xsd(<<~XSD)
157
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
158
+ targetNamespace="http://example.com/q"
159
+ elementFormDefault="qualified">
160
+ <xs:element name="note" type="xs:string"/>
161
+ </xs:schema>
162
+ XSD
163
+ end
164
+
165
+ it "rejects the root in the wrong namespace" do
166
+ doc = ctx.parse(
167
+ '<note xmlns="http://example.com/other">hi</note>',
168
+ )
169
+ expect(schema.valid?(doc)).to be(false)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.157
4
+ version: 0.5.158
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -318,6 +318,7 @@ files:
318
318
  - lib/moxml/xpath/ruby/generator.rb
319
319
  - lib/moxml/xpath/ruby/node.rb
320
320
  - lib/moxml/xsd.rb
321
+ - lib/moxml/xsd/schema_rules.rb
321
322
  - lib/moxml/xslt.rb
322
323
  - lib/moxml/xslt/stylesheet.rb
323
324
  - moxml.gemspec
@@ -631,6 +632,7 @@ files:
631
632
  - spec/moxml/xsd_handle_mismatch_spec.rb
632
633
  - spec/moxml/xsd_release_spec.rb
633
634
  - spec/moxml/xsd_spec.rb
635
+ - spec/moxml/xsd_underdetection_spec.rb
634
636
  - spec/moxml/xslt/stylesheet_spec.rb
635
637
  - spec/performance/README.md
636
638
  - spec/performance/benchmark_spec.rb