moxml 0.5.161 → 0.5.163

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: 39e75ce66c66f3b1d5c72e8ba103dd2a4e6a2eb14d3e7fc9b016672e35a8759b
4
- data.tar.gz: d502f9d3c5c739d6d86c68d79d2486d40b1a85747d3ef5b8503d1f1e9fc0d17c
3
+ metadata.gz: 66e9a76f3fb1a9a8e41d39d3f49c6ea97536a1d08a7aaf145169eb2ca8b2e72e
4
+ data.tar.gz: 5190677a144235ce163990b3cd3f7f6ef2a123ef4e02b1b81626e2467d74bcaf
5
5
  SHA512:
6
- metadata.gz: 9ae62f4b5b1cef0b4f84c0a12b640292d762d110004dc0fd2bbadf4af5a374ace336d30e9aef7cd9e8e151590de5b67f07d9cf1e31afc7e82b8de1b2f01ebda2
7
- data.tar.gz: 819a5bade9e79d666087a4a302484e0f88653f3a77db26585c2d370c70cd388e774b685bf5499ee1dbff2cb7307f5476aee3153acbf098db61e6e0c3bb010fcb
6
+ metadata.gz: 8dae5f5b85c572bf5f31efff0dcd1694854a5fffcf03feb066997136b53a44e2423a8208c08944c2159cd28ecdf83954cfe9856ed8354597d8ae8a664d7c434f
7
+ data.tar.gz: 0347c21ed0aecc388117e2c69632f9be715f0f1a073b5ac1dfad06fc8a0760cf07d652fc7cb39b0641ff5358b8a0d075f22de71befa72bdd687e24bf9ea1f2b4
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` | 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 |
95
+ | `parse` | 4.61x | parity | met |
96
+ | `serialize` | 1.65x | parity | met |
97
+ | `xpath //field (count)` | 3.39x | parity | met |
98
+ | `xpath //field (each)` | 0.34x | structural | tracked (below parity) |
99
+ | `attr pairs listing` | 8.48x | parity | met |
100
+ | `build add_element` | 0.74x | structural | tracked (below parity) |
101
+ | `walk+read (warm)` | 0.54x | structural | tracked (below parity) |
102
+ | `walk+read (cold)` | 0.37x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
103
+ | `children recursion (cold)` | 0.45x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
104
+ | `plan records (typed)` | 1.13x | data | informational |
105
105
  <!-- SCOREBOARD:END -->
106
106
 
107
107
  The open rows are the known structural gap: every node the facade
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocol": {
3
- "measured": "2026-10-23T15:54:45Z",
3
+ "measured": "2026-10-24T05:26:53Z",
4
4
  "ruby": "3.3.13",
5
5
  "adapter": "leptris",
6
6
  "adapter_version": "1.9.381.0",
@@ -8,16 +8,16 @@
8
8
  "reps": 9,
9
9
  "warmup": 2,
10
10
  "clock": "CLOCK_PROCESS_CPUTIME_ID",
11
- "load": 1.87
11
+ "load": 1.74
12
12
  },
13
13
  "rows": [
14
14
  {
15
15
  "row": "parse",
16
16
  "kind": "parity",
17
17
  "parity": true,
18
- "baseline_us": 1027.8,
19
- "candidate_us": 286.6,
20
- "ratio": 3.586,
18
+ "baseline_us": 1075.7,
19
+ "candidate_us": 233.6,
20
+ "ratio": 4.606,
21
21
  "baseline_allocs": 7,
22
22
  "candidate_allocs": 18
23
23
  },
@@ -25,9 +25,9 @@
25
25
  "row": "serialize",
26
26
  "kind": "parity",
27
27
  "parity": true,
28
- "baseline_us": 517.0,
29
- "candidate_us": 281.9,
30
- "ratio": 1.834,
28
+ "baseline_us": 457.0,
29
+ "candidate_us": 277.6,
30
+ "ratio": 1.646,
31
31
  "baseline_allocs": 30,
32
32
  "candidate_allocs": 14
33
33
  },
@@ -35,9 +35,9 @@
35
35
  "row": "xpath //field (count)",
36
36
  "kind": "parity",
37
37
  "parity": true,
38
- "baseline_us": 121.9,
39
- "candidate_us": 25.4,
40
- "ratio": 4.791,
38
+ "baseline_us": 64.4,
39
+ "candidate_us": 19.0,
40
+ "ratio": 3.387,
41
41
  "baseline_allocs": 13,
42
42
  "candidate_allocs": 8
43
43
  },
@@ -45,9 +45,9 @@
45
45
  "row": "xpath //field (each)",
46
46
  "kind": "structural",
47
47
  "parity": false,
48
- "baseline_us": 361.9,
49
- "candidate_us": 822.7,
50
- "ratio": 0.44,
48
+ "baseline_us": 256.4,
49
+ "candidate_us": 744.4,
50
+ "ratio": 0.344,
51
51
  "baseline_allocs": 1213,
52
52
  "candidate_allocs": 10
53
53
  },
@@ -55,9 +55,9 @@
55
55
  "row": "attr pairs listing",
56
56
  "kind": "parity",
57
57
  "parity": true,
58
- "baseline_us": 502.0,
59
- "candidate_us": 67.3,
60
- "ratio": 7.458,
58
+ "baseline_us": 503.6,
59
+ "candidate_us": 59.4,
60
+ "ratio": 8.484,
61
61
  "baseline_allocs": 3300,
62
62
  "candidate_allocs": 0
63
63
  },
@@ -65,19 +65,19 @@
65
65
  "row": "build add_element",
66
66
  "kind": "structural",
67
67
  "parity": false,
68
- "baseline_us": 5532.8,
69
- "candidate_us": 9658.2,
70
- "ratio": 0.573,
68
+ "baseline_us": 5607.1,
69
+ "candidate_us": 7620.4,
70
+ "ratio": 0.736,
71
71
  "baseline_allocs": 20358,
72
- "candidate_allocs": 11363
72
+ "candidate_allocs": 10163
73
73
  },
74
74
  {
75
75
  "row": "walk+read (warm)",
76
76
  "kind": "structural",
77
77
  "parity": false,
78
- "baseline_us": 1805.4,
79
- "candidate_us": 2797.9,
80
- "ratio": 0.645,
78
+ "baseline_us": 1631.1,
79
+ "candidate_us": 3005.5,
80
+ "ratio": 0.543,
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": 2938.6,
89
- "candidate_us": 17354.2,
90
- "ratio": 0.169,
88
+ "baseline_us": 3094.4,
89
+ "candidate_us": 8308.1,
90
+ "ratio": 0.372,
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": 3346.5,
99
- "candidate_us": 25949.4,
100
- "ratio": 0.129,
98
+ "baseline_us": 3414.5,
99
+ "candidate_us": 7574.2,
100
+ "ratio": 0.451,
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": 2901.1,
109
- "candidate_us": 2554.9,
110
- "ratio": 1.136,
108
+ "baseline_us": 3056.3,
109
+ "candidate_us": 2700.7,
110
+ "ratio": 1.132,
111
111
  "baseline_allocs": 8112,
112
112
  "candidate_allocs": 13232
113
113
  }
@@ -41,6 +41,7 @@ Generated by `bundle exec rake docs:coverage` from the coverage matrix and the d
41
41
  | materialization | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
42
42
  | validation | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
43
43
  | validation under-detections | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
44
+ | validation version reporting | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
44
45
  | loud capability contracts | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent | equivalent
45
46
  |===
46
47
 
@@ -14,21 +14,21 @@ the marked block below from a live measurement; see
14
14
  // SCOREBOARD:BEGIN
15
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.13, leptris 1.9.381.0, nokogiri 1.19.4, 2026-10-23T15:54:45Z, 1-minute load 1.87, 9 repetitions.
17
+ Measurement: Ruby 3.3.13, leptris 1.9.381.0, nokogiri 1.19.4, 2026-10-24T05:26:53Z, 1-minute load 1.74, 9 repetitions.
18
18
 
19
19
  [cols="2,1,1,1",options="header"]
20
20
  |===
21
21
  | Row | Ratio | Kind | Status
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
22
+ | `parse` | 4.61x | parity | met
23
+ | `serialize` | 1.65x | parity | met
24
+ | `xpath //field (count)` | 3.39x | parity | met
25
+ | `xpath //field (each)` | 0.34x | structural | tracked (below parity)
26
+ | `attr pairs listing` | 8.48x | parity | met
27
+ | `build add_element` | 0.74x | structural | tracked (below parity)
28
+ | `walk+read (warm)` | 0.54x | structural | tracked (below parity)
29
+ | `walk+read (cold)` | 0.37x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
30
+ | `children recursion (cold)` | 0.45x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
31
+ | `plan records (typed)` | 1.13x | data | informational
32
32
  |===
33
33
 
34
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.
@@ -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`
258
+ Pins:: `spec/moxml/xsd_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
@@ -263,9 +263,15 @@ checks). `validate` returns accumulated `ValidationError` entries —
263
263
  message always; line/column/path where the engine reports them, nil
264
264
  where it does not; `[]` means valid. Documents outside the engine's
265
265
  scope validate laxly, except that strict `xs:any` /
266
- `xs:anyAttribute` wildcard matches and `xsi:nil` are re-checked from
267
- the schema source on every adapter — the re-check can only
268
- under-report, never false-report. Unsupported adapters raise
266
+ `xs:anyAttribute` wildcard matches, `xsi:nil`, and the XSD 1.1
267
+ negative wildcards (`notQName` / `notNamespace` — an item excluded
268
+ by them has no particle and the content model cannot accept it) are
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
271
+ version its source declares: root `vc:minVersion="1.1"` scopes the
272
+ document to 1.1, anything else is 1.0 (consumers scope 1.0-only
273
+ checks with it; applying 1.1 semantics is the engine-side program).
274
+ Unsupported adapters raise
269
275
  `Moxml::NotImplementedError` at compile; `Moxml::XSD.supported?`
270
276
  answers without raising. RELAX NG rides engines with an RNG face
271
277
  (the same shapes).
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.161"
4
+ VERSION = "0.5.163"
5
5
  end
@@ -8,7 +8,11 @@ module Moxml
8
8
  #
9
9
  # * a strict `xs:any` wildcard with an undeclared element,
10
10
  # * a strict `xs:anyAttribute` with an undeclared attribute,
11
- # * `xsi:nil` on an element whose declaration is not nillable.
11
+ # * `xsi:nil` on an element whose declaration is not nillable,
12
+ # * the XSD 1.1 negative wildcards — `notQName` /
13
+ # `notNamespace` on `xs:any` / `xs:anyAttribute` (#460):
14
+ # engines that compile them without enforcing them absorb
15
+ # excluded items the content model cannot accept.
12
16
  #
13
17
  # The rules run from an index of the schema SOURCE (this gem's
14
18
  # own XML machinery — no engine involvement) and merge with the
@@ -20,22 +24,18 @@ module Moxml
20
24
  module SchemaRules
21
25
  XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
22
26
 
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
+ # A wildcard's namespace constraint and disallowed names, per
28
+ # XML Schema Structures §3.10.1–3.10.2 (##any default;
29
+ # processContents default strict; notNamespace / notQName are
30
+ # XSD 1.1). :tns / :local are resolved against the indexed
31
+ # schema at check time; notQName QNames are resolved against
32
+ # the schema element's in-scope bindings at index time.
27
33
  class Wildcard
28
- def initialize(value, process)
34
+ def initialize(value, process, not_namespace: nil, not_qname: nil,
35
+ element: nil)
29
36
  @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
37
+ @mode, @namespaces = namespace_constraint(value, not_namespace)
38
+ @disallowed = disallowed_names(not_qname, element).freeze
39
39
  end
40
40
 
41
41
  def strict?
@@ -47,13 +47,89 @@ module Moxml
47
47
  when :any then true
48
48
  when :other
49
49
  namespace != :local && namespace != :tns
50
+ when :not
51
+ @namespaces.none? { |entry| entry_covers?(entry, namespace) }
50
52
  else
51
53
  @namespaces.any? { |entry| entry_covers?(entry, namespace) }
52
54
  end
53
55
  end
54
56
 
57
+ # Why the wildcard refuses an item outright (Structures
58
+ # §3.10.4.1 Item Valid): a refusal is a match-level failure,
59
+ # so it applies at every processContents — the content model
60
+ # has no particle for the item and cannot accept it. 1.0
61
+ # namespace lists stay the engine's enforcement and never
62
+ # name an exclusion here.
63
+ def negative_exclusion(qname, namespace, defined:,
64
+ sibling_names: [])
65
+ @disallowed.each do |entry|
66
+ case entry
67
+ when :defined
68
+ return :not_qname if defined
69
+ when :sibling
70
+ return :not_qname if sibling_names.include?(qname)
71
+ else
72
+ return :not_qname if entry == qname
73
+ end
74
+ end
75
+ return :not_namespace if @mode == :not &&
76
+ @namespaces.any? { |entry| entry_covers?(entry, namespace) }
77
+
78
+ nil
79
+ end
80
+
55
81
  private
56
82
 
83
+ def namespace_constraint(value, not_namespace)
84
+ if not_namespace
85
+ [:not, not_namespace.split(/\s+/).reject(&:empty?)]
86
+ elsif value.nil? || value == "##any" || value == ""
87
+ [:any, []]
88
+ elsif value == "##other"
89
+ [:other, []]
90
+ else
91
+ [:list, value.split(/\s+/).reject(&:empty?)]
92
+ end
93
+ end
94
+
95
+ # notQName entries (§3.10.2): QName | ##defined |
96
+ # ##definedSibling (xs:any; ##defined only on
97
+ # xs:anyAttribute). Prefixed QNames resolve against the
98
+ # wildcard element's in-scope bindings; unprefixed QNames
99
+ # carry no namespace — XSD QName values take no default
100
+ # namespace. Unresolvable prefixes are dropped: the index
101
+ # under-reports, never false-reports.
102
+ def disallowed_names(not_qname, element)
103
+ not_qname.to_s.split(/\s+/).reject(&:empty?).filter_map do |token|
104
+ case token
105
+ when "##defined" then :defined
106
+ when "##definedSibling" then :sibling
107
+ when /\A([^:]+):(.+)\z/
108
+ uri = prefix_uri(Regexp.last_match(1), element)
109
+ "{#{uri}}#{Regexp.last_match(2)}" unless uri.empty?
110
+ else
111
+ "{}#{token}"
112
+ end
113
+ end
114
+ end
115
+
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
+
57
133
  def entry_covers?(entry, namespace)
58
134
  namespace == case entry
59
135
  when "##targetNamespace" then :tns
@@ -67,7 +143,11 @@ module Moxml
67
143
  :declared_attributes, :any_attribute)
68
144
  SchemaIndex = Struct.new(:target_namespace, :element_default,
69
145
  :attribute_default, :elements,
70
- :attributes, :contents)
146
+ :attributes, :contents, :version)
147
+
148
+ # The XSD 1.1 version-tagging namespace (Structures §C.3):
149
+ # vc:minVersion / vc:maxVersion gate 1.1 constructs.
150
+ VC_NS = "http://www.w3.org/2001/XMLSchema-versioning"
71
151
 
72
152
  class << self
73
153
  # Index one schema document. Returns nil when the source
@@ -81,7 +161,7 @@ module Moxml
81
161
  root["targetNamespace"].to_s,
82
162
  root["elementFormDefault"] == "qualified",
83
163
  root["attributeFormDefault"] == "qualified",
84
- {}, {}, {}
164
+ {}, {}, {}, schema_version(root)
85
165
  )
86
166
  child_elements(root).each do |decl|
87
167
  case decl.name
@@ -94,6 +174,20 @@ module Moxml
94
174
  nil
95
175
  end
96
176
 
177
+ # The effective schema version declared at the root
178
+ # (#460 item 2): vc:minVersion="1.1" on <xs:schema> scopes
179
+ # the document to 1.1 semantics; anything else reports
180
+ # 1.0. Conditional (subtree-level) vc: evaluation is the
181
+ # engine's conditional-assignment work — the facade reports
182
+ # the root declaration only.
183
+ def schema_version(root)
184
+ declared = root.attributes.find do |attribute|
185
+ attribute.namespace&.uri == VC_NS &&
186
+ attribute.name == "minVersion"
187
+ end&.value
188
+ declared.to_s.start_with?("1.1") ? "1.1" : "1.0"
189
+ end
190
+
97
191
  # Post-validation findings for one instance document.
98
192
  def findings(doc, index)
99
193
  return [] unless index
@@ -175,12 +269,9 @@ module Moxml
175
269
  add_particle(child, index, model,
176
270
  model.declared_attributes)
177
271
  when "any"
178
- model.any ||= Wildcard.new(child["namespace"],
179
- child["processContents"])
272
+ model.any ||= wildcard_from(child)
180
273
  when "anyAttribute"
181
- model.any_attribute ||=
182
- Wildcard.new(child["namespace"],
183
- child["processContents"])
274
+ model.any_attribute ||= wildcard_from(child)
184
275
  else
185
276
  collect_model(child, index, model)
186
277
  end
@@ -216,19 +307,25 @@ module Moxml
216
307
  end
217
308
  end
218
309
 
310
+ def wildcard_from(node)
311
+ Wildcard.new(
312
+ node["namespace"], node["processContents"],
313
+ not_namespace: node["notNamespace"],
314
+ not_qname: node["notQName"],
315
+ element: node
316
+ )
317
+ end
318
+
219
319
  def child_findings(child, model, index)
220
320
  qname = qname(child)
221
321
  findings = []
222
322
  if model.declared_children.include?(qname)
223
323
  declaration = index.elements[qname]
224
324
  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
- )
325
+ elsif model.any &&
326
+ (finding = wildcard_child_finding(child, model, index,
327
+ qname))
328
+ findings << finding
232
329
  end
233
330
  findings.concat(
234
331
  attribute_findings(child, index.contents[qname], index),
@@ -236,13 +333,34 @@ module Moxml
236
333
  findings
237
334
  end
238
335
 
239
- def undeclared_strict_wildcard_match?(_child, model, index, qname)
336
+ def wildcard_child_finding(child, model, index, qname)
240
337
  wildcard = model.any
241
- return false unless wildcard&.strict?
242
- return false if index.elements.key?(qname)
243
-
244
338
  namespace = resolve_namespace(qname, index)
245
- wildcard.covers?(namespace) && !index.elements.key?(qname)
339
+ exclusion = wildcard.negative_exclusion(
340
+ qname, namespace,
341
+ defined: index.elements.key?(qname),
342
+ sibling_names: model.declared_children
343
+ )
344
+ return excluded_child_finding(child, qname, exclusion) if exclusion
345
+
346
+ return unless wildcard.covers?(namespace) && wildcard.strict?
347
+ return if index.elements.key?(qname)
348
+
349
+ Moxml::XSD::ValidationError.new(
350
+ message: "element <#{local(qname)}> matches a strict " \
351
+ "wildcard but is not declared",
352
+ path: path_of(child),
353
+ )
354
+ end
355
+
356
+ def excluded_child_finding(child, qname, exclusion)
357
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
358
+ Moxml::XSD::ValidationError.new(
359
+ message: "element <#{local(qname)}> is disallowed by a " \
360
+ "wildcard (#{reason}) and is not declared in the " \
361
+ "content model",
362
+ path: path_of(child),
363
+ )
246
364
  end
247
365
 
248
366
  def attribute_findings(element, model, index)
@@ -253,14 +371,19 @@ module Moxml
253
371
  next if attribute.namespace&.uri == XSI_NS
254
372
 
255
373
  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),
374
+ next if model.declared_attributes.include?(qname)
375
+ next unless any_attribute
376
+
377
+ namespace = resolve_namespace(qname, index)
378
+ exclusion = any_attribute.negative_exclusion(
379
+ qname, namespace,
380
+ defined: index.attributes.key?(qname)
263
381
  )
382
+ next excluded_attribute_finding(element, qname, exclusion) if exclusion
383
+
384
+ next unless any_attribute.covers?(namespace)
385
+ next unless any_attribute.strict?
386
+ next if index.attributes.key?(qname)
264
387
 
265
388
  Moxml::XSD::ValidationError.new(
266
389
  message: "attribute #{local(qname)} matches a strict " \
@@ -270,6 +393,16 @@ module Moxml
270
393
  end
271
394
  end
272
395
 
396
+ def excluded_attribute_finding(element, qname, exclusion)
397
+ reason = exclusion == :not_namespace ? "notNamespace" : "notQName"
398
+ Moxml::XSD::ValidationError.new(
399
+ message: "attribute #{local(qname)} is disallowed by a " \
400
+ "wildcard (#{reason}) and is not declared in the " \
401
+ "content model",
402
+ path: path_of(element),
403
+ )
404
+ end
405
+
273
406
  def nil_finding(element, declaration)
274
407
  return if declaration[:nillable]
275
408
 
data/lib/moxml/xsd.rb CHANGED
@@ -175,6 +175,15 @@ module Moxml
175
175
  @context.config.adapter.xsd_declarations(@handle)
176
176
  end
177
177
 
178
+ # The effective schema version the source declares (#460 item
179
+ # 2): "1.1" when the root carries vc:minVersion="1.1", else
180
+ # "1.0". Consumers (lutaml-xsd) scope 1.0-only checks with
181
+ # it; the engine's application of 1.1 semantics is the
182
+ # engine-side program.
183
+ def version
184
+ @rules_index ? @rules_index.version : "1.0"
185
+ end
186
+
178
187
  # Deterministic drop (#362.3): true when the adapter freed
179
188
  # the engine-side schema NOW; false when the adapter can only
180
189
  # wait for GC (documented per adapter). After a true release
@@ -4,11 +4,11 @@ require "spec_helper"
4
4
 
5
5
  # Processing model §Validation (normative): pinned here.
6
6
  # #452: tier-1 engines can accept, inside their recognized scope,
7
- # instances that violate strict wildcards or xsi:nil. The
8
- # Ruby-side SchemaRules post-validation closes those gaps from the
9
- # schema source; findings merge with the engine's (duplicates
10
- # dropped), so every adapter answers invalid for the four
11
- # corpus shapes.
7
+ # instances that violate strict wildcards or xsi:nil. #460: engines
8
+ # may compile the 1.1 negative wildcards without enforcing them.
9
+ # The Ruby-side SchemaRules post-validation closes those gaps from
10
+ # the schema source; findings merge with the engine's (duplicates
11
+ # dropped), so every adapter answers invalid for the corpus shapes.
12
12
  RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
13
13
  %i[nokogiri leptris].each do |adapter_name|
14
14
  describe "on #{adapter_name}" do
@@ -152,6 +152,172 @@ RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
152
152
  end
153
153
  end
154
154
 
155
+ describe "negative wildcards (XSD 1.1, #460)" do
156
+ before do
157
+ if adapter_name == :nokogiri
158
+ skip "#{adapter_name} requires an XSD 1.1 wildcard-capable " \
159
+ "engine"
160
+ end
161
+ end
162
+
163
+ describe "xs:any notQName naming an excluded element" do
164
+ let(:schema) do
165
+ ctx.xsd(<<~XSD)
166
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
167
+ xmlns:w="http://example.com/w"
168
+ targetNamespace="http://example.com/w"
169
+ elementFormDefault="qualified">
170
+ <xs:element name="container">
171
+ <xs:complexType>
172
+ <xs:sequence>
173
+ <xs:any processContents="strict"
174
+ notQName="w:banned" minOccurs="0"
175
+ maxOccurs="unbounded"/>
176
+ </xs:sequence>
177
+ </xs:complexType>
178
+ </xs:element>
179
+ <xs:element name="banned" type="xs:string"/>
180
+ <xs:element name="known" type="xs:string"/>
181
+ </xs:schema>
182
+ XSD
183
+ end
184
+
185
+ it "rejects the excluded element although it is declared" do
186
+ doc = ctx.parse(
187
+ '<container xmlns="http://example.com/w"><banned>x</banned></container>',
188
+ )
189
+ expect(schema.valid?(doc)).to be(false)
190
+ expect(schema.validate(doc).map(&:message)).to include(
191
+ /disallowed by a wildcard \(notQName\)/,
192
+ )
193
+ end
194
+
195
+ it "accepts a wildcard-matched declared element" do
196
+ doc = ctx.parse(
197
+ '<container xmlns="http://example.com/w"><known>x</known></container>',
198
+ )
199
+ expect(schema.valid?(doc)).to be(true)
200
+ end
201
+ end
202
+
203
+ describe "xs:any notQName=##defined" do
204
+ let(:schema) do
205
+ ctx.xsd(<<~XSD)
206
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
207
+ targetNamespace="http://example.com/w"
208
+ elementFormDefault="qualified">
209
+ <xs:element name="container">
210
+ <xs:complexType>
211
+ <xs:sequence>
212
+ <xs:any processContents="lax"
213
+ notQName="##defined" minOccurs="0"
214
+ maxOccurs="unbounded"/>
215
+ </xs:sequence>
216
+ </xs:complexType>
217
+ </xs:element>
218
+ <xs:element name="known" type="xs:string"/>
219
+ </xs:schema>
220
+ XSD
221
+ end
222
+
223
+ it "rejects a declared element the ##defined wildcard excludes" do
224
+ doc = ctx.parse(
225
+ '<container xmlns="http://example.com/w"><known>x</known></container>',
226
+ )
227
+ expect(schema.valid?(doc)).to be(false)
228
+ expect(schema.validate(doc).map(&:message)).to include(
229
+ /disallowed by a wildcard \(notQName\)/,
230
+ )
231
+ end
232
+
233
+ it "absorbs an undeclared element laxly" do
234
+ doc = ctx.parse(
235
+ '<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
236
+ )
237
+ expect(schema.valid?(doc)).to be(true)
238
+ end
239
+ end
240
+
241
+ describe "xs:any notNamespace" do
242
+ let(:schema) do
243
+ ctx.xsd(<<~XSD)
244
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
245
+ targetNamespace="http://example.com/w"
246
+ elementFormDefault="qualified">
247
+ <xs:element name="container">
248
+ <xs:complexType>
249
+ <xs:sequence>
250
+ <xs:any processContents="lax"
251
+ notNamespace="##targetNamespace"
252
+ minOccurs="0" maxOccurs="unbounded"/>
253
+ </xs:sequence>
254
+ </xs:complexType>
255
+ </xs:element>
256
+ </xs:schema>
257
+ XSD
258
+ end
259
+
260
+ it "rejects an element in the disallowed namespace" do
261
+ doc = ctx.parse(
262
+ '<container xmlns="http://example.com/w"><stray/></container>',
263
+ )
264
+ expect(schema.valid?(doc)).to be(false)
265
+ expect(schema.validate(doc).map(&:message)).to include(
266
+ /disallowed by a wildcard \(notNamespace\)/,
267
+ )
268
+ end
269
+
270
+ it "absorbs an element from an allowed namespace" do
271
+ doc = ctx.parse(
272
+ '<container xmlns="http://example.com/w">' \
273
+ '<stray xmlns="http://example.com/other"/></container>',
274
+ )
275
+ expect(schema.valid?(doc)).to be(true)
276
+ end
277
+ end
278
+
279
+ describe "xs:anyAttribute notQName=##defined" do
280
+ let(:schema) do
281
+ ctx.xsd(<<~XSD)
282
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
283
+ targetNamespace="http://example.com/w"
284
+ elementFormDefault="qualified"
285
+ attributeFormDefault="qualified">
286
+ <xs:element name="settings">
287
+ <xs:complexType>
288
+ <xs:complexContent>
289
+ <xs:restriction base="xs:anyType">
290
+ <xs:anyAttribute processContents="lax"
291
+ notQName="##defined"/>
292
+ </xs:restriction>
293
+ </xs:complexContent>
294
+ </xs:complexType>
295
+ </xs:element>
296
+ <xs:attribute name="marked" type="xs:string"/>
297
+ </xs:schema>
298
+ XSD
299
+ end
300
+
301
+ it "rejects a declared attribute the ##defined wildcard excludes" do
302
+ doc = ctx.parse(
303
+ '<settings xmlns="http://example.com/w" ' \
304
+ 'xmlns:w="http://example.com/w" w:marked="x"/>',
305
+ )
306
+ expect(schema.valid?(doc)).to be(false)
307
+ expect(schema.validate(doc).map(&:message)).to include(
308
+ /attribute marked is disallowed by a wildcard \(notQName\)/,
309
+ )
310
+ end
311
+
312
+ it "absorbs an undeclared attribute laxly" do
313
+ doc = ctx.parse(
314
+ '<settings xmlns="http://example.com/w" free="x"/>',
315
+ )
316
+ expect(schema.valid?(doc)).to be(true)
317
+ end
318
+ end
319
+ end
320
+
155
321
  describe "wrong-namespace root" do
156
322
  let(:schema) do
157
323
  ctx.xsd(<<~XSD)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Processing model §Validation (normative): pinned here.
6
+
7
+ # #460 item 2: a compiled schema reports the effective version its
8
+ # source declares — vc:minVersion="1.1" on the root scopes the
9
+ # document to 1.1 semantics; anything else reports 1.0. The
10
+ # detection is namespace-based (vc: is a convention, not a
11
+ # requirement) and reads the ROOT declaration; conditional
12
+ # (subtree-level) vc: evaluation is the engine's program.
13
+ RSpec.describe "Moxml::XSD::Schema#version (#460 item 2)" do
14
+ %i[nokogiri leptris].each do |adapter_name|
15
+ describe "on #{adapter_name}" do
16
+ let(:ctx) { Moxml.new(adapter_name) }
17
+
18
+ before do
19
+ skip "#{adapter_name} requires an XSD-capable engine" unless
20
+ Moxml::XSD.supported?(ctx)
21
+ end
22
+
23
+ it "reports 1.1 when the root declares vc:minVersion=1.1" do
24
+ schema = ctx.xsd(<<~XSD)
25
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
26
+ xmlns:vc="http://www.w3.org/2001/XMLSchema-versioning"
27
+ vc:minVersion="1.1">
28
+ <xs:element name="note" type="xs:string"/>
29
+ </xs:schema>
30
+ XSD
31
+ expect(schema.version).to eq("1.1")
32
+ end
33
+
34
+ it "detects the versioning namespace, not the prefix" do
35
+ schema = ctx.xsd(<<~XSD)
36
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
37
+ xmlns:vcx="http://www.w3.org/2001/XMLSchema-versioning"
38
+ vcx:minVersion="1.1">
39
+ <xs:element name="note" type="xs:string"/>
40
+ </xs:schema>
41
+ XSD
42
+ expect(schema.version).to eq("1.1")
43
+ end
44
+
45
+ it "reports 1.0 without a root version declaration" do
46
+ schema = ctx.xsd(<<~XSD)
47
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
48
+ <xs:element name="note" type="xs:string"/>
49
+ </xs:schema>
50
+ XSD
51
+ expect(schema.version).to eq("1.0")
52
+ end
53
+
54
+ it "reports 1.0 for an explicit root vc:minVersion=1.0" do
55
+ schema = ctx.xsd(<<~XSD)
56
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
57
+ xmlns:vc="http://www.w3.org/2001/XMLSchema-versioning"
58
+ vc:minVersion="1.0">
59
+ <xs:element name="note" type="xs:string"/>
60
+ </xs:schema>
61
+ XSD
62
+ expect(schema.version).to eq("1.0")
63
+ end
64
+
65
+ it "reports 1.0 when only a construct carries vc:minVersion" do
66
+ schema = ctx.xsd(<<~XSD)
67
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
68
+ xmlns:vc="http://www.w3.org/2001/XMLSchema-versioning">
69
+ <xs:element name="note">
70
+ <xs:complexType>
71
+ <xs:sequence vc:minVersion="1.1">
72
+ <xs:element name="a"/>
73
+ </xs:sequence>
74
+ </xs:complexType>
75
+ </xs:element>
76
+ </xs:schema>
77
+ XSD
78
+ expect(schema.version).to eq("1.0")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -85,6 +85,10 @@ module ModelCoverage
85
85
  section: "Validation",
86
86
  pin: "spec/moxml/xsd_underdetection_spec.rb",
87
87
  },
88
+ "validation version reporting" => {
89
+ section: "Validation",
90
+ pin: "spec/moxml/xsd_version_spec.rb",
91
+ },
88
92
  "loud capability contracts" => {
89
93
  section: "Errors and loud degradation",
90
94
  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.161
4
+ version: 0.5.163
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-24 00:00:00.000000000 Z
11
+ date: 2026-10-25 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
@@ -634,6 +634,7 @@ files:
634
634
  - spec/moxml/xsd_release_spec.rb
635
635
  - spec/moxml/xsd_spec.rb
636
636
  - spec/moxml/xsd_underdetection_spec.rb
637
+ - spec/moxml/xsd_version_spec.rb
637
638
  - spec/moxml/xslt/stylesheet_spec.rb
638
639
  - spec/performance/README.md
639
640
  - spec/performance/benchmark_spec.rb