lutaml-xsd 1.2.1 → 1.2.2

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: c78737c6dd26a5aa2ab915675b5fc5ce6316590d776d9ef6d5947988ef804adf
4
- data.tar.gz: 56dd63144161aa1f7065067fcc74cf434bf9d40e1e1059c7e48e3cd99da00124
3
+ metadata.gz: a22b97347b4ad22fc7698a047a1037f4b16c5a6bad3ed0154701908f0052ea29
4
+ data.tar.gz: 0a3d2bd48d987524cbdc427adefcdc128ebcd089a2ed5e25203c28ff27ba4416
5
5
  SHA512:
6
- metadata.gz: 89fe169405a033ca52e85b7017e65075167766a5bfba5b2c779483b7ff9ca0ea0a568daa799fdee61009e1e385af0c584b91e7e4af1bbcd578da67501e626107
7
- data.tar.gz: b18d6e86f3ee582dbe52ace10581e2ea015681f6742bee4601b5a202e9ebc5e16e1878af8763cee247b2c452a18fe817d4e585cd067b403f5667ef52243c2ae9
6
+ metadata.gz: 304b7555580c9bd6b79464b3b2d9ff73d9c69177790fbcd8bb97b9b0438fdc96b19ce19bdbaba4a9a3c2c6446402e81ee457614cd3e33c3dac35e02b2c948d74
7
+ data.tar.gz: 39593e00b27fb41c0fe4e374b682c4172dabcf56cd8250e2917f76c135e9f6421e1c65eaca4c0c3896a7d03bae1395fa18c8a1b6f8e38bb3e23cbfa4e195ef6a
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lutaml-xsd (1.2.0)
4
+ lutaml-xsd (1.2.1)
5
5
  liquid (>= 4.0, < 6.0)
6
- lutaml-model (~> 0.8.114)
6
+ lutaml-model (~> 0.8.123)
7
7
  lutaml-store (~> 0.3)
8
8
  moxml (~> 0.5.147)
9
9
  paint (~> 2.3)
@@ -264,7 +264,7 @@ CHECKSUMS
264
264
  liquid (5.14.0) sha256=e1020c2b2ee58c7a4d3bfdc7a331537bd27a783353748bff0bf27c6ac0c17065
265
265
  lutaml-model (0.8.123) sha256=475f391371965f5baee2a9d018a7c9da8361c3774d3802749aa8805ee1253fde
266
266
  lutaml-store (0.3.2) sha256=9c455dbab32b4c635c48a27213f6f56fda191731c4574eb6187184ba0588a672
267
- lutaml-xsd (1.2.0)
267
+ lutaml-xsd (1.2.1)
268
268
  memo_wise (1.13.0) sha256=30220c38c4cef410849bc73553c58664dc2c91c6379e4a1df22aea02358b716b
269
269
  moxml (0.5.153) sha256=561c6df843edd4a3b7f4c6785f9cfd6fef61050df7f1113fdcd45d8495704fc2
270
270
  nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
data/TODO.conformance.md CHANGED
@@ -89,7 +89,13 @@ Remaining engine-mode gaps (tracked, moxml/leptris-side):
89
89
  resolution (unknown-type rejection; content walk follows the
90
90
  override); the engine's valid-derived false positive is fixed in
91
91
  leptris 1.9.380.2 (moxml#433)
92
- - [ ] Particle checking: UPA detection — DEFERRED (engine covers the
93
- tier-1 subset; UPA is schema-side checking)
92
+ - [x] Particle checking: UPA detection (2026-10-22) —
93
+ `UniqueParticleAttribution` schema-side checker: first-set
94
+ computation over sequence/choice/all with wildcard/element
95
+ overlap, choice-alternative and sequence-junction conflict rules,
96
+ container occurrence folding. Standalone API
97
+ (`findings` → upa_violation findings); zero findings on the
98
+ conformance corpus schemas (no false positives). Deferred depth:
99
+ xs:group expansion, substitution-group-aware first-sets
94
100
  - [ ] XSD 1.1 constructs behind a version flag — DEFERRED: 1.1 strategy
95
101
  decision needed (see TODO.som.md)
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xsd
5
+ module Validation
6
+ # Schema-side Unique Particle Attribution checking (XSD 1.0 §3.8.6).
7
+ #
8
+ # Reports content models where an instance element could match more
9
+ # than one particle, making a valid document's interpretation
10
+ # ambiguous:
11
+ # - xs:choice: overlapping first-sets between alternatives
12
+ # - xs:sequence: a nullable or repeatable particle followed by a
13
+ # particle matching the same first element
14
+ # - element vs wildcard and wildcard vs wildcard overlaps at the
15
+ # same junctions
16
+ #
17
+ # First-sets are computed recursively over nested
18
+ # sequence/choice/all containers; xs:group references are treated
19
+ # as opaque (not descended) and contribute no names.
20
+ #
21
+ # findings = UniqueParticleAttribution.new(schema).findings
22
+ # findings.each { |f| puts "#{f.code}: #{f.message}" }
23
+ class UniqueParticleAttribution
24
+ Finding = Struct.new(:code, :message, :path, keyword_init: true)
25
+
26
+ FirstSet = Struct.new(:names, :wildcards, :nullable, :repeatable) do
27
+ def merge(other)
28
+ FirstSet.new(
29
+ names | other.names,
30
+ wildcards | other.wildcards,
31
+ nullable || other.nullable,
32
+ )
33
+ end
34
+
35
+ def overlaps?(other)
36
+ names.intersect?(other.names) ||
37
+ wildcards.any? { |w| other.wildcards.any? { |o| wildcard_overlap?(w, o) } } ||
38
+ wildcards.any? { |w| other.names.any? { |name| wildcard_admits?(w, name) } } ||
39
+ other.wildcards.any? { |w| names.any? { |name| wildcard_admits?(w, name) } }
40
+ end
41
+
42
+ private
43
+
44
+ # Conservative wildcard-pair overlap: ##any overlaps everything;
45
+ # distinct constraints are treated as overlapping only when
46
+ # identical (##local, ##other, lists, single namespaces).
47
+ def wildcard_overlap?(left, right)
48
+ left == right || left == "##any" || right == "##any"
49
+ end
50
+
51
+ def wildcard_admits?(wildcard, (namespace, _name))
52
+ case wildcard
53
+ when "##any", "" then true
54
+ when "##local" then namespace.empty?
55
+ when "##other" then !namespace.empty?
56
+ when "##targetNamespace" then false
57
+ else wildcard.split(/\s+/).include?(namespace)
58
+ end
59
+ end
60
+ end
61
+
62
+ EMPTY_FIRST = FirstSet.new([].freeze, [].freeze, true).freeze
63
+
64
+ # @param schema [Lutaml::Xml::Schema::Xsd::Schema] parsed schema
65
+ def initialize(schema)
66
+ @schema = schema
67
+ @seen = {}.compare_by_identity
68
+ end
69
+
70
+ # @return [Array<Finding>] UPA violations, empty when deterministic
71
+ def findings
72
+ compute! unless @computed
73
+ @findings
74
+ end
75
+
76
+ private
77
+
78
+ def compute!
79
+ @findings = []
80
+ Array(@schema.element).each do |element|
81
+ check_type(element.complex_type, "/#{element.name}") if
82
+ element.complex_type
83
+ end
84
+ Array(@schema.complex_type).each do |complex_type|
85
+ check_type(complex_type, complex_type.name.to_s)
86
+ end
87
+ @computed = true
88
+ end
89
+
90
+ def check_type(type, path)
91
+ return unless type.is_a?(Lutaml::Xml::Schema::Xsd::ComplexType)
92
+
93
+ container = type.sequence || type.choice || type.all
94
+ check_container(container, path) if container
95
+ end
96
+
97
+ # Walks a container: checks its own junctions, then descends.
98
+ def check_container(container, path)
99
+ return unless container
100
+ return if @seen[container]
101
+
102
+ @seen[container] = true
103
+
104
+ particles = ordered_particles(container)
105
+ check_junctions(particles, container, path)
106
+ particles.each do |particle|
107
+ case particle
108
+ when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
109
+ check_container(particle, "#{path}/#{particle_name(particle)}")
110
+ end
111
+ end
112
+ end
113
+
114
+ def check_junctions(particles, container, path)
115
+ firsts = particles.map { |p| first_set(p) }
116
+
117
+ if container.is_a?(Lutaml::Xsd::Choice)
118
+ check_choice_overlap(particles, firsts, path)
119
+ else
120
+ check_sequence_junctions(particles, firsts, path)
121
+ end
122
+ end
123
+
124
+ def check_choice_overlap(particles, firsts, path)
125
+ firsts.each_with_index do |first, i|
126
+ firsts[(i + 1)..].each_with_index do |other, j|
127
+ next unless first.overlaps?(other)
128
+
129
+ report(
130
+ path,
131
+ "ambiguous choice: '#{particle_name(particles[i])}' and " \
132
+ "'#{particle_name(particles[i + 1 + j])}' can match the " \
133
+ "same element",
134
+ )
135
+ end
136
+ end
137
+ end
138
+
139
+ def check_sequence_junctions(particles, firsts, path)
140
+ firsts.each_with_index do |first, i|
141
+ next if i.zero?
142
+
143
+ previous = firsts[i - 1]
144
+ next unless previous.nullable || previous.repeatable
145
+ next unless first.overlaps?(previous)
146
+
147
+ report(
148
+ path,
149
+ "ambiguous sequence: '#{particle_name(particles[i - 1])}' " \
150
+ "can be followed by '#{particle_name(particles[i])}' — both " \
151
+ "match the same first element",
152
+ )
153
+ end
154
+ end
155
+
156
+ def report(path, message)
157
+ @findings << Finding.new(
158
+ code: "upa_violation",
159
+ message: message,
160
+ path: path,
161
+ )
162
+ end
163
+
164
+ # First-set of a particle: the element names and wildcards it can
165
+ # match first, and whether it can be absent. Repeatable marks
166
+ # particles whose occurrence permits more than one match.
167
+ def first_set(particle)
168
+ case particle
169
+ when Lutaml::Xml::Schema::Xsd::Element
170
+ FirstSet.new([element_key(particle)].compact, [].freeze,
171
+ parse_min(particle).zero?).tap do |first|
172
+ first.repeatable = parse_max(particle) != 1
173
+ end
174
+ when Lutaml::Xsd::Any
175
+ FirstSet.new([].freeze, [wildcard_spec(particle)].compact,
176
+ parse_min(particle).zero?).tap do |first|
177
+ first.repeatable = parse_max(particle) != 1
178
+ end
179
+ when Lutaml::Xsd::Sequence
180
+ apply_occurrence(sequence_first(particle), particle)
181
+ when Lutaml::Xsd::Choice, Lutaml::Xsd::All
182
+ apply_occurrence(choice_first(particle), particle)
183
+ else
184
+ EMPTY_FIRST
185
+ end
186
+ end
187
+
188
+ # A container particle's own occurrence widens its computed
189
+ # first-set: minOccurs="0" makes it nullable, max≠1 repeatable.
190
+ def apply_occurrence(first, particle)
191
+ first.nullable ||= parse_min(particle).zero?
192
+ first.repeatable ||= parse_max(particle) != 1
193
+ first
194
+ end
195
+
196
+ # Leading names until the first required, exactly-once particle;
197
+ # nullable only when every particle can be absent.
198
+ def sequence_first(sequence)
199
+ names = []
200
+ wildcards = []
201
+ nullable = true
202
+ repeatable = false
203
+ ordered_particles(sequence).each do |particle|
204
+ first = first_set(particle)
205
+ names |= first.names
206
+ wildcards |= first.wildcards
207
+ repeatable ||= first.repeatable
208
+ unless first.nullable
209
+ nullable = false
210
+ break
211
+ end
212
+ end
213
+ FirstSet.new(names, wildcards, nullable).tap do |first|
214
+ first.repeatable = repeatable
215
+ end
216
+ end
217
+
218
+ def choice_first(choice)
219
+ names = []
220
+ wildcards = []
221
+ nullable = false
222
+ repeatable = false
223
+ ordered_particles(choice).each do |particle|
224
+ first = first_set(particle)
225
+ names |= first.names
226
+ wildcards |= first.wildcards
227
+ nullable ||= first.nullable
228
+ repeatable ||= first.repeatable
229
+ end
230
+ FirstSet.new(names, wildcards, nullable).tap do |first|
231
+ first.repeatable = repeatable
232
+ end
233
+ end
234
+
235
+ # Particles in declaration order — resolved_element_order preserves
236
+ # the source sequence including xs:any, which attribute-style
237
+ # accessor reads would reorder.
238
+ def ordered_particles(container)
239
+ ordered = container.resolved_element_order
240
+ return ordered.to_a if ordered.to_a.any?
241
+
242
+ case container
243
+ when Lutaml::Xml::Schema::Xsd::Sequence,
244
+ Lutaml::Xml::Schema::Xsd::Choice
245
+ %i[element any sequence choice].flat_map do |kind|
246
+ Array(container.public_send(kind))
247
+ end.compact
248
+ when Lutaml::Xml::Schema::Xsd::All
249
+ Array(container.element).compact
250
+ else
251
+ []
252
+ end
253
+ end
254
+
255
+ def element_key(element)
256
+ name = element.name || element.ref
257
+ return nil if name.to_s.empty?
258
+
259
+ namespace = if element.name
260
+ element.target_namespace.to_s
261
+ else
262
+ @schema.target_namespace.to_s
263
+ end
264
+ [namespace, name.to_s]
265
+ end
266
+
267
+ def wildcard_spec(any_particle)
268
+ any_particle.namespace.to_s
269
+ end
270
+
271
+ def parse_min(particle)
272
+ value = particle.min_occurs.to_s
273
+ value.empty? ? 1 : value.to_i
274
+ rescue StandardError
275
+ 1
276
+ end
277
+
278
+ def parse_max(particle)
279
+ value = particle.max_occurs.to_s
280
+ return Float::INFINITY if value == "unbounded"
281
+ return 1 if value.empty?
282
+
283
+ value.to_i
284
+ rescue StandardError
285
+ 1
286
+ end
287
+
288
+ def particle_name(particle)
289
+ case particle
290
+ when Lutaml::Xml::Schema::Xsd::Element
291
+ particle.name || particle.ref || "element"
292
+ when Lutaml::Xsd::Any then "any"
293
+ when Lutaml::Xsd::Choice then "choice"
294
+ when Lutaml::Xsd::Sequence then "sequence"
295
+ when Lutaml::Xsd::All then "all"
296
+ else particle.class.name.split("::").last
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Xsd
5
- VERSION = "1.2.1"
5
+ VERSION = "1.2.2"
6
6
  end
7
7
  end
data/lutaml-xsd.gemspec CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
  end
33
33
 
34
34
  spec.add_dependency "liquid", ">= 4.0", "< 6.0"
35
- spec.add_dependency "lutaml-model", "~> 0.8.114"
35
+ spec.add_dependency "lutaml-model", "~> 0.8.123"
36
36
  spec.add_dependency "lutaml-store", "~> 0.3"
37
37
  spec.add_dependency "moxml", "~> 0.5.147"
38
38
  spec.add_dependency "paint", "~> 2.3"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-xsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.2
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-22 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: liquid
@@ -36,14 +36,14 @@ dependencies:
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.8.114
39
+ version: 0.8.123
40
40
  type: :runtime
41
41
  prerelease: false
42
42
  version_requirements: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: 0.8.114
46
+ version: 0.8.123
47
47
  - !ruby/object:Gem::Dependency
48
48
  name: lutaml-store
49
49
  requirement: !ruby/object:Gem::Requirement
@@ -576,6 +576,7 @@ files:
576
576
  - lib/lutaml/xsd/validation/schema_definition_resolver.rb
577
577
  - lib/lutaml/xsd/validation/schema_location_extractor.rb
578
578
  - lib/lutaml/xsd/validation/schema_resolver.rb
579
+ - lib/lutaml/xsd/validation/unique_particle_attribution.rb
579
580
  - lib/lutaml/xsd/validation/validation_configuration.rb
580
581
  - lib/lutaml/xsd/validation/validation_error.rb
581
582
  - lib/lutaml/xsd/validation/validation_job.rb