moxml 0.5.163 → 0.5.166

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: 66e9a76f3fb1a9a8e41d39d3f49c6ea97536a1d08a7aaf145169eb2ca8b2e72e
4
- data.tar.gz: 5190677a144235ce163990b3cd3f7f6ef2a123ef4e02b1b81626e2467d74bcaf
3
+ metadata.gz: 932f907222ac657e72e53b9e5ac2e514bb6aa07a51da815caa7ee31c0b0a02fa
4
+ data.tar.gz: 11c82e15a42a80adb5bb19b4b819e9b4cfa1e7e508c6088d383dfdd793a33956
5
5
  SHA512:
6
- metadata.gz: 8dae5f5b85c572bf5f31efff0dcd1694854a5fffcf03feb066997136b53a44e2423a8208c08944c2159cd28ecdf83954cfe9856ed8354597d8ae8a664d7c434f
7
- data.tar.gz: 0347c21ed0aecc388117e2c69632f9be715f0f1a073b5ac1dfad06fc8a0760cf07d652fc7cb39b0641ff5358b8a0d075f22de71befa72bdd687e24bf9ea1f2b4
6
+ metadata.gz: 2ef7a9115c29ad09a1b3cb91e3c1c7c8cae297529de98cc772b6939b1e3a893c9820889b85b61f82cfd52c6e9536f688b841551c6f125c09aea0fee61ae12d10
7
+ data.tar.gz: c2822eb479197ddf8151820c1c28e3ce61f49b2a1b3263d1fa4ffc7bc581e387aa52799faef3fba303fb9113bed5ac51cb5b93b5736ed1f03884b398c5a6df15
@@ -42,6 +42,8 @@ Generated by `bundle exec rake docs:coverage` from the coverage matrix and the d
42
42
  | validation | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
43
43
  | validation under-detections | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
44
44
  | validation version reporting | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
+ | validation regex dismissal | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
46
+ | validation substitution dismissal | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
47
  | loud capability contracts | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
46
48
  |===
47
49
 
@@ -255,7 +255,7 @@ semantics.
255
255
 
256
256
  === Validation
257
257
 
258
- Pins:: `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
258
+ Pins:: `spec/moxml/xsd_regex_spec.rb`, `spec/moxml/xsd_spec.rb`, `spec/moxml/xsd_substitution_spec.rb`, `spec/moxml/xsd_underdetection_spec.rb`, `spec/moxml/xsd_version_spec.rb`
259
259
 
260
260
  XSD validation through engines that ship a validator (libxml2;
261
261
  leptris tier-1: recognized roots, one-level content descent, lexical
@@ -267,7 +267,24 @@ scope validate laxly, except that strict `xs:any` /
267
267
  negative wildcards (`notQName` / `notNamespace` — an item excluded
268
268
  by them has no particle and the content model cannot accept it) are
269
269
  re-checked from the schema source on every adapter — the re-check
270
- can only under-report, never false-report. A compiled schema reports the
270
+ can only under-report, never false-report. Engine lexical findings
271
+ that the source's own `pattern` facets refute are dismissed: an
272
+ engine that cannot evaluate a dialect construct (e.g. the
273
+ `\p{Is...}` block escapes) rejects every occurrence of the facet,
274
+ so the facade re-checks the value against the facet translated to
275
+ the host regex dialect — only whitelisted constructs translate
276
+ (block escapes with Unicode-rename aliases, general categories,
277
+ single-character escapes; subtraction, `\i`, `\c` do not), an
278
+ engine finding is dropped only when every same-named instance
279
+ matches the facet, and anything untranslatable keeps the engine's
280
+ answer, so a dismissal can only remove false rejections, never
281
+ create false acceptances. Engine content-model findings that reject
282
+ a substitution-group member are dismissed likewise: the parent's
283
+ model must be a pure sequence, the child sequence must match it
284
+ position-for-position with every particle exactly-once, and a
285
+ registered member must realize a position — any choice/all,
286
+ cardinality violation, or unknown name keeps the engine's answer.
287
+ A compiled schema reports the
271
288
  version its source declares: root `vc:minVersion="1.1"` scopes the
272
289
  document to 1.1, anything else is 1.0 (consumers scope 1.0-only
273
290
  checks with it; applying 1.1 semantics is the engine-side program).
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.163"
4
+ VERSION = "0.5.166"
5
5
  end
@@ -105,7 +105,9 @@ module Moxml
105
105
  when "##defined" then :defined
106
106
  when "##definedSibling" then :sibling
107
107
  when /\A([^:]+):(.+)\z/
108
- uri = prefix_uri(Regexp.last_match(1), element)
108
+ uri = ::Moxml::XSD::SchemaRules.prefix_uri(
109
+ Regexp.last_match(1), element
110
+ )
109
111
  "{#{uri}}#{Regexp.last_match(2)}" unless uri.empty?
110
112
  else
111
113
  "{}#{token}"
@@ -113,23 +115,6 @@ module Moxml
113
115
  end
114
116
  end
115
117
 
116
- # node.namespaces is adapter-divergent (some engines return
117
- # in-scope bindings, others own-node declarations only), so
118
- # the in-scope map is walked out by hand — per-node
119
- # definitions merged along the ancestor axis, deeper
120
- # overriding shallower, identically on every adapter.
121
- def prefix_uri(prefix, element)
122
- bindings = {}
123
- node = element
124
- while node
125
- node.namespaces.each do |namespace|
126
- bindings[namespace.prefix] = namespace.uri
127
- end
128
- node = node.parent&.element? ? node.parent : nil
129
- end
130
- bindings[prefix].to_s
131
- end
132
-
133
118
  def entry_covers?(entry, namespace)
134
119
  namespace == case entry
135
120
  when "##targetNamespace" then :tns
@@ -140,15 +125,120 @@ module Moxml
140
125
  end
141
126
 
142
127
  ContentModel = Struct.new(:declared_children, :any,
143
- :declared_attributes, :any_attribute)
128
+ :declared_attributes, :any_attribute,
129
+ :particles, :pure_sequence)
144
130
  SchemaIndex = Struct.new(:target_namespace, :element_default,
145
131
  :attribute_default, :elements,
146
- :attributes, :contents, :version)
132
+ :attributes, :contents, :version,
133
+ :element_patterns, :attribute_patterns,
134
+ :substitution_members)
135
+
136
+ # Resolves a QName token against an element's in-scope
137
+ # bindings. node.namespaces is adapter-divergent (some
138
+ # engines return in-scope bindings, others own-node
139
+ # declarations only), so the in-scope map is walked out by
140
+ # hand — per-node definitions merged along the ancestor
141
+ # axis, deeper overriding shallower, identically on every
142
+ # adapter. Unresolvable prefixes yield "".
143
+ module_function
144
+
145
+ def prefix_uri(prefix, element)
146
+ bindings = {}
147
+ node = element
148
+ while node
149
+ node.namespaces.each do |namespace|
150
+ bindings[namespace.prefix] = namespace.uri
151
+ end
152
+ node = node.parent&.element? ? node.parent : nil
153
+ end
154
+ bindings[prefix].to_s
155
+ end
147
156
 
148
157
  # The XSD 1.1 version-tagging namespace (Structures §C.3):
149
158
  # vc:minVersion / vc:maxVersion gate 1.1 constructs.
150
159
  VC_NS = "http://www.w3.org/2001/XMLSchema-versioning"
151
160
 
161
+ # XSD pattern-facet dialect → Ruby regex, bounded to the
162
+ # subset the facade vouches for (Datatypes Appendix F). Block
163
+ # escapes \p{Is…}/\P{Is…} map to Ruby's In-prefixed blocks
164
+ # (a few Unicode-renamed blocks carry an alias); general
165
+ # categories and the single-character escapes pass through;
166
+ # \i, \c and class subtraction are NOT translated. Anything
167
+ # unrecognized — or a translation Ruby itself rejects —
168
+ # returns nil: the engine's answer then stands.
169
+ module XsdRegex
170
+ BLOCK_ALIASES = {
171
+ "Greek" => "GreekandCoptic",
172
+ "CombiningMarksforSymbols" =>
173
+ "CombiningDiacriticalMarksforSymbols",
174
+ "Turkish" => "Latin-1Supplement",
175
+ }.freeze
176
+
177
+ PASS_THROUGH_ESCAPES = %w[
178
+ n r t \\ . | ? * + ( ) [ ] { } ^ $ -
179
+ ].freeze
180
+
181
+ module_function
182
+
183
+ def translate(pattern)
184
+ translated = scan(pattern.to_s)
185
+ return nil unless translated
186
+
187
+ Regexp.new("\\A(?:#{translated})\\z")
188
+ rescue RegexpError
189
+ nil
190
+ end
191
+
192
+ def scan(pattern)
193
+ out = +""
194
+ index = 0
195
+ while index < pattern.length
196
+ char = pattern[index]
197
+ if char == "\\"
198
+ consumed = escape(pattern, index, out)
199
+ return nil unless consumed
200
+
201
+ index += consumed
202
+ else
203
+ return nil if char == "-" && pattern[index + 1] == "["
204
+
205
+ out << char
206
+ index += 1
207
+ end
208
+ end
209
+ out
210
+ end
211
+
212
+ # Appends the translation of the escape at index to out and
213
+ # returns the chars consumed, or nil when unrecognized.
214
+ def escape(pattern, index, out)
215
+ kind = pattern[index + 1]
216
+ case kind
217
+ when "p", "P"
218
+ close = pattern.index("}", index)
219
+ return nil unless close
220
+
221
+ body = pattern[index + 3, close - index - 3]
222
+ if body&.start_with?("Is")
223
+ mapped = body.delete_prefix("Is")
224
+ mapped = BLOCK_ALIASES.fetch(mapped, mapped)
225
+ begin
226
+ Regexp.new("\\p{In#{mapped}}")
227
+ rescue RegexpError
228
+ return nil
229
+ end
230
+ out << "\\#{kind}{In#{mapped}}"
231
+ else
232
+ out << pattern[index..close]
233
+ end
234
+ close - index + 1
235
+ when *PASS_THROUGH_ESCAPES
236
+ out << pattern[index, 2]
237
+ 2
238
+ end
239
+ end
240
+ end
241
+
152
242
  class << self
153
243
  # Index one schema document. Returns nil when the source
154
244
  # cannot be indexed — the engine's answer then stands alone
@@ -161,7 +251,7 @@ module Moxml
161
251
  root["targetNamespace"].to_s,
162
252
  root["elementFormDefault"] == "qualified",
163
253
  root["attributeFormDefault"] == "qualified",
164
- {}, {}, {}, schema_version(root)
254
+ {}, {}, {}, schema_version(root), {}, {}, {}
165
255
  )
166
256
  child_elements(root).each do |decl|
167
257
  case decl.name
@@ -169,6 +259,7 @@ module Moxml
169
259
  when "attribute" then index_global_attribute(decl, index)
170
260
  end
171
261
  end
262
+ index_patterns(root, index)
172
263
  index
173
264
  rescue StandardError
174
265
  nil
@@ -188,6 +279,226 @@ module Moxml
188
279
  declared.to_s.start_with?("1.1") ? "1.1" : "1.0"
189
280
  end
190
281
 
282
+ # Index the pattern facets the dismissal may vouch with
283
+ # (#464): named simpleTypes resolve through one type-ref
284
+ # hop, inline simpleTypes directly; keys are local names.
285
+ # A name declared twice with different pattern sets is
286
+ # ambiguous for name-based matching and is removed — an
287
+ # ambiguous vouch could mask a real violation.
288
+ def index_patterns(root, index)
289
+ named = {}
290
+ collect_named_patterns(root, named)
291
+ collect_decl_patterns(root, index, named)
292
+ end
293
+
294
+ def collect_named_patterns(node, named)
295
+ child_elements(node).each do |child|
296
+ if child.name == "simpleType" && child["name"] &&
297
+ (patterns = restriction_patterns(child))
298
+ named[child["name"]] = patterns
299
+ else
300
+ collect_named_patterns(child, named)
301
+ end
302
+ end
303
+ end
304
+
305
+ def collect_decl_patterns(node, index, named)
306
+ child_elements(node).each do |child|
307
+ case child.name
308
+ when "element"
309
+ register_pattern(index.element_patterns, child["name"],
310
+ decl_patterns(child, named))
311
+ when "attribute"
312
+ register_pattern(index.attribute_patterns, child["name"],
313
+ decl_patterns(child, named))
314
+ end
315
+ collect_decl_patterns(child, index, named)
316
+ end
317
+ end
318
+
319
+ def decl_patterns(decl, named)
320
+ inline = child_elements(decl).find do |child|
321
+ child.name == "simpleType"
322
+ end
323
+ if inline
324
+ restriction_patterns(inline)
325
+ elsif decl["type"]
326
+ named[decl["type"].split(":").last]
327
+ end
328
+ end
329
+
330
+ def restriction_patterns(simple_type)
331
+ restriction = child_elements(simple_type).find do |child|
332
+ child.name == "restriction"
333
+ end
334
+ return nil unless restriction
335
+
336
+ patterns = child_elements(restriction)
337
+ .select { |facet| facet.name == "pattern" }
338
+ .map { |facet| XsdRegex.translate(facet["value"].to_s) }
339
+ return nil if patterns.any?(&:nil?)
340
+
341
+ patterns
342
+ end
343
+
344
+ def register_pattern(sink, name, patterns)
345
+ return unless name && patterns && !patterns.empty?
346
+
347
+ sink[name] = sink.key?(name) ? nil : patterns
348
+ end
349
+
350
+ # Dismisses engine findings the schema source refutes:
351
+ #
352
+ # * lexical findings pattern facets accept (#464) — dropped
353
+ # only when every same-named instance matches the facet;
354
+ # * content-model findings that reject a substitution-group
355
+ # member (#466 class 1) — dropped only when the parent's
356
+ # content model is a pure sequence, the child sequence
357
+ # matches it position-for-position with every particle
358
+ # exactly-once, and a substitution member resolves a
359
+ # position. One non-vouching instance — or any real
360
+ # mismatch — keeps the engine's answer, so a dismissal
361
+ # can only remove false rejections, never create false
362
+ # acceptances.
363
+ def dismiss(engine_errors, doc, index)
364
+ return engine_errors unless index
365
+
366
+ vouched = vouch_table(doc, index)
367
+ engine_errors.reject do |error|
368
+ if (subject = lexical_subject(error.message))
369
+ vouched[subject]
370
+ elsif (sequence = content_model_subject(error.message))
371
+ sequence_vouched?(doc, index, *sequence)
372
+ end
373
+ end
374
+ end
375
+
376
+ LEXICAL_MESSAGE = /\A(?:element|attribute) '([^']+)' @\d+:\d+: (element|attribute) '([^']+)': lexical value does not match its type\z/
377
+ CONTENT_MODEL_MESSAGE = /\Aelement '([^']+)' @\d+:\d+: element '([^']+)': children do not match the content model \(at '([^']+)'\)\z/
378
+
379
+ private_constant :LEXICAL_MESSAGE, :CONTENT_MODEL_MESSAGE
380
+
381
+ def lexical_subject(message)
382
+ match = LEXICAL_MESSAGE.match(message.to_s)
383
+ return unless match
384
+
385
+ [match[2], match[3]]
386
+ end
387
+
388
+ def content_model_subject(message)
389
+ match = CONTENT_MODEL_MESSAGE.match(message.to_s)
390
+ return unless match
391
+
392
+ [match[2], match[3]]
393
+ end
394
+
395
+ # Every same-named element in the document must vouch: a
396
+ # sequence the finding names could be valid while another
397
+ # same-named one is not. The root is included — the
398
+ # engine's finding can name it.
399
+ def sequence_vouched?(doc, index, name, offender)
400
+ root = doc.root
401
+ return false unless root
402
+
403
+ witnesses = []
404
+ yield_self = root.name == name
405
+ witnesses << sequence_valid?(root, index, offender) if yield_self
406
+ each_named_element(root, name) do |element|
407
+ witnesses << sequence_valid?(element, index, offender)
408
+ end
409
+ !witnesses.empty? && witnesses.all?
410
+ end
411
+
412
+ def each_named_element(node, name, &block)
413
+ child_elements(node).each do |child|
414
+ yield child if child.name == name
415
+ each_named_element(child, name, &block)
416
+ end
417
+ end
418
+
419
+ # The airtight witness: child count equals particle count,
420
+ # every position resolves to its particle's name or a
421
+ # registered member of it, every particle occurs exactly
422
+ # once, and the reported offender is a substitution member
423
+ # realized at some position. Anything else leaves the
424
+ # engine's answer standing.
425
+ def sequence_valid?(element, index, offender)
426
+ model = index.contents[qname(element)]
427
+ return false unless model&.pure_sequence
428
+
429
+ particles = model.particles
430
+ children = child_elements(element)
431
+ return false if children.empty? || children.size != particles.size
432
+
433
+ resolved = false
434
+ children.each_with_index do |child, position|
435
+ particle_name, min, max = particles[position]
436
+ child_qname = qname(child)
437
+ return false unless exactly_once?(min, max)
438
+
439
+ if child_qname == particle_name
440
+ next
441
+ elsif index.substitution_members.fetch(particle_name, [])
442
+ .include?(child_qname)
443
+ resolved = true
444
+ else
445
+ return false
446
+ end
447
+ end
448
+ resolved && offender_realized?(children, offender, index)
449
+ end
450
+
451
+ def exactly_once?(min, max)
452
+ min <= 1 && (max == :unbounded || max >= 1)
453
+ end
454
+
455
+ def offender_realized?(children, offender, index)
456
+ members = index.substitution_members.values.flatten
457
+ children.any? do |child|
458
+ child.name == offender &&
459
+ members.include?(qname(child))
460
+ end
461
+ end
462
+
463
+ def vouch_table(doc, index)
464
+ root = doc.root
465
+ return {} unless root
466
+
467
+ elements = Hash.new { |hash, key| hash[key] = [] }
468
+ attributes = Hash.new { |hash, key| hash[key] = [] }
469
+ collect_witnesses(root, index, elements, attributes)
470
+ vouched = {}
471
+ elements.each do |name, matches|
472
+ vouched[["element", name]] = !matches.empty? && matches.all?
473
+ end
474
+ attributes.each do |name, matches|
475
+ vouched[["attribute", name]] = !matches.empty? && matches.all?
476
+ end
477
+ vouched
478
+ end
479
+
480
+ def collect_witnesses(node, index, elements, attributes)
481
+ child_elements(node).each do |child|
482
+ if (patterns = index.element_patterns[child.name])
483
+ elements[child.name] << (text_only?(child) &&
484
+ patterns.any? { |regex| regex.match?(child.text.to_s) })
485
+ end
486
+ child.attributes.each do |attribute|
487
+ next if attribute.namespace&.uri == XSI_NS
488
+
489
+ if (patterns = index.attribute_patterns[attribute.name])
490
+ attributes[attribute.name] <<
491
+ patterns.any? { |regex| regex.match?(attribute.value.to_s) }
492
+ end
493
+ end
494
+ collect_witnesses(child, index, elements, attributes)
495
+ end
496
+ end
497
+
498
+ def text_only?(element)
499
+ child_elements(element).empty?
500
+ end
501
+
191
502
  # Post-validation findings for one instance document.
192
503
  def findings(doc, index)
193
504
  return [] unless index
@@ -215,6 +526,9 @@ module Moxml
215
526
  name = decl["name"] or return
216
527
  qname = "{#{index.target_namespace}}#{name}"
217
528
  index.elements[qname] = { nillable: decl["nillable"] == "true" }
529
+ if (head = decl["substitutionGroup"])
530
+ register_substitution(head, decl, qname, index)
531
+ end
218
532
  type_attr = decl.attribute("type")
219
533
  model = if type_attr.nil? || type_attr.value.empty?
220
534
  inline_model(decl, index)
@@ -224,6 +538,23 @@ module Moxml
224
538
  index.contents[qname] = model if model
225
539
  end
226
540
 
541
+ # substitutionGroup is a QName resolved against the global
542
+ # declaration's in-scope bindings (#466 class 1); the
543
+ # member's own qname is target-namespace-qualified — global
544
+ # declarations always are.
545
+ def register_substitution(head, decl, member_qname, index)
546
+ if head.include?(":")
547
+ prefix, _, local = head.partition(":")
548
+ uri = prefix_uri(prefix, decl)
549
+ return if uri.empty?
550
+
551
+ head_qname = "{#{uri}}#{local}"
552
+ else
553
+ head_qname = "{}#{head}"
554
+ end
555
+ (index.substitution_members[head_qname] ||= []) << member_qname
556
+ end
557
+
227
558
  def index_global_attribute(decl, index)
228
559
  name = decl["name"] or return
229
560
  index.attributes["{#{index.target_namespace}}#{name}"] = true
@@ -249,7 +580,7 @@ module Moxml
249
580
  end
250
581
 
251
582
  def content_model(type_node, index)
252
- model = ContentModel.new([], nil, [], nil)
583
+ model = ContentModel.new([], nil, [], nil, [], true)
253
584
  collect_model(type_node, index, model)
254
585
  model
255
586
  end
@@ -258,36 +589,58 @@ module Moxml
258
589
  # choice / all / complexContent wrappers flattened): we only
259
590
  # ADD findings for strict-wildcard matches, so flattening
260
591
  # can only under-report, never over-report. Cardinality and
261
- # order stay the engine's job.
592
+ # order stay the engine's job — EXCEPT for the substitution
593
+ # dismissal (#466 class 1), which vouches only for pure
594
+ # sequences: any choice/all marks the model impure and the
595
+ # flattened order is then not a validity witness.
262
596
  def collect_model(node, index, model)
263
597
  child_elements(node).each do |child|
264
598
  case child.name
265
599
  when "element"
266
- add_particle(child, index, model,
267
- model.declared_children)
600
+ if (qname = particle_qname(child, index))
601
+ model.declared_children << qname
602
+ model.particles << [
603
+ qname,
604
+ occur_value(child, "minOccurs", 1),
605
+ occur_value(child, "maxOccurs", 1),
606
+ ]
607
+ end
268
608
  when "attribute"
269
- add_particle(child, index, model,
270
- model.declared_attributes)
609
+ if (qname = particle_qname(child, index))
610
+ model.declared_attributes << qname
611
+ end
271
612
  when "any"
272
613
  model.any ||= wildcard_from(child)
273
614
  when "anyAttribute"
274
615
  model.any_attribute ||= wildcard_from(child)
616
+ when "choice", "all"
617
+ model.pure_sequence = false
618
+ collect_model(child, index, model)
275
619
  else
276
620
  collect_model(child, index, model)
277
621
  end
278
622
  end
279
623
  end
280
624
 
281
- def add_particle(particle, index, _model, sink)
625
+ def particle_qname(particle, index)
282
626
  ref = particle["ref"]
283
627
  name = ref&.split(":")&.last || particle["name"]
284
628
  return unless name
285
629
 
286
- sink << if ref
287
- "{#{index.target_namespace}}#{name}"
288
- else
289
- "{#{form_namespace(particle, index)}}#{name}"
290
- end
630
+ if ref
631
+ "{#{index.target_namespace}}#{name}"
632
+ else
633
+ "{#{form_namespace(particle, index)}}#{name}"
634
+ end
635
+ end
636
+
637
+ def occur_value(particle, attribute, default)
638
+ value = particle[attribute]
639
+ return :unbounded if value == "unbounded"
640
+
641
+ Integer(value || default)
642
+ rescue ArgumentError
643
+ default
291
644
  end
292
645
 
293
646
  # Particles declare with form="qualified"/"unqualified",
data/lib/moxml/xsd.rb CHANGED
@@ -157,9 +157,11 @@ module Moxml
157
157
  return engine_errors unless @rules_index
158
158
 
159
159
  # The #452 post-validation rules only ever add findings for
160
- # documents the engine accepted; exact duplicates are
161
- # dropped for engines that already detect them.
162
- (engine_errors +
160
+ # documents the engine accepted; the #464 dismissal removes
161
+ # only engine lexical findings the source's own pattern
162
+ # facets refute. Exact duplicates are dropped for engines
163
+ # that already detect them.
164
+ (SchemaRules.dismiss(engine_errors, doc, @rules_index) +
163
165
  SchemaRules.findings(doc, @rules_index)).uniq
164
166
  end
165
167
 
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Processing model §Validation (normative): pinned here.
6
+ # #464: an engine that cannot evaluate an XSD pattern-facet
7
+ # dialect construct (e.g. the \p{Is...} block escapes) rejects
8
+ # every occurrence of the facet — including values the facet
9
+ # accepts — false-positiving valid documents. The Ruby-side
10
+ # SchemaRules dismissal removes such engine findings only when the
11
+ # schema source's own pattern facets vouch for every same-named
12
+ # instance: one non-matching value keeps the engine's answer, so
13
+ # a dismissal can only remove false rejections, never create
14
+ # false acceptances. Constructs outside the translated dialect
15
+ # subset keep the engine's answer.
16
+ RSpec.describe "Moxml::XSD pattern-facet dismissal (#464)" do
17
+ describe "bounded dialect translator" do
18
+ let(:translator) { Moxml::XSD::SchemaRules::XsdRegex }
19
+
20
+ it "translates block escapes and categories" do
21
+ expect(translator.translate('\p{IsBasicLatin}+').source)
22
+ .to eq("\\A(?:\\p{InBasicLatin}+)\\z")
23
+ expect(translator.translate('\P{IsLatin-1Supplement}').source)
24
+ .to eq("\\A(?:\\P{InLatin-1Supplement})\\z")
25
+ expect(translator.translate('\p{IsGreek}').source)
26
+ .to eq("\\A(?:\\p{InGreekandCoptic})\\z")
27
+ expect(translator.translate('\p{L}').source)
28
+ .to eq("\\A(?:\\p{L})\\z")
29
+ end
30
+
31
+ it "refuses constructs outside the translated subset" do
32
+ expect(translator.translate('\i\c*')).to be_nil
33
+ expect(translator.translate('[\p{IsBasicLatin}-[abc]]+'))
34
+ .to be_nil
35
+ expect(translator.translate('\p{IsNoSuchBlock}')).to be_nil
36
+ end
37
+ end
38
+
39
+ %i[nokogiri leptris].each do |adapter_name|
40
+ describe "on #{adapter_name}" do
41
+ let(:ctx) { Moxml.new(adapter_name) }
42
+
43
+ before do
44
+ skip "#{adapter_name} requires an XSD-capable engine" unless
45
+ Moxml::XSD.supported?(ctx)
46
+ end
47
+
48
+ describe "block-escape pattern on an element" do
49
+ let(:schema) do
50
+ ctx.xsd(<<~'XSD')
51
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
52
+ <xs:element name="doc">
53
+ <xs:complexType>
54
+ <xs:sequence>
55
+ <xs:element name="elem" type="regex"
56
+ minOccurs="1" maxOccurs="unbounded"/>
57
+ </xs:sequence>
58
+ </xs:complexType>
59
+ </xs:element>
60
+ <xs:simpleType name="regex">
61
+ <xs:restriction base="xs:string">
62
+ <xs:pattern value="\p{IsBasicLatin}+"/>
63
+ </xs:restriction>
64
+ </xs:simpleType>
65
+ </xs:schema>
66
+ XSD
67
+ end
68
+
69
+ it "accepts the value the facet accepts" do
70
+ doc = ctx.parse(
71
+ "<doc><elem>&#x9;&#xA;&#xD;&#x20;&#x7F;</elem></doc>",
72
+ )
73
+ expect(schema.valid?(doc)).to be(true)
74
+ end
75
+
76
+ it "rejects the value the facet rejects" do
77
+ doc = ctx.parse(
78
+ "<doc><elem>abc</elem><elem>ab&#233;c</elem></doc>",
79
+ )
80
+ expect(schema.valid?(doc)).to be(false)
81
+ end
82
+ end
83
+
84
+ describe "negated block escape" do
85
+ let(:schema) do
86
+ ctx.xsd(<<~'XSD')
87
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
88
+ <xs:element name="doc">
89
+ <xs:complexType>
90
+ <xs:sequence>
91
+ <xs:element name="elem" type="nonbasic"
92
+ minOccurs="1"/>
93
+ </xs:sequence>
94
+ </xs:complexType>
95
+ </xs:element>
96
+ <xs:simpleType name="nonbasic">
97
+ <xs:restriction base="xs:string">
98
+ <xs:pattern value="\P{IsBasicLatin}"/>
99
+ </xs:restriction>
100
+ </xs:simpleType>
101
+ </xs:schema>
102
+ XSD
103
+ end
104
+
105
+ it "accepts a value outside the negated block" do
106
+ doc = ctx.parse("<doc><elem>&#233;</elem></doc>")
107
+ expect(schema.valid?(doc)).to be(true)
108
+ end
109
+ end
110
+
111
+ describe "block-escape pattern on an attribute" do
112
+ let(:schema) do
113
+ ctx.xsd(<<~'XSD')
114
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
115
+ <xs:element name="doc">
116
+ <xs:complexType>
117
+ <xs:sequence>
118
+ <xs:element name="elem" minOccurs="1"
119
+ maxOccurs="unbounded">
120
+ <xs:complexType>
121
+ <xs:simpleContent>
122
+ <xs:extension base="xs:string">
123
+ <xs:attribute name="code">
124
+ <xs:simpleType>
125
+ <xs:restriction base="xs:string">
126
+ <xs:pattern
127
+ value="\p{IsBasicLatin}{2}"/>
128
+ </xs:restriction>
129
+ </xs:simpleType>
130
+ </xs:attribute>
131
+ </xs:extension>
132
+ </xs:simpleContent>
133
+ </xs:complexType>
134
+ </xs:element>
135
+ </xs:sequence>
136
+ </xs:complexType>
137
+ </xs:element>
138
+ </xs:schema>
139
+ XSD
140
+ end
141
+
142
+ it "accepts when every instance matches the facet" do
143
+ doc = ctx.parse(
144
+ '<doc><elem code="ab">x</elem><elem code="z9">y</elem></doc>',
145
+ )
146
+ expect(schema.valid?(doc)).to be(true)
147
+ end
148
+
149
+ it "rejects when one instance mismatches" do
150
+ doc = ctx.parse(
151
+ '<doc><elem code="ab">x</elem><elem code="1">y</elem></doc>',
152
+ )
153
+ expect(schema.valid?(doc)).to be(false)
154
+ end
155
+ end
156
+
157
+ describe "class subtraction rides the engine unchanged" do
158
+ let(:schema) do
159
+ ctx.xsd(<<~'XSD')
160
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
161
+ <xs:element name="doc">
162
+ <xs:complexType>
163
+ <xs:sequence>
164
+ <xs:element name="elem" type="latinish"
165
+ minOccurs="1"/>
166
+ </xs:sequence>
167
+ </xs:complexType>
168
+ </xs:element>
169
+ <xs:simpleType name="latinish">
170
+ <xs:restriction base="xs:string">
171
+ <xs:pattern value="[\p{IsBasicLatin}-[abc]]+"/>
172
+ </xs:restriction>
173
+ </xs:simpleType>
174
+ </xs:schema>
175
+ XSD
176
+ end
177
+
178
+ it "keeps the engine's answer" do
179
+ # The translation refuses subtraction, so the dismissal
180
+ # never fires: both engines answer per their own
181
+ # dialect coverage, unmodified by the facade.
182
+ doc = ctx.parse("<doc><elem>xyz</elem></doc>")
183
+ expect(schema.valid?(doc)).to be(true)
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Processing model §Validation (normative): pinned here.
6
+ # #466 class 1: the engine's content-model matcher matches
7
+ # particles by exact name only, so a valid substitution-group
8
+ # member is rejected with "children do not match the content
9
+ # model". The Ruby-side dismissal drops such a finding only when
10
+ # the parent's content model is a pure sequence, the child
11
+ # sequence matches it position-for-position with every particle
12
+ # exactly-once, and a registered member realizes a position —
13
+ # any choice/all, cardinality violation, or unknown name keeps
14
+ # the engine's answer, so the dismissal can only remove false
15
+ # rejections, never create false acceptances.
16
+ RSpec.describe "Moxml::XSD substitution-group dismissal (#466)" do
17
+ %i[nokogiri leptris].each do |adapter_name|
18
+ describe "on #{adapter_name}" do
19
+ let(:ctx) { Moxml.new(adapter_name) }
20
+
21
+ before do
22
+ skip "#{adapter_name} requires an XSD-capable engine" unless
23
+ Moxml::XSD.supported?(ctx)
24
+ end
25
+
26
+ describe "member of a referenced head particle" do
27
+ let(:schema) do
28
+ ctx.xsd(<<~XSD)
29
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
30
+ xmlns:x="http://xsdtesting"
31
+ targetNamespace="http://xsdtesting"
32
+ elementFormDefault="qualified">
33
+ <xsd:element name="doc">
34
+ <xsd:complexType>
35
+ <xsd:sequence>
36
+ <xsd:element ref="x:elem1"/>
37
+ </xsd:sequence>
38
+ </xsd:complexType>
39
+ </xsd:element>
40
+ <xsd:element name="elem1" abstract="false"/>
41
+ <xsd:element name="elem2" substitutionGroup="x:elem1"/>
42
+ </xsd:schema>
43
+ XSD
44
+ end
45
+
46
+ it "accepts the member where the head is declared" do
47
+ doc = ctx.parse(
48
+ '<x:doc xmlns:x="http://xsdtesting"><x:elem2/></x:doc>',
49
+ )
50
+ expect(schema.valid?(doc)).to be(true)
51
+ end
52
+
53
+ it "accepts the head itself" do
54
+ doc = ctx.parse(
55
+ '<x:doc xmlns:x="http://xsdtesting"><x:elem1/></x:doc>',
56
+ )
57
+ expect(schema.valid?(doc)).to be(true)
58
+ end
59
+
60
+ it "rejects a stray non-member child" do
61
+ doc = ctx.parse(
62
+ '<x:doc xmlns:x="http://xsdtesting"><x:unknown/></x:doc>',
63
+ )
64
+ expect(schema.valid?(doc)).to be(false)
65
+ end
66
+ end
67
+
68
+ describe "member inside a nested sequence" do
69
+ let(:schema) do
70
+ ctx.xsd(<<~XSD)
71
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
72
+ xmlns:x="http://xsdtesting"
73
+ targetNamespace="http://xsdtesting"
74
+ elementFormDefault="qualified">
75
+ <xsd:element name="doc">
76
+ <xsd:complexType>
77
+ <xsd:sequence>
78
+ <xsd:element ref="x:mid"/>
79
+ </xsd:sequence>
80
+ </xsd:complexType>
81
+ </xsd:element>
82
+ <xsd:element name="mid">
83
+ <xsd:complexType>
84
+ <xsd:sequence>
85
+ <xsd:element ref="x:elem1"/>
86
+ </xsd:sequence>
87
+ </xsd:complexType>
88
+ </xsd:element>
89
+ <xsd:element name="elem1" abstract="false"/>
90
+ <xsd:element name="elem2" substitutionGroup="x:elem1"/>
91
+ </xsd:schema>
92
+ XSD
93
+ end
94
+
95
+ it "accepts the member at the inner position" do
96
+ doc = ctx.parse(
97
+ '<x:doc xmlns:x="http://xsdtesting">' \
98
+ "<x:mid><x:elem2/></x:mid></x:doc>",
99
+ )
100
+ expect(schema.valid?(doc)).to be(true)
101
+ end
102
+ end
103
+
104
+ describe "models the dismissal refuses" do
105
+ let(:schema) do
106
+ ctx.xsd(<<~XSD)
107
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
108
+ xmlns:x="http://xsdtesting"
109
+ targetNamespace="http://xsdtesting"
110
+ elementFormDefault="qualified">
111
+ <xsd:element name="doc">
112
+ <xsd:complexType>
113
+ <xsd:choice>
114
+ <xsd:element ref="x:elem1"/>
115
+ <xsd:element name="other"/>
116
+ </xsd:choice>
117
+ </xsd:complexType>
118
+ </xsd:element>
119
+ <xsd:element name="elem1" abstract="false"/>
120
+ <xsd:element name="elem2" substitutionGroup="x:elem1"/>
121
+ </xsd:schema>
122
+ XSD
123
+ end
124
+
125
+ it "keeps the engine's answer for a two-child choice match" do
126
+ # A choice takes one item; two children cannot be valid
127
+ # even though both names resolve — the flattened particle
128
+ # list is not a validity witness for non-sequences.
129
+ doc = ctx.parse(
130
+ '<x:doc xmlns:x="http://xsdtesting">' \
131
+ "<x:elem2/><x:other/></x:doc>",
132
+ )
133
+ expect(schema.valid?(doc)).to be(false)
134
+ end
135
+ end
136
+
137
+ describe "cardinality the dismissal refuses" do
138
+ let(:schema) do
139
+ ctx.xsd(<<~XSD)
140
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
141
+ xmlns:x="http://xsdtesting"
142
+ targetNamespace="http://xsdtesting"
143
+ elementFormDefault="qualified">
144
+ <xsd:element name="doc">
145
+ <xsd:complexType>
146
+ <xsd:sequence>
147
+ <xsd:element ref="x:elem1" minOccurs="2" maxOccurs="2"/>
148
+ </xsd:sequence>
149
+ </xsd:complexType>
150
+ </xsd:element>
151
+ <xsd:element name="elem1" abstract="false"/>
152
+ <xsd:element name="elem2" substitutionGroup="x:elem1"/>
153
+ </xsd:schema>
154
+ XSD
155
+ end
156
+
157
+ it "keeps the engine's answer below minOccurs" do
158
+ doc = ctx.parse(
159
+ '<x:doc xmlns:x="http://xsdtesting"><x:elem2/></x:doc>',
160
+ )
161
+ expect(schema.valid?(doc)).to be(false)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -89,6 +89,14 @@ module ModelCoverage
89
89
  section: "Validation",
90
90
  pin: "spec/moxml/xsd_version_spec.rb",
91
91
  },
92
+ "validation regex dismissal" => {
93
+ section: "Validation",
94
+ pin: "spec/moxml/xsd_regex_spec.rb",
95
+ },
96
+ "validation substitution dismissal" => {
97
+ section: "Validation",
98
+ pin: "spec/moxml/xsd_substitution_spec.rb",
99
+ },
92
100
  "loud capability contracts" => {
93
101
  section: "Errors and loud degradation",
94
102
  pin: "spec/consistency/loud_degradation_spec.rb",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.163
4
+ version: 0.5.166
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API
@@ -631,8 +631,10 @@ files:
631
631
  - spec/moxml/xpath_spec.rb
632
632
  - spec/moxml/xsd_error_contract_spec.rb
633
633
  - spec/moxml/xsd_handle_mismatch_spec.rb
634
+ - spec/moxml/xsd_regex_spec.rb
634
635
  - spec/moxml/xsd_release_spec.rb
635
636
  - spec/moxml/xsd_spec.rb
637
+ - spec/moxml/xsd_substitution_spec.rb
636
638
  - spec/moxml/xsd_underdetection_spec.rb
637
639
  - spec/moxml/xsd_version_spec.rb
638
640
  - spec/moxml/xslt/stylesheet_spec.rb