diff --git a/.changeset/brave-pandas-shout.md b/.changeset/brave-pandas-shout.md new file mode 100644 index 0000000..4f06849 --- /dev/null +++ b/.changeset/brave-pandas-shout.md @@ -0,0 +1,13 @@ +--- +"@cosyte/transform": patch +--- + +Add a narrow reverse path, FHIR to HL7 v2: `toV2Patient(patient, trigger)` emits a complete `ADT` message carrying a `PID`, and `toV2Observation(observation, trigger)` a complete `ORU` message carrying an `OBX` (roadmap §Phase 7, shipped for two of the three scoped shapes). + +Each takes the FHIR resource **plus the v2 trigger the message should carry**, and returns the same `{ value, issues }` envelope the forward direction uses, where `value` is a complete `@cosyte/hl7` message. The trigger is required and is never inferred: no FHIR resource carries an HL7 v2 message trigger, so a missing, empty or non-string one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic without calling the builder at all, and a trigger that is not a bare token (whitespace, or a delimiter that would split MSH-9) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE` rather than being trimmed into something else. + +**This direction is lossy by design and is not a round-trip.** The IG maps v2 to FHIR and publishes no map the other way, so every row here is the inverse of a published row, and an inverse is only usable where the forward row is one-to-one. `invertCodeMap` enforces exactly that, and the many-to-one rows are refused with `TRANSFORM_CODE_NOT_INVERTIBLE` rather than resolved to their likeliest source code: `gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and `Observation.status` `entered-in-error`. An element with no v2 field in this map is flagged `TRANSFORM_NO_V2_TARGET`, a value v2 cannot carry unchanged is left out with `TRANSFORM_VALUE_NOT_REPRESENTABLE`, and a coding system with no v2 mnemonic is flagged `TRANSFORM_CODE_SYSTEM_NOT_V2` rather than written under a borrowed table. Nothing asserts that a message transformed to FHIR and back equals the original; the property suite verifies only that every emitted message parses back under `parseHL7` without a fatal error and carries the caller's trigger verbatim in MSH-9. + +Seven issue codes are added (`TRANSFORM_MISSING_TRIGGER`, `TRANSFORM_UNSUPPORTED_RESOURCE`, `TRANSFORM_RESOURCE_MALFORMED`, `TRANSFORM_NO_V2_TARGET`, `TRANSFORM_VALUE_NOT_REPRESENTABLE`, `TRANSFORM_CODE_NOT_INVERTIBLE`, `TRANSFORM_CODE_SYSTEM_NOT_V2`), additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed. They are `ISSUE_CODES` entries because they are returned rather than thrown, which is the structural line between the two registries in this package. + +The third scoped shape, a `Patient` + `Encounter` visit-carrying ADT, is deferred with a dated rationale in `documentation/decisions/0003`: the vendored parser exports no ADT assembly entry point, and hand-assembling that message structure here would invert the tier split ADR 0001 draws. diff --git a/.changeset/quiet-owls-declare.md b/.changeset/quiet-owls-declare.md new file mode 100644 index 0000000..31974ca --- /dev/null +++ b/.changeset/quiet-owls-declare.md @@ -0,0 +1,11 @@ +--- +"@cosyte/transform": patch +--- + +Declare, rather than merely leave, what the reverse (FHIR to v2) direction cannot supply. Two issue codes are added, additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed, and no emitted segment content changes. + +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` is raised once per v2-required field that ends up absent from an emitted segment because the FHIR resource carried no source this map could ground it from. Until now that absence was silent: a `Patient` with neither `identifier` nor `name` emitted an `ADT` whose `PID` carried neither PID-3 (Patient Identifier List) nor PID-5 (Patient Name), and an `Observation` with no `status` emitted an `ORU` whose `OBX` carried no OBX-11 (Observation Result Status), both with an empty `issues` array. The field is still left absent, exactly as before, because a placeholder written to satisfy v2 structure would be a fabricated clinical value; what changes is that the receiver is no longer the first to find out. + +`TRANSFORM_NO_V2_MESSAGE_EMITTED` is raised when a conversion produces no message at all, because nothing in the resource grounded a single field of the target segment. That case previously returned `{ value: undefined, issues: [] }`, which a caller could not tell apart from a successful empty conversion. It is distinct from the refusals that name their own cause (an absent trigger, an unsupported resource type, a structurally malformed resource), each of which still returns its own code. + +Both codes carry an `error` severity, a v2 location and a FHIR path, and no value, in keeping with the value-free diagnostic contract. Callers that branch on severity will now see these two, which is the intent: an emitted message missing a v2-required field is not conformant, and the diagnostic channel is where that is said. diff --git a/CLAUDE.md b/CLAUDE.md index 0fdbf36..b13a916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,26 @@ as a trap is clinical-safety content. ## Status -- **Phases 1–6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM - message graphs, and the IG value-ConceptMap translation layer. Phases **7 (FHIR→v2)** and - **8 (profiles)** and deeper terminology are deferred. Full per-phase inventory: +- **Phases 1-6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM + message graphs, and the IG value-ConceptMap translation layer. Full per-phase inventory: `documentation/agent-notes.md#shipped-phase-history-phases-16`. +- **Phase 7 (FHIR→v2) shipped NARROWLY, and the narrowness is the point**: `toV2Patient` and + `toV2Observation` emit a **complete** v2 message (`ADT^` + PID, `ORU^` + OBX) + from the subset of the IG segment maps whose **inverse is one-to-one**. The **trigger is a required + argument on every entry point** and is never inferred: no FHIR resource carries one. + **▶ THE IG PUBLISHES NO FHIR-TO-V2 MAP**, so a many-to-one forward row has no usable inverse and is + refused, never resolved to its most likely source code; and **round-trip is asserted only as + "parses back", never as "equals"**. **▶ AND ABSENT IS NOT THE SAME AS SILENT**: a v2-REQUIRED field + the resource gives no source for (PID-3, PID-5, OBX-11) stays absent and RAISES + `TRANSFORM_V2_REQUIRED_FIELD_ABSENT`, and a conversion that grounds no field at all raises + `TRANSFORM_NO_V2_MESSAGE_EMITTED` instead of returning an empty success. **The usage cells behind + those rows are asserted, NOT extracted** (the pass that wrote them had no network egress), so + re-extract before trusting or widening them. The `Patient` + `Encounter` visit-carrying ADT is + **deferred, not dropped**: the vendored parser exports no ADT assembly entry point (measured, zero + occurrences in its `dist/`), and hand-assembling PID + PV1 here would invert the tier split. Every + measurement, the refusal set, and the deferral: + `documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. Phase **8 + (profiles)** and deeper terminology remain deferred. - **Never quote a version here.** This line read "not yet published to npm" for several releases after first publish, which is part of why a `VERSION` constant stuck at `"0.0.0"` shipped unnoticed. Derive it: `npm view @cosyte/transform version`. diff --git a/README.md b/README.md index a0acf2b..a8f2340 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh > via `toFhir(msg)`, and **terminology value translation** of coded fields: route/site, > appointment type, order priority, and substitution are now value-translated through their IG > `mappedVia` ConceptMaps via `toFhirCodeableConceptVia`, fail-safe on any code the IG leaves unmapped. -> The v2→FHIR direction is feature-complete for the IG-covered message set; deeper -> terminology, profiles, and the reverse FHIR → v2 direction are not implemented. +> The v2→FHIR direction is feature-complete for the IG-covered message set. It also ships a +> **narrow reverse path**, FHIR → v2: `toV2Patient(patient, trigger)` and +> `toV2Observation(observation, trigger)` emit a complete v2 message carrying a `PID` or an `OBX`. +> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented. ## Install @@ -134,6 +136,54 @@ its `(unmapped)` group is flagged, never coerced to a neighbour. Fields whose IG (RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented. +## Emit v2 back out, narrowly + +Two entry points go the other way, FHIR → v2. Each takes the FHIR resource **plus the v2 trigger you +want the message to carry**, and returns the same `{ value, issues }` envelope, where `value` is a +complete `@cosyte/hl7` message: + +| Function | in | out | +| --------------------------------------- | ------------------ | ------------------------------------------ | +| `toV2Patient(patient, trigger)` | FHIR `Patient` | a v2 `ADT^` message with a `PID` | +| `toV2Observation(observation, trigger)` | FHIR `Observation` | a v2 `ORU^` message with an `OBX` | + +```ts +import { parseResource } from "@cosyte/fhir"; +import { toV2Patient } from "@cosyte/transform"; + +const { resource } = parseResource(patientJson); +const { value, issues } = toV2Patient(resource, "A28", { + assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" }, + envelope: { sendingApp: "EHR", sendingFacility: "MAIN" }, +}); +// value.toString() -> "MSH|^~\\&|EHR|MAIN|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r" +``` + +**The trigger is required and is never inferred.** No FHIR resource carries an HL7 v2 message +trigger, so there is nothing to derive one from: supply it, or the call returns no message and one +`TRANSFORM_MISSING_TRIGGER` diagnostic, without building anything. + +**This direction is lossy by design, and it is not a round-trip.** The published mapping guide runs +v2 → FHIR, and several of its rows are many-to-one, so their inverse is ambiguous and is **refused**: +`gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and +`Observation.status` `entered-in-error` each leave their v2 field absent with a +`TRANSFORM_CODE_NOT_INVERTIBLE` diagnostic rather than picking one of the v2 codes that could have +produced them. An element with no v2 field in this narrow map is flagged +(`TRANSFORM_NO_V2_TARGET`), a value v2 cannot carry unchanged is flagged and left out +(`TRANSFORM_VALUE_NOT_REPRESENTABLE`), and a coding system with no v2 mnemonic is flagged rather than +written under a borrowed table (`TRANSFORM_CODE_SYSTEM_NOT_V2`). Nothing here reconstructs the +message a resource came from, and nothing claims to. + +**What v2 requires but your resource does not carry is left absent, and said out loud.** A `PID` +needs PID-3 (Patient Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation +Result Status). A resource that gives no source for one of them still gets a message with that field +absent, never a placeholder invented to satisfy v2 structure, and one +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` diagnostic per field, carrying the v2 location and the FHIR path +it would have come from. A resource that grounds no field of the target segment at all returns no +message and one `TRANSFORM_NO_V2_MESSAGE_EMITTED`, so an empty-handed conversion is never mistaken +for a successful one. Both are `error` severity: an emitted message missing a field v2 requires is +not conformant, and this is where you find that out rather than at the receiver. + ## License MIT © Cosyte diff --git a/docs-content/guides-overview.md b/docs-content/guides-overview.md index d75dae6..70d54ff 100644 --- a/docs-content/guides-overview.md +++ b/docs-content/guides-overview.md @@ -41,6 +41,74 @@ toFhirDateTime(parseDtm("20260721143000"), { assumeTimezoneOffsetMinutes: -300 } // => { value: "2026-07-21T14:30:00-05:00", issues: [ TRANSFORM_TIMESTAMP_NO_TIMEZONE ] } ``` +## Emit a v2 message from a FHIR resource + +The reverse path is narrow on purpose: a `Patient` becomes an `ADT`-shaped message carrying a `PID`, +an `Observation` becomes an `ORU`-shaped message carrying an `OBX`. **You supply the trigger.** No +FHIR resource carries an HL7 v2 message trigger, so there is nothing to infer one from, and an +absent one returns no message plus a `TRANSFORM_MISSING_TRIGGER` diagnostic. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","gender":"female"}'); + +const emitted = toV2Patient(resource, "A28"); +emitted.value?.toString().includes("ADT^A28"); // => true + +const refused = toV2Patient(resource, ""); +refused.value; // => undefined +refused.issues[0]?.code === ISSUE_CODES.TRANSFORM_MISSING_TRIGGER; // => true +``` + +The direction is **lossy by design and not a round-trip**. The mapping guide runs v2 to FHIR, and +several of its rows are many-to-one, so their inverse is ambiguous: `gender` `other` could have come +from three different v2 codes, so it is refused rather than resolved to one of them. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","gender":"other"}'); +const { issues } = toV2Patient(resource, "A28"); + +issues[0]?.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE; // => true +issues[0]?.v2Location; // => "PID.8" +``` + +The same rule covers what v2 requires and your resource does not carry. A `PID` needs PID-3 (Patient +Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation Result Status). None of +them has a safe default, so the field is left absent rather than padded with an invented value, and +its absence is reported: check the issues before you send the message. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","identifier":[{"value":"MRN1"}]}'); +const { value, issues } = toV2Patient(resource, "A28"); + +// The message is emitted, with the required name field absent rather than fabricated. +value?.toString().includes("PID|||MRN1"); // => true +issues[0]?.code === ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT; // => true +issues[0]?.v2Location; // => "PID.5" +``` + +If nothing in the resource grounds a single field of the target segment, there is no message at all, +and that is reported too rather than returned as an empty success. + +```ts runnable +import { toV2Patient, ISSUE_CODES } from "@cosyte/transform"; +import { parseResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient"}'); +const { value, issues } = toV2Patient(resource, "A28"); + +value; // => undefined +issues[0]?.code === ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED; // => true +``` + ## Planned guides Not yet written: assembling a full `Patient`/`Encounter`/`Observation` graph from a message, diff --git a/docs-content/intro.md b/docs-content/intro.md index 836262d..df40443 100644 --- a/docs-content/intro.md +++ b/docs-content/intro.md @@ -23,8 +23,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh > Immunization**, **SIU_S12 → Appointment**, and **MDM_T02 → DocumentReference**, plus > **terminology value translation** of coded fields: route/site, appointment type, order > priority, and substitution translated through their IG `mappedVia` ConceptMaps. The -> v2→FHIR direction is feature-complete for the IG-covered message set; deeper terminology, profiles, -> and the reverse FHIR → v2 direction are not implemented. +> v2→FHIR direction is feature-complete for the IG-covered message set. A **narrow reverse path** +> also ships, FHIR → v2: `toV2Patient(patient, trigger)` and `toV2Observation(observation, trigger)` +> emit a complete v2 message carrying a `PID` or an `OBX`, lossy by design and never a round-trip. +> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented. ## The fail-safe promise diff --git a/docs-content/troubleshooting.md b/docs-content/troubleshooting.md index ed5c882..23f6987 100644 --- a/docs-content/troubleshooting.md +++ b/docs-content/troubleshooting.md @@ -50,8 +50,26 @@ resource values; those carry PHI. ORU^R01 → DiagnosticReport + Observation, ORM_O01 / OML_O21 → ServiceRequest and RXO → MedicationRequest, and the thin IG singles VXU_V04 → Immunization, SIU_S12 → Appointment, and MDM_T02 → DocumentReference. The v2→FHIR direction is - feature-complete for the IG-covered message set; terminology depth, profiles, and the reverse - FHIR → v2 direction are not implemented. + feature-complete for the IG-covered message set; terminology depth and profiles are not + implemented. +- **Reverse (FHIR → v2) scope: two shapes, deliberately.** `toV2Patient` emits an `ADT`-shaped + message carrying a `PID`, `toV2Observation` an `ORU`-shaped message carrying an `OBX`. Both + require the caller to pass the v2 trigger (no resource carries one, so it is never inferred: a + missing one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic). The direction is + **lossy by design and not a round-trip**: a mapping row whose inverse is ambiguous is refused + (`TRANSFORM_CODE_NOT_INVERTIBLE`), an element with no v2 field in this map is flagged + (`TRANSFORM_NO_V2_TARGET`), and a value v2 cannot carry unchanged is left out + (`TRANSFORM_VALUE_NOT_REPRESENTABLE`). Emitting a `Patient` **and** an `Encounter` together as a + visit-carrying ADT is not implemented. +- **An emitted message can be missing a field v2 requires, and it tells you so.** PID-3 (Patient + Identifier List), PID-5 (Patient Name) and OBX-11 (Observation Result Status) are required fields + with no safe default: a resource that carries no source for one leaves it absent, never a + fabricated placeholder, and raises one `TRANSFORM_V2_REQUIRED_FIELD_ABSENT` per field naming the v2 + location and the FHIR path it would have come from. Supply the missing element on the resource, or + repair the message before you send it. If nothing in the resource grounds any field of the target + segment, there is no message to repair: the call returns `value: undefined` and one + `TRANSFORM_NO_V2_MESSAGE_EMITTED`, which is how an empty-handed conversion is told apart from a + successful one. - **Thin-IG-single scope**: each family covers the single trigger the IG maps and the resource-internal fields; references to resources this tier does not yet build (Immunization performer/manufacturer/location, Appointment practitioner/location participants, DocumentReference diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 0351514..5ab156d 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -68,9 +68,83 @@ flagged, never coerced; SNOMED-target maps (RXR-4 method, SCH-7 reason) stay str SNOMED bundled; and fields with no IG value map (TXA-2, RXA-5) are documented as structural, never invented. +Phase 7 opened the **reverse direction, narrowly**: `toV2Patient` and `toV2Observation` emit a +complete v2 message carrying a `PID` or an `OBX`, each requiring a caller-supplied trigger. Two of +the three shapes the phase scoped shipped; the `Patient` + `Encounter` visit-carrying ADT did not. +The measurements, the refusals and the deferral are in +`documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. + **Deferred to later phases:** deeper terminology (the full HL7 THO NamingSystem crosswalk beyond the -shipped value maps, consumer-supplied ConceptMap application), the reverse FHIR→v2 direction (Phase 7), -and profiles (Phase 8). +shipped value maps, consumer-supplied ConceptMap application), the rest of the reverse FHIR→v2 +direction, and profiles (Phase 8). + +## The reverse direction, and what it does not claim + +**The mapping authority runs one way.** The IG is a **v2 to FHIR** guide; it publishes no FHIR to v2 +map. So every reverse row here is the *inverse* of a published row, and an inverse is only usable +where the forward row is one-to-one. `invertCodeMap` is that rule as code: it keeps a target only +when exactly one source maps to it, and drops the rest. Measured against the shipped forward maps, +the dropped set is `gender` `other` (Table 0001 `O`/`A`/`N`), name use `official` (`L`/`R`) and +`temp` (`NAV`/`TEMP`), address use `work` (`B`/`O`), every `Address.type` row (`M`/`SH` both mean +`postal`), and `Observation.status` `entered-in-error` (`D`/`W`). Each one raises +`TRANSFORM_CODE_NOT_INVERTIBLE` and leaves the v2 field absent. **Do not "fix" one by picking the +most likely source code**: that is a confident wrong value in the one direction where the reader is +a clinical system, not a person. + +**Round-trip is exercised as "parses back", never as "equals".** `test/reverse/property.test.ts` +asserts that every emitted message starts `MSH|`, parses under `parseHL7` without a fatal error, and +carries the caller's trigger verbatim in MSH-9. It never asserts that a v2 message transformed to +FHIR and back equals the original, and no shipped text says it does. A bare segment would fail the +parse outright (`parseHL7` fatally rejects input whose first segment is not `MSH`), which is why +each shape emits a whole message. + +**The trigger is a required argument because nothing else can supply it.** No FHIR resource carries +an HL7 v2 message trigger. Missing, empty or non-string returns `TRANSFORM_MISSING_TRIGGER` with no +builder call at all; a string that is not *bare* (whitespace, or an HL7 delimiter, which `^` would +turn into further MSH-9 components) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE`, because it could +not be written verbatim into MSH-9.2 and trimming it would emit something the caller did not ask +for. Both were found by the fuzz suite, not by reading. + +**▶ ABSENT IS THE RIGHT WIRE. SILENT IS NOT, AND THAT DISTINCTION SHIPPED BROKEN ONCE.** The first +cut of this direction satisfied half of its own rule: a `Patient` carrying neither `identifier` nor +`name` emitted an `ADT` whose `PID` had no PID-3 and no PID-5, an `Observation` with no `status` +emitted an `OBX` with no OBX-11, and both returned an **empty `issues` array**. Nothing was +fabricated, which was the half that held. But `flagUnmapped` iterates the elements a resource +**carries**, so a wholly ABSENT element could raise nothing at all, and a wholly empty conversion +returned `{ value: undefined, issues: [] }`, indistinguishable from a successful empty one. The fix +is diagnostics-only and the wire is pinned byte for byte in both suites: each shape now declares its +required fields (`PID_REQUIRED`, `OBX_REQUIRED`), every one that no mapped content reached raises +`TRANSFORM_V2_REQUIRED_FIELD_ABSENT`, and an empty field map raises +`TRANSFORM_NO_V2_MESSAGE_EMITTED` instead of nothing. **Do not "fix" an absent required field by +supplying a default**: an OBX-11 defaulted to `F` reports a result as final that the sender never +called final. + +**▶ AND THE USAGE CELLS BEHIND THOSE ROWS ARE ASSERTED, NOT EXTRACTED. THAT IS A DISCLOSED GAP, NOT A +CLEARED ONE.** Each row claims one cell of a published v2.5.1 segment attribute table (the OPT column +reading `R`), from Chapter 3 §3.4.2 for PID and Chapter 7 §7.4.2 for OBX. The pass that wrote them +**could not open either publication** (no network egress), so it asserted the four cells rather than +reading them out, and said so in the banner above `RequiredV2Field` in `src/reverse/message.ts`. +The rule the field-number corroboration left behind is kept literally: **an item number appears only +where this repository already extracted one**, so PID-3 carries `00106` and PID-5 carries `00108` +(from the dated Chapter 3 extraction in `test/scripts/phi-scan.test.ts`) and the two OBX rows carry +none at all. **Re-extract all four the next time a reader has the tables open**, and re-extract +before adding a fifth: a wrong usage cell is a false diagnostic on a clinical field, and unlike a +wrong field number it fires on every conversion rather than once. + +**Composites are structured, never concatenated.** Field content goes to `addSegment` as a +`RawField` of components, so the serializer owns escaping: a family name of `Do^e|Public` emits as +`Do\S\e\F\Public` and reads back identically, where a hand-joined `"Do^e"` string would have become +two components. Nothing in `src/reverse` writes a `^` or a `~`. + +**Deferred, and why: the `Patient` + `Encounter` visit-carrying ADT.** The vendored `@cosyte/hl7` +this repository actually builds and tests against exports no ADT assembly entry point (no +`buildAdt`, and no `buildOru`/`encodeComposite` either): measured on the installed package, zero +occurrences in both `dist/index.d.ts` and `dist/index.mjs`. Assembling PID + PV1 by hand instead +would be this package inventing a message-structure layout that the parser tier owns, which is the +opposite of the tier split ADR 0001 draws. The mapping itself is not the blocker and the deferral is +dated and written out in `documentation/decisions/`. **Re-measure the vendored package before +picking it up again** (`pnpm vendor:refresh` is a by-hand job, and the tarballs are unwatched by both +Dependabot routes): the entry point may exist in a later parser release than the one vendored here. ## Publish state, and the stale claim inside it diff --git a/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md b/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md new file mode 100644 index 0000000..2326ab1 --- /dev/null +++ b/documentation/decisions/0003-the-reverse-direction-ships-narrowly-and-defers-the-visit-carrying-adt.md @@ -0,0 +1,75 @@ +# 0003: The reverse direction ships narrowly, and the visit-carrying ADT is deferred + +- **Status:** Accepted (2026-08-14) +- **Scope:** `@cosyte/transform` (`src/reverse`, `toV2Patient`, `toV2Observation`) +- **Relates to:** transform roadmap (`operations/roadmaps/transform.md` §Phase 7), ADR 0001 (the + transformation tier may depend on the parser tier, and does not re-implement it), umbrella ADR 0018 + (grounded on the published map, never invented). + +## Context + +Phase 7 scoped three reverse (FHIR to v2) shapes: `Patient` to a message carrying a `PID`, +`Observation` to a message carrying an `OBX`, and `Patient` + `Encounter` to a **visit-carrying ADT** +assembled through the parser tier's own ADT entry point. + +Two facts, both measured against the checkout rather than assumed, decided how much of that shipped. + +1. **The IG publishes no FHIR-to-v2 map.** `hl7.fhir.uv.v2mappings` maps v2 **to** FHIR. Every row + used in reverse here is therefore the *inverse* of a published row, and an inverse is only usable + where the forward row is one-to-one. Several of the rows this package already ships are + many-to-one: three v2 administrative-sex codes mean `other`, two name types mean `official`, two + mean `temp`, two address types mean `work`, two mean `postal`, and two result statuses mean + `entered-in-error`. + +2. **The vendored parser exports no ADT assembly entry point.** The `@cosyte/hl7` this repository + builds and tests against (the `vendor/` tarball, which is a by-hand `pnpm vendor:refresh` job and + is watched by neither Dependabot route) exports `buildMessage`, `Hl7Message.addSegment` and + `parseHL7`, and **zero** occurrences of an ADT builder, an ORU builder, or a composite encoder in + either `dist/index.d.ts` or `dist/index.mjs`. + +## Decision + +1. **Ship the two shapes that ground out; defer the third, dated, rather than guess it.** + `toV2Patient` and `toV2Observation` ship. The `Patient` + `Encounter` visit-carrying ADT does not: + the mapping is not the blocker, the entry point is. Hand-assembling a PID + PV1 message-structure + layout inside `transform` would be this tier re-implementing what the parser tier owns, which + inverts ADR 0001's split for no safety gain. Re-measure the vendored parser before picking it up: + a later parser release may export it. + +2. **Where the inverse is not one-to-one, refuse and flag.** `invertCodeMap` keeps a target only when + exactly one source maps to it. Everything else raises `TRANSFORM_CODE_NOT_INVERTIBLE` and leaves + the v2 field **absent**. Resolving `other` to Table 0001 `O` because it looks likeliest would be a + confident wrong value, in the direction where the reader is another clinical system. + +3. **The trigger is a required argument, never inferred.** No FHIR resource carries an HL7 v2 message + trigger, so there is nothing to derive one from. A missing, empty or non-string trigger returns + `TRANSFORM_MISSING_TRIGGER` on the ordinary `{ value, issues }` channel, **before** any builder + call. A string that is not a bare trigger (whitespace, or a delimiter that would split MSH-9 into + further components) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE`, because it cannot be carried into + MSH-9.2 verbatim and trimming it would emit something the caller did not ask for. + +4. **The refusal codes live in `ISSUE_CODES`, not `FATAL_CODES`.** The split between the two + registries is structural in this package: a `FatalCode` is the type carried by a *thrown* error, + while `TransformIssue.code` is typed `IssueCode` and `ISSUE_REGISTRY` is exhaustive over + `ISSUE_CODES`. Since nothing here throws, every reverse refusal is returned, so it is an + `ISSUE_CODES` addition. Keying them under `FATAL_CODES` would have meant widening + `TransformIssue.code` and blurring the very split the module documents. Both registries stay + additions-only: no existing key was renamed or removed. + +5. **Emit whole messages, and verify only that they parse back.** Each shape returns the complete + message (`buildMessage(...).addSegment(...)`), never a bare segment, which the parser would + fatally reject for having no leading `MSH`. The property suite asserts *parses back under + `parseHL7` without a fatal error*, and that MSH-9 carries the caller's trigger verbatim. It does + **not** assert, and no shipped text claims, that a message transformed to FHIR and back equals the + original: this direction is lossy by construction. + +## Consequences + +- **Positive.** A consumer who already trusts the forward direction can emit demographics and result + observations back out, with every loss surfaced as a typed, value-free diagnostic instead of a + silent approximation. The bijective-subset rule is executable (`invertCodeMap`) rather than a + comment, so it cannot drift from the forward maps it inverts. +- **Negative / cost.** The reverse output is deliberately thin: an identifier's assigning authority + appears only when the caller seeds it, several codes are refused outright, and there is no + visit-carrying ADT at all. Consumers who want a fuller message must supply the missing context + themselves, and this package will keep refusing to invent it. diff --git a/src/diagnostics/codes.ts b/src/diagnostics/codes.ts index 481f469..14489af 100644 --- a/src/diagnostics/codes.ts +++ b/src/diagnostics/codes.ts @@ -14,6 +14,16 @@ * entry points. **Nothing in this library throws one today**: the datatype converters and * `toFhir` alike fail safe to a value-free issue instead. * + * **The line between the two registries is structural, and it decides where a new code goes.** A + * `FatalCode` is the type carried by a *thrown* error; a `TransformIssue.code` is typed `IssueCode` + * and {@link ISSUE_REGISTRY} is exhaustive over {@link ISSUE_CODES} alone. So a condition that is + * *returned* on the non-throwing `{ value, issues }` channel is an {@link ISSUE_CODES} entry by + * construction, however severe it is: the missing-trigger refusal on the reverse + * (FHIR to v2) entry points is returned, never thrown, so it is + * {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} rather than a fatal code. Adding it to + * {@link FATAL_CODES} instead would have required widening `TransformIssue.code` to + * `IssueCode | FatalCode` and blurring exactly that split. + * * @packageDocumentation */ @@ -78,6 +88,47 @@ export const ISSUE_CODES = { * emitted with a `data-absent-reason` extension (value `unknown`) rather than fabricated, e.g. * `MessageHeader.source.endpoint` from an MSH-3 application namespace that is not a URL. */ TRANSFORM_REQUIRED_ELEMENT_UNKNOWN: "TRANSFORM_REQUIRED_ELEMENT_UNKNOWN", + /** A reverse (FHIR to v2) conversion was called without the explicit message trigger it requires. + * No FHIR resource carries a v2 trigger, so it is never inferred from resource content and never + * defaulted: no message is built and no builder is called. */ + TRANSFORM_MISSING_TRIGGER: "TRANSFORM_MISSING_TRIGGER", + /** A reverse (FHIR to v2) conversion was handed a resource outside the narrow set of shapes it + * supports. No v2 message is emitted and no segment layout is guessed. */ + TRANSFORM_UNSUPPORTED_RESOURCE: "TRANSFORM_UNSUPPORTED_RESOURCE", + /** A resource handed to a reverse (FHIR to v2) conversion is not structurally a FHIR resource of + * its expected type (not an object, no `resourceType`, or an element carrying the wrong node + * kind). Nothing is emitted and nothing is thrown. */ + TRANSFORM_RESOURCE_MALFORMED: "TRANSFORM_RESOURCE_MALFORMED", + /** A populated FHIR element has no v2 field in the narrow reverse map that covers this shape + * (the IG map defines no source for it, or its inverse is not implemented here). The element is + * surfaced and left out, never approximated into a neighbouring v2 field. */ + TRANSFORM_NO_V2_TARGET: "TRANSFORM_NO_V2_TARGET", + /** A FHIR value cannot be carried into its v2 target without altering it (a lexical form v2 has + * no representation for, or content that exceeds the target's component structure). The v2 field + * is left absent rather than truncated, rounded, or coerced. */ + TRANSFORM_VALUE_NOT_REPRESENTABLE: "TRANSFORM_VALUE_NOT_REPRESENTABLE", + /** A FHIR code has no usable inverse in the IG ConceptMap that governs its field: either several + * v2 source codes map onto it (so the inverse is ambiguous) or the map has no row for it at all. + * The v2 field is left absent, never resolved to one arbitrary member of the ambiguous set. */ + TRANSFORM_CODE_NOT_INVERTIBLE: "TRANSFORM_CODE_NOT_INVERTIBLE", + /** A `Coding.system` is not a system this library can name with a v2 coding-system mnemonic, so + * the coding cannot be written into a v2 coded field. It is flagged rather than emitted with no + * table context or with a code from an unrelated table. */ + TRANSFORM_CODE_SYSTEM_NOT_V2: "TRANSFORM_CODE_SYSTEM_NOT_V2", + /** A v2 field the segment's own attribute table marks **required** (usage `R`) is absent from an + * emitted segment, because the FHIR resource carried no source this reverse map could ground it + * from, or the source it carried had no faithful v2 form. The field is left absent per v2 + * optionality rules and **declared here**: a placeholder is never written to satisfy v2 structure, + * and the receiver is never left to discover the gap. Distinct from + * {@link ISSUE_CODES.TRANSFORM_REQUIRED_ELEMENT_UNKNOWN}, which is the forward direction's + * FHIR-required element. */ + TRANSFORM_V2_REQUIRED_FIELD_ABSENT: "TRANSFORM_V2_REQUIRED_FIELD_ABSENT", + /** A reverse (FHIR to v2) conversion produced **no message at all**: nothing in the resource + * grounded a single field of the target segment, and an empty segment is never emitted. Raised so + * that an empty-handed conversion is never indistinguishable from a successful one, and separate + * from the refusals that name their own cause (an absent trigger, an unsupported resource type, a + * structurally malformed resource). */ + TRANSFORM_NO_V2_MESSAGE_EMITTED: "TRANSFORM_NO_V2_MESSAGE_EMITTED", } as const; /** A value from {@link ISSUE_CODES}: the type consumers narrow `issue.code` against. */ diff --git a/src/diagnostics/issue.ts b/src/diagnostics/issue.ts index 01ad5cd..50596fd 100644 --- a/src/diagnostics/issue.ts +++ b/src/diagnostics/issue.ts @@ -129,6 +129,60 @@ export const ISSUE_REGISTRY: Readonly> = Object.fre message: "required FHIR element could not be derived from the source; emitted with a data-absent-reason extension, never fabricated.", }, + [ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]: { + severity: "error", + fhirIssueType: "required", + message: + "reverse conversion requires an explicit v2 trigger from the caller; no resource carries one, so none was inferred and no message was built.", + }, + [ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE]: { + severity: "error", + fhirIssueType: "not-supported", + message: + "resource type is outside the reverse converter's supported shapes; no v2 message emitted and no segment layout guessed.", + }, + [ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED]: { + severity: "error", + fhirIssueType: "structure", + message: + "input is not a structurally well-shaped FHIR resource for this conversion; nothing emitted, nothing thrown.", + }, + [ISSUE_CODES.TRANSFORM_NO_V2_TARGET]: { + severity: "information", + fhirIssueType: "informational", + message: + "populated FHIR element has no v2 field in this reverse map; surfaced and left out, never approximated into a neighbouring field.", + }, + [ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE]: { + severity: "warning", + fhirIssueType: "value", + message: + "FHIR value cannot be carried into its v2 target without altering it; the v2 field is left absent, never truncated, rounded, or coerced.", + }, + [ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE]: { + severity: "warning", + fhirIssueType: "code-invalid", + message: + "FHIR code has no usable inverse in the governing IG ConceptMap (ambiguous or absent); v2 field left absent, never resolved arbitrarily.", + }, + [ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2]: { + severity: "warning", + fhirIssueType: "code-invalid", + message: + "coding system has no v2 coding-system mnemonic here; coding flagged rather than written with no table context or from an unrelated table.", + }, + [ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]: { + severity: "error", + fhirIssueType: "required", + message: + "a v2-required field is absent from the emitted segment because the resource carried no source this map could ground it from; left absent per v2 optionality rules and declared here, never filled with a placeholder.", + }, + [ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED]: { + severity: "error", + fhirIssueType: "processing", + message: + "no v2 message was emitted: nothing in the resource grounded a single field of the target segment, and an empty segment is never emitted.", + }, }); /** diff --git a/src/index.ts b/src/index.ts index 7de7bb5..7283c0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,15 @@ * all through `toFhir(msg)`), and the **terminology value-translation** layer: a * `$translate`-shaped {@link toFhirCodeableConceptVia} engine applying the license-clean IG value * ConceptMaps to the previously structural-only coded fields (route/site, appointment type, order - * priority, substitution). Terminology **depth beyond these maps**, profiles, and the reverse - * FHIR → v2 direction are not implemented. + * priority, substitution). + * + * It also ships a **narrow reverse path**, FHIR → v2: {@link toV2Patient} and + * {@link toV2Observation} emit a complete v2 message carrying a `PID` or an `OBX` built from the + * subset of the IG segment maps whose inverse is defensible. Each requires the caller to supply the + * v2 trigger, because no FHIR resource carries one. It is **lossy by design and not a round-trip**: + * a value the inverse of the IG map cannot ground is flagged and left absent, never guessed. + * Terminology **depth beyond these maps**, profiles, and any wider FHIR → v2 conversion are not + * implemented. * * @packageDocumentation */ @@ -77,7 +84,11 @@ export { ADMINISTRATIVE_GENDER_MAP } from "./messages/patient.js"; export { ENCOUNTER_CLASS_V3_MAP, ENCOUNTER_STATUS_MAP } from "./messages/encounter.js"; // ── Message-level assembly: HL7 v2 ORU^R01 → FHIR DiagnosticReport + Observation graph (Phase 3) ── -export { OBSERVATION_STATUS_MAP, HL70078_INTERPRETATION_CODES } from "./messages/observation.js"; +export { + OBSERVATION_STATUS_MAP, + HL70078_INTERPRETATION_CODES, + V3_OBSERVATION_INTERPRETATION_SYSTEM, +} from "./messages/observation.js"; export { DIAGNOSTIC_REPORT_STATUS_MAP } from "./messages/diagnostic-report.js"; // ── Message-level assembly: HL7 v2 ORM/OML → ServiceRequest, RXO → MedicationRequest (Phase 4) ──── @@ -109,3 +120,10 @@ export { } from "./terminology/concept-map.js"; export type { CodedTarget, CodedValueMap } from "./terminology/concept-map.js"; export { SERVICE_REQUEST_PRIORITY_MAP } from "./messages/service-request.js"; + +// ── The reverse direction: FHIR → a narrow, explicitly-scoped v2 message subset (Phase 7) ──────── +export { toV2Patient, GENDER_TO_V2, NAME_USE_TO_V2, ADDRESS_USE_TO_V2 } from "./reverse/patient.js"; +export { toV2Observation, OBSERVATION_STATUS_TO_V2 } from "./reverse/observation.js"; +export { invertCodeMap } from "./reverse/v2.js"; +export type { ReverseOptions } from "./reverse/coding.js"; +export type { ReverseResult } from "./reverse/message.js"; diff --git a/src/messages/observation.ts b/src/messages/observation.ts index 65b640c..de64c8a 100644 --- a/src/messages/observation.ts +++ b/src/messages/observation.ts @@ -49,8 +49,16 @@ import type { ConvertResult } from "../diagnostics/result.js"; import type { TransformContext } from "../terminology/context.js"; import { reference } from "./reference.js"; -/** The v3 ObservationInterpretation canonical system (FHIR `Observation.interpretation` binding). */ -const V3_OBSERVATION_INTERPRETATION_SYSTEM = +/** + * The v3 ObservationInterpretation canonical system (FHIR `Observation.interpretation` binding). + * + * @example + * ```ts + * import { V3_OBSERVATION_INTERPRETATION_SYSTEM } from "@cosyte/transform"; + * V3_OBSERVATION_INTERPRETATION_SYSTEM.endsWith("v3-ObservationInterpretation"); // => true + * ``` + */ +export const V3_OBSERVATION_INTERPRETATION_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"; /** diff --git a/src/reverse/coding.ts b/src/reverse/coding.ts new file mode 100644 index 0000000..cf4cc6b --- /dev/null +++ b/src/reverse/coding.ts @@ -0,0 +1,225 @@ +/** + * Codes and coding systems on the way out to v2, and the caller-supplied context that resolution + * needs. + * + * A v2 coded field names its vocabulary with a Table 0396 mnemonic (`LN`, `SCT`, `UCUM`), so writing + * a FHIR `Coding` into one means turning a canonical system URI back into that mnemonic. There is no + * algorithm for that, only a registry, so this module inverts the same seed the forward direction + * resolves through ({@link DEFAULT_V2_CODE_SYSTEMS} plus whatever the caller adds) and **refuses + * everything else**: an unrecognized system is flagged, never written into a coded field with no + * table context and never re-coded into a neighbouring table. + * + * The inversion keeps only URIs exactly one mnemonic names, for the same reason the code-map + * inversion does: two mnemonics for one URI is an ambiguity, not a choice to make silently. + * + * @packageDocumentation + */ + +import type { BuildMessageInit } from "@cosyte/hl7"; +import type { FhirComplex } from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { DEFAULT_V2_CODE_SYSTEMS } from "../terminology/naming-system.js"; +import { at, readComplexes, readString } from "./read.js"; +import { invertCodeMap, type V2Components } from "./v2.js"; + +/** + * Caller context for a reverse (FHIR to v2) conversion. Every entry is **caller-vetted**: nothing + * here is derived from resource content, and omitting all of it is safe (the conversion then flags + * what it cannot resolve rather than guessing it). + * + * @example + * ```ts + * import { toV2Patient } from "@cosyte/transform"; + * const options = { + * assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" }, + * envelope: { sendingApp: "EHR", sendingFacility: "MAIN" }, + * }; + * void options; + * void toV2Patient; + * ``` + */ +export interface ReverseOptions { + /** + * Extra v2 coding-system mnemonic to canonical URI entries, merged over + * {@link DEFAULT_V2_CODE_SYSTEMS} and then inverted. Same shape and direction as the forward + * registry's seed, so one declaration serves both directions. + */ + readonly codeSystems?: Readonly>; + /** + * `Identifier.system` URI to the v2 assigning-authority namespace (HD.1) that stands for it. There + * is no derivation from a URI to a namespace, so an identifier whose system is absent here is + * emitted with its value and **no** assigning authority, flagged rather than invented. + */ + readonly assigningAuthorities?: Readonly>; + /** + * MSH envelope fields for the emitted message (sending/receiving application and facility, control + * id, timestamp, version, processing id). The message type is never taken from here: it is fixed + * by the shape plus the caller's trigger argument. + */ + readonly envelope?: Omit; +} + +/** The resolved, inverted lookups one conversion runs against. */ +export interface ReverseContext { + /** The v2 Table 0396 mnemonic for a canonical system URI, or `undefined` when unresolvable. */ + readonly mnemonicFor: (system: string) => string | undefined; + /** The v2 assigning-authority namespace for an `Identifier.system`, or `undefined`. */ + readonly namespaceFor: (system: string) => string | undefined; + /** The MSH envelope fields to build the message with. */ + readonly envelope: Omit; +} + +/** + * Resolve a {@link ReverseOptions} into the lookups a conversion consults. + * + * @param options - The caller's reverse options. + * @example + * ```ts + * // reverseContext({ codeSystems: { LOCAL: "http://example.org/cs" } }).mnemonicFor("http://loinc.org") + * // => "LN" + * ``` + */ +export function reverseContext(options: ReverseOptions = {}): ReverseContext { + const mnemonics = invertCodeMap({ ...DEFAULT_V2_CODE_SYSTEMS, ...options.codeSystems }); + const authorities = options.assigningAuthorities ?? {}; + return { + mnemonicFor: (system) => (Object.hasOwn(mnemonics, system) ? mnemonics[system] : undefined), + namespaceFor: (system) => + Object.hasOwn(authorities, system) ? authorities[system] : undefined, + envelope: options.envelope ?? {}, + }; +} + +/** One `Coding`, read into the three v2 components a coded triplet carries. */ +interface V2Coding { + readonly code: string | undefined; + readonly display: string | undefined; + readonly mnemonic: string; +} + +/** + * Read one `Coding` into its v2 triplet, or `undefined` when its system has no v2 mnemonic (flagged) + * or it carries no code at all. + */ +function readCoding( + coding: FhirComplex, + ctx: ReverseContext, + location: string, + fhirPath: string, + issues: TransformIssue[], +): V2Coding | undefined { + const code = readString(at(coding, "code"), location, `${fhirPath}.code`, issues); + const display = readString(at(coding, "display"), location, `${fhirPath}.display`, issues); + const system = readString(at(coding, "system"), location, `${fhirPath}.system`, issues); + if (code === undefined) return undefined; + const mnemonic = system === undefined ? undefined : ctx.mnemonicFor(system); + if (mnemonic === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, location, `${fhirPath}.system`)); + return undefined; + } + return { code, display, mnemonic }; +} + +/** + * A FHIR `CodeableConcept` as CWE components (CWE.1/2/3 primary triplet, CWE.4/5/6 alternate, + * CWE.9 original text), or `undefined` when nothing in it can be written to a v2 coded field. + * + * Codings whose system has no v2 mnemonic are flagged and left out: the concept degrades to the + * codings that resolve, and to `CodeableConcept.text` when none do, which is the exact inverse of + * the CWE to CodeableConcept map's `CWE.9` row. + * + * @param concept - The `CodeableConcept` node. + * @param ctx - The resolved reverse context. + * @param location - The v2 target location (e.g. `"OBX.3"`), for value-free issues. + * @param fhirPath - The FHIR path being converted. + * @param issues - The issue sink. + * @example + * ```ts + * // codeableToCwe(observationCode, ctx, "OBX.3", "Observation.code", issues) + * // => ["789-8", "Hemoglobin", "LN"] + * ``` + */ +export function codeableToCwe( + concept: FhirComplex, + ctx: ReverseContext, + location: string, + fhirPath: string, + issues: TransformIssue[], +): V2Components | undefined { + const codings: V2Coding[] = []; + for (const coding of readComplexes( + at(concept, "coding"), + location, + `${fhirPath}.coding`, + issues, + )) { + const read = readCoding(coding, ctx, location, `${fhirPath}.coding`, issues); + if (read !== undefined) codings.push(read); + } + const text = readString(at(concept, "text"), location, `${fhirPath}.text`, issues); + if (codings.length === 0 && text === undefined) return undefined; + + // A third and further coding has no CWE slot: CWE carries a primary and one alternate triplet. + if (codings.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, location, `${fhirPath}.coding`), + ); + } + const primary = codings[0]; + const alternate = codings[1]; + return [ + primary?.code, + primary?.display, + primary?.mnemonic, + alternate?.code, + alternate?.display, + alternate?.mnemonic, + undefined, + undefined, + text, + ]; +} + +/** + * The code a concept carries in one specific system, or `undefined` when it carries none there. + * Used where a v2 field is a bare table code rather than a full CWE (an identifier type, an abnormal + * flag): a coding from any other system is flagged, never re-read as if it were from this table. + * + * @param concept - The `CodeableConcept` node. + * @param system - The canonical system URI the v2 table corresponds to. + * @param location - The v2 target location, for value-free issues. + * @param fhirPath - The FHIR path being converted. + * @param issues - The issue sink. + * @example + * ```ts + * // codeInSystem(identifierType, V2_0203_SYSTEM, "CX.5", "Identifier.type", issues) -> "MR" + * ``` + */ +export function codeInSystem( + concept: FhirComplex, + system: string, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + let found: string | undefined; + let foreign = false; + for (const coding of readComplexes( + at(concept, "coding"), + location, + `${fhirPath}.coding`, + issues, + )) { + const codingSystem = readString(at(coding, "system"), location, `${fhirPath}.system`, issues); + const code = readString(at(coding, "code"), location, `${fhirPath}.code`, issues); + if (code === undefined) continue; + if (codingSystem === system) found ??= code; + else foreign = true; + } + if (found === undefined && foreign) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, location, `${fhirPath}.system`)); + } + return found; +} diff --git a/src/reverse/message.ts b/src/reverse/message.ts new file mode 100644 index 0000000..035766d --- /dev/null +++ b/src/reverse/message.ts @@ -0,0 +1,306 @@ +/** + * The scaffolding every reverse (FHIR to v2) entry point shares: the required trigger, the resource + * gate, and the message the mapped segment is carried in. + * + * **The trigger is always the caller's.** No FHIR resource carries an HL7 v2 message trigger: not + * `Patient`, not `Observation`, not `Encounter`. So it is a required argument, and a missing, empty + * or non-string one stops the conversion **before** any builder call, with + * {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} on the ordinary `{ value, issues }` channel. It is + * never defaulted, never derived from resource content, and never substituted with a placeholder. + * + * **What is emitted is a message, never a bare segment.** A segment on its own is not parseable HL7 + * (a v2 parser fatally rejects any input whose first segment is not `MSH`), so each shape builds a + * complete message through `buildMessage` and appends its mapped segment to it. The trigger is used + * verbatim as the trailing component of the fixed message code the shape itself owns. + * + * **What is missing is declared, not merely missing.** A FHIR resource carrying no source for a v2 + * field the segment's own attribute table marks *required* leaves that field absent, because the + * alternative is inventing content for a clinical reader. Absent is the right wire; **silent** is + * not, so each shape names its required fields ({@link ReverseShape.required}) and every one of them + * that ends up unsourced raises {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}. When nothing + * at all grounded a field, there is no segment to be missing from and no message is built: + * {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED} declares that outcome on its own, so an + * empty-handed conversion never looks like a successful one. + * + * @packageDocumentation + */ + +import { buildMessage, type Hl7Message, type RawField } from "@cosyte/hl7"; +import { isComplex, resourceType, type FhirComplex, type FhirNode } from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import type { ConvertResult } from "../diagnostics/result.js"; +import type { ReverseContext } from "./coding.js"; +import { segmentFields } from "./v2.js"; + +/** + * What a reverse conversion returns: the complete `@cosyte/hl7` message it could faithfully build + * (or `undefined` when it could not), plus the value-free diagnostics it raised. The same fail-safe + * envelope the forward converters return, in the other direction. + * + * @example + * ```ts + * import { toV2Patient } from "@cosyte/transform"; + * // const { value, issues } = toV2Patient(patientNode, "A28"); + * // value?.toString() -> "MSH|^~\\&|...|ADT^A28|...\rPID|||MRN1\r" + * void toV2Patient; + * ``` + */ +export type ReverseResult = ConvertResult; + +/** + * The resource types this library names in a diagnostic. It is **library-owned vocabulary**: the + * resource types the forward direction produces, plus the reverse shapes' own inputs. An observed + * `resourceType` outside it is reported as the generic `Resource`, so no string taken from input + * content can reach a diagnostic through the unsupported-resource path. + */ +const NAMED_RESOURCES: ReadonlySet = new Set([ + "Appointment", + "Bundle", + "DiagnosticReport", + "DocumentReference", + "Encounter", + "Immunization", + "MedicationRequest", + "MessageHeader", + "Observation", + "OperationOutcome", + "Patient", + "RelatedPerson", + "ServiceRequest", +]); + +/** Whether a caller actually supplied a trigger at all (the parameter is typed, callers may not be). */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +/** A bare MSH-9.2 trigger: one token, no whitespace and none of the HL7 delimiter characters. */ +const BARE_TRIGGER = /^[^\s|^~\\&\r\n]+$/; + +/** + * Check the caller-supplied trigger. A `false` return means **no builder call may be made**, and one + * of two value-free issues has been raised: + * + * - {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} when it is missing, empty, or not a string. There + * is nothing to fall back to: no resource carries a trigger, so none is inferred or defaulted. + * - {@link ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE} when it is a string but not a *bare* + * trigger. A value carrying a component separator or whitespace cannot be written into MSH-9.2 + * verbatim (`^` would split it into further MSH-9 components, and padding does not survive a + * parse), so it is refused rather than trimmed or escaped into something the caller did not ask + * for. + * + * @param trigger - The caller's bare v2 trigger (e.g. `"A28"`). + * @param issues - The issue sink. + * @example + * ```ts + * // hasTrigger("A28", issues) -> true; hasTrigger("", issues) -> false + one issue + * ``` + */ +export function hasTrigger(trigger: string, issues: TransformIssue[]): boolean { + if (!isNonEmptyString(trigger)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_MISSING_TRIGGER, "MSH.9.2", "trigger")); + return false; + } + if (!BARE_TRIGGER.test(trigger)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "MSH.9.2", "trigger")); + return false; + } + return true; +} + +/** + * Gate an input node into the resource a shape accepts: a complex node carrying the expected + * `resourceType`. Anything else raises {@link ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED} (not a + * resource) or {@link ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE} (a resource this converter does + * not map), and reads as nothing to convert. + * + * @param input - The FHIR node handed to the converter. + * @param expected - The `resourceType` this shape converts. + * @param location - The v2 target segment, for value-free issues. + * @param issues - The issue sink. + * @example + * ```ts + * // readResource(node, "Patient", "PID", issues) -> the Patient node, or undefined + one issue + * ``` + */ +export function readResource( + input: FhirNode, + expected: string, + location: string, + issues: TransformIssue[], +): FhirComplex | undefined { + if (!isComplex(input)) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, "Resource")); + return undefined; + } + const type = resourceType(input); + if (type === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, "Resource.resourceType")); + return undefined; + } + if (type !== expected) { + const named = NAMED_RESOURCES.has(type) ? type : "Resource"; + issues.push(issue(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE, location, named)); + return undefined; + } + return input; +} + +/** + * Flag every populated element of a resource that this reverse map does not carry into v2, one + * value-free issue each. Element **names** are reported only from the supplied table, which is + * library-owned vocabulary; an element outside it is reported against the resource itself, so no + * name taken from input content reaches a diagnostic. + * + * @param resource - The gated resource node. + * @param mapped - The element names this shape does convert. + * @param targets - The known-but-unconverted element names, each with the v2 location it would take. + * @param resourceName - The resource type, for the FHIR path. + * @param segment - The v2 segment, used when an element has no location of its own. + * @param issues - The issue sink. + * @example + * ```ts + * // flagUnmapped(patient, PATIENT_MAPPED, PATIENT_UNMAPPED, "Patient", "PID", issues) + * // -> one TRANSFORM_NO_V2_TARGET per populated element outside PATIENT_MAPPED + * ``` + */ +export function flagUnmapped( + resource: FhirComplex, + mapped: ReadonlySet, + targets: Readonly>, + resourceName: string, + segment: string, + issues: TransformIssue[], +): void { + for (const property of resource.properties) { + if (mapped.has(property.name)) continue; + const known = Object.hasOwn(targets, property.name); + issues.push( + issue( + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + known ? (targets[property.name] ?? segment) : segment, + known ? `${resourceName}.${property.name}` : resourceName, + ), + ); + } +} + +// ▶ EVERY `RequiredV2Field` ROW A SHAPE DECLARES CLAIMS ONE CELL OF A PUBLISHED HL7 +// v2.5.1 SEGMENT ATTRIBUTE TABLE: the OPT (usage) column reading `R`. The tables +// are Chapter 3 §3.4.2 (PID) and Chapter 7 §7.4.2 (OBX), the same standard text +// and the same second, version-pinned publication that `scripts/phi-scan.ts` cites +// for its field numbers, and the VERSION IS LOAD-BEARING here for the same reason +// it is there: a later v2 reads some of these cells differently, so grounding +// against the wrong version's table yields a confident wrong answer, not an error. +// +// ▶ AND THE USAGE CELL WAS NOT EXTRACTED BY THE PASS THAT WROTE THESE ROWS, WHICH +// IS SAID HERE RATHER THAN LEFT TO BE DISCOVERED. That pass ran with no network +// egress and could not open either publication, so the four rows below are +// ASSERTED from the attribute tables rather than read out of them. This repository +// has already measured what that costs once: a `PV1-7` item number written from +// recall, right by luck, and invisible either way. So the rule that work left +// behind is kept literally here too: AN ITEM NUMBER IS WRITTEN ONLY WHERE THIS +// REPOSITORY HAS ALREADY EXTRACTED ONE. `PID-3` (`00106`) and `PID-5` (`00108`) +// carry theirs, from the Chapter 3 extraction dated 2026-08-08 in +// `test/scripts/phi-scan.test.ts`; the OBX rows carry NONE, because nothing here +// has ever extracted one. +// +// ▶ RE-EXTRACT BEFORE ADDING A ROW, AND RE-EXTRACT THESE FOUR THE NEXT TIME A +// READER HAS THE TABLES OPEN. A wrong usage cell is a FALSE DIAGNOSTIC ON A +// CLINICAL FIELD, the same harm class the field-number corroboration exists for, +// and unlike a wrong field number it fires on every conversion rather than once. + +/** + * One v2 field the segment's own attribute table marks **required**, paired with the FHIR element + * this reverse map would have sourced it from. A required field with no source is left absent (v2 + * optionality is a receiver-side contract, and a placeholder would be a fabricated clinical value), + * and declared: absence is the right wire, silence is not. + * + * @example + * ```ts + * // { position: 3, location: "PID.3", fhirPath: "Patient.identifier" } + * ``` + */ +export interface RequiredV2Field { + /** The 1-based HL7 field position within the segment (`3` is the third field). */ + readonly position: number; + /** The v2 location the issue reports (`"PID.3"`), never a value. */ + readonly location: string; + /** The FHIR path this map sources the field from (`"Patient.identifier"`). */ + readonly fhirPath: string; +} + +/** + * A reverse shape's fixed identity: the message code it emits under, the segment it carries, the + * resource it converts, and that segment's required fields. All four are library-owned constants; + * none is derived from input content, so none can carry a value into a diagnostic. + * + * @example + * ```ts + * // { messageCode: "ADT", segment: "PID", resourceName: "Patient", required: PID_REQUIRED } + * ``` + */ +export interface ReverseShape { + /** The fixed MSH-9.1 message code (`"ADT"`, `"ORU"`). */ + readonly messageCode: string; + /** The segment this shape appends (`"PID"`, `"OBX"`). */ + readonly segment: string; + /** The resource type this shape converts, for the FHIR path of a whole-message diagnostic. */ + readonly resourceName: string; + /** The segment's v2-required fields, in field order. */ + readonly required: readonly RequiredV2Field[]; +} + +/** + * Build the complete message for a shape: `buildMessage` with the shape's fixed message code and the + * caller's trigger, then the mapped segment appended to it, and finally the honest account of what + * the resource could not supply. + * + * Two outcomes, and each is declared: + * + * - **Nothing grounded a field.** No message is built (an empty segment is never emitted) and + * {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED} is raised, so `{ value: undefined }` can + * never be read as a successful empty conversion. The required-field rows are *not* also raised + * here: there is no emitted segment for a field to be absent from, and restating it per field + * would add noise, not information. + * - **A message is built.** Every one of the shape's required fields that no mapped content reached + * raises {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}. The wire is untouched by this: + * the issue reports the field's absence, it never fills it. + * + * @param shape - The shape's fixed identity and its segment's required fields. + * @param trigger - The caller's bare trigger, used verbatim as MSH-9.2. + * @param byPosition - The mapped fields, keyed by 1-based HL7 field position. + * @param ctx - The resolved reverse context (its `envelope` supplies the MSH fields). + * @param issues - The issue sink. + * @example + * ```ts + * // emitMessage(PATIENT_SHAPE, "A28", fields, ctx, issues)?.toString() + * // -> "MSH|^~\\&|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r" + * ``` + */ +export function emitMessage( + shape: ReverseShape, + trigger: string, + byPosition: ReadonlyMap, + ctx: ReverseContext, + issues: TransformIssue[], +): Hl7Message | undefined { + if (byPosition.size === 0) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, shape.segment, shape.resourceName), + ); + return undefined; + } + for (const field of shape.required) { + if (byPosition.has(field.position)) continue; + issues.push( + issue(ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, field.location, field.fhirPath), + ); + } + return buildMessage({ ...ctx.envelope, type: `${shape.messageCode}^${trigger}` }).addSegment( + shape.segment, + segmentFields(byPosition), + ); +} diff --git a/src/reverse/observation.ts b/src/reverse/observation.ts new file mode 100644 index 0000000..1d5f86e --- /dev/null +++ b/src/reverse/observation.ts @@ -0,0 +1,457 @@ +/** + * FHIR `Observation` to a v2 message carrying an `OBX` segment: the inverse of the IG **Segment OBX + * to Observation** ConceptMap, over the rows whose inverse is defensible. + * + * | FHIR element | OBX field | inverse | + * |---|---|---| + * | `Observation.code` | OBX-3 (CWE) | coding to CWE.1/2/3 (+ one alternate triplet), `text` to CWE.9 | + * | `Observation.value[x]` | OBX-2 + OBX-5 | the value type OBX-2 declares is derived from which `value[x]` is present | + * | `Observation.valueQuantity` units | OBX-6 (CWE) | UCUM code/display, other systems flagged | + * | `Observation.referenceRange.text` | OBX-7 | carried verbatim, never composed from `low`/`high` | + * | `Observation.interpretation` | OBX-8 | v3 ObservationInterpretation codes the HL70078 map carries | + * | `Observation.status` | OBX-11 | Table 0085 where the map inverts | + * | `Observation.effectiveDateTime` | OBX-14 | lexical timestamp, precision preserved | + * + * **OBX-2 is derived, never assumed.** `valueQuantity` writes `NM` (or `SN` when the quantity + * carries a comparator, which is the only OBX-2 type that can hold one), `valueCodeableConcept` + * writes `CWE`, `valueString` writes `ST`, `valueDateTime` writes `DTM`. A `value[x]` with no + * faithful OBX-5 form (`valueRange`, `valueRatio`, `valueBoolean`, ...) writes **nothing** and is + * flagged: emitting the magnitude of a comparator-bearing or ranged value as a bare number would be + * a confidently wrong result, which is the one thing this library never does. + * + * `Observation.subject` and `.encounter` are flagged rather than emitted: this shape's input is an + * `Observation` alone, and a PID or PV1 assembled from a bare reference would be fabricated. + * + * @packageDocumentation + */ + +import type { FhirComplex, FhirNode } from "@cosyte/fhir"; +import type { RawField } from "@cosyte/hl7"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { + HL70078_INTERPRETATION_CODES, + OBSERVATION_STATUS_MAP, + V3_OBSERVATION_INTERPRETATION_SYSTEM, +} from "../messages/observation.js"; +import { + codeableToCwe, + codeInSystem, + reverseContext, + type ReverseContext, + type ReverseOptions, +} from "./coding.js"; +import { + emitMessage, + flagUnmapped, + hasTrigger, + readResource, + type RequiredV2Field, + type ReverseResult, + type ReverseShape, +} from "./message.js"; +import { at, readComplexes, readNumberText, readString } from "./read.js"; +import { invertCodeMap, v2Field, v2Number, v2Timestamp, type V2Components } from "./v2.js"; + +/** + * FHIR `observation-status` to HL7 v2 Table 0085, the invertible rows of the IG's **HL70085 to + * Observation Status** map. `entered-in-error` is absent on purpose (`D` and `W` both carry it), as + * are the FHIR statuses the map never targets (`registered`, `unknown`). + * + * @example + * ```ts + * import { OBSERVATION_STATUS_TO_V2 } from "@cosyte/transform"; + * OBSERVATION_STATUS_TO_V2["corrected"]; // => "C" + * OBSERVATION_STATUS_TO_V2["entered-in-error"]; // => undefined (ambiguous inverse, flagged instead) + * ``` + */ +export const OBSERVATION_STATUS_TO_V2: Readonly> = + invertCodeMap(OBSERVATION_STATUS_MAP); + +/** The FHIR `Quantity.comparator` codes an SN can carry (SN has no "not equal" comparator). */ +const SN_COMPARATORS: ReadonlySet = new Set(["<", "<=", ">=", ">"]); + +/** The `Observation` elements this map carries into OBX. */ +const OBSERVATION_MAPPED: ReadonlySet = new Set([ + "resourceType", + "code", + "status", + "effectiveDateTime", + "interpretation", + "referenceRange", + "valueQuantity", + "valueCodeableConcept", + "valueString", + "valueDateTime", +]); + +// The OBX rows whose v2.5.1 usage is `R`, and ONLY those two. OBX-2 (Value Type) +// and OBX-5 (Observation Value) are `C`, conditional on each other rather than +// required, and OBX-4 is `C` too, so none of the three is declared here: a +// conditional field reported as required is a false diagnostic on a clinical +// field. OBX-1 and OBX-6 through OBX-14 are `O`. Read the grounding banner above +// `RequiredV2Field` in `message.ts` before adding a row to this list. +/** The OBX fields v2.5.1 requires, with the `Observation` element this map sources each from. */ +const OBX_REQUIRED: readonly RequiredV2Field[] = [ + // OBX-3 Observation Identifier. UNREACHABLE TODAY BY CONSTRUCTION, and kept + // anyway: `obxFields` returns no field at all when it cannot build OBX-3, so an + // emitted OBX always carries one and this row can only fire if that early return + // is ever relaxed. It guards the next edit rather than changing this one. + { position: 3, location: "OBX.3", fhirPath: "Observation.code" }, + // OBX-11 Observation Result Status. + { position: 11, location: "OBX.11", fhirPath: "Observation.status" }, +]; + +/** What `toV2Observation` emits: an `ORU` message carrying an `OBX`, required fields above. */ +const OBSERVATION_SHAPE: ReverseShape = { + messageCode: "ORU", + segment: "OBX", + resourceName: "Observation", + required: OBX_REQUIRED, +}; + +/** `Observation` elements with a known OBX/OBR home this narrow map does not implement. */ +const OBSERVATION_UNMAPPED: Readonly> = Object.freeze({ + valueBoolean: "OBX.5", + valueInteger: "OBX.5", + valueRange: "OBX.5", + valueRatio: "OBX.5", + valueTime: "OBX.5", + valuePeriod: "OBX.5", + valueSampledData: "OBX.5", + valueAttachment: "OBX.5", + dataAbsentReason: "OBX.5", + effectivePeriod: "OBX.14", + effectiveInstant: "OBX.14", + issued: "OBX.19", + performer: "OBX.16", + method: "OBX.17", + specimen: "OBX.18", + device: "OBX.18", + bodySite: "OBX.20", + identifier: "OBX.21", + note: "NTE", + subject: "PID", + encounter: "PV1", + basedOn: "OBR", + category: "OBR", + component: "OBX", + hasMember: "OBX", + derivedFrom: "OBX", +}); + +/** The units CWE for a quantity, or `undefined` when it carries no unit at all. */ +function unitComponents( + quantity: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): V2Components | undefined { + const unit = readString(at(quantity, "unit"), "OBX.6", "Observation.valueQuantity.unit", issues); + const code = readString(at(quantity, "code"), "OBX.6", "Observation.valueQuantity.code", issues); + const system = readString( + at(quantity, "system"), + "OBX.6", + "Observation.valueQuantity.system", + issues, + ); + const mnemonic = system === undefined ? undefined : ctx.mnemonicFor(system); + if (code !== undefined && mnemonic !== undefined) return [code, unit, mnemonic]; + if (code !== undefined) { + // A coded unit whose system has no v2 mnemonic (or none at all) cannot be written as a coded + // unit: the display text survives, the code does not get a borrowed table. + issues.push( + issue(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, "OBX.6", "Observation.valueQuantity.system"), + ); + } + return unit === undefined ? undefined : [undefined, unit]; +} + +/** OBX-2 / OBX-5 / OBX-6 for a `valueQuantity`. */ +function quantityValue( + quantity: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): { valueType: string; value: V2Components; units: V2Components | undefined } | undefined { + const raw = readNumberText( + at(quantity, "value"), + "OBX.5", + "Observation.valueQuantity.value", + issues, + ); + const magnitude = raw === undefined ? undefined : v2Number(raw); + if (magnitude === undefined) { + if (raw !== undefined) { + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.5", + "Observation.valueQuantity.value", + ), + ); + } + return undefined; + } + const units = unitComponents(quantity, ctx, issues); + const comparator = readString( + at(quantity, "comparator"), + "OBX.5", + "Observation.valueQuantity.comparator", + issues, + ); + if (comparator === undefined) return { valueType: "NM", value: [magnitude], units }; + if (!SN_COMPARATORS.has(comparator)) { + // Emitting the magnitude without its comparator would assert a different result: emit neither. + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.5", + "Observation.valueQuantity.comparator", + ), + ); + return undefined; + } + // SN.1 comparator, SN.2 number: the structured-numeric shape the IG's OBX-2 = SN row describes. + return { valueType: "SN", value: [comparator, magnitude], units }; +} + +/** OBX-2 / OBX-5 / OBX-6 for whichever `value[x]` the observation carries, if any. */ +function observationValue( + observation: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): { valueType: string; value: V2Components; units: V2Components | undefined } | undefined { + const quantity = readComplexes( + at(observation, "valueQuantity"), + "OBX.5", + "Observation.valueQuantity", + issues, + )[0]; + if (quantity !== undefined) return quantityValue(quantity, ctx, issues); + + const concept = readComplexes( + at(observation, "valueCodeableConcept"), + "OBX.5", + "Observation.valueCodeableConcept", + issues, + )[0]; + if (concept !== undefined) { + const cwe = codeableToCwe(concept, ctx, "OBX.5", "Observation.valueCodeableConcept", issues); + return cwe === undefined ? undefined : { valueType: "CWE", value: cwe, units: undefined }; + } + + const text = readString( + at(observation, "valueString"), + "OBX.5", + "Observation.valueString", + issues, + ); + if (text !== undefined) return { valueType: "ST", value: [text], units: undefined }; + + const instant = readString( + at(observation, "valueDateTime"), + "OBX.5", + "Observation.valueDateTime", + issues, + ); + if (instant !== undefined) { + const dtm = v2Timestamp(instant); + if (dtm === undefined) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "OBX.5", "Observation.valueDateTime"), + ); + return undefined; + } + return { valueType: "DTM", value: [dtm], units: undefined }; + } + return undefined; +} + +/** OBX-8: the abnormal flags whose v3 interpretation code the HL70078 map carries. */ +function interpretationField( + observation: FhirComplex, + issues: TransformIssue[], +): RawField | undefined { + const flags: V2Components[] = []; + for (const concept of readComplexes( + at(observation, "interpretation"), + "OBX.8", + "Observation.interpretation", + issues, + )) { + const code = codeInSystem( + concept, + V3_OBSERVATION_INTERPRETATION_SYSTEM, + "OBX.8", + "Observation.interpretation", + issues, + ); + if (code === undefined) continue; + // The HL70078 map is code-preserving, so a v3 code it carries is its own v2 flag; one it does + // not carry has no v2 abnormal flag and is never coerced to a neighbouring one. + if (HL70078_INTERPRETATION_CODES.has(code)) flags.push([code]); + else { + issues.push( + issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "OBX.8", "Observation.interpretation"), + ); + } + } + return v2Field(flags); +} + +/** OBX-7: the reference range's text, carried verbatim and never composed from its endpoints. */ +function referenceRangeField( + observation: FhirComplex, + issues: TransformIssue[], +): RawField | undefined { + const ranges = readComplexes( + at(observation, "referenceRange"), + "OBX.7", + "Observation.referenceRange", + issues, + ); + if (ranges.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "OBX.7", "Observation.referenceRange"), + ); + } + const first = ranges[0]; + if (first === undefined) return undefined; + const text = readString(at(first, "text"), "OBX.7", "Observation.referenceRange.text", issues); + if (text === undefined) { + // low/high are structured endpoints; OBX-7's own IG row is the text one, and assembling + // "3.5-5.0" from two Quantities would be composing a v2 value this map has no row for. + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "OBX.7", "Observation.referenceRange")); + return undefined; + } + return v2Field([[text]]); +} + +/** Every OBX field this map produces, keyed by HL7 field position. */ +function obxFields( + observation: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): ReadonlyMap { + const fields = new Map(); + + // OBX-3 is the observation identifier: without one there is no OBX to emit at all. + const concept = readComplexes(at(observation, "code"), "OBX.3", "Observation.code", issues)[0]; + if (concept === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, "OBX.3", "Observation.code")); + return fields; + } + const cwe = codeableToCwe(concept, ctx, "OBX.3", "Observation.code", issues); + const obx3 = cwe === undefined ? undefined : v2Field([cwe]); + if (obx3 === undefined) return fields; + fields.set(3, obx3); + + const value = observationValue(observation, ctx, issues); + if (value !== undefined) { + const obx2 = v2Field([[value.valueType]]); + const obx5 = v2Field([value.value]); + if (obx2 !== undefined && obx5 !== undefined) { + fields.set(2, obx2); + fields.set(5, obx5); + } + const obx6 = value.units === undefined ? undefined : v2Field([value.units]); + if (obx6 !== undefined) fields.set(6, obx6); + } + + const referenceRange = referenceRangeField(observation, issues); + if (referenceRange !== undefined) fields.set(7, referenceRange); + + const interpretation = interpretationField(observation, issues); + if (interpretation !== undefined) fields.set(8, interpretation); + + const status = readString(at(observation, "status"), "OBX.11", "Observation.status", issues); + if (status !== undefined) { + const code = Object.hasOwn(OBSERVATION_STATUS_TO_V2, status) + ? OBSERVATION_STATUS_TO_V2[status] + : undefined; + if (code === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "OBX.11", "Observation.status")); + } else { + const obx11 = v2Field([[code]]); + if (obx11 !== undefined) fields.set(11, obx11); + } + } + + const effective = readString( + at(observation, "effectiveDateTime"), + "OBX.14", + "Observation.effectiveDateTime", + issues, + ); + if (effective !== undefined) { + const dtm = v2Timestamp(effective); + if (dtm === undefined) { + issues.push( + issue( + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + "OBX.14", + "Observation.effectiveDateTime", + ), + ); + } else { + const obx14 = v2Field([[dtm]]); + if (obx14 !== undefined) fields.set(14, obx14); + } + } + + return fields; +} + +/** + * Convert a FHIR R4 `Observation` into a **complete** v2 message carrying an `OBX` segment, in the + * result-report (`ORU`) shape. + * + * The `trigger` argument is **required and never inferred**: no `Observation` element maps to a v2 + * message trigger, so the caller supplies it, and a missing, empty or non-string one returns + * `{ value: undefined }` plus {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} without calling the + * message builder at all. It is used verbatim as MSH-9.2 under the `ORU` message code this shape + * fixes. + * + * The message carries the `OBX` alone: this shape's input is an `Observation`, which names no + * patient, and a subject segment assembled from a reference would be fabricated. Lossy by design and + * never round-trip-safe. + * + * An `OBX` field v2 requires (OBX-11 Observation Result Status) that this resource gives no source + * for stays absent and is declared with {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}, + * rather than defaulted to `F`: a result reported as final that the sender never called final is the + * confidently wrong value this library exists to refuse. An `Observation` that grounds no `OBX` + * field at all yields no message and {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED}. + * + * @param resource - The FHIR `Observation` node. + * @param trigger - The bare v2 trigger, e.g. `"R01"`. Required; never derived from the resource. + * @param options - Caller-vetted reverse context: code systems and the MSH envelope. + * @example + * ```ts + * import { parseResource } from "@cosyte/fhir"; + * import { toV2Observation } from "@cosyte/transform"; + * + * const { resource } = parseResource( + * '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]}}', + * ); + * const { value, issues } = toV2Observation(resource, "R01"); + * // value.toString() carries "ORU^R01" in MSH-9, then an OBX whose OBX-3 is "789-8^^LN" + * void value; + * void issues; + * ``` + */ +export function toV2Observation( + resource: FhirNode, + trigger: string, + options: ReverseOptions = {}, +): ReverseResult { + const issues: TransformIssue[] = []; + const triggerOk = hasTrigger(trigger, issues); + const observation = readResource(resource, "Observation", "OBX", issues); + if (!triggerOk || observation === undefined) return { value: undefined, issues }; + + flagUnmapped(observation, OBSERVATION_MAPPED, OBSERVATION_UNMAPPED, "Observation", "OBX", issues); + const ctx = reverseContext(options); + const fields = obxFields(observation, ctx, issues); + const value = emitMessage(OBSERVATION_SHAPE, trigger, fields, ctx, issues); + return { value, issues }; +} diff --git a/src/reverse/patient.ts b/src/reverse/patient.ts new file mode 100644 index 0000000..97e289e --- /dev/null +++ b/src/reverse/patient.ts @@ -0,0 +1,351 @@ +/** + * FHIR `Patient` to a v2 message carrying a `PID` segment: the inverse of the IG **Segment PID to + * Patient** ConceptMap, over the rows whose inverse is defensible. + * + * | FHIR element | PID field | inverse | + * |---|---|---| + * | `Patient.identifier` | PID-3 (CX) | value to CX.1, caller-vetted authority to CX.4, Table 0203 type to CX.5 | + * | `Patient.name` | PID-5 (XPN) | family/given/prefix/suffix to XPN.1-XPN.5, `use` to XPN.7 where the HL70200 map inverts | + * | `Patient.birthDate` | PID-7 (DTM) | lexical date, precision preserved | + * | `Patient.gender` | PID-8 | Table 0001 where the map inverts (`female`/`male`/`unknown`) | + * | `Patient.address` | PID-11 (XAD) | line/city/state/postalCode/country/district, `use` to XAD.7 where HL70190 inverts | + * + * **This is the lossy direction, and it says so rather than papering over it.** The published map is + * v2 to FHIR; several of its rows are many-to-one, so the inverse is ambiguous and is refused: + * `gender` `other` (Table 0001 `O`, `A` and `N` all mean it), name use `official` (`L` and `R`) and + * `temp` (`NAV` and `TEMP`), address use `work` (`B` and `O`), and every `Address.type` + * (`M` and `SH` both mean `postal`). Each leaves its v2 field absent and raises + * {@link ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE}. Nothing here reconstructs a v2 message that was + * transformed to FHIR: it writes what the FHIR resource itself supports, and flags the rest. + * + * @packageDocumentation + */ + +import type { FhirComplex, FhirNode } from "@cosyte/fhir"; +import type { RawField } from "@cosyte/hl7"; + +import { ADDRESS_USE_MAP } from "../datatypes/address.js"; +import { NAME_USE_MAP } from "../datatypes/human-name.js"; +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; +import { ADMINISTRATIVE_GENDER_MAP } from "../messages/patient.js"; +import { V2_0203_SYSTEM } from "../terminology/naming-system.js"; +import { + codeInSystem, + reverseContext, + type ReverseContext, + type ReverseOptions, +} from "./coding.js"; +import { + emitMessage, + flagUnmapped, + hasTrigger, + readResource, + type RequiredV2Field, + type ReverseResult, + type ReverseShape, +} from "./message.js"; +import { at, readComplexes, readString, readStrings } from "./read.js"; +import { invertCodeMap, v2Date, v2Field, type V2Components } from "./v2.js"; + +/** + * FHIR `administrative-gender` to HL7 v2 Table 0001, the invertible rows of the IG's **HL70001 to + * Administrative Gender** map. `other` is absent on purpose: three source codes carry it. + * + * @example + * ```ts + * import { GENDER_TO_V2 } from "@cosyte/transform"; + * GENDER_TO_V2["female"]; // => "F" + * GENDER_TO_V2["other"]; // => undefined (ambiguous inverse, flagged instead) + * ``` + */ +export const GENDER_TO_V2: Readonly> = + invertCodeMap(ADMINISTRATIVE_GENDER_MAP); + +/** + * FHIR `name-use` to HL7 v2 Table 0200, the invertible rows of the IG's **HL70200 to name-use** map. + * `official` and `temp` are absent on purpose: two source codes each. + * + * @example + * ```ts + * import { NAME_USE_TO_V2 } from "@cosyte/transform"; + * NAME_USE_TO_V2["maiden"]; // => "M" + * ``` + */ +export const NAME_USE_TO_V2: Readonly> = invertCodeMap(NAME_USE_MAP); + +/** + * FHIR `address-use` to HL7 v2 Table 0190, the invertible rows of the IG's **HL70190 to + * address-use** map. `work` is absent on purpose (`B` and `O`), as is every `Address.type` row. + * + * @example + * ```ts + * import { ADDRESS_USE_TO_V2 } from "@cosyte/transform"; + * ADDRESS_USE_TO_V2["home"]; // => "H" + * ``` + */ +export const ADDRESS_USE_TO_V2: Readonly> = invertCodeMap(ADDRESS_USE_MAP); + +/** The `Patient` elements this map carries into PID. */ +const PATIENT_MAPPED: ReadonlySet = new Set([ + "resourceType", + "identifier", + "name", + "birthDate", + "gender", + "address", +]); + +// The PID rows whose v2.5.1 usage is `R`, and ONLY those two. PID-1 is `O`, PID-2 +// and PID-4 are `B` (retained for backward compatibility, not required), and +// PID-6, PID-7, PID-8 and PID-11 are `O`, so none of them is declared here even +// though this map writes three of them: declaring an optional field as required +// would be a false diagnostic on a clinical field. Read the grounding banner above +// `RequiredV2Field` in `message.ts` before adding a row to this list. +/** The PID fields v2.5.1 requires, with the `Patient` element this map sources each from. */ +const PID_REQUIRED: readonly RequiredV2Field[] = [ + // PID-3 Patient Identifier List, v2.5.1 item 00106. + { position: 3, location: "PID.3", fhirPath: "Patient.identifier" }, + // PID-5 Patient Name, v2.5.1 item 00108. + { position: 5, location: "PID.5", fhirPath: "Patient.name" }, +]; + +/** What `toV2Patient` emits: an `ADT` message carrying a `PID`, whose required fields are above. */ +const PATIENT_SHAPE: ReverseShape = { + messageCode: "ADT", + segment: "PID", + resourceName: "Patient", + required: PID_REQUIRED, +}; + +/** `Patient` elements with a known PID home this narrow map does not implement. */ +const PATIENT_UNMAPPED: Readonly> = Object.freeze({ + telecom: "PID.13", + communication: "PID.15", + maritalStatus: "PID.16", + multipleBirthBoolean: "PID.24", + multipleBirthInteger: "PID.25", + deceasedDateTime: "PID.29", + deceasedBoolean: "PID.30", + contact: "NK1", +}); + +/** A code's inverse, or `undefined` plus a flag when the governing map does not invert it. */ +function invertible( + inverse: Readonly>, + code: string, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (Object.hasOwn(inverse, code)) return inverse[code]; + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, location, fhirPath)); + return undefined; +} + +/** One `Identifier` as CX components, or `undefined` when it carries no value to key on. */ +function identifierComponents( + identifier: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): V2Components | undefined { + const value = readString(at(identifier, "value"), "CX.1", "Patient.identifier.value", issues); + if (value === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "CX.1", "Patient.identifier")); + return undefined; + } + // CX.4 is an assigning authority, and no algorithm turns a system URI into one: only a + // caller-vetted namespace is written, and an unseeded system is flagged, never synthesized. + const system = readString(at(identifier, "system"), "CX.4", "Patient.identifier.system", issues); + let namespace: string | undefined; + if (system !== undefined) { + namespace = ctx.namespaceFor(system); + if (namespace === undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_NO_V2_TARGET, "CX.4", "Patient.identifier.system")); + } + } + const typeConcept = readComplexes( + at(identifier, "type"), + "CX.5", + "Patient.identifier.type", + issues, + )[0]; + const typeCode = + typeConcept === undefined + ? undefined + : codeInSystem(typeConcept, V2_0203_SYSTEM, "CX.5", "Patient.identifier.type", issues); + return [value, undefined, undefined, namespace, typeCode]; +} + +/** One `HumanName` as XPN components. */ +function nameComponents(name: FhirComplex, issues: TransformIssue[]): V2Components { + const family = readString(at(name, "family"), "XPN.1", "Patient.name.family", issues); + const given = readStrings(at(name, "given"), "XPN.2", "Patient.name.given", issues); + const prefix = readStrings(at(name, "prefix"), "XPN.5", "Patient.name.prefix", issues); + const suffix = readStrings(at(name, "suffix"), "XPN.4", "Patient.name.suffix", issues); + const use = readString(at(name, "use"), "XPN.7", "Patient.name.use", issues); + + // XPN carries one further-given component, one prefix and one suffix: extras would have to be + // joined or dropped, and both alter the name, so they are flagged and left out. + if (given.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.3", "Patient.name.given"), + ); + } + if (prefix.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.5", "Patient.name.prefix"), + ); + } + if (suffix.length > 1) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XPN.4", "Patient.name.suffix"), + ); + } + const nameType = + use === undefined + ? undefined + : invertible(NAME_USE_TO_V2, use, "XPN.7", "Patient.name.use", issues); + return [family, given[0], given[1], suffix[0], prefix[0], undefined, nameType]; +} + +/** One `Address` as XAD components. */ +function addressComponents(address: FhirComplex, issues: TransformIssue[]): V2Components { + const line = readStrings(at(address, "line"), "XAD.1", "Patient.address.line", issues); + const city = readString(at(address, "city"), "XAD.3", "Patient.address.city", issues); + const state = readString(at(address, "state"), "XAD.4", "Patient.address.state", issues); + const postal = readString( + at(address, "postalCode"), + "XAD.5", + "Patient.address.postalCode", + issues, + ); + const country = readString(at(address, "country"), "XAD.6", "Patient.address.country", issues); + const district = readString(at(address, "district"), "XAD.9", "Patient.address.district", issues); + const use = readString(at(address, "use"), "XAD.7", "Patient.address.use", issues); + const type = readString(at(address, "type"), "XAD.7", "Patient.address.type", issues); + + if (line.length > 2) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "XAD.2", "Patient.address.line"), + ); + } + const addressType = + use === undefined + ? undefined + : invertible(ADDRESS_USE_TO_V2, use, "XAD.7", "Patient.address.use", issues); + // FHIR splits use (home/work) from type (postal/physical); XAD.7 is one code, and the IG's + // address-type rows are many-to-one, so a type is always flagged rather than merged into XAD.7. + if (type !== undefined) { + issues.push(issue(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, "XAD.7", "Patient.address.type")); + } + return [line[0], line[1], city, state, postal, country, addressType, undefined, district]; +} + +/** Every PID field this map produces, keyed by HL7 field position. */ +function pidFields( + patient: FhirComplex, + ctx: ReverseContext, + issues: TransformIssue[], +): ReadonlyMap { + const fields = new Map(); + + const identifiers: V2Components[] = []; + for (const identifier of readComplexes( + at(patient, "identifier"), + "PID.3", + "Patient.identifier", + issues, + )) { + const components = identifierComponents(identifier, ctx, issues); + if (components !== undefined) identifiers.push(components); + } + const pid3 = v2Field(identifiers); + if (pid3 !== undefined) fields.set(3, pid3); + + const names = readComplexes(at(patient, "name"), "PID.5", "Patient.name", issues).map((name) => + nameComponents(name, issues), + ); + const pid5 = v2Field(names); + if (pid5 !== undefined) fields.set(5, pid5); + + const birthDate = readString(at(patient, "birthDate"), "PID.7", "Patient.birthDate", issues); + if (birthDate !== undefined) { + const dtm = v2Date(birthDate); + if (dtm === undefined) { + issues.push( + issue(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, "PID.7", "Patient.birthDate"), + ); + } else { + const pid7 = v2Field([[dtm]]); + if (pid7 !== undefined) fields.set(7, pid7); + } + } + + const gender = readString(at(patient, "gender"), "PID.8", "Patient.gender", issues); + const sex = + gender === undefined + ? undefined + : invertible(GENDER_TO_V2, gender, "PID.8", "Patient.gender", issues); + const pid8 = sex === undefined ? undefined : v2Field([[sex]]); + if (pid8 !== undefined) fields.set(8, pid8); + + const addresses = readComplexes(at(patient, "address"), "PID.11", "Patient.address", issues).map( + (address) => addressComponents(address, issues), + ); + const pid11 = v2Field(addresses); + if (pid11 !== undefined) fields.set(11, pid11); + + return fields; +} + +/** + * Convert a FHIR R4 `Patient` into a **complete** v2 message carrying a `PID` segment, for the + * demographics-only trigger events (`A28`, `A31`, `A29`, ...) that carry a patient with no visit. + * + * The `trigger` argument is **required and never inferred**: no `Patient` element maps to a v2 + * message trigger, so the caller supplies it, and a missing, empty or non-string one returns + * `{ value: undefined }` plus {@link ISSUE_CODES.TRANSFORM_MISSING_TRIGGER} without calling the + * message builder at all. It is used verbatim as MSH-9.2 under the `ADT` message code this shape + * fixes; the caller never supplies the code and it never varies per call. + * + * Lossy by design and never round-trip-safe: a value the inverse of the IG map cannot ground is + * flagged and left absent, never guessed, and a resource of another type is refused outright. + * + * A `PID` field v2 requires (PID-3 Patient Identifier List, PID-5 Patient Name) that this resource + * gives no source for stays absent and is declared with + * {@link ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT}: the emitted message is honest about what + * it lacks rather than padded to look conformant. A `Patient` that grounds no `PID` field at all + * yields no message and {@link ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED}. + * + * @param resource - The FHIR `Patient` node (build one with `@cosyte/fhir`'s `parseResource`). + * @param trigger - The bare v2 trigger, e.g. `"A28"`. Required; never derived from the resource. + * @param options - Caller-vetted reverse context: assigning authorities, code systems, MSH envelope. + * @example + * ```ts + * import { parseResource } from "@cosyte/fhir"; + * import { toV2Patient } from "@cosyte/transform"; + * + * const { resource } = parseResource('{"resourceType":"Patient","gender":"female"}'); + * const { value, issues } = toV2Patient(resource, "A28"); + * // value.toString() starts "MSH|^~\\&|" and carries "ADT^A28" in MSH-9, then a PID segment + * void value; + * void issues; + * ``` + */ +export function toV2Patient( + resource: FhirNode, + trigger: string, + options: ReverseOptions = {}, +): ReverseResult { + const issues: TransformIssue[] = []; + const triggerOk = hasTrigger(trigger, issues); + const patient = readResource(resource, "Patient", "PID", issues); + if (!triggerOk || patient === undefined) return { value: undefined, issues }; + + flagUnmapped(patient, PATIENT_MAPPED, PATIENT_UNMAPPED, "Patient", "PID", issues); + const ctx = reverseContext(options); + const fields = pidFields(patient, ctx, issues); + const value = emitMessage(PATIENT_SHAPE, trigger, fields, ctx, issues); + return { value, issues }; +} diff --git a/src/reverse/read.ts b/src/reverse/read.ts new file mode 100644 index 0000000..2bc720b --- /dev/null +++ b/src/reverse/read.ts @@ -0,0 +1,154 @@ +/** + * Reading the `@cosyte/fhir` node model on the way **out** of FHIR, never-throw by construction. + * + * The reverse direction is handed nodes it did not build, so every read here answers two questions at + * once: what the element carries, and whether it is the *kind* of node FHIR says it should be. A node + * of the wrong kind (a `HumanName` where a string belongs, a string where a list belongs) is not an + * exception here: it raises {@link ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED} on the caller's issue + * sink and reads as absent, so the whole-repository never-throw guardrail holds for structurally + * malformed input exactly as it does for unmappable input. + * + * @packageDocumentation + */ + +import { + getProperty, + isComplex, + isList, + isPrimitive, + type FhirComplex, + type FhirNode, +} from "@cosyte/fhir"; + +import { ISSUE_CODES } from "../diagnostics/codes.js"; +import { issue, type TransformIssue } from "../diagnostics/issue.js"; + +/** The nodes an element carries: a list's items, a lone node as one item, or none when absent. */ +function itemsOf(node: FhirNode | undefined): readonly FhirNode[] { + if (node === undefined) return []; + return isList(node) ? node.items : [node]; +} + +/** + * The named property of a complex node, or `undefined` when the element is absent. + * + * @param node - The complex node to read. + * @param name - The FHIR element name. + * @example + * ```ts + * // at(patientNode, "birthDate") -> the birthDate primitive node, or undefined + * ``` + */ +export function at(node: FhirComplex, name: string): FhirNode | undefined { + return getProperty(node, name); +} + +/** + * The string a primitive element carries, or `undefined` when it is absent or empty. A node that is + * not a string primitive is flagged malformed and read as absent. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readString(at(name, "family"), "PID.5.1", "Patient.name.family", issues) -> "Public" + * ``` + */ +export function readString( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (node === undefined) return undefined; + if (!isPrimitive(node) || typeof node.value !== "string") { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + return undefined; + } + return node.value === "" ? undefined : node.value; +} + +/** + * Every string a repeating primitive element carries, in order; non-string items are flagged + * malformed and skipped. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readStrings(at(name, "given"), "PID.5.2", "Patient.name.given", issues) -> ["Jane"] + * ``` + */ +export function readStrings( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): readonly string[] { + const out: string[] = []; + for (const item of itemsOf(node)) { + const value = readString(item, location, fhirPath, issues); + if (value !== undefined) out.push(value); + } + return out; +} + +/** + * Every complex item a repeating element carries; items of another kind are flagged malformed and + * skipped. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readComplexes(at(patient, "name"), "PID.5", "Patient.name", issues) -> [HumanName, ...] + * ``` + */ +export function readComplexes( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): readonly FhirComplex[] { + const out: FhirComplex[] = []; + for (const item of itemsOf(node)) { + if (isComplex(item)) out.push(item); + else issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + } + return out; +} + +/** + * The **exact lexical form** of a numeric element, never routed through a JS `number`: a + * string-backed `decimal` yields its raw text unchanged. A boolean (or a value-absent) primitive is + * flagged malformed. + * + * @param node - The element node, or `undefined`. + * @param location - The v2 target location, for a value-free issue. + * @param fhirPath - The FHIR path being read. + * @param issues - The issue sink. + * @example + * ```ts + * // readNumberText(at(quantity, "value"), "OBX.5", "Observation.valueQuantity.value", issues) -> "120.50" + * ``` + */ +export function readNumberText( + node: FhirNode | undefined, + location: string, + fhirPath: string, + issues: TransformIssue[], +): string | undefined { + if (node === undefined) return undefined; + const value = isPrimitive(node) ? node.value : undefined; + if (value === undefined || typeof value === "boolean") { + issues.push(issue(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, location, fhirPath)); + return undefined; + } + return typeof value === "string" ? value : value.raw; +} diff --git a/src/reverse/v2.ts b/src/reverse/v2.ts new file mode 100644 index 0000000..1ae4a6c --- /dev/null +++ b/src/reverse/v2.ts @@ -0,0 +1,170 @@ +/** + * Writing HL7 v2 field content: the small, safe primitives the reverse (FHIR to v2) maps build on. + * + * Two disciplines live here. + * + * **Composite content is structured, never concatenated.** A typed composite (XPN, CX, CWE, XAD) is + * handed to `@cosyte/hl7` as a `RawField` of components, so the serializer owns delimiter escaping: + * a family name of `Do^e` emits as `Do\S\e` and reads back as `Do^e`, where a hand-built `"Do^e"` + * string would have silently become two components. Nothing in this module writes a `^` or a `~`. + * + * **A lexical form v2 cannot carry is refused, never trimmed to fit.** {@link v2Timestamp} and + * {@link v2Number} return `undefined` rather than an approximation, and their callers flag the value + * and leave the v2 field absent. + * + * @packageDocumentation + */ + +import type { RawField } from "@cosyte/hl7"; + +/** One repetition of a field: its components, in order, `undefined` for an absent component. */ +export type V2Components = readonly (string | undefined)[]; + +/** A component with a single subcomponent, or an empty component for absent content. */ +function component(value: string | undefined): { subcomponents: readonly string[] } { + return { subcomponents: value === undefined || value === "" ? [] : [value] }; +} + +/** + * Build a v2 field from its repetitions, each a list of components, or `undefined` when every + * repetition is empty (an empty field is left absent, never emitted as structure). + * + * @param repetitions - The field's repetitions, outermost first. + * @example + * ```ts + * // v2Field([["Public", "Jane"]]) -> a PID-5 field that serializes as `Public^Jane` + * ``` + */ +export function v2Field(repetitions: readonly V2Components[]): RawField | undefined { + const present = repetitions.filter((components) => + components.some((value) => value !== undefined && value !== ""), + ); + if (present.length === 0) return undefined; + return { + isNull: false, + repetitions: present.map((components) => { + // Trailing absent components carry no information on the wire: drop them, keep interior gaps. + let last = components.length; + while (last > 0 && (components[last - 1] === undefined || components[last - 1] === "")) + last--; + return { components: components.slice(0, last).map(component) }; + }), + }; +} + +/** + * Lay fields out in HL7 1-indexed positional order for `addSegment`, filling unmapped positions with + * an empty field. Position 1 is the segment's first field (PID-1, OBX-1, ...). + * + * @param byPosition - The mapped fields, keyed by their 1-based HL7 field position. + * @example + * ```ts + * // segmentFields(new Map([[3, mrnField], [5, nameField]])) -> ["", "", mrnField, "", nameField] + * ``` + */ +export function segmentFields( + byPosition: ReadonlyMap, +): readonly (string | RawField)[] { + const positions = [...byPosition.keys()]; + const highest = positions.length === 0 ? 0 : Math.max(...positions); + const out: (string | RawField)[] = []; + for (let position = 1; position <= highest; position++) { + out.push(byPosition.get(position) ?? ""); + } + return out; +} + +/** FHIR `date`/`dateTime` lexical forms, at every precision R4 permits. */ +const FHIR_DATETIME = + /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(\.\d{1,4})?(Z|[+-]\d{2}:\d{2}))?)?)?$/; + +/** + * FHIR `date`/`dateTime` to a v2 DTM, precision-preserving, or `undefined` when the value has no + * faithful v2 form (an unparseable lexical form, or sub-second precision finer than DTM's four + * digits, which would have to be truncated). `Z` becomes the explicit `+0000` offset v2 writes. + * + * @param value - The FHIR lexical date or dateTime. + * @example + * ```ts + * // v2Timestamp("2026-01-02") -> "20260102" + * // v2Timestamp("2026-01-02T10:15:00-05:00") -> "20260102101500-0500" + * ``` + */ +export function v2Timestamp(value: string): string | undefined { + const parts = FHIR_DATETIME.exec(value); + if (parts === null) return undefined; + const [, year, month, day, hour, minute, second, fraction, zone] = parts; + let out = year ?? ""; + if (month !== undefined) out += month; + if (day !== undefined) out += day; + if (hour === undefined) return out; + out += `${hour}${minute ?? ""}${second ?? ""}${fraction ?? ""}`; + return out + (zone === "Z" ? "+0000" : (zone ?? "").replace(":", "")); +} + +/** + * FHIR `date` to a v2 DTM. A `date` element carrying a time-of-day is outside its own type's value + * domain, so it is refused (`undefined`) rather than quietly re-read as a `dateTime`: reinterpreting + * an out-of-domain value is the coercion the fail-safe rule forbids. + * + * @param value - The FHIR lexical date. + * @example + * ```ts + * // v2Date("1980-01-15") -> "19800115" + * // v2Date("1980-01-15T10:00:00-05:00") -> undefined + * ``` + */ +export function v2Date(value: string): string | undefined { + return value.includes("T") ? undefined : v2Timestamp(value); +} + +/** The FHIR `decimal` lexical forms a v2 NM can carry verbatim: no exponent, no leading `+`. */ +const V2_NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?$/; + +/** + * A FHIR decimal's exact lexical form when v2 NM can carry it unchanged, else `undefined`. An + * exponent form (`1e3`) has no NM representation, and rewriting it would alter the value's lexical + * precision, so it is refused rather than expanded. + * + * @param raw - The decimal's exact lexical text. + * @example + * ```ts + * // v2Number("120.50") -> "120.50" (trailing-zero precision preserved) + * // v2Number("1e3") -> undefined + * ``` + */ +export function v2Number(raw: string): string | undefined { + return V2_NUMBER.test(raw) ? raw : undefined; +} + +/** + * Invert a forward v2-to-FHIR code map, keeping **only** the targets exactly one v2 code maps to. + * + * This is the bijective-subset rule as code. Where several v2 codes are "equivalent to" one FHIR + * concept (Table 0001 `O`/`A`/`N` all map to `other`), the inverse is ambiguous: the target is + * dropped here, and its caller flags `TRANSFORM_CODE_NOT_INVERTIBLE` rather than picking one member + * of the set. + * + * @param forward - A v2 code to FHIR code map, as published by the IG ConceptMap. + * @example + * ```ts + * import { invertCodeMap } from "@cosyte/transform"; + * invertCodeMap({ F: "female", O: "other", A: "other" }); // => { female: "F" } + * ``` + */ +export function invertCodeMap( + forward: Readonly>, +): Readonly> { + const sources = new Map(); + for (const [v2Code, fhirCode] of Object.entries(forward)) { + const seen = sources.get(fhirCode); + if (seen === undefined) sources.set(fhirCode, [v2Code]); + else seen.push(v2Code); + } + const inverse: Record = {}; + for (const [fhirCode, v2Codes] of sources) { + const only = v2Codes[0]; + if (v2Codes.length === 1 && only !== undefined) inverse[fhirCode] = only; + } + return Object.freeze(inverse); +} diff --git a/test/diagnostics/codes-and-issue.test.ts b/test/diagnostics/codes-and-issue.test.ts index a783bee..49827b3 100644 --- a/test/diagnostics/codes-and-issue.test.ts +++ b/test/diagnostics/codes-and-issue.test.ts @@ -33,6 +33,16 @@ describe("stable code registries", () => { "TRANSFORM_REQUIRED_ELEMENT_UNKNOWN", "TRANSFORM_RESOURCE_INVALID", "TRANSFORM_SEGMENT_ASSEMBLED", + // Phase 7: the reverse direction (additions only). + "TRANSFORM_CODE_NOT_INVERTIBLE", + "TRANSFORM_CODE_SYSTEM_NOT_V2", + "TRANSFORM_MISSING_TRIGGER", + "TRANSFORM_NO_V2_MESSAGE_EMITTED", + "TRANSFORM_NO_V2_TARGET", + "TRANSFORM_RESOURCE_MALFORMED", + "TRANSFORM_UNSUPPORTED_RESOURCE", + "TRANSFORM_V2_REQUIRED_FIELD_ABSENT", + "TRANSFORM_VALUE_NOT_REPRESENTABLE", ].sort(), ); }); diff --git a/test/reverse/observation.test.ts b/test/reverse/observation.test.ts new file mode 100644 index 0000000..9e41be8 --- /dev/null +++ b/test/reverse/observation.test.ts @@ -0,0 +1,367 @@ +/** + * FHIR `Observation` to a v2 ORU-shaped message carrying an OBX: the value-type discrimination, the + * refusals that never emit a confidently wrong result, and the never-throw guardrail. + * + * Every fixture is synthetic; no fixture carries a patient identity at all (this shape's input is an + * `Observation` alone). + */ + +import { describe, it, expect } from "vitest"; +import { parseHL7 } from "@cosyte/hl7"; +import { complex, primitive, parseResource, type FhirComplex } from "@cosyte/fhir"; + +import { toV2Observation, ISSUE_CODES, OBSERVATION_STATUS_TO_V2 } from "../../src/index.js"; + +const LOINC = { system: "http://loinc.org", code: "789-8", display: "Hemoglobin" }; +const UCUM = "http://unitsofmeasure.org"; + +function observation(json: Record): FhirComplex { + return parseResource( + JSON.stringify({ resourceType: "Observation", code: { coding: [LOINC] }, ...json }), + ).resource; +} + +const codes = (result: { issues: readonly { code: string }[] }): string[] => + result.issues.map((i) => i.code); + +describe("toV2Observation: the emitted message", () => { + it("builds a complete ORU message whose OBX carries the code, value, units and status", () => { + const { value, issues } = toV2Observation( + observation({ + status: "final", + effectiveDateTime: "2026-01-02T10:15:00-05:00", + valueQuantity: { value: 120.5, unit: "g/L", system: UCUM, code: "g/L" }, + referenceRange: [{ text: "130-170" }], + interpretation: [ + { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + code: "L", + }, + ], + }, + ], + }), + "R01", + ); + + expect(issues).toEqual([]); + const round = parseHL7(value?.toString() ?? ""); + expect(round.meta.type).toBe("ORU^R01"); + expect(round.get("OBX.2")).toBe("NM"); + expect(round.get("OBX.3.1")).toBe("789-8"); + expect(round.get("OBX.3.2")).toBe("Hemoglobin"); + expect(round.get("OBX.3.3")).toBe("LN"); + expect(round.get("OBX.5")).toBe("120.5"); + expect(round.get("OBX.6.1")).toBe("g/L"); + expect(round.get("OBX.6.3")).toBe("UCUM"); + expect(round.get("OBX.7")).toBe("130-170"); + expect(round.get("OBX.8")).toBe("L"); + expect(round.get("OBX.11")).toBe("F"); + expect(round.get("OBX.14")).toBe("20260102101500-0500"); + }); + + it("carries a magnitude's exact lexical precision, never through a JS number", () => { + // A trailing zero is significant in FHIR and a JS number would drop it: raw JSON in, raw out. + const { value } = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]},"valueQuantity":{"value":120.50}}', + ).resource, + "R01", + ); + expect(parseHL7(value?.toString() ?? "").get("OBX.5")).toBe("120.50"); + }); + + it.each([ + ["a coded value", { valueCodeableConcept: { coding: [LOINC] } }, "CWE", "789-8"], + ["a string value", { valueString: "positive" }, "ST", "positive"], + ["a date-time value", { valueDateTime: "2026-01-02" }, "DTM", "20260102"], + ])("derives OBX-2 from %s rather than assuming a type", (_label, json, type, first) => { + const { value } = toV2Observation(observation({ status: "final", ...json }), "R01"); + const round = parseHL7(value?.toString() ?? ""); + expect(round.get("OBX.2")).toBe(type); + expect(round.get("OBX.5.1")).toBe(first); + }); + + it("writes a comparator-bearing quantity as a structured numeric, never as a bare magnitude", () => { + const { value, issues } = toV2Observation( + observation({ status: "final", valueQuantity: { comparator: "<", value: 5, unit: "mg/L" } }), + "R01", + ); + expect(issues).toEqual([]); + const round = parseHL7(value?.toString() ?? ""); + expect(round.get("OBX.2")).toBe("SN"); + expect(round.get("OBX.5.1")).toBe("<"); + expect(round.get("OBX.5.2")).toBe("5"); + }); +}); + +describe("toV2Observation: a v2-required field with no FHIR source is declared, never fabricated", () => { + // The one fixture the wire pin needs to be exact about: no display text, so OBX-3 reads `789-8^^LN`. + const bare = () => + parseResource( + '{"resourceType":"Observation","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]}}', + ).resource; + + it("flags OBX-11 when the Observation carries no status", () => { + const result = toV2Observation(bare(), "R01"); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(result.issues[0]?.v2Location).toBe("OBX.11"); + expect(result.issues[0]?.fhirPath).toBe("Observation.status"); + // Absent, not defaulted: a result the sender never called final is never reported as final. + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBeUndefined(); + }); + + it("flags OBX-11 when a status arrives but its inverse is not usable", () => { + const result = toV2Observation(observation({ status: "entered-in-error" }), "R01"); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ]); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBeUndefined(); + }); + + it("raises nothing when the required status is sourced", () => { + expect(toV2Observation(observation({ status: "final" }), "R01").issues).toEqual([]); + }); + + it("declares the absence without touching a single byte of the wire", () => { + // Byte-for-byte the wire this shape emitted before the absence was declared. + const result = toV2Observation(bare(), "R01", { + envelope: { controlId: "MSGID1", timestamp: "20260102101500" }, + }); + expect(result.value?.toString()).toBe( + "MSH|^~\\&|||||20260102101500||ORU^R01|MSGID1|P|2.5\rOBX|||789-8^^LN\r", + ); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + }); + + it("says so when it declines to emit any message at all", () => { + const result = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED, + ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, + ]); + expect(result.issues[1]?.v2Location).toBe("OBX"); + expect(result.issues[1]?.fhirPath).toBe("Observation"); + }); +}); + +describe("toV2Observation: the required trigger", () => { + it("refuses an empty trigger without building a message", () => { + const result = toV2Observation(observation({ status: "final" }), ""); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("uses the trigger verbatim under the ORU message code the shape fixes", () => { + const { value } = toV2Observation(observation({ status: "final" }), "R30"); + expect(parseHL7(value?.toString() ?? "").meta.type).toBe("ORU^R30"); + }); +}); + +describe("toV2Observation: refusals that never guess", () => { + it("emits nothing when the observation code has no v2-nameable system", () => { + const result = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://example.org/local","code":"X"}]}}', + ).resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + }); + + it("flags a status whose inverse is ambiguous and leaves OBX-11 absent", () => { + const result = toV2Observation(observation({ status: "entered-in-error" }), "R01"); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.11")).toBe(undefined); + expect(OBSERVATION_STATUS_TO_V2["entered-in-error"]).toBeUndefined(); + expect(OBSERVATION_STATUS_TO_V2["corrected"]).toBe("C"); + }); + + it.each([ + ["a range value", { valueRange: { low: { value: 1 }, high: { value: 2 } } }], + ["a ratio value", { valueRatio: { numerator: { value: 1 }, denominator: { value: 2 } } }], + ["a boolean value", { valueBoolean: true }], + ])("emits no OBX-5 for %s and flags it", (_label, json) => { + const result = toV2Observation(observation({ status: "final", ...json }), "R01"); + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("OBX.5")).toBe(undefined); + expect(round.get("OBX.2")).toBe(undefined); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged[0]?.v2Location).toBe("OBX.5"); + }); + + it("refuses a magnitude in exponent form rather than rewriting it", () => { + // Written as raw JSON: the exponent form has to survive to the reader as a lexical literal. + const result = toV2Observation( + parseResource( + '{"resourceType":"Observation","status":"final","code":{"coding":[{"system":"http://loinc.org","code":"789-8"}]},"valueQuantity":{"value":1e3}}', + ).resource, + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe(undefined); + }); + + it("flags a unit whose system is not UCUM and keeps only its display text", () => { + const result = toV2Observation( + observation({ + status: "final", + valueQuantity: { + value: 5, + unit: "widgets", + system: "http://example.org/units", + code: "wid", + }, + }), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("OBX.6.1")).toBe(""); + expect(round.get("OBX.6.2")).toBe("widgets"); + }); + + it.each([ + [ + "an abnormal flag the HL70078 map does not carry", + "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + "ZZ", + ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ], + [ + "an interpretation from an unrelated system", + "http://example.org/flags", + "H", + ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2, + ], + ])("flags %s and emits no OBX-8", (_label, system, code, expected) => { + const result = toV2Observation( + observation({ status: "final", interpretation: [{ coding: [{ system, code }] }] }), + "R01", + ); + expect(codes(result)).toContain(expected); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.8")).toBe(undefined); + }); + + it("never composes OBX-7 from structured range endpoints", () => { + const result = toV2Observation( + observation({ + status: "final", + referenceRange: [{ low: { value: 130 }, high: { value: 170 } }], + }), + "R01", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged[0]?.v2Location).toBe("OBX.7"); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.7")).toBe(undefined); + }); + + it("flags a second reference range rather than dropping it silently", () => { + const result = toV2Observation( + observation({ status: "final", referenceRange: [{ text: "130-170" }, { text: "120-160" }] }), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE); + }); + + it("flags the subject and encounter rather than assembling a segment for them", () => { + const result = toV2Observation( + observation({ + status: "final", + subject: { reference: "Patient/1" }, + encounter: { reference: "Encounter/1" }, + }), + "R01", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged.map((i) => i.v2Location)).toEqual(["PID", "PV1"]); + expect(parseHL7(result.value?.toString() ?? "").segments("PID")).toHaveLength(0); + }); + + it("refuses a resource of another type", () => { + const result = toV2Observation(parseResource('{"resourceType":"Patient"}').resource, "R01"); + expect(result.value).toBeUndefined(); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Patient"); + }); +}); + +describe("toV2Observation: the never-throw guardrail on malformed input", () => { + it("flags an observation with no code rather than emitting a code-less OBX", () => { + const result = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it("flags a code that is a string where a CodeableConcept belongs, without throwing", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: primitive("789-8") }, + ]), + "R01", + ); + expect(result.value).toBeUndefined(); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it.each([ + ["a quantity magnitude that is a boolean", primitive(true)], + ["a quantity magnitude with no value at all", primitive(undefined)], + ])("flags %s, without throwing", (_label, magnitude) => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "valueQuantity", value: complex([{ name: "value", value: magnitude }]) }, + ]), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe(undefined); + }); + + it("reads a magnitude that arrived as a JSON string, and carries it verbatim", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "valueQuantity", value: complex([{ name: "value", value: primitive("12.50") }]) }, + ]), + "R01", + ); + expect(parseHL7(result.value?.toString() ?? "").get("OBX.5")).toBe("12.50"); + }); + + it("flags a status that is a boolean, without throwing", () => { + const result = toV2Observation( + complex([ + { name: "resourceType", value: primitive("Observation") }, + { name: "code", value: complex([{ name: "text", value: primitive("Hemoglobin") }]) }, + { name: "status", value: primitive(true) }, + ]), + "R01", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); + + it("carries CodeableConcept.text into CWE.9 when no coding resolves", () => { + const { value } = toV2Observation( + parseResource('{"resourceType":"Observation","status":"final","code":{"text":"Hemoglobin"}}') + .resource, + "R01", + ); + expect(parseHL7(value?.toString() ?? "").get("OBX.3.9")).toBe("Hemoglobin"); + }); +}); diff --git a/test/reverse/patient.test.ts b/test/reverse/patient.test.ts new file mode 100644 index 0000000..388d3ab --- /dev/null +++ b/test/reverse/patient.test.ts @@ -0,0 +1,333 @@ +/** + * FHIR `Patient` to a v2 ADT-shaped message carrying a PID: the happy path, the fail-safe refusals, + * and the never-throw guardrail on structurally malformed input. + * + * Every fixture is synthetic: the person tokens, MRN and date of birth are the ones declared in + * `scripts/phi-allow-list.txt`. + */ + +import { describe, it, expect } from "vitest"; +import { parseHL7 } from "@cosyte/hl7"; +import { complex, list, primitive, parseResource, type FhirComplex } from "@cosyte/fhir"; + +import { toV2Patient, ISSUE_CODES, GENDER_TO_V2, NAME_USE_TO_V2 } from "../../src/index.js"; + +function patient(json: Record): FhirComplex { + return parseResource(JSON.stringify({ resourceType: "Patient", ...json })).resource; +} + +const codes = (result: { issues: readonly { code: string }[] }): string[] => + result.issues.map((i) => i.code); + +describe("toV2Patient: the emitted message", () => { + it("builds a complete ADT message whose MSH-9 carries the caller's trigger verbatim", () => { + const { value, issues } = toV2Patient( + patient({ + identifier: [ + { + value: "MRN1", + type: { + coding: [{ system: "http://terminology.hl7.org/CodeSystem/v2-0203", code: "MR" }], + }, + }, + ], + name: [{ use: "maiden", family: "Public", given: ["Jane", "Q"] }], + birthDate: "1980-01-15", + gender: "female", + address: [ + { use: "home", line: ["123 Main St"], city: "Boston", state: "MA", postalCode: "02101" }, + ], + }), + "A28", + { assigningAuthorities: { "urn:oid:1.2.3": "HOSP" } }, + ); + + expect(issues).toEqual([]); + const wire = value?.toString() ?? ""; + expect(wire.startsWith("MSH|^~\\&|")).toBe(true); + + // Parses back under the parser that owns the wire format: no fatal error, MSH-led, fields intact. + const round = parseHL7(wire); + expect(round.meta.type).toBe("ADT^A28"); + expect(round.get("PID.3.1")).toBe("MRN1"); + expect(round.get("PID.3.5")).toBe("MR"); + expect(round.get("PID.5.1")).toBe("Public"); + expect(round.get("PID.5.2")).toBe("Jane"); + expect(round.get("PID.5.3")).toBe("Q"); + expect(round.get("PID.5.7")).toBe("M"); + expect(round.get("PID.7")).toBe("19800115"); + expect(round.get("PID.8")).toBe("F"); + expect(round.get("PID.11.1")).toBe("123 Main St"); + expect(round.get("PID.11.3")).toBe("Boston"); + expect(round.get("PID.11.5")).toBe("02101"); + expect(round.get("PID.11.7")).toBe("H"); + }); + + it("carries the MSH envelope the caller supplies, and repeats a repeating field", () => { + const { value } = toV2Patient( + patient({ name: [{ family: "Public" }, { family: "Doe" }] }), + "A31", + { envelope: { sendingApp: "EHR", sendingFacility: "MAIN", controlId: "MSGID1" } }, + ); + const round = parseHL7(value?.toString() ?? ""); + expect(round.meta.sendingApp).toBe("EHR"); + expect(round.meta.controlId).toBe("MSGID1"); + expect(round.get("PID.5.1")).toBe("Public"); + expect(round.get("PID.5[1].1")).toBe("Doe"); + }); + + it("escapes delimiter-bearing content instead of splitting a composite", () => { + const { value } = toV2Patient(patient({ name: [{ family: "Do^e|Public" }] }), "A28"); + const wire = value?.toString() ?? ""; + expect(wire).toContain("Do\\S\\e\\F\\Public"); + expect(parseHL7(wire).get("PID.5.1")).toBe("Do^e|Public"); + }); + + it("emits no message when nothing in the resource maps to a PID field, and says so", () => { + const result = toV2Patient(patient({ active: true }), "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED, + ]); + }); + + it("seeds the assigning authority only from the caller, never from the system URI", () => { + const withSeed = toV2Patient( + patient({ identifier: [{ value: "MRN1", system: "urn:oid:1.2.3" }] }), + "A28", + { assigningAuthorities: { "urn:oid:1.2.3": "HOSP" } }, + ); + expect(parseHL7(withSeed.value?.toString() ?? "").get("PID.3.4")).toBe("HOSP"); + // The seeding itself raises nothing. What this fixture DOES raise is PID-5: it carries no name, + // and PID-5 is v2-required, so its absence is declared rather than left for the receiver. + expect(codes(withSeed)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(withSeed.issues[0]?.v2Location).toBe("PID.5"); + + const unseeded = toV2Patient( + patient({ identifier: [{ value: "MRN1", system: "urn:oid:1.2.3" }] }), + "A28", + ); + expect(parseHL7(unseeded.value?.toString() ?? "").get("PID.3.4")).toBe(undefined); + expect(codes(unseeded)).toEqual([ + ISSUE_CODES.TRANSFORM_NO_V2_TARGET, + ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ]); + }); +}); + +describe("toV2Patient: a v2-required field with no FHIR source is declared, never fabricated", () => { + it("flags PID-3 and PID-5 when the Patient carries neither an identifier nor a name", () => { + const result = toV2Patient(patient({ gender: "female" }), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT, + ); + expect(flagged.map((i) => i.v2Location)).toEqual(["PID.3", "PID.5"]); + expect(flagged.map((i) => i.fhirPath)).toEqual(["Patient.identifier", "Patient.name"]); + + // The other half of the same rule, asserted in the same case: absent, not fabricated. Neither + // field carries a placeholder, an empty component, or anything else invented to satisfy v2. + const round = parseHL7(result.value?.toString() ?? ""); + expect(round.get("PID.3")).toBeUndefined(); + expect(round.get("PID.5")).toBeUndefined(); + }); + + it.each([ + ["PID.3", "Patient.identifier", { name: [{ family: "Public" }] }], + ["PID.5", "Patient.name", { identifier: [{ value: "MRN1" }] }], + ])( + "flags %s alone when the resource sources the other required field", + (location, path, json) => { + const result = toV2Patient(patient(json), "A28"); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + expect(result.issues[0]?.v2Location).toBe(location); + expect(result.issues[0]?.fhirPath).toBe(path); + }, + ); + + it("raises nothing when both required fields are sourced", () => { + const result = toV2Patient( + patient({ identifier: [{ value: "MRN1" }], name: [{ family: "Public" }] }), + "A28", + ); + expect(result.issues).toEqual([]); + }); + + it("declares the absence without touching a single byte of the wire", () => { + // The exact wire this shape emitted before the absence was declared, pinned byte for byte: this + // is a diagnostics-only contract, so no placeholder, no empty component, no reordering and no + // extra field may appear because a required field went unsourced. + const result = toV2Patient(patient({ identifier: [{ value: "MRN1" }] }), "A28", { + envelope: { controlId: "MSGID1", timestamp: "20260102101500" }, + }); + expect(result.value?.toString()).toBe( + "MSH|^~\\&|||||20260102101500||ADT^A28|MSGID1|P|2.5\rPID|||MRN1\r", + ); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT]); + }); + + it("says so when it declines to emit any message at all", () => { + // Nothing grounded a PID field, so there is no segment for a field to be absent from: one code + // declares the whole outcome, and `{ value: undefined }` is never a silent empty success. + const result = toV2Patient(patient({}), "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED]); + expect(result.issues[0]?.v2Location).toBe("PID"); + expect(result.issues[0]?.fhirPath).toBe("Patient"); + }); +}); + +describe("toV2Patient: the required trigger", () => { + it.each([ + ["empty", ""], + ["whitespace only", " "], + ])("refuses a %s trigger without building a message", (_label, trigger) => { + const result = toV2Patient(patient({ gender: "female" }), trigger); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("refuses a non-string trigger from an untyped caller without throwing", () => { + // Deliberate: the parameter is typed `string`, and a JavaScript caller can still pass anything. + const result = toV2Patient(patient({ gender: "female" }), undefined as unknown as string); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_MISSING_TRIGGER]); + }); + + it("uses the trigger verbatim, never padded or normalized", () => { + const { value } = toV2Patient(patient({ gender: "male" }), "a08"); + expect(parseHL7(value?.toString() ?? "").meta.type).toBe("ADT^a08"); + }); +}); + +describe("toV2Patient: refusals that never guess", () => { + it("refuses a resource of another supported type and names it", () => { + const result = toV2Patient( + parseResource('{"resourceType":"Observation","status":"final"}').resource, + "A28", + ); + expect(result.value).toBeUndefined(); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Observation"); + }); + + it("reports an unrecognized resource type generically, so no input text reaches a diagnostic", () => { + const result = toV2Patient(parseResource('{"resourceType":"Zzz9"}').resource, "A28"); + expect(result.issues[0]?.code).toBe(ISSUE_CODES.TRANSFORM_UNSUPPORTED_RESOURCE); + expect(result.issues[0]?.fhirPath).toBe("Resource"); + }); + + it.each([ + ["a gender with an ambiguous inverse", { gender: "other" }, "PID.8"], + [ + "a name use with an ambiguous inverse", + { name: [{ use: "official", family: "Public" }] }, + "XPN.7", + ], + [ + "an address use with an ambiguous inverse", + { address: [{ use: "work", city: "Boston" }] }, + "XAD.7", + ], + [ + "an address type, which XAD.7 cannot carry alongside use", + { address: [{ type: "postal", city: "Boston" }] }, + "XAD.7", + ], + ])("flags %s rather than picking one v2 code", (_label, json, location) => { + const result = toV2Patient(patient(json), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE, + ); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe(location); + expect( + parseHL7(result.value?.toString() ?? "MSH|^~\\&|||||20260101||ADT^A28|1|P|2.5").get("PID.8"), + ).toBe(undefined); + }); + + it("keeps the invertible rows of the same maps", () => { + expect(GENDER_TO_V2["female"]).toBe("F"); + expect(GENDER_TO_V2["other"]).toBeUndefined(); + expect(NAME_USE_TO_V2["maiden"]).toBe("M"); + expect(NAME_USE_TO_V2["official"]).toBeUndefined(); + }); + + it.each([ + ["a third given name", { name: [{ family: "Public", given: ["Jane", "Q", "X"] }] }, "XPN.3"], + ["a second prefix", { name: [{ family: "Public", prefix: ["Dr", "Prof"] }] }, "XPN.5"], + ["a second suffix", { name: [{ family: "Public", suffix: ["Jr", "III"] }] }, "XPN.4"], + ["a third address line", { address: [{ line: ["123 Main St", "Apt 4", "1 St"] }] }, "XAD.2"], + ["a birth date carrying a time", { birthDate: "1980-01-15T10:00:00-05:00" }, "PID.7"], + ])("flags %s rather than truncating it", (_label, json, location) => { + const result = toV2Patient(patient(json), "A28"); + const flagged = result.issues.filter( + (i) => i.code === ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + ); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe(location); + }); + + it("flags an identifier type coding from an unrelated table", () => { + const result = toV2Patient( + patient({ + identifier: [ + { value: "MRN1", type: { coding: [{ system: "http://example.org/local", code: "MR" }] } }, + ], + }), + "A28", + ); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_CODE_SYSTEM_NOT_V2); + expect(parseHL7(result.value?.toString() ?? "").get("PID.3.5")).toBe(undefined); + }); + + it("flags a populated element with no v2 field in this map", () => { + const result = toV2Patient( + patient({ gender: "female", telecom: [{ value: "555-1234" }] }), + "A28", + ); + const flagged = result.issues.filter((i) => i.code === ISSUE_CODES.TRANSFORM_NO_V2_TARGET); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.v2Location).toBe("PID.13"); + expect(flagged[0]?.fhirPath).toBe("Patient.telecom"); + }); +}); + +describe("toV2Patient: the never-throw guardrail on malformed input", () => { + it.each([ + ["a node that is not a resource", primitive("Patient")], + ["a resource with no resourceType", complex([{ name: "gender", value: primitive("female") }])], + ])("returns a typed diagnostic for %s", (_label, node) => { + const result = toV2Patient(node, "A28"); + expect(result.value).toBeUndefined(); + expect(codes(result)).toEqual([ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED]); + }); + + it.each([ + [ + "a name that is a string where a HumanName belongs", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "name", value: primitive("Public") }, + ]), + ], + [ + "a birthDate that is a boolean", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "birthDate", value: primitive(true) }, + ]), + ], + [ + "a given name list holding a complex", + complex([ + { name: "resourceType", value: primitive("Patient") }, + { name: "name", value: list([complex([{ name: "given", value: list([complex([])]) }])]) }, + ]), + ], + ])("flags %s without throwing", (_label, node) => { + const result = toV2Patient(node, "A28"); + expect(codes(result)).toContain(ISSUE_CODES.TRANSFORM_RESOURCE_MALFORMED); + }); +}); diff --git a/test/reverse/property.test.ts b/test/reverse/property.test.ts new file mode 100644 index 0000000..5707d86 --- /dev/null +++ b/test/reverse/property.test.ts @@ -0,0 +1,185 @@ +/** + * Property + fuzz coverage over the **reverse boundary**. For arbitrary (including hostile) FHIR + * resources and triggers, `toV2Patient` / `toV2Observation` must: + * 1. **never throw** (the fail-safe rule, in the other direction); + * 2. raise only **registered**, **value-free** issue codes (a sentinel threaded through every + * mapped value, and through `resourceType` itself, must never reach the diagnostic channel); + * 3. emit a **complete message** that `@cosyte/hl7`'s own parser accepts without a fatal error + * (never a bare segment: a segment with no MSH is not parseable HL7); + * 4. carry the caller's trigger **verbatim** in MSH-9, under the message code the shape fixes; and + * 5. build **no message at all** when the required trigger is missing, empty, or not bare. + * + * (3) is a parses-back check, not a round-trip claim: nothing here asserts that the emitted message + * equals, or transforms back to, any original. + */ + +import { describe, it, expect } from "vitest"; +import fc from "fast-check"; +import { parseHL7, Hl7ParseError } from "@cosyte/hl7"; +import { parseResource } from "@cosyte/fhir"; + +import { + toV2Patient, + toV2Observation, + ISSUE_CODES, + ISSUE_REGISTRY, + type ReverseResult, +} from "../../src/index.js"; + +const SENTINEL = "PHIZZ"; +const registeredCodes = new Set(Object.values(ISSUE_CODES)); +const numRuns = Number(process.env["FUZZ_RUNS"] ?? "300"); + +/** A token that always carries the leak sentinel, and may carry HL7 delimiters. */ +const token = fc.stringMatching(/^[A-Za-z0-9 |^~\\&]{0,8}$/).map((s) => SENTINEL + s); +const optToken = fc.option(token, { nil: undefined }); +const trigger = fc.constantFrom("A01", "A08", "A28", "A31", "R01", "R30", "a08", " A01 ", "^", "|"); +const gender = fc.constantFrom("female", "male", "other", "unknown", "PHIZZ", ""); +const status = fc.constantFrom("final", "corrected", "entered-in-error", "registered", "PHIZZ", ""); + +/** Assert the invariants that hold for every reverse result, whatever it was handed. */ +function assertResult(result: ReverseResult, messageCode: string, trig: string): void { + // (2) registered + value-free + expect(JSON.stringify(result.issues)).not.toContain(SENTINEL); + for (const raised of result.issues) { + expect(registeredCodes.has(raised.code)).toBe(true); + expect(raised.v2Location.length).toBeGreaterThan(0); + expect(raised.message).toBe(ISSUE_REGISTRY[raised.code].message); + } + if (result.value === undefined) return; + // (3) a complete message the parser accepts, never a bare segment + const wire = result.value.toString(); + expect(wire.startsWith("MSH|")).toBe(true); + const round = parseHL7(wire); + // (4) the trigger, verbatim, under the shape's own message code + expect(round.meta.type).toBe(`${messageCode}^${trig}`); +} + +describe("reverse boundary: fail-safe, value-free, parses back, trigger verbatim", () => { + it("never throws and holds every invariant over arbitrary Patient resources", () => { + const arb = fc.record({ + trig: trigger, + family: optToken, + given: fc.array(token, { maxLength: 3 }), + mrn: optToken, + system: fc.option(fc.constantFrom("urn:oid:1.2.3", "http://example.org/x"), { + nil: undefined, + }), + sex: gender, + use: fc.constantFrom("official", "maiden", "temp", "PHIZZ", ""), + birthDate: fc.constantFrom("1980-01-15", "1980-01", "1980", "PHIZZ", "1980-01-15T10:00:00Z"), + city: optToken, + }); + fc.assert( + fc.property(arb, (p) => { + const resource = parseResource( + JSON.stringify({ + resourceType: "Patient", + identifier: p.mrn === undefined ? undefined : [{ value: p.mrn, system: p.system }], + name: [{ use: p.use, family: p.family, given: p.given }], + birthDate: p.birthDate, + gender: p.sex, + address: [{ city: p.city }], + }), + ).resource; + let result: ReverseResult; + try { + result = toV2Patient(resource, p.trig, { + assigningAuthorities: { "urn:oid:1.2.3": "HOSP" }, + }); + } catch (err) { + throw new Error("toV2Patient threw (reverse fail-safe violated)", { cause: err }); + } + assertResult(result, "ADT", p.trig); + }), + { numRuns }, + ); + }); + + it("never throws and holds every invariant over arbitrary Observation resources", () => { + const arb = fc.record({ + trig: trigger, + code: optToken, + system: fc.constantFrom("http://loinc.org", "http://example.org/local", ""), + state: status, + magnitude: fc.constantFrom(120.5, 0, -3, 1), + unit: optToken, + text: optToken, + flag: fc.constantFrom("H", "ZZ", "PHIZZ", ""), + effective: fc.constantFrom("2026-01-02T10:15:00-05:00", "2026-01-02", "PHIZZ"), + }); + fc.assert( + fc.property(arb, fc.boolean(), (p, coded) => { + const value = coded + ? { valueCodeableConcept: { coding: [{ system: p.system, code: p.code }] } } + : { valueQuantity: { value: p.magnitude, unit: p.unit } }; + const resource = parseResource( + JSON.stringify({ + resourceType: "Observation", + status: p.state, + code: { coding: [{ system: p.system, code: p.code }], text: p.text }, + effectiveDateTime: p.effective, + interpretation: [ + { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", + code: p.flag, + }, + ], + }, + ], + ...value, + }), + ).resource; + let result: ReverseResult; + try { + result = toV2Observation(resource, p.trig); + } catch (err) { + throw new Error("toV2Observation threw (reverse fail-safe violated)", { cause: err }); + } + assertResult(result, "ORU", p.trig); + }), + { numRuns }, + ); + }); + + it("never throws, and never emits, on a hostile resourceType or an unusable trigger", () => { + const hostileType = fc.stringMatching(/^[A-Za-z0-9]{0,12}$/).map((s) => SENTINEL + s); + fc.assert( + fc.property(hostileType, fc.constantFrom("", " ", "A^28", "A 28", "A28"), (type, trig) => { + const resource = parseResource( + JSON.stringify({ resourceType: type, gender: "female" }), + ).resource; + const result = toV2Patient(resource, trig); + // A rejected type never reaches a diagnostic: it is reported as the generic Resource. + expect(JSON.stringify(result.issues)).not.toContain(SENTINEL); + for (const raised of result.issues) expect(registeredCodes.has(raised.code)).toBe(true); + expect(result.value).toBeUndefined(); + if (trig.trim() === "") { + expect(result.issues.map((i) => i.code)).toContain(ISSUE_CODES.TRANSFORM_MISSING_TRIGGER); + } + }), + { numRuns }, + ); + }); + + it("refuses a trigger that is not bare, rather than writing something else into MSH-9", () => { + for (const trig of ["A^28", "A 28", "A|28", "A~28"]) { + const result = toV2Patient( + parseResource('{"resourceType":"Patient","gender":"female"}').resource, + trig, + ); + expect(result.value).toBeUndefined(); + expect(result.issues.map((i) => i.code)).toEqual([ + ISSUE_CODES.TRANSFORM_VALUE_NOT_REPRESENTABLE, + ]); + } + }); + + it("emits nothing a v2 parser would fatally reject", () => { + // The one shape that is guaranteed to fail: a bare segment with no MSH. Asserted here so the + // parses-back property above is known to have teeth. + expect(() => parseHL7("PID|||MRN1||Public^Jane")).toThrow(Hl7ParseError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4e912e7..f0343ac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ import { cosyteVitest } from "@cosyte/vitest-config"; * foundation. Add directories here (e.g. "messages", "profiles") as later phases land. */ export default cosyteVitest({ - coverageDirs: ["datatypes", "diagnostics", "terminology", "messages"], + coverageDirs: ["datatypes", "diagnostics", "terminology", "messages", "reverse"], test: { globals: false, environment: "node",