@cosyte/astm 0.1.1 → 0.1.2
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 +173 -7
- package/LICENSE +1 -1
- package/README.md +38 -711
- package/TRADEMARKS.md +30 -0
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -12
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<a href="https://cosyte.com">
|
|
2
2
|
<picture>
|
|
3
3
|
<source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
|
|
4
|
-
<img alt="Cosyte
|
|
4
|
+
<img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
|
|
5
5
|
</picture>
|
|
6
6
|
</a>
|
|
7
7
|
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
> Read a real analyzer's ASTM traffic in one line, and never get a confident wrong value back.
|
|
11
11
|
|
|
12
12
|
[](https://www.npmjs.com/package/@cosyte/astm)
|
|
13
|
-
[](https://github.com/cosyte/astm/actions/workflows/ci.yml)
|
|
14
|
+
[](https://github.com/cosyte/astm/blob/main/LICENSE)
|
|
15
|
+
[](https://nodejs.org)
|
|
16
16
|
|
|
17
17
|
ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
|
|
18
18
|
|
|
@@ -20,10 +20,11 @@ ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on pars
|
|
|
20
20
|
- [Status](#status)
|
|
21
21
|
- [Install](#install)
|
|
22
22
|
- [Usage](#usage)
|
|
23
|
-
- [
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
23
|
+
- [Documentation](#documentation)
|
|
24
|
+
- [Support](#support)
|
|
25
|
+
- [Security](#security)
|
|
26
26
|
- [Contributing](#contributing)
|
|
27
|
+
- [Trademarks](#trademarks)
|
|
27
28
|
- [License](#license)
|
|
28
29
|
|
|
29
30
|
## Why this exists
|
|
@@ -32,23 +33,22 @@ Lab integration teams receive ASTM off analyzers that each read the standard a l
|
|
|
32
33
|
|
|
33
34
|
## Status
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Published on npm. The public API is settled and is stable enough to depend on. The record, framing, transport, emit, profile, and terminology layers are all shipped, and their exported names, options, and return shapes are what a consumer builds against. Warning and error codes are part of that surface: consumers branch on `w.code`, so renaming one is a breaking change and is treated as one.
|
|
36
37
|
|
|
37
38
|
Two public surfaces are still moving, named here rather than left to be found:
|
|
38
39
|
|
|
39
|
-
- **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
|
|
40
|
+
- **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate, and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
|
|
40
41
|
- **The warning and error code sets.** A code is added when a deviation is measured that nothing reported before, and a new code is safety-critical by default, so a profile that tolerated the old reports does not tolerate the new one. Adding a code is additive, though it can change which streams a `{ strict: true }` parse refuses; renaming one is breaking and is treated as breaking.
|
|
41
42
|
|
|
42
43
|
## Install
|
|
43
44
|
|
|
44
45
|
```bash
|
|
45
|
-
# Requires Node.js >=22.0.0. Ships dual ESM + CJS, with zero runtime dependencies.
|
|
46
46
|
pnpm add @cosyte/astm
|
|
47
47
|
# or
|
|
48
48
|
npm install @cosyte/astm
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
- **Node.js
|
|
51
|
+
- **Node.js 22 or later, tested on 22, 24, and 26.** That floor is `package.json` `engines.node` (`>=22.0.0`); nothing below it is supported.
|
|
52
52
|
- **Dual ESM and CJS.** `import` resolves to `dist/index.mjs` and `require` to `dist/index.cjs`, each with its own type declarations, so the package loads from either module system without an interop shim.
|
|
53
53
|
- **Zero runtime dependencies.** `dependencies` is empty and the package imports no Node built-in.
|
|
54
54
|
|
|
@@ -87,723 +87,46 @@ PRACTICE-0001 LAB-0002
|
|
|
87
87
|
0
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
Six things are worth reading off that output. The value and the units are surfaced raw, never converted and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing,
|
|
90
|
+
Six things are worth reading off that output. The value and the units are surfaced raw, never converted, and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing, and the pages under Documentation below say what each one means.
|
|
91
91
|
|
|
92
92
|
That block is executed on every test run and its output is asserted against the text beside it (`test/readme-usage.test.ts`), so an example that drifts from the package fails the build rather than misleading a reader.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
The rest of the API, layer by layer (several messages in one stream, framed bytes, the transport and its `LTP` reducer, LIVD mapping, and dates without an invented timezone), is in the guide [The API, layer by layer](https://github.com/cosyte/astm/blob/main/docs-content/guides-api-by-layer.md).
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
**What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
|
|
99
|
-
|
|
100
|
-
**What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
|
|
101
|
-
|
|
102
|
-
**What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks or de-identifies. It surfaces what arrived.
|
|
103
|
-
|
|
104
|
-
**One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone and comment free text is not implemented here. It guards this repository's own fixtures, tests and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
|
|
105
|
-
|
|
106
|
-
## API
|
|
107
|
-
|
|
108
|
-
`@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the Cosyte parser archetype: a lenient
|
|
109
|
-
parser that turns real-world, vendor-quirky input into **warnings** rather than failures, paired with
|
|
110
|
-
a serializer that always emits spec-clean output (Postel's Law). It mirrors the API shape of the
|
|
111
|
-
reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
112
|
-
|
|
113
|
-
Every export carries JSDoc that compiles into `dist/index.d.ts`, so the full signature reference is what your editor already shows on hover; the source is at [github.com/cosyte/astm/tree/main/src](https://github.com/cosyte/astm/tree/main/src). What follows is the shape of each layer and the caveats that bite.
|
|
114
|
-
|
|
115
|
-
### Parse records
|
|
116
|
-
|
|
117
|
-
```ts
|
|
118
|
-
import { parseAstmRecords, results, patient } from "@cosyte/astm";
|
|
119
|
-
|
|
120
|
-
// A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
|
|
121
|
-
const msg = parseAstmRecords(raw);
|
|
122
|
-
|
|
123
|
-
results(msg)[0]?.value; // the measured value, surfaced raw
|
|
124
|
-
results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
|
|
125
|
-
patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
|
|
126
|
-
msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
|
|
130
|
-
produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
|
|
131
|
-
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
132
|
-
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
133
|
-
|
|
134
|
-
### Several messages in one stream
|
|
135
|
-
|
|
136
|
-
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
137
|
-
splits a parsed stream into them, and each entry carries only its own records, so a patient is only
|
|
138
|
-
ever paired with the results that message actually carried:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
import { parseAstmRecords, messages } from "@cosyte/astm";
|
|
142
|
-
|
|
143
|
-
for (const m of messages(parseAstmRecords(raw))) {
|
|
144
|
-
m.patient?.practiceAssignedId; // the P for THIS message
|
|
145
|
-
m.results; // the Rs for THIS message
|
|
146
|
-
m.delimiters; // the set THIS message's records were read with
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
|
|
151
|
-
so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
|
|
152
|
-
across patients:
|
|
153
|
-
|
|
154
|
-
- `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
|
|
155
|
-
- `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
|
|
156
|
-
one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
|
|
157
|
-
|
|
158
|
-
**Both are breaking**, and the second one reaches single-message callers: a lone message carrying
|
|
159
|
-
several patients used to answer with the first of them. A stream that is one message with at most one
|
|
160
|
-
patient is unchanged, and so is a result-only message with no `P` at all, which still answers
|
|
161
|
-
`undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
|
|
162
|
-
already names the message.
|
|
163
|
-
|
|
164
|
-
Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
|
|
165
|
-
you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
|
|
166
|
-
byte for instance, opens no message, and the messages either side of it merge back into one, so a
|
|
167
|
-
patient can end up holding results that arrived under a different header. The
|
|
168
|
-
parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
|
|
169
|
-
only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
|
|
170
|
-
a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
|
|
171
|
-
not gate on the warning count, though, because the records that merged in can raise warnings of
|
|
172
|
-
their own.
|
|
173
|
-
|
|
174
|
-
Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
|
|
175
|
-
records after it are read with the previous set and their fields can be lost rather than merely
|
|
176
|
-
misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
|
|
177
|
-
the delimiters in force found no field separator in it at all, so the whole line read back as one
|
|
178
|
-
field and none of its modeled fields survived. On a result record that is the value, the units and
|
|
179
|
-
the status at once, so treat it as a lost result, not a formatting nit. The fields are never
|
|
180
|
-
reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
|
|
181
|
-
code is safety-critical, and it does not need a mangled header to fire: a lone record written in
|
|
182
|
-
another set trips it too.
|
|
183
|
-
|
|
184
|
-
**Its absence does not certify that a record was read in its own set**, and this is the important
|
|
185
|
-
half. The check tests one of the four delimiter roles, the **field** separator, and only in its
|
|
186
|
-
total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
|
|
187
|
-
outside it:
|
|
188
|
-
|
|
189
|
-
- A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
|
|
190
|
-
the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
|
|
191
|
-
the value, the units and the status with no warning at all, while the identical record without
|
|
192
|
-
that one byte is reported. This also happens **inside** a run of these warnings, so even a run
|
|
193
|
-
does not mean every record in it was checked.
|
|
194
|
-
- A set differing in the **repeat, component or escape** role usually splits into fields normally,
|
|
195
|
-
and the damage then varies. A mis-split component can cost a test identity while the value and
|
|
196
|
-
units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
|
|
197
|
-
longer merges the rest of the record (it reads as a literal and raises
|
|
198
|
-
`ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
|
|
199
|
-
that is itself a delimiter in force is an opaque
|
|
200
|
-
atom, so that delimiter does not split and the value, the units and the status can still go
|
|
201
|
-
together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
|
|
202
|
-
alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
|
|
203
|
-
where the leftmost alignment lets a delimiter split that a competing alignment would have held,
|
|
204
|
-
gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
|
|
205
|
-
not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
|
|
206
|
-
an escape character heading no sequence it can interpret, every later field shifts and a
|
|
207
|
-
result's units
|
|
208
|
-
and status are read out of slots the other alignment does not put them in: that raises
|
|
209
|
-
`ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
|
|
210
|
-
nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
|
|
211
|
-
truncates a value and costs a test identity or a patient name the components that sat after it:
|
|
212
|
-
that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
|
|
213
|
-
**component** boundary nothing leaves the record and every component after it moves along the
|
|
214
|
-
component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
|
|
215
|
-
alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
|
|
216
|
-
tolerable either.
|
|
217
|
-
|
|
218
|
-
All are accepted limits, for two different reasons: widening the field-separator check would mean
|
|
219
|
-
deciding which set a record ought to have had, which is the same guess the parser declines to make
|
|
220
|
-
elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
|
|
221
|
-
written down rather than papered over. Read the warning as "this record definitely lost its
|
|
222
|
-
fields", never as "no other record did". If
|
|
223
|
-
delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
|
|
224
|
-
outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
|
|
225
|
-
invalidating what follows it rather than expecting this warning to enumerate the damage.
|
|
226
|
-
|
|
227
|
-
An unrecognized type letter also makes the message **kind** unknowable, because the letter that
|
|
228
|
-
could not be read may have been the very `Q` that decides it. `classification.kind` is
|
|
229
|
-
`indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
|
|
230
|
-
says why. A `Q` that was read still wins outright.
|
|
231
|
-
|
|
232
|
-
### Decode a framed byte stream
|
|
233
|
-
|
|
234
|
-
```ts
|
|
235
|
-
import { decodeAstmFrames, parseFramedAstm, results } from "@cosyte/astm";
|
|
236
|
-
|
|
237
|
-
// A raw ASTM byte stream off a serial line or socket.
|
|
238
|
-
const { records, frames, warnings } = decodeAstmFrames(framedBytes);
|
|
239
|
-
frames[0]?.checksum.valid; // the modulo-256 checksum verdict (emitted uppercase, accepted lowercase)
|
|
240
|
-
warnings; // ASTM_FRAME_* deviations, each with a frame number + byte offset (never the record bytes)
|
|
241
|
-
|
|
242
|
-
// Or compose both layers: decode frames → parse the trusted, reassembled records.
|
|
243
|
-
const { message } = parseFramedAstm(framedBytes);
|
|
244
|
-
results(message)[0]?.value; // only checksum-verified frames ever reach the record parser
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>240) frame are each a
|
|
248
|
-
**warning** in the default lenient mode (surfaced, flagged, never silently trusted) and a thrown
|
|
249
|
-
`AstmFrameStrictError` under `{ strict: true }`.
|
|
250
|
-
|
|
251
|
-
### Drive the transport (framed vs raw) + the LTP protocol
|
|
252
|
-
|
|
253
|
-
ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
|
|
254
|
-
vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
|
|
255
|
-
cobas b121 drops it and streams de-framed record bytes directly. Detect which you have, then drive the
|
|
256
|
-
pure protocol reducer with your own socket I/O.
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
import {
|
|
260
|
-
detectFraming,
|
|
261
|
-
decodeAstmFrames,
|
|
262
|
-
parseAstmRecords,
|
|
263
|
-
ltpInitialState,
|
|
264
|
-
ltpReduce,
|
|
265
|
-
} from "@cosyte/astm";
|
|
266
|
-
|
|
267
|
-
// 1. Route by the stream's leading byte (STX/ENQ ⇒ framed; a bare record letter ⇒ raw).
|
|
268
|
-
const { framing } = detectFraming(leadingBytes); // "framed" | "raw" (override: { override: "raw" })
|
|
269
|
-
if (framing === "raw") {
|
|
270
|
-
// cobas b121 raw-TCP: no handshake, no frames, parse the record bytes directly.
|
|
271
|
-
parseAstmRecords(rawBytes);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// 2. Framed transport: drive the pure receiver-side state machine. YOU own the socket + clock.
|
|
275
|
-
let state = ltpInitialState();
|
|
276
|
-
function onControlOrFrame(event) {
|
|
277
|
-
const { state: next, actions, warnings } = ltpReduce(state, event);
|
|
278
|
-
state = next;
|
|
279
|
-
for (const a of actions) {
|
|
280
|
-
if (a.type === "sendAck") socket.write(Uint8Array.of(0x06)); // ACK, only ever for a good frame
|
|
281
|
-
if (a.type === "sendNak") socket.write(Uint8Array.of(0x15)); // NAK, bad checksum ⇒ retransmit
|
|
282
|
-
if (a.type === "deliverRecord") parseAstmRecords(a.record); // a complete, trusted record
|
|
283
|
-
}
|
|
284
|
-
void warnings; // ASTM_LTP_*, value-free (a code + at most a frame number)
|
|
285
|
-
}
|
|
286
|
-
// Feed events as you read them: { type: "enq" }, { type: "frame", frame: decodeAstmFrames(b).frames[0] }, …
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
The reducer is deterministic and fully testable without a socket. Its inviolable rule: a frame the
|
|
290
|
-
codec did not vouch for (bad checksum, unterminated, or out of sequence) yields `sendNak`, **never** a
|
|
291
|
-
fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retransmit, not acceptance.
|
|
292
|
-
The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
|
|
293
|
-
state transitions, not the wall-clock timers.
|
|
294
|
-
|
|
295
|
-
### Map local codes to LOINC (LIVD, bring-your-own)
|
|
96
|
+
## Documentation
|
|
296
97
|
|
|
297
|
-
|
|
298
|
-
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
|
|
299
|
-
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
300
|
-
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
98
|
+
The full documentation lives at [docs.cosyte.com/astm](https://docs.cosyte.com/astm), built from this repository's [`docs-content/`](https://github.com/cosyte/astm/tree/main/docs-content). Start with these pages:
|
|
301
99
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
|
|
309
|
-
const { annotations, warnings } = applyLivd(msg, catalog);
|
|
310
|
-
|
|
311
|
-
annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
|
|
312
|
-
warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
**Your catalog answers the analyte-identity question, and the wire never does.** The Universal Test
|
|
316
|
-
ID's first component is a LOINC slot, and the guide this catalog format comes from puts transmitting
|
|
317
|
-
LOINC directly from IVD instruments explicitly out of scope: the analyte arrives as a vendor-defined
|
|
318
|
-
code. So the lookup happens whenever a vendor local code is present, keyed on that code alone, and a
|
|
319
|
-
populated first component neither answers for it nor selects among candidates. **This package
|
|
320
|
-
performs no LOINC validation of any kind**: it never decides whether such a value "looks like" a
|
|
321
|
-
LOINC, so `Glucose` and `2345-7` there are treated identically. The value is carried verbatim as
|
|
322
|
-
`unvalidatedWireValue`, on every disposition, and is never reported as a LOINC.
|
|
323
|
-
|
|
324
|
-
```ts
|
|
325
|
-
const msg = parseAstmRecords("H|\\^&\rR|1|Glucose^^^687|28.6|U/L||N||F\rL|1\r");
|
|
326
|
-
const [a] = applyLivd(msg, catalog).annotations;
|
|
327
|
-
|
|
328
|
-
a?.mapping.status; // "mapped": your catalog vouched for 1920-8 (the lookup used "687")
|
|
329
|
-
a?.reportedCode; // "687": the code the catalog was consulted WITH, verbatim
|
|
330
|
-
a?.unvalidatedWireValue; // "Glucose": carried verbatim, vouched for by nothing
|
|
331
|
-
a?.wireValueDisagreesWithCatalog; // true: the two differ, and that is ALL this says
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
`mapping.status` is a closed discriminant, so a `switch` over it is exhaustive:
|
|
335
|
-
|
|
336
|
-
| `status` | what happened | warning |
|
|
337
|
-
| ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
|
|
338
|
-
| `mapped` | your catalog vouched for exactly one LOINC for the vendor local code | none |
|
|
339
|
-
| `unmapped` | the vendor local code was looked up and your catalog held no entry | `ASTM_LIVD_UNMAPPED_CODE` |
|
|
340
|
-
| `ambiguous` | several distinct LOINCs the reported units did not settle; all surfaced, **none chosen** | `ASTM_LIVD_AMBIGUOUS_MAPPING` |
|
|
341
|
-
| `no-vendor-code` | component 1 is populated and there is no vendor local code: nothing was looked up | none |
|
|
342
|
-
| `no-code` | the Universal Test ID carried no code at all | none |
|
|
343
|
-
|
|
344
|
-
`unvalidatedWireValue` and `wireValueDisagreesWithCatalog` sit **beside** that discriminant and can
|
|
345
|
-
accompany any of its cases. `wireValueDisagreesWithCatalog` is `true` only where your catalog
|
|
346
|
-
vouched for exactly one LOINC, component 1 is populated, and the two are not byte-identical; it is
|
|
347
|
-
`false` everywhere else and is never absent, so an ordinary `R|1|^^^687|...` record ships no
|
|
348
|
-
standing false disagreement. **It reports the difference and nothing else**: both values stay
|
|
349
|
-
surfaced, neither is marked correct, neither is rewritten, and nothing here says the difference was
|
|
350
|
-
settled. Deciding which source to believe is a clinical judgement this library will not make for
|
|
351
|
-
you.
|
|
352
|
-
|
|
353
|
-
Two corners worth knowing before you write a catalog adapter: a `lookup` that **throws** propagates
|
|
354
|
-
to your caller unchanged rather than reading as a catalog miss (your crash must not be
|
|
355
|
-
indistinguishable from "this code is not in the catalog"), and a hit whose `loinc` is a zero-length
|
|
356
|
-
string is reported as a miss rather than as a vouched-for empty LOINC.
|
|
357
|
-
|
|
358
|
-
#### One vendor code, several LOINCs: the units decide
|
|
359
|
-
|
|
360
|
-
One vendor analyte code mapping to several LOINCs is the **ordinary** case, not an edge: the guide
|
|
361
|
-
this catalog format comes from gives the commonest chemistry analytes as its own worked examples, a
|
|
362
|
-
serum glucose reported as a mass concentration versus a substance concentration, and a urine analyte
|
|
363
|
-
reported as a spot concentration versus a 24 hour excretion rate. Its remedy is a mapping per unit,
|
|
364
|
-
so a catalog row can carry a **representative unit of measure** beside the vendor code, and the
|
|
365
|
-
`R` record already states its units on the wire.
|
|
366
|
-
|
|
367
|
-
```ts
|
|
368
|
-
import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
|
|
369
|
-
|
|
370
|
-
const glucose = defineLivdCatalog([
|
|
371
|
-
{ vendorCode: "GLU", loinc: "2345-7", representativeUnit: "mg/dL" },
|
|
372
|
-
{ vendorCode: "GLU", loinc: "14749-6", representativeUnit: "mmol/L" },
|
|
373
|
-
]);
|
|
374
|
-
|
|
375
|
-
const msg = parseAstmRecords("H|\\^&\rR|1|^^^GLU|5.5|mmol/L||N||F\rL|1\r");
|
|
376
|
-
applyLivd(msg, glucose).annotations[0]?.mapping;
|
|
377
|
-
// { status: "mapped", loinc: "14749-6", representativeUnit: "mmol/L",
|
|
378
|
-
// unitComparison: { comparison: "verbatim-case-sensitive", ucumSemantic: false, ... },
|
|
379
|
-
// source: "livd", derived: true }
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
**The comparison is verbatim and case sensitive, and it is not UCUM.** A catalog saying `mg/dL` does
|
|
383
|
-
not match a feed saying `MG/DL`, and it never matches `g/L` however convertible the two are: nothing
|
|
384
|
-
is normalized, case folded, scaled or converted on either side. UCUM defines a case-insensitive
|
|
385
|
-
variant of every terminal symbol and requires a program claiming full conformance to compare unit
|
|
386
|
-
expressions by their **semantics**, so this is a deliberately limited choice, the only comparison
|
|
387
|
-
that cannot invent an equivalence. Because you could otherwise read a matched unit as a conformance
|
|
388
|
-
claim this package does not make, every unit-selected answer carries `unitComparison` saying exactly
|
|
389
|
-
what was compared.
|
|
390
|
-
|
|
391
|
-
Refusing still beats guessing, so all four of these are `ambiguous` with **every candidate surfaced
|
|
392
|
-
and no LOINC chosen**: the units match no candidate, they match more than one, the record reported no
|
|
393
|
-
units (absent, empty, or whitespace only) and the candidates differ only by unit, or the units could
|
|
394
|
-
not be read at all because the record was truncated or malformed. An unreadable units field reads as
|
|
395
|
-
"no units reported": you still get a typed annotation, never an exception and never a dropped record.
|
|
396
|
-
The answer's `reason` says which case it was, and `candidateDetails` carries each candidate row's
|
|
397
|
-
LOINC and LIVD attributes so a human can choose what this package will not.
|
|
398
|
-
|
|
399
|
-
A candidate whose `representativeUnit` is **absent, empty or whitespace only** is not unit qualified:
|
|
400
|
-
it is never selected by a unit comparison, and it is still surfaced among the candidates. And a
|
|
401
|
-
vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree, with
|
|
402
|
-
no `unitComparison`, because no unit chose anything there.
|
|
403
|
-
|
|
404
|
-
On a `mapped` answer, `representativeUnit` is provenance about the **catalog row** rather than a
|
|
405
|
-
restatement of what the record reported: where a code carries a single candidate LOINC across several
|
|
406
|
-
rows that spell the unit differently, the answer takes the first row's attributes, so that field can
|
|
407
|
-
name a unit the record did not report. **Only `unitComparison` says the two were equal**, and it is
|
|
408
|
-
present only where a unit actually chose between candidates. Branch on that field, not on this one.
|
|
409
|
-
|
|
410
|
-
Two attributes ride along and are **never matched on**: `vendorSpecimenDescription` and
|
|
411
|
-
`vendorResultDescription`. Both are free text, and the guide states directly that this information is
|
|
412
|
-
not intended to be parsed by software that automates the mapping, so they are stored and surfaced
|
|
413
|
-
verbatim for a human to read. Only the representative unit ever selects. All three attributes are
|
|
414
|
-
**optional**: a catalog carrying none of them answers exactly as it did before they existed, for
|
|
415
|
-
`mapped`, `unmapped` and `ambiguous` alike.
|
|
416
|
-
|
|
417
|
-
**No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
|
|
418
|
-
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
419
|
-
separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
|
|
420
|
-
its license obligations).
|
|
421
|
-
|
|
422
|
-
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
423
|
-
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
424
|
-
> instruments that reuse the same code for different analytes would both match: supply a catalog built
|
|
425
|
-
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
426
|
-
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
427
|
-
|
|
428
|
-
### Read a date without inventing a timezone
|
|
429
|
-
|
|
430
|
-
**An ASTM timestamp carries no timezone, so it is not an instant.** The value is local to the
|
|
431
|
-
instrument that wrote it, and nothing on the wire says which zone that was. `toDate` therefore
|
|
432
|
-
returns `undefined` until **you** say what the offset was: it never reads the host machine's zone
|
|
433
|
-
and never assumes UTC. Both guesses are silent, and both shift a collection date by a day in every
|
|
434
|
-
negative-offset zone.
|
|
435
|
-
|
|
436
|
-
`parseAstmDate` reads the digit run into an `AstmDate` that keeps whatever precision the instrument
|
|
437
|
-
sent (`"20240315"` is a **day**, not a fabricated midnight). Three conversions read that value, and
|
|
438
|
-
they carry the same names, the same shapes and the same timezone rule in every `@cosyte` parser:
|
|
439
|
-
|
|
440
|
-
| function | returns | on a value it cannot convert |
|
|
441
|
-
| ------------------------- | ------------------------ | --------------------------------------------- |
|
|
442
|
-
| `toObject(value)` | `DateParts \| undefined` | `undefined` |
|
|
443
|
-
| `toISO(value)` | `string \| undefined` | `undefined` |
|
|
444
|
-
| `toDate(value, options?)` | `Date \| undefined` | `undefined`, including when no zone was given |
|
|
445
|
-
|
|
446
|
-
```ts
|
|
447
|
-
import { parseAstmDate, toObject, toISO, toDate } from "@cosyte/astm";
|
|
448
|
-
|
|
449
|
-
const collected = parseAstmDate("20240315"); // day precision, no time, no zone
|
|
100
|
+
- [Quickstart](https://github.com/cosyte/astm/blob/main/docs-content/quickstart.md): parse a result stream, read a result and its warnings, emit and frame records, and map a local code to LOINC.
|
|
101
|
+
- [The API, layer by layer](https://github.com/cosyte/astm/blob/main/docs-content/guides-api-by-layer.md): records, several messages in one stream, frames, the transport, LIVD mapping, and dates.
|
|
102
|
+
- [Coverage and compatibility](https://github.com/cosyte/astm/blob/main/docs-content/concepts-coverage.md): what the parser reads and emits, and the escape and delimiter edge cases each warning code reports.
|
|
103
|
+
- [Vendor profiles](https://github.com/cosyte/astm/blob/main/docs-content/guides-vendor-profiles.md): how a profile tolerates one documented deviation without touching a value.
|
|
104
|
+
- [What the library does with patient data](https://github.com/cosyte/astm/blob/main/docs-content/patient-data.md), and [what it does and does not do](https://github.com/cosyte/astm/blob/main/docs-content/limitations.md).
|
|
105
|
+
- [Troubleshooting](https://github.com/cosyte/astm/blob/main/docs-content/troubleshooting.md): common symptoms, and how to read what the parser is telling you.
|
|
450
106
|
|
|
451
|
-
|
|
452
|
-
toISO(collected); // "2024-03-15" no trailing Z, ever
|
|
453
|
-
toDate(collected); // undefined the zone is unstated
|
|
107
|
+
## Support
|
|
454
108
|
|
|
455
|
-
|
|
456
|
-
toDate(collected, { assumeOffsetMinutes: -300 }); // 2024-03-15T05:00:00.000Z
|
|
457
|
-
toDate(collected, { assumeOffsetMinutes: 0 }); // 2024-03-15T00:00:00.000Z, "read it as UTC"
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
`assumeOffsetMinutes` is signed minutes east of UTC, and an explicit `0` is a real answer ("treat
|
|
461
|
-
this value as UTC"), not a default. Components below the stated precision fill to their lowest legal
|
|
462
|
-
value for the instant only (month and day to 1, the time to 0); the value itself is untouched, so
|
|
463
|
-
`toObject` and `toISO` keep reporting the precision that arrived. A four-digit year below 100 stays
|
|
464
|
-
that year: `"00500101"` is the year 50, never 1950.
|
|
465
|
-
|
|
466
|
-
An offset that names no usable zone is refused rather than applied, and **the answer is never an
|
|
467
|
-
`Invalid Date`**: not a `NaN` or infinite `assumeOffsetMinutes`, and not one so large that applying
|
|
468
|
-
it lands outside the range a JS `Date` represents. Both give you `undefined`, like every other case
|
|
469
|
-
this surface cannot answer.
|
|
470
|
-
|
|
471
|
-
```ts
|
|
472
|
-
toDate(collected, { assumeOffsetMinutes: Number.NaN }); // undefined
|
|
473
|
-
toDate(collected, { assumeOffsetMinutes: 1e15 }); // undefined, past the representable range
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
An `Invalid Date` would satisfy the `Date | undefined` return type and defeat its point: you cannot
|
|
477
|
-
tell one from a real instant without testing `getTime()` for `NaN`, and calling `toISOString()` on it
|
|
478
|
-
throws. Checking for `undefined` is the only test you need.
|
|
479
|
-
|
|
480
|
-
`toObject` returns a **frozen** object carrying only the components the value stated, so
|
|
481
|
-
`Object.keys()` is the precision. There is no `precision`, `raw` or `truncated` key on it (that is
|
|
482
|
-
the parse record, and it stays on `AstmDate`), no zero-filling, and no key holding `undefined`. The
|
|
483
|
-
month is spec-native **1 to 12**, and the keys are singular, so deleting `offsetMinutes` (which an
|
|
484
|
-
ASTM value never has) leaves an object `Temporal.PlainDateTime.from` and luxon's
|
|
485
|
-
`DateTime.fromObject` accept with no key rename and no value adjustment. Neither library is a
|
|
486
|
-
dependency here: the shape is the interoperability, not an import.
|
|
487
|
-
|
|
488
|
-
**A component that is not on the calendar is refused, not normalised.** The parser is lenient by
|
|
489
|
-
design and will read `"19881301"` into month 13 rather than drop the field, so the conversions are
|
|
490
|
-
where that has to be answered, and all three of them answer `undefined`:
|
|
109
|
+
Questions, bug reports, and analyzer quirks you have met on a real feed all start on the [issue tracker](https://github.com/cosyte/astm/issues); there is no separate forum. Describe a quirk by its shape, or reproduce it with synthetic values: never paste real patient data into an issue.
|
|
491
110
|
|
|
492
|
-
|
|
493
|
-
parseAstmDate("19881301")?.month; // 13: the parser reports what arrived
|
|
494
|
-
toObject(parseAstmDate("19881301")); // undefined
|
|
495
|
-
toISO(parseAstmDate("19881301")); // undefined
|
|
496
|
-
toDate(parseAstmDate("19881301"), { assumeOffsetMinutes: 0 }); // undefined
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
A JS `Date` would have rolled that month 13 into January **1989** and handed back a date of birth a
|
|
500
|
-
year out, with nothing to say so. A month outside 1 to 12, a day past the end of its own month (29
|
|
501
|
-
February in a common year included), an hour past 23 and a minute or second past 59 are all refused
|
|
502
|
-
the same way. The refusal is of the whole value, never of the offending component alone: `undefined`
|
|
503
|
-
rather than a year-precision `{ year: 1988 }` the instrument never sent.
|
|
111
|
+
## Security
|
|
504
112
|
|
|
505
|
-
|
|
506
|
-
this parser produces **whose components are a real calendar date**, under the name every `@cosyte`
|
|
507
|
-
parser answers to. On a value whose components are not, the two differ on purpose and in one
|
|
508
|
-
direction: `astmDateToLocalISO` still renders the digits it was handed (`"1988-13-01"`), which is its
|
|
509
|
-
long-standing behaviour, and `toISO` gives you `undefined` instead of a string no ISO-8601 reader
|
|
510
|
-
accepts.
|
|
113
|
+
Report a vulnerability privately, to security@cosyte.com or through GitHub's private vulnerability reporting, as the [security policy](https://github.com/cosyte/.github/blob/main/SECURITY.md) describes, and never in a public issue.
|
|
511
114
|
|
|
512
|
-
|
|
115
|
+
### PHI and safety
|
|
513
116
|
|
|
514
|
-
|
|
515
|
-
Alias them, or namespace-import:
|
|
117
|
+
ASTM instrument traffic carries PHI. This section says what the library does with it and, where a guarantee cannot be grounded in the code, states the limit instead.
|
|
516
118
|
|
|
517
|
-
|
|
518
|
-
import { parseAstmDate, toISO as astmToISO, toDate as astmToDate } from "@cosyte/astm";
|
|
519
|
-
import { toISO as hl7ToISO } from "@cosyte/hl7";
|
|
119
|
+
**What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
|
|
520
120
|
|
|
521
|
-
|
|
522
|
-
import * as astm from "@cosyte/astm";
|
|
121
|
+
**What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier, or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
|
|
523
122
|
|
|
524
|
-
|
|
525
|
-
astm.toDate(parseAstmDate("20240315"), { assumeOffsetMinutes: -300 });
|
|
526
|
-
```
|
|
123
|
+
**What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks, or de-identifies. It surfaces what arrived.
|
|
527
124
|
|
|
528
|
-
|
|
529
|
-
differently on purpose: `hl7ToISO` may end in `Z` or `+HH:MM`, `astmToISO` never does. That
|
|
530
|
-
difference is the standards', not the API's.
|
|
531
|
-
|
|
532
|
-
### The Cosyte parser archetype
|
|
533
|
-
|
|
534
|
-
- **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
|
|
535
|
-
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
536
|
-
- **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
537
|
-
Tier 3 fatal always.
|
|
538
|
-
- **Stable warning codes**: warnings carry stable string codes + positional context; consumers
|
|
539
|
-
branch on `w.code`, so renaming a code is a breaking change.
|
|
540
|
-
- **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
|
|
541
|
-
- **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
|
|
542
|
-
- **Immutability**: parsed models are immutable; mutation is via explicit methods.
|
|
543
|
-
- **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
544
|
-
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
545
|
-
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
546
|
-
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
547
|
-
- **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
|
|
548
|
-
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. The
|
|
549
|
-
catalog answers for the analyte identity, never the wire, and no LOINC validation of any kind is
|
|
550
|
-
performed. No LOINC / SNOMED / LIVD data is bundled.
|
|
551
|
-
|
|
552
|
-
## Compatibility
|
|
553
|
-
|
|
554
|
-
- **Record content: ASTM E1394 / CLSI LIS02-A2.** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read; an unrecognized type letter is surfaced, never dropped.
|
|
555
|
-
- **Framing and transport: ASTM E1381 / CLSI LIS01-A2.** Modulo-256 checksum, frame-number sequencing, the 240-byte multi-frame split, and a pure `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine.
|
|
556
|
-
- **Delimiters come from the stream, never from an assumption.** They are read at every `H` and scoped forward, and records already read keep the set they were read with.
|
|
557
|
-
- **Framed and raw TCP are both handled.** Serial always frames; over TCP it varies within one vendor, so `detectFraming` routes cobas 4800 and Iguana (framed) from cobas b121 (framing dropped).
|
|
558
|
-
- **No named per-vendor profile ships.** The engine, the registry and the safety gate are public; the built-in set is `default` plus the corpus-grounded `referenceCorpus`. Named profiles for cobas, Sysmex, ADVIA, Mindray and Snibe stay gated behind a public, vendor-attributed quirk document, and firsthand inspection of the public corpus found the record layer spec-clean for them.
|
|
559
|
-
- **Three behaviors are reasoned from this reader rather than cited to a clause.** LIS02-A2 sections 5.4 and 6.2 are withheld from CLSI's free sample and the paywalled editions were not read here, so the forward-scoping rule for redeclared delimiters, the Latin-1 wire encoding and the reserved-byte set are this package's reading, not a quotation.
|
|
560
|
-
- **The result-status letter set is bound by no citable published source**, and every interpreted status reports that in its `vocabulary` rather than implying an attribution it does not have. Abnormal flags are graded against HL7 v3 ObservationInterpretation and report that vocabulary too, recognized or not.
|
|
561
|
-
- **The wire is read as Latin-1**, one byte per character. A character above `U+00FF` handed to the frame encoder is a typed error rather than a silently truncated byte, and a raw `STX`, `ETB` or `ETX` byte in a record is refused for the same reason: framing has no escape sequence for them.
|
|
562
|
-
- **No LOINC, SNOMED or LIVD dictionary is bundled**, and no LOINC is ever guessed. You bring the catalog and its license obligations.
|
|
563
|
-
|
|
564
|
-
### What it covers
|
|
565
|
-
|
|
566
|
-
- **Records (E1394 / LIS02-A2).** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read with per-header
|
|
567
|
-
delimiter self-declaration and escape decode, so an escaped delimiter inside a value reads as one
|
|
568
|
-
component. Result semantics are modeled and fail-safe: abnormal flags graded against the HL7 v3
|
|
569
|
-
ObservationInterpretation code system, result status (a correction `C` or cancel `X` never reads as
|
|
570
|
-
active-final), reference ranges kept verbatim, and a missing unit flagged rather than defaulted.
|
|
571
|
-
Every interpreted flag and status reports the vocabulary it was graded against, recognized or not,
|
|
572
|
-
so an unknown code is distinguishable from one the library has not caught up to; the status letter
|
|
573
|
-
set reports that **no citable published source** binds it. The practice-, laboratory-, and third patient IDs stay
|
|
574
|
-
distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
|
|
575
|
-
a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
|
|
576
|
-
as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
|
|
577
|
-
surfaced verbatim rather than interpreted into clinical fields.
|
|
578
|
-
- **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
|
|
579
|
-
frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
|
|
580
|
-
untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
|
|
581
|
-
reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
|
|
582
|
-
cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
|
|
583
|
-
socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
|
|
584
|
-
for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
|
|
585
|
-
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
586
|
-
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
587
|
-
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
588
|
-
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
589
|
-
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
590
|
-
and lost. Those three read the set alone, so each record is additionally checked against the set it
|
|
591
|
-
is written with: a separator equal to a record's own type letter escapes that letter away and the
|
|
592
|
-
record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
|
|
593
|
-
output. That second check is about **transcoding**, not about which set you asked for, so it can
|
|
594
|
-
fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
|
|
595
|
-
whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
|
|
596
|
-
reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
|
|
597
|
-
byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
|
|
598
|
-
character encoding for you, and it never quietly writes a different character than the one you gave
|
|
599
|
-
it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
|
|
600
|
-
record is a typed error as well, in either form: those three are what the decoder reads as the shape
|
|
601
|
-
of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
|
|
602
|
-
that byte, silently losing a whole record whenever the two bytes after it happened to be the short
|
|
603
|
-
frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
|
|
604
|
-
wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
|
|
605
|
-
compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
|
|
606
|
-
record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
|
|
607
|
-
`7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
|
|
608
|
-
the **default** start; a non-default one writes a continuation of a sequence already in progress,
|
|
609
|
-
and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
|
|
610
|
-
about and not emitted.
|
|
611
|
-
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
612
|
-
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
613
|
-
ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
|
|
614
|
-
status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
|
|
615
|
-
unrecognized record type, and any frame or transport integrity warning. The gate runs when a
|
|
616
|
-
profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
|
|
617
|
-
plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
|
|
618
|
-
make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
|
|
619
|
-
message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
|
|
620
|
-
quirk document.
|
|
621
|
-
- **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
|
|
622
|
-
LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
|
|
623
|
-
mutates the raw code or value and never guesses a LOINC. The catalog is consulted whenever a
|
|
624
|
-
vendor local code is present and is keyed on that code alone; a value in the Universal Test ID's
|
|
625
|
-
first component is carried verbatim as an **unvalidated wire value**, never validated, never
|
|
626
|
-
reported as a LOINC, and never used as a lookup key. No LOINC, SNOMED, or LIVD dictionary is
|
|
627
|
-
bundled: the package stays a structural recognizer and you bring the catalog.
|
|
628
|
-
|
|
629
|
-
### An unescaped ampersand does not cost you the rest of the record
|
|
630
|
-
|
|
631
|
-
An escape sequence is the escape character, **one** body character, and the escape character again
|
|
632
|
-
(`&F&` `&S&` `&R&` `&E&`). An escape character that heads no such sequence is not an escape: it is
|
|
633
|
-
read as the literal character it is, it opens no atom, and `ASTM_UNPAIRED_ESCAPE_CHARACTER` reports
|
|
634
|
-
it. So `R|1|^^^687|28.6&|U/L||N||F` reads a value of `28.6&` with units `U/L` and status `final`,
|
|
635
|
-
and `O&Brien` in a surname keeps the patient's birth date and sex.
|
|
636
|
-
|
|
637
|
-
The parser does not decide what the sender meant by the character: it keeps the byte that arrived
|
|
638
|
-
and says so. The spec-clean way to send a literal escape character is `&E&`, which is what this
|
|
639
|
-
package's serializer emits, so a stream it produced never trips the code. The code is **tolerable**,
|
|
640
|
-
so a vendor profile can expect it on a feed that sends bare ampersands and still parse
|
|
641
|
-
`{ strict: true }`.
|
|
642
|
-
|
|
643
|
-
**One escape shape still costs a field boundary, and it has a code of its own.** A real
|
|
644
|
-
three-character sequence is opaque by design, which is what keeps `&F&` one token under a set that
|
|
645
|
-
names `F` as a delimiter. So where the body is an **unrecognized** character that is itself a
|
|
646
|
-
delimiter in force (`&|&` under the canonical set)
|
|
647
|
-
that delimiter does not split, and every field after it shifts: `R|1|^^^687|28.6&|&U/L||||F` reads a
|
|
648
|
-
value of `28.6&|&U/L` with no units and status `unspecified`. That reading is unchanged, and
|
|
649
|
-
narrowing the atom to change it would break the guarantee the atom exists for. What such a record now
|
|
650
|
-
raises, alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`, is
|
|
651
|
-
`ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which **no profile may tolerate**, so a
|
|
652
|
-
`{ strict: true }` parse refuses it even under the shipped `referenceCorpus`. The narrower code fires
|
|
653
|
-
only where the unrecognized body is one of the three splitting roles in force. Two exclusions are
|
|
654
|
-
deliberate: the escape role, because nothing splits on it, and every **recognized** mnemonic, because
|
|
655
|
-
`&F&` under a set naming `F` as the repeat delimiter is the sender escaping the field separator on
|
|
656
|
-
purpose, and reporting that would report the escape mechanism working as a defect.
|
|
657
|
-
|
|
658
|
-
Read it as a report, not a repair. It also does not survive a re-emit: the serializer rewrites the
|
|
659
|
-
preserved sequence into recognized mnemonics, and that stream says the same value unambiguously, so a
|
|
660
|
-
second-generation read is silent and is right about its own bytes. The first read of the wire bytes
|
|
661
|
-
is where the condition exists to be caught.
|
|
662
|
-
|
|
663
|
-
**The mirror of it costs a boundary in the other direction, and it also has a code of its own.**
|
|
664
|
-
Sequences are matched greedily and leftmost, so the escape character that closes one cannot also open
|
|
665
|
-
the next. Where it could have, the same bytes carry two alignments that disagree by one boundary:
|
|
666
|
-
`R|1|^^^687|28.6&Z&|&U/L||||F` reads a value of `28.6&Z&` and units of `&U/L` under the alignment
|
|
667
|
-
taken, and reads as a single unsplit field carrying both under the other. Every byte is preserved and
|
|
668
|
-
the leftmost reading is kept (picking the other one would be a different guess with no more evidence
|
|
669
|
-
behind it), but the boundary it hands you is a choice, so it raises
|
|
670
|
-
`ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, which **no profile may tolerate**. Both codes the condition
|
|
671
|
-
raised before (`ASTM_UNKNOWN_ESCAPE_SEQUENCE` and `ASTM_UNPAIRED_ESCAPE_CHARACTER`) are tolerable, so
|
|
672
|
-
a strict parse under a profile naming them used to accept it. Two exclusions again. A **recognized**
|
|
673
|
-
mnemonic before the delimiter is silent, because the reading taken interprets a construct (`&F&` is
|
|
674
|
-
the sender escaping a separator, which is what the mechanism is for) while the competitor's body is a
|
|
675
|
-
delimiter character the codec usually cannot interpret, so its own vocabulary prefers the reading
|
|
676
|
-
taken. That is **not** the same as the reading taken being conformant, and the exclusion is wider
|
|
677
|
-
than that argument: `28.6&F&|&U/L` reads a value of `28.6|` and units of `&U/L` while raising only
|
|
678
|
-
the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, and a declared set naming a mnemonic letter as a
|
|
679
|
-
splitting delimiter makes both alignments interpret one construct each with neither preferred. The
|
|
680
|
-
first of those is covered by a second code, below; the second is measured and recorded as an open
|
|
681
|
-
residue. The other exclusion is a delimiter with no escape character two positions past it, which is
|
|
682
|
-
no competing alignment at all. What does fire is a subset of what already raises
|
|
683
|
-
`ASTM_UNKNOWN_ESCAPE_SEQUENCE`. It does not survive a re-emit either, for the same reason as its
|
|
684
|
-
mirror: catch it on the first read.
|
|
685
|
-
|
|
686
|
-
**And where that gained boundary is a FIELD boundary, it moves a result's status, which is a
|
|
687
|
-
different question and so a different code.** The two alignments resume one character apart, so they
|
|
688
|
-
disagree about the bytes after the boundary, not only about it (they can resync later, and the
|
|
689
|
-
class where they do is named below). Where the escape character the
|
|
690
|
-
reading taken resumes on heads no sequence this reader can _interpret_ (none at all, or one whose
|
|
691
|
-
body is not a recognized mnemonic and is therefore preserved verbatim rather than read), that
|
|
692
|
-
reading bought the boundary with bytes it cannot read while the competing alignment is the one that
|
|
693
|
-
can use them. On the field separator that
|
|
694
|
-
matters clinically: `R|1|^^^687|28.6&F&|&U/L||||F` reads **nine** fields under the alignment taken
|
|
695
|
-
and **eight** under the other, so the sender's trailing `F` lands in field 9, the result status,
|
|
696
|
-
under the first and in no field at all under the second. The parse hands back units of `&U/L` and a
|
|
697
|
-
status of **`final`**, and both are consequences of the alignment rather than values the sender put
|
|
698
|
-
in those slots. That raises `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, which **no profile may
|
|
699
|
-
tolerate**; before it existed the only warning on that stream was the tolerable
|
|
700
|
-
`ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse under a legal profile accepted it. The reading is
|
|
701
|
-
unchanged: it reports the shift rather than repairing it. It is wired to the **field** separator
|
|
702
|
-
only, because a gained repeat or component boundary divides one field and so moves no field-indexed
|
|
703
|
-
slot. **That bound is a choice, not a consequence**: components are modeled inside a field, so a
|
|
704
|
-
gained repeat or component boundary does reach a modeled slot, and each of those two has a code of
|
|
705
|
-
its own below. It stays silent in exactly one case,
|
|
706
|
-
where the trailing escape character heads a sequence this reader RECOGNIZES: that is the escape
|
|
707
|
-
mechanism working, and refusing it would refuse well-formed traffic. That is the only tail on which
|
|
708
|
-
a stream's escaping can be clean, and so the only exclusion, **wherever the escape role is a
|
|
709
|
-
character distinct from the three splitting roles**. Where a header names the escape character in a
|
|
710
|
-
splitting role too, these codes can fire with neither escape report beside them, and what refuses the
|
|
711
|
-
stream is `ASTM_RECORD_DELIMITER_ROLE_COLLISION` instead. **That silence is a trade
|
|
712
|
-
and not a claim that nothing was lost there**: the gained field boundary is exactly as real, and on
|
|
713
|
-
that tail it is `warnings: []`, so `R|1|^^^687|28.6&F&|&F&U/L||||F` reads nine fields against the
|
|
714
|
-
competing alignment's eight and hands back a status of `final` with nothing reported at all. Read
|
|
715
|
-
the raw line when an escape character sits next to a delimiter, whether or not anything fired.
|
|
716
|
-
**The shift has one measured exception, and it is named rather than left to be found**: where the
|
|
717
|
-
sequence past the boundary carries the field separator itself as its body, the reading taken holds
|
|
718
|
-
that character inside an opaque atom while the competing alignment splits on it, so the two readings
|
|
719
|
-
read the **same number** of fields in **different places**. `R|1|^^^687|28.6&F&|&|&U/L||||F` reads
|
|
720
|
-
nine fields under both, with the status `F` in field 9 under both, and what differs is the units.
|
|
721
|
-
The code still fires there, which is over-reporting relative to the field indexes and never
|
|
722
|
-
under-reporting, and that class costs no stream its disposition, because
|
|
723
|
-
`ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE` has already refused it. It does not survive a re-emit
|
|
724
|
-
either: catch it on the first read.
|
|
725
|
-
|
|
726
|
-
**And where that gained boundary is a REPEAT boundary, nothing shifts and the field can still be
|
|
727
|
-
read short.** What the report says is that the field is read as more repeats than the competing
|
|
728
|
-
alignment gives it. A field's modeled value and components are taken from its **first repeat
|
|
729
|
-
alone**, so where the gained boundary is the **first** one everything past it stays on the wire,
|
|
730
|
-
stays in `repeats`, and leaves every modeled slot. On the canonical set, `R|1|^^^687|28.6&S&\&U/L|U/L||||F` reads a value of `28.6^`
|
|
731
|
-
and drops `&U/L`, and `R|1|&F&\&687|28.6|U/L||||F` reads a Universal Test ID of one component
|
|
732
|
-
holding a decoded field separator, so the local code `687` is in no modeled slot and the record is
|
|
733
|
-
left with no code to key on at all, only an unvalidated wire value nobody wrote. A patient name
|
|
734
|
-
loses its given and middle names the same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
|
|
735
|
-
before it existed the only warnings on those streams were tolerable ones, so a strict parse under a
|
|
736
|
-
legal profile accepted a truncated value and an emptied test identity. The reading is unchanged: it
|
|
737
|
-
reports the gained boundary rather than repairing it. **At a LATER boundary it fires and no modeled
|
|
738
|
-
slot moves**, because the first repeat is then the same under both readings; that is over-reporting
|
|
739
|
-
relative to the modeled slots and never under-reporting, and it is measured rather than assumed. Its
|
|
740
|
-
tail bound is the shift report's, for the same reason, and so is its one exclusion:
|
|
741
|
-
`28.6&R&\&R&U/L` is the repeat separator escaped, written, and escaped again, and refusing it would
|
|
742
|
-
refuse a well-formed stream. **That silence is a trade, not a claim that nothing was lost**:
|
|
743
|
-
`28.6&S&\&S&U/L` still reads a value of `28.6^` with `warnings: []`. **The truncation has the same
|
|
744
|
-
one exception as the shift**: where the sequence past the boundary carries the repeat separator
|
|
745
|
-
itself, the two readings read the same number of repeats in different places, so the field is not
|
|
746
|
-
read as more repeats at all, and that class was already refused by
|
|
747
|
-
`ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. It is wired to the **repeat** separator only; a gained
|
|
748
|
-
**component** boundary reaches a modeled slot differently, and has its own code below. It does not
|
|
749
|
-
survive a re-emit either: catch it on the first read.
|
|
750
|
-
|
|
751
|
-
**And where that gained boundary is a COMPONENT boundary, nothing leaves the record and the slots
|
|
752
|
-
MOVE.** Components are modeled inside a field, so every component after the gained boundary sits
|
|
753
|
-
further right than the competing alignment puts it, by a displacement that is **not fixed**: one
|
|
754
|
-
place where the field carries a single contested construct, one more for each additional one, and
|
|
755
|
-
none at all on the tie class. Counting warnings does not give it. On the canonical set,
|
|
756
|
-
`R|1|&F&^&GLU^L^687|28.6|U/L||||F` reads a Universal Test ID of four components, so `L` is the coding
|
|
757
|
-
scheme and `687` the vendor's local code; the competing alignment reads three, and `687` is the
|
|
758
|
-
**coding scheme**. A code-system selector and a vendor's local code are not the same thing.
|
|
759
|
-
`P|1||MRN-0001||DOE&F&^&JANE^A||19700101|F` reads a given name of `&JANE` and a middle name of `A`,
|
|
760
|
-
where the competing alignment makes `A` the **given** name with no middle name at all. That raises
|
|
761
|
-
`ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, which **no profile may tolerate**; before it existed the
|
|
762
|
-
only warning on either stream was the tolerable `ASTM_UNPAIRED_ESCAPE_CHARACTER`, so a strict parse
|
|
763
|
-
under a legal profile accepted both. The reading is unchanged: it reports the moved slots rather than
|
|
764
|
-
repairing them. **Every gained boundary at or before the last modeled component index moves them,
|
|
765
|
-
not only the first**, because the shift propagates to the end of the component list, which is where
|
|
766
|
-
this differs from the repeat role. **Two bounds run the other way and it fires inside both**,
|
|
767
|
-
over-reporting and never under: past the last modeled index nothing named moves (a name models three
|
|
768
|
-
components, a Universal Test ID four), and inside a later repeat nothing modeled moves at all. Its
|
|
769
|
-
tail bound is the other two reports', for the same reason, and so is its one exclusion, **and that
|
|
770
|
-
silence is a trade rather than a claim that nothing was lost**: `&F&^&F&GLU^L^687` still reads one
|
|
771
|
-
component more than the competing alignment, with `warnings: []`. **A THIRD bound runs the other way
|
|
772
|
-
here, and unlike the two above it is about the bytes past the boundary rather than where the
|
|
773
|
-
boundary sits**: where the sequence past it carries the component separator itself, the two readings
|
|
774
|
-
read the same number of components in different places, so `DOE&F&^&^&JANE^A` reads three components
|
|
775
|
-
under both with `A` the middle name under both, and that class was already refused by
|
|
776
|
-
`ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`. What holds wherever any of the three codes fires is
|
|
777
|
-
that the two readings disagree and that both consume every byte, so neither is forced. It does not
|
|
778
|
-
survive a re-emit either: catch it on the first read. With this the three splitting roles are all
|
|
779
|
-
wired, and there is no fourth: nothing splits on the escape role.
|
|
780
|
-
|
|
781
|
-
### A header that names one character in two delimiter roles
|
|
782
|
-
|
|
783
|
-
ASTM messages are self-describing: the `H` record declares the four delimiters and a conformant
|
|
784
|
-
reader follows them. A declaration whose **field** separator is also one of the other three is
|
|
785
|
-
refused outright, because the four roles would be indistinguishable. A declaration where two of the
|
|
786
|
-
_remaining_ three agree is still read, because the stream is readable and refusing it would drop
|
|
787
|
-
records the sender did send, but the boundary between those two roles is no longer in the bytes.
|
|
788
|
-
|
|
789
|
-
Under `H|^^&`, where the repeat and component roles are both `^`, a field a canonical sender would
|
|
790
|
-
have written as two repeats of two components reads back as four repeats of one component each.
|
|
791
|
-
Under `H|\&&`, where the component and escape roles are both `&`, the same character splits
|
|
792
|
-
(`A&B` is two components) or opens an escape atom (`A&F&B` is the single component `A|B`) depending
|
|
793
|
-
only on what follows it.
|
|
794
|
-
|
|
795
|
-
Such a header now raises `ASTM_RECORD_DELIMITER_ROLE_COLLISION`, at the header that put the set into
|
|
796
|
-
force, once rather than once per colliding pair. A later header restating the set already in force
|
|
797
|
-
warns nothing, on the same rule that governs the other delimiter warnings. The code is **not** tolerable. That matters
|
|
798
|
-
because every such set is by definition non-canonical, so the only warning it used to raise was
|
|
799
|
-
`ASTM_NONSTANDARD_DELIMITERS`, which is tolerable: a profile expecting an ordinary vendor set left a
|
|
800
|
-
`{ strict: true }` parse accepting a declaration whose own field tree cannot be recovered. Emit has
|
|
801
|
-
always refused these sets (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap first showed: a
|
|
802
|
-
message that parsed clean threw when it was serialized back against its own declared delimiters.
|
|
125
|
+
**One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone, and comment free text is not implemented here. It guards this repository's own fixtures, tests, and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
|
|
803
126
|
|
|
804
127
|
## Contributing
|
|
805
128
|
|
|
806
|
-
Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum.
|
|
129
|
+
Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum. The organization's [contributing guide](https://github.com/cosyte/.github/blob/main/CONTRIBUTING.md) and [code of conduct](https://github.com/cosyte/.github/blob/main/CODE_OF_CONDUCT.md) apply here.
|
|
807
130
|
|
|
808
131
|
A contribution has to clear the same gates CI runs, and every one of them runs locally:
|
|
809
132
|
|
|
@@ -819,10 +142,14 @@ pnpm run check:no-internal-refs
|
|
|
819
142
|
pnpm run phi-scan
|
|
820
143
|
```
|
|
821
144
|
|
|
822
|
-
Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs and every JSDoc comment say what the software does, never how the change got made.
|
|
145
|
+
Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example, or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em or en dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period, or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs, and every JSDoc comment say what the software does, never how the change got made.
|
|
823
146
|
|
|
824
147
|
A behavior change needs a test that fails without it. Renaming a published warning or error code is a breaking change; adding one is not, though it can change which streams a `{ strict: true }` parse refuses, so say so.
|
|
825
148
|
|
|
149
|
+
## Trademarks
|
|
150
|
+
|
|
151
|
+
ASTM, CLSI, LOINC, SNOMED CT, and the analyzer names in this package's documentation are the property of their owners, and Cosyte is not affiliated with, endorsed by, or sponsored by any of them. The names identify the standards this package reads and the analyzers whose behavior it describes. See [TRADEMARKS.md](https://github.com/cosyte/astm/blob/main/TRADEMARKS.md).
|
|
152
|
+
|
|
826
153
|
## License
|
|
827
154
|
|
|
828
|
-
MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).
|
|
155
|
+
MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte LLC. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).
|