diff --git a/crates/fhir-validator/Cargo.toml b/crates/fhir-validator/Cargo.toml index c268655f9..75c139d83 100644 --- a/crates/fhir-validator/Cargo.toml +++ b/crates/fhir-validator/Cargo.toml @@ -46,6 +46,9 @@ helios-fhirpath = { path = "../fhirpath", version = "0.2.1", optional = true, de serde = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt", "macros"] } +# tests/schema_coverage.rs reads the committed packs off disk (feature-agnostic, +# unlike src/packs.rs which gates each pack behind its version feature). +flate2 = "1" [[bin]] name = "generate-schema-packs" diff --git a/crates/fhir-validator/packs/fhir_schemas_r4.json.gz b/crates/fhir-validator/packs/fhir_schemas_r4.json.gz index f7757159f..6f9db1d6d 100644 Binary files a/crates/fhir-validator/packs/fhir_schemas_r4.json.gz and b/crates/fhir-validator/packs/fhir_schemas_r4.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz b/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz index e27caa7da..f29898cab 100644 Binary files a/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz and b/crates/fhir-validator/packs/fhir_schemas_r4b.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r5.json.gz b/crates/fhir-validator/packs/fhir_schemas_r5.json.gz index 80c33d160..272da8a31 100644 Binary files a/crates/fhir-validator/packs/fhir_schemas_r5.json.gz and b/crates/fhir-validator/packs/fhir_schemas_r5.json.gz differ diff --git a/crates/fhir-validator/packs/fhir_schemas_r6.json.gz b/crates/fhir-validator/packs/fhir_schemas_r6.json.gz index 537c22610..bbd3eeb05 100644 Binary files a/crates/fhir-validator/packs/fhir_schemas_r6.json.gz and b/crates/fhir-validator/packs/fhir_schemas_r6.json.gz differ diff --git a/crates/fhir-validator/src/converter/mod.rs b/crates/fhir-validator/src/converter/mod.rs index f967c8c72..ea308f6f0 100644 --- a/crates/fhir-validator/src/converter/mod.rs +++ b/crates/fhir-validator/src/converter/mod.rs @@ -19,6 +19,7 @@ //! | multi-type / `foo[x]` | `choices` declarer + one `choiceOf` branch per type | //! | `contentReference: "#A.b"` | `elementReference: ["A", "elements", "b"]` | //! | `slicing.discriminator` | `slicing.slices[].match` (pattern) — see `slicing` | +//! | `slicing.ordered` | `slicing.ordered` + `slices[].order` (declaration ordinal) | //! | extension slice + type profile | parent `extensions` sugar | //! | `binding` | `{valueSet, strength}` carried for all strengths | //! | `constraint[]` | `constraints` map (`ele-1`/`ext-1` dropped off non-root elements — they are enforced once via the `Element`/`Extension` type schemas) | diff --git a/crates/fhir-validator/src/converter/slicing.rs b/crates/fhir-validator/src/converter/slicing.rs index 073a4dad9..63b30dd07 100644 --- a/crates/fhir-validator/src/converter/slicing.rs +++ b/crates/fhir-validator/src/converter/slicing.rs @@ -8,6 +8,12 @@ //! match and no minimum**: it can never produce false cardinality errors, //! its constraints simply stay dormant until the matcher lands (Phase 7), //! and the generator surfaces a warning. +//! +//! Under `ordered: true` each slice also gets its declaration ordinal as +//! `order` — FHIR's ordered slicing means "matched items appear in the order +//! the slices are declared", and the engine's check needs a number to compare. +//! Emitted only when the slicing is ordered, matching upstream's generated +//! schemas (an unordered slicing carries no `order`). use super::EdDiscriminator; use super::tree::{SliceNode, finalize}; @@ -23,8 +29,9 @@ pub(super) fn build_slicing( ordered: Option, warnings: &mut Vec, ) -> Option { + let is_ordered = ordered == Some(true); let mut out: IndexMap = IndexMap::new(); - for (name, slice_node) in slices { + for (position, (name, slice_node)) in slices.into_iter().enumerate() { let SliceNode { node, min, @@ -53,7 +60,9 @@ pub(super) fn build_slicing( min: if match_.is_some() { min } else { None }, max, match_, - order: None, + // Ordinals are relative — only their sequence is compared — + // so gaps from lifted extension-sugar slices are harmless. + order: is_ordered.then_some(position as u64), reslice: None, slice_is_constraining: None, schema: schema.map(Arc::new), diff --git a/crates/fhir-validator/tests/converter_coverage.rs b/crates/fhir-validator/tests/converter_coverage.rs new file mode 100644 index 000000000..fbbbea3c5 --- /dev/null +++ b/crates/fhir-validator/tests/converter_coverage.rs @@ -0,0 +1,332 @@ +//! Converter input-coverage guard. +//! +//! Sweeps every `ElementDefinition` in the vendored FHIR spec bundles and +//! asserts that each key is either read by the SD→schema converter or listed +//! here as deliberately ignored. The same sweep runs over the sub-objects of +//! the fields the converter does read (`base`, `type`, `slicing`, `binding`, +//! `constraint`). +//! +//! This is the other half of the #364/#429 guard: `schema_coverage.rs` catches +//! an IR field nothing fills, this catches an ElementDefinition field nothing +//! reads — including fields a future spec version introduces, which otherwise +//! arrive in total silence (the converter's `Ed` model absorbs everything it +//! does not name into `#[serde(flatten)] rest`). +//! +//! The corpus is the same one `generate-schema-packs` converts, so a key that +//! appears here is a key that reaches the converter for real. It is *core* +//! only, though — no IG is vendored, so profile-driven constructs +//! (`sliceIsConstraining`, re-slicing) are not exercised. See the note on +//! `expected_absent` in `schema_coverage.rs`. +//! +//! `CONSUMED` / `IGNORED` are hand-maintained: the converter's `Ed` model is +//! crate-private, so this test cannot derive them. That is the point — moving +//! a key between the two lists is the deliberate act of deciding what the +//! converter does with it. + +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +// --------------------------------------------------------------------- +// What the converter reads, and what it deliberately does not. +// --------------------------------------------------------------------- + +/// `ElementDefinition` fields read by `converter/mod.rs` (`Ed`) and applied in +/// `converter/tree.rs`. +const CONSUMED: &[&str] = &[ + "id", + "path", + "sliceName", + "min", + "max", + "base", + "type", + "contentReference", + "slicing", + "binding", + "constraint", + // Informational mirrors carried into the IR (#429). + "mustSupport", + "isModifier", + "isSummary", + "short", +]; + +/// `fixed[x]` / `pattern[x]` are matched by prefix in `apply_value_keywords`. +const CONSUMED_PREFIXES: &[&str] = &["fixed", "pattern"]; + +/// Fields present in the corpus that the converter deliberately does not read. +const IGNORED: &[(&str, &str)] = &[ + ( + "definition", + "long-form documentation; not carried (pack size — `short` is the label)", + ), + ("comment", "documentation"), + ("requirements", "documentation"), + ("alias", "documentation"), + ("mapping", "cross-standard mappings; no FHIR Schema keyword"), + ( + "condition", + "back-links to constraint keys; the constraints themselves are carried", + ), + ("isModifierReason", "documentation for `isModifier`"), + ( + "representation", + "XML/JSON serialization hints; not a validation rule", + ), + ("meaningWhenMissing", "documentation"), + ("orderMeaning", "documentation"), + ("example", "illustrative values; not a validation rule"), + ( + "extension", + "ED-level extensions; the two we need are read off `type[].extension`", + ), + ( + "maxLength", + "validation-bearing, but FHIR Schema defines no keyword for it", + ), +]; + +/// `minValue[x]` / `maxValue[x]`, like `maxLength`, are real constraints with +/// no FHIR Schema keyword to carry them. +const IGNORED_PREFIXES: &[&str] = &["minValue", "maxValue"]; + +/// Sub-object keys, per parent field: `(parent, key, consumed)`. +const SUB_KEYS: &[(&str, &str, bool)] = &[ + ("base", "max", true), + ("base", "path", false), + ("base", "min", false), + ("type", "code", true), + ("type", "profile", true), + ("type", "targetProfile", true), + ("type", "extension", true), + ("slicing", "discriminator", true), + ("slicing", "rules", true), + ("slicing", "ordered", true), + ("slicing", "description", false), + ("binding", "strength", true), + ("binding", "valueSet", true), + ("binding", "description", false), + ("binding", "extension", false), + ("binding", "additional", false), + ("constraint", "key", true), + ("constraint", "severity", true), + ("constraint", "human", true), + ("constraint", "expression", true), + ("constraint", "source", false), + ("constraint", "xpath", false), + ("constraint", "extension", false), + ("constraint", "requirements", false), +]; + +/// Parents whose sub-objects are swept. +const SUB_PARENTS: &[&str] = &["base", "type", "slicing", "binding", "constraint"]; + +fn is_known(key: &str) -> bool { + // `_field` carries the primitive sidecar of `field`; it follows its base. + let base = key.strip_prefix('_').unwrap_or(key); + CONSUMED.contains(&base) + || IGNORED.iter().any(|(k, _)| *k == base) + || CONSUMED_PREFIXES + .iter() + .chain(IGNORED_PREFIXES) + .any(|p| base.len() > p.len() && base.starts_with(p)) +} + +// --------------------------------------------------------------------- +// Corpus sweep. +// --------------------------------------------------------------------- + +/// A narrow view of a spec bundle: only the element lists, so the bulk of each +/// file (narrative, ValueSets, CodeSystems) is never materialized. +#[derive(Deserialize)] +struct Bundle { + #[serde(default)] + entry: Vec, +} + +#[derive(Deserialize)] +struct Entry { + resource: Option, +} + +#[derive(Deserialize)] +struct Resource { + #[serde(rename = "resourceType")] + resource_type: Option, + snapshot: Option, + differential: Option, +} + +#[derive(Deserialize)] +struct ElementList { + #[serde(default)] + element: Vec>, +} + +const VERSIONS: [&str; 4] = ["R4", "R4B", "R5", "R6"]; +const BUNDLES: [&str; 3] = [ + "profiles-types.json", + "profiles-resources.json", + "profiles-others.json", +]; + +fn resources_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fhir-gen/resources") +} + +/// Observed key → the version it was first seen in. +#[derive(Default)] +struct Sweep { + ed_keys: BTreeMap, + sub_keys: BTreeMap<(String, String), String>, + elements: usize, +} + +fn sweep() -> Sweep { + let mut out = Sweep::default(); + for version in VERSIONS { + for bundle_name in BUNDLES { + let path = resources_dir().join(version).join(bundle_name); + let Ok(bytes) = std::fs::read(&path) else { + continue; // e.g. R6 before fhir-gen's download has run + }; + let bundle: Bundle = serde_json::from_slice(&bytes) + .unwrap_or_else(|e| panic!("{}: parses as a Bundle: {e}", path.display())); + for entry in &bundle.entry { + let Some(resource) = &entry.resource else { + continue; + }; + if resource.resource_type.as_deref() != Some("StructureDefinition") { + continue; + } + for list in [&resource.snapshot, &resource.differential] + .into_iter() + .flatten() + { + for element in &list.element { + out.elements += 1; + for (key, value) in element { + out.ed_keys + .entry(key.clone()) + .or_insert_with(|| version.to_string()); + if !SUB_PARENTS.contains(&key.as_str()) { + continue; + } + let items: &[Value] = match value { + Value::Array(items) => items, + other => std::slice::from_ref(other), + }; + for item in items { + for sub in item.as_object().into_iter().flatten().map(|(k, _)| k) { + out.sub_keys + .entry((key.clone(), sub.clone())) + .or_insert_with(|| version.to_string()); + } + } + } + } + } + } + // Free the bundle before the next (they are tens of MB each). + drop(bundle); + } + } + out +} + +// --------------------------------------------------------------------- +// Guards. +// --------------------------------------------------------------------- + +/// Every ElementDefinition field in the spec corpus is accounted for. +#[test] +fn every_element_definition_field_is_consumed_or_documented() { + let sweep = sweep(); + if sweep.elements == 0 { + eprintln!( + "skipping: no spec bundles under {} — run fhir-gen's resource download first", + resources_dir().display() + ); + return; + } + + let unaccounted: Vec = sweep + .ed_keys + .iter() + .filter(|(key, _)| !is_known(key)) + .map(|(key, version)| format!(" ElementDefinition.{key} (first seen in {version})")) + .collect(); + assert!( + unaccounted.is_empty(), + "ElementDefinition fields reaching the converter that it neither reads nor \ + documents as ignored — decide which, and add them to CONSUMED or IGNORED:\n{}", + unaccounted.join("\n") + ); +} + +/// Sub-objects of the fields the converter *does* read are accounted for too — +/// this is where a new spec version quietly adds something like +/// `binding.additional`. +#[test] +fn every_sub_object_field_is_consumed_or_documented() { + let sweep = sweep(); + if sweep.elements == 0 { + eprintln!("skipping: no spec bundles found (see the sibling test)"); + return; + } + + let known: BTreeSet<(&str, &str)> = SUB_KEYS.iter().map(|(p, k, _)| (*p, *k)).collect(); + let unaccounted: Vec = sweep + .sub_keys + .iter() + .filter(|((parent, key), _)| !known.contains(&(parent.as_str(), key.as_str()))) + .map(|((parent, key), version)| { + format!(" ElementDefinition.{parent}.{key} (first seen in {version})") + }) + .collect(); + assert!( + unaccounted.is_empty(), + "sub-object fields the converter neither reads nor documents as ignored — \ + add them to SUB_KEYS:\n{}", + unaccounted.join("\n") + ); +} + +/// The allowlists describe the corpus, not wishful thinking: an entry that no +/// longer appears anywhere is stale and should be deleted. +#[test] +fn allowlists_have_no_stale_entries() { + let sweep = sweep(); + if sweep.elements == 0 { + eprintln!("skipping: no spec bundles found (see the sibling test)"); + return; + } + // R6 is downloaded on demand; without it, absence proves nothing. + if !resources_dir().join("R6").join(BUNDLES[1]).exists() { + eprintln!("skipping: R6 bundles absent, cannot distinguish stale from R6-only"); + return; + } + + let mut stale: Vec = IGNORED + .iter() + .filter(|(key, _)| !sweep.ed_keys.contains_key(*key)) + .map(|(key, _)| format!(" IGNORED: ElementDefinition.{key}")) + .collect(); + stale.extend( + SUB_KEYS + .iter() + .filter(|(parent, key, _)| { + !sweep + .sub_keys + .contains_key(&(parent.to_string(), key.to_string())) + }) + .map(|(parent, key, _)| format!(" SUB_KEYS: ElementDefinition.{parent}.{key}")), + ); + assert!( + stale.is_empty(), + "allowlist entries that no longer appear in the spec corpus — delete them:\n{}", + stale.join("\n") + ); +} diff --git a/crates/fhir-validator/tests/converter_tests.rs b/crates/fhir-validator/tests/converter_tests.rs index 0088ee57b..33fd00767 100644 --- a/crates/fhir-validator/tests/converter_tests.rs +++ b/crates/fhir-validator/tests/converter_tests.rs @@ -230,3 +230,139 @@ fn carries_informational_mirrors_and_short_labels() { assert_eq!(note["short"], json!("Free-text remark")); assert_eq!(note["mustSupport"], Value::Null); } + +/// `ordered: true` slicing carries each slice's declaration ordinal as +/// `order` — without it the engine's ordered check has nothing to compare and +/// silently passes (`engine/slicing.rs`). +#[test] +fn converts_ordered_slicing_with_slice_ordinals() { + let actual = convert_to_value("mini-ordered-slicing.json"); + let expected = json!({ + "url": "http://example.org/StructureDefinition/mini-ordered-slicing", + "name": "MiniOrderedSlicing", + "base": "http://hl7.org/fhir/StructureDefinition/Patient", + "kind": "resource", + "derivation": "constraint", + "type": "Patient", + "required": ["telecom"], + "elements": { + "telecom": { + "array": true, + "min": 1, + "slicing": { + "slices": { + "phone": { + "match": { + "type": "pattern", + "value": { "system": "phone" } + }, + "min": 1, + "max": 1, + "order": 0, + "schema": { + "required": ["system"], + "elements": { + "system": { "fixed": "phone" } + } + } + }, + "email": { + "match": { + "type": "pattern", + "value": { "system": "email" } + }, + "min": 0, + "max": 2, + "order": 1, + "schema": { + "required": ["system"], + "elements": { + "system": { "fixed": "email" } + } + } + } + }, + "rules": "closed", + "ordered": true + } + } + } + }); + assert_eq!(actual, expected); +} + +/// End-to-end: the ordinals the converter emits are what makes the engine's +/// ordered-slicing check fire. Without `order` the check reads `None` for +/// every slice and passes silently, so this is the test that would have caught +/// the gap. +#[test] +fn converted_ordered_slicing_is_enforced_by_the_engine() { + use helios_fhir_validator::{ + FhirSchema, SchemaRegistry, UnknownProfilePolicy, ValidationOptions, Validator, + }; + use std::sync::Arc; + + const PROFILE_URL: &str = "http://example.org/StructureDefinition/mini-ordered-slicing"; + const PATIENT_URL: &str = "http://hl7.org/fhir/StructureDefinition/Patient"; + + let profile = convert(&load_sd("mini-ordered-slicing.json")) + .expect("fixture converts") + .schema; + + // Just enough of the base layer for the profile to resolve and walk. + let named = |v: Value| -> FhirSchema { serde_json::from_value(v).expect("schema parses") }; + let patient = named(json!({ + "kind": "resource", "type": "Patient", + "elements": { + "resourceType": { "type": "code" }, + "telecom": { "type": "ContactPoint", "array": true } + } + })); + let mut registry = SchemaRegistry::new(); + registry.insert_named("Patient", patient.clone()); + registry.insert_named(PATIENT_URL, patient); + registry.insert_named( + "ContactPoint", + named(json!({ "kind": "complex-type", "elements": { "system": { "type": "code" } } })), + ); + registry.insert_named("code", named(json!({ "kind": "primitive-type" }))); + registry.insert_named(PROFILE_URL, profile); + + let validator = Validator::new(Arc::new(registry)); + let opts = ValidationOptions { + profiles: vec![PROFILE_URL.to_string()], + use_meta_profiles: true, + unknown_profile: UnknownProfilePolicy::Error, + }; + let kinds = |data: &Value| -> Vec { + validator + .validate_sync(data, &opts) + .errors + .iter() + .map(|e| { + serde_json::to_value(e).unwrap()["type"] + .as_str() + .unwrap() + .to_string() + }) + .collect() + }; + + // phone (order 0) before email (order 1): clean. + assert_eq!( + kinds(&json!({ + "resourceType": "Patient", + "telecom": [{ "system": "phone" }, { "system": "email" }] + })), + Vec::::new(), + ); + + // email before phone violates the declared order. + assert_eq!( + kinds(&json!({ + "resourceType": "Patient", + "telecom": [{ "system": "email" }, { "system": "phone" }] + })), + vec!["slice-order".to_string()], + ); +} diff --git a/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-ordered-slicing.json b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-ordered-slicing.json new file mode 100644 index 000000000..6d2a6d9fb --- /dev/null +++ b/crates/fhir-validator/tests/fixtures/structuredefinitions/mini-ordered-slicing.json @@ -0,0 +1,54 @@ +{ + "resourceType": "StructureDefinition", + "id": "mini-ordered-slicing", + "url": "http://example.org/StructureDefinition/mini-ordered-slicing", + "name": "MiniOrderedSlicing", + "status": "active", + "kind": "resource", + "abstract": false, + "type": "Patient", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Patient", + "derivation": "constraint", + "differential": { + "element": [ + { "id": "Patient", "path": "Patient" }, + { + "id": "Patient.telecom", + "path": "Patient.telecom", + "min": 1, + "max": "*", + "slicing": { + "discriminator": [{ "type": "value", "path": "system" }], + "rules": "closed", + "ordered": true + } + }, + { + "id": "Patient.telecom:phone", + "path": "Patient.telecom", + "sliceName": "phone", + "min": 1, + "max": "1" + }, + { + "id": "Patient.telecom:phone.system", + "path": "Patient.telecom.system", + "min": 1, + "fixedCode": "phone" + }, + { + "id": "Patient.telecom:email", + "path": "Patient.telecom", + "sliceName": "email", + "min": 0, + "max": "2" + }, + { + "id": "Patient.telecom:email.system", + "path": "Patient.telecom.system", + "min": 1, + "fixedCode": "email" + } + ] + } +} diff --git a/crates/fhir-validator/tests/schema_coverage.rs b/crates/fhir-validator/tests/schema_coverage.rs new file mode 100644 index 000000000..7717131a9 --- /dev/null +++ b/crates/fhir-validator/tests/schema_coverage.rs @@ -0,0 +1,398 @@ +//! IR keyword-coverage guards. +//! +//! The FHIR Schema IR is deserialized tolerantly — unknown keys are ignored +//! (see `schema.rs`) — and the converter is free to leave any IR field unset. +//! Both are correct at runtime and both hide the same failure mode: a keyword +//! the format defines that we silently drop, or an IR field nothing ever +//! fills. #364/#429 were the latter (`mustSupport`/`isModifier`/`isSummary` +//! existed on the struct and stayed empty in every pack, and no test noticed). +//! +//! Two directions, both mechanical: +//! +//! 1. [`no_unknown_keywords_in_packs_or_fixtures`] — every key observed in the +//! committed packs and the conformance fixtures must map to an IR field. +//! 2. [`every_ir_field_is_emitted_or_documented_absent`] — every IR field must +//! appear at least once across the packs, or be listed in `EXPECTED_ABSENT` +//! with the reason. The assertion is set *equality*, so both a regression +//! (field stops being emitted) and a fix (field starts being emitted) trip +//! it and force the list to be updated. +//! +//! The field names are not hand-written: each context serializes a struct +//! literal that names every field, so adding a field to `schema.rs` without +//! accounting for it here is a compile error in this file. + +use helios_fhir_validator::{Binding, Constraint, FhirSchema, Match, Slice, Slicing}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::Arc; + +// --------------------------------------------------------------------- +// The IR field sets, derived from the types themselves. +// --------------------------------------------------------------------- + +/// Where in the IR a JSON object sits. Determines which field set applies. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Ctx { + Schema, + Binding, + Constraint, + Slicing, + Slice, + Match, +} + +impl Ctx { + fn name(self) -> &'static str { + match self { + Ctx::Schema => "FhirSchema", + Ctx::Binding => "Binding", + Ctx::Constraint => "Constraint", + Ctx::Slicing => "Slicing", + Ctx::Slice => "Slice", + Ctx::Match => "Match", + } + } + + const ALL: [Ctx; 6] = [ + Ctx::Schema, + Ctx::Binding, + Ctx::Constraint, + Ctx::Slicing, + Ctx::Slice, + Ctx::Match, + ]; +} + +/// The serialized key names of a fully-populated value. +fn keys_of(value: &T) -> BTreeSet { + serde_json::to_value(value) + .expect("IR type serializes") + .as_object() + .expect("IR type serializes to a JSON object") + .keys() + .cloned() + .collect() +} + +/// Every field the IR declares, per context. +/// +/// The struct literals below are deliberately exhaustive — no +/// `..Default::default()` — so a new field in `schema.rs` fails to compile +/// here until it is added. +fn declared_fields(ctx: Ctx) -> BTreeSet { + match ctx { + Ctx::Schema => keys_of(&FhirSchema { + url: Some(String::new()), + name: Some(String::new()), + base: Some(String::new()), + kind: Some(String::new()), + derivation: Some(String::new()), + type_: Some(String::new()), + array: Some(true), + scalar: Some(true), + min: Some(0), + max: Some(0), + elements: Some(Default::default()), + required: Some(Vec::new()), + excluded: Some(Vec::new()), + element_reference: Some(Vec::new()), + choices: Some(Vec::new()), + choice_of: Some(String::new()), + fixed: Some(Value::Null), + pattern: Some(Value::Null), + binding: Some(Binding { + value_set: String::new(), + strength: None, + }), + constraints: Some(Default::default()), + refers: Some(Vec::new()), + slicing: Some(Slicing { + slices: Default::default(), + rules: None, + ordered: None, + }), + extensions: Some(Default::default()), + modifier: Some(true), + must_support: Some(true), + summary: Some(true), + short: Some(String::new()), + regex: Some(String::new()), + }), + Ctx::Binding => keys_of(&Binding { + value_set: String::new(), + strength: Some(String::new()), + }), + Ctx::Constraint => keys_of(&Constraint { + expression: String::new(), + severity: Some(String::new()), + human: Some(String::new()), + }), + Ctx::Slicing => keys_of(&Slicing { + slices: Default::default(), + rules: Some(String::new()), + ordered: Some(true), + }), + Ctx::Slice => keys_of(&Slice { + match_: Some(Match { + type_: None, + value: None, + resolve_ref: None, + }), + min: Some(0), + max: Some(0), + order: Some(0), + reslice: Some(String::new()), + slice_is_constraining: Some(true), + schema: Some(Arc::new(FhirSchema::default())), + }), + Ctx::Match => keys_of(&Match { + type_: Some(String::new()), + value: Some(Value::Null), + resolve_ref: Some(true), + }), + } +} + +/// IR fields the converter never emits into the core packs, with the reason. +/// +/// This is a statement about *our converter*, not about the format: every key +/// here is a valid FHIR Schema keyword we can parse but do not produce. +fn expected_absent(ctx: Ctx) -> BTreeSet { + let names: &[&str] = match ctx { + // Upstream's generated schemas mark every singular element + // `scalar: true`. Our converter emits only `array`, because the engine + // treats "not an array" as "must be singular" (`engine/walk.rs`) — + // stricter than the format's tri-state, so nothing is lost on packs we + // generate ourselves. + Ctx::Schema => &["scalar"], + // Unexercised rather than unimplemented: the core spec bundles contain + // no re-slicing, so an IG corpus would be needed to cover these. + // (`order` used to live here — the converter now emits it under + // `ordered: true`.) + Ctx::Slice => &["reslice", "sliceIsConstraining"], + // Only reachable via a `resolve()`-style discriminator, which + // `build_match` does not translate. + Ctx::Match => &["resolve-ref"], + _ => &[], + }; + names.iter().map(|s| s.to_string()).collect() +} + +// --------------------------------------------------------------------- +// Corpus walk. +// --------------------------------------------------------------------- + +/// Observed keys per context, with one example location for the failure text. +#[derive(Default)] +struct Observed { + keys: BTreeMap>, +} + +impl Observed { + fn record(&mut self, ctx: Ctx, key: &str, at: &str) { + self.keys + .entry(ctx) + .or_default() + .entry(key.to_string()) + .or_insert_with(|| at.to_string()); + } + + /// Walk a FHIR-Schema-shaped object, recursing only through *structural* + /// keys — `fixed` / `pattern` / `match.value` payloads are arbitrary FHIR + /// data and must not be read as schemas. + fn walk(&mut self, ctx: Ctx, value: &Value, at: &str) { + let Some(object) = value.as_object() else { + return; + }; + for (key, child) in object { + self.record(ctx, key, at); + let here = format!("{at}.{key}"); + match (ctx, key.as_str()) { + (Ctx::Schema, "elements" | "extensions") => { + for (name, schema) in child.as_object().into_iter().flatten() { + self.walk(Ctx::Schema, schema, &format!("{here}[{name}]")); + } + } + (Ctx::Schema, "binding") => self.walk(Ctx::Binding, child, &here), + (Ctx::Schema, "constraints") => { + for (key, constraint) in child.as_object().into_iter().flatten() { + self.walk(Ctx::Constraint, constraint, &format!("{here}[{key}]")); + } + } + (Ctx::Schema, "slicing") => self.walk(Ctx::Slicing, child, &here), + (Ctx::Slicing, "slices") => { + for (name, slice) in child.as_object().into_iter().flatten() { + self.walk(Ctx::Slice, slice, &format!("{here}[{name}]")); + } + } + (Ctx::Slice, "match") => self.walk(Ctx::Match, child, &here), + (Ctx::Slice, "schema") => self.walk(Ctx::Schema, child, &here), + _ => {} + } + } + } +} + +fn crate_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +/// Walk every committed pack. Returns the number of packs read. +fn walk_packs(observed: &mut Observed) -> usize { + use flate2::read::GzDecoder; + use std::io::Read; + + let mut count = 0; + for version in ["r4", "r4b", "r5", "r6"] { + let path = crate_dir().join(format!("packs/fhir_schemas_{version}.json.gz")); + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let mut json = Vec::new(); + GzDecoder::new(&bytes[..]) + .read_to_end(&mut json) + .unwrap_or_else(|e| panic!("{}: decompresses: {e}", path.display())); + let schemas: Vec = serde_json::from_slice(&json) + .unwrap_or_else(|e| panic!("{}: parses as a schema array: {e}", path.display())); + for schema in &schemas { + let name = schema + .get("url") + .and_then(Value::as_str) + .unwrap_or(""); + observed.walk(Ctx::Schema, schema, &format!("{version}:{name}")); + } + count += 1; + } + count +} + +/// Walk the inline schemas of the conformance fixtures (upstream + extended). +fn walk_fixtures(observed: &mut Observed) -> usize { + let mut count = 0; + for dir in ["tests/fixtures/upstream", "tests/fixtures/extended"] { + let Ok(entries) = std::fs::read_dir(crate_dir().join(dir)) else { + continue; + }; + let mut paths: Vec = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(); + paths.sort(); + for path in paths { + let doc: Value = serde_json::from_slice(&std::fs::read(&path).expect("fixture reads")) + .unwrap_or_else(|e| panic!("{}: parses: {e}", path.display())); + let file = path.file_name().unwrap_or_default().to_string_lossy(); + // Fixtures carry a top-level `schemas` map, and may repeat it per + // test case. + let inline = std::iter::once(doc.get("schemas")).chain( + doc.get("tests") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|t| t.get("schemas")), + ); + for schemas in inline.flatten() { + for (name, schema) in schemas.as_object().into_iter().flatten() { + observed.walk(Ctx::Schema, schema, &format!("{file}:{name}")); + count += 1; + } + } + } + } + count +} + +// --------------------------------------------------------------------- +// Guards. +// --------------------------------------------------------------------- + +/// Direction 1: nothing in the corpus uses a keyword the IR cannot hold. +/// +/// Deserialization is tolerant by design, so an unmodelled keyword is dropped +/// in silence at runtime. This is where it becomes loud. +#[test] +fn no_unknown_keywords_in_packs_or_fixtures() { + let mut observed = Observed::default(); + let packs = walk_packs(&mut observed); + let fixtures = walk_fixtures(&mut observed); + assert!(packs > 0, "no committed packs found under packs/"); + assert!( + fixtures > 0, + "no fixture schemas found under tests/fixtures/" + ); + + let mut unknown: Vec = Vec::new(); + for ctx in Ctx::ALL { + let declared = declared_fields(ctx); + for (key, at) in observed.keys.get(&ctx).into_iter().flatten() { + if !declared.contains(key) { + unknown.push(format!(" {}.{key} (first seen at {at})", ctx.name())); + } + } + } + assert!( + unknown.is_empty(), + "FHIR Schema keywords observed in the corpus that the IR does not model \ + (they are silently dropped on deserialization — add them to schema.rs \ + or document why they are ignored):\n{}", + unknown.join("\n") + ); +} + +/// Direction 2: every IR field is actually produced by the converter. +/// +/// This is the #364/#429 guard — a field that exists on the struct but that no +/// pack ever carries. `expected_absent` is the escape hatch, and it is checked +/// for equality so it cannot go stale in either direction. +#[test] +fn every_ir_field_is_emitted_or_documented_absent() { + let mut observed = Observed::default(); + assert!( + walk_packs(&mut observed) > 0, + "no committed packs found under packs/" + ); + + let mut problems: Vec = Vec::new(); + for ctx in Ctx::ALL { + let seen: BTreeSet = observed + .keys + .get(&ctx) + .into_iter() + .flatten() + .map(|(k, _)| k.clone()) + .collect(); + // A context with no instances at all in the packs says nothing about + // its fields; only `Slicing`/`Slice`/`Match` could be empty, and they + // are not. + if seen.is_empty() { + problems.push(format!( + "{}: no instance of this context appears in any pack", + ctx.name() + )); + continue; + } + let absent: BTreeSet = declared_fields(ctx).difference(&seen).cloned().collect(); + let expected = expected_absent(ctx); + for field in absent.difference(&expected) { + problems.push(format!( + "{}.{field}: declared in the IR but never emitted into any pack — \ + wire it up in the converter, or add it to `expected_absent` with the reason", + ctx.name() + )); + } + for field in expected.difference(&absent) { + problems.push(format!( + "{}.{field}: listed in `expected_absent` but the converter now emits it — \ + remove it from the list", + ctx.name() + )); + } + } + assert!( + problems.is_empty(), + "IR emission gaps:\n {}", + problems.join("\n ") + ); +}