@cosyte/astm 0.0.3 → 0.0.5

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.cjs CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/common/errors.ts
4
4
  var FATAL_CODES = {
5
- /** Input was empty or whitespace-only — there is nothing to parse. Shared across layers. */
5
+ /** Input was empty or whitespace-only: there is nothing to parse. Shared across layers. */
6
6
  EMPTY_INPUT: "EMPTY_INPUT",
7
- /** The first record is not an `H` (header) record — an ASTM message must lead with `H`. */
7
+ /** The first record is not an `H` (header) record: an ASTM message must lead with `H`. */
8
8
  ASTM_RECORD_NO_HEADER: "ASTM_RECORD_NO_HEADER",
9
9
  /** The `H` record is too short to declare the four delimiters (field/repeat/component/escape). */
10
10
  ASTM_RECORD_UNDECLARED_DELIMITERS: "ASTM_RECORD_UNDECLARED_DELIMITERS"
@@ -121,21 +121,21 @@ function primaryCode(u) {
121
121
 
122
122
  // src/common/warnings.ts
123
123
  var WARNING_CODES = {
124
- /** A record's type letter is not one of the modeled types — surfaced as an unsupported record. */
124
+ /** A record's type letter is not one of the modeled types: surfaced as an unsupported record. */
125
125
  ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
126
- /** The header declared delimiters other than the canonical `H|\^&` — tolerated, noted. */
126
+ /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
127
127
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
128
- /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&` — preserved verbatim. */
128
+ /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
129
129
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
130
130
  /**
131
131
  * A result value field carried an *unescaped* component delimiter, so it split into more than one
132
- * component. Both the full raw value and the split are surfaced and this warning fires — the
132
+ * component. Both the full raw value and the split are surfaced and this warning fires: the
133
133
  * ambiguity is never resolved silently into a truncated value.
134
134
  */
135
135
  ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT: "ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT",
136
136
  /**
137
137
  * A result's abnormal-flag field (`R` field 7) carried a letter outside HL7 Table 0078. The flag is
138
- * surfaced as `undefined` — never dropped, and **never coerced to `normal`** (a clinical error).
138
+ * surfaced as `undefined`: never dropped, and **never coerced to `normal`** (a clinical error).
139
139
  */
140
140
  ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG: "ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG",
141
141
  /**
@@ -145,7 +145,7 @@ var WARNING_CODES = {
145
145
  ASTM_RECORD_UNDEFINED_RESULT_STATUS: "ASTM_RECORD_UNDEFINED_RESULT_STATUS",
146
146
  /**
147
147
  * A result's reference-range field (`R` field 6) did not match a recognized form (`low-high`,
148
- * `<high`, `>low`). The text is surfaced verbatim as `unparsed` — a bound is **never fabricated**.
148
+ * `<high`, `>low`). The text is surfaced verbatim as `unparsed`: a bound is **never fabricated**.
149
149
  */
150
150
  ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE: "ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE",
151
151
  /**
@@ -154,35 +154,57 @@ var WARNING_CODES = {
154
154
  */
155
155
  ASTM_RECORD_UNITS_ABSENT: "ASTM_RECORD_UNITS_ABSENT",
156
156
  /**
157
- * A `C` (comment) record had no valid preceding `H`/`P`/`O`/`R` parent — an **orphan**. The comment
157
+ * A `C` (comment) record had no valid preceding `H`/`P`/`O`/`R` parent: an **orphan**. The comment
158
158
  * is attached to the message root (`attachedToRoot: true`) and surfaced, **never dropped**.
159
159
  */
160
160
  ASTM_RECORD_ORPHAN_COMMENT: "ASTM_RECORD_ORPHAN_COMMENT",
161
161
  /**
162
162
  * A `YYYYMMDDHHMMSS` timestamp had an odd digit run that truncates a two-digit component in half
163
163
  * (e.g. a partial day/hour). The raw run is preserved and the structured value stops at the last
164
- * **complete** component — the dangling digit is **never zero-filled into a fabricated time**.
164
+ * **complete** component: the dangling digit is **never zero-filled into a fabricated time**.
165
165
  */
166
166
  ASTM_RECORD_PARTIAL_TIMESTAMP: "ASTM_RECORD_PARTIAL_TIMESTAMP",
167
167
  /**
168
168
  * A `Q` (request-information) record carried a request-information status code (field 13). The code
169
169
  * *set* is `[OSS-derived / paywalled]` with no publicly-groundable enumeration, so the parser
170
170
  * interprets **none** of them: the status is surfaced verbatim and this value-free warning flags that
171
- * it was passed through **uninterpreted** — never mapped to a guessed meaning.
171
+ * it was passed through **uninterpreted**, never mapped to a guessed meaning.
172
172
  */
173
173
  ASTM_RECORD_UNINTERPRETED_QUERY_STATUS: "ASTM_RECORD_UNINTERPRETED_QUERY_STATUS",
174
174
  /**
175
- * A message carried **both** a `Q` (request) and an `R` (result) record — a contradictory shape. The
175
+ * A message carried **both** a `Q` (request) and an `R` (result) record: a contradictory shape. The
176
176
  * message is classified `host-query` (the `Q` **dominates**, so it is never read as a result set) and
177
177
  * this warning flags the anomaly. Positional context only; no field value.
178
178
  */
179
179
  ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND: "ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND",
180
+ /**
181
+ * A later `H` (header) record declared a **different** delimiter set from the one in force, and the
182
+ * parser **followed it**: that header and every record after it are read with the newly-declared
183
+ * delimiters, until the next `H`. Records already read keep the set that was in force when they were
184
+ * read: a redeclaration never reinterprets bytes that have already been consumed.
185
+ *
186
+ * A stream carrying several messages back to back is ordinary, and a header that simply **repeats**
187
+ * the delimiters already in force is a no-op that warns nothing. This code fires only when the set
188
+ * actually changes, because that is the point at which a reader still using the old set would begin
189
+ * merging fields together.
190
+ */
191
+ ASTM_RECORD_DELIMITERS_REDECLARED: "ASTM_RECORD_DELIMITERS_REDECLARED",
192
+ /**
193
+ * A later `H` (header) record could not declare a usable delimiter set: it was too short, or the
194
+ * field separator it named also appeared among the other three, leaving the four roles
195
+ * indistinguishable. The delimiters **already in force are kept** and every record is still surfaced;
196
+ * a set is never guessed and no record is dropped.
197
+ *
198
+ * The same condition on the *first* header is unrecoverable and remains the
199
+ * `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal: there is no earlier set to fall back to.
200
+ */
201
+ ASTM_RECORD_UNREADABLE_REDECLARATION: "ASTM_RECORD_UNREADABLE_REDECLARATION",
180
202
  /**
181
203
  * The downgraded form an active vendor {@link AstmProfile} produces from a deviation it *expects*
182
204
  * (see `src/profiles/`). The original warning is **never dropped**: its code moves to
183
205
  * {@link AstmRecordWarning.toleratedCode}, the warning is re-badged `PROFILE_QUIRK_APPLIED` with
184
206
  * `expected: true` and the tolerating profile named, so a consumer can filter known, grounded noise
185
- * while the fact of the deviation — and where it was — survives. A profile can only ever reach this
207
+ * while the fact of the deviation, and where it was, survives. A profile can only ever reach this
186
208
  * path for a **non-safety-critical** code (enforced at profile-definition time); a safety-critical
187
209
  * deviation (a result value, flag, status, patient identifier, code system, or a frame-integrity
188
210
  * warning) can **never** be tolerated, so it can never be re-badged here.
@@ -192,14 +214,28 @@ var WARNING_CODES = {
192
214
  function unknownRecordType(position) {
193
215
  return {
194
216
  code: WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
195
- message: "Unrecognized record type \u2014 surfaced verbatim as an unsupported record.",
217
+ message: "Unrecognized record type, surfaced verbatim as an unsupported record.",
196
218
  position
197
219
  };
198
220
  }
199
221
  function nonStandardDelimiters(position) {
200
222
  return {
201
223
  code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
202
- message: "Header declared non-canonical delimiters \u2014 read from the header and honored.",
224
+ message: "Header declared non-canonical delimiters, read from the header and honored.",
225
+ position
226
+ };
227
+ }
228
+ function delimitersRedeclared(position) {
229
+ return {
230
+ code: WARNING_CODES.ASTM_RECORD_DELIMITERS_REDECLARED,
231
+ message: "A later header declared different delimiters, honored from that header onward; earlier records keep the set they were read with.",
232
+ position
233
+ };
234
+ }
235
+ function unreadableRedeclaration(position) {
236
+ return {
237
+ code: WARNING_CODES.ASTM_RECORD_UNREADABLE_REDECLARATION,
238
+ message: "A later header could not declare a usable delimiter set, the delimiters already in force were kept.",
203
239
  position
204
240
  };
205
241
  }
@@ -213,63 +249,63 @@ function unknownEscapeSequence(position) {
213
249
  function ambiguousValueSplit(position) {
214
250
  return {
215
251
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
216
- message: "Result value contained an unescaped component delimiter \u2014 full raw value and split both surfaced.",
252
+ message: "Result value contained an unescaped component delimiter, full raw value and split both surfaced.",
217
253
  position
218
254
  };
219
255
  }
220
256
  function undefinedAbnormalFlag(position) {
221
257
  return {
222
258
  code: WARNING_CODES.ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG,
223
- message: "Abnormal flag is not in HL7 Table 0078 \u2014 surfaced as undefined, never as normal.",
259
+ message: "Abnormal flag is not in HL7 Table 0078, surfaced as undefined, never as normal.",
224
260
  position
225
261
  };
226
262
  }
227
263
  function undefinedResultStatus(position) {
228
264
  return {
229
265
  code: WARNING_CODES.ASTM_RECORD_UNDEFINED_RESULT_STATUS,
230
- message: "Result status is not a recognized status letter \u2014 surfaced as undefined.",
266
+ message: "Result status is not a recognized status letter, surfaced as undefined.",
231
267
  position
232
268
  };
233
269
  }
234
270
  function unparseableReferenceRange(position) {
235
271
  return {
236
272
  code: WARNING_CODES.ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE,
237
- message: "Reference range did not match a recognized form \u2014 surfaced verbatim, no bound invented.",
273
+ message: "Reference range did not match a recognized form, surfaced verbatim, no bound invented.",
238
274
  position
239
275
  };
240
276
  }
241
277
  function unitsAbsent(position) {
242
278
  return {
243
279
  code: WARNING_CODES.ASTM_RECORD_UNITS_ABSENT,
244
- message: "Numeric result value carried no units \u2014 never defaulted, guessed, or converted.",
280
+ message: "Numeric result value carried no units, never defaulted, guessed, or converted.",
245
281
  position
246
282
  };
247
283
  }
248
284
  function orphanComment(position) {
249
285
  return {
250
286
  code: WARNING_CODES.ASTM_RECORD_ORPHAN_COMMENT,
251
- message: "Comment had no valid preceding parent \u2014 attached to the message root, never dropped.",
287
+ message: "Comment had no valid preceding parent, attached to the message root, never dropped.",
252
288
  position
253
289
  };
254
290
  }
255
291
  function partialTimestamp(position) {
256
292
  return {
257
293
  code: WARNING_CODES.ASTM_RECORD_PARTIAL_TIMESTAMP,
258
- message: "Timestamp digit run truncates a component \u2014 preserved verbatim, never zero-filled.",
294
+ message: "Timestamp digit run truncates a component, preserved verbatim, never zero-filled.",
259
295
  position
260
296
  };
261
297
  }
262
298
  function uninterpretedQueryStatus(position) {
263
299
  return {
264
300
  code: WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS,
265
- message: "Query request-information status surfaced verbatim \u2014 code set paywalled, never interpreted.",
301
+ message: "Query request-information status surfaced verbatim, code set paywalled, never interpreted.",
266
302
  position
267
303
  };
268
304
  }
269
305
  function ambiguousMessageKind(position) {
270
306
  return {
271
307
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND,
272
- message: "Message carried both a Q (request) and an R (result) record \u2014 classified host-query; Q dominates.",
308
+ message: "Message carried both a Q (request) and an R (result) record, classified host-query; Q dominates.",
273
309
  position
274
310
  };
275
311
  }
@@ -533,12 +569,12 @@ var FRAME_WARNING_CODES = {
533
569
  * A frame's two-hex-char checksum did not match the modulo-256 sum recomputed over its bytes. The
534
570
  * frame is surfaced with `trusted: false` and its text is **never merged** into a reassembled
535
571
  * record (default **warn** in lenient mode, escalated to a thrown error in `strict`). Corruption is
536
- * never silently trusted — the "checksums are routinely not validated" claim was refuted; we
572
+ * never silently trusted: the "checksums are routinely not validated" claim was refuted; we
537
573
  * validate.
538
574
  */
539
575
  ASTM_FRAME_BAD_CHECKSUM: "ASTM_FRAME_BAD_CHECKSUM",
540
576
  /**
541
- * A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`) — a frame was
577
+ * A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`): a frame was
542
578
  * possibly dropped. The stream is **never silently concatenated across the gap** as if contiguous;
543
579
  * the in-progress record is tainted and not emitted as a clean reassembly.
544
580
  */
@@ -552,7 +588,7 @@ var FRAME_WARNING_CODES = {
552
588
  ASTM_FRAME_UNTERMINATED: "ASTM_FRAME_UNTERMINATED",
553
589
  /**
554
590
  * A frame's record text exceeded the 240-byte limit without a split. The frame is still surfaced
555
- * (and, if its checksum validates, reassembled) — the deviation is flagged, not silently dropped
591
+ * (and, if its checksum validates, reassembled): the deviation is flagged, not silently dropped
556
592
  * (warn in lenient, thrown in `strict`).
557
593
  */
558
594
  ASTM_FRAME_OVERSIZE: "ASTM_FRAME_OVERSIZE"
@@ -560,28 +596,28 @@ var FRAME_WARNING_CODES = {
560
596
  function frameBadChecksum(position) {
561
597
  return {
562
598
  code: FRAME_WARNING_CODES.ASTM_FRAME_BAD_CHECKSUM,
563
- message: "Frame checksum mismatch \u2014 surfaced untrusted, never merged into a record.",
599
+ message: "Frame checksum mismatch, surfaced untrusted, never merged into a record.",
564
600
  position
565
601
  };
566
602
  }
567
603
  function frameSequenceGap(position) {
568
604
  return {
569
605
  code: FRAME_WARNING_CODES.ASTM_FRAME_SEQUENCE_GAP,
570
- message: "Frame number out of sequence \u2014 possible dropped frame, never silently bridged.",
606
+ message: "Frame number out of sequence, possible dropped frame, never silently bridged.",
571
607
  position
572
608
  };
573
609
  }
574
610
  function frameUnterminated(position) {
575
611
  return {
576
612
  code: FRAME_WARNING_CODES.ASTM_FRAME_UNTERMINATED,
577
- message: "Unterminated frame \u2014 partial bytes surfaced untrusted, no partial record invented.",
613
+ message: "Unterminated frame, partial bytes surfaced untrusted, no partial record invented.",
578
614
  position
579
615
  };
580
616
  }
581
617
  function frameOversize(position) {
582
618
  return {
583
619
  code: FRAME_WARNING_CODES.ASTM_FRAME_OVERSIZE,
584
- message: "Frame text exceeded the 240-byte limit without a split \u2014 flagged, never dropped.",
620
+ message: "Frame text exceeded the 240-byte limit without a split, flagged, never dropped.",
585
621
  position
586
622
  };
587
623
  }
@@ -590,21 +626,21 @@ function frameOversize(position) {
590
626
  var LTP_WARNING_CODES = {
591
627
  /**
592
628
  * The transport detector could not tell a framed stream from a raw (unframed) one from its leading
593
- * byte — it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
629
+ * byte: it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
594
630
  * never guessing silently into data loss; a profile override forces the mode. (cobas b121 drops
595
- * framing over TCP; cobas 4800 / Iguana retain it — both realities exist, so an unrecognizable lead
631
+ * framing over TCP; cobas 4800 / Iguana retain it, both realities exist, so an unrecognizable lead
596
632
  * is defaulted, not assumed.)
597
633
  */
598
634
  ASTM_LTP_AMBIGUOUS_TRANSPORT: "ASTM_LTP_AMBIGUOUS_TRANSPORT",
599
635
  /**
600
- * A control event arrived in a protocol state that did not expect it — e.g. an inbound `ACK`/`NAK`
636
+ * A control event arrived in a protocol state that did not expect it: e.g. an inbound `ACK`/`NAK`
601
637
  * at a receiver (which sends, never receives, those), or an `ENQ` mid-transfer. The event is
602
638
  * surfaced and handled defensively (an unexpected `ACK`/`NAK` is **never** read as acceptance of
603
639
  * data); it never advances the transfer as if valid.
604
640
  */
605
641
  ASTM_LTP_UNEXPECTED_EVENT: "ASTM_LTP_UNEXPECTED_EVENT",
606
642
  /**
607
- * The receiver rejected a received frame with a `NAK` instead of accepting it — because its
643
+ * The receiver rejected a received frame with a `NAK` instead of accepting it: because its
608
644
  * checksum failed, it was unterminated, or its frame number was out of sequence. The frame's text
609
645
  * is **never** appended to the record and the transfer does **not** advance; the receiver awaits the
610
646
  * sender's retransmit. This is the protocol-level face of the frame codec's fail-safe: a bad frame
@@ -615,20 +651,20 @@ var LTP_WARNING_CODES = {
615
651
  function ltpAmbiguousTransport() {
616
652
  return {
617
653
  code: LTP_WARNING_CODES.ASTM_LTP_AMBIGUOUS_TRANSPORT,
618
- message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter \u2014 defaulted to framed; override with a profile to force raw."
654
+ message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter, defaulted to framed; override with a profile to force raw."
619
655
  };
620
656
  }
621
657
  function ltpUnexpectedEvent(frameNumber) {
622
658
  return {
623
659
  code: LTP_WARNING_CODES.ASTM_LTP_UNEXPECTED_EVENT,
624
- message: "Control event arrived in a state that did not expect it \u2014 handled defensively, never read as acceptance.",
660
+ message: "Control event arrived in a state that did not expect it, handled defensively, never read as acceptance.",
625
661
  ...frameNumber !== void 0 ? { frameNumber } : {}
626
662
  };
627
663
  }
628
664
  function ltpFrameRejected(frameNumber) {
629
665
  return {
630
666
  code: LTP_WARNING_CODES.ASTM_LTP_FRAME_REJECTED,
631
- message: "Frame rejected (bad checksum, unterminated, or out of sequence) \u2014 NAK sent, retransmit expected, never accepted.",
667
+ message: "Frame rejected (bad checksum, unterminated, or out of sequence), NAK sent, retransmit expected, never accepted.",
632
668
  ...frameNumber !== void 0 ? { frameNumber } : {}
633
669
  };
634
670
  }
@@ -736,7 +772,7 @@ function validateTolerations(tolerate, profileName) {
736
772
  }
737
773
  if (isSafetyCriticalCode(t.code)) {
738
774
  throw new AstmProfileDefinitionError(
739
- `Profile '${profileName}' may not tolerate '${t.code}' \u2014 it is a safety-critical warning code (a result value / flag / status / range / units, patient or comment context, message-kind ambiguity, code system, or a frame / LTP integrity warning). A profile quiets benign structural noise, never a deviation that could change a clinical reading or corrupt the wire \u2014 it can never make a bad checksum "ok" or a cancelled result read "final."`,
775
+ `Profile '${profileName}' may not tolerate '${t.code}': it is a safety-critical warning code (a result value / flag / status / range / units, patient or comment context, message-kind ambiguity, code system, or a frame / LTP integrity warning). A profile quiets benign structural noise, never a deviation that could change a clinical reading or corrupt the wire: it can never make a bad checksum "ok" or a cancelled result read "final."`,
740
776
  profileName
741
777
  );
742
778
  }
@@ -779,14 +815,14 @@ function defineAstmProfile(opts) {
779
815
  // src/profiles/default.ts
780
816
  var defaultProfile = defineAstmProfile({
781
817
  name: "default",
782
- description: "Conservative baseline \u2014 tolerates nothing; every deviation surfaces as a warning.",
818
+ description: "Conservative baseline: tolerates nothing; every deviation surfaces as a warning.",
783
819
  tolerate: []
784
820
  });
785
821
 
786
822
  // src/profiles/reference-corpus.ts
787
823
  var referenceCorpus = defineAstmProfile({
788
824
  name: "referenceCorpus",
789
- description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only \u2014 the value is preserved verbatim, never a safety-critical value.",
825
+ description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only: the value is preserved verbatim, never a safety-critical value.",
790
826
  provenance: {
791
827
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
792
828
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -899,6 +935,17 @@ function tokenizeRecord(record, d, onUnknownEscape) {
899
935
  const rawFields = splitEscapeAware(record, d.field, d.escape);
900
936
  return rawFields.map((raw, fieldIndex) => toField(raw, d, () => onUnknownEscape?.(fieldIndex)));
901
937
  }
938
+ function tokenizeHeader(record, d, onUnknownEscape) {
939
+ const defEnd = record.indexOf(d.field, 2);
940
+ const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
941
+ const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
942
+ if (defEnd === -1) return head;
943
+ const data = tokenizeRecord(record.slice(defEnd + 1), d, (i) => onUnknownEscape?.(i + 2));
944
+ return [...head, ...data];
945
+ }
946
+ function opaqueField(raw) {
947
+ return { raw, components: [raw], repeats: [[raw]] };
948
+ }
902
949
  function toField(raw, d, onUnknownEscape) {
903
950
  const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
904
951
  const repeats = rawRepeats.map(
@@ -952,9 +999,13 @@ function parseAstmRecords(raw, options = {}) {
952
999
  if (isNonStandard(delimiters)) {
953
1000
  warnings.push(nonStandardDelimiters({ recordIndex: 0, recordType: "H" }));
954
1001
  }
955
- const built = lines.map(
956
- (line2, recordIndex) => buildRecord(line2, recordIndex, delimiters, warnings)
957
- );
1002
+ let active = delimiters;
1003
+ const built = lines.map((line2, recordIndex) => {
1004
+ if (recordIndex > 0 && line2.charAt(0) === "H") {
1005
+ active = adoptRedeclaredDelimiters(line2, active, recordIndex, warnings);
1006
+ }
1007
+ return buildRecord(line2, recordIndex, active, warnings);
1008
+ });
958
1009
  const records = attachComments(built, warnings);
959
1010
  const classification = classifyMessage(records);
960
1011
  if (classification.hasQuery && classification.hasResults) {
@@ -984,6 +1035,21 @@ function resolveProfile(options) {
984
1035
  if (options.profile !== void 0) return options.profile;
985
1036
  return getDefaultAstmProfile();
986
1037
  }
1038
+ function adoptRedeclaredDelimiters(line2, active, recordIndex, warnings) {
1039
+ const position = { recordIndex, recordType: "H" };
1040
+ const declared = readDelimiters(line2);
1041
+ if (declared === void 0) {
1042
+ warnings.push(unreadableRedeclaration(position));
1043
+ return active;
1044
+ }
1045
+ if (sameDelimiters(declared, active)) return active;
1046
+ warnings.push(delimitersRedeclared(position));
1047
+ if (isNonStandard(declared)) warnings.push(nonStandardDelimiters(position));
1048
+ return declared;
1049
+ }
1050
+ function sameDelimiters(a, b) {
1051
+ return a.field === b.field && a.repeat === b.repeat && a.component === b.component && a.escape === b.escape;
1052
+ }
987
1053
  function decodeBytes(bytes) {
988
1054
  let out = "";
989
1055
  for (const b of bytes) out += String.fromCharCode(b);
@@ -994,11 +1060,12 @@ function astmField(fields, n) {
994
1060
  }
995
1061
  function buildRecord(line2, recordIndex, d, warnings) {
996
1062
  const rawType = line2.charAt(0);
997
- const fields = tokenizeRecord(line2, d, (fieldIndex) => {
1063
+ const onUnknownEscape = (fieldIndex) => {
998
1064
  warnings.push(
999
1065
  unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1000
1066
  );
1001
- });
1067
+ };
1068
+ const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape) : tokenizeRecord(line2, d, onUnknownEscape);
1002
1069
  switch (rawType) {
1003
1070
  case "H":
1004
1071
  return {
@@ -1019,7 +1086,7 @@ function buildRecord(line2, recordIndex, d, warnings) {
1019
1086
  case "Q":
1020
1087
  return buildQuery(recordIndex, fields, warnings);
1021
1088
  // `M` (manufacturer) and `S` (scientific) are vendor-defined free-form data surfaced VERBATIM and
1022
- // NEVER interpreted into typed clinical fields — the exact wire line is preserved for round-trip.
1089
+ // NEVER interpreted into typed clinical fields: the exact wire line is preserved for round-trip.
1023
1090
  case "M":
1024
1091
  return { type: "M", recordIndex, fields, rawLine: line2 };
1025
1092
  case "S":
@@ -1052,7 +1119,7 @@ function buildPatient(recordIndex, fields, warnings) {
1052
1119
  recordIndex,
1053
1120
  fields,
1054
1121
  ...definedString("seq", fieldScalar(astmField(fields, 2))),
1055
- // The three patient identifiers stay DISTINCT — practice-assigned (3), laboratory-assigned (4),
1122
+ // The three patient identifiers stay DISTINCT: practice-assigned (3), laboratory-assigned (4),
1056
1123
  // and a third ID (5) never collapse into one; conflating them is the primary misfiling path.
1057
1124
  ...definedString("practiceAssignedId", fieldScalar(astmField(fields, 3))),
1058
1125
  ...definedString("laboratoryAssignedId", fieldScalar(astmField(fields, 4))),
@@ -1075,7 +1142,7 @@ function buildOrder(recordIndex, fields) {
1075
1142
  ...hasContent(utidField) ? { universalTestId: recognizeUniversalTestId(utidField.components) } : {},
1076
1143
  // Priority (6), action code (~12), and report type (~26) are surfaced verbatim. The `~` field
1077
1144
  // indices and the code sets are `[OSS-derived]` (paywalled), so they are never mapped to a
1078
- // guessed meaning — see the JSDoc on `OrderRecord`.
1145
+ // guessed meaning: see the JSDoc on `OrderRecord`.
1079
1146
  ...definedString("priority", fieldScalar(astmField(fields, 6))),
1080
1147
  ...definedString("actionCode", fieldScalar(astmField(fields, 12))),
1081
1148
  ...definedString("reportType", fieldScalar(astmField(fields, 26)))
@@ -1220,19 +1287,57 @@ function definedString(key, value) {
1220
1287
 
1221
1288
  // src/records/serialize.ts
1222
1289
  var AstmSerializeError = class extends Error {
1223
- /** Stable discriminant; `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value. */
1290
+ /**
1291
+ * Stable discriminant. `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value;
1292
+ * `ASTM_EMIT_INVALID_DELIMITERS` for a delimiter set that cannot be emitted
1293
+ * reversibly.
1294
+ */
1224
1295
  code;
1225
1296
  /** 0-based ordinal of the record within the message, when known. */
1226
1297
  recordIndex;
1227
1298
  /** @internal */
1228
- constructor(message, recordIndex) {
1299
+ constructor(message, recordIndex, code = "ASTM_EMIT_UNENCODABLE_VALUE") {
1229
1300
  super(message);
1230
1301
  this.name = "AstmSerializeError";
1231
- this.code = "ASTM_EMIT_UNENCODABLE_VALUE";
1302
+ this.code = code;
1232
1303
  if (recordIndex !== void 0) this.recordIndex = recordIndex;
1233
1304
  }
1234
1305
  };
1306
+ var DELIMITER_ROLES = ["field", "repeat", "component", "escape"];
1307
+ var CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
1308
+ function assertEmittableDelimiters(d, recordIndex) {
1309
+ const invalid = (reason) => {
1310
+ throw new AstmSerializeError(
1311
+ `Cannot emit against these delimiters: ${reason}.`,
1312
+ recordIndex,
1313
+ "ASTM_EMIT_INVALID_DELIMITERS"
1314
+ );
1315
+ };
1316
+ if (typeof d !== "object" || d === null) {
1317
+ invalid("a delimiter set is required");
1318
+ }
1319
+ for (const role of DELIMITER_ROLES) {
1320
+ const char = d[role];
1321
+ if (typeof char !== "string" || char.length !== 1) {
1322
+ invalid(`the ${role} delimiter must be exactly one character`);
1323
+ }
1324
+ if (char === "\r" || char === "\n") {
1325
+ invalid(`the ${role} delimiter must not be a record terminator (CR/LF)`);
1326
+ }
1327
+ }
1328
+ DELIMITER_ROLES.forEach((a, i) => {
1329
+ for (const b of DELIMITER_ROLES.slice(i + 1)) {
1330
+ if (d[a] === d[b]) {
1331
+ invalid(`the ${a} and ${b} delimiters must not be the same character`);
1332
+ }
1333
+ }
1334
+ });
1335
+ }
1235
1336
  function encodeComponent(leaf, d, recordIndex) {
1337
+ assertEmittableDelimiters(d, recordIndex);
1338
+ return encodeLeaf(leaf, d, recordIndex);
1339
+ }
1340
+ function encodeLeaf(leaf, d, recordIndex) {
1236
1341
  if (leaf.includes("\r") || leaf.includes("\n")) {
1237
1342
  throw new AstmSerializeError(
1238
1343
  "A value contains a record terminator (CR/LF), which cannot be escaped without breaking framing.",
@@ -1242,28 +1347,58 @@ function encodeComponent(leaf, d, recordIndex) {
1242
1347
  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);
1243
1348
  }
1244
1349
  function encodeField(repeats, d, recordIndex) {
1245
- return repeats.map((rep) => rep.map((c) => encodeComponent(c, d, recordIndex)).join(d.component)).join(d.repeat);
1350
+ return repeats.map((rep) => rep.map((c) => encodeLeaf(c, d, recordIndex)).join(d.component)).join(d.repeat);
1246
1351
  }
1247
1352
  function serializeAstmRecord(record, d = CANONICAL_DELIMITERS) {
1248
- if (record.type === "M" || record.type === "S") return record.rawLine;
1353
+ assertEmittableDelimiters(d, record.recordIndex);
1354
+ return serializeRecordChecked(record, d);
1355
+ }
1356
+ function serializeRecordChecked(record, d) {
1357
+ if (record.type === "M" || record.type === "S") return serializeVerbatimRecord(record, d);
1249
1358
  if (record.type === "H") return serializeHeader(record, d);
1250
1359
  return record.fields.map((f) => encodeField(f.repeats, d, record.recordIndex)).join(d.field);
1251
1360
  }
1361
+ function serializeVerbatimRecord(record, d) {
1362
+ if (recoversVerbatim(record, d)) return record.rawLine;
1363
+ return record.fields.map((f) => encodeField(f.repeats, d, record.recordIndex)).join(d.field);
1364
+ }
1365
+ function recoversVerbatim(record, d) {
1366
+ return sameFieldTree(tokenizeRecord(record.rawLine, d), record.fields);
1367
+ }
1368
+ function sameFieldTree(a, b) {
1369
+ if (a.length !== b.length) return false;
1370
+ return a.every((fieldA, i) => {
1371
+ const fieldB = b[i];
1372
+ if (fieldB === void 0) return false;
1373
+ if (fieldA.repeats.length !== fieldB.repeats.length) return false;
1374
+ return fieldA.repeats.every((repA, r) => {
1375
+ const repB = fieldB.repeats[r];
1376
+ if (repB === void 0 || repA.length !== repB.length) return false;
1377
+ return repA.every((comp, c) => comp === repB[c]);
1378
+ });
1379
+ });
1380
+ }
1252
1381
  function serializeHeader(header, d) {
1253
- const head = "H" + d.field + d.repeat + d.component + d.escape;
1254
- const src = header.delimiters;
1255
- const defEnd = header.rawLine.indexOf(src.field, 2);
1256
- if (defEnd === -1) return head;
1257
- const dataPortion = header.rawLine.slice(defEnd + 1);
1258
- const dataFields = tokenizeRecord(dataPortion, src);
1382
+ const head = "H" + d.field + d.repeat + d.component + d.escape + declarationResidual(header, d);
1383
+ const dataFields = header.fields.slice(2);
1259
1384
  const rest = dataFields.map((f) => d.field + encodeField(f.repeats, d, header.recordIndex)).join("");
1260
1385
  return head + rest;
1261
1386
  }
1387
+ function declarationResidual(header, d) {
1388
+ const declaration = header.fields[1]?.raw ?? "";
1389
+ const roles = d.repeat + d.component + d.escape;
1390
+ if (!declaration.startsWith(roles)) return "";
1391
+ const residual = declaration.slice(roles.length);
1392
+ if (residual.includes(d.field) || CONTROL_CHARACTER.test(residual)) return "";
1393
+ return residual;
1394
+ }
1262
1395
  function serializeAstmRecords(input, d = CANONICAL_DELIMITERS) {
1396
+ assertEmittableDelimiters(d);
1263
1397
  const records = Array.isArray(input) ? input : input.records;
1264
- return records.map((r) => serializeAstmRecord(r, d) + "\r").join("");
1398
+ return records.map((r) => serializeRecordChecked(r, d) + "\r").join("");
1265
1399
  }
1266
1400
  function serializeField(field, d = CANONICAL_DELIMITERS) {
1401
+ assertEmittableDelimiters(d);
1267
1402
  return encodeField(field.repeats, d);
1268
1403
  }
1269
1404
 
@@ -1398,17 +1533,108 @@ function buildAstmMessage(input) {
1398
1533
  return lines.map((l) => l + "\r").join("");
1399
1534
  }
1400
1535
 
1536
+ // src/records/messages.ts
1537
+ var AMBIGUOUS_CODES = {
1538
+ /** The stream carries more than one `H` … `L` message, so a stream-wide answer spans patients. */
1539
+ ASTM_AMBIGUOUS_MULTI_MESSAGE: "ASTM_AMBIGUOUS_MULTI_MESSAGE",
1540
+ /** The one message in the stream carries more than one `P`, so "the patient" is not determined. */
1541
+ ASTM_AMBIGUOUS_MULTI_PATIENT: "ASTM_AMBIGUOUS_MULTI_PATIENT"
1542
+ };
1543
+ var AstmAmbiguousStreamError = class extends Error {
1544
+ /** The stable discriminant: see {@link AMBIGUOUS_CODES}. */
1545
+ code;
1546
+ /** Where the ambiguity became visible: the second header, or the second `P`. Value-free. */
1547
+ position;
1548
+ /** How many `H` … `L` messages the stream carries. */
1549
+ messageCount;
1550
+ /** How many `P` records the message in question carries. */
1551
+ patientCount;
1552
+ /** @internal */
1553
+ constructor(code, message, position, counts) {
1554
+ super(message);
1555
+ this.name = "AstmAmbiguousStreamError";
1556
+ this.code = code;
1557
+ this.position = position;
1558
+ this.messageCount = counts.messageCount;
1559
+ this.patientCount = counts.patientCount;
1560
+ }
1561
+ };
1562
+ function messages(msg) {
1563
+ const groups = [];
1564
+ let current;
1565
+ for (const record of msg.records) {
1566
+ if (record.type === "H" || current === void 0) {
1567
+ current = [];
1568
+ groups.push(current);
1569
+ }
1570
+ current.push(record);
1571
+ }
1572
+ return Object.freeze(
1573
+ groups.map((records, index) => {
1574
+ const header = records.find((r) => r.type === "H") ?? msg.header;
1575
+ const patients = records.filter((r) => r.type === "P");
1576
+ return Object.freeze({
1577
+ index,
1578
+ header,
1579
+ delimiters: header.delimiters,
1580
+ records: Object.freeze(records),
1581
+ patient: patients.length === 1 ? patients[0] : void 0,
1582
+ patients: Object.freeze(patients),
1583
+ results: Object.freeze(records.filter((r) => r.type === "R")),
1584
+ orders: Object.freeze(records.filter((r) => r.type === "O")),
1585
+ comments: Object.freeze(records.filter((r) => r.type === "C")),
1586
+ queries: Object.freeze(records.filter((r) => r.type === "Q"))
1587
+ });
1588
+ })
1589
+ );
1590
+ }
1591
+ function surveyMessages(msg) {
1592
+ let count = 0;
1593
+ let secondHeader;
1594
+ for (const record of msg.records) {
1595
+ if (record.type !== "H") continue;
1596
+ count += 1;
1597
+ if (count === 2) secondHeader = record;
1598
+ }
1599
+ return { count, secondHeader };
1600
+ }
1601
+ function assertSingleMessage(msg, accessor) {
1602
+ const { count, secondHeader } = surveyMessages(msg);
1603
+ if (count <= 1) return;
1604
+ throw new AstmAmbiguousStreamError(
1605
+ AMBIGUOUS_CODES.ASTM_AMBIGUOUS_MULTI_MESSAGE,
1606
+ `${accessor} is scoped to the whole stream, and this stream carries ${String(count)} messages, so its answer would span patients. Use messages() and read each message's own records.`,
1607
+ { recordIndex: secondHeader?.recordIndex ?? 0, recordType: "H" },
1608
+ { messageCount: count, patientCount: 0 }
1609
+ );
1610
+ }
1611
+ function assertSinglePatient(msg, accessor) {
1612
+ const patients = msg.records.filter((r) => r.type === "P");
1613
+ if (patients.length <= 1) return;
1614
+ throw new AstmAmbiguousStreamError(
1615
+ AMBIGUOUS_CODES.ASTM_AMBIGUOUS_MULTI_PATIENT,
1616
+ `${accessor} cannot name one patient: this message carries ${String(patients.length)} P records. Read them from messages()[n].patients and pair each with the records it owns.`,
1617
+ { recordIndex: patients[1]?.recordIndex ?? 0, recordType: "P" },
1618
+ { messageCount: 1, patientCount: patients.length }
1619
+ );
1620
+ }
1621
+
1401
1622
  // src/records/extractors.ts
1402
1623
  function results(msg) {
1624
+ assertSingleMessage(msg, "results()");
1403
1625
  return msg.records.filter((r) => r.type === "R");
1404
1626
  }
1405
1627
  function patient(msg) {
1628
+ assertSingleMessage(msg, "patient()");
1629
+ assertSinglePatient(msg, "patient()");
1406
1630
  return msg.records.find((r) => r.type === "P");
1407
1631
  }
1408
1632
  function orders(msg) {
1633
+ assertSingleMessage(msg, "orders()");
1409
1634
  return msg.records.filter((r) => r.type === "O");
1410
1635
  }
1411
1636
  function comments(msg) {
1637
+ assertSingleMessage(msg, "comments()");
1412
1638
  return msg.records.filter((r) => r.type === "C");
1413
1639
  }
1414
1640
  function commentsFor(msg, parent) {
@@ -1417,6 +1643,7 @@ function commentsFor(msg, parent) {
1417
1643
  );
1418
1644
  }
1419
1645
  function query(msg) {
1646
+ assertSingleMessage(msg, "query()");
1420
1647
  return msg.records.filter((r) => r.type === "Q");
1421
1648
  }
1422
1649
 
@@ -1459,14 +1686,14 @@ function defineLivdCatalog(entries) {
1459
1686
  var LIVD_WARNING_CODES = {
1460
1687
  /**
1461
1688
  * A record carried a vendor/local test code, but the consumer-supplied LIVD catalog held **no**
1462
- * entry for it. The code stays surfaced verbatim and the mapping is `unmapped` — a LOINC is
1689
+ * entry for it. The code stays surfaced verbatim and the mapping is `unmapped`: a LOINC is
1463
1690
  * **never** guessed. Purely advisory: the raw code and value are untouched.
1464
1691
  */
1465
1692
  ASTM_LIVD_UNMAPPED_CODE: "ASTM_LIVD_UNMAPPED_CODE",
1466
1693
  /**
1467
1694
  * A vendor/local test code matched **more than one distinct LOINC** in the catalog (e.g. the same
1468
1695
  * transmission code used by two devices for different analytes). The mapping is `ambiguous` and the
1469
- * candidate LOINCs are surfaced for inspection, but **none is chosen** — refusing to pick is the
1696
+ * candidate LOINCs are surfaced for inspection, but **none is chosen**: refusing to pick is the
1470
1697
  * fail-safe, since guessing wrong mis-identifies the test.
1471
1698
  */
1472
1699
  ASTM_LIVD_AMBIGUOUS_MAPPING: "ASTM_LIVD_AMBIGUOUS_MAPPING"
@@ -1474,14 +1701,14 @@ var LIVD_WARNING_CODES = {
1474
1701
  function livdUnmappedCode(position) {
1475
1702
  return {
1476
1703
  code: LIVD_WARNING_CODES.ASTM_LIVD_UNMAPPED_CODE,
1477
- message: "Reported test code had no LIVD mapping \u2014 surfaced unmapped, never a guessed LOINC.",
1704
+ message: "Reported test code had no LIVD mapping, surfaced unmapped, never a guessed LOINC.",
1478
1705
  position
1479
1706
  };
1480
1707
  }
1481
1708
  function livdAmbiguousMapping(position) {
1482
1709
  return {
1483
1710
  code: LIVD_WARNING_CODES.ASTM_LIVD_AMBIGUOUS_MAPPING,
1484
- message: "Reported test code mapped to multiple distinct LOINCs \u2014 surfaced ambiguous, never one guessed.",
1711
+ message: "Reported test code mapped to multiple distinct LOINCs, surfaced ambiguous, never one guessed.",
1485
1712
  position
1486
1713
  };
1487
1714
  }
@@ -1925,7 +2152,7 @@ function encodeFrame(text, frameNumber, isFinal) {
1925
2152
  }
1926
2153
  function composeAstmFrames(records, options = {}) {
1927
2154
  if (records.length === 0) {
1928
- throw new AstmFrameEncodeError("Cannot frame an empty record list \u2014 nothing to transmit.");
2155
+ throw new AstmFrameEncodeError("Cannot frame an empty record list: nothing to transmit.");
1929
2156
  }
1930
2157
  const out = [];
1931
2158
  let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
@@ -1933,7 +2160,7 @@ function composeAstmFrames(records, options = {}) {
1933
2160
  const bytes = toBytes(record);
1934
2161
  if (bytes.length === 0) {
1935
2162
  throw new AstmFrameEncodeError(
1936
- "Cannot frame an empty record \u2014 a frame must carry record bytes.",
2163
+ "Cannot frame an empty record: a frame must carry record bytes.",
1937
2164
  recordIndex
1938
2165
  );
1939
2166
  }
@@ -1972,13 +2199,15 @@ function concatBytes3(chunks) {
1972
2199
  }
1973
2200
 
1974
2201
  // src/index.ts
1975
- var VERSION = "0.0.3";
2202
+ var VERSION = "0.0.5";
1976
2203
 
1977
2204
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2205
+ exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
1978
2206
  exports.ASTM_ACK = ACK;
1979
2207
  exports.ASTM_ENQ = ENQ;
1980
2208
  exports.ASTM_EOT = EOT;
1981
2209
  exports.ASTM_NAK = NAK;
2210
+ exports.AstmAmbiguousStreamError = AstmAmbiguousStreamError;
1982
2211
  exports.AstmFrameEncodeError = AstmFrameEncodeError;
1983
2212
  exports.AstmFrameStrictError = AstmFrameStrictError;
1984
2213
  exports.AstmParseError = AstmParseError;
@@ -2013,6 +2242,7 @@ exports.decodeEscapes = decodeEscapes;
2013
2242
  exports.deepFreeze = deepFreeze;
2014
2243
  exports.defineAstmProfile = defineAstmProfile;
2015
2244
  exports.defineLivdCatalog = defineLivdCatalog;
2245
+ exports.delimitersRedeclared = delimitersRedeclared;
2016
2246
  exports.detectFraming = detectFraming;
2017
2247
  exports.encodeComponent = encodeComponent;
2018
2248
  exports.fieldScalar = fieldScalar;
@@ -2035,6 +2265,7 @@ exports.ltpFrameRejected = ltpFrameRejected;
2035
2265
  exports.ltpInitialState = ltpInitialState;
2036
2266
  exports.ltpReduce = ltpReduce;
2037
2267
  exports.ltpUnexpectedEvent = ltpUnexpectedEvent;
2268
+ exports.messages = messages;
2038
2269
  exports.nonStandardDelimiters = nonStandardDelimiters;
2039
2270
  exports.orders = orders;
2040
2271
  exports.orphanComment = orphanComment;
@@ -2059,6 +2290,7 @@ exports.serializeFramedAstm = serializeFramedAstm;
2059
2290
  exports.setDefaultAstmProfile = setDefaultAstmProfile;
2060
2291
  exports.splitEscapeAware = splitEscapeAware;
2061
2292
  exports.toChecksumHex = toChecksumHex;
2293
+ exports.tokenizeHeader = tokenizeHeader;
2062
2294
  exports.tokenizeRecord = tokenizeRecord;
2063
2295
  exports.undefinedAbnormalFlag = undefinedAbnormalFlag;
2064
2296
  exports.undefinedResultStatus = undefinedResultStatus;
@@ -2067,5 +2299,6 @@ exports.unitsAbsent = unitsAbsent;
2067
2299
  exports.unknownEscapeSequence = unknownEscapeSequence;
2068
2300
  exports.unknownRecordType = unknownRecordType;
2069
2301
  exports.unparseableReferenceRange = unparseableReferenceRange;
2302
+ exports.unreadableRedeclaration = unreadableRedeclaration;
2070
2303
  //# sourceMappingURL=index.cjs.map
2071
2304
  //# sourceMappingURL=index.cjs.map