@cosyte/astm 0.0.3 → 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 +503 -67
- package/README.md +53 -21
- package/dist/index.cjs +301 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +883 -434
- package/dist/index.d.ts +883 -434
- package/dist/index.mjs +296 -69
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,444 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`messages()`: read a stream as the sequence of messages it actually is**
|
|
15
|
+
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`). A parsed model has always been a whole record
|
|
16
|
+
**stream**, and a stream may carry several messages back to back: a message runs from an `H`
|
|
17
|
+
header to its `L` terminator. `messages(msg)` splits the stream into them and returns a
|
|
18
|
+
readonly array of `AstmStreamMessage`, each carrying only its own records:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
for (const m of messages(parseAstmRecords(raw))) {
|
|
22
|
+
m.patient; // the P for THIS message
|
|
23
|
+
m.results; // the Rs for THIS message
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each entry carries `index`, `header`, `delimiters`, `records`, `patient`, `patients`,
|
|
28
|
+
`results`, `orders`, `comments`, and `queries`. It never throws, and a single-message stream
|
|
29
|
+
yields exactly one entry whose `records` are the whole stream, so it is safe to reach for
|
|
30
|
+
unconditionally.
|
|
31
|
+
|
|
32
|
+
**A message opens at every `H`** and runs to the record before the next one. That is
|
|
33
|
+
deliberately the _same_ boundary the parser already uses to scope delimiters, so message
|
|
34
|
+
boundaries and delimiter scope cannot disagree; `m.delimiters` is the set that message's
|
|
35
|
+
records were actually read with. The `L` terminator closes a message but opens no scope, so
|
|
36
|
+
records between an `L` and the next `H` stay with the message their header opened rather than
|
|
37
|
+
being dropped. Grouping is **total**: every record lands in exactly one message, none
|
|
38
|
+
duplicated and none lost, which is asserted directly.
|
|
39
|
+
|
|
40
|
+
**Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: CLSI LIS02-A2 §2 is
|
|
41
|
+
definitional about the unit itself, bounding a message by the `H` record at one end and the `L`
|
|
42
|
+
record at the other. Read directly in CLSI's own free sample, and stated here in our own words
|
|
43
|
+
on purpose: we may read and cite the standard but never reproduce its prose, and this file ships
|
|
44
|
+
inside the npm tarball. That clause is the whole of what is claimed from the standard here, and
|
|
45
|
+
it does **not** settle which record _opens_ a new scope partway through a stream. So treating a
|
|
46
|
+
second `H` as the start of the next message is recorded as a **reasoned choice, not a citation**:
|
|
47
|
+
no clause number is claimed for it, and we do not assert the standard is silent either. It is
|
|
48
|
+
chosen to agree with the delimiter scoping this parser already enforces, and because it drops no
|
|
49
|
+
record. **The OSS corpus was checked rather than dismissed**, and unlike for delimiter questions
|
|
50
|
+
it is informative here, but only negatively: neither commonly-cited reference implementation
|
|
51
|
+
models the message unit at all. One dispatches records to per-type callbacks one at a time with
|
|
52
|
+
no carried state, leaving the association to the integrator; the other merges every message of a
|
|
53
|
+
transport session into a single envelope of flat per-record-type buckets, from which the
|
|
54
|
+
association cannot be recovered at all. A flat model is the incumbent shape, and this entry is
|
|
55
|
+
what it costs.
|
|
56
|
+
|
|
57
|
+
**`patient` is answered only when the message determines one.** It is the single `P` when the
|
|
58
|
+
message carries exactly one, and `undefined` when it carries none **or** several;
|
|
59
|
+
`patients.length` distinguishes those two, and there is no third meaning. A message carrying
|
|
60
|
+
several patients is not answered with the first one, because that is the guess this release
|
|
61
|
+
exists to remove. Within-message record hierarchy (which `P` a given `R` files against when a
|
|
62
|
+
message carries several) is **not** modeled: the clauses that would ground it (the
|
|
63
|
+
message-level structure diagram, and the `P` sequence-number rule) are withheld from the free
|
|
64
|
+
sample and paywalled, so the layer declines rather than inventing a rule. Multi-patient
|
|
65
|
+
messages are real, at least one openly-published vendor interface grammar makes the patient
|
|
66
|
+
group repeatable on the download direction, so this is a deferral with a known shape, not a
|
|
67
|
+
claim that the case does not arise, and not a claim that the standard is silent about it.
|
|
68
|
+
|
|
69
|
+
- **`pnpm check:no-internal-refs` + a `no-internal-refs` CI job** (`PUBLIC-SURFACE-HYGIENE`,
|
|
70
|
+
founder directive 2026-07-27). Internal only; no change to the published package surface. `astm` was
|
|
71
|
+
the last parser with no gate on this rule at all, which is why the class had regrown. The script is
|
|
72
|
+
ported from `ncpdp`'s copy (which carries three fixes `hl7`'s reference lacks: the `src/`
|
|
73
|
+
string-literal pass, the plural `phases?` stem, and `/` in the ADR separator class) plus `cli`'s
|
|
74
|
+
seventh rule, the prose roadmap citation, which neither `hl7` nor `ncpdp` has and which found the
|
|
75
|
+
second-largest class here. Four surfaces are scanned against seven rules: public markdown +
|
|
76
|
+
the npm `description`/`keywords`, `src/` doc comments, and `src/` string literals. The two prose
|
|
77
|
+
surfaces are scanned line by line **and** paragraph-reflowed, so a violation that straddles a wrap
|
|
78
|
+
is still caught; the npm metadata and the string-literal pass are line-scanned only. A multi-line
|
|
79
|
+
template literal is not scanned at all, split or not, because the extractor needs both delimiters
|
|
80
|
+
on one line. `//` comments, `CHANGELOG.md` and `.changeset/` are deliberately out of scope:
|
|
81
|
+
the convention names them as where identifiers belong.
|
|
82
|
+
|
|
83
|
+
Re-derived for this repo rather than copied: rule 2 now excludes E1381/CLSI-LIS01's own
|
|
84
|
+
**protocol phases** (`protocol`, `three-`, `establishment`, `transfer`, `termination`, `neutral`,
|
|
85
|
+
`idle`), because "phase" is the standard's word for the state of the line and `LtpState.phase` is an
|
|
86
|
+
exported field. The cost is disclosed rather than papered over: because a lookbehind exempts the
|
|
87
|
+
whole match, one of our own phases sitting behind one of those words is not caught (`the transfer
|
|
88
|
+
phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was written, measured and
|
|
89
|
+
removed, because the justification did not survive the corpus: the digit is usually the next
|
|
90
|
+
clause's first token, so it red on `In the transfer phase 240 bytes is the maximum frame text` and
|
|
91
|
+
on `a three-phase 400 V supply`, which are reference prose. Where a rule cannot be guarded, it is
|
|
92
|
+
cut rather than hardened. And the `SPEC-7` /
|
|
93
|
+
`ACC-42` synthetic example ids in every runnable sample are
|
|
94
|
+
`WORD-N` exactly, so a shape-keyed rule would rewrite the sample a consumer copy-pastes. Both are
|
|
95
|
+
asserted in negative self-tests, alongside `ASTM-E1394`, `CLSI-LIS01`, `LIS02-A2`, `POCT1-A`,
|
|
96
|
+
`E1394-97` and `ICD-10-CM`. A further negative self-test pins **bare `§` section citations as
|
|
97
|
+
deliberately unruled**, so closing that hole has to be a decision rather than a widening that lands
|
|
98
|
+
by accident.
|
|
99
|
+
|
|
100
|
+
The script also **refuses to run under a `grep` that cannot see its subject.** A scanner-visibility
|
|
101
|
+
probe seeds a violation into a file containing a NUL byte and requires it to come back as either a
|
|
102
|
+
hit or a stderr diagnostic; silence at exit 0 is a hard refusal, because a tool that skips a file
|
|
103
|
+
produces output identical to a tool that read it and found it clean. This is not theoretical: the
|
|
104
|
+
development container interposes a `grep` with `-I` forced, which drops such input without a word.
|
|
105
|
+
An interposed shell function is also `unset` rather than assumed absent. Both directions are
|
|
106
|
+
proven: an `-I`-forcing `grep` ahead of it on `PATH` makes the gate refuse, and an exported `grep`
|
|
107
|
+
function is neutralised. The companion `-G` (basic-regex) hazard needs no separate guard: every
|
|
108
|
+
positive self-test uses alternation, so a BRE-forced `grep` fails them and refuses.
|
|
109
|
+
|
|
110
|
+
Known gaps, stated rather than discovered later: a bare `P\d+` label (`(P7)`) is not caught, because
|
|
111
|
+
a general `P\d+` rule has corrupted ICD-10-CM codes in a sibling; `phase` at the end of a clause is
|
|
112
|
+
not caught; bare `§` section citations are deliberately unruled; and `dist/` is build output this
|
|
113
|
+
script cannot read, so it gates dist's source, not dist. Fourteen `P\d+` lines and the falsehoods
|
|
114
|
+
below were found by hand, not by a rule.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
|
|
118
|
+
- **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
|
+
|
|
151
|
+
- **BREAKING: `patient()`, `results()`, `orders()`, `comments()`, and `query()` now throw on a
|
|
152
|
+
stream they cannot answer for, instead of answering across patients**
|
|
153
|
+
(`ASTM-PATIENT-RESULT-MISATTRIBUTION`; shipped `0.0.1` through `0.0.3`).
|
|
154
|
+
|
|
155
|
+
**The defect.** These accessors read the whole stream. On a stream carrying more than one
|
|
156
|
+
message, `patient()` answered with the **first** `P` in the stream while `results()` answered
|
|
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
|
|
159
|
+
and no unusual delimiters: an ordinary two-message stream in one canonical set reproduced it,
|
|
160
|
+
with **zero** warnings, and `strict` mode raised no objection either. On an analyzer-to-LIS
|
|
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.
|
|
163
|
+
|
|
164
|
+
**The change.** Those five accessors now throw `AstmAmbiguousStreamError` with code
|
|
165
|
+
`ASTM_AMBIGUOUS_MULTI_MESSAGE` when the stream carries more than one message. `patient()` also
|
|
166
|
+
throws `ASTM_AMBIGUOUS_MULTI_PATIENT` when the single message it is reading carries more than
|
|
167
|
+
one `P`, which is the same harm one level down: "the first `P`" is a guess about whose result
|
|
168
|
+
it is. The error carries a stable code, a value-free position, and the two counts; never an
|
|
169
|
+
identifier and never a value.
|
|
170
|
+
|
|
171
|
+
**Why the break is the fix, not a cost of it.** The callers this breaks are exactly the
|
|
172
|
+
population that is being silently corrupted today. A caller who now gets an exception is
|
|
173
|
+
strictly better off than one who quietly received another patient's results: the failure is
|
|
174
|
+
loud, immediate, positioned, and correctable, where before it was invisible at every layer.
|
|
175
|
+
There is no version of this where the old behaviour is safe to keep, and no warning strong
|
|
176
|
+
enough to substitute for refusing to answer, because the wrong answer was already being used.
|
|
177
|
+
|
|
178
|
+
**Which callers are affected, stated exactly.** A stream that is one message carrying at most one
|
|
179
|
+
`P` is **unchanged**, and so is a result-only message with no `P` at all: `patient()` still answers
|
|
180
|
+
`undefined` there, which is an ordinary shape and not an error. But `ASTM_AMBIGUOUS_MULTI_PATIENT`
|
|
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
|
|
183
|
+
and is not claimed. That second break is the same wrong-patient guess as the first, one level down,
|
|
184
|
+
and it is disclosed on the README and in the docs for the same reason the first one is.
|
|
185
|
+
`commentsFor()` is unchanged and works on any stream, single- or multi-message, because the parent
|
|
186
|
+
record it is handed already names the message.
|
|
187
|
+
|
|
188
|
+
**Migrating** is mechanical: replace `results(msg)` and `patient(msg)` with a walk over
|
|
189
|
+
`messages(msg)`, reading each message's own `patient` and `results`. Where a caller genuinely
|
|
190
|
+
wants every result in a stream regardless of who they belong to, that is
|
|
191
|
+
`messages(msg).flatMap((m) => m.results)`: written out, so it is a choice rather than a
|
|
192
|
+
default.
|
|
193
|
+
|
|
12
194
|
### Fixed
|
|
13
195
|
|
|
196
|
+
- **Emit no longer writes a stream it cannot read back** (`ASTM-EMIT-RESIDUALS`; both gaps shipped
|
|
197
|
+
`0.0.1` through `0.0.3`, `PRE-EXISTING`, neither a regression). Two residual emit-side holes, both
|
|
198
|
+
recorded and deliberately deferred by `ASTM-MIXED-DELIMITER-EMIT` (#21) and again by
|
|
199
|
+
`ASTM-SECOND-HEADER-COLLAPSE` (#22) because each turned on a question those slices did not answer.
|
|
200
|
+
Both were reproduced by **executing** the shipped code before anything was changed, and re-verified
|
|
201
|
+
the same way after; neither was half-fixed by the two earlier slices.
|
|
202
|
+
|
|
203
|
+
**1. A delimiter declaration longer than three characters lost its extra bytes.** Three characters
|
|
204
|
+
of a header's declaration carry a role (repeat, component, escape, by position) and the reader
|
|
205
|
+
ignores any beyond them rather than refusing the stream. Emit regenerated the declaration from the
|
|
206
|
+
three roles alone, so a header that arrived as `H|\^&#` went back out as `H|\^&`, with no warning
|
|
207
|
+
and no way for a caller to notice. Measured before the fix: `serializeAstmRecords(msg, msg.delimiters)`
|
|
208
|
+
on `H|\^&#|||SENDER^SYS|||||||P|1` returned a header short of its `#` and a round-trip that was not
|
|
209
|
+
byte-exact.
|
|
210
|
+
|
|
211
|
+
The surplus is now carried through from the modeled declaration field. Of the three available
|
|
212
|
+
dispositions (preserve, refuse, report), **preserve** was chosen. Refusing would reject a stream
|
|
213
|
+
the parser reads without complaint, on a published package, over bytes it has already decided are
|
|
214
|
+
inert. Reporting is not available at all: emit returns a bare string and has no warning channel, so
|
|
215
|
+
a warning could only be ignored while the truncated stream still shipped, which is the same reason
|
|
216
|
+
#21 chose re-encoding. Preserving costs nothing and is the only one of the three that makes the
|
|
217
|
+
round-trip byte-exact. What the surplus _means_ remains unresolved (the clauses that would settle
|
|
218
|
+
it are not in the freely published material and were not read, so no clause is cited here in either
|
|
219
|
+
direction) but carrying bytes through unread is a strictly smaller claim than deleting them, and it
|
|
220
|
+
stays coherent with a reader that now scopes delimiters forward from every header: the re-read
|
|
221
|
+
declaration resolves to the same four roles either way. This lands on the **default** canonical
|
|
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
|
|
224
|
+
those bytes will find it no longer does.
|
|
225
|
+
|
|
226
|
+
The surplus is dropped in exactly the two cases where it could not be read back as surplus: the
|
|
227
|
+
header is being transcoded into a different delimiter set, so the surplus belonged to the
|
|
228
|
+
declaration being replaced; or the surplus is not inert on the wire, meaning it contains the field
|
|
229
|
+
separator or **any control character**. The control rule is deliberately wider than the record
|
|
230
|
+
layer alone needs. A `CR`/`LF` would end the record and shift every data field along, but this text
|
|
231
|
+
also reaches the **frame** layer through `serializeFramedAstm`, where `STX`, `ETX` and `ETB` are
|
|
232
|
+
structural: a surplus carrying one of those truncated the frame body, and re-reading the framed
|
|
233
|
+
stream then dropped the **entire header record** (its sender, its receiver, its control ID)
|
|
234
|
+
behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
|
|
235
|
+
to reserve, and re-derive that list whenever a layer is added, no control character is carried at
|
|
236
|
+
all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
|
|
237
|
+
the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
|
|
238
|
+
so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
|
|
239
|
+
typed error or an unknown-record-type warning, never silently; and refusing a control character in
|
|
240
|
+
a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
|
|
241
|
+
there. Those are
|
|
242
|
+
structural losses; dropping inert bytes is not.
|
|
243
|
+
|
|
244
|
+
**2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
|
|
245
|
+
three siblings took `d` on trust. Measured before the fix, across six malformed sets: a
|
|
246
|
+
multi-character field separator, an empty escape character, a `field`/`escape` collision and a `CR`
|
|
247
|
+
separator each emitted a stream `parseAstmRecords` then **threw** on; a `repeat`/`component`
|
|
248
|
+
collision and a `component`/`escape` collision each emitted one it **re-read with a different field
|
|
249
|
+
tree and zero warnings**. An empty escape additionally garbled the values themselves: `28.6` went
|
|
250
|
+
out as `2E8E.E6`. On the analyzer-to-LIS path a field tree that changes under a re-read is a lost
|
|
251
|
+
result or a lost specimen identifier, and the caller had no signal either way.
|
|
252
|
+
|
|
253
|
+
`d` is now checked before any bytes are written, on all four public emit entry points
|
|
254
|
+
(`serializeAstmRecords`, `serializeAstmRecord`, `serializeField`, `encodeComponent`): each separator
|
|
255
|
+
exactly one character, none a `CR`/`LF`, no two the same character. A failing set is an
|
|
256
|
+
`AstmSerializeError` with the new code `ASTM_EMIT_INVALID_DELIMITERS`: including a set that omits a
|
|
257
|
+
member or holds a non-string, which the types forbid but a JavaScript caller can still pass, and
|
|
258
|
+
which previously surfaced as a raw `TypeError` from inside the serializer.
|
|
259
|
+
|
|
260
|
+
**The three rules are necessary, not sufficient, and the docs say so rather than implying a
|
|
261
|
+
guarantee.** A set can satisfy all three and still emit a stream that reads back wrong: a separator
|
|
262
|
+
equal to a record's type letter (`field` of `R`) makes the type letter itself get escaped away, and
|
|
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
|
|
265
|
+
here, because the rule that would catch it has to be derived rather than guessed. **A typed error rather than a
|
|
266
|
+
warning, and the house rule from #21 is why**: with no warning channel on a `string` return, refusing
|
|
267
|
+
at the call is the only disposition that reaches the caller at all. The counter-argument was weighed
|
|
268
|
+
and is real: `@cosyte/astm` is published, and refusing rejects input previously accepted. It is
|
|
269
|
+
accepted deliberately, because the input turned away is exactly the input that was being corrupted,
|
|
270
|
+
and because the three rules are not stylistic: each names a case where the emitted bytes provably do
|
|
271
|
+
not read back as the records that produced them. The narrowing has one consequence worth stating
|
|
272
|
+
plainly: the **reader** tolerates a header declaring, say, the same character for `repeat` and
|
|
273
|
+
`component`, so `serializeAstmRecords(msg, msg.delimiters)` on such a message now throws where it
|
|
274
|
+
previously returned bytes that read back wrong. The canonical default is untouched, and so is every
|
|
275
|
+
well-formed set. The error names the role at fault and never echoes the offending characters, since
|
|
276
|
+
a value failing the one-character rule can be arbitrary caller text and the message reaches logs.
|
|
277
|
+
|
|
278
|
+
The invariant both halves are held against is the one #21 and #22 established: **a round-trip never
|
|
279
|
+
silently loses a field, in either direction.** The property is asserted by round-tripping through the
|
|
280
|
+
real parser, not by inspecting the serializer.
|
|
281
|
+
|
|
282
|
+
`AstmSerializeError.code` widens from a single string literal to a two-member union, exported as the
|
|
283
|
+
new `AstmSerializeErrorCode` type. `ASTM_EMIT_UNENCODABLE_VALUE` is unchanged.
|
|
284
|
+
|
|
285
|
+
- **`parseAstmRecords` now reads the delimiters from every `H` record, not only the first, so a
|
|
286
|
+
stream carrying more than one message no longer silently loses fields** (`ASTM-SECOND-HEADER-COLLAPSE`;
|
|
287
|
+
shipped `0.0.1` through `0.0.3`, `PRE-EXISTING`, not a regression). This is the same silent field
|
|
288
|
+
collapse as `ASTM-MIXED-DELIMITER-EMIT` one layer over, on the **parse** side, which is why #21 did
|
|
289
|
+
not narrow it: #21 fixed what we write, and a stream arriving with two headers is mis-read on the
|
|
290
|
+
way **in**, before any of that runs.
|
|
291
|
+
|
|
292
|
+
A message runs `H` … `L`, so one stream may carry several back to back and each header declares the
|
|
293
|
+
delimiters for the records that follow it. Delimiters were read only from `records[0]` and applied
|
|
294
|
+
to the whole stream, so every record after a redeclaring header **collapsed into a single field,
|
|
295
|
+
with zero warnings**, and `{ strict: true }` accepted it. Measured by execution on `dc09a3a` before
|
|
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
|
|
298
|
+
`resultStatus` **all absent** and `status` reading `unspecified`. On the analyzer↔LIS path that is a
|
|
299
|
+
lost result with no signal to the caller.
|
|
300
|
+
|
|
301
|
+
**The decision, and its grounding.** Delimiters are now scoped **forward from each header**: a later
|
|
302
|
+
`H` governs itself and the records after it, until the next `H`. The asymmetry with the emit bug is
|
|
303
|
+
real: on emit the library was corrupting its own output, so re-encoding was unambiguously safe,
|
|
304
|
+
whereas on parse the bytes are the sender's and a second `H` is legal. What settles it is that
|
|
305
|
+
forward scoping is the **only** reading that never reinterprets bytes already consumed: records
|
|
306
|
+
before a redeclaration keep the set they were read with, so the two sets never disagree about the
|
|
307
|
+
same bytes. "First header wins forever" is the collapse itself; "last header wins retroactively"
|
|
308
|
+
would require re-reading records already delivered.
|
|
309
|
+
|
|
310
|
+
**Evidence, labelled.** The message unit is `H` … `L`, _verified primary_: LIS02-A2 §2 makes the
|
|
311
|
+
message a **bounded unit**, one record type opening it and another closing it, which is why a second
|
|
312
|
+
`H` begins a new message rather than corrupting the current one. Read directly in CLSI's own free
|
|
313
|
+
sample of the document. (Stated in our own words on purpose. We may read and cite the standard but
|
|
314
|
+
never reproduce its prose, and `CHANGELOG.md` ships inside the npm tarball: an earlier draft of
|
|
315
|
+
this entry quoted the clause verbatim and was caught in review.) That a header may follow a
|
|
316
|
+
terminator to start another message: _verified secondary_, two independent restatements of the
|
|
317
|
+
terminator clause (Roche cobas b 121 ASTM interface description; Genaux, _Introduction to ASTM
|
|
318
|
+
Message Formats_, 2024).
|
|
319
|
+
That a header's delimiters govern "the message": _verified secondary_ (Genaux; Stratford Software
|
|
320
|
+
interface spec), which leans per-message but **does not address a redeclaration that changes the
|
|
321
|
+
set**. **We could not reach the normative text on that specific question**: LIS02-A2 §5.4
|
|
322
|
+
(Delimiters) and §6.2 (Delimiter Definition) are precisely the clauses withheld from the free
|
|
323
|
+
sample, and E1394-97 and LIS01-A2 stayed paywalled. So the forward-scoping rule is recorded as a
|
|
324
|
+
**reasoned choice, not a citation**: no clause number is claimed for it, and we do not assert the
|
|
325
|
+
standard is silent either, because that would need the same evidence.
|
|
326
|
+
|
|
327
|
+
The OSS reference corpus cannot ground this one and is reported as a negative result rather than
|
|
328
|
+
dressed up: `kxepal/python-astm` and `senaite.astm` both hardcode `|\^&` as module constants, never
|
|
329
|
+
read the header's declaration at all, and neither tracks `H` … `L` boundaries, so differential
|
|
330
|
+
testing against them is uninformative here.
|
|
331
|
+
|
|
332
|
+
**Behaviour.** A redeclaration that **changes** the set is honored and raises
|
|
333
|
+
`ASTM_RECORD_DELIMITERS_REDECLARED`. A header that merely **restates** the set in force is a no-op
|
|
334
|
+
and raises nothing: several messages in one delimiter set is an ordinary shape, and warning on it
|
|
335
|
+
would be noise. A later header whose declaration is **unusable** (too short, or a field separator
|
|
336
|
+
that also names another role) keeps the set already in force and raises
|
|
337
|
+
`ASTM_RECORD_UNREADABLE_REDECLARATION`; a set is never guessed and no record is dropped. The same
|
|
338
|
+
condition on the _first_ header remains the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal: there is no
|
|
339
|
+
earlier set to fall back to, and that is pinned by a test so it cannot be softened by accident.
|
|
340
|
+
|
|
341
|
+
**Surface.** Two new stable warning codes (`ASTM_RECORD_DELIMITERS_REDECLARED`,
|
|
342
|
+
`ASTM_RECORD_UNREADABLE_REDECLARATION`) with `delimitersRedeclared` / `unreadableRedeclaration`
|
|
343
|
+
factories, both **safety-critical** by the profile gate's default-deny rule, so no profile can quiet
|
|
344
|
+
them (asserted). `HeaderRecord.delimiters` now reports the set **that** header put into force rather
|
|
345
|
+
than always the first header's; `AstmMessage.delimiters` is unchanged and stays the first header's.
|
|
346
|
+
The sites that _stated_ the old single-header rule were swept, the `parseAstmRecords` JSDoc, the
|
|
347
|
+
module header, `HeaderRecord`/`AstmMessage` type docs, `readDelimiters` (whose doc comment said the
|
|
348
|
+
caller escalates an unusable declaration to the fatal, true only of the first header now), and
|
|
349
|
+
`docs-content/quickstart.md`, because on #21 the refuter's first pass was refused for exactly the
|
|
350
|
+
opposite failure: correct code shipped behind documentation that steered consumers the wrong way.
|
|
351
|
+
The `readDelimiters` comment also said it returns `{ ok: false }` when it has always returned
|
|
352
|
+
`undefined`; that was already wrong before this slice and is corrected in the same paragraph.
|
|
353
|
+
|
|
354
|
+
**Deliberately left for their own slices** (both recorded with this item, both **emit**-side, and
|
|
355
|
+
both turning on questions this one does not answer): a delimiter declaration **longer than three
|
|
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
|
|
358
|
+
validate a caller-supplied `d`**, so a malformed set emits a stream this library's own parser then
|
|
359
|
+
rejects or mis-reads, with no typed error. Neither is touched here; folding an emit-side change into
|
|
360
|
+
a parse-side fix would have widened the diff without answering either question.
|
|
361
|
+
|
|
362
|
+
**Strict-mode boundary, disclosed as a sample and not a census.** Measured base-build vs head-build
|
|
363
|
+
over 216,699 synthetic streams (the 11 repo fixtures, 464 single-header streams, and multi-header
|
|
364
|
+
pairs across a sampled sweep of delimiter sets): **394 moved accepted→rejected, 0 the other way.**
|
|
365
|
+
All 11 fixtures and all 464 single-header streams were **unchanged**, in strict mode and in their
|
|
366
|
+
lenient warning lists, which matches the code path, since the new warnings can only be raised at an
|
|
367
|
+
`H` record that is not `records[0]`.
|
|
368
|
+
|
|
369
|
+
- **`serializeAstmRecords` no longer emits a mixed-delimiter stream that silently loses fields on the
|
|
370
|
+
next read** (`ASTM-MIXED-DELIMITER-EMIT`; shipped since `0.0.1`, `PRE-EXISTING`, not a regression).
|
|
371
|
+
Emit normalized the header to the canonical `H|\^&` set but re-emitted `M` (manufacturer) and `S`
|
|
372
|
+
(scientific) records byte-for-byte from `rawLine`. For a message that arrived under a vendor
|
|
373
|
+
delimiter set the output was therefore **non-conformant**: a canonical header above `M`/`S` rows
|
|
374
|
+
still written in the original delimiters. Re-parsing that output **collapsed every field of those
|
|
375
|
+
rows into one, with zero warnings**: on the analyzer↔LIS path, a lost result or a lost
|
|
376
|
+
patient/specimen identifier with no signal to the caller. Verified by execution before and after,
|
|
377
|
+
not by inspection: a five-field `M` row round-tripped to one field.
|
|
378
|
+
|
|
379
|
+
**The semantics chosen, and why.** The two candidates were to re-encode every record to the
|
|
380
|
+
delimiters the header declares, or to refuse/warn on a message whose records disagree.
|
|
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
|
|
383
|
+
no warning channel, so a warning could only have been ignored while the corrupt stream still
|
|
384
|
+
shipped, and a refusal would have rejected messages this library successfully parsed, a harder
|
|
385
|
+
break on a published package. The invariant that had to hold either way is that **a round-trip
|
|
386
|
+
never silently loses a field, in either direction**; it is now pinned by a property over arbitrary
|
|
387
|
+
declared delimiter sets, which fails against the old code. `M`/`S` are reproduced byte-for-byte
|
|
388
|
+
**only when a reader using the emit delimiters would recover exactly the fields the record models**,
|
|
389
|
+
so bytes change for exactly the streams that were being corrupted and for no others. The reasoning
|
|
390
|
+
is recorded at the site in `src/records/serialize.ts`.
|
|
391
|
+
|
|
392
|
+
- **Editing a modeled `H` field and re-serializing no longer silently keeps the original value**
|
|
393
|
+
(`PRE-EXISTING`, found in the same pass). The header was emitted by re-tokenizing its preserved
|
|
394
|
+
`rawLine`, so the model was bypassed on emit. It is now emitted from `HeaderRecord.fields` like
|
|
395
|
+
every other record type. The delimiter declaration itself is still never taken from the model: it
|
|
396
|
+
always states the delimiters actually being emitted with, because a declaration that disagrees with
|
|
397
|
+
the records around it is the very corruption above.
|
|
398
|
+
|
|
399
|
+
- **`HeaderRecord.fields` now holds the header's real fields.** The generic escape-aware tokenizer
|
|
400
|
+
reads the escape character sitting _literally_ inside the delimiter declaration as an unterminated
|
|
401
|
+
escape and swallows the remainder of the record, so a canonical header modeled as **two** fields
|
|
402
|
+
(`H` and everything else merged) instead of one per field. A header-aware `tokenizeHeader` takes the
|
|
403
|
+
declaration verbatim as one opaque field and tokenizes the data portion normally; it is exported
|
|
404
|
+
alongside `tokenizeRecord`. This is what made emitting the header from its model possible.
|
|
405
|
+
|
|
406
|
+
**A `strict` consumer will observe one side effect, stated rather than left to be discovered.**
|
|
407
|
+
The old tokenizer read the declaration's literal escape char as an unterminated escape and raised a
|
|
408
|
+
spurious `ASTM_UNKNOWN_ESCAPE_SEQUENCE` on some headers, which `{ strict: true }` rejected. Those
|
|
409
|
+
warnings are gone, so a few messages that previously threw now parse. Measured over ~40,000
|
|
410
|
+
differential messages: 1,176 moved from rejected to accepted, **0** moved the other way, and every
|
|
411
|
+
warning delta is confined to `recordIndex 0`, no warning on any other record is added or dropped.
|
|
412
|
+
|
|
413
|
+
Three `PRE-EXISTING` neighbours were found while grading this and are deliberately **not** fixed
|
|
414
|
+
here, to keep the slice the size of its item: `parseAstmRecords` reads delimiters only from the
|
|
415
|
+
first header, so a **second `H` mid-stream that redeclares them** still yields the same silent
|
|
416
|
+
field collapse (parse-side, not emit-side, the emitter faithfully reproduces what parse modeled),
|
|
417
|
+
**since fixed, see the parse-side entry above**;
|
|
418
|
+
a delimiter declaration longer than three characters silently loses its extra bytes on emit; and
|
|
419
|
+
`serializeAstmRecords(msg, d)` does not validate a caller-supplied `d`, so a malformed set (a
|
|
420
|
+
multi-char delimiter, an empty escape, a `field`/`escape` collision) emits a stream this library's
|
|
421
|
+
own parser then rejects or mis-reads, with no typed error.
|
|
422
|
+
|
|
423
|
+
- **The type documentation shipped in `dist/index.d.ts` no longer understates the library.** The
|
|
424
|
+
entry-point module documentation ended by calling serialize and build deferred, on a tree that exports
|
|
425
|
+
`serializeAstmRecords`, `buildAstmMessage`, `composeAstmFrames` and `serializeFramedAstm`, and the
|
|
426
|
+
record-types module documentation described only `H`/`P`/`O`/`R`/`L` as modeled, naming
|
|
427
|
+
result-flag/status semantics, comment, query, `M`/`S`, framing and serialization as still to come,
|
|
428
|
+
when all of them are modeled. Both
|
|
429
|
+
blocks are carried verbatim in `dist/index.d.ts` and `dist/index.d.cts`, which is what an installer
|
|
430
|
+
receives. (They are module-level blocks that bind to no exported symbol, so they sit in the
|
|
431
|
+
declaration text rather than in any symbol's editor tooltip.) Every affected block now describes
|
|
432
|
+
what the code does. 18 source files changed; no runtime behaviour, export, type or warning code is
|
|
433
|
+
affected.
|
|
434
|
+
- **The fatal-error taxonomy is now accurate about what `err.code` can hold.** `FATAL_CODES`
|
|
435
|
+
documented itself as later growing the frame codec's own `ASTM_FRAME_*` fatals. `FatalCode` is a
|
|
436
|
+
closed three-value union and the frame codec does not widen it: it reuses `EMPTY_INPUT` for an
|
|
437
|
+
empty stream, and its own thrown errors are separate types with their own discriminants
|
|
438
|
+
(`AstmFrameEncodeError` carries `ASTM_FRAME_EMPTY_RECORD`; `AstmFrameStrictError` carries the
|
|
439
|
+
rejected warnings rather than a `code`). Narrowing an `AstmParseError` on `code` will only ever
|
|
440
|
+
see one of the three.
|
|
441
|
+
- **The serializer's round-trip note now names the cases that do not round-trip.** It claimed
|
|
442
|
+
serialization emits from the decoded component tree. Three record types do not: `H`, `M` and `S`
|
|
443
|
+
are emitted from their preserved `rawLine`, so an edit to their modeled `fields` is silently not
|
|
444
|
+
reflected on emit -- editing a header field and re-serializing keeps the original value, with no
|
|
445
|
+
warning. `M`/`S` additionally keep whatever delimiters they arrived with, so a non-canonical
|
|
446
|
+
`M`/`S` row does not come back as separate fields. Normalization to `H|\^&` is also skipped when a
|
|
447
|
+
delimiter set is passed explicitly. All of it is now stated next to the round-trip claim it
|
|
448
|
+
bounds, and `serializeAstmRecords` gained the `@param d` its siblings already had.
|
|
449
|
+
|
|
14
450
|
- **The published documentation sidebar now follows the shared section order.** The shipped
|
|
15
451
|
`docs-content/sidebars.json` carried a category named "About" holding the "what it does and does not
|
|
16
452
|
do" page. That label is not one of the sections the documentation site recognises (Overview,
|
|
@@ -79,12 +515,12 @@ ladder (`0.0.x` until first alpha).
|
|
|
79
515
|
record stream and pull result value + units + flag in one line.
|
|
80
516
|
- `parseAstmRecords(raw, opts?)` → an immutable, deeply-frozen `AstmMessage`; `results(msg)` /
|
|
81
517
|
`patient(msg)` typed extractors.
|
|
82
|
-
- **Delimiter self-declaration
|
|
518
|
+
- **Delimiter self-declaration**: the four delimiters (field / repeat / component / escape) are
|
|
83
519
|
read from each `H` record, never hardcoded, with ASTM's `\`=repeat and `&`=escape mapping.
|
|
84
|
-
- **Escape codec
|
|
520
|
+
- **Escape codec**: `&F&`/`&S&`/`&R&`/`&E&` are decoded via escape-aware split-then-decode, so a
|
|
85
521
|
value containing an escaped component delimiter reads as **one** component (the documented
|
|
86
522
|
silent-misread class the OSS references exhibit). Re-escaping is deferred to the emit phase (P7).
|
|
87
|
-
- Modeled records: `H` (delimiter provenance), `P` (identity
|
|
523
|
+
- Modeled records: `H` (delimiter provenance), `P` (identity, practice-assigned ID and
|
|
88
524
|
laboratory-assigned ID kept **distinct**), `O` (accession + Universal Test ID), `R` (all 14
|
|
89
525
|
fields; value / units / flags / status surfaced **raw**), `L`. Unknown record types surface as
|
|
90
526
|
`unsupported` records with a warning, never dropped.
|
|
@@ -93,10 +529,10 @@ ladder (`0.0.x` until first alpha).
|
|
|
93
529
|
recognition (`[OSS-derived]` field order), the deep-freeze base, and the warning/fatal registry.
|
|
94
530
|
- Fatal codes: `EMPTY_INPUT` (shared), `ASTM_RECORD_NO_HEADER`, `ASTM_RECORD_UNDECLARED_DELIMITERS`.
|
|
95
531
|
Warning codes: `ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`,
|
|
96
|
-
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT
|
|
532
|
+
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`, `ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT`, all carry stable code +
|
|
97
533
|
value-free positional context.
|
|
98
534
|
- **Fail-safe on an unescaped component delimiter in a result value:** the full raw value and the
|
|
99
|
-
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
|
|
100
536
|
primary `value` is never silently truncated to the first component.
|
|
101
537
|
- `scripts/phi-scan.ts` extended toward the P-record loci (name + DOB, delimiter-aware); synthetic
|
|
102
538
|
fixtures declared in `scripts/phi-allow-list.txt`.
|
|
@@ -104,109 +540,109 @@ ladder (`0.0.x` until first alpha).
|
|
|
104
540
|
`AstmParseError`, `AstmStrictError`, the record/value model types, and the `WARNING_CODES` /
|
|
105
541
|
`FATAL_CODES` registries.
|
|
106
542
|
- **Safety-critical result semantics (ASTM-2, roadmap Phase 2).** The raw `R`-record letters that
|
|
107
|
-
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
|
|
108
544
|
value_. The raw strings (`abnormalFlags`, `resultStatus`, `referenceRange`, `units`) still coexist
|
|
109
545
|
with the modeled views; nothing is collapsed or reconciled.
|
|
110
546
|
- **Abnormal flags (field 7) → HL7 Table 0078.** `interpretAbnormalFlag()` and the `flag` field on
|
|
111
547
|
`ResultRecord` model the full value set: `L`/`H`, panic `LL`/`HH`, off-scale `<`/`>`, `N`, `A`/`AA`,
|
|
112
|
-
the **directional** significant-change `U` (up) / `D` (down)
|
|
548
|
+
the **directional** significant-change `U` (up) / `D` (down), _not_ units/delta, `B`/`W`, and
|
|
113
549
|
microbiology `S`/`R`/`I`. An **unrecognized** flag is surfaced as `meaning: "undefined"` with an
|
|
114
|
-
`ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning
|
|
550
|
+
`ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG` warning, **never dropped, never coerced to `normal`**.
|
|
115
551
|
- **Result status (field 9).** `interpretResultStatus()` and the always-present `status` field model
|
|
116
552
|
`F`/`C`/`P`/`R`/`S`/`I`/`X`, with **`C` correction** (`supersedes: true`) and **`X` cancel**
|
|
117
|
-
(`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`
|
|
118
554
|
is `true` only for a plain `F`. An **absent** status is typed `unspecified` (never assumed `final`);
|
|
119
555
|
an unrecognized one is `undefined` + `ASTM_RECORD_UNDEFINED_RESULT_STATUS`.
|
|
120
556
|
- **Reference range (field 6).** `parseReferenceRange()` and the `range` field parse `low-high`
|
|
121
557
|
(closed), `<high` (open-low), and `>low` (open-high); bounds are surfaced as **verbatim numeric
|
|
122
558
|
text** (never coerced to floats). The range is read from the **full field text**, so a
|
|
123
|
-
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
|
|
124
560
|
truncated to a single bound. An unparseable range is `kind: "unparsed"` +
|
|
125
|
-
`ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE
|
|
561
|
+
`ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`, **no bound is fabricated**. The exact delimiter is
|
|
126
562
|
`[OSS-derived]` pending the purchased CLSI LIS02-A2 (roadmap §10 Q1).
|
|
127
563
|
- **Units discipline (field 5).** A _numeric_ result value with no units raises
|
|
128
564
|
`ASTM_RECORD_UNITS_ABSENT`; units are vendor free text (not UCUM) and are **never defaulted,
|
|
129
565
|
guessed, or converted**.
|
|
130
566
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG`,
|
|
131
567
|
`ASTM_RECORD_UNDEFINED_RESULT_STATUS`, `ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE`,
|
|
132
|
-
`ASTM_RECORD_UNITS_ABSENT
|
|
568
|
+
`ASTM_RECORD_UNITS_ABSENT`, all value-free (code + record/field index only).
|
|
133
569
|
- **Patient/order identity depth, comments, and partial-timestamp hardening (ASTM-3, roadmap Phase 3).**
|
|
134
570
|
The misfiling-prevention slice: model the identity that a result files against, and the context that
|
|
135
571
|
qualifies it.
|
|
136
572
|
- **Full patient (`P`) identity.** The **practice-assigned ID (field 3)**, the **laboratory-assigned
|
|
137
573
|
ID (field 4)**, and a **third patient ID (field 5)** are modeled as **distinct** fields that never
|
|
138
|
-
collapse into one
|
|
574
|
+
collapse into one: conflating them is the primary result-misfiling path. Adds mother's maiden name
|
|
139
575
|
(field 7) alongside the existing name components (field 6), birthdate (field 8), and sex (field 9).
|
|
140
576
|
- **Full order (`O`).** `priority` (field 6), `actionCode` (field ~12), and `reportType` (field ~26)
|
|
141
577
|
are surfaced **verbatim** on top of the existing specimen/accession + Universal Test ID. The `~`
|
|
142
|
-
field indices and the code sets are `[OSS-derived]` (paywalled)
|
|
143
|
-
- **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,
|
|
144
580
|
the full text is surfaced plus the component split, never truncated), and `commentType` (field 5).
|
|
145
581
|
Each comment is **attached by position** to the immediately-preceding `H`/`P`/`O`/`R` parent
|
|
146
582
|
(`parentIndex`); consecutive comments share that parent. **Fail-safe:** an **orphan** comment with no
|
|
147
583
|
valid parent is attached to the message root (`attachedToRoot: true`) with an
|
|
148
|
-
`ASTM_RECORD_ORPHAN_COMMENT` warning
|
|
584
|
+
`ASTM_RECORD_ORPHAN_COMMENT` warning, **never silently dropped**. New extractors `comments(msg)` /
|
|
149
585
|
`commentsFor(msg, record)` / `orders(msg)`, and the pure `attachComments()` attachment pass.
|
|
150
586
|
- **Comment-type codes are `[OSS-derived]`.** `I` (instrument) is the only value seen in the
|
|
151
587
|
permissively-licensed real transcripts; `G`/`T`/`P` are defined only in the paywalled CLSI LIS02-A2
|
|
152
|
-
and are **not** interpreted
|
|
588
|
+
and are **not** interpreted, `commentType` is surfaced raw, never mapped to a guessed meaning.
|
|
153
589
|
- **Partial-timestamp hardening.** A `YYYYMMDDHHMMSS` value with an odd digit run that truncates a
|
|
154
590
|
two-digit component (lengths 5/7/9/11/13) sets `AstmDate.truncated`, is preserved verbatim in `raw`,
|
|
155
|
-
and stops at the last **complete** component
|
|
591
|
+
and stops at the last **complete** component: the dangling digit is **never zero-filled into a
|
|
156
592
|
fabricated time**. A caller surfaces this as a value-free `ASTM_RECORD_PARTIAL_TIMESTAMP` warning
|
|
157
|
-
(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.
|
|
158
594
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_ORPHAN_COMMENT`,
|
|
159
|
-
`ASTM_RECORD_PARTIAL_TIMESTAMP
|
|
595
|
+
`ASTM_RECORD_PARTIAL_TIMESTAMP`, value-free (code + record/field index only).
|
|
160
596
|
- `scripts/phi-scan.ts` extended toward the mother's-maiden locus (P field 7), on top of the existing
|
|
161
597
|
name (field 6) + DOB (field 8) detection; synthetic fixtures declared in `scripts/phi-allow-list.txt`.
|
|
162
598
|
- **Query (`Q`) + host-query flow + `M`/`S` surfaced verbatim (ASTM-4, roadmap Phase 4).** Completes the
|
|
163
|
-
record grammar
|
|
599
|
+
record grammar: **the record-content layer is now feature-complete.**
|
|
164
600
|
- **The `Q` (Request Information) record.** Modeled at the public ASTM E1394 field positions:
|
|
165
601
|
`startingRangeId` (field 3) and `endingRangeId` (field 4) surfaced as the **full verbatim field**
|
|
166
602
|
(never truncated to a component), the Universal Test ID (field 5, same caret structure as `O`/`R`),
|
|
167
603
|
and `requestInformationStatus` (field 13) surfaced **verbatim**. The range component structure, the
|
|
168
604
|
`ALL` universal-query keyword (`queriesAllTests`), and the request-information status code set are
|
|
169
|
-
all **`[OSS-derived / paywalled]`** (roadmap §10 Q3)
|
|
605
|
+
all **`[OSS-derived / paywalled]`** (roadmap §10 Q3): surfaced, flagged, and **never interpreted or
|
|
170
606
|
guessed**. New `query(msg)` extractor.
|
|
171
607
|
- **The host-query flow.** Every message is classified up front (`msg.classification`): an `H/P/Q/L`
|
|
172
608
|
**request** is `host-query`, an `R`-bearing message is `results`, an `O`-only message is `orders`,
|
|
173
|
-
else `indeterminate`. **Fail-safe:** the `Q` **dominates
|
|
609
|
+
else `indeterminate`. **Fail-safe:** the `Q` **dominates**, a `Q`-bearing message is a request and
|
|
174
610
|
is **never** read as a result set, even when a result record is also present (a contradiction flagged
|
|
175
611
|
with `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`). Gate on `classification.isHostQueryRequest`. Pure
|
|
176
612
|
`classifyMessage(records)` exported.
|
|
177
613
|
- **`M` (manufacturer) + `S` (scientific) records surfaced verbatim.** Vendor-defined free-form
|
|
178
614
|
QC / calibration / maintenance data, preserved byte-for-byte on `record.rawLine` and **never**
|
|
179
|
-
interpreted into typed clinical fields
|
|
615
|
+
interpreted into typed clinical fields: a QC value can never be read as a patient result. Round-trip
|
|
180
616
|
byte-identical.
|
|
181
617
|
- New warning codes (registry extended, snapshot locked): `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`
|
|
182
618
|
(a Q request-information status surfaced verbatim; the code set is paywalled, so it is passed through
|
|
183
|
-
uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND
|
|
619
|
+
uninterpreted) and `ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND`, both value-free (code + position only).
|
|
184
620
|
- `AstmMessage` gains a `classification` field; `AstmRecord` gains `QueryRecord` / `ManufacturerRecord`
|
|
185
621
|
/ `ScientificRecord` members (an unknown type letter is still an `UnsupportedRecord`, never dropped).
|
|
186
|
-
- **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:
|
|
187
623
|
a separate, independent layer from the record layer, sharing only the payload boundary. `src/frames/`
|
|
188
624
|
decodes a framed byte stream into frames + reassembled record bytes; `src/common/` and `src/records/`
|
|
189
625
|
are untouched.
|
|
190
626
|
- `decodeAstmFrames(bytes, opts?)` → `{ records: readonly Uint8Array[]; frames: readonly AstmFrame[];
|
|
191
627
|
warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS <CR><LF>`.
|
|
192
628
|
- **Modulo-256 checksum** over the bytes after `STX` up to and **including** the `ETB`/`ETX`
|
|
193
|
-
terminator, two hex chars
|
|
629
|
+
terminator, two hex chars: **verified on decode, emitted uppercase, accepted lowercase** (a real
|
|
194
630
|
vendor quirk). `computeChecksum` / `toChecksumHex` / `parseChecksumHex` exported.
|
|
195
631
|
- **Frame-number `0`–`7` sequencing** (rolls over `7 → 0 → 1`, starts at `1`) and **multi-frame record
|
|
196
|
-
reassembly
|
|
632
|
+
reassembly**: text is capped at **240 bytes** (the seven control bytes are **not** counted), `ETB`
|
|
197
633
|
is intermediate / `ETX` final. `parseFramedAstm(bytes, opts?)` composes the framing and record layers
|
|
198
634
|
at the edge (decode → reassemble trusted records → `parseAstmRecords`).
|
|
199
635
|
- **Fail-safe (byte-level, safety-critical):** a **checksum mismatch** surfaces the frame flagged
|
|
200
|
-
`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,
|
|
201
637
|
the "checksums are routinely not validated" claim was _refuted_: we validate); a **frame-number gap**
|
|
202
638
|
warns and is **never silently bridged**; an **unterminated** frame surfaces the partial bytes
|
|
203
639
|
untrusted and **invents no partial record**; an **oversize** (>240) frame is flagged, never dropped.
|
|
204
640
|
- New `ASTM_FRAME_*` warning registry (a **second** registry alongside `ASTM_RECORD_*`, sharing only
|
|
205
641
|
the `EMPTY_INPUT` fatal; snapshot locked): `ASTM_FRAME_BAD_CHECKSUM`, `ASTM_FRAME_SEQUENCE_GAP`,
|
|
206
|
-
`ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE
|
|
642
|
+
`ASTM_FRAME_UNTERMINATED`, `ASTM_FRAME_OVERSIZE`, every warning **value-free**, carrying a **frame
|
|
207
643
|
number + byte offset** only, never the record bytes a frame holds. `{ strict: true }` throws
|
|
208
644
|
`AstmFrameStrictError`.
|
|
209
|
-
- **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 /
|
|
210
646
|
truncated / mixed / control-char-laden bytes never crash, hang, or OOM; they degrade to a typed
|
|
211
647
|
error or a value-free warning. Plus property tests: N-frame reassembly equals the single-frame form,
|
|
212
648
|
and every trusted frame's recomputed checksum matches its declared value.
|
|
@@ -214,13 +650,13 @@ warnings: readonly AstmFrameWarning[] }`. A frame is `<STX> FN text <ETB|ETX> CS
|
|
|
214
650
|
`DecodeAstmFramesResult`, `FramedAstmResult`, `AstmFramePosition`, `AstmFrameWarning`,
|
|
215
651
|
`FrameWarningCode`, `FRAME_WARNING_CODES`.
|
|
216
652
|
- **Transport variants + pure LTP protocol reducer (ASTM-6, roadmap Phase 6).** The **LTP protocol
|
|
217
|
-
layer
|
|
653
|
+
layer**, `src/ltp/`, sits above the frame codec: transport auto-detection plus a deterministic,
|
|
218
654
|
socket-free session state machine. No live I/O: the consumer owns the wire and clock; this layer
|
|
219
655
|
decides.
|
|
220
656
|
- **Transport auto-detection.** `detectFraming(bytes, opts?)` → `{ framing: "framed" | "raw";
|
|
221
657
|
defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, and the cobas 4800 /
|
|
222
658
|
iNTERFACEWARE Iguana framed-over-TCP reality); a leading bare record letter (`H`/`P`/`O`/`R`/`C`/
|
|
223
|
-
`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
|
|
224
660
|
directly). An unrecognizable lead **defaults to framed and warns**
|
|
225
661
|
(`ASTM_LTP_AMBIGUOUS_TRANSPORT`), never guessing silently into data loss; an `override` forces the
|
|
226
662
|
mode (the Phase-8 profile hook).
|
|
@@ -229,8 +665,8 @@ defaulted: boolean; warnings }`. A leading `STX`/`ENQ` ⇒ **framed** (serial, a
|
|
|
229
665
|
codec-decoded `frame`; actions are `sendAck` / `sendNak` / `sendEot` / `deliverRecord`. It models
|
|
230
666
|
the LIS01-A2 establishment → transfer → termination phases as `neutral ⇄ transfer`, reassembling
|
|
231
667
|
`ETB…ETX` runs into delivered records and tracking the `0`–`7` frame sequence.
|
|
232
|
-
- **ACK-failsafe (safety-critical, borrowed from `mllp`).** A frame the codec did not vouch for
|
|
233
|
-
**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`,
|
|
234
670
|
**never** a fabricated positive `ACK`, and its bytes are **never** appended to a record or
|
|
235
671
|
delivered. A `NAK` drives **retransmit, not acceptance** (`ASTM_LTP_FRAME_REJECTED`). A duplicate
|
|
236
672
|
of the last-accepted frame is idempotently re-`ACK`ed without re-appending; a partial record open
|
|
@@ -245,33 +681,33 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
245
681
|
`ltpInitialState`, `ltpReduce`, `LtpPhase`, `LtpState`, `LtpEvent`, `LtpAction`, `LtpTransition`,
|
|
246
682
|
`AstmLtpWarning`, `LtpWarningCode`, `LTP_WARNING_CODES`, `ltpAmbiguousTransport`,
|
|
247
683
|
`ltpUnexpectedEvent`, `ltpFrameRejected`.
|
|
248
|
-
- **Spec-clean serializers + builders
|
|
684
|
+
- **Spec-clean serializers + builders, both layers (ASTM-7, roadmap Phase 7).** The **emit** side: the
|
|
249
685
|
conservative inverse of the parser and the frame codec, so **round-trip fidelity holds by
|
|
250
|
-
construction**. Postel's Law's second half
|
|
686
|
+
construction**. Postel's Law's second half: liberal on parse, strict on emit.
|
|
251
687
|
- **Record serializer.** `serializeAstmRecords(msg | records)` and `serializeAstmRecord(record)` emit a
|
|
252
688
|
`CR`-terminated stream with the **canonical** `H|\^&` delimiters and every embedded delimiter
|
|
253
|
-
re-escaped. `encodeComponent()` is the exact inverse of the Phase-1 escape codec
|
|
254
|
-
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&`),
|
|
255
691
|
so a value containing a delimiter (a titre `1^40` → `1&S&40`) can never break framing and reads back
|
|
256
692
|
as **one** component. A source parsed with **non-canonical** delimiters is **normalized** to the
|
|
257
693
|
canonical set on emit (vendor-delimiter round-tripping is a Phase-8 profile concern). The header's
|
|
258
694
|
delimiter declaration is emitted **literally** (never escaped); `M`/`S` records are re-emitted
|
|
259
695
|
**byte-identically** from `rawLine`.
|
|
260
696
|
- **Message builder.** `buildAstmMessage(input)` constructs a spec-clean stream from typed input under
|
|
261
|
-
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
|
|
262
698
|
field is left empty, **never a defaulted clinical value** (an unset result status reads back as
|
|
263
699
|
`unspecified`, never `final`; units / abnormal flags / patient IDs are never defaulted). The
|
|
264
|
-
**structure**
|
|
265
|
-
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).
|
|
266
702
|
- **Frame encoder.** `composeAstmFrames(records, opts?)` is the exact inverse of `decodeAstmFrames`:
|
|
267
703
|
it wraps reassembled record bytes into `<STX> FN text <ETB|ETX> CS <CR><LF>` frames with the
|
|
268
704
|
modulo-256 **checksum** and the `0`–`7` **frame number** **computed** (never accepted-as-given or
|
|
269
705
|
faked; emitted uppercase), numbered continuously across the stream (start `1`, roll over `7 → 0`),
|
|
270
706
|
and every record over **240** text bytes **split** `ETB…ETX` (the seven control bytes never counted).
|
|
271
|
-
`serializeFramedAstm(msg | records)` composes both emit layers at the edge
|
|
707
|
+
`serializeFramedAstm(msg | records)` composes both emit layers at the edge: the mirror of
|
|
272
708
|
`parseFramedAstm`.
|
|
273
|
-
- **Framing-integrity guards (typed errors, conservative emit).** A value carrying a `CR`/`LF
|
|
274
|
-
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`)
|
|
275
711
|
rather than emitted into a corrupted wire; an empty record or empty record list is an
|
|
276
712
|
`AstmFrameEncodeError` (`ASTM_FRAME_EMPTY_RECORD`), never an empty frame.
|
|
277
713
|
- **Round-trip proven.** The shared archetype `roundTripProperty` is now **live** (serialize is the
|
|
@@ -285,23 +721,23 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
285
721
|
`MessageInput`, `HeaderInput`, `PatientInput`, `PatientNameInput`, `OrderInput`, `ResultInput`,
|
|
286
722
|
`CommentInput`, `QueryInput`, `VerbatimInput`), `composeAstmFrames`, `AstmFrameEncodeError`,
|
|
287
723
|
`ComposeFramesOptions`, `serializeFramedAstm`.
|
|
288
|
-
- **Vendor profile system
|
|
724
|
+
- **Vendor profile system, engine + registry + quirk tolerance + definition-time safety gate
|
|
289
725
|
(ASTM-8, roadmap Phase 8).** `src/profiles/` mirrors the sibling `@cosyte/hl7` `defineProfile` /
|
|
290
726
|
`@cosyte/ccda` `defineCcdaProfile` shape: `name` / `lineage` / `describe()` / `extends`-merge, a
|
|
291
727
|
provenance-backed built-in registry, a runtime tolerance transform, and a definition-time safety gate.
|
|
292
728
|
- `defineAstmProfile(opts)` builds a frozen, immutable profile declaring the **non-safety-critical**
|
|
293
729
|
warning codes a class of streams is expected to trip (each with a grounded `rationale`), plus an
|
|
294
|
-
optional `transport` override (`"framed"`/`"raw"`)
|
|
730
|
+
optional `transport` override (`"framed"`/`"raw"`): the raw-vs-framed-TCP knob a consumer feeds to
|
|
295
731
|
`detectFraming(bytes, { override })` for a stream whose leading byte would auto-detect the wrong way.
|
|
296
732
|
- **A profile never alters an extracted value.** The transform (`applyAstmProfileToWarnings`, run last
|
|
297
733
|
in `parseAstmRecords`) only ever re-badges a warning it _expects_ to the new `PROFILE_QUIRK_APPLIED`
|
|
298
|
-
code (flagged `expected: true`, carrying the original `toleratedCode` and position)
|
|
734
|
+
code (flagged `expected: true`, carrying the original `toleratedCode` and position), Postel's Law
|
|
299
735
|
with a receipt: nothing is dropped, and a spec-clean message parses byte-identically with or without
|
|
300
736
|
a profile.
|
|
301
737
|
- **The safety gate is default-deny and total.** Only four benign, value-preserving record codes are
|
|
302
738
|
tolerable (`ASTM_RECORD_UNKNOWN_TYPE`, `ASTM_NONSTANDARD_DELIMITERS`, `ASTM_UNKNOWN_ESCAPE_SEQUENCE`,
|
|
303
|
-
`ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`); **every other code across all three registries
|
|
304
|
-
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**
|
|
305
741
|
with an `AstmProfileDefinitionError`. A profile therefore can never make a bad checksum "ok," a
|
|
306
742
|
cancelled result read "final," or quiet a wrong value / flag / status / range / units / patient or
|
|
307
743
|
comment context / message-kind ambiguity. Any warning code added in a future phase is
|
|
@@ -309,7 +745,7 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
309
745
|
- `parseAstmRecords(raw, { profile })` accepts an explicit profile; `{ profile: null }` opts out of
|
|
310
746
|
the process-scoped default (`setDefaultAstmProfile`); an `expected` quirk does **not** escalate in
|
|
311
747
|
`strict` mode. `AstmMessage` gains an additive `profile?: { name, lineage }` attribution.
|
|
312
|
-
- **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus
|
|
748
|
+
- **Built-ins:** `astmProfiles.default` (tolerates nothing) + `astmProfiles.referenceCorpus`, a
|
|
313
749
|
**non-vendor**, evidence-backed profile grounded firsthand in the redistributable OSS reference
|
|
314
750
|
corpus (`kxepal/python-astm` `codec.py` (BSD) + `senaite.astm`, which split on raw delimiters and
|
|
315
751
|
never un-escape `&F&`/`&S&`/`&R&`/`&E&`), tolerating only the resulting non-standard-escape
|
|
@@ -322,49 +758,49 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
322
758
|
`resolveProfileTransport`, `profileQuirkApplied`, `SAFETY_CRITICAL_CODES`, `TOLERABLE_CODES`,
|
|
323
759
|
`ALL_ASTM_WARNING_CODES`, `isSafetyCriticalCode`, and the `AstmProfile`, `DefineAstmProfileOptions`,
|
|
324
760
|
`AstmQuirkTolerance`, `AstmQuirkMatch`, `AstmProfileProvenance`, `AnyAstmWarningCode` types.
|
|
325
|
-
- **LIVD-aware LOINC recognition
|
|
761
|
+
- **LIVD-aware LOINC recognition: bring-your-own catalog, zero bundled terminology data (ASTM-9,
|
|
326
762
|
roadmap Phase 9).** The `src/terminology/` layer maps an analyzer's local test code (the Universal
|
|
327
763
|
Test ID's vendor/local code on `R`/`O` records) to a standard LOINC via a **consumer-supplied** IICC
|
|
328
|
-
LIVD ("LOINC to Vendor IVD") catalog
|
|
764
|
+
LIVD ("LOINC to Vendor IVD") catalog: **additive, advisory, and never a guessed LOINC** (a wrong
|
|
329
765
|
LOINC mis-identifies a test).
|
|
330
766
|
- `defineLivdCatalog(entries)` builds an immutable, frozen catalog indexed by the **Vendor Analyte
|
|
331
767
|
Code** (the vendor transmission code the instrument sends), grounded firsthand on the IICC LIVD
|
|
332
768
|
digital format / HL7 LIVD IG; `catalog.lookup(code)` returns `mapped` (one LOINC), `unmapped` (a
|
|
333
|
-
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**).
|
|
334
770
|
- `applyLivd(msg, catalog)` produces a **separate** layer of per-`R`/`O` `LivdAnnotation`s and never
|
|
335
771
|
mutates, alters, or drops the raw reported code/value; a catalog hit is labeled `derived: true`
|
|
336
772
|
(`source: "livd"`), an inline LOINC already on the wire is surfaced `source: "wire"` (never
|
|
337
773
|
overwritten by the catalog), and a miss/conflict is `unmapped`/`ambiguous` with a **value-free**
|
|
338
|
-
warning
|
|
774
|
+
warning, a LOINC is **never** fabricated. `lookupLivdForRecord(record, catalog)` annotates one
|
|
339
775
|
record.
|
|
340
|
-
- **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,
|
|
341
777
|
redistributable only _with its attribution notice_, not public-domain; and the public CDC LIVD file
|
|
342
778
|
is a **SARS-CoV-2-specific** publication that also carries separately-licensed SNOMED CT, not a
|
|
343
779
|
general-analyte, public-domain catalog. The package stays a structural recognizer, not a dictionary:
|
|
344
780
|
the consumer supplies the LIVD data (and owns its license obligations).
|
|
345
|
-
- 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
|
|
346
782
|
fourth, self-contained registry, deliberately outside the profile safety gate's universe (a LIVD
|
|
347
783
|
non-mapping is a post-parse advisory, not a parse-time deviation a profile could tolerate). New
|
|
348
784
|
exports: `defineLivdCatalog`, `applyLivd`, `lookupLivdForRecord`, `LIVD_WARNING_CODES`,
|
|
349
785
|
`livdUnmappedCode`, `livdAmbiguousMapping`, and the `LivdCatalog`, `LivdEntry`, `LivdLookup`,
|
|
350
786
|
`LivdAnnotation`, `LivdMapping`, `LivdResult`, `AstmLivdWarning`, `LivdWarningCode` types.
|
|
351
|
-
- **Release hardening (ASTM-10, roadmap Phase 10
|
|
787
|
+
- **Release hardening (ASTM-10, roadmap Phase 10, the final phase).** Publish-readiness for the now
|
|
352
788
|
feature-complete parser: coverage, fuzz, firsthand differential testing, the full docs spine, and a
|
|
353
789
|
proven release shape. No new runtime API.
|
|
354
790
|
- **Differential conformance vs [python-astm][pa]** (BSD-3-Clause reference codec, commit
|
|
355
791
|
`4170ce0c`), grounded **firsthand** in `test/differential/`: outputs captured once from the
|
|
356
792
|
reference (`generate-reference-vectors.py` → `reference-vectors.json`; **no reference code
|
|
357
|
-
vendored**), then asserted against `@cosyte/astm` on three shared paths
|
|
793
|
+
vendored**), then asserted against `@cosyte/astm` on three shared paths, the **modulo-256
|
|
358
794
|
checksum**, the **record field/component split** (escape-free, non-header), and a
|
|
359
795
|
**cross-implementation frame decode** (python encodes + splits → our decoder verifies every
|
|
360
796
|
checksum and reassembles the exact record bytes). The **deliberate divergences** are asserted on
|
|
361
797
|
purpose: we un-escape `&F&`/`&S&`/`&R&`/`&E&` (python leaves them literal), we validate the frame
|
|
362
798
|
checksum (python does not verify on decode), and we classify the `Q` host-query (python has no
|
|
363
|
-
model). CI needs no Python
|
|
364
|
-
- **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`,
|
|
365
801
|
and `terminology` now gate per-dir alongside `common`/`records`/`profiles` (on top of the global
|
|
366
802
|
gate), so the release bar holds directory by directory, not just in aggregate.
|
|
367
|
-
- **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
|
|
368
804
|
frame-codec fuzz: arbitrary / truncated / delimiter- and escape-laden input into
|
|
369
805
|
`parseAstmRecords` never crashes, hangs, or OOMs; lenient mode only ever throws a sanctioned
|
|
370
806
|
Tier-3 fatal, strict only `AstmStrictError`, and every warning carries a registered code. Both
|
|
@@ -373,9 +809,9 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
373
809
|
- **Publish dry-run proven release-shaped:** `attw` all-green (per-condition ESM/CJS types), a new
|
|
374
810
|
`smoke` gate (`scripts/smoke.mjs`) that imports the **built** ESM and requires the **built** CJS
|
|
375
811
|
entry and parses a result through each (now wired into `verify.sh`), and an `npm publish
|
|
376
|
-
--dry-run` pack inspection (10 files
|
|
812
|
+
--dry-run` pack inspection (10 files, `dist/` + `README`/`LICENSE`/`CHANGELOG`/`package.json`,
|
|
377
813
|
no `src` or tests). Zero runtime dependencies; MIT.
|
|
378
|
-
- **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,
|
|
379
815
|
and does not do**: no live I/O, units are verbatim free text not UCUM, no bundled terminology
|
|
380
816
|
dictionary, `M`/`S` verbatim, the archived-standard status, and the MIT-vs-CLSI license posture)
|
|
381
817
|
and `docs-content/architecture.md` (the two independent layers and their payload boundary); the
|
|
@@ -393,12 +829,12 @@ EOT` session **reassembles exactly the source records**; a **raw-TCP stream equa
|
|
|
393
829
|
|
|
394
830
|
### Deferred (later phases)
|
|
395
831
|
|
|
396
|
-
- **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:
|
|
397
833
|
the Phase-8 engine supports them (tolerate + transport override), but no public vendor-attributed
|
|
398
834
|
quirk document grounds a named one. **No bundled terminology
|
|
399
|
-
dictionary
|
|
835
|
+
dictionary**: LIVD-aware LOINC recognition is bring-your-own by design (Phase 9); the package ships
|
|
400
836
|
no LOINC / SNOMED / LIVD data and mapping quality is the consumer's catalog. The LTP reducer remains
|
|
401
|
-
a pure state machine
|
|
837
|
+
a pure state machine, no live I/O: wiring it to a real `SerialPort`/`net.Socket` (and the
|
|
402
838
|
interactive contention/timeout/retransmit **timing**) is a thin consumer adapter, and the standard's
|
|
403
839
|
exact numeric timeouts / retry counts are deferred (we model transitions, not timers).
|
|
404
840
|
|