lutaml-xsd 1.1.13 → 1.2.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CLAUDE.md +18 -1
  4. data/Gemfile +2 -1
  5. data/Gemfile.lock +327 -0
  6. data/Rakefile +78 -1
  7. data/TODO.VISION.md +123 -0
  8. data/TODO.cleanup.md +43 -0
  9. data/TODO.conformance.md +99 -0
  10. data/TODO.docs-spa.md +46 -0
  11. data/TODO.interop.md +52 -0
  12. data/TODO.performance.md +80 -0
  13. data/TODO.som.md +47 -0
  14. data/benchmark/engines.rb +77 -0
  15. data/config/validation.yml +4 -0
  16. data/docs/CONFORMANCE.adoc +43 -0
  17. data/docs/JSON_SCHEMA.adoc +42 -0
  18. data/docs/superpowers/plans/2026-10-20-moxml-leptris-migration.md +735 -0
  19. data/docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md +169 -0
  20. data/docs/superpowers/specs/2026-10-21-rule-engine-wiring-design.md +84 -0
  21. data/lib/lutaml/xsd/conformance/runner.rb +169 -0
  22. data/lib/lutaml/xsd/json_schema_exporter.rb +240 -0
  23. data/lib/lutaml/xsd/package_builder.rb +2 -2
  24. data/lib/lutaml/xsd/spa/schema_serializer.rb +12 -7
  25. data/lib/lutaml/xsd/validation/engine_context.rb +190 -0
  26. data/lib/lutaml/xsd/validation/rule_engine.rb +36 -18
  27. data/lib/lutaml/xsd/validation/rule_registry.rb +34 -0
  28. data/lib/lutaml/xsd/validation/rules/attribute_validation_rule.rb +126 -17
  29. data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +304 -194
  30. data/lib/lutaml/xsd/validation/rules/element_structure_rule.rb +25 -15
  31. data/lib/lutaml/xsd/validation/rules/identity_constraints_rule.rb +134 -0
  32. data/lib/lutaml/xsd/validation/rules/occurrence_validation_rule.rb +76 -25
  33. data/lib/lutaml/xsd/validation/rules/type_validation_rule.rb +134 -17
  34. data/lib/lutaml/xsd/validation/schema_definition_resolver.rb +254 -0
  35. data/lib/lutaml/xsd/validation/validation_configuration.rb +1 -0
  36. data/lib/lutaml/xsd/validation/validation_job.rb +70 -3
  37. data/lib/lutaml/xsd/validation/validation_rule.rb +44 -5
  38. data/lib/lutaml/xsd/validation/validator.rb +2 -1
  39. data/lib/lutaml/xsd/validation/xml_element.rb +2 -2
  40. data/lib/lutaml/xsd/version.rb +1 -1
  41. data/lib/lutaml/xsd.rb +14 -0
  42. data/lutaml-xsd.gemspec +2 -2
  43. metadata +27 -8
@@ -0,0 +1,735 @@
1
+ # Moxml/leptris Migration + Moxml::XSD Engine Integration — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Eliminate the last direct Nokogiri usage in lutaml-xsd, run the dev/CI stack on the Leptris engine, and integrate `Moxml::XSD` engine validation as an additive fast-path with lexical/content-model delegation.
6
+
7
+ **Architecture:** Two PRs. PR 1 (`feat/moxml-port-leptris`) ports `create_type_wrapper_xsd`/`resolve_type_prefix` to Moxml behind a characterization spec, then swaps the Gemfile to leptris. PR 2 (`feat/moxml-xsd-engine-validation`) adds `Validation::EngineContext` (compiled `Moxml::XSD::Schema` handles + capability probe + finding mapping), wires it into `ValidationJob` as a config-gated phase, and delegates lexical/content checks inside `TypeValidationRule`/`ContentModelValidationRule` via `options[:engine_context]`.
8
+
9
+ **Tech Stack:** Ruby >= 3.0, moxml ~> 0.5.147 (`Moxml::XSD`, leptris adapter), leptris (Gemfile dev-only), RSpec.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md` (posted as lutaml/lutaml-xsd#105)
12
+
13
+ ## Global Constraints
14
+
15
+ - NEVER use `double()` in specs — real objects only (standing rule). Engine specs use real leptris; they skip when `Moxml::XSD.supported?` is false.
16
+ - NEVER call private methods via `send`. Both ported serializer methods are public — spec them directly.
17
+ - Engine findings are ADDITIVE (`code: "engine_validation"`); the Ruby rule engine stays authoritative. Never replace Ruby rules with engine results.
18
+ - `leptris` is Gemfile (dev) ONLY. The gemspec gains NO adapter dependencies. Do not touch gemspec in either PR.
19
+ - No commits to main; no AI attribution in commits; stage files by explicit path only; PR bodies via `--body-file`.
20
+ - Config flag name is exactly `engine_validation`, nested at `validation.features.engine_validation`; default enabled (`feature_enabled?` returns true unless explicitly false).
21
+ - Full-suite gates: `bundle exec rspec` (expect 0 failures) and `bundle exec rubocop` before every push.
22
+
23
+ ---
24
+
25
+ ## PR 1 — `feat/moxml-port-leptris`
26
+
27
+ ### Task 1: Characterization spec for the wrapper-XSD methods
28
+
29
+ **Files:**
30
+ - Create: `spec/lutaml/xsd/spa/schema_serializer_wrapper_spec.rb`
31
+
32
+ **Interfaces:**
33
+ - Consumes: `Lutaml::Xsd::Spa::SchemaSerializer.new(repository_or_package, config = {})` (repository unused by the methods under test — pass `nil`), `#create_type_wrapper_xsd(type_name, original_file_path) -> String (tempfile path)`, `#resolve_type_prefix(type_name, doc, ns) -> String`.
34
+ - Produces: the fixture path helper `write_schema_xsd` and expected semantics that Task 2's port must preserve verbatim.
35
+
36
+ - [ ] **Step 1: Write the characterization spec**
37
+
38
+ ```ruby
39
+ # frozen_string_literal: true
40
+
41
+ require "spec_helper"
42
+ require "moxml"
43
+ require "tempfile"
44
+
45
+ RSpec.describe Lutaml::Xsd::Spa::SchemaSerializer do
46
+ subject(:serializer) { described_class.new(nil, { verbose: false }) }
47
+
48
+ let(:schema_xml) do
49
+ <<~XML
50
+ <?xml version="1.0" encoding="UTF-8"?>
51
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
52
+ xmlns:tns="http://example.com/test"
53
+ targetNamespace="http://example.com/test">
54
+ <xs:complexType name="Address">
55
+ <xs:sequence>
56
+ <xs:element name="street" type="xs:string"/>
57
+ </xs:sequence>
58
+ </xs:complexType>
59
+ <xs:element name="address" type="tns:Address"/>
60
+ </xs:schema>
61
+ XML
62
+ end
63
+
64
+ let(:schema_path) do
65
+ file = Tempfile.new(["characterization", ".xsd"])
66
+ file.write(schema_xml)
67
+ file.close
68
+ file.path
69
+ end
70
+
71
+ def wrapper_document(path)
72
+ Moxml::Context.new.parse(File.read(path))
73
+ end
74
+
75
+ describe "#create_type_wrapper_xsd" do
76
+ it "wraps a schema-level type in a synthetic root element" do
77
+ wrapper_path = serializer.create_type_wrapper_xsd("Address", schema_path)
78
+ doc = wrapper_document(wrapper_path)
79
+
80
+ expect(doc.root.name).to eq("schema")
81
+ added = doc.xpath("//element").map(&:name)
82
+ expect(added).to include("_diagram_root_Address")
83
+ expect(File.read(wrapper_path)).to include("complexType")
84
+ expect(File.read(wrapper_path)).to include('name="street"')
85
+ end
86
+
87
+ it "returns the original path when no xs:schema exists" do
88
+ no_schema = Tempfile.new(["no-schema", ".xsd"])
89
+ no_schema.write("<not-a-schema/>")
90
+ no_schema.close
91
+
92
+ expect(serializer.create_type_wrapper_xsd("Address", no_schema.path))
93
+ .to eq(no_schema.path)
94
+ ensure
95
+ no_schema.unlink
96
+ end
97
+ end
98
+
99
+ describe "#resolve_type_prefix" do
100
+ it "prefixes types defined in the schema's targetNamespace" do
101
+ doc = Moxml::Context.new.parse(schema_xml)
102
+ ns = { "xs" => "http://www.w3.org/2001/XMLSchema" }
103
+
104
+ expect(serializer.resolve_type_prefix("Address", doc, ns))
105
+ .to eq("tns:Address")
106
+ end
107
+
108
+ it "returns the bare name for unknown types" do
109
+ doc = Moxml::Context.new.parse(schema_xml)
110
+ ns = { "xs" => "http://www.w3.org/2001/XMLSchema" }
111
+
112
+ expect(serializer.resolve_type_prefix("xs:string", doc, ns))
113
+ .to eq("xs:string")
114
+ end
115
+ end
116
+ end
117
+ ```
118
+
119
+ - [ ] **Step 2: Run it against the current (Nokogiri) implementation**
120
+
121
+ Run: `bundle exec rspec spec/lutaml/xsd/spa/schema_serializer_wrapper_spec.rb`
122
+ Expected: PASS (this characterizes today's behavior). If a semantic assertion fails, the assertion is wrong — fix the spec, not the code. Do not proceed until green.
123
+
124
+ - [ ] **Step 3: Commit**
125
+
126
+ ```bash
127
+ git add spec/lutaml/xsd/spa/schema_serializer_wrapper_spec.rb
128
+ git commit -m "Add characterization spec for wrapper XSD generation"
129
+ ```
130
+
131
+ ### Task 2: Port `create_type_wrapper_xsd` and `resolve_type_prefix` to Moxml
132
+
133
+ **Files:**
134
+ - Modify: `lib/lutaml/xsd/spa/schema_serializer.rb:8` (delete `require "nokogiri"`), `:503-570` (the two methods)
135
+
136
+ **Interfaces:**
137
+ - Consumes: Moxml `Context#parse`, `Document#at_xpath(expr, ns_hash)`, `Document#create_element(name)`, `Element#namespace_definitions` (`#href`, `#prefix`), `Element#namespace=(ns_or_hash)`, `Node#add_child`, `Document#to_xml`.
138
+ - Produces: identical behavior to Task 1's characterization spec.
139
+
140
+ - [ ] **Step 1: Replace the Nokogiri implementation**
141
+
142
+ In `create_type_wrapper_xsd` (keep the method contract and all comments about xsdvi):
143
+
144
+ ```ruby
145
+ def create_type_wrapper_xsd(type_name, original_file_path)
146
+ doc = Moxml::Context.new.parse(File.read(original_file_path))
147
+ ns = { "xs" => "http://www.w3.org/2001/XMLSchema" }
148
+ schema = doc.at_xpath("//xs:schema", ns)
149
+
150
+ unless schema
151
+ warn "xsdvi: No xs:schema found in '#{original_file_path}'" if config[:verbose]
152
+ return original_file_path
153
+ end
154
+
155
+ wrapper_name = "_diagram_root_#{type_name}"
156
+ type_ref = resolve_type_prefix(type_name, doc, ns)
157
+
158
+ xsd_ns = "http://www.w3.org/2001/XMLSchema"
159
+ xsd_ns_decl = schema.namespace_definitions.find do |nd|
160
+ nd.href == xsd_ns
161
+ end
162
+
163
+ element_node = doc.create_element("element")
164
+ element_node.namespace = xsd_ns_decl if xsd_ns_decl
165
+ element_node["name"] = wrapper_name
166
+ element_node["type"] = type_ref
167
+
168
+ schema.add_child(element_node)
169
+
170
+ tmp = Tempfile.new(["xsdvi-wrapper-", ".xsd"])
171
+ tmp.write(doc.to_xml)
172
+ tmp.close
173
+ tmp.path
174
+ end
175
+ ```
176
+
177
+ In `resolve_type_prefix` the body is already namespace-API-compatible; verify `schema["targetNamespace"]`, `doc.xpath(...)` and `schema.namespace_definitions` work unchanged (they do in Moxml). Adjust only if a call site raises.
178
+
179
+ - [ ] **Step 2: Remove `require "nokogiri"` (line 8) and verify no Nokogiri references remain in lib/**
180
+
181
+ Run: `grep -rn "Nokogiri\|nokogiri" lib/ | grep -v "\.bak"`
182
+ Expected: only the two historical comments in `package_builder.rb:70,204` mentioning Nokogiri in prose. Update those comment strings to say "Moxml nodes" / "XML nodes" since they describe XmlElement contents.
183
+
184
+ - [ ] **Step 3: Run the characterization spec, then the full suite**
185
+
186
+ Run: `bundle exec rspec spec/lutaml/xsd/spa/schema_serializer_wrapper_spec.rb && bundle exec rspec`
187
+ Expected: PASS everywhere (suite currently runs on the Nokogiri moxml adapter — leptris is not bundled yet).
188
+
189
+ - [ ] **Step 4: Commit**
190
+
191
+ ```bash
192
+ git add lib/lutaml/xsd/spa/schema_serializer.rb
193
+ git commit -m "Port wrapper XSD generation from Nokogiri to Moxml"
194
+ ```
195
+
196
+ ### Task 3: Bundle leptris, drop nokogiri from the Gemfile
197
+
198
+ **Files:**
199
+ - Modify: `Gemfile` (remove `gem "nokogiri"`, add `gem "leptris"`)
200
+
201
+ **Interfaces:**
202
+ - Consumes: moxml default adapter selection (leptris when `Leptris::XML::Document.create` responds).
203
+ - Produces: bundle resolves leptris; `Moxml.new.config.adapter_name == :leptris`.
204
+
205
+ - [ ] **Step 1: Edit the Gemfile** — delete the `gem "nokogiri"` line, add `gem "leptris"` in its alphabetical position.
206
+
207
+ - [ ] **Step 2: Refresh the lockfile and verify the adapter**
208
+
209
+ Run: `bundle install && bundle exec ruby -e 'require "moxml"; puts Moxml.new.config.adapter_name'`
210
+ Expected: `:leptris` (nokogiri remains in the lockfile only via xsdvi `~> 1.16`).
211
+
212
+ - [ ] **Step 3: Run the FULL suite on the leptris engine**
213
+
214
+ Run: `bundle exec rspec`
215
+ Expected: 0 failures. This is the load-bearing verification — every parse/serialize/XPath in the suite now executes on leptris via moxml. Any failure is a moxml-adapter behavior difference: fix by adjusting the code (not by weakening specs), and if a genuine moxml/leptris defect appears, stop and report it instead of working around it.
216
+
217
+ - [ ] **Step 4: Commit, push, open PR**
218
+
219
+ ```bash
220
+ git add Gemfile
221
+ git commit -m "Run development bundle on leptris, drop direct nokogiri"
222
+ git push -u origin feat/moxml-port-leptris
223
+ printf 'Completes the Moxml migration (workstreams A+B of #105):\n\n- Port `create_type_wrapper_xsd`/`resolve_type_prefix` from Nokogiri to Moxml (characterization-gated)\n- No direct Nokogiri usage remains in lib/ (xsdvi keeps it in the lockfile transitively)\n- Gemfile: leptris in, nokogiri out — dev/CI runs the moxml default adapter chain on leptris\n- Full suite green on the leptris engine\n' > /tmp/pr1-body.md
224
+ gh pr create --title "Complete Moxml migration and run on leptris" --body-file /tmp/pr1-body.md
225
+ ```
226
+
227
+ ### Task 4: CLAUDE.md — document the Moxml-only XML stack
228
+
229
+ **Files:**
230
+ - Modify: `CLAUDE.md` (new section after "Key Patterns")
231
+
232
+ - [ ] **Step 1: Add the section**
233
+
234
+ ```markdown
235
+ ### XML Stack: Moxml Only
236
+
237
+ All XML handling goes through `Moxml::Context` (moxml ~> 0.5.147). Never
238
+ require or call Nokogiri/REXML/Ox directly in `lib/` — xsdvi keeps nokogiri
239
+ in the lockfile transitively, but our code does not touch it.
240
+
241
+ - Adapter selection is moxml's: leptris when bundled (dev Gemfile), nokogiri
242
+ fallback otherwise. No adapter is hardcoded here.
243
+ - `Moxml::XSD` (engine XSD validation, tier-1: leptris/nokogiri) backs the
244
+ validation fast-path — see `lib/lutaml/xsd/validation/engine_context.rb`.
245
+ Engine findings are additive (`code: "engine_validation"`); the Ruby rule
246
+ engine remains authoritative because tier-1 validates laxly outside its
247
+ scope.
248
+ ```
249
+
250
+ - [ ] **Step 2: Commit on the PR branch and push**
251
+
252
+ ```bash
253
+ git add CLAUDE.md
254
+ git commit -m "Document Moxml-only XML stack in CLAUDE.md"
255
+ git push
256
+ ```
257
+
258
+ ---
259
+
260
+ ## PR 2 — `feat/moxml-xsd-engine-validation`
261
+
262
+ Branch from updated `main` after PR 1 merges.
263
+
264
+ ### Task 5: `Validation::EngineContext`
265
+
266
+ **Files:**
267
+ - Create: `lib/lutaml/xsd/validation/engine_context.rb`
268
+ - Create: `spec/lutaml/xsd/validation/engine_context_spec.rb`
269
+ - Modify: `lib/lutaml/xsd/validation/validator.rb` (autoload-adjacent require of the new file at top, following its existing `require_relative` block)
270
+
271
+ **Interfaces:**
272
+ - Produces (consumed by Tasks 6-8):
273
+ - `EngineContext.compile(repository, context: Moxml::Context.new) -> EngineContext or nil` — nil when `Moxml::XSD.supported?(context)` is false.
274
+ - `#supported? -> Boolean` (true when at least one schema compiled)
275
+ - `#findings_for(xml_content) -> Array<Lutaml::Xsd::Validation::ValidationError>` with `code: "engine_validation"`, `line_number:` from the engine finding, `location:` from `path`.
276
+ - `#builtin_valid?(builtin_name, lexical) -> true/false/nil` (nil = engine could not answer; caller falls back)
277
+ - `#simple_valid?(type_name, lexical) -> true/false/nil`
278
+ - `#content_valid?(element_name, children) -> true/false/nil`
279
+ - `ENGINE_CODE = "engine_validation"`
280
+
281
+ - [ ] **Step 1: Write the failing spec** (skip-gated shared context; real leptris)
282
+
283
+ ```ruby
284
+ # frozen_string_literal: true
285
+
286
+ require "spec_helper"
287
+ require "moxml"
288
+
289
+ RSpec.describe Lutaml::Xsd::Validation::EngineContext do
290
+ let(:context) { Moxml::Context.new }
291
+ let(:xsd_source) do
292
+ <<~XML
293
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
294
+ <xs:element name="note" type="xs:string"/>
295
+ </xs:schema>
296
+ XML
297
+ end
298
+ let(:repository) do
299
+ instance = nil
300
+ schema = Lutaml::Xml::Schema::Xsd.parse(xsd_source)
301
+ instance = Class.new do
302
+ define_method(:all_schemas) { { "http://example.com/test" => schema } }
303
+ end.new
304
+ instance
305
+ end
306
+
307
+ def compile
308
+ described_class.compile(repository, context: context)
309
+ end
310
+
311
+ it "compiles when the adapter ships an XSD validator", if: Moxml::XSD.supported?(Moxml::Context.new) do
312
+ engine = compile
313
+ expect(engine).to be_a(described_class)
314
+ expect(engine).to be_supported
315
+ end
316
+
317
+ it "returns nil on adapters without an XSD validator", unless: Moxml::XSD.supported?(Moxml::Context.new) do
318
+ expect(compile).to be_nil
319
+ end
320
+
321
+ it "maps engine findings to Validation::ValidationError", if: Moxml::XSD.supported?(Moxml::Context.new) do
322
+ engine = compile
323
+ findings = engine.findings_for("<note>12</note>")
324
+ expect(findings).to all(be_a(Lutaml::Xsd::Validation::ValidationError))
325
+ expect(findings.map(&:code)).to all(eq(described_class::ENGINE_CODE))
326
+ expect(findings).to all(be_error)
327
+ end
328
+
329
+ it "reports no findings for valid documents", if: Moxml::XSD.supported?(Moxml::Context.new) do
330
+ engine = compile
331
+ expect(engine.findings_for("<note>hello</note>")).to be_empty
332
+ end
333
+ end
334
+ ```
335
+
336
+ Note: `repository` here is an anonymous-class stand-in exposing the one method EngineContext consumes (`all_schemas` — a Hash of `Lutaml::Xml::Schema::Xsd::Schema` keyed by namespace, values serialized with `to_xml`). It is a real object, not a double. `Moxml::XSD.supported?` raises on adapters without a compiled validator constant, so guard: `defined?(Moxml::XSD) && (Moxml::XSD.supported?(ctx) rescue false)` if the plain call raises in the `if:`/`unless:` guards.
337
+
338
+ - [ ] **Step 2: Run to verify failure**
339
+
340
+ Run: `bundle exec rspec spec/lutaml/xsd/validation/engine_context_spec.rb`
341
+ Expected: FAIL — `LoadError`/`NameError`, class does not exist.
342
+
343
+ - [ ] **Step 3: Implement EngineContext**
344
+
345
+ ```ruby
346
+ # frozen_string_literal: true
347
+
348
+ require "moxml"
349
+
350
+ module Lutaml
351
+ module Xsd
352
+ module Validation
353
+ # EngineContext adapts Moxml::XSD (engine-native XSD validation on
354
+ # leptris/nokogiri) to lutaml-xsd's validation stack.
355
+ #
356
+ # Engine findings are additive: tier-1 engine scope validates laxly
357
+ # outside named+typed top-level elements / one-level content-model
358
+ # descent / lexical checks, so the Ruby rule engine stays
359
+ # authoritative. All query methods return nil (not false) when the
360
+ # engine cannot answer, and callers fall back to Ruby logic.
361
+ class EngineContext
362
+ ENGINE_CODE = "engine_validation"
363
+
364
+ attr_reader :context, :schemas
365
+
366
+ class << self
367
+ # Compile repository schemas for the current adapter.
368
+ #
369
+ # @param repository [#all_schemas] Schema repository
370
+ # @param context [Moxml::Context] Adapter context
371
+ # @return [EngineContext, nil] nil when the adapter has no engine
372
+ def compile(repository, context: Moxml::Context.new)
373
+ return nil unless xsd_supported?(context)
374
+
375
+ schemas = repository.all_schemas.values.filter_map do |schema|
376
+ compile_schema(context, schema)
377
+ end
378
+ new(context, schemas)
379
+ end
380
+
381
+ def xsd_supported?(context)
382
+ Moxml::XSD.supported?(context)
383
+ rescue Moxml::NotImplementedError, NameError
384
+ false
385
+ end
386
+
387
+ # Map a Moxml::XSD::ValidationError into the lutaml-xsd error type
388
+ def map_finding(finding)
389
+ ValidationError.new(
390
+ code: ENGINE_CODE,
391
+ message: finding.message,
392
+ severity: :error,
393
+ location: finding.path,
394
+ line_number: finding.line,
395
+ context: { column: finding.column }.compact,
396
+ )
397
+ end
398
+
399
+ private
400
+
401
+ def compile_schema(context, schema)
402
+ compiled = context.xsd(schema.to_xml)
403
+ compiled.compile_error ? nil : compiled
404
+ rescue Moxml::NotImplementedError, Moxml::ParseError, StandardError
405
+ nil
406
+ end
407
+ end
408
+
409
+ def initialize(context, schemas)
410
+ @context = context
411
+ @schemas = schemas
412
+ end
413
+
414
+ def supported?
415
+ !@schemas.empty?
416
+ end
417
+
418
+ # Validate raw XML against all compiled schemas.
419
+ #
420
+ # @param xml_content [String]
421
+ # @return [Array<Validation::ValidationError>]
422
+ def findings_for(xml_content)
423
+ @schemas.flat_map { |schema| schema.validate(xml_content) }
424
+ .map { |finding| self.class.map_finding(finding) }
425
+ end
426
+
427
+ # Lexical check against the built-in type table ("xs:NAME" spelling).
428
+ # @return [Boolean, nil] nil when the engine cannot answer
429
+ def builtin_valid?(builtin_name, lexical)
430
+ Moxml::XSD.builtin_valid?(@context, builtin_name, lexical)
431
+ rescue ArgumentError, Moxml::NotImplementedError, StandardError
432
+ nil
433
+ end
434
+
435
+ # Lexical check against a schema's named simpleType.
436
+ # @return [Boolean, nil] nil when no compiled schema knows the type
437
+ def simple_valid?(type_name, lexical)
438
+ @schemas.each do |schema|
439
+ answer = schema.simple_valid?(type_name, lexical)
440
+ return answer
441
+ rescue StandardError
442
+ next
443
+ end
444
+ nil
445
+ end
446
+
447
+ # Content-model check for one element's children (Moxml nodes).
448
+ # @return [Boolean, nil] nil when the engine cannot answer
449
+ def content_valid?(element_name, children)
450
+ @schemas.each do |schema|
451
+ answer = schema.content_valid?(element_name, children)
452
+ return answer
453
+ rescue StandardError
454
+ next
455
+ end
456
+ nil
457
+ end
458
+ end
459
+ end
460
+ end
461
+ end
462
+ ```
463
+
464
+ Add `require_relative "engine_context"` to the top require block of `lib/lutaml/xsd/validation/validator.rb`.
465
+
466
+ - [ ] **Step 4: Run the spec — green**
467
+
468
+ Run: `bundle exec rspec spec/lutaml/xsd/validation/engine_context_spec.rb`
469
+ Expected: PASS (leptris bundled → compile/tests run on real engine).
470
+
471
+ - [ ] **Step 5: Commit**
472
+
473
+ ```bash
474
+ git add lib/lutaml/xsd/validation/engine_context.rb lib/lutaml/xsd/validation/validator.rb spec/lutaml/xsd/validation/engine_context_spec.rb
475
+ git commit -m "Add EngineContext bridging Moxml::XSD engine validation"
476
+ ```
477
+
478
+ ### Task 6: ValidationJob engine phase + `engine_validation` feature flag
479
+
480
+ **Files:**
481
+ - Modify: `lib/lutaml/xsd/validation/validation_job.rb` (`execute` + new private method)
482
+ - Modify: `config/validation.yml:29` (`features:` — add `engine_validation: true`)
483
+ - Modify: `lib/lutaml/xsd/validation/validation_configuration.rb` (`default_config_hash` features hash — add `"engine_validation" => true`)
484
+ - Test: `spec/lutaml/xsd/validation/validation_job_spec.rb` (extend)
485
+
486
+ **Interfaces:**
487
+ - Consumes: `EngineContext.compile(repository)` / `#findings_for(xml_content)` / `#supported?` (Task 5); `ResultCollector#add_error`.
488
+ - Produces: engine findings appear in `ValidationResult` with `code: "engine_validation"`; disable via `features: { engine_validation: false }`.
489
+
490
+ - [ ] **Step 1: Write the failing spec** (reuse Task 5's real-schema fixture style; build the job with a real repository stand-in)
491
+
492
+ ```ruby
493
+ describe "engine validation phase" do
494
+ let(:engine_supported) do
495
+ Lutaml::Xsd::Validation::EngineContext.xsd_supported?(Moxml::Context.new)
496
+ end
497
+
498
+ it "adds engine findings for invalid documents", if: engine_supported do
499
+ job = described_class.new(
500
+ xml_content: "<note>12</note>",
501
+ repository: repository,
502
+ rule_registry: rule_registry,
503
+ config: Lutaml::Xsd::Validation::ValidationConfiguration.default,
504
+ )
505
+ result = job.execute
506
+ codes = result.errors.map(&:code)
507
+ expect(codes).to include("engine_validation")
508
+ end
509
+
510
+ it "skips engine validation when the feature is disabled", if: engine_supported do
511
+ config = Lutaml::Xsd::Validation::ValidationConfiguration.new(
512
+ { "validation" => { "features" => { "engine_validation" => false } } },
513
+ )
514
+ job = described_class.new(
515
+ xml_content: "<note>12</note>",
516
+ repository: repository,
517
+ rule_registry: rule_registry,
518
+ config: config,
519
+ )
520
+ result = job.execute
521
+ expect(result.errors.map(&:code)).not_to include("engine_validation")
522
+ end
523
+
524
+ it "adds no engine findings for valid documents", if: engine_supported do
525
+ job = described_class.new(
526
+ xml_content: "<note>hello</note>",
527
+ repository: repository,
528
+ rule_registry: rule_registry,
529
+ config: Lutaml::Xsd::Validation::ValidationConfiguration.default,
530
+ )
531
+ result = job.execute
532
+ expect(result.errors.map(&:code)).not_to include("engine_validation")
533
+ end
534
+ end
535
+ ```
536
+
537
+ (`rule_registry` = `Lutaml::Xsd::Validation::RuleRegistry.new(config)` — the default pipeline registers no rules, matching `Validator#build_rule_registry`.)
538
+
539
+ - [ ] **Step 2: Run — expect FAIL** (`engine_validation` code never appears).
540
+
541
+ - [ ] **Step 3: Wire the phase into `execute`** — insert after `parse_xml` / before `validate_structure`:
542
+
543
+ ```ruby
544
+ run_engine_validation if @config.feature_enabled?(:engine_validation)
545
+ ```
546
+
547
+ Private method:
548
+
549
+ ```ruby
550
+ # Run engine-native XSD validation (Moxml::XSD) when the adapter
551
+ # supports it. Findings are additive to rule findings.
552
+ def run_engine_validation
553
+ @engine_context ||= EngineContext.compile(@repository)
554
+ return unless @engine_context&.supported?
555
+
556
+ @engine_context.findings_for(@xml_content).each do |finding|
557
+ @result_collector.add_error(finding)
558
+ end
559
+ rescue StandardError => e
560
+ @result_collector.add_warning(
561
+ ValidationError.new(
562
+ code: "engine_validation_unavailable",
563
+ message: "Engine validation skipped: #{e.message}",
564
+ severity: :warning,
565
+ ),
566
+ )
567
+ end
568
+ ```
569
+
570
+ - [ ] **Step 4: Add the flag to `config/validation.yml`** under `features:` and to `default_config_hash`'s features hash: `"engine_validation" => true`.
571
+
572
+ - [ ] **Step 5: Run job spec + config spec, then full suite**
573
+
574
+ Run: `bundle exec rspec spec/lutaml/xsd/validation/ && bundle exec rspec`
575
+ Expected: PASS.
576
+
577
+ - [ ] **Step 6: Commit**
578
+
579
+ ```bash
580
+ git add lib/lutaml/xsd/validation/validation_job.rb lib/lutaml/xsd/validation/validation_configuration.rb config/validation.yml spec/lutaml/xsd/validation/validation_job_spec.rb
581
+ git commit -m "Run Moxml::XSD engine validation as an additive ValidationJob phase"
582
+ ```
583
+
584
+ ### Task 7: Lexical delegation in `TypeValidationRule`
585
+
586
+ **Files:**
587
+ - Modify: `lib/lutaml/xsd/validation/rules/type_validation_rule.rb`
588
+ - Test: `spec/lutaml/xsd/validation/rules/type_validation_rule_spec.rb` (extend or create)
589
+
590
+ **Interfaces:**
591
+ - Consumes: `EngineContext#builtin_valid?("xs:string", lexical) -> true/false/nil`, `#simple_valid?(type_name, lexical)`, `#supported?` (Task 5). Rules receive the engine via constructor options: `TypeValidationRule.new(engine_context: engine)`.
592
+ - Produces: delegation only; `validate(xml_element, schema_element, collector)` signature unchanged; nil answers fall back to existing Ruby validators.
593
+
594
+ - [ ] **Step 1: Failing spec** — validate `<note>12</note>` against `type="xs:string"` with a real compiled EngineContext (Task 5 fixture): expect the engine path to answer and a Ruby-registry validator NOT to be consulted. Assert observable behavior: error/no-error outcome identical to the Ruby path for valid and invalid lexical values, and that constructing the rule with `engine_context:` + forcing `engine.builtin_valid?` to receive the call is NOT asserted (no interaction testing). Concrete assertions:
595
+
596
+ ```ruby
597
+ it "accepts valid lexical values via the engine path" do
598
+ rule = described_class.new(engine_context: engine)
599
+ expect { rule.validate(element_with("hello"), xs_string_element, collector) }
600
+ .not_to change { collector.errors.count }
601
+ end
602
+
603
+ it "rejects invalid lexical values via the engine path" do
604
+ rule = described_class.new(engine_context: engine)
605
+ rule.validate(element_with(12), xs_string_element, collector)
606
+ expect(collector.errors.map(&:code)).to include(/type/)
607
+ end
608
+ ```
609
+
610
+ - [ ] **Step 2: Run — FAIL** (rule ignores `engine_context`).
611
+
612
+ - [ ] **Step 3: Implement delegation** — in `validate_builtin_type`, before consulting `BaseTypeValidatorRegistry`:
613
+
614
+ ```ruby
615
+ engine = @options[:engine_context]
616
+ if engine&.supported?
617
+ answer = engine.builtin_valid?(
618
+ "xs:#{local_name(type_name)}", value
619
+ )
620
+ return handle_builtin_answer(xml_element, type_name, answer, collector) unless answer.nil?
621
+ end
622
+ ```
623
+
624
+ with
625
+
626
+ ```ruby
627
+ def handle_builtin_answer(xml_element, type_name, answer, collector)
628
+ return if answer
629
+
630
+ collector.add_error(
631
+ ValidationError.new(
632
+ code: "type_validation_error",
633
+ message: "'#{value}' is not a valid #{type_name}",
634
+ severity: :error,
635
+ location: xml_element.path,
636
+ ),
637
+ )
638
+ end
639
+
640
+ def local_name(type_name)
641
+ type_name.to_s.split(":").last
642
+ end
643
+ ```
644
+
645
+ In `validate_simple_type_content`, for named simple types: consult `engine.simple_valid?(type_name, value)` first (nil → existing facet path). Mirror the exact existing error codes/messages when delegating so consumers see no difference — read the current bodies first and reuse their message construction.
646
+
647
+ - [ ] **Step 4: Run rule spec + full suite — green.** Commit:
648
+
649
+ ```bash
650
+ git add lib/lutaml/xsd/validation/rules/type_validation_rule.rb spec/lutaml/xsd/validation/rules/type_validation_rule_spec.rb
651
+ git commit -m "Delegate lexical type checks to the engine when supported"
652
+ ```
653
+
654
+ ### Task 8: Content-model delegation in `ContentModelValidationRule`
655
+
656
+ **Files:**
657
+ - Modify: `lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb`
658
+ - Test: `spec/lutaml/xsd/validation/rules/content_model_validation_rule_spec.rb`
659
+
660
+ **Interfaces:**
661
+ - Consumes: `EngineContext#content_valid?(element_name, moxml_children) -> true/false/nil` (Task 5); `xml_element.moxml_element.children` for the Moxml node list.
662
+ - Produces: same contract as Task 7 — `validate(xml_element, schema_def, collector)` unchanged, nil falls back to the Ruby sequence/choice/all walk.
663
+
664
+ - [ ] **Step 1: Failing spec** — same shape as Task 7: valid child order accepted, invalid child order rejected, engine-present rule behaves identically to Ruby path on fixtures from `spec/fixtures` (real objects).
665
+
666
+ - [ ] **Step 2: Run — FAIL.**
667
+
668
+ - [ ] **Step 3: Implement** — at the top of `validate`, before dispatching to `validate_sequence`/`validate_choice`/`validate_all`:
669
+
670
+ ```ruby
671
+ engine = @options[:engine_context]
672
+ if engine&.supported?
673
+ answer = engine.content_valid?(
674
+ xml_element.name, xml_element.moxml_element.children.to_a
675
+ )
676
+ unless answer.nil?
677
+ add_content_error(xml_element, collector) unless answer
678
+ return
679
+ end
680
+ end
681
+ ```
682
+
683
+ `add_content_error` mirrors the rule's existing error code/message construction (read the file; reuse it exactly).
684
+
685
+ - [ ] **Step 4: Run rule spec + full suite — green.** Commit:
686
+
687
+ ```bash
688
+ git add lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb spec/lutaml/xsd/validation/rules/content_model_validation_rule_spec.rb
689
+ git commit -m "Delegate content-model checks to the engine when supported"
690
+ ```
691
+
692
+ ### Task 9: Fallback guarantees, full gates, PR
693
+
694
+ **Files:**
695
+ - Create: `spec/lutaml/xsd/validation/engine_fallback_spec.rb`
696
+ - Modify: `CLAUDE.md` (only if engine notes changed since Task 4)
697
+
698
+ **Interfaces:**
699
+ - Consumes: everything above. `Moxml::Context.new(:rexml)` forces the non-engine adapter.
700
+
701
+ - [ ] **Step 1: Write fallback specs**
702
+
703
+ ```ruby
704
+ RSpec.describe "engine validation fallback" do
705
+ let(:rexml_context) { Moxml::Context.new(:rexml) }
706
+
707
+ it "compiles to nil on REXML (no engine validator)" do
708
+ expect(Lutaml::Xsd::Validation::EngineContext.xsd_supported?(rexml_context)).to be(false)
709
+ expect(Lutaml::Xsd::Validation::EngineContext.compile(repository, context: rexml_context)).to be_nil
710
+ end
711
+
712
+ it "runs a full validation job without engine findings on REXML" do
713
+ job = Lutaml::Xsd::Validation::ValidationJob.new(
714
+ xml_content: "<note>12</note>",
715
+ repository: repository,
716
+ rule_registry: Lutaml::Xsd::Validation::RuleRegistry.new(config),
717
+ config: config,
718
+ )
719
+ result = job.execute
720
+ expect(result.errors.map(&:code)).not_to include("engine_validation")
721
+ end
722
+ end
723
+ ```
724
+
725
+ - [ ] **Step 2: Full gates** — `bundle exec rspec` (0 failures) and `bundle exec rubocop` (clean). Fix findings, re-run.
726
+
727
+ - [ ] **Step 3: Commit, push, open PR** (the `feat/moxml-xsd-engine-validation` branch was created from updated main before Task 5)
728
+
729
+ ```bash
730
+ git add spec/lutaml/xsd/validation/engine_fallback_spec.rb CLAUDE.md
731
+ git commit -m "Add non-engine adapter fallback specs"
732
+ git push -u origin feat/moxml-xsd-engine-validation
733
+ printf 'Integrates Moxml::XSD engine validation (workstream C of #105):\n\n- `Validation::EngineContext`: capability-gated compiled-schema bridge (leptris/nokogiri tier-1)\n- `ValidationJob` phase `engine_validation` (default on when supported), findings additive with `code: "engine_validation"`\n- Lexical + content-model checks delegate to the engine via `options[:engine_context]`; nil answers fall back to the Ruby rule engine\n- REXML/Oga/Ox adapters unchanged (compile returns nil)\n' > /tmp/pr2-body.md
734
+ gh pr create --title "Integrate Moxml::XSD engine validation" --body-file /tmp/pr2-body.md
735
+ ```