@cosyte/hl7 0.0.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +490 -0
- package/README.md +498 -41
- package/dist/index.cjs +8645 -588
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6034 -1477
- package/dist/index.d.ts +6034 -1477
- package/dist/index.mjs +8616 -589
- package/dist/index.mjs.map +1 -1
- package/package.json +49 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,495 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 45c5b78: Added a datetime conversion surface: `toObject`, `toISO` and `toDate`.
|
|
8
|
+
|
|
9
|
+
Working with a parsed HL7 datetime used to mean either reading `DtmParts` field by field or reaching for `dtmToDate`, which is deliberately narrow: it answers with an absolute instant or with nothing. The two answers a developer reaches for in between, a plain calendar object and an ISO-8601 string, had no route at all. These three functions are that route, and they carry the same names and the same semantics in every `@cosyte` parser, so the conversion story is learned once.
|
|
10
|
+
|
|
11
|
+
`toObject(value)` returns a frozen plain object holding only the components the value actually stated: no `raw`, no `valid`, no `precision`, nothing zero-filled, so `Object.keys()` recovers the value's precision. `month` is spec-native 1 to 12, so deleting `offsetMinutes` leaves an object `Temporal.PlainDateTime.from({...})` and luxon's `DateTime.fromObject({...})` accept with no key rename and no value adjustment. Neither library is a dependency, and neither needs to be. `millisecond` is the first three fractional digits taken verbatim and right-padded, so `.0500` is 50 ms and `.5` is 500 ms, never a float multiplication that drops the last digit of a value like `.123`.
|
|
12
|
+
|
|
13
|
+
`toISO(value)` renders ISO-8601 truncated to the stated precision, with the fractional digits verbatim rather than padded to three. Nothing is appended when the value carried no offset: the string is deliberately zone-less rather than carrying a fabricated `Z`. A stated zero offset does render `Z`, including HL7's `-0000` form, so `toISO` is a rendering and not a round trip; `formatDtm` is still the byte-exact route back to the wire and is unchanged.
|
|
14
|
+
|
|
15
|
+
`toDate(value, options?)` delegates to `dtmToDate`, so the timezone honesty is the one this library has always applied: a stated offset gives the exact instant and ignores `assumeOffsetMinutes`; no offset plus an explicit `assumeOffsetMinutes` applies that offset; no offset and no option returns `undefined`. The host machine's timezone is never read and UTC is never assumed, and a four-digit year below 100 stays that year.
|
|
16
|
+
|
|
17
|
+
All three return `undefined` rather than throwing for a value the parser marked invalid, for `undefined` and for `null`. That includes a value refused as order-ambiguous: the report on it is bookkeeping and never reaches the converted object.
|
|
18
|
+
|
|
19
|
+
They also refuse a value whose stated components do not name a real point on the calendar: month 1 to 12, day 1 to the last day that month has under the full 4/100/400 leap rule, hour 0 to 23, minute and second 0 to 59. `20240230` and `20230229` convert to `undefined` rather than rolling over into 1 March, and the whole value is refused rather than an in-range prefix of it converted. Rendering such a date would be worse than refusing it, because `new Date("2024-02-30")` is 1 March in every JavaScript runtime, so a consumer reading the string would get a date of birth a day out with nothing thrown and nothing warned. `parseDtm` itself is unchanged and stays deliberately liberal, so `formatDtm` still round-trips those wire bytes: the bound is part of the conversion, which is the step that would otherwise answer confidently and wrongly.
|
|
20
|
+
|
|
21
|
+
The offset a value states is bounded on the same terms as its day: a whole number of minutes within 23 hours 59 minutes of UTC, which is both what the `+HH:MM` rendering can state and what an ISO-8601 reader accepts, since `new Date("2024-02-29T12:00:00+24:00")` is an `Invalid Date`. Anything else names no zone, so all three answer `undefined` rather than reporting a string in a slot documented as a number of minutes east of UTC, rendering `+NaN:NaN` or a three-digit hour into a two-digit slot, or multiplying a `null` or a `"0"` to zero and handing back a confident UTC instant. Two routes reach that field without a cast: a hand-built value, since the parsed type is a structural interface, and `msg.meta.timestamp`, whose lenient fallback read range-checks the calendar components and not the offset, so a message timestamp of `2024-02-29T12:00:00+99:99` states 6039 minutes east and used to render as `+100:39`. Every zone the world keeps is far inside the bound, the widest in use being 14 hours east; a stated offset still wins outright over any assumption beside it; and an `offsetMinutes` on a value that claims no zone states nothing, so it is ignored rather than refused. `parseDtm` still accepts what it always accepted, up to `+2400`, and `formatDtm` and `dtmToDate` still answer for it: the refusal belongs to the conversion, exactly as the calendar bound does.
|
|
22
|
+
|
|
23
|
+
`toDate` is equally careful about the argument it is handed rather than only the value: the options bag may be `null` or absent, and `assumeOffsetMinutes` has to be a finite number of minutes or the answer is `undefined`. From JavaScript, where the type is not checked for you, `"0"`, `true` and `[]` all multiply to `0`, so an unguarded delegation would hand back a confident UTC instant nobody asked for, which is the guess this surface exists to refuse; an offset large enough to leave the range a `Date` can represent is refused the same way, so no answer is ever an `Invalid Date`. A value carrying its own offset is untouched by this: its offset still wins outright and the assumption beside it is ignored, not treated as an error. `dtmToDate` keeps the behaviour it has always had; the check belongs to the new surface.
|
|
24
|
+
|
|
25
|
+
`ToDateOptions` is added as a second spelling of the existing `DtmToDateOptions`. It is an alias, so the two names denote one type and either may be used; it exists because every parser in the suite spells this options type the same way, and a consumer writing across two of them should not have to spell it differently in each.
|
|
26
|
+
|
|
27
|
+
The change is additive: no existing export was removed, renamed or changed, and the package still has zero runtime dependencies.
|
|
28
|
+
|
|
29
|
+
- 848c7e7: `msg.allergies()` now reads `IAM` (Patient Adverse Reaction Information) segments as well as `AL1`, so an ADT^A60, or any message that carries its allergies in `IAM`, no longer returns an empty list that reads as "no allergies recorded".
|
|
30
|
+
|
|
31
|
+
Before this release the helper walked `AL1` only. A conformant ADT^A60 carries `IAM` inside its adverse reaction groups and no `AL1` at all, so every allergy it sent was absent from `msg.allergies()`, with nothing to say the list was incomplete.
|
|
32
|
+
|
|
33
|
+
Each `IAM` is now one entry, in document order with any `AL1` entries around it. IAM-2 to IAM-5 fill `type`, `code`, `severity` and `reaction` in the same shapes an `AL1` entry uses. Four keys are new on `Allergy`:
|
|
34
|
+
- `source`: `"AL1"` or `"IAM"`, the segment the entry was read from. Always present, so a TypeScript literal typed as `Allergy` now has to set it.
|
|
35
|
+
- `actionCode`: IAM-6 component 1 exactly as sent, with no case-folding and no mapping. It is absent when IAM-6 is empty, never assumed to be `A`.
|
|
36
|
+
- `deleteRequested`: `true` when the action code is exactly `D`, meaning the sender asks the receiver to delete an allergy it sent earlier. The entry is still returned with every field it carries. A lowercase `d`, an unlisted code or a malformed IAM-6 (repeated, or with subcomponents) is surfaced as it arrived and not marked.
|
|
37
|
+
- `uniqueIdentifier`: IAM-7 components 1 and 2 (`entityIdentifier`, `namespaceId`). It is never filled from IAM-3 when IAM-7 is absent.
|
|
38
|
+
|
|
39
|
+
The helper reports what each segment said and applies none of it: a delete is not carried out, an update does not replace an earlier entry, and an `AL1` and an `IAM` for the same allergen stay two entries. It does not read the `IAR` and `NTE` segments under an `IAM`, IAM-1, or IAM-8 onward; `msg.segments("IAM")` reaches them. `AL1` entries are unchanged apart from `source: "AL1"`.
|
|
40
|
+
|
|
41
|
+
- 0ee807f: Schema emission for the serialized message projection: the library now authors the JSON Schema and Zod description of the object `toJSON()` returns, so a consumer validating a snapshot no longer transcribes a shape they do not own.
|
|
42
|
+
|
|
43
|
+
`messageJsonSchema()` returns a JSON Schema document at the `2020-12` dialect describing `SerializedMessage`: encoding characters, segments, fields, repetitions, components, subcomponents, warnings and the optional profile. `messageZodSource()` returns Zod schemas as TypeScript SOURCE TEXT to write into your own project. `emitMessageSchema(target)` is the named-target route over both, with `SCHEMA_EMIT_TARGETS`, `SchemaEmitTarget`, `isSchemaEmitTarget`, `SchemaTargetError`, `JSON_SCHEMA_DIALECT`, `JsonSchemaDocument` and `JsonSchemaNode` alongside it. Both artifacts are also committed under `schema/` and re-checked on every test run.
|
|
44
|
+
|
|
45
|
+
**Still zero runtime dependencies, and still none in development either.** The Zod artifact is TEXT: nothing in `src/` imports `zod` or references a `zod` value, and no JSON Schema validator was added to run any of this. The emitted `import { z } from "zod"` resolves against the consumer's own install, and which `zod` major that is stays theirs.
|
|
46
|
+
|
|
47
|
+
**Nothing here validates anything.** Both functions build a description and return it. No message is read, no schema is evaluated against a value, and no finding, diagnostic or warning is produced. Conformance checking is a separate surface and this is not an input to it.
|
|
48
|
+
|
|
49
|
+
**The description is measured, not transcribed, which is a difference you can observe.** `EncodingCharacters` declares an optional `truncation` member and the serializer copies exactly five keys, never that one, so a v2.7 message whose MSH-2 carries a truncation character has it on `msg.encodingCharacters` and not on `msg.toJSON().encodingCharacters`. The emitted schema declares the five the snapshot really contains and permits no sixth. The warning `code` enumeration is read from the warning registry in both directions, the fixture corpus is walked against the document at every nesting level on every test run, and the two artifacts are compared member for member with the Zod side recovered from its own emitted text.
|
|
50
|
+
|
|
51
|
+
**No message content reaches the output, by construction and by test.** Emission is a function of the library rather than of anything it has parsed: the same version emits byte-identical output whatever has been parsed, in whatever order. The document carries no `default`, no `examples` and no sample value, and the only literals in either artifact are the dialect URI, member and type names, and the registry's warning codes.
|
|
52
|
+
|
|
53
|
+
**Additive only.** No existing export was removed, renamed or re-typed, no parse result changed, and no serialized byte moved.
|
|
54
|
+
|
|
55
|
+
- 08a6864: **This is 0.1.0, the first release of `@cosyte/hl7` whose public API we treat as settled.**
|
|
56
|
+
|
|
57
|
+
What is covered, and what you can build against:
|
|
58
|
+
- Parsing HL7 v2 in one line with `parseHL7`: lenient by default, so the deviations real feeds carry
|
|
59
|
+
come back as stable warning codes rather than exceptions, with MLLP framing stripped, and a strict
|
|
60
|
+
mode for when you want a refusal instead. Batch files and multi-gigabyte streams (`parseStream`)
|
|
61
|
+
parse too.
|
|
62
|
+
- Reading without the spec: named helpers (`msg.patient`, `msg.visit`, `msg.observations()`,
|
|
63
|
+
`msg.allergies()`, `msg.medications()`, `msg.immunizations()` and more), dot-paths, and structural
|
|
64
|
+
traversal, with every datetime kept at the precision and zone it arrived with.
|
|
65
|
+
- Writing: `setField` and the segment methods mutate explicitly, the typed builders assemble ADT, ORM,
|
|
66
|
+
ORU, SIU, MDM, DFT and VXU messages, and the serializer always emits spec-clean HL7.
|
|
67
|
+
- Checking: conformance profiles you define and validate against, the opt-in
|
|
68
|
+
`validateMessageStructure` against HL7's published message structures, typed overlays that narrow
|
|
69
|
+
a message to its type, and JSON Schema and Zod emission for `toJSON()` output.
|
|
70
|
+
|
|
71
|
+
What the version promises. The exported functions, the message and helper surfaces, and the warning
|
|
72
|
+
and finding codes are the surface we keep stable: renaming a warning code is a breaking change. While
|
|
73
|
+
the package is below 1.0, a breaking change bumps the minor version (0.1 to 0.2) and is called out in
|
|
74
|
+
this changelog; a fix that changes no public value ships as a patch. Upgrading from 0.0.10 is itself
|
|
75
|
+
breaking in a few places, each named in the entries below: `buildAdt` now refuses a merge trigger
|
|
76
|
+
with no prior identity, and several types and profile rules narrow.
|
|
77
|
+
|
|
78
|
+
What is not covered. There is no network transport: MLLP framing is stripped on read, and sending
|
|
79
|
+
or receiving over a socket belongs to `@cosyte/mllp`. The parser does not convert HL7 v2 to FHIR
|
|
80
|
+
(that is `@cosyte/transform`), it reads v2 only (not v3 or CDA), and it validates structure rather
|
|
81
|
+
than every coded value against every HL7 table.
|
|
82
|
+
|
|
83
|
+
- 3d33917: Five new typed builders close the gap between what the library can READ and what it can AUTHOR: orders (`buildOrm`), scheduling (`buildSiu`), clinical documents (`buildMdm`), charges (`buildDft`) and immunizations (`buildVxu`), plus optional prior-identity content on the ADT typed init so merge, move and identifier-change events can be authored spec-clean.
|
|
84
|
+
|
|
85
|
+
Until now the read side surfaced orders, appointments, documents, charges, immunizations and identity events, while the emit side offered exactly three typed builders (`buildAdt`, `buildOru`, `buildAck`) beside the untyped `buildMessage`. A consumer sending a scheduling notification, a document notification, a charge, an immunization update, an order or a patient merge had to hand-assemble segments and delimiters, which is the class of work the encode-safe path exists to remove.
|
|
86
|
+
|
|
87
|
+
**Every new builder is the mirror of its read helper.** `buildSiu` takes the appointment identifiers, timing and resource groups that `msg.appointments()` returns; `buildMdm` takes the document header and body that `msg.documents()` returns; `buildDft` takes the charges `msg.charges()` returns; `buildVxu` takes the doses `msg.immunizations()` returns; `buildOrm` takes the orders `msg.orders()` returns. Values go through the same encode-safe path the existing builders use, so a delimiter, repetition or escape character inside a supplied value is escaped as data and can never forge a field or component boundary.
|
|
88
|
+
|
|
89
|
+
**The support claim is published and checkable.** `SUPPORTED_BUILDER_MESSAGES` lists every (message code, trigger event) pair the typed builders can author as a structurally complete message, `supportsBuilderMessage(code, event)` answers for one pair, and `TYPED_BUILDER_COVERAGE` records what each builder can emit. The set is DERIVED from the same published message structures the parser checks against, intersected with what each typed init can supply, so it cannot claim a pair whose structure needs a segment no init can populate: `ADT^A15` needs `PRT` and `ADT^A20` needs `NPU`, and neither is claimed. One invariant test builds every published pair and requires a zero-warning re-parse with no missing expected group.
|
|
90
|
+
|
|
91
|
+
**Absent content is refused, never emitted incomplete.** Asking for a pair without the content its published structure requires is a typed `TypeError` naming the message type and the missing content: an `MDM^T02` with no document body, a `DFT^P03` with no transaction, an `SIU` with no resource group, an `ADT^A40` with no prior identity. Supplying an empty collection is refused the same way, and an init that is not an object at all is refused before it is dereferenced.
|
|
92
|
+
|
|
93
|
+
**A trigger event outside the set is still emitted, not rejected.** The published set is a claim about structural completeness, not an allow list: a builder that takes a trigger event assembles the content supplied for any event, and the structure summary on the re-parsed message reports what is missing, exactly as it does for a message that arrived from anywhere else. Every event the shipped builders accept today, they still accept.
|
|
94
|
+
|
|
95
|
+
**No money as a float, and no fabricated clinical value.** A `DFT` amount is supplied as its typed parts (price, denomination, price type) and every part travels verbatim: nothing parses an amount to a number, rounds it, or infers a currency. Across all five builders an omitted optional member becomes an absent field, never a defaulted value; a vaccination action code and a document completion status in particular are emitted only when supplied, and `MDM`'s completion status (TXA-17) and availability status (TXA-19) stay on separate positions and are never merged.
|
|
96
|
+
|
|
97
|
+
**Two behaviour changes to check before you upgrade.** Everything else here is additive: `buildOru`, `buildAck`, `buildMessage`, the parser, the structure registry and the vendored publication snapshot are unchanged in behaviour, and a message the ADT builder emits for a call it already accepted is byte-identical to before.
|
|
98
|
+
1. **`buildAdt` now refuses a merge event with no prior identity.** For the ten trigger events whose published structure requires `MRG` (A40, A41, A42, A43, A44, A45, A47, A49, A50, A51), `buildAdt("A40", { patient })` returned a message carrying a `MISSING_EXPECTED_GROUP` warning before and now throws a `TypeError` naming `ADT^A40` and the member that would supply the missing segment. Add `priorIdentity` to those calls, or use `buildMessage` where an incomplete message is what you want. Every other ADT trigger event, including one the published structures do not recognize, is still emitted exactly as before.
|
|
99
|
+
2. **`msg.identityEvents()` recognizes five more trigger events.** A45 (move visit information) and the identifier-change family A47, A49, A50 and A51 previously returned `[]`; they now return an event carrying the MRG-sourced `prior` party, the PID-sourced `surviving` party and the constant `direction: "MRG_TO_PID"`, like every other identity event. The recognized set is now floored by the published message structures, so it cannot fall behind them again. `IdentityEventKind` gains a `"change"` member for that family: an identifier change replaces one identifier on one record, so it is deliberately NOT reported as a `"merge"`, and code that acts on `kind === "merge"` keeps acting on exactly the events it acts on today. An exhaustive `switch` over `IdentityEventKind` needs a `"change"` arm.
|
|
100
|
+
|
|
101
|
+
- ba36129: Immunizations now say whether a record is a dose given: every entry `msg.immunizations()` returns carries `administrationStatus`, derived from that RXA's own RXA-21, RXA-5 and RXA-20. A refusal, a not-administered record, a CVX `998` "no vaccine administered" placeholder and a request to delete an administration sent earlier no longer share one list with doses given and no field telling them apart.
|
|
102
|
+
- `administrationStatus.classification` is one of `completed`, `not-done`, `no-vaccine-administered`, `delete-requested` or `undetermined`.
|
|
103
|
+
- One rule decides, the first that applies: RXA-21 exactly `D` is `delete-requested`; an RXA-21 present but not exactly one of `A`, `D`, `U`, `X` (HL7 Table 0323) is `undetermined`; RXA-5 carrying CVX `998` is `no-vaccine-administered` whatever RXA-20 says, or `undetermined` when the other RXA-5 triplet carries a different identifier; otherwise RXA-20 by HL7's Table 0322 to Event Status map, `CP` and `PA` `completed`, `RE` and `NA` `not-done`. RXA-21 `A`, `U`, `X` and empty change nothing.
|
|
104
|
+
- `undetermined` is the fail-safe. An empty, absent or `""` RXA-20 and any RXA-20 or RXA-21 that is not exactly one code (a lowercase `cp` or `d`, whitespace around it, a VT or FS byte inside it, a code written as an escape sequence, or more than one repetition, component or subcomponent such as `D~A`) is `undetermined`. Only an exact `CP` or `PA`, with RXA-21 empty or exactly `A`, `U` or `X` and no CVX `998`, is ever `completed`.
|
|
105
|
+
- `administrationStatus.completionStatus` and `administrationStatus.actionCode` are the raw RXA-20 and RXA-21 codes, byte-identical to the entry's `completionStatus` and `actionCode` and absent exactly when those are. `administrationStatus.decidedBy` names the field that decided and what it read: HL7 Table 0322 version `3.0.0` with the map's canonical URL and version `1.0.0`, HL7 Table 0323 version `3.0.0`, or the CDC CVX code set for `998`.
|
|
106
|
+
- A status is reported, never applied: a `delete-requested` record is still returned in document order, and nothing is removed, merged or matched against an earlier message. Each RXA classifies from its own fields; nothing is carried across segments. `completionStatus`, `actionCode` and every other existing key are unchanged.
|
|
107
|
+
- New exported types: `ImmunizationAdministrationStatus`, `ImmunizationStatusClass`, `ImmunizationStatusBasis`, `ImmunizationStatusTable`, `ImmunizationStatusMap` and `ImmunizationStatusCodeSet`. `Immunization` gains the required `administrationStatus` key, so code that builds `Immunization` objects by hand must now supply it.
|
|
108
|
+
- New docs page: `docs-content/spec-notes-immunization-status.md`.
|
|
109
|
+
|
|
110
|
+
- 3ef4bfd: Conformance profiles: condition predicates, evaluated against the message.
|
|
111
|
+
|
|
112
|
+
A conditionally-used element could previously be declared but not decided. The engine derived no outcome from the message: a `C(t/f)` rule applied an outcome only when the caller handed it one, and `C`, `CE` and `B` had their presence skipped entirely. That is what made an empty findings list weaker than it read. Some elements were not assessed, they were stepped over, and an element whose presence nothing evaluated is indistinguishable from one that passed.
|
|
113
|
+
|
|
114
|
+
A rule may now declare a **condition predicate** on its `condition`, and the engine evaluates it against the parsed message and applies the outcome it selects. The predicate shape follows the published condition-predicate definition language: a LOCATION naming an element in the same message (segment, then the 1-indexed field, component and subcomponent), a VERB from the closed set (`is`, `is not`, `contains`, `does not contain`, `matches`, `does not match`) with a value list the author supplies, or a presence statement (`is valued`, `is not valued`), and `AND` / `OR` / `XOR` connectors that nest. Each negative verb is the exact complement of its positive twin for one value, but not for a location that repeats: see below. A regular-expression value is anchored over the whole value and read in Unicode mode.
|
|
115
|
+
|
|
116
|
+
**The three usage codes a predicate may sit on, and the outcomes each selects.** A declared conditional `C(t/f)` carries its outcomes explicitly. The bare conditional codes carry theirs by definition, and both branches are the ones IHE ITI TF Vol.2 Appendix C states: a satisfied `C` is Required and an unsatisfied one Not-permitted; a satisfied `CE` is Required-but-may-be-Empty against the same Not-permitted. `B` is deliberately excluded, for the same reason its presence behaviour was a bounded choice rather than a sourced one: the methodology lists it among the allowable indicators and carries no definition text for it, so nothing says what a predicate on a `B` element would decide.
|
|
117
|
+
|
|
118
|
+
**A predicate the message cannot decide is reported, never guessed.** A presence statement is always determinate, because an element the message does not carry is simply not valued. A comparison against an element the message does not carry is not: the language compares content and there is no content to compare, and deciding it false would fabricate an answer that turns a required element into a permitted absence. That case now yields a new finding code, `PROFILE_CONDITION_UNEVALUATABLE`, and the element's presence is not assessed. A connected predicate is undecidable exactly when its result depends on an undecidable operand, so `AND` is false as soon as one side is false, `OR` is true as soon as one side is true, and `XOR` always needs both.
|
|
119
|
+
|
|
120
|
+
**Where a location reads, and what a negative verb asks there.** A repeating field or a segment occurring more than once needs only one match: a statement is true when one or more of those repetitions or occurrences satisfies it, the language's default occurrence semantics, negative verbs included. Read that last clause carefully, because it is where a predicate is easiest to misread: `is not` is true when SOME repetition is not a listed value, not when every repetition is not one, so at a repeating location a verb and its negative can both decide true against the same value list (`PID-3.1 is 'MRN1'` and `PID-3.1 is not 'MRN1'` are both true for `PID-3 = MRN1~MRN2`). A negative verb is not a way to ask whether NO repetition is a listed value; the language carries no negation connector, so that question cannot be written in a predicate. Where the location carries exactly one value the pair does behave as an exact complement, and where it carries none neither half decides. Presence asks whether there is any content at or below the coordinate, which is the reading the `R` and `X` checks already use for a field; a comparison reads the value AT the coordinate, with an omitted component and subcomponent both defaulting to `1`, exactly as a field rule's own `component` default does.
|
|
121
|
+
|
|
122
|
+
**Refused rather than guessed**, every one as `PROFILE_MALFORMED`, with the never-throws invariant intact and no message rule evaluated against a profile the engine cannot trust: a verb or presence statement outside the published sets; a comparison with no value list, an empty one, or one carrying a non-string (a sparse array's holes count: a length is not a value list); a regular expression the platform cannot compile; a location naming no addressable element (a bad segment name, a non-positive index, a component with no field, a subcomponent with no component); a comparison whose location stops at the segment, which has no single content to compare; a statement declaring none or more than one of `presence`, `verb` and `connector`, where a key set to `undefined` still declares it, so a statement assembled from an options bag has to omit the keys it does not use rather than pass them through empty; a nesting deeper than the language defines, which is also what stops a self-referential predicate; a predicate on a rule whose usage is not conditional, including one with no usage at all; and a predicate arriving as `null`, an array, a string or any other shape a type check could not have stopped. `defineConformanceProfile` refuses all of them at authoring time.
|
|
123
|
+
|
|
124
|
+
**A rule takes its outcome from the predicate or from a caller-supplied resolution, never from both.** A resolution naming the locus of a rule that declares a predicate is now a `PROFILE_MALFORMED` finding rather than a precedence question: silently preferring either source would decide a patient-safety question by accident of implementation order.
|
|
125
|
+
|
|
126
|
+
New public surface: the `ConditionPredicate`, `PresencePredicate`, `ComparisonPredicate`, `ConnectedPredicate`, `PredicateLocation`, `PredicateVerb`, `PredicatePresence` and `PredicateConnector` types, the `PREDICATE_VERBS` and `PREDICATE_CONNECTORS` vocabulary listings, the `condition` member on segment and field rules, and the `PROFILE_CONDITION_UNEVALUATABLE` finding code. The predicate types are discriminated, so a verb outside the closed set and a comparison with no value list are both compile errors where the profile is written in TypeScript.
|
|
127
|
+
|
|
128
|
+
**Additive for every existing profile.** An omitted `condition` is not a condition: a rule that declares none behaves exactly as it did before. `C`, `CE`, `B` and a declared conditional that nothing decides still have their presence not evaluated, with their length, value-set and cardinality rules applied on the same terms; a caller-supplied resolution still selects the outcome for a rule that declares no predicate; and every existing refusal, its wording and the order the diagnostics come back in are unchanged. `FINDING_CODES` gains one entry and renames none, so a consumer switching on it exhaustively is the one shape of code that needs an arm added.
|
|
129
|
+
|
|
130
|
+
- cbee588: `msg.medications()` now lets you tell a new pharmacy order from a discontinued, held or cancelled one: every medication carries `orderControl`, the ORC-1 order control of the `ORC` that opened its order group, exactly as the sender wrote it.
|
|
131
|
+
|
|
132
|
+
Before this release the medication view dropped ORC-1, so a `DC` (discontinue) or `OD` order came back looking exactly like an `NW` (new) one. `msg.orders()` and `msg.immunizations()` already carried the same field under the same name.
|
|
133
|
+
- Every `RXO`, `RXE`, `RXD` and `RXA` in one order group (from an `ORC` up to the next `ORC`) carries that `ORC`'s code, so the second medication of an `ORC RXO RXE` group is marked too.
|
|
134
|
+
- The code is surfaced as sent: an unlisted code such as `ZZ`, or a lowercase `dc`, is returned unchanged, never normalized, rejected or dropped.
|
|
135
|
+
- It is never interpreted into an active, held or ended state. What a code means for the order is left to the receiver.
|
|
136
|
+
- The key is absent when no `ORC` precedes the medication or ORC-1 is empty. It is never carried over from an earlier group, and an `ORC` after the last medication lends its code to nothing.
|
|
137
|
+
- The `ORC-7` legacy timing is unchanged: it is still read by the first medication of the group only.
|
|
138
|
+
|
|
139
|
+
- 249d34c: Profiles can now name fields on STANDARD segments, not just on Z-segments: `segmentOverrides: { PID: { fields: { siteMrn: 19 } } }` makes `seg.get("siteMrn")` read PID-19 on every PID in the message.
|
|
140
|
+
|
|
141
|
+
A site that stuffs its own value into a standard field is the normal case on a real integration: a second MRN in PID-19, a local severity code in AL1-4, a site dose qualifier in RXA-6. Until now a profile could only name fields on its own Z-segments, so those reads fell back to a bare position typed by hand at the call site, which is exactly the read that drifts silently when a feed changes. `segmentOverrides` gives them a name in one place. Positions are 1-indexed HL7 positions, identical in meaning to the argument of `seg.field(n)` and including the MSH convention (a binding at 1 is the field separator, a binding at 3 is the sending application), so `get(name)` and `field(position)` always agree. It merges through `extends` under the rule `customSegments` already uses: per segment type, the child's binding wins at any position both declare and the parent's bindings elsewhere survive. `describe()` reports the overridden segment names on their own line.
|
|
142
|
+
|
|
143
|
+
**It is a SEPARATE option from `customSegments`, not a relaxation of it.** That map still refuses anything but a Z-segment, and it is what decides which segments the parser treats as profile-claimed for `UNKNOWN_SEGMENT`: a standard name inside it would change which segments warn. Keeping them apart also means an author reading a profile literal can see at a glance which declarations touch standard clinical fields. A Z-segment key in `segmentOverrides` is refused at definition time and pointed back at `customSegments`; a key that is not a standard segment name is refused too, with the canonical spelling suggested when only the case was wrong; a position that is not a positive integer is refused naming the profile, the segment and the field.
|
|
144
|
+
|
|
145
|
+
**ADDITIVE ONLY, and this is the point rather than a side note.** HL7 v2 messages carry PHI, and an alias that re-pointed an existing accessor would hand a downstream consumer a different clinical value with nothing to notice: no warning, no diff, and no way to undo a dose already acted on. So an override creates new named reads and changes no existing read. Positional access, dot-path lookups (`PID.siteMrn` is still a syntax error; dot-paths stay positional), the typed clinical accessors (`msg.patient?.mrn` still reads PID-3), the warning list, `toJSON()`, the byte-exact `toString()` and the whole write path are what they were. Parsing one message twice, once with the overrides and once with the same profile stripped of them, is asserted equal across every one of those observables. `Profile` and `DefineProfileOptions` gain an optional `segmentOverrides` member; nothing is removed or renamed, and the package still has zero runtime dependencies.
|
|
146
|
+
|
|
147
|
+
- a8d5f2e: Conformance profiles: a value set can now be bound to the coding system its codes must come from.
|
|
148
|
+
|
|
149
|
+
A field rule could say "this code must be one of these" and nothing more. The message says more: a coded element carries the sender's claimed coding system right beside the code, and the engine never read it. So a feed sending a plausible, permitted-looking code labelled with the wrong system validated clean, which is a false negative on the results path rather than a completeness nicety: what a certified reportable-lab interface most needs to assert about OBX-3 is precisely which vocabulary the code came from.
|
|
150
|
+
|
|
151
|
+
A field rule may now declare `codingSystem`, and optionally `codingSystemComponent`. With a binding declared, each present, non-empty repetition has its coding-system component compared against the binding, and a mismatch is the new `FINDING_CODES.PROFILE_CODING_SYSTEM_MISMATCH`.
|
|
152
|
+
|
|
153
|
+
**The new code is distinct from `PROFILE_VALUE_NOT_IN_SET` on purpose.** "A code we do not accept" and "a code that looks right but claims the wrong system" are two problems with two remedies, and one code for both would hide the second inside the first. The two checks are also INDEPENDENT: a permitted code carried under the wrong system yields exactly one coding-system finding and no value-set finding, and a code wrong on both counts yields exactly one of each.
|
|
154
|
+
|
|
155
|
+
**Comparison is by resolved identity, never string equality.** Both sides go through the Table 0396 provenance map this library already ships, so `LN`, `LOINC`, `loinc` and ` LN ` all match a binding of `LN`, and `SCT`, `SNOMED` and `SNOMEDCT` all match a binding of `SCT`. Comparing raw strings would report a mismatch for a correct feed that wrote an accepted spelling, which is the worst outcome available here.
|
|
156
|
+
|
|
157
|
+
**The coding-system component defaults to two positions after the checked one**, which is the coded element's own code-to-system relationship: a code at component 1 puts its system at component 3, and an alternate code at component 4 puts its alternate system at component 6. So a rule checking the primary triplet declares neither key, and a rule checking the alternate triplet sets `component` alone. `codingSystemComponent` overrides the offset for a field whose layout does not follow it. Checking both triplets from one rule is not supported: declare two rules on the same field.
|
|
158
|
+
|
|
159
|
+
**An absent or blank claim is a finding, never a pass.** A present, non-empty repetition whose coding-system component is missing entirely, empty, whitespace-only, or names a system that cannot be resolved is reported with the same code. Deciding otherwise would let the exact case the binding exists for pass by omitting a component. An EMPTY repetition is not reported (it claims no code, so it has no provenance to be wrong about), and an absent or empty FIELD is still left to its usage code: `PROFILE_REQUIRED_ABSENT` for `R`, nothing for `RE`, `O`, `C`, `CE` or `B`.
|
|
160
|
+
|
|
161
|
+
**A binding this library cannot resolve is refused when the profile is DEFINED**, as `PROFILE_MALFORMED` from `validateAgainstProfile` and a thrown `ProfileDefinitionError` from `defineConformanceProfile`, rather than compared at validation time against a system that cannot be resolved. The same refusal covers an identifier that is not a string, is empty or whitespace-only, a `codingSystemComponent` that is not a positive integer, a `codingSystemComponent` declared without an identifier, and a binding on a rule that declares no `valueSet`: a binding with nothing to bind is a defect, not a system-only check. Validation still never throws for any of them.
|
|
162
|
+
|
|
163
|
+
**The recognition set is a deliberate SUBSET of the published registry, not all of it.** It is the same safety-relevant map that backs `codingSystem()` and `KNOWN_CODING_SYSTEMS`, so an identifier that is genuinely registered but outside that map is still refused, and the refusal says the identifier is not one this library recognizes rather than that it is unregistered. That is the fail-safe direction, and it means a local or site-specific coding system cannot be bound at all: keep the bare `valueSet` form for those rules. Widening the map is a separate change with its own per-entry sourcing.
|
|
164
|
+
|
|
165
|
+
**No PHI, on the same terms as every other finding.** A coding-system finding names the EXPECTED system and the structural locus (segment, field, the component it read, repetition, occurrence) and nothing else. It never carries the offending claim or any other value taken from the message, and the expected identifier it does carry is the resolved registered acronym read out of the library's own frozen map rather than whatever spelling the profile author wrote.
|
|
166
|
+
|
|
167
|
+
**This checks the sender's claim, never the code.** Nothing here verifies that a code exists in the system the message names, or that the system is current. hl7 still ships no code set, bundles no value set and makes no network call; membership stays a literal check against the array you supply.
|
|
168
|
+
|
|
169
|
+
Unchanged, and deliberately: the bare `valueSet: readonly string[]` form is untouched and still first-class. A rule that declares no binding produces exactly the findings it produced before, in the same order, with the same codes, messages and severities, and can never emit the new code. The binding is purely additive and opt-in, and it constrains rather than relaxes: no profile that authored cleanly before is newly refused, and no message that validated cleanly before newly fails on a rule that did not opt in.
|
|
170
|
+
|
|
171
|
+
- 4ad3469: Message-structure awareness is now DERIVED from HL7's published, machine-readable message structures instead of hand-transcribed, it recognizes 94 (message code, trigger event) pairs instead of 32, and every warning names the missing SEGMENT and traces to the publication it came from.
|
|
172
|
+
|
|
173
|
+
The registry used to be a hand transcription of the v2.5.1 chapters: twelve message codes, eight ADT trigger events, one hand-picked "anchor" segment per group, and no traceable link to any published structure. A consumer could not be told that their `ADT^A01` was missing a segment the standard marks required, and could not audit where an expectation came from.
|
|
174
|
+
|
|
175
|
+
**The publication is vendored, not cited.** A byte-for-byte snapshot of HL7's published message-structure definitions and its two control manifests ships under `vendor/hl7-v2ig/`, and a committed offline generator derives the registry from it. There is no network call at build time or at run time, and re-running the generator in a checkout with no network access reproduces the committed artifact byte for byte.
|
|
176
|
+
|
|
177
|
+
**The provenance ships with the code.** The new `STRUCTURE_REGISTRY_PROVENANCE` export names the publication and the exact commit it was read at, the byte count, sha256 and upstream URL of all 83 vendored files, the variant family behind each referenced structure, and the structure id behind each recognized pair. `findMessageStructureDefinition(messageCode, triggerEvent)` reaches the entry behind a warning, and every entry carries `requiredSegments`, `structureId`, `structureIds` and a `derivation` of `published` or `retained-transcription`.
|
|
178
|
+
|
|
179
|
+
**Three derivation rules, all conservative in the same direction.** A segment is expected only when its minimum is at least one along the whole path from the structure root, so a segment the publication marks required inside a group it marks optional is not expected: a conformant `ORU^R01` with no `OBX` and no top-level `PID` stays warning-free. Where the publication splits a structure into single-uppercase-letter variants, a segment is expected only when every member of the family requires it. A pair with no entry is still silent.
|
|
180
|
+
|
|
181
|
+
**What consumers will notice: warning volume rises on non-conformant traffic.** More trigger events are recognized, so messages that used to be `recognized: false` are now checked. More segments are expected per pair, most visibly `EVN` across the whole ADT family and in `MDM` and `DFT^P03` too (the previous table excluded it deliberately, for every message code, which is exactly what stopped the library reporting a genuinely missing required segment), plus `PV1` in `MDM`. And an `ORU^R01` carrying `OBX` but no `OBR` now warns, where the old anchor accepted either. If your channel treats a `MISSING_EXPECTED_GROUP` warning as a rejection, sample your traffic before upgrading.
|
|
182
|
+
|
|
183
|
+
**`missingGroups` now holds segment names.** It used to hold hand-picked group labels such as `"result"` and `"patient"`; it now holds `"OBR"`, `"EVN"` and the like. The field, its type and its meaning ("what is absent") are unchanged, and the new `missingSegments` and `requiredSegments` on `msg.structure` say the same thing under clearer names. `structureIds` records which published structures the verdict came from.
|
|
184
|
+
|
|
185
|
+
**Nothing was removed.** Every value the entry point exported still exports, no exported type lost a field, and the warning code set is unchanged at twenty: a structural finding is still `MISSING_EXPECTED_GROUP`, still Tier-2 and additive, still never a throw, and still carries no field value. The retired `missingExpectedGroup` factory is retained and still works; the parser now calls the new `missingRequiredSegment`, which names the segment and the published structure.
|
|
186
|
+
|
|
187
|
+
**One entry is not derived, and says so.** The publication carries no `ORM_O01` at all: it is absent from the 305-entry structure manifest and its raw path answers 404. `ORM^O01` is the commonest legacy order message and was recognized before, so it is retained with its previous expectation (`ORC`), marked `retained-transcription`, and carries the reason it could not be derived. Eight `ADT` merge messages that the publication's own message list names no structure for are recorded as unresolved by the generator and left silent, rather than guessed.
|
|
188
|
+
|
|
189
|
+
`docs-content/spec-notes-structure.md` carries the full table, the derivation rules, and every difference from the previous transcription adjudicated segment by segment.
|
|
190
|
+
|
|
191
|
+
- 81dfc86: Vendor date formats gain month names, AM/PM, 12-hour clocks and single-digit tolerance, and `dateFormats` validation is now strict: a format that previously constructed and then never matched will now throw where you wrote it.
|
|
192
|
+
|
|
193
|
+
The vocabulary a `dateFormats` entry may use was seven tokens wide (`YYYY MM DD HH mm ss SSSS`), so a feed writing `05-JUL-1988` or `7/5/1988 2:30 PM` could not be described at all. It is now fifteen: `M`, `D` and `H` accept one digit or two, `MMM` and `MMMM` read the abbreviated and full English month names case-insensitively, `hh`/`h` read a 12-hour clock and `A` reads the meridiem, and a literal letter or digit is written inside square brackets (`YYYY-MM-DD[T]HH:mm:ss`). A month name resolves to the spec-native month number 1 to 12; a 12-hour reading plus a meridiem yields the 24-hour hour, with `12 AM` hour 0, `12 PM` hour 12 and `2 PM` hour 14, and the meridiem never appears in the parsed value.
|
|
194
|
+
|
|
195
|
+
`SSSS` was published and accepted by the validator while the matcher could only see it as four literal `S` characters, so a format carrying it constructed and matched nothing forever. It is honoured now, which is the divergence between the published token list and the matcher closed: every token the package publishes is a token the matcher reads.
|
|
196
|
+
|
|
197
|
+
BEHAVIOUR CHANGE, and the reason this entry is long. `defineProfile()` used to accept any `dateFormats` entry containing at least one recognised token, which let a typo through as literal text: `"MM/DD/YYYY hrs"` and `"YYY/MM"` both constructed a profile and then matched nothing, in silence, for as long as the profile lived. Validation is now "every character is a token or a permitted literal", plus four rules, and each refusal names the offending format and says what to do:
|
|
198
|
+
- an unescaped ASCII letter or digit that forms no token is refused, naming the character (escape it as `[x]` if it was meant literally);
|
|
199
|
+
- `h`/`hh` and `A` require each other, because a 12-hour reading is not a clock hour until AM or PM is applied and a meridiem beside a 24-hour clock has nothing to convert;
|
|
200
|
+
- `SSSS` requires `ss`, matching the rule the strict parser already applies: a fraction is only meaningful at full second precision;
|
|
201
|
+
- two numeric tokens may not sit adjacent when either is variable width (`MDYYYY` cannot decide whether `1231988` starts with month 1 or month 12). Adjacent means adjacent in the input, so an empty escape parts nothing and `M[]D/YYYY` is refused exactly as `MD/YYYY` is. Two fixed-width tokens still may sit adjacent, so `YYYYMMDDHHmmss` is unaffected.
|
|
202
|
+
|
|
203
|
+
Three of those four bind the matcher too, not only `defineProfile()`. Each of the pairing rules and the adjacency rule describes a value a parser could otherwise answer with a confident wrong reading, so an input matched against a format that breaks one of them reports no match rather than a value: `2:30` under `h:mm` is not hour 2, `14:30 PM` under `HH:mm A` is not hour 14, `1988-07-05 14:30.5` under `YYYY-MM-DD HH:mm.SSSS` is not a fraction of a minute, and `1231988` under `MDYYYY` is neither the 23rd of January nor the 31st of December. That is deliberate, because the per-parse `dateFormats` option is validated by nothing and a rule enforced only at definition time would not exist for a format arriving that way. The stray-character rule is the exception and stays definition-time only: the matcher matches a character it does not recognise verbatim, which is what keeps an existing parse-option format reading as it did.
|
|
204
|
+
|
|
205
|
+
So a profile that constructs today may throw at import after this release. That is the intended trade: the failure was previously silent and permanent, and it is now loud and immediate. Two shapes to expect. A format carrying a bare letter separator needs the escape, `"YYYY-MM-DDTHH:mm:ss"` becoming `"YYYY-MM-DD[T]HH:mm:ss"`, which is the change made to the built-in `cerner` profile here and which matches exactly the same inputs. And a format carrying `SSSS` with no `ss` never matched anything, so it can be deleted or completed.
|
|
206
|
+
|
|
207
|
+
**There is no two-digit-year token and there will not be one.** Resolving `YY` needs a century window, a window is a guess, and a wrong guess moves a date of birth by a hundred years while producing a date that looks entirely plausible and that nothing downstream can detect. A format containing `YY` is refused with a message that says so. Widen two-digit years to four at the ingest boundary, where the century is a decision somebody made deliberately. There is no timezone token either: the stance that a value with no offset is flagged rather than resolved to UTC is unchanged.
|
|
208
|
+
|
|
209
|
+
A format built from the previous seven tokens and non-alphanumeric separators parses to the identical result. `BUILTIN_DATE_FALLBACKS` keeps its membership and its order, and the `ISO-8601` sentinel is untouched.
|
|
210
|
+
|
|
211
|
+
One shape does change, and it is worth checking for, because the per-parse `dateFormats` option is not validated and so cannot warn you. `M`, `D`, `H`, `h`, `A` and `S` used to be ordinary letters that a format matched byte for byte; they are tokens now. A parse-option format that carried one of them as text (`"MM/DD/YYYY HH:mm Uhr"`, `"MM/DD/YYYY May"`) therefore stops matching, silently, where before it matched, and a declared format now reaches every typed datetime the library returns rather than `msg.meta.timestamp` alone, so check the whole list rather than the header. Escape the literal letters (`"MM/DD/YYYY HH:mm [Uhr]"`) and the format reads as it did. The same string passed to `defineProfile()` throws instead, naming the character, which is the loud half of the same change.
|
|
212
|
+
|
|
213
|
+
The grammar is now written down once, as a published `Date token grammar` page carrying the token table, the tokenisation and escaping rules, the pairing and adjacency rules, the meridiem conversion, the precision mapping and both exclusions with their reasons. A machine-readable corpus of cases ships beside it as plain JSON with no syntax belonging to any one parser, so a sibling parser can adopt the same grammar and prove it agrees rather than re-deriving it.
|
|
214
|
+
|
|
215
|
+
- 7abca94: Typed message overlays: `msg.is("ADT^A01")` answers at run time and narrows at compile time, so a message-type check now scopes everything after it.
|
|
216
|
+
|
|
217
|
+
A parsed message used to say what it was through `meta.messageCode` and `meta.triggerEvent`, both `string | undefined`, and a `switch` on them left every later access as widely typed as it was before. `msg.is(key)` closes that gap. Inside the guard the message code and trigger event are literal types rather than optional strings, and two new accessors, `part(name)` (the first segment of `name`) and `parts(name)` (every segment of `name`), take only the segment names that message type's published structure marks required. There is no cast, and no type to import per message type: the narrowed view is generic in the key (`TypedMessage<"ADT^A01">`), so a package whose export list is already large does not gain one exported name per recognized pair.
|
|
218
|
+
|
|
219
|
+
**The key set is derived, never hand-listed.** A key is `"<MSH-9.1>^<MSH-9.2>"` (`"ADT^A01"`), or `"<MSH-9.1>"` alone for a message type the derived structure registry matches on message code alone (`ACK`, whose MSH-9.2 carries the acknowledged message's trigger event). The new `SUPPORTED_OVERLAY_MESSAGES` export enumerates all 95 of them with the segments each one's published structure requires, computed from `MESSAGE_STRUCTURE_DEFINITIONS` at module load, and a test compares the claim to that registry in both directions so it can never name a pair the structure net does not recognize.
|
|
220
|
+
|
|
221
|
+
**Nothing is typed as guaranteed-present, deliberately.** The parser is lenient and a real `ADT^A01` arrives with no `PID` often enough that the library warns rather than refuses, so `part` returns `Segment | undefined` and `parts` a possibly-empty list for every name, required or not. An overlay that promised presence would delete the optional chain from consumer code and turn a truncated feed into a crash. Such a message still answers `true`, still carries its `MISSING_EXPECTED_GROUP` warning, and still reports the absence on `msg.structure`.
|
|
222
|
+
|
|
223
|
+
**A non-key string is a documented `false`, never a throw.** The comparison is on the (MSH-9.1, MSH-9.2) pair the parser extracted, so a message whose MSH-9 carries the three-component `ADT^A01^ADT_A01` matches the key `"ADT^A01"`, while the string `"ADT^A01^ADT_A01"` matches no key at all: `is` does not parse its own argument, and `msg.meta.type` is still the raw MSH-9 string. An unrecognized type, an empty string, or a value computed at run time answers `false`; a runtime-computed `string` returns a plain `boolean`, because it cannot narrow anything.
|
|
224
|
+
|
|
225
|
+
**Additive only.** No existing export was removed, renamed or re-typed, no parse result, warning code, warning position or serialized byte changed, and the package still has zero runtime dependencies. `is`, `part` and `parts` are new members on `Hl7Message`; `part` and `parts` are callable with any segment name on a message that has not been narrowed, and `segments()` is unchanged, so no caller is pushed into a cast.
|
|
226
|
+
|
|
227
|
+
- f34cdc4: Typed custom-segment field names: `seg.get("departmentCode")` is now checked against the names the applied profile declares for that segment type, so a typo fails to compile instead of silently reading nothing.
|
|
228
|
+
|
|
229
|
+
A profile author already told the parser exactly which fields a custom Z-segment carries (`customSegments: { ZDP: { fields: { departmentCode: 3 } } }`), and at run time that declaration was honoured. At compile time it was thrown away: the factory returned the widened `Profile` interface, whose `customSegments` is `Record<string, CustomSegmentDefinition>`, and the reader was `get(name: string)`. So `seg.get("departmentCod")` type-checked perfectly and returned `undefined` at run time, which is the exact class of mistake the "missing names return `undefined`, not a synthetic empty Field" design was written to surface: invisible until someone reads a blank department on a report. `defineProfile()` now carries its declarations in the type it returns, `parseHL7`, `splitBatch` and `parseStream` thread them onto the message, and `segments`, `getAll`, `part` and `parts` hand each segment the names declared for its own type.
|
|
230
|
+
|
|
231
|
+
**Narrowing is per segment type, never a flat union.** A profile that declares `ZDP` and `ZRS` gives a `ZDP` segment only its `ZDP` names; passing a `ZRS` name to a `ZDP` reader is a compile error, because the runtime lookup is per segment too and a name resolved against the wrong segment is a wrong position, not a missing one. Built-in profiles narrow straight from the namespace object (`parseHL7(raw, profiles.epic)`), with nothing to re-declare or annotate at the call site.
|
|
232
|
+
|
|
233
|
+
**The fallback is permissive everywhere the declaration is not statically known.** No profile argument, a value typed as the general `Profile` interface, a profile resolved from the `setDefaultProfile` registry, a segment type the profile does not declare, a segment declared with an empty field map, a profile declaring no custom segments at all, and the undifferentiated `msg.allSegments()` walk all keep the `string`-accepting signature. An unknown declaration is a reason to say nothing, not a reason to reject, so no existing caller gains a type error. A profile built with `extends` accepts the names it inherits and the names it adds.
|
|
234
|
+
|
|
235
|
+
**Nothing is typed as guaranteed-present.** A declared name whose position the wire message does not carry still reads `undefined`, so a declared name is typed `Field | undefined` exactly like any other. An overlay that promised presence would delete the optional chain from consumer code and turn a short segment into a crash.
|
|
236
|
+
|
|
237
|
+
**Types only, and additive.** No parse result, position, warning, warning code, profile merge or serialized byte changed; the same names resolve to the same fields as before, and the package still has zero runtime dependencies. A profile built by the factory is still assignable to `Profile` with no cast, so every existing API that accepts the interface keeps accepting it. The new exported types are `DefinedProfile`, `ProfileFieldName` and `ProfiledMessage`; `Segment` gains an optional type parameter that defaults to today's `string`, and `BatchMessageEntry`, `Batch`, `BatchSplitResult` and `StreamMessageEntry` gain one that defaults to `Hl7Message`.
|
|
238
|
+
|
|
239
|
+
- e071ab8: Opt-in published-structure validation: `validateMessageStructure(msg)` checks a parsed message's segment order, occurrence counts and unnamed segments against HL7's own published message structure for its trigger event.
|
|
240
|
+
|
|
241
|
+
`msg.structure` already answered "is a segment the publication requires actually present?", and `validateAgainstProfile` already answered "does this message meet the spec I wrote?". Neither answers the question an integration engineer asks before a feed goes live: does this message follow the shape HL7 publishes for this trigger event, in the published order, with each segment appearing as many times as the publication allows. Getting that answer used to mean hand-writing a conformance profile per trigger event, which is work the publication has already done and this package already vendors.
|
|
242
|
+
|
|
243
|
+
**The expectation is derived, not written down.** The offline generator already read each vendored element's minimum and its segment type and discarded the rest; it now keeps the rest. A second generated artifact records, per published structure variant, one node per published element carrying its position among its siblings, its minimum, its maximum and its enclosing group, in the publication's own order. Nothing is hand-picked and nothing is filtered, the unbounded marker is carried through as itself rather than as a large number so a freely repeating segment can never yield an upper-bound violation, and an element whose maximum is neither that marker nor a non-negative integer at least as large as its minimum now refuses the derivation naming the file. The presence-only registry is byte-identical: no existing expectation moved.
|
|
244
|
+
|
|
245
|
+
**Three checks, three stable codes, one PHI-free locus.** `STRUCTURE_SEGMENT_OUT_OF_ORDER` names a segment arriving where the published order does not allow it, `STRUCTURE_SEGMENT_CARDINALITY` names a segment occurring fewer times than the published minimum along the path from the structure root or more times than its maximum, and `STRUCTURE_SEGMENT_UNEXPECTED` names a segment the publication does not mention at all, a `Z` segment included, at `warning` severity because HL7 reserves them for exactly that. Every locus is a segment name, a 0-indexed occurrence and a published structure id; no field, component or subcomponent value is ever read into a finding.
|
|
246
|
+
|
|
247
|
+
**Ordering is asked against the published bounds themselves.** The order question is answered by simulating the published structure with every bound honoured at the locus the publication states it: a group it lets occur more than once may repeat, which is what lets a conformant `ORU^R01` carry `OBR OBX OBR OBX` without complaint; a group it bounds at one occurrence may not be re-entered, so a `VXU^V04` whose `PV2` arrives before its `PV1` is reported; and no occurrence of a group may begin without the child the publication requires first, so an `ADT^A01` whose `IN2` arrives before its `IN1` is reported too, though `INSURANCE` repeats and both counts are inside their bounds. One bound is dropped, and only where the cardinality check has already reported it for that segment name: too few occurrences drops its minimum, too many drops its maximum, so a count defect is one finding rather than a count finding plus every segment after it reading as misplaced. Where two segments arrive in an order the publication does not allow, the finding names the one that arrived late: the segment the publication puts first and the message delivered second. That is not always where the reading stopped, because the publication is often free to skip past the segment a message delayed and carry on, so the pair is identified by exchanging adjacent segments and asking whether the publication derives the result. Every adjacent pair is asked, not a shortlist near the stop: in a message that repeats a group the pair can sit well in front of it, as in an `OML^O21` carrying three orders whose first order's note is delivered after the second order's `ORC`, where the reading goes on for six more segments. Where more than one pair would do, the earliest is named, and the whole search costs one pass backwards over the message and one forwards however long it is. Where the publication splits a structure into variants, conforming to one variant is conforming to the family: findings come back only when every variant is violated, they are exactly one named variant's rather than a merge, and the same message selects the same variant every time.
|
|
248
|
+
|
|
249
|
+
**"Not validated" is a distinct answer from "no findings".** A message type the registry does not model, the one retained transcription (`ORM^O01`), a structure resolving to an empty expectation, and a message carrying no readable message type each return `validated: false` with a reason and zero findings, rather than an empty findings list that reads as approval or a shower of unexpected-segment findings. Zero findings on a validated message is still not a conformance attestation: field content, datatypes, value sets and HL7 tables are unchecked, coverage is twelve message codes, and the publication is vendored at a fixed commit.
|
|
250
|
+
|
|
251
|
+
**Additive and strictly opt-in.** No existing export was removed, renamed or re-typed, no new warning code exists, no parse behaviour changed, `msg.structure` is unchanged against every existing fixture, and validating a message leaves its segments, warnings and serialization byte-identical. A caller who never asks sees nothing new at all, which is deliberate: folding these findings into the default parse would start rejecting live traffic in every channel that treats a warning as a rejection.
|
|
252
|
+
|
|
253
|
+
- ea09919: Conformance profiles: a profile can now declare which conformance LEVEL it claims, every result echoes the level in force, and a claim of the implementable level is checked rather than believed.
|
|
254
|
+
|
|
255
|
+
A zero-finding result was ambiguous in the one way that matters. It could mean "every element of this interface was determined and none was violated", or it could mean "the profile left optionality in place, so parts of the message were never assessed at all", and nothing on the result told the two apart. The HL7 conformance methodology draws exactly that line between profile LEVELS: a complete assessment of an interface declaring conformance to an implementable profile can be determined, while for standard-level and constrainable-level profiles not all aspects can be.
|
|
256
|
+
|
|
257
|
+
A profile may now declare `level`, one of the three the methodology defines, and `PROFILE_LEVELS` exports them as a frozen listing ordered from the least constrained to the most: `standard`, `constrainable`, `implementable`. The new `ProfileLevel` type makes the declaration a compile-time-checked member of the profile rather than a bare string.
|
|
258
|
+
|
|
259
|
+
**Every `ConformanceResult` now carries `level`**, beside `profileName`. It is present on every result, including one for a profile so malformed that even its name fell back to a sentinel, so a consumer reading it never has to check whether it is there.
|
|
260
|
+
|
|
261
|
+
**Declaring no level claims `constrainable`**, the weaker of the two claims a working profile can make. So a profile written before the declaration existed never reads as an implementable one, keeps producing exactly the findings it produced before, and is echoed at the level it was actually assessed at.
|
|
262
|
+
|
|
263
|
+
**A claim of `implementable` is CHECKED when the profile is defined.** The methodology's terminus is that in an implementable profile ultimately only two possibilities are allowed, either a specific element is supported (`R` or `RE`) or it is not (`X`), and that a conditional usage's true and false outcomes must also be defined only as `R`, `RE` or `X`. Three things refuse the claim, each `PROFILE_MALFORMED` naming the offending rule's structural locus and the offending declaration: a usage the level does not admit (`C`, `CE`, `O` or `B`), a declared conditional carrying an outcome it does not admit (`C(R/O)`), and a rule that declares no usage at all, since an omitted usage means Optional and the level asserts optionality is gone. That last one applies to segment, field and component rules alike, and it is the one place an omitted usage is ever refused: it is refused because the profile asked to be held to it. `C` and `CE` are refused even though the engine knows outcomes for them, because the methodology requires an implementable profile's conditional outcomes to be defined and a bare code defines them nowhere in the profile; an author who means `C` at this level writes `C(R/X)`.
|
|
264
|
+
|
|
265
|
+
**A refused claim is never the level echoed.** It falls back to `constrainable`, and that holds for any profile defect rather than only a level-derived one, because a run that returned diagnostics instead of assessing the message verified no claim of completeness, and a defect anywhere in a profile can stop the gate from ever reaching the rules below it.
|
|
266
|
+
|
|
267
|
+
**The level changes no message check.** It alters nothing about which findings fire for a given message, their severity, cardinality behaviour, predicate evaluation, or the value-set and coding-system checks. It decides whether the profile is refused, never what the message is found to be. A level that is not one of the three, including a value of the wrong type reaching the engine through an unchecked cast, is `PROFILE_MALFORMED` from `validateAgainstProfile` and a thrown `ProfileDefinitionError` from `defineConformanceProfile`, on the same terms as every other profile defect; the engine still never throws.
|
|
268
|
+
|
|
269
|
+
**And zero findings at `implementable` level is still not an attestation.** The level is the author's claim, checked for internal consistency against the profile itself, never an external, third-party or accredited statement.
|
|
270
|
+
|
|
271
|
+
- 146cd86: Conformance profiles: a field rule can now say what its components ARE, and content in a component you never declared is a finding rather than a pass.
|
|
272
|
+
|
|
273
|
+
A profile graded only what it wrote down. A field rule could say a field is Required, how many times it may repeat, how long its checked component may be and which codes that component may carry, and nothing at all about the field's SHAPE. So a vendor stuffing a fourth component into a field you specified with three validated clean. The HL7 conformance methodology does not read that as harmless extra content: the presence of unexpected content for an element the profile does not specify is a conformance violation, and its own worked example is exactly that case, data present for a fourth component where the profile defines three.
|
|
274
|
+
|
|
275
|
+
A field rule may now declare `components`: a list of 1-indexed component positions, each with an optional usage code and an optional severity. Three things follow from it.
|
|
276
|
+
|
|
277
|
+
**A component's cardinality is IMPLIED by its usage and may not be declared.** The methodology requires an explicit cardinality range for segment groups, segments and field elements, and states that component elements do not include one: the range is implicitly associated with the element and follows from its usage code. `R` implies `[1..1]`, `RE` and `O` imply `[0..1]`, and `X` implies `[0..0]`. A component carries no repetition construct in HL7 v2, so each of those reduces to presence: per present repetition, `R` with nothing at that component is `PROFILE_REQUIRED_ABSENT` and `X` with something there is `PROFILE_NOT_PERMITTED`, both at a locus naming the component. A `cardinality` on a component rule is `PROFILE_MALFORMED`, not a constraint the engine quietly honours, because honouring it would let a profile declare a range the methodology says a component cannot have.
|
|
278
|
+
|
|
279
|
+
**Declaring component rules CLOSES the field's component set**, which is what makes undeclared content decidable at all. A present repetition carrying a non-empty value at an index no rule declares is the new `FINDING_CODES.PROFILE_UNDECLARED_CONTENT`, one finding per undeclared index per repetition, at a locus naming the segment, the field, the component, the repetition and the segment occurrence.
|
|
280
|
+
|
|
281
|
+
**A field rule that declares no component rules declares no depth**, is checked exactly as it always was, and can never emit the new code. What counts is the rules, not the key: omitting `components` and passing an empty list say the same thing, since neither carries a component rule. `components: []` is never read as "this field has no components", which would turn a list built by a filter that matched nothing into a finding for every component of every repetition. The check is additive and opt-in per rule, so no message that validated cleanly before newly fails on a rule that did not opt in.
|
|
282
|
+
|
|
283
|
+
**The new code is a MEMBER of an exported union consumers narrow on.** `FINDING_CODES` and the `FindingCode` type both gain `PROFILE_UNDECLARED_CONTENT`, so an exhaustive `switch` over `FindingCode` that compiled before now has a missing case. That is the intended cost of a distinct code: "a value we do not accept" and "content in an element the profile never specified" are two problems with two remedies, and folding the second into an existing code would hide it inside the first.
|
|
284
|
+
|
|
285
|
+
Separately, and applying to segment rules and field rules whether or not they declare components, **a usage paired with a cardinality the methodology disallows is now refused when the profile is DEFINED**. A Required element's minimum cardinality must be 1 or greater; any other declared usage takes a minimum of 0; `X` admits `[0..0]` and no other maximum. The one exception is the methodology's own: `RE` may carry a minimum above 0 to express an element that is not always present but has that many occurrences when it is. Refusal is `PROFILE_MALFORMED` from `validateAgainstProfile` and a thrown `ProfileDefinitionError` from `defineConformanceProfile`, on the same terms as every other profile defect.
|
|
286
|
+
|
|
287
|
+
**That refusal is a behaviour change for an existing profile.** A profile pairing `C`, `CE`, `O`, `X`, `B` or a declared conditional with a minimum cardinality above 0, or pairing `R` with a minimum of 0, or pairing `X` with a maximum other than 0, validated before and is refused now. It was never a constraint an author could have meant: a Required element permitted to be absent, or a not-supported element permitted to occur, is a profile contradicting itself, and the methodology's own table pairs `C(R/X)` with `[0..5]` rather than `[1..5]`, letting the condition supply the rest. Rewriting such a rule means dropping the minimum, or moving to `RE` where the exception applies.
|
|
288
|
+
|
|
289
|
+
**No PHI, on exactly the terms every other finding holds.** An undeclared-content finding names the structural locus and the NUMBER of components the field rule declares, and nothing else. It never carries the surprising value or any substring of it, and this is the finding most tempted to: an author reading "there is content you did not declare" wants to know what it was, and the component is undeclared precisely because the profile says nothing about it, which makes its content unconstrained message content. The locus says where to look in the message the caller already holds. The malformed-profile details are profile shape only, and the usage/cardinality detail deliberately does not echo the offending usage token, so an unresolved conditional and `B` stay findings-identical to `C` down to the defect text.
|
|
290
|
+
|
|
291
|
+
**Still bounded, and deliberately.** Undeclared-content detection is scoped to a field whose rule declares component rules, so an undeclared segment, an undeclared field and an undeclared sub-component are all still unchecked and the check is only as deep as the profile you wrote. Sub-component rules are not supported. Nothing here relaxes an existing check, the parser is untouched, and its warning and fatal code sets are unchanged. `findings.length === 0` is still not a conformance attestation.
|
|
292
|
+
|
|
293
|
+
- d4e5c5b: Observations and orders now say whether a result is current: every observation carries `resultStatus`, its OBX-11 classified by HL7's published Table 0085 to Observation Status map, and every order carries `resultStatus`, its OBR-25 classified by HL7's Table 0123 to Diagnostic Report Status map. A result posted as wrong (`W`), deleted (`D`) or cancelled (`X`) no longer looks like a final one unless you keep your own copy of both tables.
|
|
294
|
+
- `resultStatus.classification` is one of `final`, `corrected`, `amended`, `preliminary`, `entered-in-error`, `cancelled`, `registered`, `partial` or `undetermined`.
|
|
295
|
+
- `undetermined` is the fail-safe. Codes HL7's maps leave unmapped (OBX-11 `B`, `I`, `N`, `O`, `R`, `S`, `V`, `U`; OBR-25 `A`, `Y`, `Z`, `M`, `N`) are `undetermined`, and so is an absent, empty or `""` field and any value that is not exactly one code: a lowercase `f`, whitespace around the code, a VT or FS byte inside the field, a code written as an escape sequence, `FF`, `Q`, or more than one repetition or component such as `F~W`. Only an exact `F` is ever `final`.
|
|
296
|
+
- `resultStatus.code` is the raw code, byte-identical to the `status` or `orderStatus` beside it, and absent exactly when those are. `status` and `orderStatus` are unchanged.
|
|
297
|
+
- `resultStatus.table` and `resultStatus.map` name the table read (`HL7 Table 0085` or `HL7 Table 0123`, version `3.0.0`) and the map followed, by canonical URL and version `1.0.0`.
|
|
298
|
+
- Each OBX classifies from its own OBX-11 and each order from its own OBR-25; nothing is carried across segments. A status is reported, never applied: an `entered-in-error` observation is still returned.
|
|
299
|
+
- New exported types: `ResultStatusClass`, `ResultStatusClassification`, `ResultStatusTable` and `ResultStatusMap`. `ObservationBase` and `Order` gain the required `resultStatus` key, so code that builds those objects by hand must now supply it.
|
|
300
|
+
|
|
301
|
+
- ce1171e: Conformance profiles: a declared conditional usage `C(a/b)`, the `B` indicator, and caller-supplied resolutions.
|
|
302
|
+
|
|
303
|
+
A profile could previously declare only `R`, `RE`, `C`, `CE`, `O` or `X`. Two indicators the HL7 v2 conformance methodology allows in a message profile were missing: the declared conditional `C(a/b)`, which carries an explicit true and false usage outcome, and `B` (Backward Compatible). Without `C(a/b)` there was no path from a profile this engine can express to an implementable one. A rule may now declare either, on segment rules and field rules alike, and `usageOutcomes(rule)` reads a declared conditional's two outcomes back as separate simple codes (`undefined` for a rule whose usage is simple or absent, never a throw).
|
|
304
|
+
|
|
305
|
+
`validateAgainstProfile` takes a third, optional argument: an ORDERED LIST of resolutions, each naming a locus (segment name, plus the 1-indexed field position when the target is a field rule) and a boolean outcome. A resolved rule is evaluated exactly as the same rule declared with that outcome as its usage, for every occurrence of the segment and every repetition of the field. The container is a list rather than a map keyed by locus on purpose: two resolutions at one locus are a defect that has to be REPORTED, and a keyed container cannot represent the input that triggers it, so the defect would be undiagnosable. The list order is also the order the diagnostics are reported in: a defect stands where it was supplied whatever KIND it is, and a locus reported once stands where that locus FIRST appeared. Diagnostics about the profile's own shape still come before all of them.
|
|
306
|
+
|
|
307
|
+
**The engine still evaluates no condition predicate and derives no outcome from the message.** That boundary is unchanged and is the reason the resolution comes from the caller. This supersedes the previous framing that a declared conditional's outcomes would be recorded but not resolved: they are recorded AND applied, by the caller's decision rather than the engine's.
|
|
308
|
+
|
|
309
|
+
New public surface: the `usageOutcomes` function, the `UsageResolution` and `UsageOutcomes` types, and the `SimpleUsageCode` / `DeclaredConditionalUsage` / `UsageCodeRegistryEntry` types.
|
|
310
|
+
|
|
311
|
+
**Two exported shapes are now distinct, and that is a type-level break for one shape of consumer code.** `UsageCode`, the type a rule's `usage` is declared with, is now the seven simple codes plus the declared-conditional template `C(t/f)`; an exhaustive `switch` over it no longer compiles without arms for `B` and for the conditional form. `USAGE_CODES` is typed as `readonly UsageCodeRegistryEntry[]`, which additionally admits the placeholder `"C(a/b)"` and is therefore NOT assignable where a rule usage is expected. Neither type widens to a bare `string`: the typo `"RQ"` is still a compile error.
|
|
312
|
+
|
|
313
|
+
`USAGE_CODES` now lists eight entries: `R`, `RE`, `C`, `CE`, `O`, `X`, `C(a/b)`, `B`, with the previous six keeping their positions. It is a published vocabulary LISTING, not a membership test for what a rule may declare: `C(a/b)` is listed and is refused on a rule (`a` and `b` are placeholders for usage codes, not usage codes), while `C(RE/X)` is accepted on a rule and is not listed.
|
|
314
|
+
|
|
315
|
+
Every refusal now NAMES the offending token instead of only listing the permitted set, on both the validation path and the fail-fast `defineConformanceProfile` gate, so an author debugging a typo no longer has to diff their profile against the vocabulary. A token is echoed as a whole, bounded to its first 32 characters; a usage that is not a string names the value's TYPE rather than echoing its content. A resolution that matches no declared rule, matches more than one, targets a rule whose usage is not a declared conditional, or is duplicated at one locus is a `PROFILE_MALFORMED` finding, and so is a resolution argument that is not a list of well-formed resolutions: a mis-typed argument must never be read as "no resolutions" and return a clean result for elements nothing checked.
|
|
316
|
+
|
|
317
|
+
Unchanged: omitting `usage` on a rule still means Optional and is still never refused; `C`, `CE`, `B` and an unresolved declared conditional are not presence-checked, with their length, value-set and cardinality rules applied on exactly the terms a `C` rule gets today; and a profile drawn only from the previous six codes validates to the same findings, in the same order, as before.
|
|
318
|
+
|
|
319
|
+
`B`'s presence behaviour is this library's own bounded choice, not a sourced requirement: the methodology lists `B` among the allowable indicators and carries no definition text for it, so the reading taken is the one that imposes no presence obligation. A declared conditional's outcomes may be any simple code here because this engine targets the constrainable profile level; the methodology's restriction of an implementable profile to `R`, `RE` or `X` is not enforced, since a profile cannot yet declare which level it claims.
|
|
320
|
+
|
|
321
|
+
### Patch Changes
|
|
322
|
+
|
|
323
|
+
- 5c0ebee: Tooling: the pending release queue and the public export surface are now checked rather than assumed.
|
|
324
|
+
|
|
325
|
+
The version a release lands on is decided by the strongest bump type in `.changeset/`, and nothing read those frontmatter lines at review time. Every pending declaration in this repository asked for `patch`, because the contributor guidance told every contributor to pick `patch` on the `0.0.x`-until-first-alpha ladder, so a queue that includes five new typed builders, conformance condition predicates, coding-system bindings, profile levels, component rules and a re-sourced message-structure registry could not resolve to anything but another `0.0.x`. A published version number cannot be recalled, which makes "we thought that one was a patch" unrecoverable rather than merely wrong.
|
|
326
|
+
|
|
327
|
+
`scripts/release-readiness.ts` reads the checkout alone and reports every pending declaration with the bump type it carries, the strongest bump in the queue and the version that resolves to, and it refuses rather than guesses: frontmatter it cannot parse, a declaration for another package, a bump type outside the published three, a file declaring the package twice or not at all, and a file the committed readiness record never classified are each named with their defect instead of skipped. An empty queue is reported as nothing to release rather than as a clean one, and a `major` is never certified, because a first stable release is a decision a human makes deliberately. The arithmetic is cross-checked against the real `changeset version` over a throwaway copy of the checkout, so the check cannot drift from the tool it describes.
|
|
328
|
+
|
|
329
|
+
`scripts/api-surface.ts` writes down what `dist/index.d.ts` exports, one entry per name with its kind, its printed type and its members, and compares a fresh build against the committed inventory. Nothing else in this repository fails when an export leaves the bundle, changes shape or arrives unannounced. It never rewrites its own baseline on a mismatch, because a check that heals itself always passes and therefore measures nothing, and it never reports an empty surface as a match: absent, empty or export-less declarations are a missing input, not a verdict, which is the same window `scripts/attw.mjs` was built to refuse.
|
|
330
|
+
|
|
331
|
+
The contributor guidance no longer instructs patch-only changesets. A change that adds behaviour takes a `minor` and a change that corrects behaviour takes a `patch`.
|
|
332
|
+
|
|
333
|
+
- 086ae19: Tooling: the numbers the release readiness record states are now recounted from the tree instead of read as prose.
|
|
334
|
+
|
|
335
|
+
`scripts/release-readiness.ts` graded the record's header facts, its changeset table as a set, each row's bump against that file's own frontmatter, and the resolved version. Everything else the record asserted was prose: how many declarations were pending, how many of them were `minor`, how large the public export surface being certified was, and which public exports and stable codes the release removes, renames or narrows. Those four could drift arbitrarily far from `.changeset/` and `release/public-api.json` with every gate in the repository still green, which is how a record accretes: each release edits the sections a machine reads and carries the rest forward.
|
|
336
|
+
|
|
337
|
+
Three refusals close that. A stated pending count and a stated `minor` count are recounted from the queue and reported stated-beside-counted when either disagrees. A stated export-surface size is compared against `exportCount` in `release/public-api.json`, which is now a third input the check reads: absent, not JSON, carrying no usable count, or stating a count its own export list contradicts are each a missing input at exit 2, never a skipped comparison reported as a clean queue. And every changeset file the break-candidate section cites has to be pending, so a candidate whose cause has already shipped is named as carried over rather than believed; a candidate citing no file, a section holding no readable candidate, and an absent section are findings on the same terms. A record that states none of the three counts is a finding too, because a measurement the record declines to make cannot be told from one nobody took.
|
|
338
|
+
|
|
339
|
+
Exit 1 still means an unready queue and exit 2 still means a run that could not read its inputs, and the check still makes no network call and spawns no subprocess.
|
|
340
|
+
|
|
341
|
+
`release/0.1.0-readiness.md` is re-derived against the tree rather than patched: every classification row's justification is rewritten from the declaration it classifies, and the break candidates are re-derived from the pending queue with each one attributed to the declaration that causes it. No declaration's frontmatter was edited, no version was bumped and nothing was published.
|
|
342
|
+
|
|
343
|
+
No consumer-visible change: no export, type, parse behaviour, warning code or serialized byte moved, and the package still has zero runtime dependencies.
|
|
344
|
+
|
|
345
|
+
- 41e1a3c: Docs: the first example in the README and the first example in the quickstart are now executed by the test suite, read straight out of the page they are printed on.
|
|
346
|
+
|
|
347
|
+
Both examples now parse the same committed synthetic ADT^A01 fixture, byte for byte, so the message a reader copies is one the PHI scan has already read. The README block is run against the package entry point and its printed output is compared with the output block beside it; the quickstart block is run against the built package with every claimed value asserted. Changing one value in either example's message or its claimed output fails the suite.
|
|
348
|
+
|
|
349
|
+
The quickstart's first example did not compile in a strict TypeScript project: it read fields off `msg.patient`, which is `undefined` for a message with no PID segment. It now reads them with `msg.patient?.`, as the README already did, and the suite compiles both examples with the settings `tsc --init` writes for a new project, so an example that does not compile fails it.
|
|
350
|
+
|
|
351
|
+
Every install command the README and the installation page print is checked against the package's own name.
|
|
352
|
+
|
|
353
|
+
The hand-kept copy of the README example under `examples/` is gone: the README itself is what runs now, so there is no second copy to drift.
|
|
354
|
+
|
|
355
|
+
- 5dd1bd8: **Repository tooling only. Nothing about the published package changed.** The em-dash brand gate now runs the one shared implementation published in `@cosyte/script-utils` instead of this repository's own copy of it, and that copy, `scripts/check-no-emdash.sh`, is deleted. `pnpm check:no-emdash` keeps its name, its two modes and its exit codes, and what it detects is unchanged: the same six banned forms over the same set of tracked files, proved by enumerating both sets and comparing them in both directions rather than by comparing two filter expressions. What this repository leaves out of the scan is now a tracked declaration, `scripts/check-no-emdash.exclude`, instead of two patterns inside a scanner, and it leaves out exactly what it left out before: the two directories of verbatim vendored HL7 publication bytes, which are still checksummed by the vendor snapshot and its provenance test. No runtime dependency is added, `dependencies` stays empty, and the published files, the API and the types are untouched, so nothing here reaches your install.
|
|
356
|
+
- 5dacbcb: The date formats you declare now reach every datetime the library returns, not just the message header timestamp.
|
|
357
|
+
|
|
358
|
+
`dateFormats`, from `parseHL7(raw, { dateFormats })` or from an applied vendor profile, only ever reached `msg.meta.timestamp`. Every other datetime went through a strict HL7 parse, so a feed writing `1988-07-05` or `07/05/1988 00:00:00` in PID-7 gave you `patient.dateOfBirth` with `valid: false` even though you had told the parser that exact format, and there was no way to fix it from the outside. The escape hatch missed the fields worth escaping for.
|
|
359
|
+
|
|
360
|
+
It reaches all of them now: `patient.dateOfBirth`, `visit.admitDateTime` and `dischargeDateTime`, each observation's `observedDateTime` and its `TS`/`DT` typed value, allergy onset dates, diagnosis date/times, insurance effective and expiration dates, immunization administered and expiration dates, charge transaction dates, document activity date/times, order and medication timings, and appointment start and end times. A `TS` that matched a declared format names it on `matchedFormat`, which is already public; a canonical HL7 value parses strictly and leaves `matchedFormat` unset, however many formats you declared. Option formats are tried ahead of a profile's, deduplicated first-occurrence-wins, which is the order `msg.dateFormats` already reported.
|
|
361
|
+
|
|
362
|
+
**Only the formats you declare are tried on those fields, and that limit is the point.** `BUILTIN_DATE_FALLBACKS` is a US-order list carrying `MM/DD/YYYY` and no day-first form, so it is the library guessing at a sender you never described, and it does not guess on a clinical datetime. A value matching no format you declared stays `valid: false` with its `raw` text intact, exactly as it did before this change, and the built-in list remains a last resort for `msg.meta.timestamp` alone. That also keeps the order-ambiguity report where it belongs: an undeclared `05/07/1988` is refused with an `AMBIGUOUS_DATE_ORDER` report on `msg.meta.timestamp`, because the built-ins are what produce the second reading, while a typed datetime field simply reports `valid: false` with no report, having consulted nothing. Declaring the order resolves it everywhere at once.
|
|
363
|
+
|
|
364
|
+
Two timing values that read their datetime straight out of a subcomponent rather than through the typed field, SCH-11 appointment start and end, and the start and end of a legacy embedded TQ in ORC-7 or RXE-1, honour your formats too. They were the two places where "every datetime" would otherwise have been false.
|
|
365
|
+
|
|
366
|
+
Unchanged, deliberately: with no `dateFormats` declared by either route, every one of these fields parses exactly as it did before, byte for byte, including `1988-07-05` staying invalid. `SUPPORTED_DATE_TOKENS` is the same seven tokens, with no two-digit-year token; `BUILTIN_DATE_FALLBACKS` has the same entries in the same order; `defineProfile()` still refuses a format string carrying no supported token, while a `ParseOptions` entry carrying none simply matches nothing rather than throwing. A format that matches a value's shape but yields an impossible calendar date, `13/45/1988` against `MM/DD/YYYY`, is rejected rather than wrapped, and the remaining formats are still tried. An empty field, the HL7 explicit null `""`, and a position past the end of its segment all still give you `{ raw: "", valid: false, hasTimezone: false }` without throwing.
|
|
367
|
+
|
|
368
|
+
The documentation of all this was wrong in two ways and is corrected: it did not say that `dateFormats` stopped at the header timestamp, and it promised a `TIMESTAMP_FALLBACK_FORMAT` warning on `msg.warnings` that no parse has ever produced. `matchedFormat` on the `TS` is the signal that a declared format answered a value; there is no warning for it.
|
|
369
|
+
|
|
370
|
+
- 94f0b83: Parser: an order-ambiguous slash date is now refused instead of read as US-order.
|
|
371
|
+
|
|
372
|
+
`BUILTIN_DATE_FALLBACKS` carries `MM/DD/YYYY` and not `DD/MM/YYYY`, so a day-first sender's `05/07/1988` in MSH-7 parsed as May 7 1988. It did not fail, it warned only with the generic `TIMESTAMP_FALLBACK_FORMAT`, and the value it produced was a plausible, wrong calendar date. In a library whose messages carry clinical content, a confident wrong date is the failure nobody notices.
|
|
373
|
+
|
|
374
|
+
When no declared format has matched, a slash-separated numeric date whose first two components are both in 1-12 now resolves to nothing. `msg.meta.timestamp` comes back `valid: false` with an `ambiguity` report on it: `code: AMBIGUOUS_DATE_ORDER`, the raw value, and both candidate readings as `{ format, month, day, isoDate }`, so a consumer can see exactly what was declined and what to declare. That is a stable identifier of its own, distinct from `TIMESTAMP_FALLBACK_FORMAT`, so "ambiguous, refused" is now distinguishable from "malformed" (no timestamp at all) and from "resolved via a fallback" (`valid: true` with a `matchedFormat`).
|
|
375
|
+
|
|
376
|
+
**Migration, one line.** If you relied on the previous US-order default, declare it: `parseHL7(raw, { dateFormats: ["MM/DD/YYYY"] })`, or add it to your profile's `dateFormats`. A day-first feed declares `["DD/MM/YYYY"]` and gets 5 July. Declared formats are tried ahead of the built-ins, resolve the value, keep the existing `TIMESTAMP_FALLBACK_FORMAT` semantics, and never report ambiguity; options precede profile in the merged order, so an over-complete declaration listing both orders resolves deterministically on the first.
|
|
377
|
+
|
|
378
|
+
Only genuinely two-way values are refused, and the change is narrow by construction. `07/25/1988` has one reading (there is no month 25) and still resolves as July 25. `05/05/1988` has two readings that agree and still resolves. Strict HL7 timestamps, ISO-8601 values and `YYYY-MM-DD` values are untouched, with the same precision, timezone fidelity and `matchedFormat` as before. A value illegal under both readings, such as `13/13/1988`, still reports no timestamp and reports no ambiguity: malformed and ambiguous are different facts and are not conflated. An empty MSH-7 behaves exactly as it did.
|
|
379
|
+
|
|
380
|
+
Two things deliberately did not change. `DD/MM/YYYY` was **not** added to the built-in fallback list: it is consulted only as a probe for the second reading and never resolves a value, so an unambiguous day-first value such as `25/07/1988` still fails loudly rather than newly parsing, and the accepted-input set is not widened. And no warning code was added or renamed: `Hl7Message.warnings` is frozen at construction while `msg.meta` is built lazily afterwards, so a warning raised there could never reach a consumer, and the report travels on the timestamp value instead.
|
|
381
|
+
|
|
382
|
+
- 60bdef6: Docs: the README no longer advertises shipped capability as future work.
|
|
383
|
+
|
|
384
|
+
The roadmap section listed three items as not yet available that the package already exports: incremental parsing of batch files too large to buffer, JSON Schema and Zod emission for what `toJSON()` returns, and profile-declared field names carried into the type system. It also named `createHL7Stream()`, a symbol this package has never exported, when the shipped streaming parser is `parseStream`. The status section repeated the same claims and told the reader that nothing on that list was exported yet, so the front page a consumer reads on npm and on GitHub described three available features as unavailable and pointed at an entry point that does not exist.
|
|
385
|
+
|
|
386
|
+
The roadmap now records an empty forward list and says where each of those capabilities is documented, and the status section states that they ship and are covered by its stability claim. The permanently out-of-scope list is unchanged, and no behaviour, export or type changed.
|
|
387
|
+
|
|
388
|
+
- 47b20f5: Repository tooling: the em-dash gate and the public-surface gate now run as thin callers of the shared reusable workflows in `cosyte/.github`, pinned to an immutable commit.
|
|
389
|
+
|
|
390
|
+
Nothing a consumer can call changed. The package still has zero runtime dependencies, no export was added, removed or re-typed, and no parse result, warning code, warning position or serialized byte moved.
|
|
391
|
+
|
|
392
|
+
**What the gates enforce is unchanged; where the harness lives is not.** `scripts/check-no-emdash.sh` and `scripts/check-no-internal-refs.sh` are untouched, and the package scripts `check:no-emdash` and `check:no-internal-refs` keep their names and their commands, so both gates detect exactly what they detected before and both still run locally the same way. The reusable workflows carry no pattern, no allow-list and no file selection of their own: they prepare the tree, run the named command, and let its exit status decide the job. The scanning rules stay in this repository, in one copy.
|
|
393
|
+
|
|
394
|
+
**The reference is a 40-character commit SHA, not a tag.** A tag can be moved or deleted by anyone who gains write access to the workflow's repository, so a tag is not an immutable reference. The published release name rides beside the SHA as a trailing comment, so the release note stays reachable.
|
|
395
|
+
|
|
396
|
+
**The em-dash gate now publishes two check runs instead of one.** The shared workflow splits the tracked-file scan and the pull request text scan into separate jobs, so the contexts are `no-emdash / tracked-files` and `no-emdash / messages`. The tracked-file half is the one that blocks a merge. The messages half runs and reds its own context but does not block, because a pull request body composed from someone else's release notes should not hold a merge. Detection is unchanged in both halves.
|
|
397
|
+
|
|
398
|
+
`test/workflows/gate-caller-contract.test.ts` pins the caller shape in this repository's own suite: the pinned reference, the job ids that become check-run contexts, the trigger sets, the permissions ceiling, and the absence of any `with:`, `run:` or `steps:` key that would start a second copy of a rule.
|
|
399
|
+
|
|
400
|
+
- f045b27: Tooling: the PHI scanner now refuses over a file it listed and never opened, instead of reporting on the ones it did.
|
|
401
|
+
|
|
402
|
+
`scripts/phi-scan.ts` counted a run as clean once every target it actually read came back clean. A target that left the read set was simply gone from the reckoning. Measured against the previous release, in a throwaway repository laid out like this one, with the bypassed path logged in `phi-scan-overrides.md` exactly as the rejection gate instructs: naming a violator and a second file, and withdrawing the second with `--allow-fixture`, exited 1 naming only the violator. So the same command line over a corpus whose only violator is the withdrawn file exited 0 and printed "OK: no hits", byte-identical on standard output and exit code to a genuine clean run over the same tree, with one of the two declared files never opened.
|
|
403
|
+
|
|
404
|
+
A target this run enumerated and never read now refuses at exit 2, in every mode, naming the paths. It is a set difference between what the enumerating routes declared and what was actually read, never a size comparison: a count counts the files that were read, which is exactly the arithmetic that hides which ones were not, and it still would not say which. Both halves of the full sweep declare into the same set, so the accounting stays exact if either route's scope changes; today a whole-file bypass is the only thing that can leave a target unread, because every other way of failing to read one already refused.
|
|
405
|
+
|
|
406
|
+
A second, related state is closed with it. A bypass naming a path the run does not enumerate subtracts nothing, so it was accepted, its audit entry was checked, and it was then ignored: naming one clean file and bypassing a second file whose invented name tokens the scanner detects exited 0 with "OK: no hits", having never opened the named file at all. That now refuses at exit 2 too.
|
|
407
|
+
|
|
408
|
+
What it costs, decided rather than stumbled into: a whole-file `--allow-fixture` bypass can no longer reach exit 0 in any mode. The flag, its bypass log and its rejection gate all stay, and they still demand a committed audit entry naming the path; the run now ends in a refusal that names the file instead of a green line that does not. The remedy that reaches a clean run is `scripts/phi-allow-list.txt`, a token-level declaration reviewed per value that leaves the file read, and the bypass log already told developers to prefer it. The hit report no longer offers the flag as a remedy, because following that advice walked a developer from exit 1 into exit 2.
|
|
409
|
+
|
|
410
|
+
That remedy does not cover every hit, and the report now says so rather than sending developers to a file that cannot help them. The allow-list declares names, dates of birth, addresses, identifiers and email domains. A dashed Social Security number and a phone outside the 555 fake-exchange convention are matched by detectors that consult no declaration at all, so for those two, with the file still in scope, the value itself has to change. The bypass used to clear them, so this is a real narrowing and is recorded as one.
|
|
411
|
+
|
|
412
|
+
One carve-out, named rather than left to be discovered: an untracked file the sweep listed and that is gone from disk by the time it is read has no bytes to read. That stays a reported skip at exit 0, the behaviour the enumeration race already had, and it cannot be used to smuggle a bypass past the rule, because the tolerance exists only in the full sweep and a bypass always puts the run in another mode. A tracked file, and any failure that is not a missing file, still refuse.
|
|
413
|
+
|
|
414
|
+
- dfac162: Segment names are matched without regard to case, so a sender shipping `pid` or `obx` no longer loses the segment.
|
|
415
|
+
|
|
416
|
+
Segment lookup was case-sensitive, and nothing normalized a segment name. A message whose PID arrived as `pid` or `Pid` returned `undefined` from `msg.patient`; a message whose OBX arrived as `obx` returned `[]` from `observations()`, so a haemoglobin of 7.1 g/dL flagged `L` was absent from a parsed result feed. Both cases warned, but the warning said the segment was `UNKNOWN_SEGMENT`: "not in HL7 spec and no profile claim". `PID` and `OBX` are in the standard, so the one diagnostic a reader had pointed away from the cause. The data was still reachable through `allSegments()`, so nothing was lost from the parse itself, but every typed accessor above it read the message as if the segment were not there.
|
|
417
|
+
|
|
418
|
+
A segment name on the wire is now folded to its canonical uppercase spelling for every lookup that asks which segment it is: `msg.segments()` and `getAll()`, the dot-path resolver, `msg.patient`, `observations()`, `medications()`, `notes()`, `appointments()`, `msg.structure`, profile claims on custom Z-segments, and the `setField` / `setComposite` / `removeSegment` writes, so a write targets the segment a read found. `SEGMENT_CASE` reports the deviation, which is what that code was reserved for and it had no emit site until now. `UNKNOWN_SEGMENT` still fires for a name no folding can recognise, and now says the comparison ignored case. The two are mutually exclusive: `SEGMENT_CASE` claims a segment resolved, so it is never reported for a name that resolved to nothing.
|
|
419
|
+
|
|
420
|
+
Both the wire spelling and the `segments()` / `getAll()` query are folded, so **neither spelling stops working**. This matters for anyone already on a lowercase feed: because `Segment.type` used to carry the wire spelling verbatim, `msg.segments("obx")` was the call that returned the segment and `msg.segments("OBX")` was the one that returned nothing, so a consumer will have written the sender's spelling as a workaround. Both now return it. The dot-path keeps its uppercase-only **query** contract: `msg.get("obx.5")` still raises a `TypeError`, as it always has. What it resolves did change, which is the point: `msg.get("OBX.5")` now reaches a segment the sender spelled `obx`, where it used to return nothing.
|
|
421
|
+
|
|
422
|
+
The fold is **ASCII-only**, and that is the load-bearing part rather than an implementation detail. Uppercasing by the Unicode rules turns a dotless Turkish `ı` into `I`, so `pıd` would become `PID` and the demographics of a segment the sender never sent would read as a real patient. Only `a`-`z` are mapped, so a lookalike stays unrecognised and reports `UNKNOWN_SEGMENT`, which is the honest answer.
|
|
423
|
+
|
|
424
|
+
The spelling that arrived is preserved, not corrected: it stays on `RawSegment.name` and is what `toString()` re-emits, so the byte-exact round-trip is unchanged. `Segment.type` now reports the canonical spelling, since that is the field every lookup compares and the field a consumer switches on; read `Segment.raw.name` for what the sender actually sent.
|
|
425
|
+
|
|
426
|
+
Strict mode accepts nothing it rejected before. Every input that threw still throws: a known segment that is merely miscased throws `SEGMENT_CASE`, and an unrecognised segment still throws `UNKNOWN_SEGMENT`, the code it always threw. A lowercase `msh` remains the `NO_MSH_SEGMENT` fatal, deliberately: MSH is what delimiter discovery reads before any segment name exists to fold, so widening it would change what counts as a message at all.
|
|
427
|
+
|
|
428
|
+
One input strict now **rejects** that it accepted before: a hand-rolled profile object keyed with a lowercase `customSegments` name, parsed against a matching lowercase segment. It claimed the segment then and still does, so the parsed result and the profile claim are unchanged; the lenient parse gains one `SEGMENT_CASE` warning, which is what strict promotes to a throw.
|
|
429
|
+
|
|
430
|
+
- 0ebfd0b: Docs: the published bundle now documents every warning and fatal code, and a repository command proves the bundle's shape on every change.
|
|
431
|
+
|
|
432
|
+
`docs-content/troubleshooting.md` told a reader that the 20 warning codes were a public, versioned contract and pointed at Core Concepts "for what each one means". No page carried that reference, and seven of the 20 keys of `WARNING_CODES` appeared nowhere in the bundle at all: `MLLP_FRAMING_STRIPPED`, `FIELD_WHITESPACE_TRIMMED`, `EXTRA_FIELDS`, `DUPLICATE_REQUIRED_SEGMENT`, `MISSING_REQUIRED_FIELD`, `OUT_OF_ORDER_SEGMENT` and `ACK_NO_CORRELATION_ID`. So the site promised a reference for a versioned contract and did not carry it. A new page, "Warning and fatal codes", now lists all 20 `WARNING_CODES` keys and all 4 `FATAL_CODES` keys with a description of each, and the troubleshooting sentence points there.
|
|
433
|
+
|
|
434
|
+
Page metadata is uniform. Every one of the 23 pages carries `id`, `title`, `sidebar_label` and a `description` of at most 160 characters, so the site has a per-page summary to render and to hand to a search index. `benchmarks.md` carried no frontmatter at all and now does; six pages gained a `sidebar_label`; five carried a `sidebar_position` that this site never consults, because the bundle ships an explicit `sidebars.json`, and those are gone. Internal links were written two ways, `./page.md` on 15 and `./page` on 7; all 22 are now the `.md` form.
|
|
435
|
+
|
|
436
|
+
`pnpm check:docs` is the new gate, wired into a pull-request workflow. It refuses a missing or unparseable frontmatter block, an `id` that disagrees with its filename, an over-long or empty `description`, a stray `sidebar_position`, a sidebar id with no page, a page no sidebar reaches, an internal link that is extensionless or dangling, a code in the source registries and not on the reference page, a code on the page that no registry defines, and a code listed without prose beside it. It also refuses to report a clean result it did not measure: an empty `docs-content/`, an absent `sidebars.json` and a file it enumerated but could not read are all failures rather than passes.
|
|
437
|
+
|
|
438
|
+
It carries a sample-data floor that nothing else in this repo covers. `pnpm phi-scan` deliberately does not read markdown, and `docs-content/` is published to the documentation site and shipped in the release tarball, so a sample value on one of these pages had no gate in front of it. Any line shaped like an HL7 segment is now refused if it carries a nine-digit dashed identifier, a telephone number whose exchange digits are not `555`, or an email address outside `example.com` and `example.org`.
|
|
439
|
+
|
|
440
|
+
No parser behaviour changes here. The code registries are read, never modified; `pnpm pack:docs` keeps its existing contract and still refuses without `intro.md` and `sidebars.json`.
|
|
441
|
+
|
|
442
|
+
- 966cc38: **Repository tooling only. Nothing about the published package changed.** The `attw` publish gate, which checks that the tarball carries the type declarations `package.json` promises before a release goes out, now runs the one shared implementation published as `@cosyte/script-utils/attw` instead of this repository's own copy of it. That copy is deleted. The files, the exports, the API and the types are untouched, and `dependencies` stays empty: `@cosyte/script-utils` is a devDependency pinned to an exact version, so nothing here reaches your install. This change reaches the registry only as this changelog entry in the next release.
|
|
443
|
+
|
|
444
|
+
**The gate checks more than it did.** It still refuses a release whose declared files are missing or empty on disk, and one whose tarball carries no types while `attw` itself exits 0. It now also checks that every path `package.json` declares is in the tarball npm would publish, and, because this package sets `publishConfig`, that every path in the manifest pnpm would publish is in the tarball pnpm would write. It reads `attw`'s structured output rather than a sentence of its prose, and it accepts only the two arguments it can vouch for (`--profile` with a value, and `--no-definitely-typed`), refusing everything else by default instead of refusing a list of known spellings.
|
|
445
|
+
|
|
446
|
+
**If you started a profile package from `examples/profile-starter-kit`**, the kit's `scripts/attw.mjs` is now the same short caller, and the kit's `package.json` lists `@cosyte/script-utils` at `0.1.0` in `devDependencies`. A package copied from an earlier kit keeps working as it is; to take the stronger gate, copy the new `scripts/attw.mjs` and add that devDependency. Keep the `attw` script as `node scripts/attw.mjs` and keep `@arethetypeswrong/cli` installed, since the gate runs your package's own `attw`.
|
|
447
|
+
|
|
448
|
+
- 3760216: Repository tooling: the public-surface gate now reaches its detection through the published `@cosyte/script-utils` internal-reference implementation instead of this repository's own copy of it.
|
|
449
|
+
|
|
450
|
+
Nothing a consumer can call changed. The package still has zero runtime dependencies, no export was added, removed or re-typed, and no parse result, warning code, warning position or serialized byte moved. `@cosyte/script-utils` is a devDependency pinned to an exact published version, so no consumer install receives it.
|
|
451
|
+
|
|
452
|
+
**What is refused is unchanged, and that was proved before the swap rather than asserted after it.** `test/scripts/internal-refs-gate.test.ts` is a differential corpus of 62 cases, each an input and an expected verdict: all six rules on both surfaces (the public pages plus the npm metadata, and `src/` doc comments), in both passes (line by line and paragraph-joined), the hard-wrapped indented-continuation shape a line scan cannot see, every refusal the gate has instead of a silent green, and the segment-field, code-range and clinical vocabulary a widened rule would destroy (`MSH-2`, `PID-3`, `PKG-1`, `PKG-4`, `ICD-10-CM P00-P96`, the `CSP` Clinical Study Phase field names, the DICOM imaging nouns). It was committed GREEN against the old scanner first, and not one expectation moved for the swap.
|
|
453
|
+
|
|
454
|
+
**`scripts/check-no-internal-refs.sh` is deleted, last, after that proof.** Its own disclosed residuals said the prefix list was duplicated from the estate's source list, that the two could drift, and that the fix was one shared implementation rather than one patch per repository. A new programme prefix or a closed detector hole now costs one publish and a version bump instead of one review per repository. The prefix list, the standards-designation exclusions, the public surface, the tarball accounting and the source doc-comment pass stay here as the caller's axes, along with this repository's own measured reference material, which joins the shared self-test floor.
|
|
455
|
+
|
|
456
|
+
**The gate needs `pnpm install` now, and fails closed without it.** Reaching a published package by specifier means it can no longer run before dependencies are installed. The property traded away is "the gate runs even if the install is broken"; what replaces it is that an install that failed, or an implementation that cannot be reached, exits non-zero and never prints the OK line. There is no fallback scanner behind the import, deliberately: a fallback is a second copy of the rules wearing a different hat.
|
|
457
|
+
|
|
458
|
+
- ea5b9c6: Tooling: the PHI scanner's full sweep now reads the bytes git carries, not only the working tree.
|
|
459
|
+
|
|
460
|
+
`pnpm phi-scan` with no arguments walked three declared directories on disk and reported on what it found there. Reconciling that against the paths git tracks is satisfied by decoy content: a directory that mirrors the tracked names over clean files leaves every root yielding and the committed corpus unopened, and it printed "OK: no hits" at exit 0. The sweep now also reads every path in the git index, as a union with the walk, so a clean result is one that was measured rather than assumed.
|
|
461
|
+
|
|
462
|
+
Eight states that reported clean at exit 0 are closed, each measured against the previous release and each pinned by a test: a walk root swapped for a decoy directory; one file replaced by a clean decoy; a message under an undeclared top-level directory; a tracked file absent from the working tree; PHI behind a walk root that is itself a symlink; a tracked symbolic link or gitlink outside every walk root; an unmerged index entry; and an empty index, which used to satisfy every check vacuously.
|
|
463
|
+
|
|
464
|
+
It also reads directories nobody declared. The runnable example messages under `examples/data/` had never been scanned by either route; two of them carried a date of birth, a street address and person-name tokens that were in no allow-list, and those tokens are declared in the allow-list now. A new directory's committed bytes are in scope from the moment git tracks something in it. For a file that is not itself a message, that buys the dashed-SSN and non-test-email floor and nothing else.
|
|
465
|
+
|
|
466
|
+
Unchanged deliberately: the pre-commit route keeps its own scope, because what it enumerates decides what a commit is blocked on; and the per-root observation rule stays a statement about the walk, so a directory emptied on disk still refuses. The residual is written down rather than implied: working-tree bytes at a path outside every walk root are read by neither route, tracked or not, so a tracked file out there with unstaged edits is judged on its staged bytes. The gate remains detective rather than preventive on every route: it guarantees PHI is never carried silently, not that it never touched the disk.
|
|
467
|
+
|
|
468
|
+
- bba519d: **Repository tooling only. Nothing about the published package changed.** The release step that writes the new version into the exported `VERSION` constant, and the step that builds the two documentation archives the docs site reads, now run the shared implementations published in `@cosyte/process` instead of this repository's own copies of them. Both copies are deleted. The files, the exports, the API and the types are untouched, and `dependencies` stays empty: `@cosyte/process` is a devDependency pinned to an exact version, so nothing here reaches your install. This change reaches the registry only as this changelog entry in the next release.
|
|
469
|
+
|
|
470
|
+
**What a release writes is unchanged, and that was measured before the swap rather than asserted after it.** Every input of a version-sync corpus (a missing, empty or non-string version, a renamed declaration, a second declaration inside a comment, a declaration with a trailing comment, a version carrying replacement patterns such as `$&`, an already-synced tree, and a plain write) was run through the old script and the shared one before the old one was deleted: the same accept or refuse on every input, and the same resulting bytes on every accept. The two documentation archives built over this tree carry the same files as before; the only difference is that the shared build writes no directory entries.
|
|
471
|
+
|
|
472
|
+
**`pnpm pack:docs` now needs `pnpm install` first**, because the shared build is reached through an installed package. The release pipeline installs before it runs either step, so a release is unaffected.
|
|
473
|
+
|
|
474
|
+
## 0.0.10
|
|
475
|
+
|
|
476
|
+
### Patch Changes
|
|
477
|
+
|
|
478
|
+
- 68898a3: The README lockup now links to cosyte.com (`ASSETS`).
|
|
479
|
+
|
|
480
|
+
The `<picture>` block above the H1 is wrapped in an anchor to https://cosyte.com, per the founder
|
|
481
|
+
requirement of 2026-08-06. Nothing inside the block moved: the `<source>`, the `<img>`, the alt text
|
|
482
|
+
and both tile URLs are byte-identical.
|
|
483
|
+
|
|
484
|
+
What the anchor does was measured on both surfaces by `fhir`, not assumed, because fourteen READMEs
|
|
485
|
+
carry this shape. On GitHub the anchor works and the colour-scheme switch keeps working, because the
|
|
486
|
+
`<img>` stays a direct child of `<picture>`, which is the condition the HTML spec puts on `<source>`
|
|
487
|
+
applying at all. On an npm package page the anchor is lost: npm wraps a README image in its own
|
|
488
|
+
anchor to the image file, a nested anchor is not representable, so the parser closes ours early and
|
|
489
|
+
the image ends up linked to the image file rather than to cosyte.com. Shipped anyway by founder
|
|
490
|
+
decision of 2026-08-07: on npm that is no worse than the unlinked lockup it replaces, and GitHub is
|
|
491
|
+
where these READMEs are read.
|
|
492
|
+
|
|
3
493
|
## 0.0.9
|
|
4
494
|
|
|
5
495
|
### Patch Changes
|