@cosyte/astm 0.0.3 → 0.0.4
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 +403 -0
- package/README.md +33 -1
- package/dist/index.cjs +251 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +547 -98
- package/dist/index.d.ts +547 -98
- package/dist/index.mjs +246 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,411 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`messages()` — read a stream as the sequence of messages it actually is**
|
|
15
|
+
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
|
|
16
|
+
**stream**, and a stream may carry several messages back to back: a message runs from an `H`
|
|
17
|
+
header to its `L` terminator. `messages(msg)` splits the stream into them and returns a
|
|
18
|
+
readonly array of `AstmStreamMessage`, each carrying only its own records:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
for (const m of messages(parseAstmRecords(raw))) {
|
|
22
|
+
m.patient; // the P for THIS message
|
|
23
|
+
m.results; // the Rs for THIS message
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each entry carries `index`, `header`, `delimiters`, `records`, `patient`, `patients`,
|
|
28
|
+
`results`, `orders`, `comments`, and `queries`. It never throws, and a single-message stream
|
|
29
|
+
yields exactly one entry whose `records` are the whole stream, so it is safe to reach for
|
|
30
|
+
unconditionally.
|
|
31
|
+
|
|
32
|
+
**A message opens at every `H`** and runs to the record before the next one. That is
|
|
33
|
+
deliberately the _same_ boundary the parser already uses to scope delimiters, so message
|
|
34
|
+
boundaries and delimiter scope cannot disagree; `m.delimiters` is the set that message's
|
|
35
|
+
records were actually read with. The `L` terminator closes a message but opens no scope, so
|
|
36
|
+
records between an `L` and the next `H` stay with the message their header opened rather than
|
|
37
|
+
being dropped. Grouping is **total**: every record lands in exactly one message, none
|
|
38
|
+
duplicated and none lost, which is asserted directly.
|
|
39
|
+
|
|
40
|
+
**Evidence, labelled.** The message unit is `H` … `L` — _verified primary_: CLSI LIS02-A2 §2 is
|
|
41
|
+
definitional about the unit itself, bounding a message by the `H` record at one end and the `L`
|
|
42
|
+
record at the other. Read directly in CLSI's own free sample, and stated here in our own words
|
|
43
|
+
on purpose: we may read and cite the standard but never reproduce its prose, and this file ships
|
|
44
|
+
inside the npm tarball. That clause is the whole of what is claimed from the standard here, and
|
|
45
|
+
it does **not** settle which record _opens_ a new scope partway through a stream. So treating a
|
|
46
|
+
second `H` as the start of the next message is recorded as a **reasoned choice, not a citation**
|
|
47
|
+
— no clause number is claimed for it, and we do not assert the standard is silent either. It is
|
|
48
|
+
chosen to agree with the delimiter scoping this parser already enforces, and because it drops no
|
|
49
|
+
record. **The OSS corpus was checked rather than dismissed**, and unlike for delimiter questions
|
|
50
|
+
it is informative here — but only negatively: neither commonly-cited reference implementation
|
|
51
|
+
models the message unit at all. One dispatches records to per-type callbacks one at a time with
|
|
52
|
+
no carried state, leaving the association to the integrator; the other merges every message of a
|
|
53
|
+
transport session into a single envelope of flat per-record-type buckets, from which the
|
|
54
|
+
association cannot be recovered at all. A flat model is the incumbent shape, and this entry is
|
|
55
|
+
what it costs.
|
|
56
|
+
|
|
57
|
+
**`patient` is answered only when the message determines one.** It is the single `P` when the
|
|
58
|
+
message carries exactly one, and `undefined` when it carries none **or** several;
|
|
59
|
+
`patients.length` distinguishes those two, and there is no third meaning. A message carrying
|
|
60
|
+
several patients is not answered with the first one, because that is the guess this release
|
|
61
|
+
exists to remove. Within-message record hierarchy (which `P` a given `R` files against when a
|
|
62
|
+
message carries several) is **not** modeled: the clauses that would ground it (the
|
|
63
|
+
message-level structure diagram, and the `P` sequence-number rule) are withheld from the free
|
|
64
|
+
sample and paywalled, so the layer declines rather than inventing a rule. Multi-patient
|
|
65
|
+
messages are real — at least one openly-published vendor interface grammar makes the patient
|
|
66
|
+
group repeatable on the download direction — so this is a deferral with a known shape, not a
|
|
67
|
+
claim that the case does not arise, and not a claim that the standard is silent about it.
|
|
68
|
+
|
|
69
|
+
- **`pnpm check:no-internal-refs` + a `no-internal-refs` CI job** (`PUBLIC-SURFACE-HYGIENE`,
|
|
70
|
+
founder directive 2026-07-27). Internal only; no change to the published package surface. `astm` was
|
|
71
|
+
the last parser with no gate on this rule at all, which is why the class had regrown. The script is
|
|
72
|
+
ported from `ncpdp`'s copy (which carries three fixes `hl7`'s reference lacks: the `src/`
|
|
73
|
+
string-literal pass, the plural `phases?` stem, and `/` in the ADR separator class) plus `cli`'s
|
|
74
|
+
seventh rule, the prose roadmap citation, which neither `hl7` nor `ncpdp` has and which found the
|
|
75
|
+
second-largest class here. Four surfaces are scanned against seven rules: public markdown +
|
|
76
|
+
the npm `description`/`keywords`, `src/` doc comments, and `src/` string literals. The two prose
|
|
77
|
+
surfaces are scanned line by line **and** paragraph-reflowed, so a violation that straddles a wrap
|
|
78
|
+
is still caught; the npm metadata and the string-literal pass are line-scanned only. A multi-line
|
|
79
|
+
template literal is not scanned at all, split or not, because the extractor needs both delimiters
|
|
80
|
+
on one line. `//` comments, `CHANGELOG.md` and `.changeset/` are deliberately out of scope:
|
|
81
|
+
the convention names them as where identifiers belong.
|
|
82
|
+
|
|
83
|
+
Re-derived for this repo rather than copied: rule 2 now excludes E1381/CLSI-LIS01's own
|
|
84
|
+
**protocol phases** (`protocol`, `three-`, `establishment`, `transfer`, `termination`, `neutral`,
|
|
85
|
+
`idle`), because "phase" is the standard's word for the state of the line and `LtpState.phase` is an
|
|
86
|
+
exported field. The cost is disclosed rather than papered over: because a lookbehind exempts the
|
|
87
|
+
whole match, one of our own phases sitting behind one of those words is not caught (`the transfer
|
|
88
|
+
phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was written, measured and
|
|
89
|
+
removed, because the justification did not survive the corpus: the digit is usually the next
|
|
90
|
+
clause's first token, so it red on `In the transfer phase 240 bytes is the maximum frame text` and
|
|
91
|
+
on `a three-phase 400 V supply`, which are reference prose. Where a rule cannot be guarded, it is
|
|
92
|
+
cut rather than hardened. And the `SPEC-7` /
|
|
93
|
+
`ACC-42` synthetic example ids in every runnable sample are
|
|
94
|
+
`WORD-N` exactly, so a shape-keyed rule would rewrite the sample a consumer copy-pastes. Both are
|
|
95
|
+
asserted in negative self-tests, alongside `ASTM-E1394`, `CLSI-LIS01`, `LIS02-A2`, `POCT1-A`,
|
|
96
|
+
`E1394-97` and `ICD-10-CM`. A further negative self-test pins **bare `§` section citations as
|
|
97
|
+
deliberately unruled**, so closing that hole has to be a decision rather than a widening that lands
|
|
98
|
+
by accident.
|
|
99
|
+
|
|
100
|
+
The script also **refuses to run under a `grep` that cannot see its subject.** A scanner-visibility
|
|
101
|
+
probe seeds a violation into a file containing a NUL byte and requires it to come back as either a
|
|
102
|
+
hit or a stderr diagnostic; silence at exit 0 is a hard refusal, because a tool that skips a file
|
|
103
|
+
produces output identical to a tool that read it and found it clean. This is not theoretical: the
|
|
104
|
+
development container interposes a `grep` with `-I` forced, which drops such input without a word.
|
|
105
|
+
An interposed shell function is also `unset` rather than assumed absent. Both directions are
|
|
106
|
+
proven — an `-I`-forcing `grep` ahead of it on `PATH` makes the gate refuse, and an exported `grep`
|
|
107
|
+
function is neutralised. The companion `-G` (basic-regex) hazard needs no separate guard: every
|
|
108
|
+
positive self-test uses alternation, so a BRE-forced `grep` fails them and refuses.
|
|
109
|
+
|
|
110
|
+
Known gaps, stated rather than discovered later: a bare `P\d+` label (`(P7)`) is not caught, because
|
|
111
|
+
a general `P\d+` rule has corrupted ICD-10-CM codes in a sibling; `phase` at the end of a clause is
|
|
112
|
+
not caught; bare `§` section citations are deliberately unruled; and `dist/` is build output this
|
|
113
|
+
script cannot read, so it gates dist's source, not dist. Fourteen `P\d+` lines and the falsehoods
|
|
114
|
+
below were found by hand, not by a rule.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
|
|
118
|
+
- **BREAKING: `patient()`, `results()`, `orders()`, `comments()`, and `query()` now throw on a
|
|
119
|
+
stream they cannot answer for, instead of answering across patients**
|
|
120
|
+
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`; shipped `0.0.1` through `0.0.3`).
|
|
121
|
+
|
|
122
|
+
**The defect.** These accessors read the whole stream. On a stream carrying more than one
|
|
123
|
+
message, `patient()` answered with the **first** `P` in the stream while `results()` answered
|
|
124
|
+
with **every** `R` in it, so pairing them — which is exactly what this package's one-line north
|
|
125
|
+
star does — attributed one patient's results to another. It needed no delimiter redeclaration
|
|
126
|
+
and no unusual delimiters: an ordinary two-message stream in one canonical set reproduced it,
|
|
127
|
+
with **zero** warnings, and `strict` mode raised no objection either. On an analyzer-to-LIS
|
|
128
|
+
path that is a result filed against the wrong patient. Nothing was mis-_read_ — every `P` and
|
|
129
|
+
every `R` was correct in `records` — but there was no way to ask which belonged together.
|
|
130
|
+
|
|
131
|
+
**The change.** Those five accessors now throw `AstmAmbiguousStreamError` with code
|
|
132
|
+
`ASTM_AMBIGUOUS_MULTI_MESSAGE` when the stream carries more than one message. `patient()` also
|
|
133
|
+
throws `ASTM_AMBIGUOUS_MULTI_PATIENT` when the single message it is reading carries more than
|
|
134
|
+
one `P`, which is the same harm one level down: "the first `P`" is a guess about whose result
|
|
135
|
+
it is. The error carries a stable code, a value-free position, and the two counts; never an
|
|
136
|
+
identifier and never a value.
|
|
137
|
+
|
|
138
|
+
**Why the break is the fix, not a cost of it.** The callers this breaks are exactly the
|
|
139
|
+
population that is being silently corrupted today. A caller who now gets an exception is
|
|
140
|
+
strictly better off than one who quietly received another patient's results: the failure is
|
|
141
|
+
loud, immediate, positioned, and correctable, where before it was invisible at every layer.
|
|
142
|
+
There is no version of this where the old behaviour is safe to keep, and no warning strong
|
|
143
|
+
enough to substitute for refusing to answer, because the wrong answer was already being used.
|
|
144
|
+
|
|
145
|
+
**Which callers are affected, stated exactly.** A stream that is one message carrying at most one
|
|
146
|
+
`P` is **unchanged**, and so is a result-only message with no `P` at all: `patient()` still answers
|
|
147
|
+
`undefined` there, which is an ordinary shape and not an error. But `ASTM_AMBIGUOUS_MULTI_PATIENT`
|
|
148
|
+
**does reach single-message callers** — a lone message carrying several patients used to answer
|
|
149
|
+
with the first of them and now refuses — so "single-message streams are unaffected" would be false
|
|
150
|
+
and is not claimed. That second break is the same wrong-patient guess as the first, one level down,
|
|
151
|
+
and it is disclosed on the README and in the docs for the same reason the first one is.
|
|
152
|
+
`commentsFor()` is unchanged and works on any stream, single- or multi-message, because the parent
|
|
153
|
+
record it is handed already names the message.
|
|
154
|
+
|
|
155
|
+
**Migrating** is mechanical: replace `results(msg)` and `patient(msg)` with a walk over
|
|
156
|
+
`messages(msg)`, reading each message's own `patient` and `results`. Where a caller genuinely
|
|
157
|
+
wants every result in a stream regardless of who they belong to, that is
|
|
158
|
+
`messages(msg).flatMap((m) => m.results)` — written out, so it is a choice rather than a
|
|
159
|
+
default.
|
|
160
|
+
|
|
12
161
|
### Fixed
|
|
13
162
|
|
|
163
|
+
- **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
|
|
164
|
+
`0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
|
|
165
|
+
recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
|
|
166
|
+
`ASTM-SECOND-HEADER-COLLAPSE` (#22) because each turned on a question those slices did not answer.
|
|
167
|
+
Both were reproduced by **executing** the shipped code before anything was changed, and re-verified
|
|
168
|
+
the same way after; neither was half-fixed by the two earlier slices.
|
|
169
|
+
|
|
170
|
+
**1. A delimiter declaration longer than three characters lost its extra bytes.** Three characters
|
|
171
|
+
of a header's declaration carry a role — repeat, component, escape, by position — and the reader
|
|
172
|
+
ignores any beyond them rather than refusing the stream. Emit regenerated the declaration from the
|
|
173
|
+
three roles alone, so a header that arrived as `H|\^&#` went back out as `H|\^&`, with no warning
|
|
174
|
+
and no way for a caller to notice. Measured before the fix: `serializeAstmRecords(msg, msg.delimiters)`
|
|
175
|
+
on `H|\^&#|||SENDER^SYS|||||||P|1` returned a header short of its `#` and a round-trip that was not
|
|
176
|
+
byte-exact.
|
|
177
|
+
|
|
178
|
+
The surplus is now carried through from the modeled declaration field. Of the three available
|
|
179
|
+
dispositions — preserve, refuse, report — **preserve** was chosen. Refusing would reject a stream
|
|
180
|
+
the parser reads without complaint, on a published package, over bytes it has already decided are
|
|
181
|
+
inert. Reporting is not available at all: emit returns a bare string and has no warning channel, so
|
|
182
|
+
a warning could only be ignored while the truncated stream still shipped, which is the same reason
|
|
183
|
+
#21 chose re-encoding. Preserving costs nothing and is the only one of the three that makes the
|
|
184
|
+
round-trip byte-exact. What the surplus _means_ remains unresolved — the clauses that would settle
|
|
185
|
+
it are not in the freely published material and were not read, so no clause is cited here in either
|
|
186
|
+
direction — but carrying bytes through unread is a strictly smaller claim than deleting them, and it
|
|
187
|
+
stays coherent with a reader that now scopes delimiters forward from every header: the re-read
|
|
188
|
+
declaration resolves to the same four roles either way. This lands on the **default** canonical
|
|
189
|
+
path too, not only when a set is passed explicitly — normalizing a message replaces the four
|
|
190
|
+
delimiter roles, and the surplus holds none of them — so a caller who was relying on emit to strip
|
|
191
|
+
those bytes will find it no longer does.
|
|
192
|
+
|
|
193
|
+
The surplus is dropped in exactly the two cases where it could not be read back as surplus — the
|
|
194
|
+
header is being transcoded into a different delimiter set, so the surplus belonged to the
|
|
195
|
+
declaration being replaced; or the surplus is not inert on the wire, meaning it contains the field
|
|
196
|
+
separator or **any control character**. The control rule is deliberately wider than the record
|
|
197
|
+
layer alone needs. A `CR`/`LF` would end the record and shift every data field along, but this text
|
|
198
|
+
also reaches the **frame** layer through `serializeFramedAstm`, where `STX`, `ETX` and `ETB` are
|
|
199
|
+
structural: a surplus carrying one of those truncated the frame body, and re-reading the framed
|
|
200
|
+
stream then dropped the **entire header record** — its sender, its receiver, its control ID —
|
|
201
|
+
behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
|
|
202
|
+
to reserve, and re-derive that list whenever a layer is added, no control character is carried at
|
|
203
|
+
all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
|
|
204
|
+
the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
|
|
205
|
+
so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing — loudly, with a
|
|
206
|
+
typed error or an unknown-record-type warning, never silently; and refusing a control character in
|
|
207
|
+
a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
|
|
208
|
+
there. Those are
|
|
209
|
+
structural losses; dropping inert bytes is not.
|
|
210
|
+
|
|
211
|
+
**2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
|
|
212
|
+
three siblings took `d` on trust. Measured before the fix, across six malformed sets: a
|
|
213
|
+
multi-character field separator, an empty escape character, a `field`/`escape` collision and a `CR`
|
|
214
|
+
separator each emitted a stream `parseAstmRecords` then **threw** on; a `repeat`/`component`
|
|
215
|
+
collision and a `component`/`escape` collision each emitted one it **re-read with a different field
|
|
216
|
+
tree and zero warnings**. An empty escape additionally garbled the values themselves — `28.6` went
|
|
217
|
+
out as `2E8E.E6`. On the analyzer-to-LIS path a field tree that changes under a re-read is a lost
|
|
218
|
+
result or a lost specimen identifier, and the caller had no signal either way.
|
|
219
|
+
|
|
220
|
+
`d` is now checked before any bytes are written, on all four public emit entry points
|
|
221
|
+
(`serializeAstmRecords`, `serializeAstmRecord`, `serializeField`, `encodeComponent`): each separator
|
|
222
|
+
exactly one character, none a `CR`/`LF`, no two the same character. A failing set is an
|
|
223
|
+
`AstmSerializeError` with the new code `ASTM_EMIT_INVALID_DELIMITERS` — including a set that omits a
|
|
224
|
+
member or holds a non-string, which the types forbid but a JavaScript caller can still pass, and
|
|
225
|
+
which previously surfaced as a raw `TypeError` from inside the serializer.
|
|
226
|
+
|
|
227
|
+
**The three rules are necessary, not sufficient, and the docs say so rather than implying a
|
|
228
|
+
guarantee.** A set can satisfy all three and still emit a stream that reads back wrong: a separator
|
|
229
|
+
equal to a record's type letter (`field` of `R`) makes the type letter itself get escaped away, and
|
|
230
|
+
the record re-reads as unsupported with its result lost. That corruption is unchanged by this slice
|
|
231
|
+
— it reproduces byte-identically before it — and is recorded as a known defect rather than fixed
|
|
232
|
+
here, because the rule that would catch it has to be derived rather than guessed. **A typed error rather than a
|
|
233
|
+
warning, and the house rule from #21 is why**: with no warning channel on a `string` return, refusing
|
|
234
|
+
at the call is the only disposition that reaches the caller at all. The counter-argument was weighed
|
|
235
|
+
and is real — `@cosyte/astm` is published, and refusing rejects input previously accepted. It is
|
|
236
|
+
accepted deliberately, because the input turned away is exactly the input that was being corrupted,
|
|
237
|
+
and because the three rules are not stylistic: each names a case where the emitted bytes provably do
|
|
238
|
+
not read back as the records that produced them. The narrowing has one consequence worth stating
|
|
239
|
+
plainly: the **reader** tolerates a header declaring, say, the same character for `repeat` and
|
|
240
|
+
`component`, so `serializeAstmRecords(msg, msg.delimiters)` on such a message now throws where it
|
|
241
|
+
previously returned bytes that read back wrong. The canonical default is untouched, and so is every
|
|
242
|
+
well-formed set. The error names the role at fault and never echoes the offending characters, since
|
|
243
|
+
a value failing the one-character rule can be arbitrary caller text and the message reaches logs.
|
|
244
|
+
|
|
245
|
+
The invariant both halves are held against is the one #21 and #22 established: **a round-trip never
|
|
246
|
+
silently loses a field, in either direction.** The property is asserted by round-tripping through the
|
|
247
|
+
real parser, not by inspecting the serializer.
|
|
248
|
+
|
|
249
|
+
`AstmSerializeError.code` widens from a single string literal to a two-member union, exported as the
|
|
250
|
+
new `AstmSerializeErrorCode` type. `ASTM_EMIT_UNENCODABLE_VALUE` is unchanged.
|
|
251
|
+
|
|
252
|
+
- **`parseAstmRecords` now reads the delimiters from every `H` record, not only the first, so a
|
|
253
|
+
stream carrying more than one message no longer silently loses fields** (`ASTM-SECOND-HEADER-COLLAPSE`;
|
|
254
|
+
shipped `0.0.1` through `0.0.3`, `PRE-EXISTING`, not a regression). This is the same silent field
|
|
255
|
+
collapse as `ASTM-MIXED-DELIMITER-EMIT` one layer over, on the **parse** side, which is why #21 did
|
|
256
|
+
not narrow it: #21 fixed what we write, and a stream arriving with two headers is mis-read on the
|
|
257
|
+
way **in**, before any of that runs.
|
|
258
|
+
|
|
259
|
+
A message runs `H` … `L`, so one stream may carry several back to back and each header declares the
|
|
260
|
+
delimiters for the records that follow it. Delimiters were read only from `records[0]` and applied
|
|
261
|
+
to the whole stream, so every record after a redeclaring header **collapsed into a single field,
|
|
262
|
+
with zero warnings**, and `{ strict: true }` accepted it. Measured by execution on `dc09a3a` before
|
|
263
|
+
the fix: in a two-message stream whose second header declared `*~:#`, the second result — value
|
|
264
|
+
`99.9`, units `mmol/L`, abnormal flag `H`, status `F` — came back with `value`, `units`, `flag` and
|
|
265
|
+
`resultStatus` **all absent** and `status` reading `unspecified`. On the analyzer↔LIS path that is a
|
|
266
|
+
lost result with no signal to the caller.
|
|
267
|
+
|
|
268
|
+
**The decision, and its grounding.** Delimiters are now scoped **forward from each header**: a later
|
|
269
|
+
`H` governs itself and the records after it, until the next `H`. The asymmetry with the emit bug is
|
|
270
|
+
real — on emit the library was corrupting its own output, so re-encoding was unambiguously safe,
|
|
271
|
+
whereas on parse the bytes are the sender's and a second `H` is legal. What settles it is that
|
|
272
|
+
forward scoping is the **only** reading that never reinterprets bytes already consumed: records
|
|
273
|
+
before a redeclaration keep the set they were read with, so the two sets never disagree about the
|
|
274
|
+
same bytes. "First header wins forever" is the collapse itself; "last header wins retroactively"
|
|
275
|
+
would require re-reading records already delivered.
|
|
276
|
+
|
|
277
|
+
**Evidence, labelled.** The message unit is `H` … `L` — _verified primary_: LIS02-A2 §2 makes the
|
|
278
|
+
message a **bounded unit**, one record type opening it and another closing it, which is why a second
|
|
279
|
+
`H` begins a new message rather than corrupting the current one. Read directly in CLSI's own free
|
|
280
|
+
sample of the document. (Stated in our own words on purpose. We may read and cite the standard but
|
|
281
|
+
never reproduce its prose, and `CHANGELOG.md` ships inside the npm tarball — an earlier draft of
|
|
282
|
+
this entry quoted the clause verbatim and was caught in review.) That a header may follow a
|
|
283
|
+
terminator to start another message — _verified secondary_, two independent restatements of the
|
|
284
|
+
terminator clause (Roche cobas b 121 ASTM interface description; Genaux, _Introduction to ASTM
|
|
285
|
+
Message Formats_, 2024).
|
|
286
|
+
That a header's delimiters govern "the message" — _verified secondary_ (Genaux; Stratford Software
|
|
287
|
+
interface spec), which leans per-message but **does not address a redeclaration that changes the
|
|
288
|
+
set**. **We could not reach the normative text on that specific question**: LIS02-A2 §5.4
|
|
289
|
+
(Delimiters) and §6.2 (Delimiter Definition) are precisely the clauses withheld from the free
|
|
290
|
+
sample, and E1394-97 and LIS01-A2 stayed paywalled. So the forward-scoping rule is recorded as a
|
|
291
|
+
**reasoned choice, not a citation** — no clause number is claimed for it, and we do not assert the
|
|
292
|
+
standard is silent either, because that would need the same evidence.
|
|
293
|
+
|
|
294
|
+
The OSS reference corpus cannot ground this one and is reported as a negative result rather than
|
|
295
|
+
dressed up: `kxepal/python-astm` and `senaite.astm` both hardcode `|\^&` as module constants, never
|
|
296
|
+
read the header's declaration at all, and neither tracks `H` … `L` boundaries — so differential
|
|
297
|
+
testing against them is uninformative here.
|
|
298
|
+
|
|
299
|
+
**Behaviour.** A redeclaration that **changes** the set is honored and raises
|
|
300
|
+
`ASTM_RECORD_DELIMITERS_REDECLARED`. A header that merely **restates** the set in force is a no-op
|
|
301
|
+
and raises nothing — several messages in one delimiter set is an ordinary shape, and warning on it
|
|
302
|
+
would be noise. A later header whose declaration is **unusable** (too short, or a field separator
|
|
303
|
+
that also names another role) keeps the set already in force and raises
|
|
304
|
+
`ASTM_RECORD_UNREADABLE_REDECLARATION`; a set is never guessed and no record is dropped. The same
|
|
305
|
+
condition on the _first_ header remains the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal — there is no
|
|
306
|
+
earlier set to fall back to, and that is pinned by a test so it cannot be softened by accident.
|
|
307
|
+
|
|
308
|
+
**Surface.** Two new stable warning codes (`ASTM_RECORD_DELIMITERS_REDECLARED`,
|
|
309
|
+
`ASTM_RECORD_UNREADABLE_REDECLARATION`) with `delimitersRedeclared` / `unreadableRedeclaration`
|
|
310
|
+
factories, both **safety-critical** by the profile gate's default-deny rule, so no profile can quiet
|
|
311
|
+
them (asserted). `HeaderRecord.delimiters` now reports the set **that** header put into force rather
|
|
312
|
+
than always the first header's; `AstmMessage.delimiters` is unchanged and stays the first header's.
|
|
313
|
+
The sites that _stated_ the old single-header rule were swept — the `parseAstmRecords` JSDoc, the
|
|
314
|
+
module header, `HeaderRecord`/`AstmMessage` type docs, `readDelimiters` (whose doc comment said the
|
|
315
|
+
caller escalates an unusable declaration to the fatal, true only of the first header now), and
|
|
316
|
+
`docs-content/quickstart.md` — because on #21 the refuter's first pass was refused for exactly the
|
|
317
|
+
opposite failure: correct code shipped behind documentation that steered consumers the wrong way.
|
|
318
|
+
The `readDelimiters` comment also said it returns `{ ok: false }` when it has always returned
|
|
319
|
+
`undefined`; that was already wrong before this slice and is corrected in the same paragraph.
|
|
320
|
+
|
|
321
|
+
**Deliberately left for their own slices** (both recorded with this item, both **emit**-side, and
|
|
322
|
+
both turning on questions this one does not answer): a delimiter declaration **longer than three
|
|
323
|
+
characters** still loses its extra bytes on emit — what a fourth declaration byte even means is
|
|
324
|
+
unresolved by the same withheld clauses — and `serializeAstmRecords(msg, d)` still does **not
|
|
325
|
+
validate a caller-supplied `d`**, so a malformed set emits a stream this library's own parser then
|
|
326
|
+
rejects or mis-reads, with no typed error. Neither is touched here; folding an emit-side change into
|
|
327
|
+
a parse-side fix would have widened the diff without answering either question.
|
|
328
|
+
|
|
329
|
+
**Strict-mode boundary, disclosed as a sample and not a census.** Measured base-build vs head-build
|
|
330
|
+
over 216,699 synthetic streams (the 11 repo fixtures, 464 single-header streams, and multi-header
|
|
331
|
+
pairs across a sampled sweep of delimiter sets): **394 moved accepted→rejected, 0 the other way.**
|
|
332
|
+
All 11 fixtures and all 464 single-header streams were **unchanged**, in strict mode and in their
|
|
333
|
+
lenient warning lists — which matches the code path, since the new warnings can only be raised at an
|
|
334
|
+
`H` record that is not `records[0]`.
|
|
335
|
+
|
|
336
|
+
- **`serializeAstmRecords` no longer emits a mixed-delimiter stream that silently loses fields on the
|
|
337
|
+
next read** (`ASTM-MIXED-DELIMITER-EMIT`; shipped since `0.0.1`, `PRE-EXISTING`, not a regression).
|
|
338
|
+
Emit normalized the header to the canonical `H|\^&` set but re-emitted `M` (manufacturer) and `S`
|
|
339
|
+
(scientific) records byte-for-byte from `rawLine`. For a message that arrived under a vendor
|
|
340
|
+
delimiter set the output was therefore **non-conformant**: a canonical header above `M`/`S` rows
|
|
341
|
+
still written in the original delimiters. Re-parsing that output **collapsed every field of those
|
|
342
|
+
rows into one, with zero warnings** — on the analyzer↔LIS path, a lost result or a lost
|
|
343
|
+
patient/specimen identifier with no signal to the caller. Verified by execution before and after,
|
|
344
|
+
not by inspection: a five-field `M` row round-tripped to one field.
|
|
345
|
+
|
|
346
|
+
**The semantics chosen, and why.** The two candidates were to re-encode every record to the
|
|
347
|
+
delimiters the header declares, or to refuse/warn on a message whose records disagree.
|
|
348
|
+
**Re-encoding was chosen.** It is what the serializer already promises — one spec-clean stream in
|
|
349
|
+
the declared set — and a mixed-delimiter stream is not spec-clean. Emit returns a `string` and has
|
|
350
|
+
no warning channel, so a warning could only have been ignored while the corrupt stream still
|
|
351
|
+
shipped, and a refusal would have rejected messages this library successfully parsed, a harder
|
|
352
|
+
break on a published package. The invariant that had to hold either way is that **a round-trip
|
|
353
|
+
never silently loses a field, in either direction**; it is now pinned by a property over arbitrary
|
|
354
|
+
declared delimiter sets, which fails against the old code. `M`/`S` are reproduced byte-for-byte
|
|
355
|
+
**only when a reader using the emit delimiters would recover exactly the fields the record models**,
|
|
356
|
+
so bytes change for exactly the streams that were being corrupted and for no others. The reasoning
|
|
357
|
+
is recorded at the site in `src/records/serialize.ts`.
|
|
358
|
+
|
|
359
|
+
- **Editing a modeled `H` field and re-serializing no longer silently keeps the original value**
|
|
360
|
+
(`PRE-EXISTING`, found in the same pass). The header was emitted by re-tokenizing its preserved
|
|
361
|
+
`rawLine`, so the model was bypassed on emit. It is now emitted from `HeaderRecord.fields` like
|
|
362
|
+
every other record type. The delimiter declaration itself is still never taken from the model — it
|
|
363
|
+
always states the delimiters actually being emitted with, because a declaration that disagrees with
|
|
364
|
+
the records around it is the very corruption above.
|
|
365
|
+
|
|
366
|
+
- **`HeaderRecord.fields` now holds the header's real fields.** The generic escape-aware tokenizer
|
|
367
|
+
reads the escape character sitting _literally_ inside the delimiter declaration as an unterminated
|
|
368
|
+
escape and swallows the remainder of the record, so a canonical header modeled as **two** fields
|
|
369
|
+
(`H` and everything else merged) instead of one per field. A header-aware `tokenizeHeader` takes the
|
|
370
|
+
declaration verbatim as one opaque field and tokenizes the data portion normally; it is exported
|
|
371
|
+
alongside `tokenizeRecord`. This is what made emitting the header from its model possible.
|
|
372
|
+
|
|
373
|
+
**A `strict` consumer will observe one side effect, stated rather than left to be discovered.**
|
|
374
|
+
The old tokenizer read the declaration's literal escape char as an unterminated escape and raised a
|
|
375
|
+
spurious `ASTM_UNKNOWN_ESCAPE_SEQUENCE` on some headers, which `{ strict: true }` rejected. Those
|
|
376
|
+
warnings are gone, so a few messages that previously threw now parse. Measured over ~40,000
|
|
377
|
+
differential messages: 1,176 moved from rejected to accepted, **0** moved the other way, and every
|
|
378
|
+
warning delta is confined to `recordIndex 0` — no warning on any other record is added or dropped.
|
|
379
|
+
|
|
380
|
+
Three `PRE-EXISTING` neighbours were found while grading this and are deliberately **not** fixed
|
|
381
|
+
here, to keep the slice the size of its item: `parseAstmRecords` reads delimiters only from the
|
|
382
|
+
first header, so a **second `H` mid-stream that redeclares them** still yields the same silent
|
|
383
|
+
field collapse (parse-side, not emit-side — the emitter faithfully reproduces what parse modeled)
|
|
384
|
+
— **since fixed, see the parse-side entry above**;
|
|
385
|
+
a delimiter declaration longer than three characters silently loses its extra bytes on emit; and
|
|
386
|
+
`serializeAstmRecords(msg, d)` does not validate a caller-supplied `d`, so a malformed set (a
|
|
387
|
+
multi-char delimiter, an empty escape, a `field`/`escape` collision) emits a stream this library's
|
|
388
|
+
own parser then rejects or mis-reads, with no typed error.
|
|
389
|
+
|
|
390
|
+
- **The type documentation shipped in `dist/index.d.ts` no longer understates the library.** The
|
|
391
|
+
entry-point module documentation ended by calling serialize and build deferred, on a tree that exports
|
|
392
|
+
`serializeAstmRecords`, `buildAstmMessage`, `composeAstmFrames` and `serializeFramedAstm`, and the
|
|
393
|
+
record-types module documentation described only `H`/`P`/`O`/`R`/`L` as modeled, naming
|
|
394
|
+
result-flag/status semantics, comment, query, `M`/`S`, framing and serialization as still to come,
|
|
395
|
+
when all of them are modeled. Both
|
|
396
|
+
blocks are carried verbatim in `dist/index.d.ts` and `dist/index.d.cts`, which is what an installer
|
|
397
|
+
receives. (They are module-level blocks that bind to no exported symbol, so they sit in the
|
|
398
|
+
declaration text rather than in any symbol's editor tooltip.) Every affected block now describes
|
|
399
|
+
what the code does. 18 source files changed; no runtime behaviour, export, type or warning code is
|
|
400
|
+
affected.
|
|
401
|
+
- **The fatal-error taxonomy is now accurate about what `err.code` can hold.** `FATAL_CODES`
|
|
402
|
+
documented itself as later growing the frame codec's own `ASTM_FRAME_*` fatals. `FatalCode` is a
|
|
403
|
+
closed three-value union and the frame codec does not widen it: it reuses `EMPTY_INPUT` for an
|
|
404
|
+
empty stream, and its own thrown errors are separate types with their own discriminants
|
|
405
|
+
(`AstmFrameEncodeError` carries `ASTM_FRAME_EMPTY_RECORD`; `AstmFrameStrictError` carries the
|
|
406
|
+
rejected warnings rather than a `code`). Narrowing an `AstmParseError` on `code` will only ever
|
|
407
|
+
see one of the three.
|
|
408
|
+
- **The serializer's round-trip note now names the cases that do not round-trip.** It claimed
|
|
409
|
+
serialization emits from the decoded component tree. Three record types do not: `H`, `M` and `S`
|
|
410
|
+
are emitted from their preserved `rawLine`, so an edit to their modeled `fields` is silently not
|
|
411
|
+
reflected on emit -- editing a header field and re-serializing keeps the original value, with no
|
|
412
|
+
warning. `M`/`S` additionally keep whatever delimiters they arrived with, so a non-canonical
|
|
413
|
+
`M`/`S` row does not come back as separate fields. Normalization to `H|\^&` is also skipped when a
|
|
414
|
+
delimiter set is passed explicitly. All of it is now stated next to the round-trip claim it
|
|
415
|
+
bounds, and `serializeAstmRecords` gained the `@param d` its siblings already had.
|
|
416
|
+
|
|
14
417
|
- **The published documentation sidebar now follows the shared section order.** The shipped
|
|
15
418
|
`docs-content/sidebars.json` carried a category named "About" holding the "what it does and does not
|
|
16
419
|
do" page. That label is not one of the sections the documentation site recognises (Overview,
|
package/README.md
CHANGED
|
@@ -32,7 +32,9 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
|
32
32
|
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
33
33
|
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
34
34
|
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
35
|
-
construction
|
|
35
|
+
construction, and a delimiter set that fails any of the three conditions readback requires — one
|
|
36
|
+
character per separator, no `CR`/`LF`, no two the same — is a typed error rather than bytes written
|
|
37
|
+
and lost.
|
|
36
38
|
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
37
39
|
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
38
40
|
ever altering a value, behind a definition-time safety gate that refuses to tolerate any result
|
|
@@ -133,6 +135,36 @@ produce a confident wrong value: an embedded escaped delimiter reads as one comp
|
|
|
133
135
|
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
134
136
|
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
135
137
|
|
|
138
|
+
### Several messages in one stream
|
|
139
|
+
|
|
140
|
+
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
141
|
+
splits a parsed stream into them, and each entry carries only its own records — so a patient is only
|
|
142
|
+
ever paired with the results that message actually carried:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { parseAstmRecords, messages } from "@cosyte/astm";
|
|
146
|
+
|
|
147
|
+
for (const m of messages(parseAstmRecords(raw))) {
|
|
148
|
+
m.patient?.practiceAssignedId; // the P for THIS message
|
|
149
|
+
m.results; // the Rs for THIS message
|
|
150
|
+
m.delimiters; // the set THIS message's records were read with
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
|
|
155
|
+
so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
|
|
156
|
+
across patients:
|
|
157
|
+
|
|
158
|
+
- `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
|
|
159
|
+
- `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
|
|
160
|
+
one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
|
|
161
|
+
|
|
162
|
+
**Both are breaking**, and the second one reaches single-message callers: a lone message carrying
|
|
163
|
+
several patients used to answer with the first of them. A stream that is one message with at most one
|
|
164
|
+
patient is unchanged, and so is a result-only message with no `P` at all, which still answers
|
|
165
|
+
`undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
|
|
166
|
+
already names the message.
|
|
167
|
+
|
|
136
168
|
## Map local codes to LOINC (LIVD, bring-your-own)
|
|
137
169
|
|
|
138
170
|
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|