lutaml-xsd 1.1.13 → 1.1.14
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/CLAUDE.md +14 -0
- data/Gemfile +2 -1
- data/config/validation.yml +4 -0
- data/docs/superpowers/plans/2026-10-20-moxml-leptris-migration.md +735 -0
- data/docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md +169 -0
- data/lib/lutaml/xsd/package_builder.rb +2 -2
- data/lib/lutaml/xsd/spa/schema_serializer.rb +9 -6
- data/lib/lutaml/xsd/validation/engine_context.rb +190 -0
- data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +29 -0
- data/lib/lutaml/xsd/validation/rules/type_validation_rule.rb +43 -5
- data/lib/lutaml/xsd/validation/validation_configuration.rb +1 -0
- data/lib/lutaml/xsd/validation/validation_job.rb +26 -3
- data/lib/lutaml/xsd/validation/validator.rb +1 -0
- data/lib/lutaml/xsd/version.rb +1 -1
- data/lutaml-xsd.gemspec +2 -2
- metadata +11 -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
|
+
```
|