@cosyte/astm 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -127,10 +127,59 @@ var WARNING_CODES = {
127
127
  * not recognize as one does not open a new message.
128
128
  */
129
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",
130
157
  /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
131
158
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
132
159
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
133
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",
134
183
  /**
135
184
  * A result value field carried an *unescaped* component delimiter, so it split into more than one
136
185
  * component. Both the full raw value and the split are surfaced and this warning fires: the
@@ -222,6 +271,13 @@ function unknownRecordType(position) {
222
271
  position
223
272
  };
224
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.",
278
+ position
279
+ };
280
+ }
225
281
  function nonStandardDelimiters(position) {
226
282
  return {
227
283
  code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
@@ -250,6 +306,13 @@ function unknownEscapeSequence(position) {
250
306
  position
251
307
  };
252
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
+ }
253
316
  function ambiguousValueSplit(position) {
254
317
  return {
255
318
  code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
@@ -418,17 +481,27 @@ function classifyMessage(records) {
418
481
  let hasQuery = false;
419
482
  let hasResults = false;
420
483
  let hasOrders = false;
484
+ let hasUnrecognized = false;
421
485
  for (const r of records) {
422
486
  if (r.type === "Q") hasQuery = true;
423
487
  else if (r.type === "R") hasResults = true;
424
488
  else if (r.type === "O") hasOrders = true;
489
+ else if (r.type === "unsupported") hasUnrecognized = true;
425
490
  }
426
491
  let kind;
427
492
  if (hasQuery) kind = "host-query";
493
+ else if (hasUnrecognized) kind = "indeterminate";
428
494
  else if (hasResults) kind = "results";
429
495
  else if (hasOrders) kind = "orders";
430
496
  else kind = "indeterminate";
431
- 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
+ };
432
505
  }
433
506
 
434
507
  // src/frames/warnings.ts
@@ -542,6 +615,7 @@ var TOLERABLE_CODES = Object.freeze(
542
615
  /* @__PURE__ */ new Set([
543
616
  WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
544
617
  WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
618
+ WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER,
545
619
  WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
546
620
  ])
547
621
  );
@@ -826,7 +900,7 @@ var defaultProfile = defineAstmProfile({
826
900
  // src/profiles/reference-corpus.ts
827
901
  var referenceCorpus = defineAstmProfile({
828
902
  name: "referenceCorpus",
829
- 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.",
830
904
  provenance: {
831
905
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
832
906
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -836,7 +910,7 @@ var referenceCorpus = defineAstmProfile({
836
910
  tolerate: [
837
911
  {
838
912
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
839
- 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."
840
914
  }
841
915
  ]
842
916
  });
@@ -861,7 +935,10 @@ function getDefaultAstmProfile() {
861
935
  }
862
936
 
863
937
  // src/common/escapes.ts
864
- 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) {
865
942
  const esc = d.escape;
866
943
  if (!leaf.includes(esc)) return leaf;
867
944
  let out = "";
@@ -873,12 +950,13 @@ function decodeEscapes(leaf, d, onUnknown) {
873
950
  i += 1;
874
951
  continue;
875
952
  }
876
- const close = leaf.indexOf(esc, i + 1);
877
- if (close === -1) {
878
- out += leaf.slice(i);
879
- break;
953
+ if (!isEscapeSequenceAt(leaf, i, esc)) {
954
+ out += ch;
955
+ i += 1;
956
+ onUnpaired?.();
957
+ continue;
880
958
  }
881
- const body = leaf.slice(i + 1, close);
959
+ const body = leaf.charAt(i + 1);
882
960
  const replacement = escapeBody(body, d);
883
961
  if (replacement === void 0) {
884
962
  out += esc + body + esc;
@@ -886,7 +964,7 @@ function decodeEscapes(leaf, d, onUnknown) {
886
964
  } else {
887
965
  out += replacement;
888
966
  }
889
- i = close + 1;
967
+ i += 3;
890
968
  }
891
969
  return out;
892
970
  }
@@ -911,14 +989,9 @@ function splitEscapeAware(text, delimiter, escape) {
911
989
  let i = 0;
912
990
  while (i < text.length) {
913
991
  const ch = text.charAt(i);
914
- if (ch === escape) {
915
- const close = text.indexOf(escape, i + 1);
916
- if (close === -1) {
917
- current += text.slice(i);
918
- break;
919
- }
920
- current += text.slice(i, close + 1);
921
- i = close + 1;
992
+ if (ch === escape && isEscapeSequenceAt(text, i, escape)) {
993
+ current += text.slice(i, i + 3);
994
+ i += 3;
922
995
  continue;
923
996
  }
924
997
  if (ch === delimiter) {
@@ -935,26 +1008,38 @@ function splitEscapeAware(text, delimiter, escape) {
935
1008
  }
936
1009
 
937
1010
  // src/records/tokenize.ts
938
- function tokenizeRecord(record, d, onUnknownEscape) {
1011
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape) {
939
1012
  const rawFields = splitEscapeAware(record, d.field, d.escape);
940
- 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
+ );
941
1021
  }
942
- function tokenizeHeader(record, d, onUnknownEscape) {
1022
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
943
1023
  const defEnd = record.indexOf(d.field, 2);
944
1024
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
945
1025
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
946
1026
  if (defEnd === -1) return head;
947
- 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
+ );
948
1033
  return [...head, ...data];
949
1034
  }
950
1035
  function opaqueField(raw) {
951
1036
  return { raw, components: [raw], repeats: [[raw]] };
952
1037
  }
953
- function toField(raw, d, onUnknownEscape) {
1038
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape) {
954
1039
  const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
955
1040
  const repeats = rawRepeats.map(
956
1041
  (rep) => splitEscapeAware(rep, d.component, d.escape).map(
957
- (comp) => decodeEscapes(comp, d, onUnknownEscape)
1042
+ (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape)
958
1043
  )
959
1044
  );
960
1045
  const components = repeats[0] ?? [""];
@@ -1069,7 +1154,15 @@ function buildRecord(line2, recordIndex, d, warnings) {
1069
1154
  unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1070
1155
  );
1071
1156
  };
1072
- 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
+ }
1073
1166
  switch (rawType) {
1074
1167
  case "H":
1075
1168
  return {
@@ -2133,18 +2226,37 @@ var AstmFrameEncodeError = class extends Error {
2133
2226
  code;
2134
2227
  /** Index of the offending record within the input, when applicable. */
2135
2228
  recordIndex;
2229
+ /**
2230
+ * Position of the offending character within that record, when applicable:
2231
+ * its index in the string, never the character itself and never its code
2232
+ * point. Enough to find it in the caller's own data.
2233
+ */
2234
+ characterIndex;
2136
2235
  /** @internal */
2137
- constructor(message, recordIndex) {
2236
+ constructor(message, recordIndex, code = "ASTM_FRAME_EMPTY_RECORD", characterIndex) {
2138
2237
  super(message);
2139
2238
  this.name = "AstmFrameEncodeError";
2140
- this.code = "ASTM_FRAME_EMPTY_RECORD";
2239
+ this.code = code;
2141
2240
  if (recordIndex !== void 0) this.recordIndex = recordIndex;
2241
+ if (characterIndex !== void 0) this.characterIndex = characterIndex;
2142
2242
  }
2143
2243
  };
2144
- function toBytes(input) {
2244
+ var MAX_SINGLE_BYTE_CODE_POINT = 255;
2245
+ function toBytes(input, recordIndex) {
2145
2246
  if (typeof input !== "string") return input;
2146
2247
  const out = new Uint8Array(input.length);
2147
- for (let i = 0; i < input.length; i += 1) out[i] = input.charCodeAt(i) & 255;
2248
+ for (let i = 0; i < input.length; i += 1) {
2249
+ const code = input.charCodeAt(i);
2250
+ if (code > MAX_SINGLE_BYTE_CODE_POINT) {
2251
+ throw new AstmFrameEncodeError(
2252
+ "A record contains a character above U+00FF, which is not a single byte. Encode the record with the character encoding your instrument uses and pass the bytes.",
2253
+ recordIndex,
2254
+ "ASTM_FRAME_UNENCODABLE_CHARACTER",
2255
+ i
2256
+ );
2257
+ }
2258
+ out[i] = code;
2259
+ }
2148
2260
  return out;
2149
2261
  }
2150
2262
  function encodeFrame(text, frameNumber, isFinal) {
@@ -2161,7 +2273,7 @@ function composeAstmFrames(records, options = {}) {
2161
2273
  const out = [];
2162
2274
  let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
2163
2275
  records.forEach((record, recordIndex) => {
2164
- const bytes = toBytes(record);
2276
+ const bytes = toBytes(record, recordIndex);
2165
2277
  if (bytes.length === 0) {
2166
2278
  throw new AstmFrameEncodeError(
2167
2279
  "Cannot frame an empty record: a frame must carry record bytes.",
@@ -2203,7 +2315,7 @@ function concatBytes3(chunks) {
2203
2315
  }
2204
2316
 
2205
2317
  // src/index.ts
2206
- var VERSION = "0.0.8";
2318
+ var VERSION = "0.0.10";
2207
2319
 
2208
2320
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2209
2321
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
@@ -2250,6 +2362,7 @@ exports.delimitersRedeclared = delimitersRedeclared;
2250
2362
  exports.detectFraming = detectFraming;
2251
2363
  exports.encodeComponent = encodeComponent;
2252
2364
  exports.fieldScalar = fieldScalar;
2365
+ exports.fieldsUnseparated = fieldsUnseparated;
2253
2366
  exports.frameBadChecksum = frameBadChecksum;
2254
2367
  exports.frameOversize = frameOversize;
2255
2368
  exports.frameSequenceGap = frameSequenceGap;
@@ -2302,6 +2415,7 @@ exports.uninterpretedQueryStatus = uninterpretedQueryStatus;
2302
2415
  exports.unitsAbsent = unitsAbsent;
2303
2416
  exports.unknownEscapeSequence = unknownEscapeSequence;
2304
2417
  exports.unknownRecordType = unknownRecordType;
2418
+ exports.unpairedEscapeCharacter = unpairedEscapeCharacter;
2305
2419
  exports.unparseableReferenceRange = unparseableReferenceRange;
2306
2420
  exports.unreadableRedeclaration = unreadableRedeclaration;
2307
2421
  //# sourceMappingURL=index.cjs.map