@cosyte/astm 0.0.4 → 0.0.6
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 +166 -119
- package/README.md +28 -23
- package/dist/index.cjs +57 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +394 -394
- package/dist/index.d.ts +394 -394
- package/dist/index.mjs +57 -57
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,7 +11,7 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
11
11
|
|
|
12
12
|
### Added
|
|
13
13
|
|
|
14
|
-
- **`messages()
|
|
14
|
+
- **`messages()`: read a stream as the sequence of messages it actually is**
|
|
15
15
|
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
|
|
16
16
|
**stream**, and a stream may carry several messages back to back: a message runs from an `H`
|
|
17
17
|
header to its `L` terminator. `messages(msg)` splits the stream into them and returns a
|
|
@@ -37,17 +37,17 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
37
37
|
being dropped. Grouping is **total**: every record lands in exactly one message, none
|
|
38
38
|
duplicated and none lost, which is asserted directly.
|
|
39
39
|
|
|
40
|
-
**Evidence, labelled.** The message unit is `H` … `L
|
|
40
|
+
**Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: CLSI LIS02-A2 §2 is
|
|
41
41
|
definitional about the unit itself, bounding a message by the `H` record at one end and the `L`
|
|
42
42
|
record at the other. Read directly in CLSI's own free sample, and stated here in our own words
|
|
43
43
|
on purpose: we may read and cite the standard but never reproduce its prose, and this file ships
|
|
44
44
|
inside the npm tarball. That clause is the whole of what is claimed from the standard here, and
|
|
45
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
|
-
|
|
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
48
|
chosen to agree with the delimiter scoping this parser already enforces, and because it drops no
|
|
49
49
|
record. **The OSS corpus was checked rather than dismissed**, and unlike for delimiter questions
|
|
50
|
-
it is informative here
|
|
50
|
+
it is informative here, but only negatively: neither commonly-cited reference implementation
|
|
51
51
|
models the message unit at all. One dispatches records to per-type callbacks one at a time with
|
|
52
52
|
no carried state, leaving the association to the integrator; the other merges every message of a
|
|
53
53
|
transport session into a single envelope of flat per-record-type buckets, from which the
|
|
@@ -62,8 +62,8 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
62
62
|
message carries several) is **not** modeled: the clauses that would ground it (the
|
|
63
63
|
message-level structure diagram, and the `P` sequence-number rule) are withheld from the free
|
|
64
64
|
sample and paywalled, so the layer declines rather than inventing a rule. Multi-patient
|
|
65
|
-
messages are real
|
|
66
|
-
group repeatable on the download direction
|
|
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
67
|
claim that the case does not arise, and not a claim that the standard is silent about it.
|
|
68
68
|
|
|
69
69
|
- **`pnpm check:no-internal-refs` + a `no-internal-refs` CI job** (`PUBLIC-SURFACE-HYGIENE`,
|
|
@@ -103,7 +103,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
103
103
|
produces output identical to a tool that read it and found it clean. This is not theoretical: the
|
|
104
104
|
development container interposes a `grep` with `-I` forced, which drops such input without a word.
|
|
105
105
|
An interposed shell function is also `unset` rather than assumed absent. Both directions are
|
|
106
|
-
proven
|
|
106
|
+
proven: an `-I`-forcing `grep` ahead of it on `PATH` makes the gate refuse, and an exported `grep`
|
|
107
107
|
function is neutralised. The companion `-G` (basic-regex) hazard needs no separate guard: every
|
|
108
108
|
positive self-test uses alternation, so a BRE-forced `grep` fails them and refuses.
|
|
109
109
|
|
|
@@ -113,20 +113,67 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
113
113
|
script cannot read, so it gates dist's source, not dist. Fourteen `P\d+` lines and the falsehoods
|
|
114
114
|
below were found by hand, not by a rule.
|
|
115
115
|
|
|
116
|
+
- **The Cosyte mark now opens the README, and follows the reader's color scheme**
|
|
117
|
+
(`ASSETS-P8`). A `<picture>` block above the H1 offers a dark-ground tile behind a
|
|
118
|
+
`prefers-color-scheme: dark` media query, with the light-ground tile as the inner `<img>`.
|
|
119
|
+
GitHub documents support for that switch; whether npm's README renderer honours it is the open
|
|
120
|
+
question this is meant to answer, and the founder will grade it by eye on the published page.
|
|
121
|
+
|
|
122
|
+
**The failure mode is safe.** If a renderer strips `<source>`, the inner `<img>` renders, so the
|
|
123
|
+
worst case is the light-ground tile on a dark page, never a missing or broken image.
|
|
124
|
+
|
|
125
|
+
This placement is **interim**. The per-package mark supersedes it once the direction is settled,
|
|
126
|
+
and the org tile is used here only because a matched light/dark pair already exists and the
|
|
127
|
+
per-package artwork has no light-ground variant yet, which would make the comparison
|
|
128
|
+
unreadable.
|
|
129
|
+
|
|
116
130
|
### Changed
|
|
117
131
|
|
|
132
|
+
- **The em dash (U+2014) is gone from every tracked file, and a CI gate keeps it out**
|
|
133
|
+
(`EMDASH-CONFORMANCE`). The brand rule bans the character outright across every cosyte surface
|
|
134
|
+
and names commit messages explicitly; this package was the last one it had not reached, and the
|
|
135
|
+
only one where it did not arrive on a clean tree. **1,129 occurrences across 108 of the 142
|
|
136
|
+
tracked files** were rewritten with a comma, a colon, or a period. The sweep and the gate are one
|
|
137
|
+
change on purpose: a sweep with no gate grows back, and a gate with no sweep reds CI on arrival.
|
|
138
|
+
|
|
139
|
+
**What a consumer can observe.** The npm `description` no longer carries the character. The pages
|
|
140
|
+
published to docs.cosyte.com are rewritten, including the title of the limitations page, now
|
|
141
|
+
`What it does, and does not do`. Warning and error `message` text is repunctuated, and a
|
|
142
|
+
consumer's log prints that text. **No warning or fatal code changed**: codes are the stable
|
|
143
|
+
contract, and the snapshot tests that pin them are untouched. No runtime behaviour changed; the
|
|
144
|
+
parser, the frame codec, the LTP reducer, and the emit path are not part of this change.
|
|
145
|
+
|
|
146
|
+
**Two findings worth carrying, because neither is visible from a diff.** First, an em dash can be
|
|
147
|
+
a semantic **value**: `docs-content/architecture.md`'s layer table used a bare dash in the
|
|
148
|
+
**Standard** column to mean _this layer is governed by no standard_, and a bulk rewrite turned it
|
|
149
|
+
into a stray colon, so the cell read "unstated" rather than "none" on the page whose job is honest
|
|
150
|
+
disclosure. Nothing in CI could have caught that; it is now the explicit word `None`. Second, rewriting the
|
|
151
|
+
separator in the warning registries as a colon turned `test/records/multi-header-delimiters.test.ts`
|
|
152
|
+
red: that fixture declares `:` as its **component** delimiter, and the test asserts that no warning
|
|
153
|
+
message contains one of its delimiter characters. All 22 registry messages separate with a comma
|
|
154
|
+
now. The invariant that assertion really pins is that a warning message is a **constant carrying no
|
|
155
|
+
field data**, which is what makes it value-free; a comma is not safer than a colon in principle,
|
|
156
|
+
because ASTM delimiters self-declare and any character can be one. The existing test caught this,
|
|
157
|
+
not a reviewer.
|
|
158
|
+
|
|
159
|
+
**Where the rewrite needed a rewrite, not a substitution.** Where the character had bracketed an
|
|
160
|
+
aside that itself contains commas, replacing both ends with commas turns the aside into an
|
|
161
|
+
indistinguishable list item, and several sentences went false that way. Those now use
|
|
162
|
+
**parentheses**, which is the fourth option the rule always allowed and the one a mechanical
|
|
163
|
+
substitution never reaches for.
|
|
164
|
+
|
|
118
165
|
- **BREAKING: `patient()`, `results()`, `orders()`, `comments()`, and `query()` now throw on a
|
|
119
166
|
stream they cannot answer for, instead of answering across patients**
|
|
120
167
|
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`; shipped `0.0.1` through `0.0.3`).
|
|
121
168
|
|
|
122
169
|
**The defect.** These accessors read the whole stream. On a stream carrying more than one
|
|
123
170
|
message, `patient()` answered with the **first** `P` in the stream while `results()` answered
|
|
124
|
-
with **every** `R` in it, so pairing them
|
|
125
|
-
star does
|
|
171
|
+
with **every** `R` in it, so pairing them, which is exactly what this package's one-line north
|
|
172
|
+
star does, attributed one patient's results to another. It needed no delimiter redeclaration
|
|
126
173
|
and no unusual delimiters: an ordinary two-message stream in one canonical set reproduced it,
|
|
127
174
|
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_
|
|
129
|
-
every `R` was correct in `records
|
|
175
|
+
path that is a result filed against the wrong patient. Nothing was mis-_read_, every `P` and
|
|
176
|
+
every `R` was correct in `records`, but there was no way to ask which belonged together.
|
|
130
177
|
|
|
131
178
|
**The change.** Those five accessors now throw `AstmAmbiguousStreamError` with code
|
|
132
179
|
`ASTM_AMBIGUOUS_MULTI_MESSAGE` when the stream carries more than one message. `patient()` also
|
|
@@ -145,8 +192,8 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
145
192
|
**Which callers are affected, stated exactly.** A stream that is one message carrying at most one
|
|
146
193
|
`P` is **unchanged**, and so is a result-only message with no `P` at all: `patient()` still answers
|
|
147
194
|
`undefined` there, which is an ordinary shape and not an error. But `ASTM_AMBIGUOUS_MULTI_PATIENT`
|
|
148
|
-
**does reach single-message callers
|
|
149
|
-
with the first of them and now refuses
|
|
195
|
+
**does reach single-message callers**, a lone message carrying several patients used to answer
|
|
196
|
+
with the first of them and now refuses, so "single-message streams are unaffected" would be false
|
|
150
197
|
and is not claimed. That second break is the same wrong-patient guess as the first, one level down,
|
|
151
198
|
and it is disclosed on the README and in the docs for the same reason the first one is.
|
|
152
199
|
`commentsFor()` is unchanged and works on any stream, single- or multi-message, because the parent
|
|
@@ -155,7 +202,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
155
202
|
**Migrating** is mechanical: replace `results(msg)` and `patient(msg)` with a walk over
|
|
156
203
|
`messages(msg)`, reading each message's own `patient` and `results`. Where a caller genuinely
|
|
157
204
|
wants every result in a stream regardless of who they belong to, that is
|
|
158
|
-
`messages(msg).flatMap((m) => m.results)
|
|
205
|
+
`messages(msg).flatMap((m) => m.results)`: written out, so it is a choice rather than a
|
|
159
206
|
default.
|
|
160
207
|
|
|
161
208
|
### Fixed
|
|
@@ -168,7 +215,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
168
215
|
the same way after; neither was half-fixed by the two earlier slices.
|
|
169
216
|
|
|
170
217
|
**1. A delimiter declaration longer than three characters lost its extra bytes.** Three characters
|
|
171
|
-
of a header's declaration carry a role
|
|
218
|
+
of a header's declaration carry a role (repeat, component, escape, by position) and the reader
|
|
172
219
|
ignores any beyond them rather than refusing the stream. Emit regenerated the declaration from the
|
|
173
220
|
three roles alone, so a header that arrived as `H|\^&#` went back out as `H|\^&`, with no warning
|
|
174
221
|
and no way for a caller to notice. Measured before the fix: `serializeAstmRecords(msg, msg.delimiters)`
|
|
@@ -176,33 +223,33 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
176
223
|
byte-exact.
|
|
177
224
|
|
|
178
225
|
The surplus is now carried through from the modeled declaration field. Of the three available
|
|
179
|
-
dispositions
|
|
226
|
+
dispositions (preserve, refuse, report), **preserve** was chosen. Refusing would reject a stream
|
|
180
227
|
the parser reads without complaint, on a published package, over bytes it has already decided are
|
|
181
228
|
inert. Reporting is not available at all: emit returns a bare string and has no warning channel, so
|
|
182
229
|
a warning could only be ignored while the truncated stream still shipped, which is the same reason
|
|
183
230
|
#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
|
|
231
|
+
round-trip byte-exact. What the surplus _means_ remains unresolved (the clauses that would settle
|
|
185
232
|
it are not in the freely published material and were not read, so no clause is cited here in either
|
|
186
|
-
direction
|
|
233
|
+
direction) but carrying bytes through unread is a strictly smaller claim than deleting them, and it
|
|
187
234
|
stays coherent with a reader that now scopes delimiters forward from every header: the re-read
|
|
188
235
|
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
|
|
190
|
-
delimiter roles, and the surplus holds none of them
|
|
236
|
+
path too, not only when a set is passed explicitly (normalizing a message replaces the four
|
|
237
|
+
delimiter roles, and the surplus holds none of them) so a caller who was relying on emit to strip
|
|
191
238
|
those bytes will find it no longer does.
|
|
192
239
|
|
|
193
|
-
The surplus is dropped in exactly the two cases where it could not be read back as surplus
|
|
240
|
+
The surplus is dropped in exactly the two cases where it could not be read back as surplus: the
|
|
194
241
|
header is being transcoded into a different delimiter set, so the surplus belonged to the
|
|
195
242
|
declaration being replaced; or the surplus is not inert on the wire, meaning it contains the field
|
|
196
243
|
separator or **any control character**. The control rule is deliberately wider than the record
|
|
197
244
|
layer alone needs. A `CR`/`LF` would end the record and shift every data field along, but this text
|
|
198
245
|
also reaches the **frame** layer through `serializeFramedAstm`, where `STX`, `ETX` and `ETB` are
|
|
199
246
|
structural: a surplus carrying one of those truncated the frame body, and re-reading the framed
|
|
200
|
-
stream then dropped the **entire header record**
|
|
247
|
+
stream then dropped the **entire header record** (its sender, its receiver, its control ID)
|
|
201
248
|
behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
|
|
202
249
|
to reserve, and re-derive that list whenever a layer is added, no control character is carried at
|
|
203
250
|
all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
|
|
204
251
|
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
|
|
252
|
+
so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
|
|
206
253
|
typed error or an unknown-record-type warning, never silently; and refusing a control character in
|
|
207
254
|
a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
|
|
208
255
|
there. Those are
|
|
@@ -213,26 +260,26 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
213
260
|
multi-character field separator, an empty escape character, a `field`/`escape` collision and a `CR`
|
|
214
261
|
separator each emitted a stream `parseAstmRecords` then **threw** on; a `repeat`/`component`
|
|
215
262
|
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
|
|
263
|
+
tree and zero warnings**. An empty escape additionally garbled the values themselves: `28.6` went
|
|
217
264
|
out as `2E8E.E6`. On the analyzer-to-LIS path a field tree that changes under a re-read is a lost
|
|
218
265
|
result or a lost specimen identifier, and the caller had no signal either way.
|
|
219
266
|
|
|
220
267
|
`d` is now checked before any bytes are written, on all four public emit entry points
|
|
221
268
|
(`serializeAstmRecords`, `serializeAstmRecord`, `serializeField`, `encodeComponent`): each separator
|
|
222
269
|
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
|
|
270
|
+
`AstmSerializeError` with the new code `ASTM_EMIT_INVALID_DELIMITERS`: including a set that omits a
|
|
224
271
|
member or holds a non-string, which the types forbid but a JavaScript caller can still pass, and
|
|
225
272
|
which previously surfaced as a raw `TypeError` from inside the serializer.
|
|
226
273
|
|
|
227
274
|
**The three rules are necessary, not sufficient, and the docs say so rather than implying a
|
|
228
275
|
guarantee.** A set can satisfy all three and still emit a stream that reads back wrong: a separator
|
|
229
276
|
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
|
-
|
|
277
|
+
the record re-reads as unsupported with its result lost. That corruption is unchanged by this slice,
|
|
278
|
+
it reproduces byte-identically before it, and is recorded as a known defect rather than fixed
|
|
232
279
|
here, because the rule that would catch it has to be derived rather than guessed. **A typed error rather than a
|
|
233
280
|
warning, and the house rule from #21 is why**: with no warning channel on a `string` return, refusing
|
|
234
281
|
at the call is the only disposition that reaches the caller at all. The counter-argument was weighed
|
|
235
|
-
and is real
|
|
282
|
+
and is real: `@cosyte/astm` is published, and refusing rejects input previously accepted. It is
|
|
236
283
|
accepted deliberately, because the input turned away is exactly the input that was being corrupted,
|
|
237
284
|
and because the three rules are not stylistic: each names a case where the emitted bytes provably do
|
|
238
285
|
not read back as the records that produced them. The narrowing has one consequence worth stating
|
|
@@ -260,49 +307,49 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
260
307
|
delimiters for the records that follow it. Delimiters were read only from `records[0]` and applied
|
|
261
308
|
to the whole stream, so every record after a redeclaring header **collapsed into a single field,
|
|
262
309
|
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
|
|
264
|
-
`99.9`, units `mmol/L`, abnormal flag `H`, status `F
|
|
310
|
+
the fix: in a two-message stream whose second header declared `*~:#`, the second result, value
|
|
311
|
+
`99.9`, units `mmol/L`, abnormal flag `H`, status `F`, came back with `value`, `units`, `flag` and
|
|
265
312
|
`resultStatus` **all absent** and `status` reading `unspecified`. On the analyzer↔LIS path that is a
|
|
266
313
|
lost result with no signal to the caller.
|
|
267
314
|
|
|
268
315
|
**The decision, and its grounding.** Delimiters are now scoped **forward from each header**: a later
|
|
269
316
|
`H` governs itself and the records after it, until the next `H`. The asymmetry with the emit bug is
|
|
270
|
-
real
|
|
317
|
+
real: on emit the library was corrupting its own output, so re-encoding was unambiguously safe,
|
|
271
318
|
whereas on parse the bytes are the sender's and a second `H` is legal. What settles it is that
|
|
272
319
|
forward scoping is the **only** reading that never reinterprets bytes already consumed: records
|
|
273
320
|
before a redeclaration keep the set they were read with, so the two sets never disagree about the
|
|
274
321
|
same bytes. "First header wins forever" is the collapse itself; "last header wins retroactively"
|
|
275
322
|
would require re-reading records already delivered.
|
|
276
323
|
|
|
277
|
-
**Evidence, labelled.** The message unit is `H` … `L
|
|
324
|
+
**Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: LIS02-A2 §2 makes the
|
|
278
325
|
message a **bounded unit**, one record type opening it and another closing it, which is why a second
|
|
279
326
|
`H` begins a new message rather than corrupting the current one. Read directly in CLSI's own free
|
|
280
327
|
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
|
|
328
|
+
never reproduce its prose, and `CHANGELOG.md` ships inside the npm tarball: an earlier draft of
|
|
282
329
|
this entry quoted the clause verbatim and was caught in review.) That a header may follow a
|
|
283
|
-
terminator to start another message
|
|
330
|
+
terminator to start another message: _verified secondary_, two independent restatements of the
|
|
284
331
|
terminator clause (Roche cobas b 121 ASTM interface description; Genaux, _Introduction to ASTM
|
|
285
332
|
Message Formats_, 2024).
|
|
286
|
-
That a header's delimiters govern "the message"
|
|
333
|
+
That a header's delimiters govern "the message": _verified secondary_ (Genaux; Stratford Software
|
|
287
334
|
interface spec), which leans per-message but **does not address a redeclaration that changes the
|
|
288
335
|
set**. **We could not reach the normative text on that specific question**: LIS02-A2 §5.4
|
|
289
336
|
(Delimiters) and §6.2 (Delimiter Definition) are precisely the clauses withheld from the free
|
|
290
337
|
sample, and E1394-97 and LIS01-A2 stayed paywalled. So the forward-scoping rule is recorded as a
|
|
291
|
-
**reasoned choice, not a citation
|
|
338
|
+
**reasoned choice, not a citation**: no clause number is claimed for it, and we do not assert the
|
|
292
339
|
standard is silent either, because that would need the same evidence.
|
|
293
340
|
|
|
294
341
|
The OSS reference corpus cannot ground this one and is reported as a negative result rather than
|
|
295
342
|
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
|
|
343
|
+
read the header's declaration at all, and neither tracks `H` … `L` boundaries, so differential
|
|
297
344
|
testing against them is uninformative here.
|
|
298
345
|
|
|
299
346
|
**Behaviour.** A redeclaration that **changes** the set is honored and raises
|
|
300
347
|
`ASTM_RECORD_DELIMITERS_REDECLARED`. A header that merely **restates** the set in force is a no-op
|
|
301
|
-
and raises nothing
|
|
348
|
+
and raises nothing: several messages in one delimiter set is an ordinary shape, and warning on it
|
|
302
349
|
would be noise. A later header whose declaration is **unusable** (too short, or a field separator
|
|
303
350
|
that also names another role) keeps the set already in force and raises
|
|
304
351
|
`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
|
|
352
|
+
condition on the _first_ header remains the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal: there is no
|
|
306
353
|
earlier set to fall back to, and that is pinned by a test so it cannot be softened by accident.
|
|
307
354
|
|
|
308
355
|
**Surface.** Two new stable warning codes (`ASTM_RECORD_DELIMITERS_REDECLARED`,
|
|
@@ -310,18 +357,18 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
310
357
|
factories, both **safety-critical** by the profile gate's default-deny rule, so no profile can quiet
|
|
311
358
|
them (asserted). `HeaderRecord.delimiters` now reports the set **that** header put into force rather
|
|
312
359
|
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
|
|
360
|
+
The sites that _stated_ the old single-header rule were swept, the `parseAstmRecords` JSDoc, the
|
|
314
361
|
module header, `HeaderRecord`/`AstmMessage` type docs, `readDelimiters` (whose doc comment said the
|
|
315
362
|
caller escalates an unusable declaration to the fatal, true only of the first header now), and
|
|
316
|
-
`docs-content/quickstart.md
|
|
363
|
+
`docs-content/quickstart.md`, because on #21 the refuter's first pass was refused for exactly the
|
|
317
364
|
opposite failure: correct code shipped behind documentation that steered consumers the wrong way.
|
|
318
365
|
The `readDelimiters` comment also said it returns `{ ok: false }` when it has always returned
|
|
319
366
|
`undefined`; that was already wrong before this slice and is corrected in the same paragraph.
|
|
320
367
|
|
|
321
368
|
**Deliberately left for their own slices** (both recorded with this item, both **emit**-side, and
|
|
322
369
|
both turning on questions this one does not answer): a delimiter declaration **longer than three
|
|
323
|
-
characters** still loses its extra bytes on emit
|
|
324
|
-
unresolved by the same withheld clauses
|
|
370
|
+
characters** still loses its extra bytes on emit, what a fourth declaration byte even means is
|
|
371
|
+
unresolved by the same withheld clauses, and `serializeAstmRecords(msg, d)` still does **not
|
|
325
372
|
validate a caller-supplied `d`**, so a malformed set emits a stream this library's own parser then
|
|
326
373
|
rejects or mis-reads, with no typed error. Neither is touched here; folding an emit-side change into
|
|
327
374
|
a parse-side fix would have widened the diff without answering either question.
|
|
@@ -330,7 +377,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
330
377
|
over 216,699 synthetic streams (the 11 repo fixtures, 464 single-header streams, and multi-header
|
|
331
378
|
pairs across a sampled sweep of delimiter sets): **394 moved accepted→rejected, 0 the other way.**
|
|
332
379
|
All 11 fixtures and all 464 single-header streams were **unchanged**, in strict mode and in their
|
|
333
|
-
lenient warning lists
|
|
380
|
+
lenient warning lists, which matches the code path, since the new warnings can only be raised at an
|
|
334
381
|
`H` record that is not `records[0]`.
|
|
335
382
|
|
|
336
383
|
- **`serializeAstmRecords` no longer emits a mixed-delimiter stream that silently loses fields on the
|
|
@@ -339,14 +386,14 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
339
386
|
(scientific) records byte-for-byte from `rawLine`. For a message that arrived under a vendor
|
|
340
387
|
delimiter set the output was therefore **non-conformant**: a canonical header above `M`/`S` rows
|
|
341
388
|
still written in the original delimiters. Re-parsing that output **collapsed every field of those
|
|
342
|
-
rows into one, with zero warnings
|
|
389
|
+
rows into one, with zero warnings**: on the analyzer↔LIS path, a lost result or a lost
|
|
343
390
|
patient/specimen identifier with no signal to the caller. Verified by execution before and after,
|
|
344
391
|
not by inspection: a five-field `M` row round-tripped to one field.
|
|
345
392
|
|
|
346
393
|
**The semantics chosen, and why.** The two candidates were to re-encode every record to the
|
|
347
394
|
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
|
|
349
|
-
the declared set
|
|
395
|
+
**Re-encoding was chosen.** It is what the serializer already promises, one spec-clean stream in
|
|
396
|
+
the declared set, and a mixed-delimiter stream is not spec-clean. Emit returns a `string` and has
|
|
350
397
|
no warning channel, so a warning could only have been ignored while the corrupt stream still
|
|
351
398
|
shipped, and a refusal would have rejected messages this library successfully parsed, a harder
|
|
352
399
|
break on a published package. The invariant that had to hold either way is that **a round-trip
|
|
@@ -359,7 +406,7 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
359
406
|
- **Editing a modeled `H` field and re-serializing no longer silently keeps the original value**
|
|
360
407
|
(`PRE-EXISTING`, found in the same pass). The header was emitted by re-tokenizing its preserved
|
|
361
408
|
`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
|
|
409
|
+
every other record type. The delimiter declaration itself is still never taken from the model: it
|
|
363
410
|
always states the delimiters actually being emitted with, because a declaration that disagrees with
|
|
364
411
|
the records around it is the very corruption above.
|
|
365
412
|
|
|
@@ -375,13 +422,13 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
|
|
|
375
422
|
spurious `ASTM_UNKNOWN_ESCAPE_SEQUENCE` on some headers, which `{ strict: true }` rejected. Those
|
|
376
423
|
warnings are gone, so a few messages that previously threw now parse. Measured over ~40,000
|
|
377
424
|
differential messages: 1,176 moved from rejected to accepted, **0** moved the other way, and every
|
|
378
|
-
warning delta is confined to `recordIndex 0
|
|
425
|
+
warning delta is confined to `recordIndex 0`, no warning on any other record is added or dropped.
|
|
379
426
|
|
|
380
427
|
Three `PRE-EXISTING` neighbours were found while grading this and are deliberately **not** fixed
|
|
381
428
|
here, to keep the slice the size of its item: `parseAstmRecords` reads delimiters only from the
|
|
382
429
|
first header, so a **second `H` mid-stream that redeclares them** still yields the same silent
|
|
383
|
-
field collapse (parse-side, not emit-side
|
|
384
|
-
|
|
430
|
+
field collapse (parse-side, not emit-side, the emitter faithfully reproduces what parse modeled),
|
|
431
|
+
**since fixed, see the parse-side entry above**;
|
|
385
432
|
a delimiter declaration longer than three characters silently loses its extra bytes on emit; and
|
|
386
433
|
`serializeAstmRecords(msg, d)` does not validate a caller-supplied `d`, so a malformed set (a
|
|
387
434
|
multi-char delimiter, an empty escape, a `field`/`escape` collision) emits a stream this library's
|
|
@@ -482,12 +529,12 @@ ladder (`0.0.x` until first alpha).
|
|
|
482
529
|
record stream and pull result value + units + flag in one line.
|
|
483
530
|
- `parseAstmRecords(raw, opts?)` → an immutable, deeply-frozen `AstmMessage`; `results(msg)` /
|
|
484
531
|
`patient(msg)` typed extractors.
|
|
485
|
-
- **Delimiter self-declaration
|
|
532
|
+
- **Delimiter self-declaration**: the four delimiters (field / repeat / component / escape) are
|
|
486
533
|
read from each `H` record, never hardcoded, with ASTM's `\`=repeat and `&`=escape mapping.
|
|
487
|
-
- **Escape codec
|
|
534
|
+
- **Escape codec**: `&F&`/`&S&`/`&R&`/`&E&` are decoded via escape-aware split-then-decode, so a
|
|
488
535
|
value containing an escaped component delimiter reads as **one** component (the documented
|
|
489
536
|
silent-misread class the OSS references exhibit). Re-escaping is deferred to the emit phase (P7).
|
|
490
|
-
- Modeled records: `H` (delimiter provenance), `P` (identity
|
|
537
|
+
- Modeled records: `H` (delimiter provenance), `P` (identity, practice-assigned ID and
|
|
491
538
|
laboratory-assigned ID kept **distinct**), `O` (accession + Universal Test ID), `R` (all 14
|
|
492
539
|
fields; value / units / flags / status surfaced **raw**), `L`. Unknown record types surface as
|
|
493
540
|
`unsupported` records with a warning, never dropped.
|
|
@@ -496,10 +543,10 @@ ladder (`0.0.x` until first alpha).
|
|
|
496
543
|
recognition (`[OSS-derived]` field order), the deep-freeze base, and the warning/fatal registry.
|
|
497
544
|
- Fatal codes: `EMPTY_INPUT` (shared), `ASTM_RECORD_NO_HEADER`, `ASTM_RECORD_UNDECLARED_DELIMITERS`.
|
|
498
545
|
Warning codes: `ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`,
|
|
499
|
-
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT
|
|
546
|
+
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT`, all carry stable code +
|
|
500
547
|
value-free positional context.
|
|
501
548
|
- **Fail-safe on an unescaped component delimiter in a result value:** the full raw value and the
|
|
502
|
-
component split are both surfaced and an `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` warning fires
|
|
549
|
+
component split are both surfaced and an `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT` warning fires, the
|
|
503
550
|
primary `value` is never silently truncated to the first component.
|
|
504
551
|
- `scripts/phi-scan.ts` extended toward the P-record loci (name + DOB, delimiter-aware); synthetic
|
|
505
552
|
fixtures declared in `scripts/phi-allow-list.txt`.
|
|
@@ -507,109 +554,109 @@ ladder (`0.0.x` until first alpha).
|
|
|
507
554
|
`AstmParseError`, `AstmStrictError`, the record/value model types, and the `WARNING_CODES` /
|
|
508
555
|
`FATAL_CODES` registries.
|
|
509
556
|
- **Safety-critical result semantics (ASTM-2, roadmap Phase 2).** The raw `R`-record letters that
|
|
510
|
-
Phase 1 surfaced are now modeled into fail-safe semantics, under one rule
|
|
557
|
+
Phase 1 surfaced are now modeled into fail-safe semantics, under one rule: _never a confident wrong
|
|
511
558
|
value_. The raw strings (`abnormalFlags`, `resultStatus`, `referenceRange`, `units`) still coexist
|
|
512
559
|
with the modeled views; nothing is collapsed or reconciled.
|
|
513
560
|
- **Abnormal flags (field 7) → HL7 Table 0078.** `interpretAbnormalFlag()` and the `flag` field on
|
|
514
561
|
`ResultRecord` model the full value set: `L`/`H`, panic `LL`/`HH`, off-scale `<`/`>`, `N`, `A`/`AA`,
|
|
515
|
-
the **directional** significant-change `U` (up) / `D` (down)
|
|
562
|
+
the **directional** significant-change `U` (up) / `D` (down), _not_ units/delta, `B`/`W`, and
|
|
516
563
|
microbiology `S`/`R`/`I`. An **unrecognized** flag is surfaced as `meaning: "undefined"` with an
|
|
517
|
-
`ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning
|
|
564
|
+
`ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning, **never dropped, never coerced to `normal`**.
|
|
518
565
|
- **Result status (field 9).** `interpretResultStatus()` and the always-present `status` field model
|
|
519
566
|
`F`/`C`/`P`/`R`/`S`/`I`/`X`, with **`C` correction** (`supersedes: true`) and **`X` cancel**
|
|
520
|
-
(`cancelled: true`) so a superseded/cancelled result can **never** read as current
|
|
567
|
+
(`cancelled: true`) so a superseded/cancelled result can **never** read as current, `isActiveFinal`
|
|
521
568
|
is `true` only for a plain `F`. An **absent** status is typed `unspecified` (never assumed `final`);
|
|
522
569
|
an unrecognized one is `undefined` + `ASTM_RECORD_UNDEFINED_RESULT_STATUS`.
|
|
523
570
|
- **Reference range (field 6).** `parseReferenceRange()` and the `range` field parse `low-high`
|
|
524
571
|
(closed), `<high` (open-low), and `>low` (open-high); bounds are surfaced as **verbatim numeric
|
|
525
572
|
text** (never coerced to floats). The range is read from the **full field text**, so a
|
|
526
|
-
component-delimited value (`low^high`) is preserved verbatim and read as `unparsed
|
|
573
|
+
component-delimited value (`low^high`) is preserved verbatim and read as `unparsed`: never
|
|
527
574
|
truncated to a single bound. An unparseable range is `kind: "unparsed"` +
|
|
528
|
-
`ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE
|
|
575
|
+
`ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`, **no bound is fabricated**. The exact delimiter is
|
|
529
576
|
`[OSS-derived]` pending the purchased CLSI LIS02-A2 (roadmap §10 Q1).
|
|
530
577
|
- **Units discipline (field 5).** A _numeric_ result value with no units raises
|
|
531
578
|
`ASTM_RECORD_UNITS_ABSENT`; units are vendor free text (not UCUM) and are **never defaulted,
|
|
532
579
|
guessed, or converted**.
|
|
533
580
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG`,
|
|
534
581
|
`ASTM_RECORD_UNDEFINED_RESULT_STATUS`, `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`,
|
|
535
|
-
`ASTM_RECORD_UNITS_ABSENT
|
|
582
|
+
`ASTM_RECORD_UNITS_ABSENT`, all value-free (code + record/field index only).
|
|
536
583
|
- **Patient/order identity depth, comments, and partial-timestamp hardening (ASTM-3, roadmap Phase 3).**
|
|
537
584
|
The misfiling-prevention slice: model the identity that a result files against, and the context that
|
|
538
585
|
qualifies it.
|
|
539
586
|
- **Full patient (`P`) identity.** The **practice-assigned ID (field 3)**, the **laboratory-assigned
|
|
540
587
|
ID (field 4)**, and a **third patient ID (field 5)** are modeled as **distinct** fields that never
|
|
541
|
-
collapse into one
|
|
588
|
+
collapse into one: conflating them is the primary result-misfiling path. Adds mother's maiden name
|
|
542
589
|
(field 7) alongside the existing name components (field 6), birthdate (field 8), and sex (field 9).
|
|
543
590
|
- **Full order (`O`).** `priority` (field 6), `actionCode` (field ~12), and `reportType` (field ~26)
|
|
544
591
|
are surfaced **verbatim** on top of the existing specimen/accession + Universal Test ID. The `~`
|
|
545
|
-
field indices and the code sets are `[OSS-derived]` (paywalled)
|
|
546
|
-
- **The `C` (comment) record.** Modeled as `source` (field 3), `text` (field 4, component-capable
|
|
592
|
+
field indices and the code sets are `[OSS-derived]` (paywalled): never mapped to a guessed meaning.
|
|
593
|
+
- **The `C` (comment) record.** Modeled as `source` (field 3), `text` (field 4, component-capable,
|
|
547
594
|
the full text is surfaced plus the component split, never truncated), and `commentType` (field 5).
|
|
548
595
|
Each comment is **attached by position** to the immediately-preceding `H`/`P`/`O`/`R` parent
|
|
549
596
|
(`parentIndex`); consecutive comments share that parent. **Fail-safe:** an **orphan** comment with no
|
|
550
597
|
valid parent is attached to the message root (`attachedToRoot: true`) with an
|
|
551
|
-
`ASTM_RECORD_ORPHAN_COMMENT` warning
|
|
598
|
+
`ASTM_RECORD_ORPHAN_COMMENT` warning, **never silently dropped**. New extractors `comments(msg)` /
|
|
552
599
|
`commentsFor(msg, record)` / `orders(msg)`, and the pure `attachComments()` attachment pass.
|
|
553
600
|
- **Comment-type codes are `[OSS-derived]`.** `I` (instrument) is the only value seen in the
|
|
554
601
|
permissively-licensed real transcripts; `G`/`T`/`P` are defined only in the paywalled CLSI LIS02-A2
|
|
555
|
-
and are **not** interpreted
|
|
602
|
+
and are **not** interpreted, `commentType` is surfaced raw, never mapped to a guessed meaning.
|
|
556
603
|
- **Partial-timestamp hardening.** A `YYYYMMDDHHMMSS` value with an odd digit run that truncates a
|
|
557
604
|
two-digit component (lengths 5/7/9/11/13) sets `AstmDate.truncated`, is preserved verbatim in `raw`,
|
|
558
|
-
and stops at the last **complete** component
|
|
605
|
+
and stops at the last **complete** component: the dangling digit is **never zero-filled into a
|
|
559
606
|
fabricated time**. A caller surfaces this as a value-free `ASTM_RECORD_PARTIAL_TIMESTAMP` warning
|
|
560
|
-
(P field 8, R fields 12/13). No timezone is modeled
|
|
607
|
+
(P field 8, R fields 12/13). No timezone is modeled: times stay instrument-local, never assumed UTC.
|
|
561
608
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_ORPHAN_COMMENT`,
|
|
562
|
-
`ASTM_RECORD_PARTIAL_TIMESTAMP
|
|
609
|
+
`ASTM_RECORD_PARTIAL_TIMESTAMP`, value-free (code + record/field index only).
|
|
563
610
|
- `scripts/phi-scan.ts` extended toward the mother's-maiden locus (P field 7), on top of the existing
|
|
564
611
|
name (field 6) + DOB (field 8) detection; synthetic fixtures declared in `scripts/phi-allow-list.txt`.
|
|
565
612
|
- **Query (`Q`) + host-query flow + `M`/`S` surfaced verbatim (ASTM-4, roadmap Phase 4).** Completes the
|
|
566
|
-
record grammar
|
|
613
|
+
record grammar: **the record-content layer is now feature-complete.**
|
|
567
614
|
- **The `Q` (Request Information) record.** Modeled at the public ASTM E1394 field positions:
|
|
568
615
|
`startingRangeId` (field 3) and `endingRangeId` (field 4) surfaced as the **full verbatim field**
|
|
569
616
|
(never truncated to a component), the Universal Test ID (field 5, same caret structure as `O`/`R`),
|
|
570
617
|
and `requestInformationStatus` (field 13) surfaced **verbatim**. The range component structure, the
|
|
571
618
|
`ALL` universal-query keyword (`queriesAllTests`), and the request-information status code set are
|
|
572
|
-
all **`[OSS-derived / paywalled]`** (roadmap §10 Q3)
|
|
619
|
+
all **`[OSS-derived / paywalled]`** (roadmap §10 Q3): surfaced, flagged, and **never interpreted or
|
|
573
620
|
guessed**. New `query(msg)` extractor.
|
|
574
621
|
- **The host-query flow.** Every message is classified up front (`msg.classification`): an `H/P/Q/L`
|
|
575
622
|
**request** is `host-query`, an `R`-bearing message is `results`, an `O`-only message is `orders`,
|
|
576
|
-
else `indeterminate`. **Fail-safe:** the `Q` **dominates
|
|
623
|
+
else `indeterminate`. **Fail-safe:** the `Q` **dominates**, a `Q`-bearing message is a request and
|
|
577
624
|
is **never** read as a result set, even when a result record is also present (a contradiction flagged
|
|
578
625
|
with `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`). Gate on `classification.isHostQueryRequest`. Pure
|
|
579
626
|
`classifyMessage(records)` exported.
|
|
580
627
|
- **`M` (manufacturer) + `S` (scientific) records surfaced verbatim.** Vendor-defined free-form
|
|
581
628
|
QC / calibration / maintenance data, preserved byte-for-byte on `record.rawLine` and **never**
|
|
582
|
-
interpreted into typed clinical fields
|
|
629
|
+
interpreted into typed clinical fields: a QC value can never be read as a patient result. Round-trip
|
|
583
630
|
byte-identical.
|
|
584
631
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`
|
|
585
632
|
(a Q request-information status surfaced verbatim; the code set is paywalled, so it is passed through
|
|
586
|
-
uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND
|
|
633
|
+
uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`, both value-free (code + position only).
|
|
587
634
|
- `AstmMessage` gains a `classification` field; `AstmRecord` gains `QueryRecord` / `ManufacturerRecord`
|
|
588
635
|
/ `ScientificRecord` members (an unknown type letter is still an `UnsupportedRecord`, never dropped).
|
|
589
|
-
- **E1381/CLSI-LIS01 frame codec (ASTM-5, roadmap Phase 5).** The **low-level framing layer** begins
|
|
636
|
+
- **E1381/CLSI-LIS01 frame codec (ASTM-5, roadmap Phase 5).** The **low-level framing layer** begins:
|
|
590
637
|
a separate, independent layer from the record layer, sharing only the payload boundary. `src/frames/`
|
|
591
638
|
decodes a framed byte stream into frames + reassembled record bytes; `src/common/` and `src/records/`
|
|
592
639
|
are untouched.
|
|
593
640
|
- `decodeAstmFrames(bytes, opts?)` → `{ records: readonly Uint8Array[]; frames: readonly AstmFrame[];
|
|
594
641
|
warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS <CR><LF>`.
|
|
595
642
|
- **Modulo-256 checksum** over the bytes after `STX` up to and **including** the `ETB`/`ETX`
|
|
596
|
-
terminator, two hex chars
|
|
643
|
+
terminator, two hex chars: **verified on decode, emitted uppercase, accepted lowercase** (a real
|
|
597
644
|
vendor quirk). `computeChecksum` / `toChecksumHex` / `parseChecksumHex` exported.
|
|
598
645
|
- **Frame-number `0`–`7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
|
|
599
|
-
reassembly
|
|
646
|
+
reassembly**: text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
|
|
600
647
|
is intermediate / `ETX` final. `parseFramedAstm(bytes, opts?)` composes the framing and record layers
|
|
601
648
|
at the edge (decode → reassemble trusted records → `parseAstmRecords`).
|
|
602
649
|
- **Fail-safe (byte-level, safety-critical):** a **checksum mismatch** surfaces the frame flagged
|
|
603
|
-
`trusted: false` and **never merges** it into a record (default warn in lenient / thrown in strict
|
|
650
|
+
`trusted: false` and **never merges** it into a record (default warn in lenient / thrown in strict,
|
|
604
651
|
the "checksums are routinely not validated" claim was _refuted_: we validate); a **frame-number gap**
|
|
605
652
|
warns and is **never silently bridged**; an **unterminated** frame surfaces the partial bytes
|
|
606
653
|
untrusted and **invents no partial record**; an **oversize** (>240) frame is flagged, never dropped.
|
|
607
654
|
- New `ASTM_FRAME_*` warning registry (a **second** registry alongside `ASTM_RECORD_*`, sharing only
|
|
608
655
|
the `EMPTY_INPUT` fatal; snapshot locked): `ASTM_FRAME_BAD_CHECKSUM`, `ASTM_FRAME_SEQUENCE_GAP`,
|
|
609
|
-
`ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE
|
|
656
|
+
`ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE`, every warning **value-free**, carrying a **frame
|
|
610
657
|
number + byte offset** only, never the record bytes a frame holds. `{ strict: true }` throws
|
|
611
658
|
`AstmFrameStrictError`.
|
|
612
|
-
- **Fuzz gate (required, part of `verify`):** a `fast-check` target over the codec
|
|
659
|
+
- **Fuzz gate (required, part of `verify`):** a `fast-check` target over the codec, arbitrary /
|
|
613
660
|
truncated / mixed / control-char-laden bytes never crash, hang, or OOM; they degrade to a typed
|
|
614
661
|
error or a value-free warning. Plus property tests: N-frame reassembly equals the single-frame form,
|
|
615
662
|
and every trusted frame's recomputed checksum matches its declared value.
|
|
@@ -617,13 +664,13 @@ warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS
|
|
|
617
664
|
`DecodeAstmFramesResult`, `FramedAstmResult`, `AstmFramePosition`, `AstmFrameWarning`,
|
|
618
665
|
`FrameWarningCode`, `FRAME_WARNING_CODES`.
|
|
619
666
|
- **Transport variants + pure LTP protocol reducer (ASTM-6, roadmap Phase 6).** The **LTP protocol
|
|
620
|
-
layer
|
|
667
|
+
layer**, `src/ltp/`, sits above the frame codec: transport auto-detection plus a deterministic,
|
|
621
668
|
socket-free session state machine. No live I/O: the consumer owns the wire and clock; this layer
|
|
622
669
|
decides.
|
|
623
670
|
- **Transport auto-detection.** `detectFraming(bytes, opts?)` → `{ framing: "framed" | "raw";
|
|
624
671
|
defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, and the cobas 4800 /
|
|
625
672
|
iNTERFACEWARE Iguana framed-over-TCP reality); a leading bare record letter (`H`/`P`/`O`/`R`/`C`/
|
|
626
|
-
`Q`/`M`/`S`/`L`) ⇒ **raw** (the cobas b121 raw-TCP reality
|
|
673
|
+
`Q`/`M`/`S`/`L`) ⇒ **raw** (the cobas b121 raw-TCP reality, framing dropped, records streamed
|
|
627
674
|
directly). An unrecognizable lead **defaults to framed and warns**
|
|
628
675
|
(`ASTM_LTP_AMBIGUOUS_TRANSPORT`), never guessing silently into data loss; an `override` forces the
|
|
629
676
|
mode (the Phase-8 profile hook).
|
|
@@ -632,8 +679,8 @@ defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, a
|
|
|
632
679
|
codec-decoded `frame`; actions are `sendAck` / `sendNak` / `sendEot` / `deliverRecord`. It models
|
|
633
680
|
the LIS01-A2 establishment → transfer → termination phases as `neutral ⇄ transfer`, reassembling
|
|
634
681
|
`ETB…ETX` runs into delivered records and tracking the `0`–`7` frame sequence.
|
|
635
|
-
- **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for
|
|
636
|
-
**bad checksum**, an **unterminated** frame, or one **out of sequence**
|
|
682
|
+
- **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for (a
|
|
683
|
+
**bad checksum**, an **unterminated** frame, or one **out of sequence**) is answered with `NAK`,
|
|
637
684
|
**never** a fabricated positive `ACK`, and its bytes are **never** appended to a record or
|
|
638
685
|
delivered. A `NAK` drives **retransmit, not acceptance** (`ASTM_LTP_FRAME_REJECTED`). A duplicate
|
|
639
686
|
of the last-accepted frame is idempotently re-`ACK`ed without re-appending; a partial record open
|
|
@@ -648,33 +695,33 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
648
695
|
`ltpInitialState`, `ltpReduce`, `LtpPhase`, `LtpState`, `LtpEvent`, `LtpAction`, `LtpTransition`,
|
|
649
696
|
`AstmLtpWarning`, `LtpWarningCode`, `LTP_WARNING_CODES`, `ltpAmbiguousTransport`,
|
|
650
697
|
`ltpUnexpectedEvent`, `ltpFrameRejected`.
|
|
651
|
-
- **Spec-clean serializers + builders
|
|
698
|
+
- **Spec-clean serializers + builders, both layers (ASTM-7, roadmap Phase 7).** The **emit** side: the
|
|
652
699
|
conservative inverse of the parser and the frame codec, so **round-trip fidelity holds by
|
|
653
|
-
construction**. Postel's Law's second half
|
|
700
|
+
construction**. Postel's Law's second half: liberal on parse, strict on emit.
|
|
654
701
|
- **Record serializer.** `serializeAstmRecords(msg | records)` and `serializeAstmRecord(record)` emit a
|
|
655
702
|
`CR`-terminated stream with the **canonical** `H|\^&` delimiters and every embedded delimiter
|
|
656
|
-
re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec
|
|
657
|
-
encoded **first** (`&` → `&E&`), then the field / component / repeat delimiters (`&F&`/`&S&`/`&R&`)
|
|
703
|
+
re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec. The escape char is
|
|
704
|
+
encoded **first** (`&` → `&E&`), then the field / component / repeat delimiters (`&F&`/`&S&`/`&R&`),
|
|
658
705
|
so a value containing a delimiter (a titre `1^40` → `1&S&40`) can never break framing and reads back
|
|
659
706
|
as **one** component. A source parsed with **non-canonical** delimiters is **normalized** to the
|
|
660
707
|
canonical set on emit (vendor-delimiter round-tripping is a Phase-8 profile concern). The header's
|
|
661
708
|
delimiter declaration is emitted **literally** (never escaped); `M`/`S` records are re-emitted
|
|
662
709
|
**byte-identically** from `rawLine`.
|
|
663
710
|
- **Message builder.** `buildAstmMessage(input)` constructs a spec-clean stream from typed input under
|
|
664
|
-
the **never-fabricate** discipline: it emits **only** the values the caller supplied
|
|
711
|
+
the **never-fabricate** discipline: it emits **only** the values the caller supplied, an omitted
|
|
665
712
|
field is left empty, **never a defaulted clinical value** (an unset result status reads back as
|
|
666
713
|
`unspecified`, never `final`; units / abnormal flags / patient IDs are never defaulted). The
|
|
667
|
-
**structure**
|
|
668
|
-
counters, the `L` terminator
|
|
714
|
+
**structure** (record type letters, the canonical delimiter declaration, per-record-type sequence
|
|
715
|
+
counters, the `L` terminator) is **computed, not guessed** (a sequence number may be overridden).
|
|
669
716
|
- **Frame encoder.** `composeAstmFrames(records, opts?)` is the exact inverse of `decodeAstmFrames`:
|
|
670
717
|
it wraps reassembled record bytes into `<STX> FN text <ETB|ETX> CS <CR><LF>` frames with the
|
|
671
718
|
modulo-256 **checksum** and the `0`–`7` **frame number** **computed** (never accepted-as-given or
|
|
672
719
|
faked; emitted uppercase), numbered continuously across the stream (start `1`, roll over `7 → 0`),
|
|
673
720
|
and every record over **240** text bytes **split** `ETB…ETX` (the seven control bytes never counted).
|
|
674
|
-
`serializeFramedAstm(msg | records)` composes both emit layers at the edge
|
|
721
|
+
`serializeFramedAstm(msg | records)` composes both emit layers at the edge: the mirror of
|
|
675
722
|
`parseFramedAstm`.
|
|
676
|
-
- **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF
|
|
677
|
-
no ASTM escape can encode
|
|
723
|
+
- **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF`, which
|
|
724
|
+
no ASTM escape can encode, is refused with an `AstmSerializeError` (`ASTM_EMIT_UNENCODABLE_VALUE`)
|
|
678
725
|
rather than emitted into a corrupted wire; an empty record or empty record list is an
|
|
679
726
|
`AstmFrameEncodeError` (`ASTM_FRAME_EMPTY_RECORD`), never an empty frame.
|
|
680
727
|
- **Round-trip proven.** The shared archetype `roundTripProperty` is now **live** (serialize is the
|
|
@@ -688,23 +735,23 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
688
735
|
`MessageInput`, `HeaderInput`, `PatientInput`, `PatientNameInput`, `OrderInput`, `ResultInput`,
|
|
689
736
|
`CommentInput`, `QueryInput`, `VerbatimInput`), `composeAstmFrames`, `AstmFrameEncodeError`,
|
|
690
737
|
`ComposeFramesOptions`, `serializeFramedAstm`.
|
|
691
|
-
- **Vendor profile system
|
|
738
|
+
- **Vendor profile system, engine + registry + quirk tolerance + definition-time safety gate
|
|
692
739
|
(ASTM-8, roadmap Phase 8).** `src/profiles/` mirrors the sibling `@cosyte/hl7` `defineProfile` /
|
|
693
740
|
`@cosyte/ccda` `defineCcdaProfile` shape: `name` / `lineage` / `describe()` / `extends`-merge, a
|
|
694
741
|
provenance-backed built-in registry, a runtime tolerance transform, and a definition-time safety gate.
|
|
695
742
|
- `defineAstmProfile(opts)` builds a frozen, immutable profile declaring the **non-safety-critical**
|
|
696
743
|
warning codes a class of streams is expected to trip (each with a grounded `rationale`), plus an
|
|
697
|
-
optional `transport` override (`"framed"`/`"raw"`)
|
|
744
|
+
optional `transport` override (`"framed"`/`"raw"`): the raw-vs-framed-TCP knob a consumer feeds to
|
|
698
745
|
`detectFraming(bytes, { override })` for a stream whose leading byte would auto-detect the wrong way.
|
|
699
746
|
- **A profile never alters an extracted value.** The transform (`applyAstmProfileToWarnings`, run last
|
|
700
747
|
in `parseAstmRecords`) only ever re-badges a warning it _expects_ to the new `PROFILE_QUIRK_APPLIED`
|
|
701
|
-
code (flagged `expected: true`, carrying the original `toleratedCode` and position)
|
|
748
|
+
code (flagged `expected: true`, carrying the original `toleratedCode` and position), Postel's Law
|
|
702
749
|
with a receipt: nothing is dropped, and a spec-clean message parses byte-identically with or without
|
|
703
750
|
a profile.
|
|
704
751
|
- **The safety gate is default-deny and total.** Only four benign, value-preserving record codes are
|
|
705
752
|
tolerable (`ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`, `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
|
|
706
|
-
`ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries
|
|
707
|
-
frame
|
|
753
|
+
`ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries (record,
|
|
754
|
+
frame `ASTM_FRAME_*`, and LTP `ASTM_LTP_*`) is safety-critical and refused at definition time**
|
|
708
755
|
with an `AstmProfileDefinitionError`. A profile therefore can never make a bad checksum "ok," a
|
|
709
756
|
cancelled result read "final," or quiet a wrong value / flag / status / range / units / patient or
|
|
710
757
|
comment context / message-kind ambiguity. Any warning code added in a future phase is
|
|
@@ -712,7 +759,7 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
712
759
|
- `parseAstmRecords(raw, { profile })` accepts an explicit profile; `{ profile: null }` opts out of
|
|
713
760
|
the process-scoped default (`setDefaultAstmProfile`); an `expected` quirk does **not** escalate in
|
|
714
761
|
`strict` mode. `AstmMessage` gains an additive `profile?: { name, lineage }` attribution.
|
|
715
|
-
- **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus
|
|
762
|
+
- **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus`, a
|
|
716
763
|
**non-vendor**, evidence-backed profile grounded firsthand in the redistributable OSS reference
|
|
717
764
|
corpus (`kxepal/python-astm` `codec.py` (BSD) + `senaite.astm`, which split on raw delimiters and
|
|
718
765
|
never un-escape `&F&`/`&S&`/`&R&`/`&E&`), tolerating only the resulting non-standard-escape
|
|
@@ -725,49 +772,49 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
725
772
|
`resolveProfileTransport`, `profileQuirkApplied`, `SAFETY_CRITICAL_CODES`, `TOLERABLE_CODES`,
|
|
726
773
|
`ALL_ASTM_WARNING_CODES`, `isSafetyCriticalCode`, and the `AstmProfile`, `DefineAstmProfileOptions`,
|
|
727
774
|
`AstmQuirkTolerance`, `AstmQuirkMatch`, `AstmProfileProvenance`, `AnyAstmWarningCode` types.
|
|
728
|
-
- **LIVD-aware LOINC recognition
|
|
775
|
+
- **LIVD-aware LOINC recognition: bring-your-own catalog, zero bundled terminology data (ASTM-9,
|
|
729
776
|
roadmap Phase 9).** The `src/terminology/` layer maps an analyzer's local test code (the Universal
|
|
730
777
|
Test ID's vendor/local code on `R`/`O` records) to a standard LOINC via a **consumer-supplied** IICC
|
|
731
|
-
LIVD ("LOINC to Vendor IVD") catalog
|
|
778
|
+
LIVD ("LOINC to Vendor IVD") catalog: **additive, advisory, and never a guessed LOINC** (a wrong
|
|
732
779
|
LOINC mis-identifies a test).
|
|
733
780
|
- `defineLivdCatalog(entries)` builds an immutable, frozen catalog indexed by the **Vendor Analyte
|
|
734
781
|
Code** (the vendor transmission code the instrument sends), grounded firsthand on the IICC LIVD
|
|
735
782
|
digital format / HL7 LIVD IG; `catalog.lookup(code)` returns `mapped` (one LOINC), `unmapped` (a
|
|
736
|
-
miss), or `ambiguous` (a code matching more than one distinct LOINC
|
|
783
|
+
miss), or `ambiguous` (a code matching more than one distinct LOINC, surfaced, **never resolved**).
|
|
737
784
|
- `applyLivd(msg, catalog)` produces a **separate** layer of per-`R`/`O` `LivdAnnotation`s and never
|
|
738
785
|
mutates, alters, or drops the raw reported code/value; a catalog hit is labeled `derived: true`
|
|
739
786
|
(`source: "livd"`), an inline LOINC already on the wire is surfaced `source: "wire"` (never
|
|
740
787
|
overwritten by the catalog), and a miss/conflict is `unmapped`/`ambiguous` with a **value-free**
|
|
741
|
-
warning
|
|
788
|
+
warning, a LOINC is **never** fabricated. `lookupLivdForRecord(record, catalog)` annotates one
|
|
742
789
|
record.
|
|
743
|
-
- **No LOINC / SNOMED / LIVD data is bundled** (roadmap §5). Firsthand: LOINC is © Regenstrief
|
|
790
|
+
- **No LOINC / SNOMED / LIVD data is bundled** (roadmap §5). Firsthand: LOINC is © Regenstrief,
|
|
744
791
|
redistributable only _with its attribution notice_, not public-domain; and the public CDC LIVD file
|
|
745
792
|
is a **SARS-CoV-2-specific** publication that also carries separately-licensed SNOMED CT, not a
|
|
746
793
|
general-analyte, public-domain catalog. The package stays a structural recognizer, not a dictionary:
|
|
747
794
|
the consumer supplies the LIVD data (and owns its license obligations).
|
|
748
|
-
- New `ASTM_LIVD_*` warning registry (`ASTM_LIVD_UNMAPPED_CODE`, `ASTM_LIVD_AMBIGUOUS_MAPPING`)
|
|
795
|
+
- New `ASTM_LIVD_*` warning registry (`ASTM_LIVD_UNMAPPED_CODE`, `ASTM_LIVD_AMBIGUOUS_MAPPING`): a
|
|
749
796
|
fourth, self-contained registry, deliberately outside the profile safety gate's universe (a LIVD
|
|
750
797
|
non-mapping is a post-parse advisory, not a parse-time deviation a profile could tolerate). New
|
|
751
798
|
exports: `defineLivdCatalog`, `applyLivd`, `lookupLivdForRecord`, `LIVD_WARNING_CODES`,
|
|
752
799
|
`livdUnmappedCode`, `livdAmbiguousMapping`, and the `LivdCatalog`, `LivdEntry`, `LivdLookup`,
|
|
753
800
|
`LivdAnnotation`, `LivdMapping`, `LivdResult`, `AstmLivdWarning`, `LivdWarningCode` types.
|
|
754
|
-
- **Release hardening (ASTM-10, roadmap Phase 10
|
|
801
|
+
- **Release hardening (ASTM-10, roadmap Phase 10, the final phase).** Publish-readiness for the now
|
|
755
802
|
feature-complete parser: coverage, fuzz, firsthand differential testing, the full docs spine, and a
|
|
756
803
|
proven release shape. No new runtime API.
|
|
757
804
|
- **Differential conformance vs [python-astm][pa]** (BSD-3-Clause reference codec, commit
|
|
758
805
|
`4170ce0c`), grounded **firsthand** in `test/differential/`: outputs captured once from the
|
|
759
806
|
reference (`generate-reference-vectors.py` → `reference-vectors.json`; **no reference code
|
|
760
|
-
vendored**), then asserted against `@cosyte/astm` on three shared paths
|
|
807
|
+
vendored**), then asserted against `@cosyte/astm` on three shared paths, the **modulo-256
|
|
761
808
|
checksum**, the **record field/component split** (escape-free, non-header), and a
|
|
762
809
|
**cross-implementation frame decode** (python encodes + splits → our decoder verifies every
|
|
763
810
|
checksum and reassembles the exact record bytes). The **deliberate divergences** are asserted on
|
|
764
811
|
purpose: we un-escape `&F&`/`&S&`/`&R&`/`&E&` (python leaves them literal), we validate the frame
|
|
765
812
|
checksum (python does not verify on decode), and we classify the `Q` host-query (python has no
|
|
766
|
-
model). CI needs no Python
|
|
767
|
-
- **Per-directory ≥ 90 coverage gating extended to the whole `src/` surface
|
|
813
|
+
model). CI needs no Python: only the captured JSON.
|
|
814
|
+
- **Per-directory ≥ 90 coverage gating extended to the whole `src/` surface**: `frames`, `ltp`,
|
|
768
815
|
and `terminology` now gate per-dir alongside `common`/`records`/`profiles` (on top of the global
|
|
769
816
|
gate), so the release bar holds directory by directory, not just in aggregate.
|
|
770
|
-
- **Record-tokenizer fuzz** (`test/property/records-fuzz.property.test.ts`)
|
|
817
|
+
- **Record-tokenizer fuzz** (`test/property/records-fuzz.property.test.ts`), the companion to the
|
|
771
818
|
frame-codec fuzz: arbitrary / truncated / delimiter- and escape-laden input into
|
|
772
819
|
`parseAstmRecords` never crashes, hangs, or OOMs; lenient mode only ever throws a sanctioned
|
|
773
820
|
Tier-3 fatal, strict only `AstmStrictError`, and every warning carries a registered code. Both
|
|
@@ -776,9 +823,9 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
776
823
|
- **Publish dry-run proven release-shaped:** `attw` all-green (per-condition ESM/CJS types), a new
|
|
777
824
|
`smoke` gate (`scripts/smoke.mjs`) that imports the **built** ESM and requires the **built** CJS
|
|
778
825
|
entry and parses a result through each (now wired into `verify.sh`), and an `npm publish
|
|
779
|
-
--dry-run` pack inspection (10 files
|
|
826
|
+
--dry-run` pack inspection (10 files, `dist/` + `README`/`LICENSE`/`CHANGELOG`/`package.json`,
|
|
780
827
|
no `src` or tests). Zero runtime dependencies; MIT.
|
|
781
|
-
- **Full Diátaxis docs spine + honesty docs.** New `docs-content/limitations.md` (**What it does
|
|
828
|
+
- **Full Diátaxis docs spine + honesty docs.** New `docs-content/limitations.md` (**What it does,
|
|
782
829
|
and does not do**: no live I/O, units are verbatim free text not UCUM, no bundled terminology
|
|
783
830
|
dictionary, `M`/`S` verbatim, the archived-standard status, and the MIT-vs-CLSI license posture)
|
|
784
831
|
and `docs-content/architecture.md` (the two independent layers and their payload boundary); the
|
|
@@ -796,12 +843,12 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
796
843
|
|
|
797
844
|
### Deferred (later phases)
|
|
798
845
|
|
|
799
|
-
- **Named per-vendor profiles** (cobas / Sysmex / ADVIA / Mindray / Snibe) stay `REAL-CORPUS`-gated
|
|
846
|
+
- **Named per-vendor profiles** (cobas / Sysmex / ADVIA / Mindray / Snibe) stay `REAL-CORPUS`-gated:
|
|
800
847
|
the Phase-8 engine supports them (tolerate + transport override), but no public vendor-attributed
|
|
801
848
|
quirk document grounds a named one. **No bundled terminology
|
|
802
|
-
dictionary
|
|
849
|
+
dictionary**: LIVD-aware LOINC recognition is bring-your-own by design (Phase 9); the package ships
|
|
803
850
|
no LOINC / SNOMED / LIVD data and mapping quality is the consumer's catalog. The LTP reducer remains
|
|
804
|
-
a pure state machine
|
|
851
|
+
a pure state machine, no live I/O: wiring it to a real `SerialPort`/`net.Socket` (and the
|
|
805
852
|
interactive contention/timeout/retransmit **timing**) is a thin consumer adapter, and the standard's
|
|
806
853
|
exact numeric timeouts / retry counts are deferred (we model transitions, not timers).
|
|
807
854
|
|