@cosyte/astm 0.0.7 → 0.0.9

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
@@ -121,12 +121,65 @@ 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
+ /**
125
+ * A record's type letter is not one of the modeled types: surfaced as an unsupported record,
126
+ * never dropped. Treat it as a possible lost message boundary, because a header the reader did
127
+ * not recognize as one does not open a new message.
128
+ */
125
129
  ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
130
+ /**
131
+ * The delimiters in force found **no field separator at all** in a record that carries content
132
+ * beyond its type letter, so the whole record read back as a single field and none of its modeled
133
+ * fields could be recovered. The raw line is surfaced intact and nothing is dropped, but on a
134
+ * result record it means the value, the units and the status are all absent from the parsed model.
135
+ *
136
+ * This is one signature of a record being read with a delimiter set that does not belong to it,
137
+ * which is how a delimiter-scoping mistake turns into lost values. It is reported rather than
138
+ * repaired, because recovering the fields would mean guessing which set the sender meant.
139
+ *
140
+ * **Its absence is not evidence that a record was read in its own set.** This tests one of the
141
+ * four delimiter roles, the field separator, and only in its total form, where that separator
142
+ * occurs in the line (unescaped). Two whole classes of the same loss are outside it: a foreign set
143
+ * whose field separator happens to occur somewhere in the line still splits (on the wrong
144
+ * boundaries, silently, and this can happen to one record inside a run of these warnings); and a
145
+ * set differing in the repeat, component or escape role usually splits into fields normally,
146
+ * where a mis-split component can cost a test identity while the value survives, and where an
147
+ * `&X&` sequence whose body is a delimiter is an opaque atom, so that delimiter does not split
148
+ * and every field after it shifts. The escape role's worst case has narrowed and not
149
+ * disappeared: an escape character heading no sequence is now read as a literal and reported
150
+ * under {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER} rather than merging the rest of the
151
+ * record, but a delimiter swallowed inside an `&X&` body is reported only by
152
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, which is tolerable. Treat this code as a
153
+ * report that one record definitely lost its fields, never as a sweep that would have fired if
154
+ * any had.
155
+ */
156
+ ASTM_RECORD_FIELDS_UNSEPARATED: "ASTM_RECORD_FIELDS_UNSEPARATED",
126
157
  /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
127
158
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
128
159
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
129
160
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
161
+ /**
162
+ * An escape character appeared where no escape sequence starts (an escape sequence is the escape
163
+ * character, one body character, and the escape character again). It is read as the **literal
164
+ * character it is** and kept byte-for-byte in the decoded value. Nothing is dropped and no byte is
165
+ * invented: this flags that the sender did not write the character the spec-clean way, which
166
+ * is `&E&`.
167
+ *
168
+ * **What it replaced is the reason it exists.** The codec used to read such a character as the
169
+ * opening of a sequence that never closed and merge the whole remainder of the record into the
170
+ * field it sat in, reporting nothing at all. One `&` in a result value cost the units, the
171
+ * abnormal flag and the status together and left the status reading `unspecified` rather than
172
+ * `final`; one in a surname cost the patient's birth date and sex. Emit then re-escaped the merged
173
+ * text into a spec-clean-looking line that read back as the same wrong value, so the mis-read
174
+ * survived a round trip without ever surfacing.
175
+ *
176
+ * **This code is not a statement about the rest of the record.** It reports one character. A
177
+ * *different* escape character in the same record may still head a real three-character sequence,
178
+ * and if that sequence's body happens to be a delimiter (`&|&` under the canonical set) the atom
179
+ * rule means that delimiter does not split. That case is reported separately, under
180
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, and it can still cost a field boundary.
181
+ */
182
+ ASTM_UNPAIRED_ESCAPE_CHARACTER: "ASTM_UNPAIRED_ESCAPE_CHARACTER",
130
183
  /**
131
184
  * A result value field carried an *unescaped* component delimiter, so it split into more than one
132
185
  * component. Both the full raw value and the split are surfaced and this warning fires: the
@@ -214,7 +267,14 @@ var WARNING_CODES = {
214
267
  function unknownRecordType(position) {
215
268
  return {
216
269
  code: WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
217
- message: "Unrecognized record type, surfaced verbatim as an unsupported record.",
270
+ message: "Unrecognized record type, surfaced verbatim as an unsupported record. If this record was a header, message grouping did not open a new message here.",
271
+ position
272
+ };
273
+ }
274
+ function fieldsUnseparated(position) {
275
+ return {
276
+ code: WARNING_CODES.ASTM_RECORD_FIELDS_UNSEPARATED,
277
+ message: "The delimiters in force found no field separator in this record, so its whole content read back as one field and none of its modeled fields could be recovered. The raw line is surfaced intact, and no field is reconstructed, because the set the sender used is unknown.",
218
278
  position
219
279
  };
220
280
  }
@@ -246,6 +306,13 @@ function unknownEscapeSequence(position) {
246
306
  position
247
307
  };
248
308
  }
309
+ function unpairedEscapeCharacter(position) {
310
+ return {
311
+ code: WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
312
+ message: "An escape character headed no escape sequence, read as the literal character it is rather than opening a sequence that never closes. The spec-clean form is the escaped one.",
313
+ position
314
+ };
315
+ }
249
316
  function ambiguousValueSplit(position) {
250
317
  return {
251
318
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
@@ -414,17 +481,157 @@ function classifyMessage(records) {
414
481
  let hasQuery = false;
415
482
  let hasResults = false;
416
483
  let hasOrders = false;
484
+ let hasUnrecognized = false;
417
485
  for (const r of records) {
418
486
  if (r.type === "Q") hasQuery = true;
419
487
  else if (r.type === "R") hasResults = true;
420
488
  else if (r.type === "O") hasOrders = true;
489
+ else if (r.type === "unsupported") hasUnrecognized = true;
421
490
  }
422
491
  let kind;
423
492
  if (hasQuery) kind = "host-query";
493
+ else if (hasUnrecognized) kind = "indeterminate";
424
494
  else if (hasResults) kind = "results";
425
495
  else if (hasOrders) kind = "orders";
426
496
  else kind = "indeterminate";
427
- return { kind, hasQuery, hasResults, hasOrders, isHostQueryRequest: kind === "host-query" };
497
+ return {
498
+ kind,
499
+ hasQuery,
500
+ hasResults,
501
+ hasOrders,
502
+ hasUnrecognized,
503
+ isHostQueryRequest: kind === "host-query"
504
+ };
505
+ }
506
+
507
+ // src/frames/warnings.ts
508
+ var FRAME_WARNING_CODES = {
509
+ /**
510
+ * A frame's two-hex-char checksum did not match the modulo-256 sum recomputed over its bytes. The
511
+ * frame is surfaced with `trusted: false` and its text is **never merged** into a reassembled
512
+ * record (default **warn** in lenient mode, escalated to a thrown error in `strict`). Corruption is
513
+ * never silently trusted: the "checksums are routinely not validated" claim was refuted; we
514
+ * validate.
515
+ */
516
+ ASTM_FRAME_BAD_CHECKSUM: "ASTM_FRAME_BAD_CHECKSUM",
517
+ /**
518
+ * A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`): a frame was
519
+ * possibly dropped. The stream is **never silently concatenated across the gap** as if contiguous;
520
+ * the in-progress record is tainted and not emitted as a clean reassembly.
521
+ */
522
+ ASTM_FRAME_SEQUENCE_GAP: "ASTM_FRAME_SEQUENCE_GAP",
523
+ /**
524
+ * A frame opened with `STX` but no valid terminator + checksum was found before the stream ended
525
+ * (or before the next `STX`), or a record's frames ended on an intermediate `ETB` with no final
526
+ * `ETX`. The partial bytes are surfaced flagged untrusted and **no partial record is invented**
527
+ * (warn in lenient, thrown in `strict`).
528
+ */
529
+ ASTM_FRAME_UNTERMINATED: "ASTM_FRAME_UNTERMINATED",
530
+ /**
531
+ * A frame's record text exceeded the 240-byte limit without a split. The frame is still surfaced
532
+ * (and, if its checksum validates, reassembled): the deviation is flagged, not silently dropped
533
+ * (warn in lenient, thrown in `strict`).
534
+ */
535
+ ASTM_FRAME_OVERSIZE: "ASTM_FRAME_OVERSIZE"
536
+ };
537
+ function frameBadChecksum(position) {
538
+ return {
539
+ code: FRAME_WARNING_CODES.ASTM_FRAME_BAD_CHECKSUM,
540
+ message: "Frame checksum mismatch, surfaced untrusted, never merged into a record.",
541
+ position
542
+ };
543
+ }
544
+ function frameSequenceGap(position) {
545
+ return {
546
+ code: FRAME_WARNING_CODES.ASTM_FRAME_SEQUENCE_GAP,
547
+ message: "Frame number out of sequence, possible dropped frame, never silently bridged.",
548
+ position
549
+ };
550
+ }
551
+ function frameUnterminated(position) {
552
+ return {
553
+ code: FRAME_WARNING_CODES.ASTM_FRAME_UNTERMINATED,
554
+ message: "Unterminated frame, partial bytes surfaced untrusted, no partial record invented.",
555
+ position
556
+ };
557
+ }
558
+ function frameOversize(position) {
559
+ return {
560
+ code: FRAME_WARNING_CODES.ASTM_FRAME_OVERSIZE,
561
+ message: "Frame text exceeded the 240-byte limit without a split, flagged, never dropped.",
562
+ position
563
+ };
564
+ }
565
+
566
+ // src/ltp/warnings.ts
567
+ var LTP_WARNING_CODES = {
568
+ /**
569
+ * The transport detector could not tell a framed stream from a raw (unframed) one from its leading
570
+ * byte: it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
571
+ * never guessing silently into data loss; a profile override forces the mode. (cobas b121 drops
572
+ * framing over TCP; cobas 4800 / Iguana retain it, both realities exist, so an unrecognizable lead
573
+ * is defaulted, not assumed.)
574
+ */
575
+ ASTM_LTP_AMBIGUOUS_TRANSPORT: "ASTM_LTP_AMBIGUOUS_TRANSPORT",
576
+ /**
577
+ * A control event arrived in a protocol state that did not expect it: e.g. an inbound `ACK`/`NAK`
578
+ * at a receiver (which sends, never receives, those), or an `ENQ` mid-transfer. The event is
579
+ * surfaced and handled defensively (an unexpected `ACK`/`NAK` is **never** read as acceptance of
580
+ * data); it never advances the transfer as if valid.
581
+ */
582
+ ASTM_LTP_UNEXPECTED_EVENT: "ASTM_LTP_UNEXPECTED_EVENT",
583
+ /**
584
+ * The receiver rejected a received frame with a `NAK` instead of accepting it: because its
585
+ * checksum failed, it was unterminated, or its frame number was out of sequence. The frame's text
586
+ * is **never** appended to the record and the transfer does **not** advance; the receiver awaits the
587
+ * sender's retransmit. This is the protocol-level face of the frame codec's fail-safe: a bad frame
588
+ * drives retransmit, not acceptance.
589
+ */
590
+ ASTM_LTP_FRAME_REJECTED: "ASTM_LTP_FRAME_REJECTED"
591
+ };
592
+ function ltpAmbiguousTransport() {
593
+ return {
594
+ code: LTP_WARNING_CODES.ASTM_LTP_AMBIGUOUS_TRANSPORT,
595
+ message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter, defaulted to framed; override with a profile to force raw."
596
+ };
597
+ }
598
+ function ltpUnexpectedEvent(frameNumber) {
599
+ return {
600
+ code: LTP_WARNING_CODES.ASTM_LTP_UNEXPECTED_EVENT,
601
+ message: "Control event arrived in a state that did not expect it, handled defensively, never read as acceptance.",
602
+ ...frameNumber !== void 0 ? { frameNumber } : {}
603
+ };
604
+ }
605
+ function ltpFrameRejected(frameNumber) {
606
+ return {
607
+ code: LTP_WARNING_CODES.ASTM_LTP_FRAME_REJECTED,
608
+ message: "Frame rejected (bad checksum, unterminated, or out of sequence), NAK sent, retransmit expected, never accepted.",
609
+ ...frameNumber !== void 0 ? { frameNumber } : {}
610
+ };
611
+ }
612
+
613
+ // src/profiles/safety.ts
614
+ var TOLERABLE_CODES = Object.freeze(
615
+ /* @__PURE__ */ new Set([
616
+ WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
617
+ WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
618
+ WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
619
+ WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
620
+ ])
621
+ );
622
+ var ALL_WARNING_CODES = [
623
+ ...Object.values(WARNING_CODES),
624
+ ...Object.values(FRAME_WARNING_CODES),
625
+ ...Object.values(LTP_WARNING_CODES)
626
+ ];
627
+ var ALL_ASTM_WARNING_CODES = Object.freeze(
628
+ new Set(ALL_WARNING_CODES)
629
+ );
630
+ var SAFETY_CRITICAL_CODES = Object.freeze(
631
+ new Set(ALL_WARNING_CODES.filter((c) => !TOLERABLE_CODES.has(c)))
632
+ );
633
+ function isSafetyCriticalCode(code) {
634
+ return SAFETY_CRITICAL_CODES.has(code);
428
635
  }
429
636
 
430
637
  // src/profiles/apply.ts
@@ -442,6 +649,7 @@ function toleranceApplies(tolerance, warning) {
442
649
  }
443
650
  function applyAstmProfile(profile, warning) {
444
651
  if (warning.expected === true) return warning;
652
+ if (isSafetyCriticalCode(warning.code)) return warning;
445
653
  for (const tolerance of profile.tolerate) {
446
654
  if (toleranceApplies(tolerance, warning)) {
447
655
  return profileQuirkApplied(warning, profile.name);
@@ -563,136 +771,6 @@ var AstmProfileDefinitionError = class extends Error {
563
771
  }
564
772
  };
565
773
 
566
- // src/frames/warnings.ts
567
- var FRAME_WARNING_CODES = {
568
- /**
569
- * A frame's two-hex-char checksum did not match the modulo-256 sum recomputed over its bytes. The
570
- * frame is surfaced with `trusted: false` and its text is **never merged** into a reassembled
571
- * record (default **warn** in lenient mode, escalated to a thrown error in `strict`). Corruption is
572
- * never silently trusted: the "checksums are routinely not validated" claim was refuted; we
573
- * validate.
574
- */
575
- ASTM_FRAME_BAD_CHECKSUM: "ASTM_FRAME_BAD_CHECKSUM",
576
- /**
577
- * A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`): a frame was
578
- * possibly dropped. The stream is **never silently concatenated across the gap** as if contiguous;
579
- * the in-progress record is tainted and not emitted as a clean reassembly.
580
- */
581
- ASTM_FRAME_SEQUENCE_GAP: "ASTM_FRAME_SEQUENCE_GAP",
582
- /**
583
- * A frame opened with `STX` but no valid terminator + checksum was found before the stream ended
584
- * (or before the next `STX`), or a record's frames ended on an intermediate `ETB` with no final
585
- * `ETX`. The partial bytes are surfaced flagged untrusted and **no partial record is invented**
586
- * (warn in lenient, thrown in `strict`).
587
- */
588
- ASTM_FRAME_UNTERMINATED: "ASTM_FRAME_UNTERMINATED",
589
- /**
590
- * A frame's record text exceeded the 240-byte limit without a split. The frame is still surfaced
591
- * (and, if its checksum validates, reassembled): the deviation is flagged, not silently dropped
592
- * (warn in lenient, thrown in `strict`).
593
- */
594
- ASTM_FRAME_OVERSIZE: "ASTM_FRAME_OVERSIZE"
595
- };
596
- function frameBadChecksum(position) {
597
- return {
598
- code: FRAME_WARNING_CODES.ASTM_FRAME_BAD_CHECKSUM,
599
- message: "Frame checksum mismatch, surfaced untrusted, never merged into a record.",
600
- position
601
- };
602
- }
603
- function frameSequenceGap(position) {
604
- return {
605
- code: FRAME_WARNING_CODES.ASTM_FRAME_SEQUENCE_GAP,
606
- message: "Frame number out of sequence, possible dropped frame, never silently bridged.",
607
- position
608
- };
609
- }
610
- function frameUnterminated(position) {
611
- return {
612
- code: FRAME_WARNING_CODES.ASTM_FRAME_UNTERMINATED,
613
- message: "Unterminated frame, partial bytes surfaced untrusted, no partial record invented.",
614
- position
615
- };
616
- }
617
- function frameOversize(position) {
618
- return {
619
- code: FRAME_WARNING_CODES.ASTM_FRAME_OVERSIZE,
620
- message: "Frame text exceeded the 240-byte limit without a split, flagged, never dropped.",
621
- position
622
- };
623
- }
624
-
625
- // src/ltp/warnings.ts
626
- var LTP_WARNING_CODES = {
627
- /**
628
- * The transport detector could not tell a framed stream from a raw (unframed) one from its leading
629
- * byte: it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
630
- * never guessing silently into data loss; a profile override forces the mode. (cobas b121 drops
631
- * framing over TCP; cobas 4800 / Iguana retain it, both realities exist, so an unrecognizable lead
632
- * is defaulted, not assumed.)
633
- */
634
- ASTM_LTP_AMBIGUOUS_TRANSPORT: "ASTM_LTP_AMBIGUOUS_TRANSPORT",
635
- /**
636
- * A control event arrived in a protocol state that did not expect it: e.g. an inbound `ACK`/`NAK`
637
- * at a receiver (which sends, never receives, those), or an `ENQ` mid-transfer. The event is
638
- * surfaced and handled defensively (an unexpected `ACK`/`NAK` is **never** read as acceptance of
639
- * data); it never advances the transfer as if valid.
640
- */
641
- ASTM_LTP_UNEXPECTED_EVENT: "ASTM_LTP_UNEXPECTED_EVENT",
642
- /**
643
- * The receiver rejected a received frame with a `NAK` instead of accepting it: because its
644
- * checksum failed, it was unterminated, or its frame number was out of sequence. The frame's text
645
- * is **never** appended to the record and the transfer does **not** advance; the receiver awaits the
646
- * sender's retransmit. This is the protocol-level face of the frame codec's fail-safe: a bad frame
647
- * drives retransmit, not acceptance.
648
- */
649
- ASTM_LTP_FRAME_REJECTED: "ASTM_LTP_FRAME_REJECTED"
650
- };
651
- function ltpAmbiguousTransport() {
652
- return {
653
- code: LTP_WARNING_CODES.ASTM_LTP_AMBIGUOUS_TRANSPORT,
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."
655
- };
656
- }
657
- function ltpUnexpectedEvent(frameNumber) {
658
- return {
659
- code: LTP_WARNING_CODES.ASTM_LTP_UNEXPECTED_EVENT,
660
- message: "Control event arrived in a state that did not expect it, handled defensively, never read as acceptance.",
661
- ...frameNumber !== void 0 ? { frameNumber } : {}
662
- };
663
- }
664
- function ltpFrameRejected(frameNumber) {
665
- return {
666
- code: LTP_WARNING_CODES.ASTM_LTP_FRAME_REJECTED,
667
- message: "Frame rejected (bad checksum, unterminated, or out of sequence), NAK sent, retransmit expected, never accepted.",
668
- ...frameNumber !== void 0 ? { frameNumber } : {}
669
- };
670
- }
671
-
672
- // src/profiles/safety.ts
673
- var TOLERABLE_CODES = Object.freeze(
674
- /* @__PURE__ */ new Set([
675
- WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
676
- WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
677
- WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
678
- WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
679
- ])
680
- );
681
- var ALL_WARNING_CODES = [
682
- ...Object.values(WARNING_CODES),
683
- ...Object.values(FRAME_WARNING_CODES),
684
- ...Object.values(LTP_WARNING_CODES)
685
- ];
686
- var ALL_ASTM_WARNING_CODES = Object.freeze(
687
- new Set(ALL_WARNING_CODES)
688
- );
689
- var SAFETY_CRITICAL_CODES = Object.freeze(
690
- new Set(ALL_WARNING_CODES.filter((c) => !TOLERABLE_CODES.has(c)))
691
- );
692
- function isSafetyCriticalCode(code) {
693
- return SAFETY_CRITICAL_CODES.has(code);
694
- }
695
-
696
774
  // src/profiles/validate.ts
697
775
  var KNOWN_OPTION_KEYS = [
698
776
  "name",
@@ -772,7 +850,7 @@ function validateTolerations(tolerate, profileName) {
772
850
  }
773
851
  if (isSafetyCriticalCode(t.code)) {
774
852
  throw new AstmProfileDefinitionError(
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."`,
853
+ `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, an unrecognized record type, or a frame / LTP integrity warning). A profile quiets benign structural noise, never a deviation that could change a clinical reading, report a lost message boundary, or corrupt the wire: it can never make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says two messages were read as one.`,
776
854
  profileName
777
855
  );
778
856
  }
@@ -822,7 +900,7 @@ var defaultProfile = defineAstmProfile({
822
900
  // src/profiles/reference-corpus.ts
823
901
  var referenceCorpus = defineAstmProfile({
824
902
  name: "referenceCorpus",
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.",
903
+ description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Mostly syntactic encoding noise, with one measured exception: an escape sequence whose body is itself a delimiter is an opaque atom, so that delimiter does not split and a result can lose its units and its status. Read the values; do not rely on this profile leaving only cosmetic deviations behind.",
826
904
  provenance: {
827
905
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
828
906
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -832,7 +910,7 @@ var referenceCorpus = defineAstmProfile({
832
910
  tolerate: [
833
911
  {
834
912
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
835
- rationale: "The OSS reference stack (python-astm codec.py, firsthand-verified) treats '&' as literal data and never decodes escape sequences, so streams from that ecosystem carry non-standard '&\u2026&' bodies our escape-aware tokenizer flags. The body is preserved byte-for-byte in the value; the deviation is recognizable, syntactic, and non-clinical."
913
+ rationale: "The OSS reference stack (python-astm codec.py, firsthand-verified) treats '&' as literal data and never decodes escape sequences, so streams from that ecosystem carry non-standard '&\u2026&' bodies our escape-aware tokenizer flags. The body is preserved byte-for-byte in the value. NOT always non-clinical, and this is measured rather than assumed: where the body is a delimiter, that delimiter never became a field boundary, so `28.6&|&U/L` reads as one value with no units and no final status. Tolerating this code hides that too."
836
914
  }
837
915
  ]
838
916
  });
@@ -857,7 +935,10 @@ function getDefaultAstmProfile() {
857
935
  }
858
936
 
859
937
  // src/common/escapes.ts
860
- function decodeEscapes(leaf, d, onUnknown) {
938
+ function isEscapeSequenceAt(text, i, escape) {
939
+ return text.charAt(i) === escape && text.charAt(i + 2) === escape;
940
+ }
941
+ function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
861
942
  const esc = d.escape;
862
943
  if (!leaf.includes(esc)) return leaf;
863
944
  let out = "";
@@ -869,12 +950,13 @@ function decodeEscapes(leaf, d, onUnknown) {
869
950
  i += 1;
870
951
  continue;
871
952
  }
872
- const close = leaf.indexOf(esc, i + 1);
873
- if (close === -1) {
874
- out += leaf.slice(i);
875
- break;
953
+ if (!isEscapeSequenceAt(leaf, i, esc)) {
954
+ out += ch;
955
+ i += 1;
956
+ onUnpaired?.();
957
+ continue;
876
958
  }
877
- const body = leaf.slice(i + 1, close);
959
+ const body = leaf.charAt(i + 1);
878
960
  const replacement = escapeBody(body, d);
879
961
  if (replacement === void 0) {
880
962
  out += esc + body + esc;
@@ -882,7 +964,7 @@ function decodeEscapes(leaf, d, onUnknown) {
882
964
  } else {
883
965
  out += replacement;
884
966
  }
885
- i = close + 1;
967
+ i += 3;
886
968
  }
887
969
  return out;
888
970
  }
@@ -907,14 +989,9 @@ function splitEscapeAware(text, delimiter, escape) {
907
989
  let i = 0;
908
990
  while (i < text.length) {
909
991
  const ch = text.charAt(i);
910
- if (ch === escape) {
911
- const close = text.indexOf(escape, i + 1);
912
- if (close === -1) {
913
- current += text.slice(i);
914
- break;
915
- }
916
- current += text.slice(i, close + 1);
917
- i = close + 1;
992
+ if (ch === escape && isEscapeSequenceAt(text, i, escape)) {
993
+ current += text.slice(i, i + 3);
994
+ i += 3;
918
995
  continue;
919
996
  }
920
997
  if (ch === delimiter) {
@@ -931,26 +1008,38 @@ function splitEscapeAware(text, delimiter, escape) {
931
1008
  }
932
1009
 
933
1010
  // src/records/tokenize.ts
934
- function tokenizeRecord(record, d, onUnknownEscape) {
1011
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape) {
935
1012
  const rawFields = splitEscapeAware(record, d.field, d.escape);
936
- return rawFields.map((raw, fieldIndex) => toField(raw, d, () => onUnknownEscape?.(fieldIndex)));
1013
+ return rawFields.map(
1014
+ (raw, fieldIndex) => toField(
1015
+ raw,
1016
+ d,
1017
+ () => onUnknownEscape?.(fieldIndex),
1018
+ () => onUnpairedEscape?.(fieldIndex)
1019
+ )
1020
+ );
937
1021
  }
938
- function tokenizeHeader(record, d, onUnknownEscape) {
1022
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
939
1023
  const defEnd = record.indexOf(d.field, 2);
940
1024
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
941
1025
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
942
1026
  if (defEnd === -1) return head;
943
- const data = tokenizeRecord(record.slice(defEnd + 1), d, (i) => onUnknownEscape?.(i + 2));
1027
+ const data = tokenizeRecord(
1028
+ record.slice(defEnd + 1),
1029
+ d,
1030
+ (i) => onUnknownEscape?.(i + 2),
1031
+ (i) => onUnpairedEscape?.(i + 2)
1032
+ );
944
1033
  return [...head, ...data];
945
1034
  }
946
1035
  function opaqueField(raw) {
947
1036
  return { raw, components: [raw], repeats: [[raw]] };
948
1037
  }
949
- function toField(raw, d, onUnknownEscape) {
1038
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape) {
950
1039
  const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
951
1040
  const repeats = rawRepeats.map(
952
1041
  (rep) => splitEscapeAware(rep, d.component, d.escape).map(
953
- (comp) => decodeEscapes(comp, d, onUnknownEscape)
1042
+ (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape)
954
1043
  )
955
1044
  );
956
1045
  const components = repeats[0] ?? [""];
@@ -1065,7 +1154,15 @@ function buildRecord(line2, recordIndex, d, warnings) {
1065
1154
  unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1066
1155
  );
1067
1156
  };
1068
- const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape) : tokenizeRecord(line2, d, onUnknownEscape);
1157
+ const onUnpairedEscape = (fieldIndex) => {
1158
+ warnings.push(
1159
+ unpairedEscapeCharacter({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1160
+ );
1161
+ };
1162
+ const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape, onUnpairedEscape) : tokenizeRecord(line2, d, onUnknownEscape, onUnpairedEscape);
1163
+ if (fields.length === 1 && line2.slice(1).trim().length > 0) {
1164
+ warnings.push(fieldsUnseparated({ recordIndex, recordType: rawType }));
1165
+ }
1069
1166
  switch (rawType) {
1070
1167
  case "H":
1071
1168
  return {
@@ -2199,7 +2296,7 @@ function concatBytes3(chunks) {
2199
2296
  }
2200
2297
 
2201
2298
  // src/index.ts
2202
- var VERSION = "0.0.7";
2299
+ var VERSION = "0.0.9";
2203
2300
 
2204
2301
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2205
2302
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
@@ -2246,6 +2343,7 @@ exports.delimitersRedeclared = delimitersRedeclared;
2246
2343
  exports.detectFraming = detectFraming;
2247
2344
  exports.encodeComponent = encodeComponent;
2248
2345
  exports.fieldScalar = fieldScalar;
2346
+ exports.fieldsUnseparated = fieldsUnseparated;
2249
2347
  exports.frameBadChecksum = frameBadChecksum;
2250
2348
  exports.frameOversize = frameOversize;
2251
2349
  exports.frameSequenceGap = frameSequenceGap;
@@ -2298,6 +2396,7 @@ exports.uninterpretedQueryStatus = uninterpretedQueryStatus;
2298
2396
  exports.unitsAbsent = unitsAbsent;
2299
2397
  exports.unknownEscapeSequence = unknownEscapeSequence;
2300
2398
  exports.unknownRecordType = unknownRecordType;
2399
+ exports.unpairedEscapeCharacter = unpairedEscapeCharacter;
2301
2400
  exports.unparseableReferenceRange = unparseableReferenceRange;
2302
2401
  exports.unreadableRedeclaration = unreadableRedeclaration;
2303
2402
  //# sourceMappingURL=index.cjs.map