@cosyte/astm 0.0.8 → 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.mjs CHANGED
@@ -125,10 +125,59 @@ var WARNING_CODES = {
125
125
  * not recognize as one does not open a new message.
126
126
  */
127
127
  ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
128
+ /**
129
+ * The delimiters in force found **no field separator at all** in a record that carries content
130
+ * beyond its type letter, so the whole record read back as a single field and none of its modeled
131
+ * fields could be recovered. The raw line is surfaced intact and nothing is dropped, but on a
132
+ * result record it means the value, the units and the status are all absent from the parsed model.
133
+ *
134
+ * This is one signature of a record being read with a delimiter set that does not belong to it,
135
+ * which is how a delimiter-scoping mistake turns into lost values. It is reported rather than
136
+ * repaired, because recovering the fields would mean guessing which set the sender meant.
137
+ *
138
+ * **Its absence is not evidence that a record was read in its own set.** This tests one of the
139
+ * four delimiter roles, the field separator, and only in its total form, where that separator
140
+ * occurs in the line (unescaped). Two whole classes of the same loss are outside it: a foreign set
141
+ * whose field separator happens to occur somewhere in the line still splits (on the wrong
142
+ * boundaries, silently, and this can happen to one record inside a run of these warnings); and a
143
+ * set differing in the repeat, component or escape role usually splits into fields normally,
144
+ * where a mis-split component can cost a test identity while the value survives, and where an
145
+ * `&X&` sequence whose body is a delimiter is an opaque atom, so that delimiter does not split
146
+ * and every field after it shifts. The escape role's worst case has narrowed and not
147
+ * disappeared: an escape character heading no sequence is now read as a literal and reported
148
+ * under {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER} rather than merging the rest of the
149
+ * record, but a delimiter swallowed inside an `&X&` body is reported only by
150
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, which is tolerable. Treat this code as a
151
+ * report that one record definitely lost its fields, never as a sweep that would have fired if
152
+ * any had.
153
+ */
154
+ ASTM_RECORD_FIELDS_UNSEPARATED: "ASTM_RECORD_FIELDS_UNSEPARATED",
128
155
  /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
129
156
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
130
157
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
131
158
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
159
+ /**
160
+ * An escape character appeared where no escape sequence starts (an escape sequence is the escape
161
+ * character, one body character, and the escape character again). It is read as the **literal
162
+ * character it is** and kept byte-for-byte in the decoded value. Nothing is dropped and no byte is
163
+ * invented: this flags that the sender did not write the character the spec-clean way, which
164
+ * is `&E&`.
165
+ *
166
+ * **What it replaced is the reason it exists.** The codec used to read such a character as the
167
+ * opening of a sequence that never closed and merge the whole remainder of the record into the
168
+ * field it sat in, reporting nothing at all. One `&` in a result value cost the units, the
169
+ * abnormal flag and the status together and left the status reading `unspecified` rather than
170
+ * `final`; one in a surname cost the patient's birth date and sex. Emit then re-escaped the merged
171
+ * text into a spec-clean-looking line that read back as the same wrong value, so the mis-read
172
+ * survived a round trip without ever surfacing.
173
+ *
174
+ * **This code is not a statement about the rest of the record.** It reports one character. A
175
+ * *different* escape character in the same record may still head a real three-character sequence,
176
+ * and if that sequence's body happens to be a delimiter (`&|&` under the canonical set) the atom
177
+ * rule means that delimiter does not split. That case is reported separately, under
178
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, and it can still cost a field boundary.
179
+ */
180
+ ASTM_UNPAIRED_ESCAPE_CHARACTER: "ASTM_UNPAIRED_ESCAPE_CHARACTER",
132
181
  /**
133
182
  * A result value field carried an *unescaped* component delimiter, so it split into more than one
134
183
  * component. Both the full raw value and the split are surfaced and this warning fires: the
@@ -220,6 +269,13 @@ function unknownRecordType(position) {
220
269
  position
221
270
  };
222
271
  }
272
+ function fieldsUnseparated(position) {
273
+ return {
274
+ code: WARNING_CODES.ASTM_RECORD_FIELDS_UNSEPARATED,
275
+ 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.",
276
+ position
277
+ };
278
+ }
223
279
  function nonStandardDelimiters(position) {
224
280
  return {
225
281
  code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
@@ -248,6 +304,13 @@ function unknownEscapeSequence(position) {
248
304
  position
249
305
  };
250
306
  }
307
+ function unpairedEscapeCharacter(position) {
308
+ return {
309
+ code: WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
310
+ 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.",
311
+ position
312
+ };
313
+ }
251
314
  function ambiguousValueSplit(position) {
252
315
  return {
253
316
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
@@ -416,17 +479,27 @@ function classifyMessage(records) {
416
479
  let hasQuery = false;
417
480
  let hasResults = false;
418
481
  let hasOrders = false;
482
+ let hasUnrecognized = false;
419
483
  for (const r of records) {
420
484
  if (r.type === "Q") hasQuery = true;
421
485
  else if (r.type === "R") hasResults = true;
422
486
  else if (r.type === "O") hasOrders = true;
487
+ else if (r.type === "unsupported") hasUnrecognized = true;
423
488
  }
424
489
  let kind;
425
490
  if (hasQuery) kind = "host-query";
491
+ else if (hasUnrecognized) kind = "indeterminate";
426
492
  else if (hasResults) kind = "results";
427
493
  else if (hasOrders) kind = "orders";
428
494
  else kind = "indeterminate";
429
- return { kind, hasQuery, hasResults, hasOrders, isHostQueryRequest: kind === "host-query" };
495
+ return {
496
+ kind,
497
+ hasQuery,
498
+ hasResults,
499
+ hasOrders,
500
+ hasUnrecognized,
501
+ isHostQueryRequest: kind === "host-query"
502
+ };
430
503
  }
431
504
 
432
505
  // src/frames/warnings.ts
@@ -540,6 +613,7 @@ var TOLERABLE_CODES = Object.freeze(
540
613
  /* @__PURE__ */ new Set([
541
614
  WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
542
615
  WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
616
+ WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
543
617
  WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
544
618
  ])
545
619
  );
@@ -824,7 +898,7 @@ var defaultProfile = defineAstmProfile({
824
898
  // src/profiles/reference-corpus.ts
825
899
  var referenceCorpus = defineAstmProfile({
826
900
  name: "referenceCorpus",
827
- 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.",
901
+ 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.",
828
902
  provenance: {
829
903
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
830
904
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -834,7 +908,7 @@ var referenceCorpus = defineAstmProfile({
834
908
  tolerate: [
835
909
  {
836
910
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
837
- 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."
911
+ 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."
838
912
  }
839
913
  ]
840
914
  });
@@ -859,7 +933,10 @@ function getDefaultAstmProfile() {
859
933
  }
860
934
 
861
935
  // src/common/escapes.ts
862
- function decodeEscapes(leaf, d, onUnknown) {
936
+ function isEscapeSequenceAt(text, i, escape) {
937
+ return text.charAt(i) === escape && text.charAt(i + 2) === escape;
938
+ }
939
+ function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
863
940
  const esc = d.escape;
864
941
  if (!leaf.includes(esc)) return leaf;
865
942
  let out = "";
@@ -871,12 +948,13 @@ function decodeEscapes(leaf, d, onUnknown) {
871
948
  i += 1;
872
949
  continue;
873
950
  }
874
- const close = leaf.indexOf(esc, i + 1);
875
- if (close === -1) {
876
- out += leaf.slice(i);
877
- break;
951
+ if (!isEscapeSequenceAt(leaf, i, esc)) {
952
+ out += ch;
953
+ i += 1;
954
+ onUnpaired?.();
955
+ continue;
878
956
  }
879
- const body = leaf.slice(i + 1, close);
957
+ const body = leaf.charAt(i + 1);
880
958
  const replacement = escapeBody(body, d);
881
959
  if (replacement === void 0) {
882
960
  out += esc + body + esc;
@@ -884,7 +962,7 @@ function decodeEscapes(leaf, d, onUnknown) {
884
962
  } else {
885
963
  out += replacement;
886
964
  }
887
- i = close + 1;
965
+ i += 3;
888
966
  }
889
967
  return out;
890
968
  }
@@ -909,14 +987,9 @@ function splitEscapeAware(text, delimiter, escape) {
909
987
  let i = 0;
910
988
  while (i < text.length) {
911
989
  const ch = text.charAt(i);
912
- if (ch === escape) {
913
- const close = text.indexOf(escape, i + 1);
914
- if (close === -1) {
915
- current += text.slice(i);
916
- break;
917
- }
918
- current += text.slice(i, close + 1);
919
- i = close + 1;
990
+ if (ch === escape && isEscapeSequenceAt(text, i, escape)) {
991
+ current += text.slice(i, i + 3);
992
+ i += 3;
920
993
  continue;
921
994
  }
922
995
  if (ch === delimiter) {
@@ -933,26 +1006,38 @@ function splitEscapeAware(text, delimiter, escape) {
933
1006
  }
934
1007
 
935
1008
  // src/records/tokenize.ts
936
- function tokenizeRecord(record, d, onUnknownEscape) {
1009
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape) {
937
1010
  const rawFields = splitEscapeAware(record, d.field, d.escape);
938
- return rawFields.map((raw, fieldIndex) => toField(raw, d, () => onUnknownEscape?.(fieldIndex)));
1011
+ return rawFields.map(
1012
+ (raw, fieldIndex) => toField(
1013
+ raw,
1014
+ d,
1015
+ () => onUnknownEscape?.(fieldIndex),
1016
+ () => onUnpairedEscape?.(fieldIndex)
1017
+ )
1018
+ );
939
1019
  }
940
- function tokenizeHeader(record, d, onUnknownEscape) {
1020
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
941
1021
  const defEnd = record.indexOf(d.field, 2);
942
1022
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
943
1023
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
944
1024
  if (defEnd === -1) return head;
945
- const data = tokenizeRecord(record.slice(defEnd + 1), d, (i) => onUnknownEscape?.(i + 2));
1025
+ const data = tokenizeRecord(
1026
+ record.slice(defEnd + 1),
1027
+ d,
1028
+ (i) => onUnknownEscape?.(i + 2),
1029
+ (i) => onUnpairedEscape?.(i + 2)
1030
+ );
946
1031
  return [...head, ...data];
947
1032
  }
948
1033
  function opaqueField(raw) {
949
1034
  return { raw, components: [raw], repeats: [[raw]] };
950
1035
  }
951
- function toField(raw, d, onUnknownEscape) {
1036
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape) {
952
1037
  const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
953
1038
  const repeats = rawRepeats.map(
954
1039
  (rep) => splitEscapeAware(rep, d.component, d.escape).map(
955
- (comp) => decodeEscapes(comp, d, onUnknownEscape)
1040
+ (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape)
956
1041
  )
957
1042
  );
958
1043
  const components = repeats[0] ?? [""];
@@ -1067,7 +1152,15 @@ function buildRecord(line2, recordIndex, d, warnings) {
1067
1152
  unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1068
1153
  );
1069
1154
  };
1070
- const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape) : tokenizeRecord(line2, d, onUnknownEscape);
1155
+ const onUnpairedEscape = (fieldIndex) => {
1156
+ warnings.push(
1157
+ unpairedEscapeCharacter({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1158
+ );
1159
+ };
1160
+ const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape, onUnpairedEscape) : tokenizeRecord(line2, d, onUnknownEscape, onUnpairedEscape);
1161
+ if (fields.length === 1 && line2.slice(1).trim().length > 0) {
1162
+ warnings.push(fieldsUnseparated({ recordIndex, recordType: rawType }));
1163
+ }
1071
1164
  switch (rawType) {
1072
1165
  case "H":
1073
1166
  return {
@@ -2201,8 +2294,8 @@ function concatBytes3(chunks) {
2201
2294
  }
2202
2295
 
2203
2296
  // src/index.ts
2204
- var VERSION = "0.0.8";
2297
+ var VERSION = "0.0.9";
2205
2298
 
2206
- export { ALL_ASTM_WARNING_CODES, AMBIGUOUS_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, AstmAmbiguousStreamError, AstmFrameEncodeError, AstmFrameStrictError, AstmParseError, AstmProfileDefinitionError, AstmSerializeError, AstmStrictError, CANONICAL_DELIMITERS, FATAL_CODES, FRAME_WARNING_CODES, LIVD_WARNING_CODES, LTP_WARNING_CODES, SAFETY_CRITICAL_CODES, TOLERABLE_CODES, VERSION, WARNING_CODES, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, interpretAbnormalFlag, interpretResultStatus, isNonStandard, isSafetyCriticalCode, listAstmProfiles, livdAmbiguousMapping, livdUnmappedCode, lookupLivdForRecord, ltpAmbiguousTransport, ltpFrameRejected, ltpInitialState, ltpReduce, ltpUnexpectedEvent, messages, nonStandardDelimiters, orders, orphanComment, parseAstmDate, parseAstmRecords, parseChecksumHex, parseFramedAstm, parseReferenceRange, partialTimestamp, patient, primaryCode, profileQuirkApplied, query, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unparseableReferenceRange, unreadableRedeclaration };
2299
+ export { ALL_ASTM_WARNING_CODES, AMBIGUOUS_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, AstmAmbiguousStreamError, AstmFrameEncodeError, AstmFrameStrictError, AstmParseError, AstmProfileDefinitionError, AstmSerializeError, AstmStrictError, CANONICAL_DELIMITERS, FATAL_CODES, FRAME_WARNING_CODES, LIVD_WARNING_CODES, LTP_WARNING_CODES, SAFETY_CRITICAL_CODES, TOLERABLE_CODES, VERSION, WARNING_CODES, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, interpretAbnormalFlag, interpretResultStatus, isNonStandard, isSafetyCriticalCode, listAstmProfiles, livdAmbiguousMapping, livdUnmappedCode, lookupLivdForRecord, ltpAmbiguousTransport, ltpFrameRejected, ltpInitialState, ltpReduce, ltpUnexpectedEvent, messages, nonStandardDelimiters, orders, orphanComment, parseAstmDate, parseAstmRecords, parseChecksumHex, parseFramedAstm, parseReferenceRange, partialTimestamp, patient, primaryCode, profileQuirkApplied, query, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
2207
2300
  //# sourceMappingURL=index.mjs.map
2208
2301
  //# sourceMappingURL=index.mjs.map