@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/CHANGELOG.md CHANGED
@@ -11,6 +11,123 @@ this file is maintained by hand (Changesets handles the version bump and publish
11
11
 
12
12
  ### Fixed
13
13
 
14
+ - **The escape encoder protected the escape character it introduced but not the `E`/`F`/`S`/`R`
15
+ mnemonics, so a delimiter set naming one of those letters in another role altered the values it
16
+ emitted** (`ASTM-FRAME-RESIDUALS` follow-up, defect 12). `PRE-EXISTING`, byte-identical on
17
+ `4bb62f1`. `encodeLeaf` ran as four chained whole-string substitutions (escape, then field,
18
+ component, repeat), and each pass could see what the previous ones had written. Going first
19
+ protected the escape character; nothing protected the mnemonic letters. Under
20
+ `{ field: "E", escape: "R" }` a leaf of `R` went out as `RRFRR` and read back as `RER`.
21
+
22
+ **Re-measured here rather than carried over, because the recorded figures came from a sweep whose
23
+ parameters were not written down.** Over the whole four-role space drawn from the 18-character
24
+ alphabet `|` `\` `^` `&` `E` `F` `S` `R` `*` `~` `:` `#` `+` `@` `!` `$` `%` `/` (the four
25
+ canonical delimiters, the four mnemonic letters, a common vendor set, neutral punctuation), that is
26
+ P(18,4) = 73,440 sets, against **exactly the `STREAM` constant in
27
+ `test/records/escape-mnemonic-roles.test.ts`**, a synthetic ten-record
28
+ `H`/`P`/`O`/`R`/`R`/`R`/`C`/`M`/`S`/`L` message that parses with `warnings: []`, the serializer
29
+ refused 23,040 and accepted **50,400**. Of those, **10,450** emitted
30
+ a stream this library re-read with a **different field tree**, and **9,287** reported nothing
31
+ outside `TOLERABLE_CODES`, so a gate-legal profile plus `{ strict: true }` accepted an altered
32
+ value.
33
+
34
+ **The "0 silent" reading is confirmed and is weaker than it looks, which is worth more than the
35
+ number.** Zero of the 10,450 re-parsed with `warnings: []`, but a non-canonical set always reports
36
+ `ASTM_NONSTANDARD_DELIMITERS`, and that code is itself tolerable, so `warnings: []` was never
37
+ reachable for any set that could exhibit this defect. Silence was not available as a measure here.
38
+ The tier that does discriminate is the strict-accepted one above.
39
+
40
+ **The fix is one left-to-right pass.** Each character is read once and written either as itself or
41
+ as a whole `escape` + mnemonic + `escape` triple, and nothing the encoder writes is examined again,
42
+ which makes it the **exact** inverse of `decodeEscapes` rather than approximately so. No accepted
43
+ set in that sweep diverges after it. Bytes change for **exactly** the 10,450 sets that were
44
+ diverging and for no others (measured both directions: 0 sets diverged with unchanged bytes, 0 sets
45
+ changed bytes without having diverged), so a canonical stream and any set naming none of the four
46
+ mnemonic letters is byte-identical. The accept/refuse partition is unchanged over all 73,440 sets:
47
+ `ASTM_EMIT_TYPE_LETTER_COLLISION` reads the first character actually written, so it needed no
48
+ change when the encoder underneath it did, which is the argument for testing bytes rather than
49
+ enumerating dangerous roles. **No clause is claimed**: the grounding is this package's own decoder.
50
+
51
+ Pinned in `test/records/escape-mnemonic-roles.test.ts`, which transcribes the four chained
52
+ substitutions (nothing shipped still reproduces them) and **checks that transcription against the
53
+ shipped encoder on all 1,680 sets where the two must agree**, so it cannot decay into a strawman.
54
+ 8 of its assertions are red against a `git archive` of `4bb62f1`. Five of them pin the **emitted bytes as literals**, each derived in a comment rather than snapshotted, because every other assertion here reads the stream back through this package's own parser and so cannot see an encoder and decoder that agree with each other and are both wrong. The committed test pins a
55
+ 12-character subspace (P(12,4) = 11,880) rather than the 18, because the full space costs about a
56
+ minute of suite time and the extra six characters are neutral punctuation already represented. The
57
+ corpus is the same in both: the 73,440-set figures above differ from the test's only by alphabet. The claim in
58
+ `test/records/type-letter-collision.test.ts` that a type letter equal to the escape character
59
+ "does not always encode as letter + E + letter" was pinned to `RRFRR`/`RRSRR` and is now false:
60
+ it encodes that way for every set, and the assertion was updated to say so rather than deleted.
61
+
62
+ - **`composeAstmFrames` wrote an out-of-range `startFrameNumber` straight into the frame-number
63
+ position, so a value that was not a frame number produced a stream whose records the decoder
64
+ refused to emit** (`ASTM-FRAME-RESIDUALS`, defect 13). `PRE-EXISTING`, byte-identical on `64c2fd5`.
65
+ The option is now checked before `composeAstmFrames` reads a record, and refused with the new code
66
+ `ASTM_FRAME_INVALID_START_FRAME_NUMBER` (a fourth member of `AstmFrameEncodeErrorCode`) unless it is
67
+ a whole number from `0` to `7`.
68
+
69
+ **What went out instead, measured on this package's own round trip.** A frame's number is one ASCII
70
+ digit, written as `FN_ZERO + n`, and nothing checked `n`. `-1` put a `/` in the frame-number
71
+ position, and `NaN`, `Infinity` and `-Infinity` each put a `NUL` there in **every** frame of the
72
+ stream, after which `decodeAstmFrames` recognised no frame number at all, warned
73
+ `ASTM_FRAME_SEQUENCE_GAP` on every frame, and emitted **none** of the records: a whole four-record
74
+ message in, zero out, and `parseFramedAstm` throwing on empty input. **The recorded defect said an
75
+ out-of-range value writes a non-digit byte and named a space for `NaN`. It is a `NUL`**, which is
76
+ the difference between an ordinary inter-frame byte the decoder skips and a byte that costs the
77
+ stream every one of its records.
78
+
79
+ **The quieter half is why the check covers the whole domain rather than the ends.** Values that
80
+ truncated back onto a digit were accepted silently and behaved as some other start value: `1.5` and
81
+ `257` each produced the byte-for-byte stream a `startFrameNumber` of `1` produces. The option
82
+ documented `0`-`7` and enforced neither bound nor integrality in either direction.
83
+
84
+ **The domain is derived from what a frame can carry, and the refusal is not a clamp.** The writable
85
+ numbers are exactly the whole numbers `0`-`7`, the modulo-8 sequence `decodeAstmFrames` reads and
86
+ rolls over. Clamping or taking a modulo would pick a frame number the caller did not ask for, and
87
+ the frame number is the decoder's only evidence that no frame was dropped, so a stream numbered from
88
+ a value nobody chose is a stream whose sequence check certifies the wrong thing. The refusal is
89
+ raised before `composeAstmFrames` reads a record, so on that entry point it never depends on the
90
+ caller's data (`serializeFramedAstm` serializes every record first, so a record that cannot be
91
+ serialized at all is refused ahead of it on that route). Its message names the value received: that
92
+ value is the caller's own option, not stream content, and nothing from the records reaches it.
93
+
94
+ **The whole `0`-`7` range is still accepted, because a non-default start has a real use that was
95
+ measured rather than assumed.** It composes a **continuation** of a transfer already in progress:
96
+ `composeAstmFrames(head)` joined with `composeAstmFrames(tail, { startFrameNumber: n })`, where `n`
97
+ is the number after the last frame `head` used, is **byte-identical** to composing the whole list in
98
+ one call and decodes with an empty warnings array, across the `7 → 0` rollover included. Narrowing
99
+ the option to `1` would have removed that.
100
+
101
+ **What a continuation is not, now stated on the option rather than guarded against.** Read on its
102
+ own, a stream that starts anywhere but `1` opens on a sequence gap; the decoder never bridges a gap
103
+ silently, so it warns and does not emit that first record. **What `parseFramedAstm` does after that
104
+ varies with the message shape, and no rule is offered for it:** it may throw, under more than one
105
+ code (`ASTM_RECORD_NO_HEADER`, `EMPTY_INPUT` and `ASTM_RECORD_UNDECLARED_DELIMITERS` were each
106
+ measured), and it may return a message one record short. **The one statement that generalizes is
107
+ that the record layer never reports the loss**, because `parseFramedAstm` hands the record parser
108
+ only the frames the codec vouched for, so `message.warnings` carries what the surviving records
109
+ 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
110
+ is not it once a record splits, so the supported computation is named on the option. The
111
+ documented-valid `0` has always behaved that way. It is the cost of the option, not a defect in the
112
+ caller's records, and it is why `1` is the default.
113
+
114
+ **This is a narrowing on a published package, in a small population.** `1.5` and `257` used to
115
+ produce a working stream, the byte-for-byte start-at-`1` one, and now throw. Every value the check
116
+ turns away was already outside what the option documented: a frame number, a whole number `0`-`7`.
117
+
118
+ Both the old bytes and the new refusal are pinned in `test/frames/start-frame-number.test.ts`,
119
+ asserted on what the old encoder produced (rebuilt with the test-only `frame()` builder, so nothing
120
+ is transcribed twice), with a biconditional property: a value is refused **if and only if** it is
121
+ not a whole number in `0`-`7`. 14 of the 32 new tests are red against `64c2fd5`.
122
+
123
+ - **Three `{@link}` targets in the published `.d.ts` did not name a symbol declared there**
124
+ (`ASTM-FRAME-RESIDUALS`, the sibling minors). `QuirkTolerance` is `AstmQuirkTolerance` (twice, in
125
+ `src/profiles/types.ts`); the `startFrameNumber` doc linked `FIRST_FRAME_NUMBER`, which this
126
+ package does not export, and that sentence was rewritten and no longer links it; and
127
+ `AstmMessage.profile` linked a bare `warnings`, meaning its sibling member, which resolves only for
128
+ a tool that resolves against the enclosing declaration. Every `{@link}` target in
129
+ `dist/index.d.ts` now names a symbol declared in that file.
130
+
14
131
  - **A delimiter set colliding with a record's type letter emitted a stream that read back as
15
132
  DIFFERENT records, and in its silent branch it fabricated a final lab result out of patient
16
133
  identifiers** (`ASTM-FRAME-RESIDUALS`, defect 5). `PRE-EXISTING`, byte-identical on `7253098`,
@@ -323,6 +440,34 @@ this file is maintained by hand (Changesets handles the version bump and publish
323
440
  that only ever fails is not a gate and one that swallows the status is not one either. **No
324
441
  runtime code changed and the built output is identical.**
325
442
 
443
+ ### Documented
444
+
445
+ - **A header delimiter-declaration surplus loses all 31 of the C0/`DEL` characters that can reach
446
+ it, and the published surfaces stated the preservation rule without that exception.**
447
+ `PRE-EXISTING`; the
448
+ behaviour is unchanged and argued at `declarationResidual`. What changed is that it is measured,
449
+ pinned and stated, and that **one of the recorded reasons for it is now known to be false**:
450
+ "carrying the byte through would turn a spec-clean header into a stream the frame layer refuses"
451
+ holds for `STX`, `ETX` and `ETB` only. Measured, the other **28 of the 31** round-trip byte-exactly
452
+ through `composeAstmFrames` into `decodeAstmFrames` with `warnings: []`, and all 31 round-trip at
453
+ the record layer. The disposition still stands, on the reason the code site actually gives: rather
454
+ than enumerate the bytes each layer happens to reserve and re-derive that list every time a layer
455
+ is added, no control character is carried at all. Do not restate the frame-layer reason. Probing one character at a time in the surplus of an otherwise spec-clean
456
+ `H|\^&` header: **31 of the 33** C0/`DEL` characters reach the rule and are dropped, each with
457
+ `warnings: []` and no error; the other two are `CR` and `LF`, which end the record while it is being
458
+ read, so no surplus ever holds them. The drop is **all-or-nothing**, so a surplus of `#` + `US` + `$`
459
+ loses the `#` and the `$` as well, and that is deliberate: a subsequence of an opaque run whose
460
+ meaning is unresolved is a different run, not a shorter version of the same one, and choosing which
461
+ characters to keep is the guess this library declines everywhere else. `docs-content/quickstart.md`
462
+ attached the control-character drop to the transcoding case alone, which is wrong: it fires on the
463
+ **default canonical path** too. The `serializeAstmRecords` and `serializeAstmRecord` doc comments
464
+ and this module's own doc block, all of which compile into the published declarations, stated the
465
+ preservation rule unqualified. All are
466
+ widened rather than rewritten, and `docs-content/limitations.md`, which already disclosed the drop,
467
+ gains the all-or-nothing shape and the count. Pinned in
468
+ `test/records/declaration-surplus-residual.test.ts`, which passes on `4bb62f1` by design: it
469
+ measures behaviour this entry does not change.
470
+
326
471
  ### Added
327
472
 
328
473
  - **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
package/README.md CHANGED
@@ -53,7 +53,13 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
53
53
  of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
54
54
  that byte, silently losing a whole record whenever the two bytes after it happened to be the short
55
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.
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.
57
63
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
58
64
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
59
65
  ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
package/dist/index.cjs CHANGED
@@ -1435,6 +1435,13 @@ function encodeComponent(leaf, d, recordIndex) {
1435
1435
  assertEmittableDelimiters(d, recordIndex);
1436
1436
  return encodeLeaf(leaf, d, recordIndex);
1437
1437
  }
1438
+ function escapeMnemonic(ch, d) {
1439
+ if (ch === d.escape) return "E";
1440
+ if (ch === d.field) return "F";
1441
+ if (ch === d.component) return "S";
1442
+ if (ch === d.repeat) return "R";
1443
+ return void 0;
1444
+ }
1438
1445
  function encodeLeaf(leaf, d, recordIndex) {
1439
1446
  if (leaf.includes("\r") || leaf.includes("\n")) {
1440
1447
  throw new AstmSerializeError(
@@ -1442,7 +1449,13 @@ function encodeLeaf(leaf, d, recordIndex) {
1442
1449
  recordIndex
1443
1450
  );
1444
1451
  }
1445
- 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);
1452
+ let out = "";
1453
+ for (let i = 0; i < leaf.length; i += 1) {
1454
+ const ch = leaf.charAt(i);
1455
+ const mnemonic = escapeMnemonic(ch, d);
1456
+ out += mnemonic === void 0 ? ch : d.escape + mnemonic + d.escape;
1457
+ }
1458
+ return out;
1446
1459
  }
1447
1460
  function encodeField(repeats, d, recordIndex) {
1448
1461
  return repeats.map((rep) => rep.map((c) => encodeLeaf(c, d, recordIndex)).join(d.component)).join(d.repeat);
@@ -2290,6 +2303,15 @@ function assertFramable(bytes, recordIndex) {
2290
2303
  }
2291
2304
  }
2292
2305
  }
2306
+ function assertStartFrameNumber(value) {
2307
+ if (!Number.isInteger(value) || value < 0 || value > FRAME_NUMBER_MODULUS - 1) {
2308
+ throw new AstmFrameEncodeError(
2309
+ `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)}.`,
2310
+ void 0,
2311
+ "ASTM_FRAME_INVALID_START_FRAME_NUMBER"
2312
+ );
2313
+ }
2314
+ }
2293
2315
  function encodeFrame(text, frameNumber, isFinal) {
2294
2316
  const term = isFinal ? ETX : ETB;
2295
2317
  const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
@@ -2298,11 +2320,13 @@ function encodeFrame(text, frameNumber, isFinal) {
2298
2320
  return [...bytes, hex.charCodeAt(0), hex.charCodeAt(1), CR, LF];
2299
2321
  }
2300
2322
  function composeAstmFrames(records, options = {}) {
2323
+ const start = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2324
+ assertStartFrameNumber(start);
2301
2325
  if (records.length === 0) {
2302
2326
  throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
2303
2327
  }
2304
2328
  const out = [];
2305
- let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2329
+ let frameNumber = start;
2306
2330
  records.forEach((record, recordIndex) => {
2307
2331
  const bytes = toBytes(record, recordIndex);
2308
2332
  if (bytes.length === 0) {
@@ -2347,7 +2371,7 @@ function concatBytes3(chunks) {
2347
2371
  }
2348
2372
 
2349
2373
  // src/index.ts
2350
- var VERSION = "0.0.13";
2374
+ var VERSION = "0.0.15";
2351
2375
 
2352
2376
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2353
2377
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;