Skip to content

fix(validator): enforceable ordered slicing, found by new IR coverage guards - #461

Open
smunini wants to merge 2 commits into
mainfrom
test/schema-coverage-guards
Open

fix(validator): enforceable ordered slicing, found by new IR coverage guards#461
smunini wants to merge 2 commits into
mainfrom
test/schema-coverage-guards

Conversation

@smunini

@smunini smunini commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #429 (and #364). That PR fixed four IR fields the converter never populated — but the reason it went unnoticed is structural, not incidental: the IR deserializes tolerantly (unknown keys are dropped) and the converter is free to leave any field unset. Both are correct at runtime. Neither is asserted anywhere, so "keyword we silently drop" and "IR field nothing ever fills" are both invisible.

Two commits: the sweeps that make that class of bug loud, and a fix for the one live defect they turned up.


1. test(validator): mechanical coverage guards

tests/converter_coverage.rs — input side

Walks every ElementDefinition in the vendored R4/R4B/R5/R6 spec bundles (~200k elements, ~3.8s) and asserts each key is either read by the converter (CONSUMED) or listed as deliberately ignored with the reason (IGNORED). A sibling test does the same for the sub-objects of fields we do read — where a version bump quietly introduces something like binding.additional. A third fails on allowlist entries that no longer appear in the corpus, so the lists can't rot.

CONSUMED/IGNORED are hand-maintained because Ed is pub(crate). That's the intended trade: moving a key between the lists is the explicit act of deciding what the converter does with it.

tests/schema_coverage.rs — output side

  1. no_unknown_keywords_in_packs_or_fixtures — every key in the four committed packs and the conformance fixtures must map to an IR field.
  2. every_ir_field_is_emitted_or_documented_absent — the feat(validator,ui): mustSupport and short labels through the IR into the editor #429 guard proper. Every IR field appears in at least one pack, or sits in expected_absent with its reason. Asserted as set equality, so a fix trips it too and forces the list to be updated.

Field names aren't hand-written — each context serializes an exhaustive struct literal, so adding a field to schema.rs is a compile error here until it's accounted for.

What the sweeps found

  • Slice.order was a live gap — fixed below.
  • FhirSchema.scalar is a deliberate divergence. Upstream's generated schemas mark every singular element scalar: true; we emit only array, because engine/walk.rs:464 treats "not an array" as "must be singular" — stricter than the format's tri-state, so nothing is lost on packs we generate ourselves.
  • Slice.reslice / sliceIsConstraining / Match.resolve-ref are unexercised. The corpus is core-only — no IG is vendored, so re-slicing is structurally unreachable by these tests. Adding US Core or IPS to the sweep is what would make this half meaningful.

Also surfaced, and left alone as out of scope: maxLength and minValue[x]/maxValue[x] are validation-bearing ElementDefinition fields with no FHIR Schema keyword to carry them. They're in IGNORED with that noted.


2. fix(validator): emit slice ordinals

build_slicing hardcoded order: None, so every converted pack lost the one number the engine's ordered check compares. engine/slicing.rs:168 reads slices[name].order, finds None, and continues — ordered: true was carried faithfully into the IR and then enforced nowhere. The guard quantified it: 148 slicings in the packs, 5 declaring ordered: true, 0 of 195 slices with an order.

Slices now carry their declaration ordinal, emitted only under ordered: true — matching upstream's generated schemas, where an unordered slicing carries no order at all. Ordinals are compared only in sequence, so gaps left by lifted extension-sugar slices are harmless.

Packs regenerated. The diff is a pure addition: stripping order from both sides makes all four packs byte-identical to their predecessors. The values cross-check against upstream's own lipidprofile example (Cholesterol 0, Triglyceride 1, HDLCholesterol 2, LDLCholesterol 3).

Where this does and doesn't bite yet

Worth being precise about. The only ordered slicing in the core packs is lipidprofile, whose discriminator is value@resolve().code — untranslatable today, so its slices carry no match, match nothing, and the check stays dormant there until the resolve() translation lands. It fires immediately for uploaded profiles and IGs whose ordered slicing uses a translatable discriminator, which is what the new end-to-end test exercises.


Tests

New: a golden pinning the ordinals (mini-ordered-slicing.json), plus an end-to-end case that converts the fixture, validates through the engine, and asserts a slice-order error on an out-of-order instance and none on an in-order one. The existing unordered-profile golden is unchanged, pinning the absence of order when slicing isn't ordered.

Each coverage guard was mutation-tested rather than just observed passing:

Mutation Failure
drop "short" from CONSUMED ElementDefinition.short (first seen in R4)
drop "scalar" from expected_absent FhirSchema.scalar: declared in the IR but never emitted into any pack
add maxLength to a fixture schema FhirSchema.maxLength (first seen at cardinality.json:Patient.elements[name])
add a field to FhirSchema error[E0063]: missing field ... in initializer of FhirSchema

Full validator suite green (14 binaries, plus the 4 #[ignore]d pack_smoke tests run explicitly against the regenerated packs), cargo fmt --all --check clean, cargo clippy -p helios-fhir-validator --tests --all-features produces no warnings in this crate.

flate2 moves into [dev-dependencies] (already a regular dep) so the pack walk is feature-agnostic, unlike src/packs.rs which gates each pack behind its version feature.

Note for reviewers

cargo test -p helios-fhir-validator --all-features re-downloads the R6 spec and rewrites 1,603 tracked files under crates/fhir/tests/data/{json,xml}/R6/ with a changed date field only. Pre-existing behavior, unrelated to this PR, but an easy way to commit noise by accident — worth a git status before staging.

smunini added 2 commits July 30, 2026 15:48
#364/#429 were invisible for a reason: the IR fields existed, the
converter never filled them, and nothing asserted otherwise. Two
tolerances hide that class of bug — deserialization ignores unknown
keys, and the converter may leave any field unset. Both are correct at
runtime; neither is checked.

Two sweeps close it.

converter_coverage.rs (input side) walks every ElementDefinition in the
vendored R4-R6 spec bundles and asserts each key is either read by the
converter or listed as deliberately ignored, with the reason. Same for
the sub-objects of the fields we do read — where a version bump quietly
adds something like `binding.additional`. A third test fails on
allowlist entries no longer present in the corpus, so the lists cannot
rot.

schema_coverage.rs (output side) asserts no key in the packs or the
conformance fixtures falls outside the IR, and that every IR field is
emitted into at least one pack or documented absent. The absent list is
compared for equality, so a fix trips it too. Field names are derived
from exhaustive struct literals: adding a field to schema.rs is a
compile error here until it is accounted for.

What the sweeps found, now pinned in `expected_absent`:

- `Slice.order` is a live gap — converter/slicing.rs hardcodes `None`,
  so the `ordered: true` loop in engine/slicing.rs never fires on a
  converted pack (5 slicings declare it, 0 of 195 slices carry an order)
- `FhirSchema.scalar` is deliberate — upstream marks every singular
  element, we emit only `array` because engine/walk.rs treats "not an
  array" as "must be singular", stricter than the format's tri-state
- `Slice.reslice` / `sliceIsConstraining` / `Match.resolve-ref` are
  unexercised: the corpus is core-only, no IG is vendored

Each guard was mutation-tested — dropping a CONSUMED entry, an
expected_absent entry, or adding an unmodelled keyword to a fixture all
fail with the offending name and where it was first seen.

flate2 moves into dev-dependencies (already a regular dep) so the pack
walk is feature-agnostic, unlike src/packs.rs.
`build_slicing` hardcoded `order: None`, so every converted pack lost the
one number the engine's ordered check compares. `engine/slicing.rs`
reads `slices[name].order`, finds `None`, and `continue`s — meaning
`ordered: true` was carried faithfully into the IR and then enforced
nowhere. The coverage guard added alongside this pinned it: 148 slicings
in the packs, 5 declaring `ordered: true`, 0 of 195 slices with an order.

Slices now get their declaration ordinal, emitted only under
`ordered: true` — matching upstream's generated schemas, where an
unordered slicing carries no `order` at all. Ordinals are compared only
in sequence, so the gaps left by lifted extension-sugar slices are
harmless.

Packs regenerated. The diff is a pure addition: stripping `order` from
both sides makes all four packs byte-identical to their predecessors.
The values cross-check against upstream's own lipidprofile example
(Cholesterol 0, Triglyceride 1, HDLCholesterol 2, LDLCholesterol 3).

Worth knowing where this does and does not bite yet. The only ordered
slicing in the core packs is lipidprofile, whose discriminator is
`value@resolve().code` — untranslatable today, so its slices carry no
`match`, match nothing, and the check stays dormant there until the
resolve() translation lands. It fires immediately for uploaded profiles
and IGs whose ordered slicing uses a translatable discriminator, which
is what the new end-to-end test exercises.

Tests: a golden pinning the ordinals (and the existing unordered
profile golden, unchanged, pinning their absence), plus an end-to-end
case that converts the fixture, validates through the engine, and
asserts a `slice-order` error on an out-of-order instance and none on
an in-order one. `Slice.order` drops off `expected_absent` in
schema_coverage.rs.
@smunini smunini changed the title test(validator): mechanical coverage guards for the FHIR Schema IR fix(validator): enforceable ordered slicing, found by new IR coverage guards Jul 30, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant