@cosyte/astm 0.0.10 → 0.0.13
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 +247 -0
- package/README.md +13 -2
- package/dist/index.cjs +35 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +127 -36
- package/dist/index.d.ts +127 -36
- package/dist/index.mjs +35 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,215 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
11
11
|
|
|
12
12
|
### Fixed
|
|
13
13
|
|
|
14
|
+
- **A delimiter set colliding with a record's type letter emitted a stream that read back as
|
|
15
|
+
DIFFERENT records, and in its silent branch it fabricated a final lab result out of patient
|
|
16
|
+
identifiers** (`ASTM-FRAME-RESIDUALS`, defect 5). `PRE-EXISTING`, byte-identical on `7253098`,
|
|
17
|
+
before any of `ASTM-EMIT-RESIDUALS`. `serializeAstmRecords` / `serializeAstmRecord` now throw
|
|
18
|
+
`AstmSerializeError` with the new code `ASTM_EMIT_TYPE_LETTER_COLLISION` (a third member of
|
|
19
|
+
`AstmSerializeErrorCode`), carrying the `recordIndex` and quoting nothing from the record: not the
|
|
20
|
+
value, and not the type letter either, which on an unsupported record is a byte off the wire.
|
|
21
|
+
|
|
22
|
+
**The recorded defect said the record "re-reads as an unsupported record, one result in, zero out".
|
|
23
|
+
That describes one branch of two, and the branch it does not describe is silent.** A record's type
|
|
24
|
+
letter is just another leaf to the escape encoder, so a set naming that letter escapes it away: an
|
|
25
|
+
`R` record emitted with `field` = `R` goes out as `&F&R1R…`, reads back as `unsupported`, and
|
|
26
|
+
`ASTM_RECORD_UNKNOWN_TYPE` does fire. But the encoder writes an escaped character as
|
|
27
|
+
escape + mnemonic + escape, so **when the escape character is itself a record type letter the
|
|
28
|
+
escaped type letter begins with a real letter** and no unknown-type warning fires at all. Measured:
|
|
29
|
+
a `P` record emitted with `field` = `P` and `escape` = `R` comes back as an `R` record whose
|
|
30
|
+
`value` is the patient's laboratory ID, whose `units` are the practice-assigned identifier, and
|
|
31
|
+
whose `resultStatus` is `F`, so `results()` returns a fabricated **final** result built out of
|
|
32
|
+
patient identifiers. The stream's only warning is `ASTM_NONSTANDARD_DELIMITERS`, which is what a
|
|
33
|
+
**clean** non-canonical stream carries too and which is on the profile safety gate's tolerable
|
|
34
|
+
allow-list, so `{ strict: true }` accepts it under a profile the gate permits. That allow-list
|
|
35
|
+
entry reasons that "a record's type letter is the first character of its line" is read before any
|
|
36
|
+
delimiter-driven tokenization: true of the parse, and no protection when the **emit** is what chose
|
|
37
|
+
that character.
|
|
38
|
+
|
|
39
|
+
**The rule is derived from the reader and checked on the bytes, not on the delimiter set.**
|
|
40
|
+
`parseAstmRecords` takes a record's type from `line.charAt(0)`, so the condition emit has to meet
|
|
41
|
+
is that the first character it writes is the letter the record models. Testing the output rather
|
|
42
|
+
than enumerating dangerous roles is what makes a type letter equal to the **escape** character fall
|
|
43
|
+
out as accepted without a special case: it is written starting with that letter, so the record
|
|
44
|
+
re-reads as its own type. The familiar `letter` + `E` + `letter` shape is not general and the guard
|
|
45
|
+
does not rely on it: the encoder protects the escape character it introduces but not the `E`/`F`/`S`
|
|
46
|
+
mnemonics, so under `{ field: "E", escape: "R" }` an `R` encodes to `RRFRR` and its type field
|
|
47
|
+
decodes back to `RER`, not `R`. What holds across every such set is the only thing checked, the
|
|
48
|
+
first character written. Measured over 137,632
|
|
49
|
+
delimiter sets (two roles at a time, every printable ASCII character each, against a stream
|
|
50
|
+
carrying one record of every modeled type plus an unsupported one), the refusal is
|
|
51
|
+
**biconditional** with the old serializer losing a type letter: zero over-refusals and zero
|
|
52
|
+
under-refusals. Over a second sweep of 3,690 emits across nine record-set shapes, 750 streams
|
|
53
|
+
previously read back as something other than the records that produced them, **303 of them accepted
|
|
54
|
+
by `{ strict: true }`**; all 750 are now refused and the remaining 2,940 are byte-unchanged.
|
|
55
|
+
|
|
56
|
+
This is a **narrowing on a published package**, in exactly the cases that were being corrupted.
|
|
57
|
+
**It is a transcoding condition, not a judgement on the set the caller passed, so it fires with no
|
|
58
|
+
delimiter argument at all** and "emit against the canonical delimiters" is not a remedy for it: a
|
|
59
|
+
stream whose header declares a vendor set and which carries one garbled line beginning `|` parses
|
|
60
|
+
to an unsupported record whose type letter is `|`, and the canonical set escapes that `|` away, so
|
|
61
|
+
`serializeAstmRecords(msg)`, `serializeAstmRecord(record)` and `serializeFramedAstm(msg)` all
|
|
62
|
+
refuse it where the base emitted a record whose `rawType` came back as `&`. Callers passing a set
|
|
63
|
+
explicitly (`serializeAstmRecords(msg, msg.delimiters)`) reach it too. The three
|
|
64
|
+
set-level conditions are unchanged and still raise `ASTM_EMIT_INVALID_DELIMITERS`; this is a
|
|
65
|
+
separate, per-record check because whether a set collides depends on which record is being written.
|
|
66
|
+
**What it does not promise, stated rather than left to be found:** it guarantees a record re-reads
|
|
67
|
+
as its own **type**, not that every field lands where it did (an escape sequence whose body is a
|
|
68
|
+
delimiter is still an opaque atom, reported on the parse side as `ASTM_UNKNOWN_ESCAPE_SEQUENCE`),
|
|
69
|
+
and `encodeComponent` / `serializeField` take no record, so a caller assembling a line out of them
|
|
70
|
+
is outside the check. Pinned in `test/records/type-letter-collision.test.ts`, asserted on what the
|
|
71
|
+
old serializer produced, rebuilt from the shipped `serializeField` so nothing is transcribed twice.
|
|
72
|
+
**No clause is claimed**: LIS02-A2 §5.4/§6.2 stay withheld from CLSI's free sample and the
|
|
73
|
+
paywalled editions were not read; the grounding is this package's own reader.
|
|
74
|
+
|
|
75
|
+
- **A raw `STX`, `ETB` or `ETX` byte in a value was framed as given, and in its worst branch a whole
|
|
76
|
+
result record disappeared into the previous record's text with `warnings: []` at BOTH layers**
|
|
77
|
+
(`ASTM-RAW-ETX-SWALLOWS-A-RECORD`). `PRE-EXISTING`, shipped since the frame encoder did.
|
|
78
|
+
`composeAstmFrames` now throws `AstmFrameEncodeError` with the new code `ASTM_FRAME_RESERVED_BYTE`
|
|
79
|
+
(a third member of `AstmFrameEncodeErrorCode`), carrying `recordIndex` and `characterIndex` and
|
|
80
|
+
never the bytes.
|
|
81
|
+
|
|
82
|
+
**The recorded reason for deferring this said it "fails loudly". That was false, and the
|
|
83
|
+
correction is what re-ranked it.** A frame's text ends at the first `ETB`/`ETX` after its `STX`, so
|
|
84
|
+
an embedded one truncates the frame there, and the two bytes that follow are then read as that
|
|
85
|
+
short frame's checksum. When they match, the truncated frame **verifies**, `decode.ts` resumes at
|
|
86
|
+
`termIndex + 3` and skips the rest of the record as inter-frame bytes, and the next frame number is
|
|
87
|
+
still in sequence. Measured on this package's own round trip: a `C` comment ending `…HEMOLYZED` +
|
|
88
|
+
`ETX` + the two matching characters reassembled without its terminating `CR`, so the following `R`
|
|
89
|
+
merged into the comment's free text (`"SPECIMEN SLIGHTLY HEMOLYZEDR"`) and a `28.6 U/L` result
|
|
90
|
+
vanished, `results()` returning `[]` where the input carried one. **An embedded `ETB` reaches the
|
|
91
|
+
same silence by the other door**, which the defect record did not have: it leaves the record open,
|
|
92
|
+
so the _next_ record's text is appended to the truncated one and two records read back as one, with
|
|
93
|
+
every field of the result hanging off the comment. The precondition is a coincidence in the two
|
|
94
|
+
bytes that follow: they must both read as hex digits and must equal the truncated frame's checksum,
|
|
95
|
+
which is about 1 in 34,600 for uniform-random bytes (1 in 256 once they are hex digits, which is
|
|
96
|
+
where the earlier 1-in-256 figure came from), and trivially constructible on purpose. That bounds
|
|
97
|
+
the likelihood, not the harm.
|
|
98
|
+
Both silent branches, and the loud ones, are pinned in `test/frames/reserved-structure-byte.test.ts`
|
|
99
|
+
asserted on what the old encoder produced, so a weakened guard reds a test rather than passing
|
|
100
|
+
unnoticed.
|
|
101
|
+
|
|
102
|
+
**The refusal has no bytes-instead escape hatch, unlike the `U+00FF` one beside it.** A
|
|
103
|
+
`Uint8Array` is checked too, because the byte is unframable however it arrives: framing has no
|
|
104
|
+
escape sequence for it, and emit has no warning channel, so the only alternatives to refusing are
|
|
105
|
+
substituting a byte the sender did not send or dropping one they did. The three bytes are derived
|
|
106
|
+
from what `decodeAstmFrames` actually reads as structure, not from a control-character class:
|
|
107
|
+
`CR`/`LF` are deliberately absent (they are read only _after_ a frame's checksum, and a record's own
|
|
108
|
+
`CR` sits inside frame text on every stream this encoder writes), and `ENQ`/`ACK`/`NAK`/`EOT` are
|
|
109
|
+
absent too (structure only _between_ frames; measured to round-trip byte-exactly inside one).
|
|
110
|
+
**No clause is claimed:** LIS02-A2 §5.4/§6.2 are withheld from CLSI's free sample and the paywalled
|
|
111
|
+
editions were not read. The grounding is this library's own decoder, in this repo.
|
|
112
|
+
|
|
113
|
+
**The record layer is deliberately UNCHANGED, and that was the cost this slice turned on.**
|
|
114
|
+
Refusing the byte in `serializeAstmRecord` would refuse a byte the caller genuinely supplied, for
|
|
115
|
+
consumers who never frame anything. Measured, in every modeled value: all three
|
|
116
|
+
bytes round-trip through parse → serialize → parse byte for byte, value, units and status intact,
|
|
117
|
+
byte-stable, `warnings: []` on both generations. The byte becomes structure only when a frame is
|
|
118
|
+
built, and `composeAstmFrames` is the total gate on that route, including through
|
|
119
|
+
`serializeFramedAstm`. `CR`/`LF` remain the record layer's own refusal, because they end a _record_.
|
|
120
|
+
|
|
121
|
+
**That claim is about VALUES, and one position on a record line is not a value.** The surplus of a
|
|
122
|
+
header's delimiter declaration drops any control character silently on emit, so a header that
|
|
123
|
+
arrived as `H|\^&` + `ETX` goes back out without it, `warnings: []`, byte-stable, and the
|
|
124
|
+
round-trip is not byte-exact there. `PRE-EXISTING` and deliberately unchanged: that rule is argued
|
|
125
|
+
at `declarationResidual` and long predates the frame-layer refusal, and it is the better of the two
|
|
126
|
+
dispositions now, since carrying the byte through would turn a spec-clean header into a refused
|
|
127
|
+
stream. Pinned in `test/frames/reserved-structure-byte.test.ts` so the scoped sentence stays
|
|
128
|
+
measured. Found by the `conformance-refuter` grading this slice.
|
|
129
|
+
|
|
130
|
+
**What this does not close.** A delimiter colliding with a record's type letter still frames and
|
|
131
|
+
de-frames byte-exactly and re-reads as a different record; framing integrity is not record-layer
|
|
132
|
+
readback. And a caller reaching `composeAstmFrames` from JavaScript with some other typed array is
|
|
133
|
+
still outside the declared signature, and **this guard does not reach that route either**: the
|
|
134
|
+
check compares elements against the three byte values, so a `Uint16Array` element of `0x0103` is
|
|
135
|
+
not one of them and is not refused, while `Uint8Array.from` later takes its low byte and writes an
|
|
136
|
+
`ETX` after all. Measured on all three (`0x0102`/`0x0103`/`0x0117`): framed, then lost at decode
|
|
137
|
+
with a bad checksum or an unterminated frame. That is the same out-of-signature residue already
|
|
138
|
+
recorded for the low-byte truncation, unchanged here in either direction, and stated rather than
|
|
139
|
+
left to be found. Pass a `Uint8Array` or a string.
|
|
140
|
+
|
|
141
|
+
- **A symbolic link under a scan root read CLEAN on BOTH of the PHI scanner's enumerating routes, so
|
|
142
|
+
a link pointing at a file full of real identifiers passed the gate twice over**
|
|
143
|
+
(`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`). Development tooling only: `scripts/phi-scan.ts` ships in
|
|
144
|
+
no tarball and the package's public surface is unchanged.
|
|
145
|
+
|
|
146
|
+
Reproduced on `8e8012d` before any fix, with a synthetic name-bearing payload (an ASTM `P` record
|
|
147
|
+
carrying a patient name, a mother's maiden name and a birthdate, plus a dashed SSN and an email at
|
|
148
|
+
a non-test domain) placed outside the walk roots and a link to it at `src/leak.ts`. `pnpm phi-scan`
|
|
149
|
+
printed `OK: no hits` and exited **0**; `pnpm phi-scan --staged`, after `git add`, printed
|
|
150
|
+
`OK: no hits` and exited **0**. Naming the link's target explicitly on the command line reported
|
|
151
|
+
**six** hits and exited 1, so the payload was always detectable, and detectable by this package's
|
|
152
|
+
own structured `P`-record detectors rather than only by the cross-cutting SSN/email floor. The two
|
|
153
|
+
routes simply never looked at it.
|
|
154
|
+
|
|
155
|
+
The two blindnesses are separate mechanisms and needed separate fixes. The walk enumerates
|
|
156
|
+
`Dirent.isFile()`, which is an **lstat** answer, so a link is neither a file nor a directory and
|
|
157
|
+
fell out of the loop with no record that anything had been skipped, and `isDirectory()` answers
|
|
158
|
+
false for a linked directory too, so a whole subtree could disappear the same way. The `--staged`
|
|
159
|
+
route reads content with `git show :<path>`, and **git stores a symbolic link as its target path
|
|
160
|
+
under mode `120000`**, so it was handed the target's path text and scanned that. That second route
|
|
161
|
+
is this repo's `pre-commit` hook, which is exactly where the claim that `--staged` covers a link
|
|
162
|
+
would have been trusted.
|
|
163
|
+
|
|
164
|
+
**Neither route is made to follow the link.** Following would read bytes the enumeration does not
|
|
165
|
+
control (outside the repo, a loop, a device, a FIFO that blocks the gate forever) and git does not
|
|
166
|
+
carry those bytes anyway, so a hit on them would be a claim about something no commit contains.
|
|
167
|
+
Instead the enumeration is narrowed: an entry under a scan root that is not a regular file
|
|
168
|
+
**refuses the scan** (exit 2, the existing "could not complete" code), naming every offender rather
|
|
169
|
+
than the first. The walk classifies by `Dirent` (symbolic link, FIFO, socket, block device,
|
|
170
|
+
character device); `--staged` now reads `git diff --cached --raw -z` instead of `--name-only` so
|
|
171
|
+
the destination mode is visible, and refuses mode `120000` and `160000` (a gitlink) wherever that
|
|
172
|
+
route's own path scope reaches them. A `--raw` record that does not parse refuses as well, rather
|
|
173
|
+
than being skipped into a silently shortened list.
|
|
174
|
+
|
|
175
|
+
**`T` (typechange) is in the `--diff-filter`, and leaving it out would have made the mode check
|
|
176
|
+
unreachable whenever the file being replaced was already tracked.** Replacing a **tracked** regular
|
|
177
|
+
file with a link is neither an add nor a modify: git raises `:100644 120000 <sha> <sha> T`, so
|
|
178
|
+
`--diff-filter=AM` deletes the record before any mode can be read. Measured on git 2.39.5 on this
|
|
179
|
+
tree: with `AM` the raw output for that stage is empty, and the unfixed scanner passed that stage
|
|
180
|
+
green. Typechange carries a single path, exactly like `A` and `M`, so admitting it costs the record
|
|
181
|
+
stride nothing, and it means the reverse typechange, a link replaced by a real file, is now scanned
|
|
182
|
+
as the ordinary file it became.
|
|
183
|
+
|
|
184
|
+
**A refusal names the entry's own repo-relative path and an engine-owned token for its kind, and
|
|
185
|
+
never the link target**, because a target path is text off the working tree and can itself carry
|
|
186
|
+
PHI. That is asserted rather than argued: the pinning payload and the target's own filename both
|
|
187
|
+
carry a synthetic person name, and every refusal message is checked to contain none of it. 10 of
|
|
188
|
+
the file's 30 cases are red on `8e8012d`.
|
|
189
|
+
|
|
190
|
+
**What this does not cover, stated narrowly, each measured.** Explicit-path mode already read
|
|
191
|
+
through a link and reported the target's hits; it is unchanged. The `--staged` path scope is
|
|
192
|
+
unchanged, still `test/fixtures/**` and `src/**.ts`, so a staged link outside it is still not
|
|
193
|
+
looked at; narrowing what a scope admits is not widening the scope. That scope also bounds the
|
|
194
|
+
gitlink half: a submodule staged at `test/fixtures/nested` is refused, while one at `src/nested` is
|
|
195
|
+
a directory name that fails the `.ts` suffix and is not looked at. `R` (rename) and `C` (copy) are
|
|
196
|
+
still **not** enumerated by `--staged` at all, so a staged rename that also appends PHI passes that
|
|
197
|
+
route; pre-existing, unchanged here, and admitting it needs the two-path record shape handled,
|
|
198
|
+
which is a scope decision rather than this one. A tracked file **absent from the worktree** is
|
|
199
|
+
still caught at `git add` time only.
|
|
200
|
+
|
|
201
|
+
**And this scanner still has no rule that a sweep observing ZERO targets must refuse**, which is
|
|
202
|
+
worth naming rather than leaving to be discovered. Measured: run from a tree with no `src/` and no
|
|
203
|
+
`test/fixtures/`, all-mode prints `OK: no hits` and exits 0, so a clean report can be a report
|
|
204
|
+
about a tree the scan never looked at, and **nothing backstops it**: the all-mode sweep is the
|
|
205
|
+
route with the hole, so it cannot be its own compensating control, and neither the `pre-commit`
|
|
206
|
+
hook nor CI asserts that the sweep observed anything. Five of the sibling scanners carry that rule
|
|
207
|
+
(`ccda`, `hl7`, `mllp`, `ncpdp`, `synth`) and the rest, this one included, do not; do not read that
|
|
208
|
+
as a majority practice. It is unchanged by this slice in either direction, and adding it is its own
|
|
209
|
+
slice, because the threshold is a judgement about this repo's corpus rather than a consequence of
|
|
210
|
+
anything here.
|
|
211
|
+
|
|
212
|
+
**The enumerate-then-read race is deliberately NOT closed here, and the deferral is measured
|
|
213
|
+
rather than assumed.** The scanner still has no tolerance for a file that vanishes between
|
|
214
|
+
enumeration and read, so a transient appearing under a walk root can refuse a whole sweep (exit 2,
|
|
215
|
+
fails closed). This repo's walk roots are `test/fixtures/` and `src/`, and nothing was observed
|
|
216
|
+
landing in either: polling both roots continuously across a real `pnpm build` saw only the tracked
|
|
217
|
+
corpus, while `tsup.config.bundled_*.mjs` (the transient that blocked a sibling's publish) appeared
|
|
218
|
+
at the **repo root**, which is not a walk root here. This repo's own suite writes its throwaway
|
|
219
|
+
trees under the OS temp directory, not under a scan root. So the shape is present and its trigger
|
|
220
|
+
is not, which makes closing it a separate slice with its own argument rather than a rider on a PHI
|
|
221
|
+
blindness fix. It is a different defect and it fails in the safe direction.
|
|
222
|
+
|
|
14
223
|
- **The frame encoder no longer writes a different character than the one it was handed**
|
|
15
224
|
(`ASTM-FRAME-BYTE-RESIDUALS`, closing known defect 7). `composeAstmFrames` turned a record given
|
|
16
225
|
as a `string` into bytes with `charCodeAt(i) & 0xff`, so any character above `U+00FF` was replaced
|
|
@@ -76,6 +285,44 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
76
285
|
refuse the record string or reproduce it byte for byte" is asserted over 2,000 generated cases,
|
|
77
286
|
with its bound written into the test and a non-vacuity check that both sides of it are reached.
|
|
78
287
|
|
|
288
|
+
- **The `attw` publish gate now fails when the packed tarball carries no types, where the CLI exits
|
|
289
|
+
`0`** (`ATTW-FALSE-GREEN-PORT`). The `attw` script was the bare `attw --pack .`.
|
|
290
|
+
`@arethetypeswrong/cli` opens `getExitCode()` with `if (!analysis.types) return 0`, returning
|
|
291
|
+
before it ever reads the problem list, because an untyped package is a legitimate npm package and
|
|
292
|
+
the CLI treats "no types at all" as a description rather than a problem. No `--profile`,
|
|
293
|
+
`--ignore-rules` or config setting reaches that branch. For a package that ships types the same
|
|
294
|
+
result means the declarations were **not in the tarball**, which is a broken publish reported as a
|
|
295
|
+
pass. A false red costs an hour; a false green merges.
|
|
296
|
+
|
|
297
|
+
Measured on this package at `0.0.9`, on a quiet box with **no** concurrency, both states printing
|
|
298
|
+
"This package does not contain types." and exiting `0`: `rm -rf dist`, and
|
|
299
|
+
`rm -f dist/index.d.ts dist/index.d.cts`. The second is the realistic one, and the trigger is the
|
|
300
|
+
build rather than any race: timed on one real `tsup` run here, `dist/index.mjs` and
|
|
301
|
+
`dist/index.cjs` appeared at 1,176 ms and `dist/index.d.ts` and `dist/index.d.cts` at 3,063 ms, a
|
|
302
|
+
**1,887 ms window** in which `dist/` held JS and no declarations. A concurrent build or `clean` in
|
|
303
|
+
the same working tree lands the gate in that window. This is deliberately **not** answered with a
|
|
304
|
+
lock or a build queue: the gate has to be able to report that its own inputs were missing,
|
|
305
|
+
whatever removed them.
|
|
306
|
+
|
|
307
|
+
`attw` is now `node scripts/attw.mjs`, which keeps two nets because they catch different things. A
|
|
308
|
+
**preflight** checks that every relative path `package.json` promises (`main`, `module`, `types`,
|
|
309
|
+
`typings`, and every string leaf of `exports`) exists and is non-empty, and names the missing file
|
|
310
|
+
rather than leaving it to be inferred. A **post-check** promotes `attw`'s untyped sentence to a
|
|
311
|
+
failure, catching what the preflight structurally cannot: declarations present on disk but
|
|
312
|
+
excluded from the tarball by `files` or an `.npmignore`. No instance of that second case is on
|
|
313
|
+
record in this repo. Because the post-check reads a printed string, the routes that would hide it
|
|
314
|
+
are refused rather than tolerated: `--quiet`, `--format`, a `.attw.json` setting either, and
|
|
315
|
+
`--config-path` at a file setting either were each measured here to restore the exact exit `0`. The
|
|
316
|
+
refusal is by option name, wholesale, not by value, and the list is a record of what is closed
|
|
317
|
+
rather than a proof that the post-check cannot be blinded at all: `--definitely-typed` suppresses
|
|
318
|
+
the sentence by making the analysis typed, is equally true of the bare CLI, and is deliberately not
|
|
319
|
+
refused. The preflight is the net that reads no string. Other arguments are forwarded, so
|
|
320
|
+
`--profile node16` still works. `test/scripts/attw-gate.test.ts` pins the upstream exit `0` itself,
|
|
321
|
+
so an `attw` upgrade that fixes it or rewords the sentence reds the suite instead of letting the
|
|
322
|
+
net go quietly slack, and it carries a negative control plus a real `attw` failure, because a gate
|
|
323
|
+
that only ever fails is not a gate and one that swallows the status is not one either. **No
|
|
324
|
+
runtime code changed and the built output is identical.**
|
|
325
|
+
|
|
79
326
|
### Added
|
|
80
327
|
|
|
81
328
|
- **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
|
package/README.md
CHANGED
|
@@ -39,10 +39,21 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
|
39
39
|
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
40
40
|
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
41
41
|
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
42
|
-
and lost.
|
|
42
|
+
and lost. Those three read the set alone, so each record is additionally checked against the set it
|
|
43
|
+
is written with: a separator equal to a record's own type letter escapes that letter away and the
|
|
44
|
+
record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
|
|
45
|
+
output. That second check is about **transcoding**, not about which set you asked for, so it can
|
|
46
|
+
fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
|
|
47
|
+
whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
|
|
48
|
+
reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
|
|
43
49
|
byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
|
|
44
50
|
character encoding for you, and it never quietly writes a different character than the one you gave
|
|
45
|
-
it. Encode such content yourself and pass the `Uint8Array`.
|
|
51
|
+
it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
|
|
52
|
+
record is a typed error as well, in either form: those three are what the decoder reads as the shape
|
|
53
|
+
of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
|
|
54
|
+
that byte, silently losing a whole record whenever the two bytes after it happened to be the short
|
|
55
|
+
frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
|
|
56
|
+
wire and a raw-transport consumer round-trips such a value exactly.
|
|
46
57
|
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
47
58
|
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
48
59
|
ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
|
package/dist/index.cjs
CHANGED
|
@@ -1387,7 +1387,8 @@ var AstmSerializeError = class extends Error {
|
|
|
1387
1387
|
/**
|
|
1388
1388
|
* Stable discriminant. `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value;
|
|
1389
1389
|
* `ASTM_EMIT_INVALID_DELIMITERS` for a delimiter set that cannot be emitted
|
|
1390
|
-
* reversibly
|
|
1390
|
+
* reversibly; `ASTM_EMIT_TYPE_LETTER_COLLISION` for a record whose own type
|
|
1391
|
+
* letter the set being emitted with would escape away.
|
|
1391
1392
|
*/
|
|
1392
1393
|
code;
|
|
1393
1394
|
/** 0-based ordinal of the record within the message, when known. */
|
|
@@ -1451,10 +1452,24 @@ function serializeAstmRecord(record, d = CANONICAL_DELIMITERS) {
|
|
|
1451
1452
|
return serializeRecordChecked(record, d);
|
|
1452
1453
|
}
|
|
1453
1454
|
function serializeRecordChecked(record, d) {
|
|
1455
|
+
return assertTypeLetterSurvives(serializeRecordText(record, d), record);
|
|
1456
|
+
}
|
|
1457
|
+
function serializeRecordText(record, d) {
|
|
1454
1458
|
if (record.type === "M" || record.type === "S") return serializeVerbatimRecord(record, d);
|
|
1455
1459
|
if (record.type === "H") return serializeHeader(record, d);
|
|
1456
1460
|
return record.fields.map((f) => encodeField(f.repeats, d, record.recordIndex)).join(d.field);
|
|
1457
1461
|
}
|
|
1462
|
+
function recordTypeLetter(record) {
|
|
1463
|
+
return record.type === "unsupported" ? record.rawType : record.type;
|
|
1464
|
+
}
|
|
1465
|
+
function assertTypeLetterSurvives(text, record) {
|
|
1466
|
+
if (text.charAt(0) === recordTypeLetter(record)) return text;
|
|
1467
|
+
throw new AstmSerializeError(
|
|
1468
|
+
"A record's type letter is escaped away by the delimiters being emitted with, so the record would read back as a different record. The record's index is on the error; the letter itself is not quoted, because on an unsupported record it is a byte off the wire.",
|
|
1469
|
+
record.recordIndex,
|
|
1470
|
+
"ASTM_EMIT_TYPE_LETTER_COLLISION"
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1458
1473
|
function serializeVerbatimRecord(record, d) {
|
|
1459
1474
|
if (recoversVerbatim(record, d)) return record.rawLine;
|
|
1460
1475
|
return record.fields.map((f) => encodeField(f.repeats, d, record.recordIndex)).join(d.field);
|
|
@@ -2229,7 +2244,9 @@ var AstmFrameEncodeError = class extends Error {
|
|
|
2229
2244
|
/**
|
|
2230
2245
|
* Position of the offending character within that record, when applicable:
|
|
2231
2246
|
* its index in the string, never the character itself and never its code
|
|
2232
|
-
* point. Enough to find it in the caller's own data.
|
|
2247
|
+
* point. Enough to find it in the caller's own data. For a record supplied as
|
|
2248
|
+
* a `Uint8Array` this is the offending byte's index, which is the same
|
|
2249
|
+
* position: a record string is a byte string, so the two indices coincide.
|
|
2233
2250
|
*/
|
|
2234
2251
|
characterIndex;
|
|
2235
2252
|
/** @internal */
|
|
@@ -2259,6 +2276,20 @@ function toBytes(input, recordIndex) {
|
|
|
2259
2276
|
}
|
|
2260
2277
|
return out;
|
|
2261
2278
|
}
|
|
2279
|
+
var RESERVED_STRUCTURE_BYTES = /* @__PURE__ */ new Set([STX, ETB, ETX]);
|
|
2280
|
+
function assertFramable(bytes, recordIndex) {
|
|
2281
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
2282
|
+
const byte = bytes[i];
|
|
2283
|
+
if (byte !== void 0 && RESERVED_STRUCTURE_BYTES.has(byte)) {
|
|
2284
|
+
throw new AstmFrameEncodeError(
|
|
2285
|
+
"A record contains a byte the framing layer reads as structure (STX, ETB or ETX). A frame has no escape sequence for one, so it cannot be carried inside a frame: remove it from the record before framing.",
|
|
2286
|
+
recordIndex,
|
|
2287
|
+
"ASTM_FRAME_RESERVED_BYTE",
|
|
2288
|
+
i
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2262
2293
|
function encodeFrame(text, frameNumber, isFinal) {
|
|
2263
2294
|
const term = isFinal ? ETX : ETB;
|
|
2264
2295
|
const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
|
|
@@ -2280,6 +2311,7 @@ function composeAstmFrames(records, options = {}) {
|
|
|
2280
2311
|
recordIndex
|
|
2281
2312
|
);
|
|
2282
2313
|
}
|
|
2314
|
+
assertFramable(bytes, recordIndex);
|
|
2283
2315
|
for (let offset = 0; offset < bytes.length; offset += MAX_FRAME_TEXT) {
|
|
2284
2316
|
const chunk = bytes.subarray(offset, offset + MAX_FRAME_TEXT);
|
|
2285
2317
|
const isFinal = offset + MAX_FRAME_TEXT >= bytes.length;
|
|
@@ -2315,7 +2347,7 @@ function concatBytes3(chunks) {
|
|
|
2315
2347
|
}
|
|
2316
2348
|
|
|
2317
2349
|
// src/index.ts
|
|
2318
|
-
var VERSION = "0.0.
|
|
2350
|
+
var VERSION = "0.0.13";
|
|
2319
2351
|
|
|
2320
2352
|
exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
|
|
2321
2353
|
exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
|