lutaml-xsd 1.2.1 → 1.2.3

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: 689df8d8930b23eb8ca6b52c4d435c0e52ea9d654bf64cc51b546fbfe2fcb69e
4
+ data.tar.gz: 0e58e44689241fdd080621539f438a3f64d7dad94ca55ff72f06740b93320be9
5
5
  SHA512:
6
- metadata.gz: 89fe169405a033ca52e85b7017e65075167766a5bfba5b2c779483b7ff9ca0ea0a568daa799fdee61009e1e385af0c584b91e7e4af1bbcd578da67501e626107
7
- data.tar.gz: b18d6e86f3ee582dbe52ace10581e2ea015681f6742bee4601b5a202e9ebc5e16e1878af8763cee247b2c452a18fe817d4e585cd067b403f5667ef52243c2ae9
6
+ metadata.gz: 77edb222b3737cc289729cdafe818c083bf12f3aed2f0b3a8251657a92dec54fb9dcd6f0a2464d4da20fb58a39dc9884ca68bf3e5abeeb9ff6122cb3dc269d6c
7
+ data.tar.gz: 96ccd234460bdfee103628897e869e7c999e510d6d76ddc0a989f990757743dfeea0f7848c9bada3212db900096931fdeaf3e832f4b4f31b6e0a62e6072a4b92
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.2)
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.2)
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/README.adoc CHANGED
@@ -339,6 +339,28 @@ automatic detection of version-specific features. Validation is automatically
339
339
  enabled when parsing schemas via [`Lutaml::Xsd.parse()`](lib/lutaml/xsd.rb:47) but can be disabled
340
340
  if needed.
341
341
 
342
+ === Instance validation
343
+
344
+ Beyond schema-document checks, the `Lutaml::Xsd::Validation` module validates
345
+ XML *instances* against parsed schemas: element structure and content models
346
+ (sequences, choices, nested groups, `xs:any` wildcards with namespace
347
+ constraints and `processContents` enforcement), attribute presence, typing and
348
+ `xs:anyAttribute` on every shape, builtin/simple-type facets, `xsi:nil`
349
+ handling, `xsi:type` dynamic resolution, key/keyref/unique identity
350
+ constraints, and schema-side Unique Particle Attribution checking via
351
+ `Lutaml::Xsd::Validation::UniqueParticleAttribution`.
352
+
353
+ The pipeline passes 42/42 checks of its conformance corpus
354
+ (`rake conformance`); measured results live in `docs/CONFORMANCE.adoc` and
355
+ `TODO.conformance.md`.
356
+
357
+ [NOTE]
358
+ ====
359
+ Guaranteed feature set requires `lutaml-model >= 0.8.123` (the gemspec
360
+ floor). Keyref resolution and complexType/extension `xs:anyAttribute`
361
+ checking depend on model mappings introduced there.
362
+ ====
363
+
342
364
  === Validation checks
343
365
 
344
366
  The schema validator performs these checks:
data/TODO.conformance.md CHANGED
@@ -3,17 +3,17 @@
3
3
  Goal: conformance is a number, not a feeling. Vendor the W3C XML Schema
4
4
  test-suite categories, build the harness, publish the pass-rate, grow it.
5
5
 
6
- ## Baseline (2026-10-22, 20 cases / 42 checks, full pass)
6
+ ## Baseline (2026-10-23, 21 cases / 45 checks, full pass)
7
7
 
8
- Corpus: 20 case classes under `spec/conformance/cases/`, modelled on the
8
+ Corpus: 21 case classes under `spec/conformance/cases/`, modelled on the
9
9
  W3C XML Schema Test Suite category taxonomy — self-authored because the
10
10
  canonical 2007 suite distribution is no longer served from w3.org (owner
11
11
  can vendor a tarball later; provenance README goes with it).
12
12
 
13
13
  | mode | pass-rate |
14
14
  |---|---|
15
- | engine (Moxml::XSD tier-1, leptris 1.9.380.2) | 38/42 (90.5%) |
16
- | pipeline (ValidationJob + wired rule engine) | 42/42 (100.0%) |
15
+ | engine (Moxml::XSD tier-1, leptris 1.9.380.2) | 38/42 (90.5%, 1 case unsupported) |
16
+ | pipeline (ValidationJob + wired rule engine) | 45/45 (100.0%) |
17
17
 
18
18
  The pipeline now passes every check in the corpus. The engine trails on
19
19
  four: strict wildcard/anyAttribute under-detection, xsi:nil on
@@ -27,7 +27,8 @@ History: 2026-10-20 baseline 20/25 (80.0%) with pipeline == engine
27
27
  baseline 34/39 (87.2%); gap rules 37/39 (94.9%); anyAttribute 40/42
28
28
  (95.2%); keyref activation via lutaml-model 0.8.121 → 41/42 (97.6%);
29
29
  lutaml-model 0.8.123 (complexType/extension anyAttribute mappings) +
30
- leptris 1.9.380.2 → 42/42 (100.0%).
30
+ leptris 1.9.380.2 → 42/42 (100.0%); xs:group expansion in the content
31
+ and occurrence walks grew the corpus to 45 checks → 45/45 (100.0%).
31
32
 
32
33
  Remaining engine-mode gaps (tracked, moxml/leptris-side):
33
34
 
@@ -89,7 +90,21 @@ Remaining engine-mode gaps (tracked, moxml/leptris-side):
89
90
  resolution (unknown-type rejection; content walk follows the
90
91
  override); the engine's valid-derived false positive is fixed in
91
92
  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)
93
+ - [x] Particle checking: UPA detection (2026-10-22) —
94
+ `UniqueParticleAttribution` schema-side checker: first-set
95
+ computation over sequence/choice/all with wildcard/element
96
+ overlap, choice-alternative and sequence-junction conflict rules,
97
+ container occurrence folding. Standalone API
98
+ (`findings` → upa_violation findings); zero findings on the
99
+ conformance corpus schemas (no false positives). Depth (2026-10-22):
100
+ xs:group references expand to the referenced declaration
101
+ (cycle-guarded), and element first-sets include substitution-group
102
+ members — head/member junctions report per XSD 1.0 while required
103
+ head/member sequences stay deterministic. Wired into
104
+ `ValidationJob` as an opt-out `validate_upa` phase: schema
105
+ findings surface as warnings and never flip instance validity.
106
+ Also fixed the occurrence rule's choice counting: one instance
107
+ element matching duplicate alternatives counts once, not once
108
+ per alternative
94
109
  - [ ] XSD 1.1 constructs behind a version flag — DEFERRED: 1.1 strategy
95
110
  decision needed (see TODO.som.md)
@@ -32,9 +32,17 @@ Two modes are measured independently:
32
32
 
33
33
  ## Baseline and gap register
34
34
 
35
- Recorded in `TODO.conformance.md` (2026-10-20: 20/25 = 80.0% in both
36
- modes). Each failing check maps to a tracked gap; the gap register is the
37
- work queue for the conformance pillar.
35
+ Current measured result (2026-10-23, 20 case classes / 42 checks):
36
+
37
+ - **pipeline** — 42/42 (100.0%)
38
+ - **engine** — 38/42 (90.5%); the four remaining are documented tier-1
39
+ laxness (strict wildcard and anyAttribute under-detection, `xsi:nil` on
40
+ non-nillable elements, wrong-namespace root matching), tracked in
41
+ lutaml/moxml#452
42
+
43
+ The full history and gap register live in `TODO.conformance.md`; each
44
+ failing check maps to a tracked gap, and the register is the work queue
45
+ for the conformance pillar.
38
46
 
39
47
  ## Adding a case
40
48
 
@@ -129,6 +129,10 @@ module Lutaml
129
129
  # occurrence constraints survive the walk.
130
130
  if particle.is_a?(Lutaml::Xsd::Sequence)
131
131
  particles.concat(collect_particles(particle))
132
+ elsif particle.is_a?(Lutaml::Xml::Schema::Xsd::Group)
133
+ particles.concat(
134
+ expand_group_container(particle, repository),
135
+ )
132
136
  else
133
137
  particles << particle
134
138
  end
@@ -181,7 +185,8 @@ module Lutaml
181
185
  # enforced loosely for the same reason.
182
186
  container = particle.is_a?(Lutaml::Xsd::Sequence) ||
183
187
  particle.is_a?(Lutaml::Xsd::Choice) ||
184
- particle.is_a?(Lutaml::Xsd::All)
188
+ particle.is_a?(Lutaml::Xsd::All) ||
189
+ particle.is_a?(Lutaml::Xml::Schema::Xsd::Group)
185
190
  max_occurs = parse_max_occurs(
186
191
  particle.max_occurs, container ? :unbounded : 1
187
192
  )
@@ -429,20 +434,28 @@ module Lutaml
429
434
  # wildcard namespace constraints scoped to the target namespace
430
435
  # @return [Boolean]
431
436
  def particle_matches?(xml_element, particle, repository = nil,
432
- parent: nil)
437
+ parent: nil, group_depth: 0)
433
438
  case particle
434
439
  when Lutaml::Xsd::Element
435
440
  element_matches?(xml_element, particle, repository)
436
441
  when Lutaml::Xsd::Any
437
442
  wildcard_permits?(particle, parent || xml_element,
438
443
  xml_element, repository)
444
+ when Lutaml::Xml::Schema::Xsd::Group
445
+ expand_group_container(particle, repository,
446
+ group_depth).any? do |container|
447
+ particle_matches?(xml_element, container, repository,
448
+ parent: parent,
449
+ group_depth: group_depth + 1)
450
+ end
439
451
  when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
440
452
  # Nested content model: a child matches the container when it
441
453
  # matches any leaf particle inside it. Inner ordering is
442
454
  # permissive; the outer walk stays sequential.
443
455
  collect_particles(particle).any? do |leaf|
444
456
  particle_matches?(xml_element, leaf, repository,
445
- parent: parent)
457
+ parent: parent,
458
+ group_depth: group_depth)
446
459
  end
447
460
  else
448
461
  false
@@ -65,6 +65,11 @@ module Lutaml
65
65
  when Lutaml::Xsd::All
66
66
  validate_all_occurrences(parent_element, schema_particle,
67
67
  collector, repository: repository)
68
+ when Lutaml::Xml::Schema::Xsd::Group
69
+ expand_group_container(schema_particle, repository).each do |container|
70
+ validate(parent_element, container, collector,
71
+ repository: repository)
72
+ end
68
73
  end
69
74
  end
70
75
 
@@ -265,15 +270,33 @@ module Lutaml
265
270
  # @return [Integer]
266
271
  def count_choice_alternatives(parent_element, choice,
267
272
  repository = nil)
268
- return 0 if choice.element.empty?
273
+ return 0 if choice.element.empty? || parent_element.children.empty?
269
274
 
270
275
  alternatives = Array(choice.element)
271
- alternatives.count do |alt_element|
272
- count_element_occurrences(parent_element, alt_element,
273
- repository).positive?
276
+ # Count instance elements that matched the choice — a single
277
+ # child matching several identical alternatives counts once.
278
+ parent_element.children.count do |child|
279
+ alternatives.any? do |alt_element|
280
+ count_element_occurrences_for_child(child, alt_element,
281
+ parent_element,
282
+ repository)
283
+ end
274
284
  end
275
285
  end
276
286
 
287
+ # Whether one instance child matches one alternative declaration.
288
+ def count_element_occurrences_for_child(child, schema_element,
289
+ _parent_element,
290
+ repository)
291
+ element_name = schema_element.name ||
292
+ schema_element.ref.to_s.split(":").last
293
+ target_ns = resolve_target_namespace(schema_element, repository)
294
+
295
+ (child.name == element_name &&
296
+ namespaces_match?(child.namespace_uri, target_ns)) ||
297
+ substitution_member?(child, schema_element, repository)
298
+ end
299
+
277
300
  # Validate minimum occurrence constraint
278
301
  #
279
302
  # @param parent_element [XmlElement] Parent element
@@ -25,6 +25,7 @@ module Lutaml
25
25
  %i[sequence choice all],
26
26
  Lutaml::Xml::Schema::Xsd::RestrictionComplexContent =>
27
27
  %i[sequence choice all],
28
+ Lutaml::Xml::Schema::Xsd::Group => %i[sequence choice all],
28
29
  Lutaml::Xml::Schema::Xsd::Sequence => %i[sequence choice],
29
30
  Lutaml::Xml::Schema::Xsd::Choice => %i[sequence choice],
30
31
  Lutaml::Xml::Schema::Xsd::All => [].freeze,
@@ -123,6 +124,21 @@ module Lutaml
123
124
  candidates.length == 1 ? candidates.first.first : nil
124
125
  end
125
126
 
127
+ # Named group declaration by local name (for xs:group reference
128
+ # expansion in the content and occurrence walks).
129
+ #
130
+ # @param local_name [String]
131
+ # @return [Lutaml::Xml::Schema::Xsd::Group, nil]
132
+ def resolve_group(local_name)
133
+ return nil if local_name.nil? || local_name.empty?
134
+
135
+ schemas.each do |schema|
136
+ found = Array(schema.group).find { |g| g.name == local_name }
137
+ return found if found
138
+ end
139
+ nil
140
+ end
141
+
126
142
  # Content-model particle of the element's type, for occurrence
127
143
  # and content-model rules.
128
144
  #
@@ -147,7 +163,8 @@ module Lutaml
147
163
  case type
148
164
  when Lutaml::Xml::Schema::Xsd::ComplexType
149
165
  direct = Array(type.sequence).first ||
150
- Array(type.choice).first || Array(type.all).first
166
+ Array(type.choice).first || Array(type.all).first ||
167
+ Array(type.group).first
151
168
  return direct if direct
152
169
 
153
170
  # Derived types keep their particles under complex_content.
@@ -202,6 +219,11 @@ module Lutaml
202
219
  Array(schema.attribute).each do |attribute|
203
220
  index_attribute(attribute, namespace)
204
221
  end
222
+ Array(schema.group).each do |group|
223
+ each_particle_element(group) do |element|
224
+ index_element(index, element, namespace, :inline)
225
+ end
226
+ end
205
227
  end
206
228
 
207
229
  def index_attribute(attribute, namespace)
@@ -0,0 +1,345 @@
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
+ @groups = {}
69
+ Array(@schema.group).each do |group|
70
+ @groups[group.name.to_s] = group if group.name
71
+ end
72
+ @substitutes = {}
73
+ Array(@schema.element).each do |element|
74
+ next unless element.name && element.substitution_group.to_s.match?(/\S/)
75
+
76
+ head = element.substitution_group.to_s.split(":").last
77
+ (@substitutes[head] ||= []) << element
78
+ end
79
+ end
80
+
81
+ # @return [Array<Finding>] UPA violations, empty when deterministic
82
+ def findings
83
+ compute! unless @computed
84
+ @findings
85
+ end
86
+
87
+ private
88
+
89
+ def compute!
90
+ @findings = []
91
+ Array(@schema.element).each do |element|
92
+ check_type(element.complex_type, "/#{element.name}") if
93
+ element.complex_type
94
+ end
95
+ Array(@schema.complex_type).each do |complex_type|
96
+ check_type(complex_type, complex_type.name.to_s)
97
+ end
98
+ @computed = true
99
+ end
100
+
101
+ def check_type(type, path)
102
+ return unless type.is_a?(Lutaml::Xml::Schema::Xsd::ComplexType)
103
+
104
+ container = type.sequence || type.choice || type.all
105
+ check_container(container, path) if container
106
+ end
107
+
108
+ # Walks a container: checks its own junctions, then descends.
109
+ def check_container(container, path)
110
+ return unless container
111
+ return if @seen[container]
112
+
113
+ @seen[container] = true
114
+
115
+ particles = ordered_particles(container)
116
+ check_junctions(particles, container, path)
117
+ particles.each do |particle|
118
+ case particle
119
+ when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
120
+ check_container(particle, "#{path}/#{particle_name(particle)}")
121
+ end
122
+ end
123
+ end
124
+
125
+ def check_junctions(particles, container, path)
126
+ firsts = particles.map { |p| first_set(p) }
127
+
128
+ if container.is_a?(Lutaml::Xsd::Choice)
129
+ check_choice_overlap(particles, firsts, path)
130
+ else
131
+ check_sequence_junctions(particles, firsts, path)
132
+ end
133
+ end
134
+
135
+ def check_choice_overlap(particles, firsts, path)
136
+ firsts.each_with_index do |first, i|
137
+ firsts[(i + 1)..].each_with_index do |other, j|
138
+ next unless first.overlaps?(other)
139
+
140
+ report(
141
+ path,
142
+ "ambiguous choice: '#{particle_name(particles[i])}' and " \
143
+ "'#{particle_name(particles[i + 1 + j])}' can match the " \
144
+ "same element",
145
+ )
146
+ end
147
+ end
148
+ end
149
+
150
+ def check_sequence_junctions(particles, firsts, path)
151
+ firsts.each_with_index do |first, i|
152
+ next if i.zero?
153
+
154
+ previous = firsts[i - 1]
155
+ next unless previous.nullable || previous.repeatable
156
+ next unless first.overlaps?(previous)
157
+
158
+ report(
159
+ path,
160
+ "ambiguous sequence: '#{particle_name(particles[i - 1])}' " \
161
+ "can be followed by '#{particle_name(particles[i])}' — both " \
162
+ "match the same first element",
163
+ )
164
+ end
165
+ end
166
+
167
+ def report(path, message)
168
+ @findings << Finding.new(
169
+ code: "upa_violation",
170
+ message: message,
171
+ path: path,
172
+ )
173
+ end
174
+
175
+ # First-set of a particle: the element names and wildcards it can
176
+ # match first, and whether it can be absent. Repeatable marks
177
+ # particles whose occurrence permits more than one match.
178
+ def first_set(particle)
179
+ case particle
180
+ when Lutaml::Xml::Schema::Xsd::Element
181
+ FirstSet.new(element_keys(particle), [].freeze,
182
+ parse_min(particle).zero?).tap do |first|
183
+ first.repeatable = parse_max(particle) != 1
184
+ end
185
+ when Lutaml::Xsd::Any
186
+ FirstSet.new([].freeze, [wildcard_spec(particle)].compact,
187
+ parse_min(particle).zero?).tap do |first|
188
+ first.repeatable = parse_max(particle) != 1
189
+ end
190
+ when Lutaml::Xsd::Sequence
191
+ apply_occurrence(sequence_first(particle), particle)
192
+ when Lutaml::Xsd::Choice, Lutaml::Xsd::All
193
+ apply_occurrence(choice_first(particle), particle)
194
+ else
195
+ EMPTY_FIRST
196
+ end
197
+ end
198
+
199
+ # A container particle's own occurrence widens its computed
200
+ # first-set: minOccurs="0" makes it nullable, max≠1 repeatable.
201
+ def apply_occurrence(first, particle)
202
+ first.nullable ||= parse_min(particle).zero?
203
+ first.repeatable ||= parse_max(particle) != 1
204
+ first
205
+ end
206
+
207
+ # Leading names until the first required, exactly-once particle;
208
+ # nullable only when every particle can be absent.
209
+ def sequence_first(sequence)
210
+ names = []
211
+ wildcards = []
212
+ nullable = true
213
+ repeatable = false
214
+ ordered_particles(sequence).each do |particle|
215
+ first = first_set(particle)
216
+ names |= first.names
217
+ wildcards |= first.wildcards
218
+ repeatable ||= first.repeatable
219
+ unless first.nullable
220
+ nullable = false
221
+ break
222
+ end
223
+ end
224
+ FirstSet.new(names, wildcards, nullable).tap do |first|
225
+ first.repeatable = repeatable
226
+ end
227
+ end
228
+
229
+ def choice_first(choice)
230
+ names = []
231
+ wildcards = []
232
+ nullable = false
233
+ repeatable = false
234
+ ordered_particles(choice).each do |particle|
235
+ first = first_set(particle)
236
+ names |= first.names
237
+ wildcards |= first.wildcards
238
+ nullable ||= first.nullable
239
+ repeatable ||= first.repeatable
240
+ end
241
+ FirstSet.new(names, wildcards, nullable).tap do |first|
242
+ first.repeatable = repeatable
243
+ end
244
+ end
245
+
246
+ # Particles in declaration order — resolved_element_order preserves
247
+ # the source sequence including xs:any, which attribute-style
248
+ # accessor reads would reorder. xs:group references expand to the
249
+ # referenced declaration's particles (cycle-guarded per chain).
250
+ def ordered_particles(container, group_chain = {})
251
+ ordered = container.resolved_element_order
252
+ raw = if ordered.to_a.any?
253
+ ordered.to_a
254
+ else
255
+ case container
256
+ when Lutaml::Xml::Schema::Xsd::Sequence,
257
+ Lutaml::Xml::Schema::Xsd::Choice
258
+ %i[element any sequence choice].flat_map do |kind|
259
+ Array(container.public_send(kind))
260
+ end.compact
261
+ when Lutaml::Xml::Schema::Xsd::All
262
+ Array(container.element).compact
263
+ else
264
+ []
265
+ end
266
+ end
267
+ raw.flat_map do |particle|
268
+ if particle.is_a?(Lutaml::Xml::Schema::Xsd::Group)
269
+ expand_group(particle, group_chain)
270
+ else
271
+ [particle]
272
+ end
273
+ end
274
+ end
275
+
276
+ def expand_group(group_ref, chain)
277
+ local = (group_ref.ref || group_ref.name).to_s.split(":").last
278
+ declaration = @groups[local]
279
+ return [] unless declaration
280
+ return [] if chain[local]
281
+
282
+ chain[local] = true
283
+ ordered_particles(declaration, chain)
284
+ end
285
+
286
+ # An element particle matches its own name plus every member of
287
+ # the substitution group headed by it.
288
+ def element_keys(element)
289
+ own = element_key(element)
290
+ return [] unless own
291
+
292
+ members = @substitutes.fetch(own[1], [])
293
+ [own] + members.filter_map do |member|
294
+ member.name ? [@schema.target_namespace.to_s, member.name] : nil
295
+ end
296
+ end
297
+
298
+ def element_key(element)
299
+ name = element.name || element.ref
300
+ return nil if name.to_s.empty?
301
+
302
+ namespace = if element.name
303
+ element.target_namespace.to_s
304
+ else
305
+ @schema.target_namespace.to_s
306
+ end
307
+ [namespace, name.to_s]
308
+ end
309
+
310
+ def wildcard_spec(any_particle)
311
+ any_particle.namespace.to_s
312
+ end
313
+
314
+ def parse_min(particle)
315
+ value = particle.min_occurs.to_s
316
+ value.empty? ? 1 : value.to_i
317
+ rescue StandardError
318
+ 1
319
+ end
320
+
321
+ def parse_max(particle)
322
+ value = particle.max_occurs.to_s
323
+ return Float::INFINITY if value == "unbounded"
324
+ return 1 if value.empty?
325
+
326
+ value.to_i
327
+ rescue StandardError
328
+ 1
329
+ end
330
+
331
+ def particle_name(particle)
332
+ case particle
333
+ when Lutaml::Xml::Schema::Xsd::Element
334
+ particle.name || particle.ref || "element"
335
+ when Lutaml::Xsd::Any then "any"
336
+ when Lutaml::Xsd::Choice then "choice"
337
+ when Lutaml::Xsd::Sequence then "sequence"
338
+ when Lutaml::Xsd::All then "all"
339
+ else particle.class.name.split("::").last
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -5,6 +5,7 @@ require_relative "validation_error"
5
5
  require_relative "xml_navigator"
6
6
  require_relative "result_collector"
7
7
  require_relative "rule_engine"
8
+ require_relative "unique_particle_attribution"
8
9
 
9
10
  module Lutaml
10
11
  module Xsd
@@ -62,6 +63,7 @@ module Lutaml
62
63
 
63
64
  validate_root_declaration
64
65
  run_engine_validation if @config.feature_enabled?(:engine_validation)
66
+ run_upa_validation if @config.feature_enabled?(:validate_upa)
65
67
  validate_structure if @config.feature_enabled?(:validate_types)
66
68
  return result_from_collector if should_stop_after_structure?
67
69
 
@@ -119,6 +121,33 @@ module Lutaml
119
121
  # Stop validation if configured to stop on first error
120
122
  end
121
123
 
124
+ # Schema-side UPA checking (XSD 1.0 3.8.6). Findings describe the
125
+ # schema rather than the instance, so they surface as warnings.
126
+ #
127
+ # @return [void]
128
+ def run_upa_validation
129
+ @repository.all_schemas.each_value do |schema|
130
+ UniqueParticleAttribution.new(schema).findings.each do |finding|
131
+ @result_collector.add_warning(
132
+ ValidationError.new(
133
+ code: finding.code,
134
+ message: finding.message,
135
+ severity: :warning,
136
+ context: { schema_path: finding.path },
137
+ ),
138
+ )
139
+ end
140
+ end
141
+ rescue StandardError => e
142
+ @result_collector.add_warning(
143
+ ValidationError.new(
144
+ code: "upa_validation_unavailable",
145
+ message: "UPA validation skipped: #{e.message}",
146
+ severity: :warning,
147
+ ),
148
+ )
149
+ end
150
+
122
151
  # Run engine-native XSD validation (Moxml::XSD) when the adapter
123
152
  # supports it. Findings are additive to rule findings.
124
153
  #
@@ -210,6 +210,23 @@ module Lutaml
210
210
 
211
211
  protected
212
212
 
213
+ # The container an xs:group reference expands to (the declaration's
214
+ # sequence/choice/all), kept whole — matching descends into it.
215
+ # XSD 1.0 forbids recursive groups; the depth guard only defends
216
+ # against malformed schemas.
217
+ def expand_group_container(group, repository, depth = 0)
218
+ return [] unless repository
219
+ return [] if depth > 5
220
+
221
+ local = (group.ref || group.name).to_s.split(":").last
222
+ declaration = repository.resolve_group(local)
223
+ return [] unless declaration
224
+
225
+ container = Array(declaration.sequence).first ||
226
+ Array(declaration.choice).first || Array(declaration.all).first
227
+ container ? [container] : []
228
+ end
229
+
213
230
  # Helper method to create validation errors
214
231
  #
215
232
  # @param collector [ResultCollector] The result collector
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Xsd
5
- VERSION = "1.2.1"
5
+ VERSION = "1.2.3"
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.3
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