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 +4 -4
- data/Gemfile.lock +3 -3
- data/README.adoc +22 -0
- data/TODO.conformance.md +22 -7
- data/docs/CONFORMANCE.adoc +11 -3
- data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +16 -3
- data/lib/lutaml/xsd/validation/rules/occurrence_validation_rule.rb +27 -4
- data/lib/lutaml/xsd/validation/schema_definition_resolver.rb +23 -1
- data/lib/lutaml/xsd/validation/unique_particle_attribution.rb +345 -0
- data/lib/lutaml/xsd/validation/validation_job.rb +29 -0
- data/lib/lutaml/xsd/validation/validation_rule.rb +17 -0
- data/lib/lutaml/xsd/version.rb +1 -1
- data/lutaml-xsd.gemspec +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 689df8d8930b23eb8ca6b52c4d435c0e52ea9d654bf64cc51b546fbfe2fcb69e
|
|
4
|
+
data.tar.gz: 0e58e44689241fdd080621539f438a3f64d7dad94ca55ff72f06740b93320be9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
4
|
+
lutaml-xsd (1.2.2)
|
|
5
5
|
liquid (>= 4.0, < 6.0)
|
|
6
|
-
lutaml-model (~> 0.8.
|
|
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.
|
|
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-
|
|
6
|
+
## Baseline (2026-10-23, 21 cases / 45 checks, full pass)
|
|
7
7
|
|
|
8
|
-
Corpus:
|
|
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) |
|
|
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
|
-
- [
|
|
93
|
-
|
|
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)
|
data/docs/CONFORMANCE.adoc
CHANGED
|
@@ -32,9 +32,17 @@ Two modes are measured independently:
|
|
|
32
32
|
|
|
33
33
|
## Baseline and gap register
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
data/lib/lutaml/xsd/version.rb
CHANGED
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.
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|