lutaml-xsd 1.2.80 → 1.2.82

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: 0aed69846e615ba0095fd9030ca3c7d870fe590c855694797393e669a57085ec
4
- data.tar.gz: abcb15e8a7482e4589d08bbb886529f6aff6462db5c56e4626c9610dcb25777f
3
+ metadata.gz: 8c3da66790f16686077f84ff27947241c3fd9290e4a36f92d5b26ee70d340af4
4
+ data.tar.gz: 205af1e6a170523b054c34ba0cf43b86203877784bfe471ef88e47bcdbf60ae9
5
5
  SHA512:
6
- metadata.gz: 928180700fa58c7965b7f57729b681a1580f18c5da136c6b7729a013426ca5bfeaad44b686ce0e28c2eb1247dd3e73ca1a1e34c81b61c37030165bf4f297ae62
7
- data.tar.gz: 50441ce2cda522fed7e3df0acd8843ef3824a72b685bf663e21ade1d9c5a2366fbb2af244b4f83d839dbdb4e2c41faf3cf1eb14f3376bef8c0ef15f03de9fb98
6
+ metadata.gz: 6901b1b6a2fc09e00325fe3e24df146097e6bf6967493e46e04d7eabe8b7a9183cdccc2cc8ccf39cfd9d0f3cf90157b0647c772f61512a68085c041fbf1329be
7
+ data.tar.gz: 221ce2f9ce160b0524a99cb9dfb029ccc10040adbe8282e8cb37ecbc94cc9f689e1a6b2e28a6ca27cd069ef2c6547a5d728f37a3254b5fc501b9cce50d27118c
data/TODO.conformance.md CHANGED
@@ -120,7 +120,7 @@ pinned commit) is measured via `rake conformance:w3c`:
120
120
 
121
121
  | mode | pass-rate |
122
122
  |---|---|
123
- | pipeline | 4563/4696 (97.17%) |
123
+ | pipeline | 4578/4696 (97.49%) |
124
124
 
125
125
  (2026-11-02, bundled-stack measurement with the escape,
126
126
  hint-following, pattern-alternative, identity-crash, engine-set,
@@ -142,7 +142,8 @@ lockfile refresh) → 4411 (93.9%) → 4430 (94.3%) → 4435 (94.4%) →
142
142
  4436 (94.5%) → 4439 (94.5%) → 4454 (94.9%) → 4495 (95.7%) → 4524
143
143
  (96.3%) → 4534 (96.6%) → 4540 (96.7%) → 4542 (96.7%) → 4546 (96.8%)
144
144
  → 4557 (97.0%) → 4565 (97.2%) → 4569 (97.3%) → 4562 (drift raw) →
145
- 4563 (97.17%, ref-namespace round), +602 net over the 1.2.43 baseline. The escapes round (PR #197 — \d→\p{Nd},
145
+ 4563 (97.17%, ref-namespace round) → 4573 (97.38%, group-content
146
+ round) → 4578 (97.49%, attribute-prohibition round), +617 net over the 1.2.43 baseline. The escapes round (PR #197 — \d→\p{Nd},
146
147
  \w/\W/\s to their XSD value spaces) collapses Regex 120→69;
147
148
  same-step pattern facets now behave as alternatives and
148
149
  whitespace-only text reaches the facets intact (PR #200), Regex
@@ -232,15 +233,19 @@ corners.
232
233
 
233
234
  | Class | Cases | Filed |
234
235
  |---|---|---|
235
- | Facade strict re-check on composed schemas — remaining shapes (ctE019 attribute re-check, addB116 substitution admission) | ~8 | moxml#510 (+2 follow-up comments) |
236
- | Redefine self-extension drops the extension content model | 0 — FIXED in leptris 1.9.396 | leptris#1926 (closed) |
237
- | Built-in lexical tables reject valid forms (char-reference shapes; gMonth FIXED 1.9.398, \d legacy-Nd FIXED 1.9.399, stG/stH list lexicals FIXED 1.9.400/401) | ~5 | leptris#1936 |
238
- | Automaton size cap surfaces as an instance error instead of engine-inert silence | 0 — FIXED in leptris 1.9.398 | moxml#514 (follow-up to #473) |
239
- | Regex dialect remainder (engine `\d` vintage, `\p{Is...}` blocks, char-reference shapes) | ~30 | engine-side of leptris#1936 |
240
- | Empty content against a fixed element value rejected (1.9.402.0 regression — isDefault001/002/076, ctZ013a, elemQ019, stE065/066/096, particlesZ040) | 9 | leptris-rs#25 |
236
+ | Empty content against a fixed element value rejected (1.9.402.0 regression — isDefault001/002/076, ctZ013a, elemQ019, stE065/066/096, particlesZ040; attribute leg addB104, attO006) | 15 | leptris-rs#25 (+ case-list comment) |
237
+ | Content model enforced on NILLED elements (QFE1700g2, particlesIa002) | 2 | leptris-rs#26 |
238
+ | dateTime enumeration rejects timezone-equal forms (dateTime_enumeration005a/b) | 2 | leptris-rs#27 |
239
+ | "-0" rejected against nonNegativeInteger (errE002) | 1 | leptris-rs#28 |
240
+ | xsi:type override / substitution-member type binding false rejects (elemZ002, elemZ020) | 2 | leptris-rs#29 |
241
+ | Built-in lexical tables (char-reference shapes reZ003v/004v/005v/006v/006i, reK88; \D vintage reT51) | 7 | leptris/leptris#1936 (+ case-list comment) |
242
+ | Facade strict re-check on composed schemas — attributes (attgD005/019/020/021/022/025/027/029/031/033/034, attJ018) and composed type binding (schF1/G2/G7/P2) | 15 | moxml#510 (+ consolidated evidence comment) |
241
243
  | Unresolvable include/import/redefine schemaLocation raises at parse instead of warning (anyURI_a001/a002, schD7, schG8) | 4 | lutaml-model#1087 |
242
- | Strict wildcard/attribute admission on further compositions | ~5 | moxml#510 thread |
243
- | Ruby-side under-detections (Group 16, DataTypes 7, IC 6, Attribute 5, Additional 4, Particles 4, SimpleType 4, Element 1, Wildcards 1 — minus what the regex round banked) | ~60 | OURS — the remaining Ruby work queue |
244
+ | Automaton size cap surfaces as an instance error instead of engine-inert silence | 0 — FIXED in leptris 1.9.398 | moxml#514 (follow-up to #473) |
245
+ | Redefine self-extension drops the extension content model | 0 — FIXED in leptris 1.9.396 | leptris#1926 (closed) |
246
+ | Ruby-side under-detections and false rejects (Regex dialect 18 — reJ/reS/reDH disputed families excluded; DataTypes corners duration028-030/dateTime013/gMonthDay006/dtZ86723; IC idK/idZ singletons; SimpleType stZ; Wildcards wildZ013; Group/Particles stragglers; Schema schT/D10/F2/errC007; Q032 chameleon import; particlesA013/A002/Ie004/Ig003/Ob012/015/Z001; mgO006/034; attF001/attJ002/003/attLa005; isDefault079) | ~55 | OURS — the remaining Ruby work queue |
247
+
248
+ Engine-attribution census (2026-11-03): of the 123 current failures, 50 carry engine findings (all attributed above), 73 are Ruby-side.
244
249
 
245
250
  Suite history (same-stack where known): 71.8% baseline → 83.5%
246
251
  (pre-NFA) → 83.6% (NFA + guard, 1.2.41) → 84.3% (#180) → 86.0%
@@ -422,6 +427,49 @@ tuple keys, node-set field violations and absent-field rows handled, cross-eleme
422
427
  4569→4562 raw; 4563 with this round. NET -6 documented, not
423
428
  chased — the fixed-empty leg reverses the moment leptris-rs#25
424
429
  lands.
430
+ - GROUP-CONTENT ROUND (2026-11-02): +13 (Group 8→0 — the family is
431
+ CLEAN at 126/126; Particles Db001/Db006 +2, Ea004/011/018 +3; suite
432
+ 4563→4573 = 97.38% on the drifted stack, rspec green). FOUR Ruby
433
+ defects: (a) resolve_group scanned only top-level schemas — a group
434
+ declared in an IMPORTED document (the extension-body shape)
435
+ resolved to nothing (groupB009/010); nested resolved schemas are
436
+ now searched depth-guarded. (b) The NFA wiring missed the
437
+ lutaml-model container classes: a group whose declaration is xs:all
438
+ compiled ORDERED (groupO004), and with_occurrence gave min=0
439
+ particles one phantom extra copy (min=0/max=1 compiled capacity 2 —
440
+ masked for bare elements by the occurrence rule, visible wherever
441
+ nested group bounds lean on this verdict: groupJ003/J006/J008/L008
442
+ accepted forbidden repeats); a max=0 particle compiles to the empty
443
+ fragment (groupH008). (c) type_particles had no complexContent
444
+ restriction branch — a restriction body compiled EMPTY and children
445
+ passed unchecked (groupH008); a restriction replaces the base
446
+ content model. (d) The ref owner-namespace probe resolved ANY
447
+ declaration — including inline ones — so a qualified local namesake
448
+ hijacked unprefixed refs; the probe is GLOBAL-only (resolve_global),
449
+ the unprefixed ref canonically names a no-namespace declaration,
450
+ several distinct prefixed forms of one local name mean deliberate
451
+ disambiguation (no owner guess), and an unresolvable ref matches by
452
+ local name alone. KNOWN GAP left open: particlesQ032 — a
453
+ no-namespace document imported under an EXPLICIT namespace: its
454
+ globals must adopt the import namespace (MS-lenient chameleon
455
+ import; our index keeps them no-namespace so the four e2 refs
456
+ mis-resolve). Zero regressions; the drift entries above remain
457
+ (fixed-empty 9, reU6).
458
+ - ATTRIBUTE-PROHIBITION ROUND (2026-11-03): +5 Attribute (9→4,
459
+ attF001/J002/J003/Z007i under-detections + attLa005 fixed-facet;
460
+ suite 4573→4578 = 97.49%, ZERO regressions, rspec green).
461
+ use=prohibited was never enforced — instances could carry a
462
+ prohibited attribute unchecked. The declaration binds now, with
463
+ THREE suite-arbitrated exemptions for incoherent shapes: a
464
+ wildcard admitting the attribute shadows it (attZ002); fixed +
465
+ prohibited is an illegal combo treated as inert (attP031); a
466
+ prohibited member inside an attributeGroup DEFINITION is illegal
467
+ placement the suite ignores (attZ015). A derived type's OWN
468
+ declaration overrides the base chain's prohibited (attZ007v) while
469
+ a restriction body's own prohibited stands (attZ007i). A ref's
470
+ use-site fixed overlays the global declaration in the value check
471
+ (attLa005). Attribute remainder (4): attJ018 + attO009/011
472
+ (engine-silent — queue), attO006 (leptris-rs#25 attribute leg).
425
473
  - REGEX SEMANTICS ROUND (2026-10-31, PR #208): \w/\W now carry their
426
474
  Appendix F complements (\w = [^\p{P}\p{Z}\p{C}], class interiors
427
475
  via the exact positive partition L/M/N/S), \I/\C complements added
@@ -134,13 +134,13 @@ module Lutaml
134
134
 
135
135
  def compile_particle(particle)
136
136
  case particle
137
- when Lutaml::Xsd::Sequence
137
+ when Lutaml::Xsd::Sequence, Lutaml::Xml::Schema::Xsd::Sequence
138
138
  compile_bounded_container(particle)
139
- when Lutaml::Xsd::Choice
139
+ when Lutaml::Xsd::Choice, Lutaml::Xml::Schema::Xsd::Choice
140
140
  with_occurrence(particle) do
141
141
  compile_choice_particles(@container_particles.call(particle))
142
142
  end
143
- when Lutaml::Xsd::All
143
+ when Lutaml::Xsd::All, Lutaml::Xml::Schema::Xsd::All
144
144
  # The all's own bounds wrap the member bitmask: an optional
145
145
  # all accepts the empty sequence even with mandatory
146
146
  # members (addB31).
@@ -181,10 +181,17 @@ module Lutaml
181
181
  end
182
182
 
183
183
  # A group ref expands to its declaration's container, whose own
184
- # kind (sequence vs choice) decides the wiring.
184
+ # kind (sequence vs choice vs all) decides the wiring. Imported
185
+ # documents parse to the lutaml-model container classes —
186
+ # groupO004's xs:all compiled ORDERED before both All classes
187
+ # were wired to the bitmask.
185
188
  def compile_container_body(container)
186
- if container.is_a?(Lutaml::Xsd::Choice)
189
+ if container.is_a?(Lutaml::Xsd::Choice) ||
190
+ container.is_a?(Lutaml::Xml::Schema::Xsd::Choice)
187
191
  compile_choice_particles(@container_particles.call(container))
192
+ elsif container.is_a?(Lutaml::Xsd::All) ||
193
+ container.is_a?(Lutaml::Xml::Schema::Xsd::All)
194
+ with_occurrence(container) { compile_all(container) }
188
195
  else
189
196
  compile_particles(@container_particles.call(container))
190
197
  end
@@ -235,14 +242,20 @@ module Lutaml
235
242
 
236
243
  # Wraps a body fragment with the particle's occurrence range:
237
244
  # min mandatory copies, then an unbounded loop on one extra copy
238
- # or (max - min) optional copies.
245
+ # or (max - min) optional copies. The first body copy counts
246
+ # toward the bound: with min=0 it IS the first optional copy, so
247
+ # only max-1 more are wired (min=0/max=1 used to compile with
248
+ # capacity 2 — masked for bare elements by the occurrence rule,
249
+ # visible wherever nested group bounds lean on this verdict).
239
250
  def with_occurrence(particle)
240
251
  min = parse_occurs(particle.min_occurs, 1)
241
252
  max = parse_max_occurs(particle.max_occurs, 1)
242
- eff_min = [min, MAX_UNROLL].min
253
+ return empty_fragment if max.is_a?(Integer) && max.zero?
254
+
243
255
  entry, exit_state = yield
244
256
  cursor = exit_state
245
257
  last_entry = entry
258
+ eff_min = [min, MAX_UNROLL].min
246
259
  (eff_min - 1).times do
247
260
  c_entry, c_exit = yield
248
261
  @epsilons[cursor] << c_entry
@@ -259,7 +272,8 @@ module Lutaml
259
272
  @epsilons[exit_state] << final
260
273
  else
261
274
  @epsilons[cursor] << final
262
- (max - min).times do
275
+ optional = max - min - (min.zero? && max.is_a?(Integer) ? 1 : 0)
276
+ optional.times do
263
277
  c_entry, c_exit = yield
264
278
  @epsilons[cursor] << c_entry
265
279
  @epsilons[c_exit] << final
@@ -94,11 +94,24 @@ module Lutaml
94
94
  resolved_attributes,
95
95
  xml_attributes, collector)
96
96
 
97
+ # Step 1b: Check prohibited attributes are absent — the
98
+ # declaration exists, its USE does not (attF001). Three
99
+ # suite-arbitrated exemptions: a wildcard that admits the
100
+ # attribute shadows the prohibition (attZ002); fixed plus
101
+ # prohibited is an illegal declaration combo and the suite
102
+ # treats the whole declaration as inert (attP031); and a
103
+ # prohibition arriving through DERIVATION is refinement
104
+ # incoherence the suite ignores (attZ007v, attZ015).
105
+ validate_prohibited_attributes(xml_element, schema_attributes,
106
+ xml_attributes, collector,
107
+ wildcards: wildcards)
108
+
97
109
  # Step 2: Validate attribute values
98
110
  validate_attribute_values(xml_element, resolved_attributes,
99
111
  xml_attributes, collector,
100
112
  repository: repository,
101
- schema_element: schema_element)
113
+ schema_element: schema_element,
114
+ raw_attrs: schema_attributes)
102
115
 
103
116
  # Step 3: Check for unexpected attributes
104
117
  validate_no_unexpected_attributes(xml_element,
@@ -224,9 +237,35 @@ module Lutaml
224
237
  attributes.concat(inherited_attributes(part.base, repository)) if part
225
238
  end
226
239
 
240
+ override_base_prohibitions(schema_def, attributes)
227
241
  attributes
228
242
  end
229
243
 
244
+ # A derived type's OWN (type-level or derivation-body)
245
+ # non-prohibited declaration for a name overrides the base
246
+ # chain's prohibited (attZ007v: the extension re-adds att1
247
+ # required over Two's prohibited). A restriction body's own
248
+ # prohibited stands against the inherited plain declaration
249
+ # (attZ007i: e2 carrying att1 is invalid).
250
+ #
251
+ # @return [void]
252
+ def override_base_prohibitions(schema_def, attributes)
253
+ own_declarations = Array(schema_def.attribute)
254
+ content = schema_def.complex_content || schema_def.simple_content
255
+ CONTENT_PART_KINDS.each do |kind|
256
+ part = content && Array(content.public_send(kind)).first
257
+ own_declarations += Array(part&.attribute) if part
258
+ end
259
+ own_names = own_declarations
260
+ .reject { |a| a.use.to_s == "prohibited" }
261
+ .map { |a| a.name.to_s }
262
+ attributes.reject! do |a|
263
+ a.use.to_s == "prohibited" &&
264
+ own_names.include?(a.name.to_s) &&
265
+ !own_declarations.include?(a)
266
+ end
267
+ end
268
+
230
269
  # The base type's attribute set flows into the derived type
231
270
  # through extension and restriction alike.
232
271
  #
@@ -275,7 +314,11 @@ module Lutaml
275
314
  return [] unless group
276
315
 
277
316
  own = (group.name || group.ref).to_s.split(":").last
278
- attrs = Array(group.attribute)
317
+ # use=prohibited is illegal inside an attributeGroup
318
+ # DEFINITION (attZ015): the suite ignores such members.
319
+ attrs = Array(group.attribute).reject do |attr|
320
+ attr.use.to_s == "prohibited"
321
+ end
279
322
 
280
323
  # An attributeGroup may reference other attributeGroups —
281
324
  # their declarations flow into the expansion (attgD004). A
@@ -393,6 +436,44 @@ module Lutaml
393
436
  schema_attr.use == "required"
394
437
  end
395
438
 
439
+ # A prohibited attribute is DECLARED but its use is banned:
440
+ # any instance occurrence violates the schema (attF001).
441
+ #
442
+ # @return [void]
443
+ def validate_prohibited_attributes(xml_element, schema_attrs,
444
+ xml_attrs, collector,
445
+ wildcards: [])
446
+ schema_attrs.each do |schema_attr|
447
+ next unless schema_attr.use.to_s == "prohibited"
448
+ # fixed + prohibited is an illegal declaration combo
449
+ # (attP031): the suite treats the declaration as inert.
450
+ next if schema_attr.fixed
451
+
452
+ attr_name = schema_attr.name.to_s
453
+ next if attr_name.empty?
454
+
455
+ xml_attr = xml_attrs.find { |xa| xa.name == attr_name }
456
+ next unless xml_attr
457
+ next if wildcards.any? do |wildcard|
458
+ wildcard_permits_attribute?(wildcard, xml_element, xml_attr,
459
+ nil)
460
+ end
461
+
462
+ report_error(
463
+ collector,
464
+ code: "prohibited_attribute_present",
465
+ message: "Attribute '#{attr_name}' is prohibited here " \
466
+ "but present",
467
+ location: xml_element.xpath,
468
+ context: {
469
+ attribute: attr_name,
470
+ element: xml_element.name,
471
+ },
472
+ suggestion: "Remove attribute: #{attr_name}",
473
+ )
474
+ end
475
+ end
476
+
396
477
  # Validate attribute values
397
478
  #
398
479
  # @param xml_element [XmlElement] The element
@@ -402,14 +483,23 @@ module Lutaml
402
483
  # @return [void]
403
484
  def validate_attribute_values(xml_element, schema_attrs, xml_attrs,
404
485
  collector, repository: nil,
405
- schema_element: nil)
486
+ schema_element: nil, raw_attrs: [])
406
487
  xml_attrs.each do |xml_attr|
407
488
  schema_attr = find_schema_attribute(schema_attrs, xml_attr,
408
489
  repository, schema_element)
409
490
  next unless schema_attr
410
491
 
492
+ # A ref's use site may carry facets the global declaration
493
+ # lacks (attLa005: ref="x:ga1" fixed="abc" at the use
494
+ # site); the instance value is checked against them too.
495
+ use_site = raw_attrs.find do |r|
496
+ r.name == schema_attr.name ||
497
+ r.ref.to_s.split(":").last == schema_attr.name
498
+ end
411
499
  validate_attribute_value(xml_element, xml_attr, schema_attr,
412
- collector, repository: repository)
500
+ collector,
501
+ repository: repository,
502
+ use_site: use_site)
413
503
  end
414
504
  end
415
505
 
@@ -461,10 +551,11 @@ module Lutaml
461
551
  # "123").
462
552
  #
463
553
  # @return [Boolean]
464
- def fixed_value_matches?(value, schema_attr)
554
+ def fixed_value_matches?(value, schema_attr, fixed_override = nil)
555
+ fixed = fixed_override || schema_attr.fixed
465
556
  rule = attribute_whitespace_rule(schema_attr)
466
557
  canonical_lexical(value, rule) ==
467
- canonical_lexical(schema_attr.fixed, rule)
558
+ canonical_lexical(fixed, rule)
468
559
  end
469
560
 
470
561
  # @return [Symbol] :preserve, :replace or :collapse
@@ -499,21 +590,27 @@ module Lutaml
499
590
  # @param collector [ResultCollector] Result collector
500
591
  # @return [void]
501
592
  def validate_attribute_value(xml_element, xml_attr, schema_attr,
502
- collector, repository: nil)
593
+ collector, repository: nil,
594
+ use_site: nil)
503
595
  value = xml_attr.value
596
+ fixed = if use_site && !use_site.fixed.nil?
597
+ use_site.fixed
598
+ else
599
+ schema_attr.fixed
600
+ end
504
601
 
505
602
  # Check fixed value constraint
506
- if schema_attr.fixed
507
- unless fixed_value_matches?(value, schema_attr)
603
+ if fixed
604
+ unless fixed_value_matches?(value, schema_attr, fixed)
508
605
  report_error(
509
606
  collector,
510
607
  code: "fixed_attribute_value_mismatch",
511
608
  message: "Attribute '#{xml_attr.name}' must have fixed " \
512
- "value '#{schema_attr.fixed}'",
609
+ "value '#{fixed}'",
513
610
  location: xml_element.xpath,
514
611
  context: {
515
612
  attribute: xml_attr.name,
516
- expected: schema_attr.fixed,
613
+ expected: fixed,
517
614
  actual: value,
518
615
  },
519
616
  )
@@ -299,6 +299,7 @@ module Lutaml
299
299
  type.complex_content
300
300
  content = type.complex_content
301
301
  extension = Array(content.extension).first
302
+ restriction = Array(content.restriction).first
302
303
  if extension
303
304
  base_local = extension.base.to_s.split(":").last
304
305
  base_type = base_local && repository&.resolve_named_type(base_local)
@@ -311,6 +312,14 @@ module Lutaml
311
312
  particles << particle
312
313
  end
313
314
  end
315
+ elsif restriction
316
+ # A restriction REPLACES the base content model: only
317
+ # the restriction body's particles apply (groupH008's
318
+ # prohibited group ref was previously compiled as an
319
+ # EMPTY model and the children passed unchecked).
320
+ particles = COMPLEX_PARTICLE_KINDS.flat_map do |kind|
321
+ Array(restriction.public_send(kind)).compact
322
+ end
314
323
  end
315
324
  end
316
325
 
@@ -508,12 +517,16 @@ module Lutaml
508
517
 
509
518
  # A qualified ref names a declaration in ONE namespace.
510
519
  # Global names duplicated across namespaces (import + main)
511
- # resolve against the child's namespace today; when the owner
512
- # schema's target namespace declares the same local name, a
513
- # foreign child is an unexpected element (the engine — fed
514
- # the documents one at a time — admits the substitute, so
515
- # this runs on the engine-accept path). Wildcard territory
516
- # is exempt.
520
+ # resolve against the child's namespace today; a foreign
521
+ # child is an unexpected element (the engine — fed the
522
+ # documents one at a time — admits the substitute, so this
523
+ # runs on the engine-accept path). Wildcard territory is
524
+ # exempt. The PREFIX is the signal: an unprefixed ref
525
+ # canonically names a NO-NAMESPACE declaration (Q016 — its
526
+ # qualified local namesake is irrelevant), a prefixed one
527
+ # names some namespace: a child in the opposite territory,
528
+ # or — for the duplicated-name shape — in any namespace but
529
+ # the owner's, cannot match.
517
530
  #
518
531
  # @return [void]
519
532
  def scan_cross_namespace_refs(xml_element, particles, collector,
@@ -526,12 +539,35 @@ module Lutaml
526
539
  end
527
540
  return if refs.empty?
528
541
 
542
+ # Several DISTINCT prefixed forms of one local name mean the
543
+ # schema disambiguates by prefix on purpose (particlesQ032:
544
+ # foo:e2 / x:e2 / bar:e2) — the owner-namespace guess is
545
+ # untenable there.
546
+ single_prefixed_form = refs.select do |r|
547
+ r.ref.to_s.include?(":")
548
+ end.group_by { |r| r.ref.to_s.split(":").last }
549
+ .select { |_, group| group.map { |r| r.ref.to_s.split(":").first }.uniq.size == 1 }
550
+ .map(&:first)
551
+
529
552
  xml_element.children.each do |child|
530
- next if child.namespace_uri.to_s == owner_ns
531
- next unless refs.any? do |r|
532
- r.ref.to_s.split(":").last == child.name
553
+ child_ns = child.namespace_uri.to_s
554
+ next if child_ns == owner_ns
555
+
556
+ hit = refs.find do |r|
557
+ ref = r.ref.to_s
558
+ next false unless ref.split(":").last == child.name
559
+
560
+ if ref.include?(":")
561
+ next false unless single_prefixed_form.include?(child.name)
562
+
563
+ child_ns.empty? ||
564
+ (child_ns != owner_ns &&
565
+ repository.resolve_global(child.name, owner_ns))
566
+ else
567
+ child_ns.match?(/\S/)
568
+ end
533
569
  end
534
- next unless repository.resolve(child.name, owner_ns)
570
+ next unless hit
535
571
  next if wildcard_covers_child?(xml_element, particles, child,
536
572
  repository)
537
573
 
@@ -540,7 +576,7 @@ module Lutaml
540
576
  code: "unexpected_element_in_sequence",
541
577
  message: "Unexpected element '#{child.qualified_name}' " \
542
578
  "in sequence — the reference names the same " \
543
- "declaration in #{owner_ns}",
579
+ "declaration in another namespace",
544
580
  location: child.xpath,
545
581
  context: { element: child.qualified_name },
546
582
  suggestion: "Use the declaration's namespace or rename " \
@@ -563,7 +599,7 @@ module Lutaml
563
599
  return nil unless repository
564
600
 
565
601
  local = particle.ref.to_s.split(":").last
566
- (owner_ns && repository.resolve(local, owner_ns)) ||
602
+ (owner_ns && repository.resolve_global(local, owner_ns)) ||
567
603
  repository.resolve(local, child.namespace_uri) ||
568
604
  repository.resolve(local, nil)
569
605
  end
@@ -156,6 +156,27 @@ module Lutaml
156
156
  candidates.length == 1 ? candidates.first.first : nil
157
157
  end
158
158
 
159
+ # Global (top-level) element declaration only. A ref particle
160
+ # names a GLOBAL declaration; the index also holds inline ones,
161
+ # and a qualified local namesake must never satisfy the
162
+ # owner-namespace probe (particlesQ016: ref "foo" unprefixed vs
163
+ # the qualified local foo inside the restricted type).
164
+ #
165
+ # @param local_name [String]
166
+ # @param namespace_uri [String, nil]
167
+ # @return [Lutaml::Xml::Schema::Xsd::Element, nil]
168
+ def resolve_global(local_name, namespace_uri)
169
+ return nil if local_name.nil? || local_name.empty?
170
+
171
+ candidates = @elements[local_name]
172
+ return nil if candidates.empty?
173
+
174
+ exact = candidates.find do |_element, namespace, kind|
175
+ kind == :top && namespace_matches?(namespace, namespace_uri)
176
+ end
177
+ exact&.first
178
+ end
179
+
159
180
  # Named type for an element's `type="prefix:Name"` reference.
160
181
  #
161
182
  # @param schema_element [Lutaml::Xml::Schema::Xsd::Element, nil]
@@ -386,7 +407,10 @@ module Lutaml
386
407
  end
387
408
 
388
409
  # Named group declaration by local name (for xs:group reference
389
- # expansion in the content and occurrence walks).
410
+ # expansion in the content and occurrence walks). Group refs
411
+ # reach across imports (groupB009: an extension body references
412
+ # a group declared in the imported document), so nested
413
+ # resolved schemas are searched too.
390
414
  #
391
415
  # @param local_name [String]
392
416
  # @return [Lutaml::Xml::Schema::Xsd::Group, nil]
@@ -396,6 +420,25 @@ module Lutaml
396
420
  schemas.each do |schema|
397
421
  found = Array(schema.group).find { |g| g.name == local_name }
398
422
  return found if found
423
+
424
+ found = find_nested_group(local_name, schema)
425
+ return found if found
426
+ end
427
+ nil
428
+ end
429
+
430
+ # @return [Object, nil]
431
+ def find_nested_group(local_name, schema, depth = 0)
432
+ return nil if depth > 8
433
+
434
+ (Array(schema.include) + Array(schema.import)).each do |nested|
435
+ next unless nested.is_a?(Lutaml::Xml::Schema::Xsd::Schema)
436
+
437
+ found = Array(nested.group).find { |g| g.name == local_name }
438
+ return found if found
439
+
440
+ found = find_nested_group(local_name, nested, depth + 1)
441
+ return found if found
399
442
  end
400
443
  nil
401
444
  end
@@ -252,12 +252,17 @@ module Lutaml
252
252
  # owner_ns nil means UNKNOWN (the caller has no owner
253
253
  # context), not "no namespace" — resolve(local, nil) only
254
254
  # matches no-namespace declarations, and a hint-loaded
255
- # document can supply a foreign namesake.
255
+ # document can supply a foreign namesake. The owner probe
256
+ # is GLOBAL-only: a ref names a global declaration, and an
257
+ # inline namesake must not satisfy it.
256
258
  declaration =
257
- (owner_ns && repository&.resolve(local, owner_ns)) ||
259
+ (owner_ns && repository&.resolve_global(local, owner_ns)) ||
258
260
  repository&.resolve(local, child.namespace_uri) ||
259
261
  repository&.resolve(local, nil)
260
- return false unless declaration
262
+ # An unresolvable ref (particlesQ032: a no-namespace document
263
+ # imported under an explicit namespace — its globals index
264
+ # nowhere) is namespace-unconstrained: match by local name.
265
+ return child.name == local unless declaration
261
266
 
262
267
  child.name == declaration.name &&
263
268
  namespaces_match?(child.namespace_uri,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Xsd
5
- VERSION = "1.2.80"
5
+ VERSION = "1.2.82"
6
6
  end
7
7
  end
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.80
4
+ version: 1.2.82
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-11-02 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: liquid