lutaml-xsd 1.2.2 → 1.2.4

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: a22b97347b4ad22fc7698a047a1037f4b16c5a6bad3ed0154701908f0052ea29
4
- data.tar.gz: 0a3d2bd48d987524cbdc427adefcdc128ebcd089a2ed5e25203c28ff27ba4416
3
+ metadata.gz: da77a9a03a7bdbb0a9ea2092de4634b5151a6bafe0781592d58fbf869aacb542
4
+ data.tar.gz: 11c964841aa88464f5ab94c176f18757b1ff5bdc33813030d24cb7429957b828
5
5
  SHA512:
6
- metadata.gz: 304b7555580c9bd6b79464b3b2d9ff73d9c69177790fbcd8bb97b9b0438fdc96b19ce19bdbaba4a9a3c2c6446402e81ee457614cd3e33c3dac35e02b2c948d74
7
- data.tar.gz: 39593e00b27fb41c0fe4e374b682c4172dabcf56cd8250e2917f76c135e9f6421e1c65eaca4c0c3896a7d03bae1395fa18c8a1b6f8e38bb3e23cbfa4e195ef6a
6
+ metadata.gz: 96be4d45cabc55c0e0839a456ce4d6948cbe326bc25d72e1f5f228ead717a4a578871295f3b1bbeb9714657997217345657ba2c0f1b9af8a502211d62e9748cd
7
+ data.tar.gz: 55c6018abace93a782f1d661706568aeb9c48ed3c2c88b31fc64504a6586bb5ae3661fbdcbf312f0ffe75fd3e782e8c11c392387f54548bcbaab19be72892714
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lutaml-xsd (1.2.1)
4
+ lutaml-xsd (1.2.3)
5
5
  liquid (>= 4.0, < 6.0)
6
6
  lutaml-model (~> 0.8.123)
7
7
  lutaml-store (~> 0.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.1)
267
+ lutaml-xsd (1.2.3)
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, 22 cases / 47 checks, full pass)
7
7
 
8
- Corpus: 20 case classes under `spec/conformance/cases/`, modelled on the
8
+ Corpus: 22 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%, 2 cases unsupported) |
16
+ | pipeline (ValidationJob + wired rule engine) | 47/47 (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,9 @@ 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%);
32
+ abstract-element usage checking → 47/47 (100.0%).
31
33
 
32
34
  Remaining engine-mode gaps (tracked, moxml/leptris-side):
33
35
 
@@ -95,7 +97,15 @@ Remaining engine-mode gaps (tracked, moxml/leptris-side):
95
97
  overlap, choice-alternative and sequence-junction conflict rules,
96
98
  container occurrence folding. Standalone API
97
99
  (`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
100
+ conformance corpus schemas (no false positives). Depth (2026-10-22):
101
+ xs:group references expand to the referenced declaration
102
+ (cycle-guarded), and element first-sets include substitution-group
103
+ members — head/member junctions report per XSD 1.0 while required
104
+ head/member sequences stay deterministic. Wired into
105
+ `ValidationJob` as an opt-out `validate_upa` phase: schema
106
+ findings surface as warnings and never flip instance validity.
107
+ Also fixed the occurrence rule's choice counting: one instance
108
+ element matching duplicate alternatives counts once, not once
109
+ per alternative
100
110
  - [ ] XSD 1.1 constructs behind a version flag — DEFERRED: 1.1 strategy
101
111
  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
 
@@ -59,7 +59,7 @@ module Lutaml
59
59
  def validate(xml_element, schema_def, collector, repository: nil)
60
60
  return unless schema_def
61
61
 
62
- return if engine_content_check(xml_element, collector) == :handled
62
+ handled = engine_content_check(xml_element, collector) == :handled
63
63
 
64
64
  particles = effective_particles(
65
65
  xml_element, schema_def, repository
@@ -68,6 +68,12 @@ module Lutaml
68
68
  # rule already reports it, so no content walk runs here.
69
69
  return if particles.nil? || particles.empty?
70
70
 
71
+ # The engine does not check abstract-element usage; scan for
72
+ # it regardless of whether the engine answered.
73
+ scan_abstract_usage(xml_element, particles, collector,
74
+ repository)
75
+ return if handled
76
+
71
77
  validate_particle_sequence(xml_element, particles, collector,
72
78
  repository)
73
79
  end
@@ -129,6 +135,10 @@ module Lutaml
129
135
  # occurrence constraints survive the walk.
130
136
  if particle.is_a?(Lutaml::Xsd::Sequence)
131
137
  particles.concat(collect_particles(particle))
138
+ elsif particle.is_a?(Lutaml::Xml::Schema::Xsd::Group)
139
+ particles.concat(
140
+ expand_group_container(particle, repository),
141
+ )
132
142
  else
133
143
  particles << particle
134
144
  end
@@ -161,6 +171,48 @@ module Lutaml
161
171
  particles
162
172
  end
163
173
 
174
+ # Engine-accepted content still needs the checks the engine
175
+ # lacks: abstract element declarations cannot appear in
176
+ # instances. Containers flatten to their leaf declarations —
177
+ # the walk keeps them whole, the scan needs the leaves.
178
+ def scan_abstract_usage(xml_element, particles, collector,
179
+ repository)
180
+ leaves = leaf_particles(particles, repository)
181
+ xml_element.children.each do |child|
182
+ leaves.each do |particle|
183
+ next unless particle.is_a?(Lutaml::Xsd::Element)
184
+ next unless particle_matches?(child, particle, repository,
185
+ parent: xml_element)
186
+ next unless element_declaration_abstract?(particle, child,
187
+ repository)
188
+
189
+ report_error(
190
+ collector,
191
+ code: "abstract_element_used",
192
+ message: "Element '#{child.qualified_name}' is " \
193
+ "abstract and cannot appear in the instance",
194
+ location: child.xpath,
195
+ context: { element: child.qualified_name },
196
+ )
197
+ break
198
+ end
199
+ end
200
+ end
201
+
202
+ def leaf_particles(particles, repository)
203
+ particles.flat_map do |particle|
204
+ case particle
205
+ when Lutaml::Xml::Schema::Xsd::Group
206
+ leaf_particles(expand_group_container(particle, repository),
207
+ repository)
208
+ when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
209
+ leaf_particles(collect_particles(particle), repository)
210
+ else
211
+ [particle]
212
+ end
213
+ end
214
+ end
215
+
164
216
  # Sequential walk over a particle list (min/max per particle,
165
217
  # recursive matching, wildcard permission for unmatched tails).
166
218
  #
@@ -181,7 +233,8 @@ module Lutaml
181
233
  # enforced loosely for the same reason.
182
234
  container = particle.is_a?(Lutaml::Xsd::Sequence) ||
183
235
  particle.is_a?(Lutaml::Xsd::Choice) ||
184
- particle.is_a?(Lutaml::Xsd::All)
236
+ particle.is_a?(Lutaml::Xsd::All) ||
237
+ particle.is_a?(Lutaml::Xml::Schema::Xsd::Group)
185
238
  max_occurs = parse_max_occurs(
186
239
  particle.max_occurs, container ? :unbounded : 1
187
240
  )
@@ -197,6 +250,16 @@ module Lutaml
197
250
  if particle.is_a?(Lutaml::Xsd::Any)
198
251
  check_strict_wildcard_child(child, particle, collector,
199
252
  repository)
253
+ elsif particle.is_a?(Lutaml::Xsd::Element) &&
254
+ element_declaration_abstract?(particle, child, repository)
255
+ report_error(
256
+ collector,
257
+ code: "abstract_element_used",
258
+ message: "Element '#{child.qualified_name}' is " \
259
+ "abstract and cannot appear in the instance",
260
+ location: child.xpath,
261
+ context: { element: child.qualified_name },
262
+ )
200
263
  end
201
264
 
202
265
  matched_count += 1
@@ -244,6 +307,30 @@ module Lutaml
244
307
  end
245
308
  end
246
309
 
310
+ # Whether the matched declaration is abstract: abstract element
311
+ # declarations cannot appear directly in instances (substitution
312
+ # members remain legal). Direct declarations carry their own
313
+ # abstract flag; references resolve to the global declaration.
314
+ def element_declaration_abstract?(particle, child, repository)
315
+ declaration = declaration_for(particle, child, repository)
316
+ return false unless declaration
317
+
318
+ truthy?(declaration.abstract) && child.name == declaration.name
319
+ end
320
+
321
+ def declaration_for(particle, child, repository)
322
+ return particle if particle.name.to_s.match?(/\S/)
323
+ return nil unless repository
324
+
325
+ local = particle.ref.to_s.split(":").last
326
+ repository.resolve(local, child.namespace_uri) ||
327
+ repository.resolve(local, nil)
328
+ end
329
+
330
+ def truthy?(value)
331
+ value.to_s == "true"
332
+ end
333
+
247
334
  # Report an unsatisfied minimum occurrence: a choice container
248
335
  # names its alternatives, anything else reports generically.
249
336
  #
@@ -429,20 +516,28 @@ module Lutaml
429
516
  # wildcard namespace constraints scoped to the target namespace
430
517
  # @return [Boolean]
431
518
  def particle_matches?(xml_element, particle, repository = nil,
432
- parent: nil)
519
+ parent: nil, group_depth: 0)
433
520
  case particle
434
521
  when Lutaml::Xsd::Element
435
522
  element_matches?(xml_element, particle, repository)
436
523
  when Lutaml::Xsd::Any
437
524
  wildcard_permits?(particle, parent || xml_element,
438
525
  xml_element, repository)
526
+ when Lutaml::Xml::Schema::Xsd::Group
527
+ expand_group_container(particle, repository,
528
+ group_depth).any? do |container|
529
+ particle_matches?(xml_element, container, repository,
530
+ parent: parent,
531
+ group_depth: group_depth + 1)
532
+ end
439
533
  when Lutaml::Xsd::Sequence, Lutaml::Xsd::Choice, Lutaml::Xsd::All
440
534
  # Nested content model: a child matches the container when it
441
535
  # matches any leaf particle inside it. Inner ordering is
442
536
  # permissive; the outer walk stays sequential.
443
537
  collect_particles(particle).any? do |leaf|
444
538
  particle_matches?(xml_element, leaf, repository,
445
- parent: parent)
539
+ parent: parent,
540
+ group_depth: group_depth)
446
541
  end
447
542
  else
448
543
  false
@@ -458,6 +553,13 @@ module Lutaml
458
553
  # @param repository [SchemaDefinitionResolver, nil]
459
554
  # @return [Boolean]
460
555
  def element_matches?(xml_element, schema_element, repository = nil)
556
+ local_ref = schema_element.name.to_s.empty? && schema_element.ref
557
+ if local_ref && repository
558
+ return ref_element_matches?(xml_element, schema_element,
559
+ local_ref.to_s.split(":").last,
560
+ repository)
561
+ end
562
+
461
563
  if xml_element.name == schema_element.name
462
564
  return namespaces_match_instance?(
463
565
  xml_element,
@@ -468,6 +570,24 @@ module Lutaml
468
570
  substitution_member?(xml_element, schema_element, repository)
469
571
  end
470
572
 
573
+ # An xs:ref particle takes the name and target namespace of the
574
+ # global declaration it references; substitution members of that
575
+ # declaration match too.
576
+ def ref_element_matches?(xml_element, schema_element, local,
577
+ repository)
578
+ declaration = repository.resolve(local,
579
+ xml_element.namespace_uri) ||
580
+ repository.resolve(local, nil)
581
+ return false unless declaration
582
+
583
+ return true if xml_element.name == declaration.name &&
584
+ namespaces_match_instance?(
585
+ xml_element, repository.target_namespace_for(declaration)
586
+ )
587
+
588
+ substitution_member?(xml_element, schema_element, repository)
589
+ end
590
+
471
591
  # Whether the instance element's declaration is in the
472
592
  # substitution group headed by the particle's name/ref.
473
593
  #
@@ -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)
@@ -65,6 +65,17 @@ module Lutaml
65
65
  def initialize(schema)
66
66
  @schema = schema
67
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
68
79
  end
69
80
 
70
81
  # @return [Array<Finding>] UPA violations, empty when deterministic
@@ -167,7 +178,7 @@ module Lutaml
167
178
  def first_set(particle)
168
179
  case particle
169
180
  when Lutaml::Xml::Schema::Xsd::Element
170
- FirstSet.new([element_key(particle)].compact, [].freeze,
181
+ FirstSet.new(element_keys(particle), [].freeze,
171
182
  parse_min(particle).zero?).tap do |first|
172
183
  first.repeatable = parse_max(particle) != 1
173
184
  end
@@ -234,21 +245,53 @@ module Lutaml
234
245
 
235
246
  # Particles in declaration order — resolved_element_order preserves
236
247
  # the source sequence including xs:any, which attribute-style
237
- # accessor reads would reorder.
238
- def ordered_particles(container)
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 = {})
239
251
  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
+ 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
252
295
  end
253
296
  end
254
297
 
@@ -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.2"
5
+ VERSION = "1.2.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-xsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.