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