fix(validator): enforceable ordered slicing, found by new IR coverage guards - #461
Open
smunini wants to merge 2 commits into
Open
fix(validator): enforceable ordered slicing, found by new IR coverage guards#461smunini wants to merge 2 commits into
smunini wants to merge 2 commits into
Conversation
#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.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 guardstests/converter_coverage.rs— input sideWalks every
ElementDefinitionin 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 likebinding.additional. A third fails on allowlist entries that no longer appear in the corpus, so the lists can't rot.CONSUMED/IGNOREDare hand-maintained becauseEdispub(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 sideno_unknown_keywords_in_packs_or_fixtures— every key in the four committed packs and the conformance fixtures must map to an IR field.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 inexpected_absentwith 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.rsis a compile error here until it's accounted for.What the sweeps found
Slice.orderwas a live gap — fixed below.FhirSchema.scalaris a deliberate divergence. Upstream's generated schemas mark every singular elementscalar: true; we emit onlyarray, becauseengine/walk.rs:464treats "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-refare 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:
maxLengthandminValue[x]/maxValue[x]are validation-bearingElementDefinitionfields with no FHIR Schema keyword to carry them. They're inIGNOREDwith that noted.2.
fix(validator): emit slice ordinalsbuild_slicinghardcodedorder: None, so every converted pack lost the one number the engine's ordered check compares.engine/slicing.rs:168readsslices[name].order, findsNone, andcontinues —ordered: truewas carried faithfully into the IR and then enforced nowhere. The guard quantified it: 148 slicings in the packs, 5 declaringordered: true, 0 of 195 slices with anorder.Slices now carry their declaration ordinal, emitted only under
ordered: true— matching upstream's generated schemas, where an unordered slicing carries noorderat 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
orderfrom both sides makes all four packs byte-identical to their predecessors. The values cross-check against upstream's ownlipidprofileexample (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 isvalue@resolve().code— untranslatable today, so its slices carry nomatch, match nothing, and the check stays dormant there until theresolve()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 aslice-ordererror on an out-of-order instance and none on an in-order one. The existing unordered-profile golden is unchanged, pinning the absence oforderwhen slicing isn't ordered.Each coverage guard was mutation-tested rather than just observed passing:
"short"fromCONSUMEDElementDefinition.short (first seen in R4)"scalar"fromexpected_absentFhirSchema.scalar: declared in the IR but never emitted into any packmaxLengthto a fixture schemaFhirSchema.maxLength (first seen at cardinality.json:Patient.elements[name])FhirSchemaerror[E0063]: missing field ... in initializer of FhirSchemaFull validator suite green (14 binaries, plus the 4
#[ignore]dpack_smoketests run explicitly against the regenerated packs),cargo fmt --all --checkclean,cargo clippy -p helios-fhir-validator --tests --all-featuresproduces no warnings in this crate.flate2moves into[dev-dependencies](already a regular dep) so the pack walk is feature-agnostic, unlikesrc/packs.rswhich gates each pack behind its version feature.Note for reviewers
cargo test -p helios-fhir-validator --all-featuresre-downloads the R6 spec and rewrites 1,603 tracked files undercrates/fhir/tests/data/{json,xml}/R6/with a changeddatefield only. Pre-existing behavior, unrelated to this PR, but an easy way to commit noise by accident — worth agit statusbefore staging.