@cosyte/astm 0.0.13 → 0.0.15

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/dist/index.d.cts CHANGED
@@ -1103,7 +1103,7 @@ declare function detectFraming(bytes: Uint8Array, options?: DetectFramingOptions
1103
1103
  * Any warning code from any of the three registries: the record layer
1104
1104
  * (`ASTM_RECORD_*` / `ASTM_NONSTANDARD_DELIMITERS` / `ASTM_UNKNOWN_ESCAPE_SEQUENCE`),
1105
1105
  * the frame codec (`ASTM_FRAME_*`), or the LTP protocol layer (`ASTM_LTP_*`). A
1106
- * {@link QuirkTolerance} may *name* any of them, but the safety gate refuses all
1106
+ * {@link AstmQuirkTolerance} may *name* any of them, but the safety gate refuses all
1107
1107
  * but the small, benign, record-layer subset, so the union exists mainly to let
1108
1108
  * the gate reject a frame/LTP code with a precise, typed message.
1109
1109
  */
@@ -1136,7 +1136,7 @@ interface AstmProfileProvenance {
1136
1136
  readonly note?: string;
1137
1137
  }
1138
1138
  /**
1139
- * Optional structural narrowing for a {@link QuirkTolerance}. When present, the
1139
+ * Optional structural narrowing for an {@link AstmQuirkTolerance}. When present, the
1140
1140
  * tolerance applies only to warnings whose PHI-free {@link AstmPosition} matches
1141
1141
  * every provided field, so a profile can expect a deviation on one record type
1142
1142
  * (e.g. an unknown escape only inside `R` result values) without blanket-tolerating
@@ -1911,7 +1911,7 @@ interface AstmMessage {
1911
1911
  * The vendor profile that was active for this parse, when one applied: its
1912
1912
  * `name` and resolved `lineage` (attribution only; the profile object itself is
1913
1913
  * not embedded). Absent when no profile applied. A profile that tolerated a
1914
- * deviation shows up as a `PROFILE_QUIRK_APPLIED` entry in {@link warnings}, not
1914
+ * deviation shows up as a `PROFILE_QUIRK_APPLIED` entry in {@link AstmMessage.warnings}, not
1915
1915
  * here.
1916
1916
  */
1917
1917
  readonly profile?: {
@@ -2042,7 +2042,10 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2042
2042
  * set (a non-canonical source is normalized to `H|\^&` by default). Normalizing sets the
2043
2043
  * four delimiter **roles**; it does not delete a declaration's surplus characters, so a
2044
2044
  * canonically-declared header that arrived carrying surplus keeps it on the default path
2045
- * too.
2045
+ * too, **except where that surplus could not be read back as surplus, when all of it is
2046
+ * dropped** (see `declarationResidual` below). Emit re-encodes what it read, so bytes
2047
+ * can differ without anything being lost: `O&BRIEN` in a canonical stream goes out as
2048
+ * `O&E&BRIEN`.
2046
2049
  *
2047
2050
  * **One delimiter set for the whole stream.** Every record (including `H`, `M` and `S`)
2048
2051
  * is emitted against the delimiters being emitted with, and the header declares that same
@@ -2063,7 +2066,11 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2063
2066
  * `H|\^&#` rather than losing the `#`. That holds on the **default** canonical path as
2064
2067
  * well as when a set is passed explicitly: the surplus of a canonically-declared header
2065
2068
  * survives normalization, because normalization is about the four roles and the surplus
2066
- * has none.
2069
+ * has none. It stops holding where the surplus itself could not be read back, and then
2070
+ * **the whole surplus is dropped, not the offending character alone**, on the default
2071
+ * path as much as any other: `declarationResidual` states which surpluses those are and
2072
+ * why an all-or-nothing drop is the only one of the available answers that invents
2073
+ * nothing.
2067
2074
  *
2068
2075
  * **Never break framing.** A component leaf that contains a record terminator
2069
2076
  * (`CR`/`LF`) cannot be escaped by the ASTM escape codec (only the four declared
@@ -2084,10 +2091,15 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2084
2091
  * output, which is why they are refused at this one.
2085
2092
  *
2086
2093
  * **One position is not a modeled value and does not keep the byte:** the surplus
2087
- * of a header's delimiter declaration, where any control character is dropped
2094
+ * of a header's delimiter declaration, where a control character is dropped
2088
2095
  * silently (see `declarationResidual` below, where that disposition is argued and
2089
2096
  * where it long predates the frame-layer refusal). So "the record layer carries
2090
2097
  * these bytes" is a statement about values, not about every byte of every line.
2098
+ * Measured, one character at a time in the surplus of an otherwise spec-clean
2099
+ * `H|\^&` header: **31 of the 33 C0/`DEL` characters reach that rule and are
2100
+ * dropped**, with `warnings: []` and no error. The two that do not are `CR` and
2101
+ * `LF`, which never reach it at all, because they end the record while it is being
2102
+ * read and so are never part of any surplus the model carries.
2091
2103
  *
2092
2104
  * **Check the delimiter set before writing.** Three conditions are required for the
2093
2105
  * emitted bytes to read back as the records that produced them (each separator exactly
@@ -2166,10 +2178,12 @@ declare class AstmSerializeError extends Error {
2166
2178
  type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS" | "ASTM_EMIT_TYPE_LETTER_COLLISION";
2167
2179
  /**
2168
2180
  * Escape-encode one component leaf for spec-clean emit: the inverse of
2169
- * `decodeEscapes`. The **escape character itself is encoded first** (`&` → `&E&`)
2170
- * so a later delimiter substitution can never double-encode the `&` it just
2171
- * introduced; then the field / component / repeat delimiters map to their
2172
- * mnemonics.
2181
+ * `decodeEscapes`. Each character is read **once**, in one left-to-right pass, and
2182
+ * written either as itself or as a whole `escape` + mnemonic + `escape` triple
2183
+ * (`&` → `&E&`, the field / component / repeat delimiters → `&F&` / `&S&` / `&R&`).
2184
+ * Nothing this encoder writes is examined again, which is what makes it the exact
2185
+ * inverse of the decoder: the decoder reads the same output one token at a time,
2186
+ * so every triple it meets is a triple this encoder wrote.
2173
2187
  *
2174
2188
  * A `CR`/`LF` in the leaf has no escape mnemonic and would break framing, so it
2175
2189
  * is rejected with an {@link AstmSerializeError} rather than emitted raw, as is a
@@ -2199,7 +2213,11 @@ declare function encodeComponent(leaf: string, d: Delimiters, recordIndex?: numb
2199
2213
  * The header (`H`) is special-cased: its delimiter-definition field is emitted as
2200
2214
  * the **literal** declaration of `d`, never escaped, escaping it would corrupt the
2201
2215
  * very declaration a reader depends on, followed by any characters the modeled
2202
- * declaration carried beyond the three a reader takes its roles from. Manufacturer
2216
+ * declaration carried beyond the three a reader takes its roles from. Those surplus
2217
+ * characters are kept unless the surplus could not be read back as surplus, when
2218
+ * **all** of it is dropped: that covers a surplus carrying the field delimiter or
2219
+ * any control character, and it is silent, because emit has no warning channel.
2220
+ * Manufacturer
2203
2221
  * (`M`) and scientific (`S`) records are reproduced **byte-identically** from their
2204
2222
  * preserved `rawLine` when they are already in `d`, and re-encoded from their fields
2205
2223
  * when they are not, so their fields never collapse into one on the next read.
@@ -2234,7 +2252,11 @@ declare function serializeAstmRecord(record: AstmRecord, d?: Delimiters): string
2234
2252
  * re-parsing it recovers every field. Passing `d` explicitly emits against that set
2235
2253
  * instead, and the header declares it. Normalization replaces the four delimiter
2236
2254
  * **roles**; a header declaration carrying characters beyond the three that hold a
2237
- * role keeps them, on this path as on any other, rather than being truncated.
2255
+ * role keeps them, on this path as on any other, rather than being truncated. The
2256
+ * exception is a surplus that could not be read back as one, carrying the field
2257
+ * delimiter or any control character: there the **whole** surplus is dropped, on
2258
+ * every path including the default one, and silently, because emit returns a bare
2259
+ * string with no channel to say so.
2238
2260
  *
2239
2261
  * **`d` is checked before anything is written.** Each of the four separators must be
2240
2262
  * exactly one character, none may be a record terminator, and no two may share a
@@ -4407,7 +4429,8 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4407
4429
  * case of);
4408
4430
  * - the **frame number** `0`–`7`, starting at `1` and rolling over `7 → 0 → 1`,
4409
4431
  * continuous across every frame in the stream (as the decoder's sequence check
4410
- * expects);
4432
+ * expects); a caller may start the sequence elsewhere to compose one transfer
4433
+ * across several calls, but only at a number a frame can actually carry;
4411
4434
  * - the **240-byte text split**: a record longer than 240 bytes is split across
4412
4435
  * frames closed by `ETB` (intermediate) with a final `ETX`; the seven framing
4413
4436
  * control bytes are never counted toward the 240.
@@ -4432,6 +4455,9 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4432
4455
  * - `ASTM_FRAME_RESERVED_BYTE`: a record holds a byte this layer reads as frame
4433
4456
  * structure (`STX`, `ETB` or `ETX`). Framing has no escape mechanism, so such
4434
4457
  * a byte cannot be carried inside a frame at all.
4458
+ * - `ASTM_FRAME_INVALID_START_FRAME_NUMBER`: `options.startFrameNumber` is not a
4459
+ * whole number from `0` to `7`. A frame's number is one ASCII digit, so a
4460
+ * value outside that range has no digit to be written as.
4435
4461
  *
4436
4462
  * @example
4437
4463
  * ```ts
@@ -4439,7 +4465,7 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4439
4465
  * const code: AstmFrameEncodeErrorCode = "ASTM_FRAME_UNENCODABLE_CHARACTER";
4440
4466
  * ```
4441
4467
  */
4442
- type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE";
4468
+ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE" | "ASTM_FRAME_INVALID_START_FRAME_NUMBER";
4443
4469
  /**
4444
4470
  * Thrown by {@link composeAstmFrames} when the input cannot be framed into a
4445
4471
  * spec-clean stream. Carries a stable code + positional context, never the
@@ -4471,6 +4497,20 @@ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODA
4471
4497
  * before framing: which byte belongs in a clinical value is the sender's call, not
4472
4498
  * this library's, so it refuses rather than substituting or deleting one.
4473
4499
  *
4500
+ * **The start-frame-number case, and why an option gets an error of its own.** A
4501
+ * frame's number is a single ASCII digit, `FN_ZERO + n` for `n` in `0`-`7`, and
4502
+ * nothing used to check that `options.startFrameNumber` was one. What went out
4503
+ * instead was whatever byte that sum truncated to: measured on this package's own
4504
+ * round trip, `-1` wrote `/` into the frame-number position and `NaN`, `Infinity`
4505
+ * and `-Infinity` each wrote a `NUL` byte into every frame of the stream, after
4506
+ * which the decoder read no frame number at all and emitted **none** of the
4507
+ * records. Out-of-domain values that happened to land back on a digit were worse
4508
+ * in a quieter way: `1.5` and `257` both produced the byte-for-byte stream a
4509
+ * `startFrameNumber` of `1` produces, so the option silently accepted a value it
4510
+ * documented as invalid. This one refusal is about the caller's own option rather
4511
+ * than about record content, so its message names the value received. Nothing
4512
+ * from the stream is quoted.
4513
+ *
4474
4514
  * @example
4475
4515
  * ```ts
4476
4516
  * import { composeAstmFrames, AstmFrameEncodeError } from "@cosyte/astm";
@@ -4500,8 +4540,42 @@ declare class AstmFrameEncodeError extends Error {
4500
4540
  /** Options for {@link composeAstmFrames}. */
4501
4541
  interface ComposeFramesOptions {
4502
4542
  /**
4503
- * The frame number to start the sequence at (`0`–`7`). Defaults to
4504
- * {@link FIRST_FRAME_NUMBER} (`1`): the ASTM convention.
4543
+ * The frame number to start the sequence at: a whole number from `0` to `7`.
4544
+ * Defaults to `1`, the number ASTM gives the first frame of a transfer and the
4545
+ * one {@link decodeAstmFrames} expects to read first.
4546
+ *
4547
+ * **Any other value writes a continuation rather than the start of a transfer,
4548
+ * and that is what the option is for**: composing one transfer across more than
4549
+ * one call. Concatenating `composeAstmFrames(head)` with
4550
+ * `composeAstmFrames(tail, { startFrameNumber: n })`, where `n` is the number
4551
+ * after the last frame `head` used, is byte-identical to composing the whole
4552
+ * list in a single call and decodes with an empty warnings array, across the
4553
+ * `7 → 0` rollover included.
4554
+ *
4555
+ * **Decoded on its own, a stream that starts anywhere but `1` opens on a
4556
+ * sequence gap.** The decoder never bridges a gap silently, so it warns
4557
+ * (`ASTM_FRAME_SEQUENCE_GAP`) and does not emit that first record.
4558
+ * **What {@link parseFramedAstm} does after that varies with the message
4559
+ * shape and no rule is offered for it here.** It may throw, under more than
4560
+ * one code, and it may return a message that is simply one record short. **The
4561
+ * one thing that does hold is that the record layer never reports the loss**:
4562
+ * `parseFramedAstm` hands the record parser only the frames the codec
4563
+ * vouched for, so `message.warnings` carries what the surviving records
4564
+ * warrant and nothing about the record that did not survive. **Read
4565
+ * `frameWarnings`.** That is the cost of the option rather than a defect in
4566
+ * the caller's records, and it is why `1` is the default.
4567
+ *
4568
+ * **Finding the number to continue from.** Nothing here returns it, and the
4569
+ * frame count is not it once a record splits: decode the part you just
4570
+ * composed and read the last frame's number, then add one modulo 8, as in
4571
+ * `((decodeAstmFrames(part).frames.at(-1)?.frameNumber ?? 0) + 1) % 8`. A frame
4572
+ * carries no number only when the stream ends immediately after its `STX`,
4573
+ * which is not something this encoder writes, so that fallback never fires on
4574
+ * a part composed here. Getting the number wrong costs the record at the join,
4575
+ * warned rather than silently.
4576
+ *
4577
+ * A value outside `0`-`7`, or one that is not a whole number, is refused with
4578
+ * `ASTM_FRAME_INVALID_START_FRAME_NUMBER`.
4505
4579
  */
4506
4580
  readonly startFrameNumber?: number;
4507
4581
  }
@@ -4539,6 +4613,17 @@ interface ComposeFramesOptions {
4539
4613
  * absorbed into the previous one with an empty warnings array at both layers.
4540
4614
  * Supplying the record as a `Uint8Array` does not route around this check.
4541
4615
  *
4616
+ * **`options.startFrameNumber` is checked before *this function* reads a record**,
4617
+ * so on this entry point the refusal never depends on the caller's data.
4618
+ * ({@link serializeFramedAstm} serializes every record *before* it gets here, so
4619
+ * a record that cannot be serialized is refused first on that route.) It has to
4620
+ * be a whole number from `0` to `7`, because a frame's number is one ASCII digit;
4621
+ * anything else is `ASTM_FRAME_INVALID_START_FRAME_NUMBER` rather than whatever
4622
+ * byte the arithmetic truncated to. A value other than the default `1` writes a
4623
+ * **continuation** of a sequence already in progress, which is what the option is
4624
+ * for, and a continuation decoded on its own opens on a sequence gap: see
4625
+ * {@link ComposeFramesOptions}.
4626
+ *
4542
4627
  * What is still **not** guaranteed: that an accepted record round-trips through
4543
4628
  * the *record* layer. A delimiter colliding with a record's type letter, for one,
4544
4629
  * frames and de-frames byte-exactly and still re-reads as a different record.
@@ -4547,10 +4632,12 @@ interface ComposeFramesOptions {
4547
4632
  * `string`), one entry per complete record.
4548
4633
  * @param options - Encode options.
4549
4634
  * @returns The framed byte stream.
4550
- * @throws {@link AstmFrameEncodeError} when the list is empty, a record has no
4551
- * bytes (`ASTM_FRAME_EMPTY_RECORD`), a record string holds a character above
4552
- * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`,
4553
- * `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4635
+ * @throws {@link AstmFrameEncodeError} when `options.startFrameNumber` is not a
4636
+ * whole number from `0` to `7` (`ASTM_FRAME_INVALID_START_FRAME_NUMBER`), the
4637
+ * list is empty, a record has no bytes (`ASTM_FRAME_EMPTY_RECORD`), a record
4638
+ * string holds a character above `U+00FF`
4639
+ * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4640
+ * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4554
4641
  * @example
4555
4642
  * ```ts
4556
4643
  * import { composeAstmFrames, decodeAstmFrames } from "@cosyte/astm";
@@ -4625,7 +4712,11 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4625
4712
  * delimiters, embedded delimiters re-escaped) and then framed **independently**
4626
4713
  * (one record per `ETX`-closed frame run), so the framing exactly mirrors what
4627
4714
  * {@link decodeAstmFrames} reassembles: `parseFramedAstm(serializeFramedAstm(msg))`
4628
- * yields an equal message.
4715
+ * yields an equal message **with the default `startFrameNumber`**. That clause is
4716
+ * load-bearing rather than pedantic: a non-default start writes a continuation of
4717
+ * a sequence already in progress, and a continuation read on its own opens on a
4718
+ * sequence gap, so the decoder does not emit its first record and this round trip
4719
+ * does not hold for it. See {@link ComposeFramesOptions}.
4629
4720
  *
4630
4721
  * A value carrying a character above `U+00FF` is **refused** here rather than
4631
4722
  * framed: the record layer is happy to hold one, but a frame carries bytes and
@@ -4645,10 +4736,11 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4645
4736
  * survive being written in the canonical set this function serializes with
4646
4737
  * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The second reaches messages parsed under a
4647
4738
  * **different** delimiter set, since this function passes no set of its own.
4648
- * @throws {@link AstmFrameEncodeError} when there are no records to frame
4649
- * (`ASTM_FRAME_EMPTY_RECORD`), a value holds a character above `U+00FF`
4650
- * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4651
- * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4739
+ * @throws {@link AstmFrameEncodeError} when `options.startFrameNumber` is not a
4740
+ * whole number from `0` to `7` (`ASTM_FRAME_INVALID_START_FRAME_NUMBER`), there
4741
+ * are no records to frame (`ASTM_FRAME_EMPTY_RECORD`), a value holds a
4742
+ * character above `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record
4743
+ * holds an `STX`, `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4652
4744
  * @example
4653
4745
  * ```ts
4654
4746
  * import { parseAstmRecords, serializeFramedAstm, parseFramedAstm } from "@cosyte/astm";
package/dist/index.d.ts CHANGED
@@ -1103,7 +1103,7 @@ declare function detectFraming(bytes: Uint8Array, options?: DetectFramingOptions
1103
1103
  * Any warning code from any of the three registries: the record layer
1104
1104
  * (`ASTM_RECORD_*` / `ASTM_NONSTANDARD_DELIMITERS` / `ASTM_UNKNOWN_ESCAPE_SEQUENCE`),
1105
1105
  * the frame codec (`ASTM_FRAME_*`), or the LTP protocol layer (`ASTM_LTP_*`). A
1106
- * {@link QuirkTolerance} may *name* any of them, but the safety gate refuses all
1106
+ * {@link AstmQuirkTolerance} may *name* any of them, but the safety gate refuses all
1107
1107
  * but the small, benign, record-layer subset, so the union exists mainly to let
1108
1108
  * the gate reject a frame/LTP code with a precise, typed message.
1109
1109
  */
@@ -1136,7 +1136,7 @@ interface AstmProfileProvenance {
1136
1136
  readonly note?: string;
1137
1137
  }
1138
1138
  /**
1139
- * Optional structural narrowing for a {@link QuirkTolerance}. When present, the
1139
+ * Optional structural narrowing for an {@link AstmQuirkTolerance}. When present, the
1140
1140
  * tolerance applies only to warnings whose PHI-free {@link AstmPosition} matches
1141
1141
  * every provided field, so a profile can expect a deviation on one record type
1142
1142
  * (e.g. an unknown escape only inside `R` result values) without blanket-tolerating
@@ -1911,7 +1911,7 @@ interface AstmMessage {
1911
1911
  * The vendor profile that was active for this parse, when one applied: its
1912
1912
  * `name` and resolved `lineage` (attribution only; the profile object itself is
1913
1913
  * not embedded). Absent when no profile applied. A profile that tolerated a
1914
- * deviation shows up as a `PROFILE_QUIRK_APPLIED` entry in {@link warnings}, not
1914
+ * deviation shows up as a `PROFILE_QUIRK_APPLIED` entry in {@link AstmMessage.warnings}, not
1915
1915
  * here.
1916
1916
  */
1917
1917
  readonly profile?: {
@@ -2042,7 +2042,10 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2042
2042
  * set (a non-canonical source is normalized to `H|\^&` by default). Normalizing sets the
2043
2043
  * four delimiter **roles**; it does not delete a declaration's surplus characters, so a
2044
2044
  * canonically-declared header that arrived carrying surplus keeps it on the default path
2045
- * too.
2045
+ * too, **except where that surplus could not be read back as surplus, when all of it is
2046
+ * dropped** (see `declarationResidual` below). Emit re-encodes what it read, so bytes
2047
+ * can differ without anything being lost: `O&BRIEN` in a canonical stream goes out as
2048
+ * `O&E&BRIEN`.
2046
2049
  *
2047
2050
  * **One delimiter set for the whole stream.** Every record (including `H`, `M` and `S`)
2048
2051
  * is emitted against the delimiters being emitted with, and the header declares that same
@@ -2063,7 +2066,11 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2063
2066
  * `H|\^&#` rather than losing the `#`. That holds on the **default** canonical path as
2064
2067
  * well as when a set is passed explicitly: the surplus of a canonically-declared header
2065
2068
  * survives normalization, because normalization is about the four roles and the surplus
2066
- * has none.
2069
+ * has none. It stops holding where the surplus itself could not be read back, and then
2070
+ * **the whole surplus is dropped, not the offending character alone**, on the default
2071
+ * path as much as any other: `declarationResidual` states which surpluses those are and
2072
+ * why an all-or-nothing drop is the only one of the available answers that invents
2073
+ * nothing.
2067
2074
  *
2068
2075
  * **Never break framing.** A component leaf that contains a record terminator
2069
2076
  * (`CR`/`LF`) cannot be escaped by the ASTM escape codec (only the four declared
@@ -2084,10 +2091,15 @@ declare function attachComments(records: readonly AstmRecord[], warnings: AstmRe
2084
2091
  * output, which is why they are refused at this one.
2085
2092
  *
2086
2093
  * **One position is not a modeled value and does not keep the byte:** the surplus
2087
- * of a header's delimiter declaration, where any control character is dropped
2094
+ * of a header's delimiter declaration, where a control character is dropped
2088
2095
  * silently (see `declarationResidual` below, where that disposition is argued and
2089
2096
  * where it long predates the frame-layer refusal). So "the record layer carries
2090
2097
  * these bytes" is a statement about values, not about every byte of every line.
2098
+ * Measured, one character at a time in the surplus of an otherwise spec-clean
2099
+ * `H|\^&` header: **31 of the 33 C0/`DEL` characters reach that rule and are
2100
+ * dropped**, with `warnings: []` and no error. The two that do not are `CR` and
2101
+ * `LF`, which never reach it at all, because they end the record while it is being
2102
+ * read and so are never part of any surplus the model carries.
2091
2103
  *
2092
2104
  * **Check the delimiter set before writing.** Three conditions are required for the
2093
2105
  * emitted bytes to read back as the records that produced them (each separator exactly
@@ -2166,10 +2178,12 @@ declare class AstmSerializeError extends Error {
2166
2178
  type AstmSerializeErrorCode = "ASTM_EMIT_UNENCODABLE_VALUE" | "ASTM_EMIT_INVALID_DELIMITERS" | "ASTM_EMIT_TYPE_LETTER_COLLISION";
2167
2179
  /**
2168
2180
  * Escape-encode one component leaf for spec-clean emit: the inverse of
2169
- * `decodeEscapes`. The **escape character itself is encoded first** (`&` → `&E&`)
2170
- * so a later delimiter substitution can never double-encode the `&` it just
2171
- * introduced; then the field / component / repeat delimiters map to their
2172
- * mnemonics.
2181
+ * `decodeEscapes`. Each character is read **once**, in one left-to-right pass, and
2182
+ * written either as itself or as a whole `escape` + mnemonic + `escape` triple
2183
+ * (`&` → `&E&`, the field / component / repeat delimiters → `&F&` / `&S&` / `&R&`).
2184
+ * Nothing this encoder writes is examined again, which is what makes it the exact
2185
+ * inverse of the decoder: the decoder reads the same output one token at a time,
2186
+ * so every triple it meets is a triple this encoder wrote.
2173
2187
  *
2174
2188
  * A `CR`/`LF` in the leaf has no escape mnemonic and would break framing, so it
2175
2189
  * is rejected with an {@link AstmSerializeError} rather than emitted raw, as is a
@@ -2199,7 +2213,11 @@ declare function encodeComponent(leaf: string, d: Delimiters, recordIndex?: numb
2199
2213
  * The header (`H`) is special-cased: its delimiter-definition field is emitted as
2200
2214
  * the **literal** declaration of `d`, never escaped, escaping it would corrupt the
2201
2215
  * very declaration a reader depends on, followed by any characters the modeled
2202
- * declaration carried beyond the three a reader takes its roles from. Manufacturer
2216
+ * declaration carried beyond the three a reader takes its roles from. Those surplus
2217
+ * characters are kept unless the surplus could not be read back as surplus, when
2218
+ * **all** of it is dropped: that covers a surplus carrying the field delimiter or
2219
+ * any control character, and it is silent, because emit has no warning channel.
2220
+ * Manufacturer
2203
2221
  * (`M`) and scientific (`S`) records are reproduced **byte-identically** from their
2204
2222
  * preserved `rawLine` when they are already in `d`, and re-encoded from their fields
2205
2223
  * when they are not, so their fields never collapse into one on the next read.
@@ -2234,7 +2252,11 @@ declare function serializeAstmRecord(record: AstmRecord, d?: Delimiters): string
2234
2252
  * re-parsing it recovers every field. Passing `d` explicitly emits against that set
2235
2253
  * instead, and the header declares it. Normalization replaces the four delimiter
2236
2254
  * **roles**; a header declaration carrying characters beyond the three that hold a
2237
- * role keeps them, on this path as on any other, rather than being truncated.
2255
+ * role keeps them, on this path as on any other, rather than being truncated. The
2256
+ * exception is a surplus that could not be read back as one, carrying the field
2257
+ * delimiter or any control character: there the **whole** surplus is dropped, on
2258
+ * every path including the default one, and silently, because emit returns a bare
2259
+ * string with no channel to say so.
2238
2260
  *
2239
2261
  * **`d` is checked before anything is written.** Each of the four separators must be
2240
2262
  * exactly one character, none may be a record terminator, and no two may share a
@@ -4407,7 +4429,8 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4407
4429
  * case of);
4408
4430
  * - the **frame number** `0`–`7`, starting at `1` and rolling over `7 → 0 → 1`,
4409
4431
  * continuous across every frame in the stream (as the decoder's sequence check
4410
- * expects);
4432
+ * expects); a caller may start the sequence elsewhere to compose one transfer
4433
+ * across several calls, but only at a number a frame can actually carry;
4411
4434
  * - the **240-byte text split**: a record longer than 240 bytes is split across
4412
4435
  * frames closed by `ETB` (intermediate) with a final `ETX`; the seven framing
4413
4436
  * control bytes are never counted toward the 240.
@@ -4432,6 +4455,9 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4432
4455
  * - `ASTM_FRAME_RESERVED_BYTE`: a record holds a byte this layer reads as frame
4433
4456
  * structure (`STX`, `ETB` or `ETX`). Framing has no escape mechanism, so such
4434
4457
  * a byte cannot be carried inside a frame at all.
4458
+ * - `ASTM_FRAME_INVALID_START_FRAME_NUMBER`: `options.startFrameNumber` is not a
4459
+ * whole number from `0` to `7`. A frame's number is one ASCII digit, so a
4460
+ * value outside that range has no digit to be written as.
4435
4461
  *
4436
4462
  * @example
4437
4463
  * ```ts
@@ -4439,7 +4465,7 @@ declare function decodeAstmFrames(bytes: Uint8Array, options?: FrameOptions): De
4439
4465
  * const code: AstmFrameEncodeErrorCode = "ASTM_FRAME_UNENCODABLE_CHARACTER";
4440
4466
  * ```
4441
4467
  */
4442
- type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE";
4468
+ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODABLE_CHARACTER" | "ASTM_FRAME_RESERVED_BYTE" | "ASTM_FRAME_INVALID_START_FRAME_NUMBER";
4443
4469
  /**
4444
4470
  * Thrown by {@link composeAstmFrames} when the input cannot be framed into a
4445
4471
  * spec-clean stream. Carries a stable code + positional context, never the
@@ -4471,6 +4497,20 @@ type AstmFrameEncodeErrorCode = "ASTM_FRAME_EMPTY_RECORD" | "ASTM_FRAME_UNENCODA
4471
4497
  * before framing: which byte belongs in a clinical value is the sender's call, not
4472
4498
  * this library's, so it refuses rather than substituting or deleting one.
4473
4499
  *
4500
+ * **The start-frame-number case, and why an option gets an error of its own.** A
4501
+ * frame's number is a single ASCII digit, `FN_ZERO + n` for `n` in `0`-`7`, and
4502
+ * nothing used to check that `options.startFrameNumber` was one. What went out
4503
+ * instead was whatever byte that sum truncated to: measured on this package's own
4504
+ * round trip, `-1` wrote `/` into the frame-number position and `NaN`, `Infinity`
4505
+ * and `-Infinity` each wrote a `NUL` byte into every frame of the stream, after
4506
+ * which the decoder read no frame number at all and emitted **none** of the
4507
+ * records. Out-of-domain values that happened to land back on a digit were worse
4508
+ * in a quieter way: `1.5` and `257` both produced the byte-for-byte stream a
4509
+ * `startFrameNumber` of `1` produces, so the option silently accepted a value it
4510
+ * documented as invalid. This one refusal is about the caller's own option rather
4511
+ * than about record content, so its message names the value received. Nothing
4512
+ * from the stream is quoted.
4513
+ *
4474
4514
  * @example
4475
4515
  * ```ts
4476
4516
  * import { composeAstmFrames, AstmFrameEncodeError } from "@cosyte/astm";
@@ -4500,8 +4540,42 @@ declare class AstmFrameEncodeError extends Error {
4500
4540
  /** Options for {@link composeAstmFrames}. */
4501
4541
  interface ComposeFramesOptions {
4502
4542
  /**
4503
- * The frame number to start the sequence at (`0`–`7`). Defaults to
4504
- * {@link FIRST_FRAME_NUMBER} (`1`): the ASTM convention.
4543
+ * The frame number to start the sequence at: a whole number from `0` to `7`.
4544
+ * Defaults to `1`, the number ASTM gives the first frame of a transfer and the
4545
+ * one {@link decodeAstmFrames} expects to read first.
4546
+ *
4547
+ * **Any other value writes a continuation rather than the start of a transfer,
4548
+ * and that is what the option is for**: composing one transfer across more than
4549
+ * one call. Concatenating `composeAstmFrames(head)` with
4550
+ * `composeAstmFrames(tail, { startFrameNumber: n })`, where `n` is the number
4551
+ * after the last frame `head` used, is byte-identical to composing the whole
4552
+ * list in a single call and decodes with an empty warnings array, across the
4553
+ * `7 → 0` rollover included.
4554
+ *
4555
+ * **Decoded on its own, a stream that starts anywhere but `1` opens on a
4556
+ * sequence gap.** The decoder never bridges a gap silently, so it warns
4557
+ * (`ASTM_FRAME_SEQUENCE_GAP`) and does not emit that first record.
4558
+ * **What {@link parseFramedAstm} does after that varies with the message
4559
+ * shape and no rule is offered for it here.** It may throw, under more than
4560
+ * one code, and it may return a message that is simply one record short. **The
4561
+ * one thing that does hold is that the record layer never reports the loss**:
4562
+ * `parseFramedAstm` hands the record parser only the frames the codec
4563
+ * vouched for, so `message.warnings` carries what the surviving records
4564
+ * warrant and nothing about the record that did not survive. **Read
4565
+ * `frameWarnings`.** That is the cost of the option rather than a defect in
4566
+ * the caller's records, and it is why `1` is the default.
4567
+ *
4568
+ * **Finding the number to continue from.** Nothing here returns it, and the
4569
+ * frame count is not it once a record splits: decode the part you just
4570
+ * composed and read the last frame's number, then add one modulo 8, as in
4571
+ * `((decodeAstmFrames(part).frames.at(-1)?.frameNumber ?? 0) + 1) % 8`. A frame
4572
+ * carries no number only when the stream ends immediately after its `STX`,
4573
+ * which is not something this encoder writes, so that fallback never fires on
4574
+ * a part composed here. Getting the number wrong costs the record at the join,
4575
+ * warned rather than silently.
4576
+ *
4577
+ * A value outside `0`-`7`, or one that is not a whole number, is refused with
4578
+ * `ASTM_FRAME_INVALID_START_FRAME_NUMBER`.
4505
4579
  */
4506
4580
  readonly startFrameNumber?: number;
4507
4581
  }
@@ -4539,6 +4613,17 @@ interface ComposeFramesOptions {
4539
4613
  * absorbed into the previous one with an empty warnings array at both layers.
4540
4614
  * Supplying the record as a `Uint8Array` does not route around this check.
4541
4615
  *
4616
+ * **`options.startFrameNumber` is checked before *this function* reads a record**,
4617
+ * so on this entry point the refusal never depends on the caller's data.
4618
+ * ({@link serializeFramedAstm} serializes every record *before* it gets here, so
4619
+ * a record that cannot be serialized is refused first on that route.) It has to
4620
+ * be a whole number from `0` to `7`, because a frame's number is one ASCII digit;
4621
+ * anything else is `ASTM_FRAME_INVALID_START_FRAME_NUMBER` rather than whatever
4622
+ * byte the arithmetic truncated to. A value other than the default `1` writes a
4623
+ * **continuation** of a sequence already in progress, which is what the option is
4624
+ * for, and a continuation decoded on its own opens on a sequence gap: see
4625
+ * {@link ComposeFramesOptions}.
4626
+ *
4542
4627
  * What is still **not** guaranteed: that an accepted record round-trips through
4543
4628
  * the *record* layer. A delimiter colliding with a record's type letter, for one,
4544
4629
  * frames and de-frames byte-exactly and still re-reads as a different record.
@@ -4547,10 +4632,12 @@ interface ComposeFramesOptions {
4547
4632
  * `string`), one entry per complete record.
4548
4633
  * @param options - Encode options.
4549
4634
  * @returns The framed byte stream.
4550
- * @throws {@link AstmFrameEncodeError} when the list is empty, a record has no
4551
- * bytes (`ASTM_FRAME_EMPTY_RECORD`), a record string holds a character above
4552
- * `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`,
4553
- * `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4635
+ * @throws {@link AstmFrameEncodeError} when `options.startFrameNumber` is not a
4636
+ * whole number from `0` to `7` (`ASTM_FRAME_INVALID_START_FRAME_NUMBER`), the
4637
+ * list is empty, a record has no bytes (`ASTM_FRAME_EMPTY_RECORD`), a record
4638
+ * string holds a character above `U+00FF`
4639
+ * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4640
+ * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4554
4641
  * @example
4555
4642
  * ```ts
4556
4643
  * import { composeAstmFrames, decodeAstmFrames } from "@cosyte/astm";
@@ -4625,7 +4712,11 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4625
4712
  * delimiters, embedded delimiters re-escaped) and then framed **independently**
4626
4713
  * (one record per `ETX`-closed frame run), so the framing exactly mirrors what
4627
4714
  * {@link decodeAstmFrames} reassembles: `parseFramedAstm(serializeFramedAstm(msg))`
4628
- * yields an equal message.
4715
+ * yields an equal message **with the default `startFrameNumber`**. That clause is
4716
+ * load-bearing rather than pedantic: a non-default start writes a continuation of
4717
+ * a sequence already in progress, and a continuation read on its own opens on a
4718
+ * sequence gap, so the decoder does not emit its first record and this round trip
4719
+ * does not hold for it. See {@link ComposeFramesOptions}.
4629
4720
  *
4630
4721
  * A value carrying a character above `U+00FF` is **refused** here rather than
4631
4722
  * framed: the record layer is happy to hold one, but a frame carries bytes and
@@ -4645,10 +4736,11 @@ declare function parseFramedAstm(bytes: Uint8Array, options?: FrameOptions & Ast
4645
4736
  * survive being written in the canonical set this function serializes with
4646
4737
  * (`ASTM_EMIT_TYPE_LETTER_COLLISION`). The second reaches messages parsed under a
4647
4738
  * **different** delimiter set, since this function passes no set of its own.
4648
- * @throws {@link AstmFrameEncodeError} when there are no records to frame
4649
- * (`ASTM_FRAME_EMPTY_RECORD`), a value holds a character above `U+00FF`
4650
- * (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record holds an `STX`, `ETB` or
4651
- * `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4739
+ * @throws {@link AstmFrameEncodeError} when `options.startFrameNumber` is not a
4740
+ * whole number from `0` to `7` (`ASTM_FRAME_INVALID_START_FRAME_NUMBER`), there
4741
+ * are no records to frame (`ASTM_FRAME_EMPTY_RECORD`), a value holds a
4742
+ * character above `U+00FF` (`ASTM_FRAME_UNENCODABLE_CHARACTER`), or a record
4743
+ * holds an `STX`, `ETB` or `ETX` byte (`ASTM_FRAME_RESERVED_BYTE`).
4652
4744
  * @example
4653
4745
  * ```ts
4654
4746
  * import { parseAstmRecords, serializeFramedAstm, parseFramedAstm } from "@cosyte/astm";
package/dist/index.mjs CHANGED
@@ -1433,6 +1433,13 @@ function encodeComponent(leaf, d, recordIndex) {
1433
1433
  assertEmittableDelimiters(d, recordIndex);
1434
1434
  return encodeLeaf(leaf, d, recordIndex);
1435
1435
  }
1436
+ function escapeMnemonic(ch, d) {
1437
+ if (ch === d.escape) return "E";
1438
+ if (ch === d.field) return "F";
1439
+ if (ch === d.component) return "S";
1440
+ if (ch === d.repeat) return "R";
1441
+ return void 0;
1442
+ }
1436
1443
  function encodeLeaf(leaf, d, recordIndex) {
1437
1444
  if (leaf.includes("\r") || leaf.includes("\n")) {
1438
1445
  throw new AstmSerializeError(
@@ -1440,7 +1447,13 @@ function encodeLeaf(leaf, d, recordIndex) {
1440
1447
  recordIndex
1441
1448
  );
1442
1449
  }
1443
- return leaf.split(d.escape).join(d.escape + "E" + d.escape).split(d.field).join(d.escape + "F" + d.escape).split(d.component).join(d.escape + "S" + d.escape).split(d.repeat).join(d.escape + "R" + d.escape);
1450
+ let out = "";
1451
+ for (let i = 0; i < leaf.length; i += 1) {
1452
+ const ch = leaf.charAt(i);
1453
+ const mnemonic = escapeMnemonic(ch, d);
1454
+ out += mnemonic === void 0 ? ch : d.escape + mnemonic + d.escape;
1455
+ }
1456
+ return out;
1444
1457
  }
1445
1458
  function encodeField(repeats, d, recordIndex) {
1446
1459
  return repeats.map((rep) => rep.map((c) => encodeLeaf(c, d, recordIndex)).join(d.component)).join(d.repeat);
@@ -2288,6 +2301,15 @@ function assertFramable(bytes, recordIndex) {
2288
2301
  }
2289
2302
  }
2290
2303
  }
2304
+ function assertStartFrameNumber(value) {
2305
+ if (!Number.isInteger(value) || value < 0 || value > FRAME_NUMBER_MODULUS - 1) {
2306
+ throw new AstmFrameEncodeError(
2307
+ `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)}.`,
2308
+ void 0,
2309
+ "ASTM_FRAME_INVALID_START_FRAME_NUMBER"
2310
+ );
2311
+ }
2312
+ }
2291
2313
  function encodeFrame(text, frameNumber, isFinal) {
2292
2314
  const term = isFinal ? ETX : ETB;
2293
2315
  const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
@@ -2296,11 +2318,13 @@ function encodeFrame(text, frameNumber, isFinal) {
2296
2318
  return [...bytes, hex.charCodeAt(0), hex.charCodeAt(1), CR, LF];
2297
2319
  }
2298
2320
  function composeAstmFrames(records, options = {}) {
2321
+ const start = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2322
+ assertStartFrameNumber(start);
2299
2323
  if (records.length === 0) {
2300
2324
  throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
2301
2325
  }
2302
2326
  const out = [];
2303
- let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2327
+ let frameNumber = start;
2304
2328
  records.forEach((record, recordIndex) => {
2305
2329
  const bytes = toBytes(record, recordIndex);
2306
2330
  if (bytes.length === 0) {
@@ -2345,7 +2369,7 @@ function concatBytes3(chunks) {
2345
2369
  }
2346
2370
 
2347
2371
  // src/index.ts
2348
- var VERSION = "0.0.13";
2372
+ var VERSION = "0.0.15";
2349
2373
 
2350
2374
  export { ALL_ASTM_WARNING_CODES, AMBIGUOUS_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, AstmAmbiguousStreamError, AstmFrameEncodeError, AstmFrameStrictError, AstmParseError, AstmProfileDefinitionError, AstmSerializeError, AstmStrictError, CANONICAL_DELIMITERS, FATAL_CODES, FRAME_WARNING_CODES, LIVD_WARNING_CODES, LTP_WARNING_CODES, SAFETY_CRITICAL_CODES, TOLERABLE_CODES, VERSION, WARNING_CODES, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, interpretAbnormalFlag, interpretResultStatus, isNonStandard, isSafetyCriticalCode, listAstmProfiles, livdAmbiguousMapping, livdUnmappedCode, lookupLivdForRecord, ltpAmbiguousTransport, ltpFrameRejected, ltpInitialState, ltpReduce, ltpUnexpectedEvent, messages, nonStandardDelimiters, orders, orphanComment, parseAstmDate, parseAstmRecords, parseChecksumHex, parseFramedAstm, parseReferenceRange, partialTimestamp, patient, primaryCode, profileQuirkApplied, query, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
2351
2375
  //# sourceMappingURL=index.mjs.map