@cosyte/astm 0.0.11 → 0.0.14

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,202 @@ this file is maintained by hand (Changesets handles the version bump and publish
11
11
 
12
12
  ### Fixed
13
13
 
14
+ - **`composeAstmFrames` wrote an out-of-range `startFrameNumber` straight into the frame-number
15
+ position, so a value that was not a frame number produced a stream whose records the decoder
16
+ refused to emit** (`ASTM-FRAME-RESIDUALS`, defect 13). `PRE-EXISTING`, byte-identical on `64c2fd5`.
17
+ The option is now checked before `composeAstmFrames` reads a record, and refused with the new code
18
+ `ASTM_FRAME_INVALID_START_FRAME_NUMBER` (a fourth member of `AstmFrameEncodeErrorCode`) unless it is
19
+ a whole number from `0` to `7`.
20
+
21
+ **What went out instead, measured on this package's own round trip.** A frame's number is one ASCII
22
+ digit, written as `FN_ZERO + n`, and nothing checked `n`. `-1` put a `/` in the frame-number
23
+ position, and `NaN`, `Infinity` and `-Infinity` each put a `NUL` there in **every** frame of the
24
+ stream, after which `decodeAstmFrames` recognised no frame number at all, warned
25
+ `ASTM_FRAME_SEQUENCE_GAP` on every frame, and emitted **none** of the records: a whole four-record
26
+ message in, zero out, and `parseFramedAstm` throwing on empty input. **The recorded defect said an
27
+ out-of-range value writes a non-digit byte and named a space for `NaN`. It is a `NUL`**, which is
28
+ the difference between an ordinary inter-frame byte the decoder skips and a byte that costs the
29
+ stream every one of its records.
30
+
31
+ **The quieter half is why the check covers the whole domain rather than the ends.** Values that
32
+ truncated back onto a digit were accepted silently and behaved as some other start value: `1.5` and
33
+ `257` each produced the byte-for-byte stream a `startFrameNumber` of `1` produces. The option
34
+ documented `0`-`7` and enforced neither bound nor integrality in either direction.
35
+
36
+ **The domain is derived from what a frame can carry, and the refusal is not a clamp.** The writable
37
+ numbers are exactly the whole numbers `0`-`7`, the modulo-8 sequence `decodeAstmFrames` reads and
38
+ rolls over. Clamping or taking a modulo would pick a frame number the caller did not ask for, and
39
+ the frame number is the decoder's only evidence that no frame was dropped, so a stream numbered from
40
+ a value nobody chose is a stream whose sequence check certifies the wrong thing. The refusal is
41
+ raised before `composeAstmFrames` reads a record, so on that entry point it never depends on the
42
+ caller's data (`serializeFramedAstm` serializes every record first, so a record that cannot be
43
+ serialized at all is refused ahead of it on that route). Its message names the value received: that
44
+ value is the caller's own option, not stream content, and nothing from the records reaches it.
45
+
46
+ **The whole `0`-`7` range is still accepted, because a non-default start has a real use that was
47
+ measured rather than assumed.** It composes a **continuation** of a transfer already in progress:
48
+ `composeAstmFrames(head)` joined with `composeAstmFrames(tail, { startFrameNumber: n })`, where `n`
49
+ is the number after the last frame `head` used, is **byte-identical** to composing the whole list in
50
+ one call and decodes with an empty warnings array, across the `7 → 0` rollover included. Narrowing
51
+ the option to `1` would have removed that.
52
+
53
+ **What a continuation is not, now stated on the option rather than guarded against.** Read on its
54
+ own, a stream that starts anywhere but `1` opens on a sequence gap; the decoder never bridges a gap
55
+ silently, so it warns and does not emit that first record. **What `parseFramedAstm` does after that
56
+ varies with the message shape, and no rule is offered for it:** it may throw, under more than one
57
+ code (`ASTM_RECORD_NO_HEADER`, `EMPTY_INPUT` and `ASTM_RECORD_UNDECLARED_DELIMITERS` were each
58
+ measured), and it may return a message one record short. **The one statement that generalizes is
59
+ that the record layer never reports the loss**, because `parseFramedAstm` hands the record parser
60
+ only the frames the codec vouched for, so `message.warnings` carries what the surviving records
61
+ warrant and nothing about the record that did not survive. Read `frameWarnings`. Nothing returns the number to continue a sequence from either, and the frame count
62
+ is not it once a record splits, so the supported computation is named on the option. The
63
+ documented-valid `0` has always behaved that way. It is the cost of the option, not a defect in the
64
+ caller's records, and it is why `1` is the default.
65
+
66
+ **This is a narrowing on a published package, in a small population.** `1.5` and `257` used to
67
+ produce a working stream, the byte-for-byte start-at-`1` one, and now throw. Every value the check
68
+ turns away was already outside what the option documented: a frame number, a whole number `0`-`7`.
69
+
70
+ Both the old bytes and the new refusal are pinned in `test/frames/start-frame-number.test.ts`,
71
+ asserted on what the old encoder produced (rebuilt with the test-only `frame()` builder, so nothing
72
+ is transcribed twice), with a biconditional property: a value is refused **if and only if** it is
73
+ not a whole number in `0`-`7`. 14 of the 32 new tests are red against `64c2fd5`.
74
+
75
+ - **Three `{@link}` targets in the published `.d.ts` did not name a symbol declared there**
76
+ (`ASTM-FRAME-RESIDUALS`, the sibling minors). `QuirkTolerance` is `AstmQuirkTolerance` (twice, in
77
+ `src/profiles/types.ts`); the `startFrameNumber` doc linked `FIRST_FRAME_NUMBER`, which this
78
+ package does not export, and that sentence was rewritten and no longer links it; and
79
+ `AstmMessage.profile` linked a bare `warnings`, meaning its sibling member, which resolves only for
80
+ a tool that resolves against the enclosing declaration. Every `{@link}` target in
81
+ `dist/index.d.ts` now names a symbol declared in that file.
82
+
83
+ - **A delimiter set colliding with a record's type letter emitted a stream that read back as
84
+ DIFFERENT records, and in its silent branch it fabricated a final lab result out of patient
85
+ identifiers** (`ASTM-FRAME-RESIDUALS`, defect 5). `PRE-EXISTING`, byte-identical on `7253098`,
86
+ before any of `ASTM-EMIT-RESIDUALS`. `serializeAstmRecords` / `serializeAstmRecord` now throw
87
+ `AstmSerializeError` with the new code `ASTM_EMIT_TYPE_LETTER_COLLISION` (a third member of
88
+ `AstmSerializeErrorCode`), carrying the `recordIndex` and quoting nothing from the record: not the
89
+ value, and not the type letter either, which on an unsupported record is a byte off the wire.
90
+
91
+ **The recorded defect said the record "re-reads as an unsupported record, one result in, zero out".
92
+ That describes one branch of two, and the branch it does not describe is silent.** A record's type
93
+ letter is just another leaf to the escape encoder, so a set naming that letter escapes it away: an
94
+ `R` record emitted with `field` = `R` goes out as `&F&R1R…`, reads back as `unsupported`, and
95
+ `ASTM_RECORD_UNKNOWN_TYPE` does fire. But the encoder writes an escaped character as
96
+ escape + mnemonic + escape, so **when the escape character is itself a record type letter the
97
+ escaped type letter begins with a real letter** and no unknown-type warning fires at all. Measured:
98
+ a `P` record emitted with `field` = `P` and `escape` = `R` comes back as an `R` record whose
99
+ `value` is the patient's laboratory ID, whose `units` are the practice-assigned identifier, and
100
+ whose `resultStatus` is `F`, so `results()` returns a fabricated **final** result built out of
101
+ patient identifiers. The stream's only warning is `ASTM_NONSTANDARD_DELIMITERS`, which is what a
102
+ **clean** non-canonical stream carries too and which is on the profile safety gate's tolerable
103
+ allow-list, so `{ strict: true }` accepts it under a profile the gate permits. That allow-list
104
+ entry reasons that "a record's type letter is the first character of its line" is read before any
105
+ delimiter-driven tokenization: true of the parse, and no protection when the **emit** is what chose
106
+ that character.
107
+
108
+ **The rule is derived from the reader and checked on the bytes, not on the delimiter set.**
109
+ `parseAstmRecords` takes a record's type from `line.charAt(0)`, so the condition emit has to meet
110
+ is that the first character it writes is the letter the record models. Testing the output rather
111
+ than enumerating dangerous roles is what makes a type letter equal to the **escape** character fall
112
+ out as accepted without a special case: it is written starting with that letter, so the record
113
+ re-reads as its own type. The familiar `letter` + `E` + `letter` shape is not general and the guard
114
+ does not rely on it: the encoder protects the escape character it introduces but not the `E`/`F`/`S`
115
+ mnemonics, so under `{ field: "E", escape: "R" }` an `R` encodes to `RRFRR` and its type field
116
+ decodes back to `RER`, not `R`. What holds across every such set is the only thing checked, the
117
+ first character written. Measured over 137,632
118
+ delimiter sets (two roles at a time, every printable ASCII character each, against a stream
119
+ carrying one record of every modeled type plus an unsupported one), the refusal is
120
+ **biconditional** with the old serializer losing a type letter: zero over-refusals and zero
121
+ under-refusals. Over a second sweep of 3,690 emits across nine record-set shapes, 750 streams
122
+ previously read back as something other than the records that produced them, **303 of them accepted
123
+ by `{ strict: true }`**; all 750 are now refused and the remaining 2,940 are byte-unchanged.
124
+
125
+ This is a **narrowing on a published package**, in exactly the cases that were being corrupted.
126
+ **It is a transcoding condition, not a judgement on the set the caller passed, so it fires with no
127
+ delimiter argument at all** and "emit against the canonical delimiters" is not a remedy for it: a
128
+ stream whose header declares a vendor set and which carries one garbled line beginning `|` parses
129
+ to an unsupported record whose type letter is `|`, and the canonical set escapes that `|` away, so
130
+ `serializeAstmRecords(msg)`, `serializeAstmRecord(record)` and `serializeFramedAstm(msg)` all
131
+ refuse it where the base emitted a record whose `rawType` came back as `&`. Callers passing a set
132
+ explicitly (`serializeAstmRecords(msg, msg.delimiters)`) reach it too. The three
133
+ set-level conditions are unchanged and still raise `ASTM_EMIT_INVALID_DELIMITERS`; this is a
134
+ separate, per-record check because whether a set collides depends on which record is being written.
135
+ **What it does not promise, stated rather than left to be found:** it guarantees a record re-reads
136
+ as its own **type**, not that every field lands where it did (an escape sequence whose body is a
137
+ delimiter is still an opaque atom, reported on the parse side as `ASTM_UNKNOWN_ESCAPE_SEQUENCE`),
138
+ and `encodeComponent` / `serializeField` take no record, so a caller assembling a line out of them
139
+ is outside the check. Pinned in `test/records/type-letter-collision.test.ts`, asserted on what the
140
+ old serializer produced, rebuilt from the shipped `serializeField` so nothing is transcribed twice.
141
+ **No clause is claimed**: LIS02-A2 §5.4/§6.2 stay withheld from CLSI's free sample and the
142
+ paywalled editions were not read; the grounding is this package's own reader.
143
+
144
+ - **A raw `STX`, `ETB` or `ETX` byte in a value was framed as given, and in its worst branch a whole
145
+ result record disappeared into the previous record's text with `warnings: []` at BOTH layers**
146
+ (`ASTM-RAW-ETX-SWALLOWS-A-RECORD`). `PRE-EXISTING`, shipped since the frame encoder did.
147
+ `composeAstmFrames` now throws `AstmFrameEncodeError` with the new code `ASTM_FRAME_RESERVED_BYTE`
148
+ (a third member of `AstmFrameEncodeErrorCode`), carrying `recordIndex` and `characterIndex` and
149
+ never the bytes.
150
+
151
+ **The recorded reason for deferring this said it "fails loudly". That was false, and the
152
+ correction is what re-ranked it.** A frame's text ends at the first `ETB`/`ETX` after its `STX`, so
153
+ an embedded one truncates the frame there, and the two bytes that follow are then read as that
154
+ short frame's checksum. When they match, the truncated frame **verifies**, `decode.ts` resumes at
155
+ `termIndex + 3` and skips the rest of the record as inter-frame bytes, and the next frame number is
156
+ still in sequence. Measured on this package's own round trip: a `C` comment ending `…HEMOLYZED` +
157
+ `ETX` + the two matching characters reassembled without its terminating `CR`, so the following `R`
158
+ merged into the comment's free text (`"SPECIMEN SLIGHTLY HEMOLYZEDR"`) and a `28.6 U/L` result
159
+ vanished, `results()` returning `[]` where the input carried one. **An embedded `ETB` reaches the
160
+ same silence by the other door**, which the defect record did not have: it leaves the record open,
161
+ so the _next_ record's text is appended to the truncated one and two records read back as one, with
162
+ every field of the result hanging off the comment. The precondition is a coincidence in the two
163
+ bytes that follow: they must both read as hex digits and must equal the truncated frame's checksum,
164
+ which is about 1 in 34,600 for uniform-random bytes (1 in 256 once they are hex digits, which is
165
+ where the earlier 1-in-256 figure came from), and trivially constructible on purpose. That bounds
166
+ the likelihood, not the harm.
167
+ Both silent branches, and the loud ones, are pinned in `test/frames/reserved-structure-byte.test.ts`
168
+ asserted on what the old encoder produced, so a weakened guard reds a test rather than passing
169
+ unnoticed.
170
+
171
+ **The refusal has no bytes-instead escape hatch, unlike the `U+00FF` one beside it.** A
172
+ `Uint8Array` is checked too, because the byte is unframable however it arrives: framing has no
173
+ escape sequence for it, and emit has no warning channel, so the only alternatives to refusing are
174
+ substituting a byte the sender did not send or dropping one they did. The three bytes are derived
175
+ from what `decodeAstmFrames` actually reads as structure, not from a control-character class:
176
+ `CR`/`LF` are deliberately absent (they are read only _after_ a frame's checksum, and a record's own
177
+ `CR` sits inside frame text on every stream this encoder writes), and `ENQ`/`ACK`/`NAK`/`EOT` are
178
+ absent too (structure only _between_ frames; measured to round-trip byte-exactly inside one).
179
+ **No clause is claimed:** LIS02-A2 §5.4/§6.2 are withheld from CLSI's free sample and the paywalled
180
+ editions were not read. The grounding is this library's own decoder, in this repo.
181
+
182
+ **The record layer is deliberately UNCHANGED, and that was the cost this slice turned on.**
183
+ Refusing the byte in `serializeAstmRecord` would refuse a byte the caller genuinely supplied, for
184
+ consumers who never frame anything. Measured, in every modeled value: all three
185
+ bytes round-trip through parse → serialize → parse byte for byte, value, units and status intact,
186
+ byte-stable, `warnings: []` on both generations. The byte becomes structure only when a frame is
187
+ built, and `composeAstmFrames` is the total gate on that route, including through
188
+ `serializeFramedAstm`. `CR`/`LF` remain the record layer's own refusal, because they end a _record_.
189
+
190
+ **That claim is about VALUES, and one position on a record line is not a value.** The surplus of a
191
+ header's delimiter declaration drops any control character silently on emit, so a header that
192
+ arrived as `H|\^&` + `ETX` goes back out without it, `warnings: []`, byte-stable, and the
193
+ round-trip is not byte-exact there. `PRE-EXISTING` and deliberately unchanged: that rule is argued
194
+ at `declarationResidual` and long predates the frame-layer refusal, and it is the better of the two
195
+ dispositions now, since carrying the byte through would turn a spec-clean header into a refused
196
+ stream. Pinned in `test/frames/reserved-structure-byte.test.ts` so the scoped sentence stays
197
+ measured. Found by the `conformance-refuter` grading this slice.
198
+
199
+ **What this does not close.** A delimiter colliding with a record's type letter still frames and
200
+ de-frames byte-exactly and re-reads as a different record; framing integrity is not record-layer
201
+ readback. And a caller reaching `composeAstmFrames` from JavaScript with some other typed array is
202
+ still outside the declared signature, and **this guard does not reach that route either**: the
203
+ check compares elements against the three byte values, so a `Uint16Array` element of `0x0103` is
204
+ not one of them and is not refused, while `Uint8Array.from` later takes its low byte and writes an
205
+ `ETX` after all. Measured on all three (`0x0102`/`0x0103`/`0x0117`): framed, then lost at decode
206
+ with a bad checksum or an unterminated frame. That is the same out-of-signature residue already
207
+ recorded for the low-byte truncation, unchanged here in either direction, and stated rather than
208
+ left to be found. Pass a `Uint8Array` or a string.
209
+
14
210
  - **A symbolic link under a scan root read CLEAN on BOTH of the PHI scanner's enumerating routes, so
15
211
  a link pointing at a file full of real identifiers passed the gate twice over**
16
212
  (`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`). Development tooling only: `scripts/phi-scan.ts` ships in
package/README.md CHANGED
@@ -39,10 +39,27 @@ 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. `startFrameNumber` lets you
57
+ compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
58
+ record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
59
+ `7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
60
+ the **default** start; a non-default one writes a continuation of a sequence already in progress,
61
+ and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
62
+ about and not emitted.
46
63
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
47
64
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
48
65
  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,29 @@ 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
+ }
2293
+ function assertStartFrameNumber(value) {
2294
+ if (!Number.isInteger(value) || value < 0 || value > FRAME_NUMBER_MODULUS - 1) {
2295
+ throw new AstmFrameEncodeError(
2296
+ `startFrameNumber must be a whole number from 0 to 7: a frame's number is one ASCII digit and the sequence is modulo 8. Received: ${String(value)}.`,
2297
+ void 0,
2298
+ "ASTM_FRAME_INVALID_START_FRAME_NUMBER"
2299
+ );
2300
+ }
2301
+ }
2262
2302
  function encodeFrame(text, frameNumber, isFinal) {
2263
2303
  const term = isFinal ? ETX : ETB;
2264
2304
  const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
@@ -2267,11 +2307,13 @@ function encodeFrame(text, frameNumber, isFinal) {
2267
2307
  return [...bytes, hex.charCodeAt(0), hex.charCodeAt(1), CR, LF];
2268
2308
  }
2269
2309
  function composeAstmFrames(records, options = {}) {
2310
+ const start = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2311
+ assertStartFrameNumber(start);
2270
2312
  if (records.length === 0) {
2271
2313
  throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
2272
2314
  }
2273
2315
  const out = [];
2274
- let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2316
+ let frameNumber = start;
2275
2317
  records.forEach((record, recordIndex) => {
2276
2318
  const bytes = toBytes(record, recordIndex);
2277
2319
  if (bytes.length === 0) {
@@ -2280,6 +2322,7 @@ function composeAstmFrames(records, options = {}) {
2280
2322
  recordIndex
2281
2323
  );
2282
2324
  }
2325
+ assertFramable(bytes, recordIndex);
2283
2326
  for (let offset = 0; offset < bytes.length; offset += MAX_FRAME_TEXT) {
2284
2327
  const chunk = bytes.subarray(offset, offset + MAX_FRAME_TEXT);
2285
2328
  const isFinal = offset + MAX_FRAME_TEXT >= bytes.length;
@@ -2315,7 +2358,7 @@ function concatBytes3(chunks) {
2315
2358
  }
2316
2359
 
2317
2360
  // src/index.ts
2318
- var VERSION = "0.0.11";
2361
+ var VERSION = "0.0.14";
2319
2362
 
2320
2363
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2321
2364
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;