@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 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. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
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.10";
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;