@cosyte/astm 0.0.3 → 0.0.4

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
@@ -177,6 +177,28 @@ var WARNING_CODES = {
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
@@ -203,6 +225,20 @@ function nonStandardDelimiters(position) {
203
225
  position
204
226
  };
205
227
  }
228
+ function delimitersRedeclared(position) {
229
+ return {
230
+ code: WARNING_CODES.ASTM_RECORD_DELIMITERS_REDECLARED,
231
+ message: "A later header declared different delimiters \u2014 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 \u2014 the delimiters already in force were kept.",
239
+ position
240
+ };
241
+ }
206
242
  function unknownEscapeSequence(position) {
207
243
  return {
208
244
  code: WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
@@ -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 {
@@ -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
 
@@ -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.4";
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