@cosyte/astm 0.0.9 → 0.0.10

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
@@ -9,6 +9,73 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Fixed
13
+
14
+ - **The frame encoder no longer writes a different character than the one it was handed**
15
+ (`ASTM-FRAME-BYTE-RESIDUALS`, closing known defect 7). `composeAstmFrames` turned a record given
16
+ as a `string` into bytes with `charCodeAt(i) & 0xff`, so any character above `U+00FF` was replaced
17
+ by its low byte. That low byte is another perfectly ordinary character, which is why the
18
+ substitution was **silent**: it reached the wire, checksummed clean, and came back with an empty
19
+ warnings array from **both** layers.
20
+
21
+ Measured, on this package's own round trip, three shapes on the canonical set:
22
+ - `R|1|^^^687|28.6|μmol/L||N||F` read back `28.6` in `¼mol/L` (`U+03BC` low byte `0xBC`). A lab
23
+ number whose **units** silently changed.
24
+ - `P|1||LAB-0001||ŁUKASZ^JAN||19800101|M` read back the name spelled `AUKASZ` (`U+0141` low byte
25
+ `0x41`).
26
+ - `P|1||LAB-0001||GRAżYNA^ANNA||19800101|F` read back as **ten fields where the record models
27
+ nine** (`U+017C` low byte `0x7C`, the field separator). Every field after the split shifted one
28
+ along, so `patient().sex` answered `19800101` and the birth date was gone. That is **silent
29
+ re-read divergence**: a different field tree, `warnings: []` at both layers.
30
+
31
+ The known-defect entry said this class fails loudly in every case measured, and for the cases it
32
+ had measured that was true: they all truncated onto `STX`/`ETX`/`ETB`/`CR`. Nothing had measured a
33
+ code point whose low byte is an ordinary character, and that is the larger and quieter class.
34
+
35
+ **The disposition is a refusal, and it invents nothing.** `composeAstmFrames` now throws
36
+ `AstmFrameEncodeError` with the new `ASTM_FRAME_UNENCODABLE_CHARACTER` code, carrying the record
37
+ index and the character's index, never the character and never its code point. The alternative,
38
+ encoding as UTF-8, was **considered and rejected**: turning a character into bytes requires a
39
+ character encoding, this library reads no character-set declaration from any ASTM record, and
40
+ picking one would be a guess at bytes the caller never supplied, in a package that declines to
41
+ infer a delimiter set, a record type letter or a code system. Emit also has no warning channel, so
42
+ a warning could only have been ignored while the wrong bytes still shipped. That is the same
43
+ argument already written for `ASTM_EMIT_UNENCODABLE_VALUE` and `ASTM_EMIT_INVALID_DELIMITERS`.
44
+
45
+ **What grounds the Latin-1 boundary**, since no clause is claimed for it and none was read. Two
46
+ things, both firsthand. (1) This package's own byte-to-string boundary is already Latin-1, on the
47
+ read side, deliberately: `parseAstmRecords` decodes with one `String.fromCharCode` per byte "so
48
+ every byte survives 1:1". The encoder is documented as the exact inverse of the decoder, and the
49
+ decoder can never produce a code point above `U+00FF`, so a string carrying one is outside the
50
+ codec's domain by construction. (2) The redistributable OSS reference implementation
51
+ (`kxepal/python-astm`, BSD) parameterizes the codec on `encoding` throughout
52
+ (`encode(records, encoding=ENCODING, ...)` / `decode(data, encoding=ENCODING)`) and defines
53
+ `ENCODING = 'latin-1'` in `astm/constants.py`: the wire code page there is caller-supplied
54
+ out-of-band knowledge with a latin-1 default, not something a reader discovers from the stream.
55
+ **The normative text was not reachable and no clause is cited**: LIS02-A2 is withheld from CLSI's
56
+ free sample and E1394-97 / LIS01-A2 are paywalled, so this is a reasoned choice, exactly as the
57
+ delimiter-scoping and surplus-declaration choices before it were.
58
+
59
+ **The refusal removes no capability.** `composeAstmFrames` already accepted `Uint8Array`, so a
60
+ consumer who knows their instrument's code page encodes with their own encoder and passes bytes,
61
+ which are written through untouched. A stream whose every character is Latin-1 is byte-identical
62
+ to before, the full `0x00`–`0xFF` range included. The **record** layer is deliberately unchanged:
63
+ `serializeAstmRecords` returns a `string`, which is not yet bytes, so the caller keeps the choice.
64
+
65
+ **Stated no wider than it was measured.** This closes the string-to-bytes step only. It is not a
66
+ claim that every accepted record reads back: a record already carrying a raw `STX`/`ETX`/`ETB`
67
+ byte is framed as given and re-decodes wrong (known defect 6), which this change does not touch.
68
+ **Both** of that defect's branches are now pinned as out of scope in
69
+ `test/frames/unencodable-character.test.ts`, the warned one and the **silent** one: where the two
70
+ bytes following the embedded control character happen to be that truncated frame's checksum, the
71
+ short frame verifies and a whole result record is absorbed into the previous record's text with
72
+ `warnings: []` at both layers. That branch was found grading this change, reproduces byte-identically
73
+ on the base, and is why known defect 6's "it fails loudly" ranking was withdrawn rather than restated. A surrogate pair needs no
74
+ separate rule, each half being above `U+00FF` itself. New export `AstmFrameEncodeErrorCode`;
75
+ `AstmFrameEncodeError.code` is now that union and gains a `characterIndex`. The property "either
76
+ refuse the record string or reproduce it byte for byte" is asserted over 2,000 generated cases,
77
+ with its bound written into the test and a non-vacuity check that both sides of it are reached.
78
+
12
79
  ### Added
13
80
 
14
81
  - **`ASTM_RECORD_FIELDS_UNSEPARATED`: a record that lost its fields no longer loses them in
@@ -481,11 +548,11 @@ phase 8` passes while `Phase 8` reds). An arm keyed on a following digit was wri
481
548
  behind nothing but an `ASTM_FRAME_BAD_CHECKSUM`. Rather than enumerate the bytes each layer happens
482
549
  to reserve, and re-derive that list whenever a layer is added, no control character is carried at
483
550
  all. Two things that rule does **not** do, said here rather than left to be found: it is keyed on
484
- the character while the frame layer's structure is keyed on the low byte (`charCodeAt(i) & 0xff`),
485
- so a non-control character truncating onto `STX`/`ETX`/`ETB` still breaks framing, loudly, with a
486
- typed error or an unknown-record-type warning, never silently; and refusing a control character in
487
- a _surplus_ does not mean one cannot be a _delimiter role_, since only `CR`/`LF` are refused
488
- there. Those are
551
+ the character, so a surplus character above `U+00FF` passes it (the frame layer is where that is
552
+ settled now, and refuses such a character rather than truncating it, see the entry above; when
553
+ this was written the frame encoder truncated to the low byte and the case broke framing instead);
554
+ and refusing a control character in a _surplus_ does not mean one cannot be a _delimiter role_,
555
+ since only `CR`/`LF` are refused there. Those are
489
556
  structural losses; dropping inert bytes is not.
490
557
 
491
558
  **2. A caller-supplied delimiter set was never validated.** `serializeAstmRecords(msg, d)` and its
package/README.md CHANGED
@@ -39,7 +39,10 @@ reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
39
39
  frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
40
40
  construction, and a delimiter set that fails any of the three conditions readback requires (one
41
41
  character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
42
- and lost.
42
+ and lost. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
43
+ byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
44
+ 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`.
43
46
  - **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
44
47
  downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
45
48
  ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
package/dist/index.cjs CHANGED
@@ -2226,18 +2226,37 @@ var AstmFrameEncodeError = class extends Error {
2226
2226
  code;
2227
2227
  /** Index of the offending record within the input, when applicable. */
2228
2228
  recordIndex;
2229
+ /**
2230
+ * Position of the offending character within that record, when applicable:
2231
+ * 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.
2233
+ */
2234
+ characterIndex;
2229
2235
  /** @internal */
2230
- constructor(message, recordIndex) {
2236
+ constructor(message, recordIndex, code = "ASTM_FRAME_EMPTY_RECORD", characterIndex) {
2231
2237
  super(message);
2232
2238
  this.name = "AstmFrameEncodeError";
2233
- this.code = "ASTM_FRAME_EMPTY_RECORD";
2239
+ this.code = code;
2234
2240
  if (recordIndex !== void 0) this.recordIndex = recordIndex;
2241
+ if (characterIndex !== void 0) this.characterIndex = characterIndex;
2235
2242
  }
2236
2243
  };
2237
- function toBytes(input) {
2244
+ var MAX_SINGLE_BYTE_CODE_POINT = 255;
2245
+ function toBytes(input, recordIndex) {
2238
2246
  if (typeof input !== "string") return input;
2239
2247
  const out = new Uint8Array(input.length);
2240
- for (let i = 0; i < input.length; i += 1) out[i] = input.charCodeAt(i) & 255;
2248
+ for (let i = 0; i < input.length; i += 1) {
2249
+ const code = input.charCodeAt(i);
2250
+ if (code > MAX_SINGLE_BYTE_CODE_POINT) {
2251
+ throw new AstmFrameEncodeError(
2252
+ "A record contains a character above U+00FF, which is not a single byte. Encode the record with the character encoding your instrument uses and pass the bytes.",
2253
+ recordIndex,
2254
+ "ASTM_FRAME_UNENCODABLE_CHARACTER",
2255
+ i
2256
+ );
2257
+ }
2258
+ out[i] = code;
2259
+ }
2241
2260
  return out;
2242
2261
  }
2243
2262
  function encodeFrame(text, frameNumber, isFinal) {
@@ -2254,7 +2273,7 @@ function composeAstmFrames(records, options = {}) {
2254
2273
  const out = [];
2255
2274
  let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2256
2275
  records.forEach((record, recordIndex) => {
2257
- const bytes = toBytes(record);
2276
+ const bytes = toBytes(record, recordIndex);
2258
2277
  if (bytes.length === 0) {
2259
2278
  throw new AstmFrameEncodeError(
2260
2279
  "Cannot frame an empty record: a frame must carry record bytes.",
@@ -2296,7 +2315,7 @@ function concatBytes3(chunks) {
2296
2315
  }
2297
2316
 
2298
2317
  // src/index.ts
2299
- var VERSION = "0.0.9";
2318
+ var VERSION = "0.0.10";
2300
2319
 
2301
2320
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2302
2321
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;