@cosyte/astm 0.0.15 → 0.0.17

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
@@ -6,7 +6,19 @@ var FATAL_CODES = {
6
6
  EMPTY_INPUT: "EMPTY_INPUT",
7
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
- /** The `H` record is too short to declare the four delimiters (field/repeat/component/escape). */
9
+ /**
10
+ * The first `H` record could not declare all four delimiters (field/repeat/component/escape).
11
+ * One code, four reasons, and the message says which: the record is not a header, it is shorter
12
+ * than a header plus a three-character definition, its definition field holds fewer than three
13
+ * characters before the next field separator, or its field separator is also one of the other
14
+ * three. Only the second of those is "too short", so read the message rather than assuming it.
15
+ *
16
+ * **Two of the four cannot be reached on this fatal, and are not dead code.** A first record that
17
+ * is not an `H` raises `ASTM_RECORD_NO_HEADER` before the declaration is ever read, and a field
18
+ * separator reused among the other three ends the delimiter definition where it appears, so the
19
+ * truncation reason answers first. The reader names all four because it is also called directly
20
+ * and on later headers, where the same conditions are a warning rather than this fatal.
21
+ */
10
22
  ASTM_RECORD_UNDECLARED_DELIMITERS: "ASTM_RECORD_UNDECLARED_DELIMITERS"
11
23
  };
12
24
  var AstmParseError = class extends Error {
@@ -45,17 +57,26 @@ var CANONICAL_DELIMITERS = {
45
57
  escape: "&"
46
58
  };
47
59
  function readDelimiters(headerRecord) {
48
- if (headerRecord.length < 5) return void 0;
49
- if (headerRecord.charAt(0) !== "H") return void 0;
60
+ const result = readDelimiterDeclaration(headerRecord);
61
+ return result.ok ? result.delimiters : void 0;
62
+ }
63
+ function readDelimiterDeclaration(headerRecord) {
64
+ if (headerRecord.charAt(0) !== "H") return { ok: false, fault: "not-a-header" };
65
+ if (headerRecord.length < 5) return { ok: false, fault: "record-too-short" };
50
66
  const field = headerRecord.charAt(1);
51
67
  const defEnd = headerRecord.indexOf(field, 2);
52
68
  const definition = headerRecord.slice(2, defEnd === -1 ? headerRecord.length : defEnd);
53
- if (definition.length < 3) return void 0;
69
+ if (definition.length < 3) return { ok: false, fault: "definition-truncated" };
54
70
  const repeat = definition.charAt(0);
55
71
  const component = definition.charAt(1);
56
72
  const escape = definition.charAt(2);
57
- if (field === repeat || field === component || field === escape) return void 0;
58
- return { field, repeat, component, escape };
73
+ if (field === repeat || field === component || field === escape) {
74
+ return { ok: false, fault: "field-separator-reused" };
75
+ }
76
+ return { ok: true, delimiters: { field, repeat, component, escape } };
77
+ }
78
+ function hasCollidingRoles(d) {
79
+ return d.repeat === d.component || d.repeat === d.escape || d.component === d.escape;
59
80
  }
60
81
  function isNonStandard(d) {
61
82
  return d.field !== CANONICAL_DELIMITERS.field || d.repeat !== CANONICAL_DELIMITERS.repeat || d.component !== CANONICAL_DELIMITERS.component || d.escape !== CANONICAL_DELIMITERS.escape;
@@ -144,18 +165,170 @@ var WARNING_CODES = {
144
165
  * boundaries, silently, and this can happen to one record inside a run of these warnings); and a
145
166
  * set differing in the repeat, component or escape role usually splits into fields normally,
146
167
  * 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
168
+ * `&X&` sequence whose body is an unrecognized character that is itself a delimiter in force is an
169
+ * opaque atom, so that delimiter does not split
148
170
  * and every field after it shifts. The escape role's worst case has narrowed and not
149
171
  * disappeared: an escape character heading no sequence is now read as a literal and reported
150
172
  * 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
173
+ * record, and a delimiter swallowed inside an `&X&` body now raises
174
+ * {@link WARNING_CODES.ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE} as well as the tolerable
175
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, though it still splits the same way. The
176
+ * mirror of that case, where the leftmost alignment lets a delimiter split that a competing
177
+ * alignment would have held, gains a boundary rather than losing one, so the record splits into
178
+ * more fields than another reading gives and this code cannot see it either
179
+ * ({@link WARNING_CODES.ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT} reports that). Treat this code as a
153
180
  * report that one record definitely lost its fields, never as a sweep that would have fired if
154
181
  * any had.
155
182
  */
156
183
  ASTM_RECORD_FIELDS_UNSEPARATED: "ASTM_RECORD_FIELDS_UNSEPARATED",
157
184
  /** The header declared delimiters other than the canonical `H|\^&`: tolerated, noted. */
158
185
  ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
186
+ /**
187
+ * A header declared **one character in two roles**, so the boundary between those two roles is
188
+ * not recoverable from the bytes. The declaration is still read and honored and no record is
189
+ * dropped: what is gone is a distinction the sender's own bytes no longer carry.
190
+ *
191
+ * The field separator is not part of this: a declaration naming it in another role is refused
192
+ * earlier (the `ASTM_RECORD_UNDECLARED_DELIMITERS` fatal on the first header,
193
+ * {@link WARNING_CODES.ASTM_RECORD_UNREADABLE_REDECLARATION} on a later one). What this code
194
+ * covers is the three unordered pairs among the rest: **repeat/component, repeat/escape,
195
+ * component/escape**.
196
+ *
197
+ * Measured on `H|^^&` (repeat and component both `^`): the field `A^B^C^D` reads back as four
198
+ * repeats of one component each, so `components` holds only `A` and a two-repeats-of-two-components
199
+ * reading cannot be recovered. Measured on `H|\&&` (component and escape both `&`): `A&B` splits
200
+ * into two components while `A&F&B` reads as the single component `A|B`, so the same character
201
+ * means two different things depending on what follows it.
202
+ *
203
+ * **It is not tolerable**, and the reason is the pair it travels with: such a set is always
204
+ * non-canonical, so before this code existed the only warning on the stream was
205
+ * {@link WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS}, which a profile may tolerate. That made a
206
+ * structurally unreadable declaration indistinguishable, to a strict consumer, from an ordinary
207
+ * vendor set. Emit refuses the same sets outright (`ASTM_EMIT_INVALID_DELIMITERS`).
208
+ *
209
+ * One warning per header that **changes** the set in force into such a set, not one per colliding
210
+ * pair. A later header restating the colliding set already in force is a no-op and warns nothing,
211
+ * on the same rule as {@link WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS}: the set it names was
212
+ * already reported when it came into force.
213
+ */
214
+ ASTM_RECORD_DELIMITER_ROLE_COLLISION: "ASTM_RECORD_DELIMITER_ROLE_COLLISION",
215
+ /**
216
+ * An escape sequence whose body was **not** a recognized mnemonic held a character that is one of
217
+ * the three splitting delimiters in force, and the atom rule (an `&X&` triple is opaque) kept it
218
+ * out of the split, so a boundary the bytes carried never became one. The sequence is preserved
219
+ * verbatim in the value; nothing is dropped and nothing is re-split.
220
+ *
221
+ * Measured on the canonical set: `R|1|^^^687|28.6&|&U/L||||F` reads `value` = `28.6&|&U/L`, with
222
+ * **no units** and status `unspecified` rather than `final`.
223
+ *
224
+ * **This is the code that says a boundary was lost.** The same condition also raises
225
+ * {@link WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE}, which stays and stays tolerable: that code
226
+ * reports only that a body was not recognized, which is true of bodies that cost nothing. This one
227
+ * is the narrower, safety-critical half, and no profile may tolerate it.
228
+ *
229
+ * **What it does not do is repair anything.** The atom rule is unchanged (it is what keeps `&F&`
230
+ * one token under a set that names `F` as a delimiter), so the value is byte-identical to what it
231
+ * was before this code existed. It also cannot see the condition through a re-emit: emit rewrites
232
+ * the preserved sequence into recognized mnemonics, and the resulting stream says that value
233
+ * unambiguously, so a second-generation read is silent and correct about its own bytes. The place
234
+ * to catch this is the first read of the wire bytes, which is where it now refuses a strict parse.
235
+ */
236
+ ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE: "ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE",
237
+ /**
238
+ * Escape sequences are matched greedily and leftmost, so the escape character that **closed** an
239
+ * unrecognized sequence could not also **open** the next one. Where it could have, and where the
240
+ * body it would have held is the delimiter that was split on, the same bytes carry two alignments
241
+ * that disagree by one boundary: under the reading taken that delimiter ends a field, repeat or
242
+ * component, and under the other it sits inside an opaque atom and ends nothing. The leftmost
243
+ * reading is kept, every byte is preserved, and nothing is re-split.
244
+ *
245
+ * **This is the mirror of {@link WARNING_CODES.ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE}, and
246
+ * the direction is the reason it needs its own code.** That one reports a boundary the reading
247
+ * **lost**; this one reports a boundary the reading may have **gained**, which is the more
248
+ * dangerous direction, because a gained boundary hands back a value the sender's bytes do not
249
+ * unambiguously carry. Measured on the canonical set: `R|1|^^^687|28.6&Z&|&U/L||||F` reads
250
+ * `value` = `28.6&Z&` and `units` = `&U/L` under the leftmost alignment, and reads as a single
251
+ * unsplit field carrying both under the other.
252
+ *
253
+ * **Two exclusions, both deliberate, and the first is wider than its own argument.** Where the
254
+ * earlier sequence's body is a **recognized** mnemonic nothing is reported, because the reading
255
+ * taken interprets a construct (`&F&` is the sender escaping a field separator, which is what the
256
+ * mechanism is for) while the competing alignment's body is a delimiter character it usually
257
+ * cannot interpret at all, so this codec's own vocabulary prefers the reading taken. That is not
258
+ * the same as the reading taken being conformant, and where the declared set names a mnemonic
259
+ * letter as a splitting delimiter both alignments interpret one construct and neither is
260
+ * preferred, yet this stays silent. Both residues are measured and recorded rather than closed by
261
+ * widening the test. And where the escape
262
+ * character after the delimiter does not itself close a sequence there is no competing alignment
263
+ * at all, so an ordinary escaped value followed by an ordinary boundary is silent.
264
+ *
265
+ * **It is a report, not a repair, and not a round-trip guard.** The reading is unchanged: picking
266
+ * the other alignment would be a different guess with no more evidence behind it. Emit then
267
+ * rewrites the preserved sequences into recognized mnemonics, and those bytes carry the reading
268
+ * that was taken unambiguously, so a second-generation read is silent and is correct about its own
269
+ * bytes. The first read of the wire bytes is the only place the ambiguity exists to be caught.
270
+ */
271
+ ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT: "ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT",
272
+ /**
273
+ * Two escape alignments of the same bytes disagreed about a **field** boundary, the reading taken
274
+ * kept it, and the escape character that reading resumes on **heads no sequence at all**, so the
275
+ * boundary was bought with a byte this reading cannot read while the competing alignment is
276
+ * exactly the reading that can. Every field after that point sits one place further right than the
277
+ * competing alignment puts it.
278
+ *
279
+ * **The shift is the harm, and on a result record it reaches the status slot.** Measured on the
280
+ * canonical set, `R|1|^^^687|28.6&F&|&U/L||||F` reads **9** fields under the reading taken and
281
+ * **8** under the competing one, so the sender's trailing `F` lands in field 9 (the result status)
282
+ * under the first and in no field at all under the second. The parse hands back units `&U/L` and a
283
+ * status of **`final`**, and both are consequences of the alignment rather than values the sender
284
+ * placed in those slots. A downstream system reading `final` would act on a result the bytes do
285
+ * not say was finalised. Before this code the only warning on that stream was the **tolerable**
286
+ * {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER}, so the widest gate-legal profile plus
287
+ * `{ strict: true }` accepted it.
288
+ *
289
+ * **It is a report, not a repair.** The split is unchanged, every decoded byte is identical, and
290
+ * the units and status read are the ones that were always read. Picking the other alignment would
291
+ * be a different guess with no more evidence behind it, and it would change values on a published
292
+ * package. What is new is that the shift is reported by a code no profile may tolerate.
293
+ *
294
+ * **It fires alongside {@link WARNING_CODES.ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT}, not instead
295
+ * of it, and neither test is a widening of the other.** That code asks whether this codec's
296
+ * vocabulary prefers the reading taken *at* the contested position, and is silent where the
297
+ * earlier body is a recognized mnemonic. This one asks what the reading taken makes of the bytes
298
+ * *after* the boundary, and does not consult the earlier body at all.
299
+ *
300
+ * **Two deliberate bounds, both stated rather than left to be found.**
301
+ * - **Only the field role, and that bound is a CHOICE rather than a consequence.** A gained repeat
302
+ * or component boundary divides one field and reaches nothing outside it, so it moves no
303
+ * **field-indexed** slot: the units and the status stay where they were. **It does not follow
304
+ * that it moves no modeled slot at all, and writing that down would be false.** Components are
305
+ * modeled *inside* a field: a Universal Test ID's four components are the LOINC-candidate slot,
306
+ * the test name, the **coding scheme** and the **local code**, and a patient name's three are
307
+ * last, first and middle. A gained component boundary shifts those, so a local code can be read
308
+ * as a coding scheme and a given name as a middle name. Measured, and **where the earlier body is
309
+ * a recognized mnemonic, reported by nothing** (only the tolerable
310
+ * {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER} fires, so a gate-legal profile accepts it).
311
+ * An **unrecognized** earlier body raises
312
+ * {@link WARNING_CODES.ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT} there and is refused, which is the
313
+ * same split this code's own exclusion takes. It is a separate, open condition rather than something this
314
+ * code covers, because wiring this sink to another split is a different criterion needing its own
315
+ * population measurement. The repeat role costs the **value**, which this code does not report
316
+ * either.
317
+ * - **The tail is weighed one construct deep.** Where the escape character the reading taken
318
+ * resumes on heads a sequence this codec *recognizes*, the reading taken interprets it and the
319
+ * competing alignment would leave it bare, so the bytes prefer the reading taken: under a set
320
+ * naming the field separator `F`, `28.6&F&F&F&U/L` is that separator escaped, written, and
321
+ * escaped again, and refusing it would be an over-refusal of a well-formed stream. Where it
322
+ * heads a sequence whose body is *unrecognized*, the reading taken still consumes it while the
323
+ * competing alignment would leave **two** escape characters bare, so the preference is stronger
324
+ * again, and this stays silent there even though the field shift is real. That last case is the
325
+ * named residue: measured, not overlooked.
326
+ *
327
+ * **Catch it on the first read.** Emit rewrites the preserved sequences into recognized mnemonics,
328
+ * and those bytes carry the reading that was taken unambiguously, so a second-generation read is
329
+ * silent and is correct about its own bytes. A clean re-read is not evidence.
330
+ */
331
+ ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS: "ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS",
159
332
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
160
333
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
161
334
  /**
@@ -177,7 +350,8 @@ var WARNING_CODES = {
177
350
  * *different* escape character in the same record may still head a real three-character sequence,
178
351
  * and if that sequence's body happens to be a delimiter (`&|&` under the canonical set) the atom
179
352
  * 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.
353
+ * {@link WARNING_CODES.ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE}, and it still costs a field
354
+ * boundary.
181
355
  */
182
356
  ASTM_UNPAIRED_ESCAPE_CHARACTER: "ASTM_UNPAIRED_ESCAPE_CHARACTER",
183
357
  /**
@@ -243,7 +417,8 @@ var WARNING_CODES = {
243
417
  */
244
418
  ASTM_RECORD_DELIMITERS_REDECLARED: "ASTM_RECORD_DELIMITERS_REDECLARED",
245
419
  /**
246
- * A later `H` (header) record could not declare a usable delimiter set: it was too short, or the
420
+ * A later `H` (header) record could not declare a usable delimiter set: it was not a header, it
421
+ * was too short, its delimiter definition held fewer than three characters, or the
247
422
  * field separator it named also appeared among the other three, leaving the four roles
248
423
  * indistinguishable. The delimiters **already in force are kept** and every record is still surfaced;
249
424
  * a set is never guessed and no record is dropped.
@@ -285,6 +460,34 @@ function nonStandardDelimiters(position) {
285
460
  position
286
461
  };
287
462
  }
463
+ function delimiterRoleCollision(position) {
464
+ return {
465
+ code: WARNING_CODES.ASTM_RECORD_DELIMITER_ROLE_COLLISION,
466
+ message: "Header declared one character in two delimiter roles, honored as declared; the boundary between those two roles cannot be recovered from the bytes, and emit refuses such a set.",
467
+ position
468
+ };
469
+ }
470
+ function delimiterSwallowedByEscape(position) {
471
+ return {
472
+ code: WARNING_CODES.ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE,
473
+ message: "An unrecognized escape sequence held a delimiter in force, so that delimiter did not end a field, repeat or component. The sequence is preserved verbatim and nothing is re-split.",
474
+ position
475
+ };
476
+ }
477
+ function ambiguousEscapeAlignment(position) {
478
+ return {
479
+ code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT,
480
+ message: "An unrecognized escape sequence ended where another one could have begun, so a delimiter that ended a field, repeat or component here sits inside a sequence under the other alignment. The leftmost alignment was kept, every byte is preserved, and nothing is re-split.",
481
+ position
482
+ };
483
+ }
484
+ function alignmentShiftedFields(position) {
485
+ return {
486
+ code: WARNING_CODES.ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS,
487
+ message: "A field boundary here is one of two escape alignments the bytes carry, and the reading that took it resumes on an escape character heading no sequence, which the other alignment uses to close one. Every later field is one place further right than the other reading puts it, so a result's units and status may be read out of slots the sender did not put them in. The reading was kept and every byte is preserved.",
488
+ position
489
+ };
490
+ }
288
491
  function delimitersRedeclared(position) {
289
492
  return {
290
493
  code: WARNING_CODES.ASTM_RECORD_DELIMITERS_REDECLARED,
@@ -900,7 +1103,7 @@ var defaultProfile = defineAstmProfile({
900
1103
  // src/profiles/reference-corpus.ts
901
1104
  var referenceCorpus = defineAstmProfile({
902
1105
  name: "referenceCorpus",
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.",
1106
+ 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 an unrecognized character that is itself a delimiter in force is an opaque atom, so that delimiter does not split and a result can lose its units and its status. The mirror case is measured too: sequences are matched leftmost, so one can end where another could have begun and a delimiter splits that the other alignment would have held. Read the values; do not rely on this profile leaving only cosmetic deviations behind.",
904
1107
  provenance: {
905
1108
  source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
906
1109
  reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
@@ -910,7 +1113,7 @@ var referenceCorpus = defineAstmProfile({
910
1113
  tolerate: [
911
1114
  {
912
1115
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
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."
1116
+ 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. That subset now also raises ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE, which this profile cannot tolerate, so tolerating this code no longer hides it. The same holds for the mirror case, where a sequence ends where another could have begun and a delimiter splits that the other alignment would have held: that raises ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT, which this profile cannot tolerate either. Where that gained boundary is a field boundary and the reading taken resumes on an escape character heading no sequence, every later field shifts and a result's units and status move with it, which raises ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS, untolerable here as well."
914
1117
  }
915
1118
  ]
916
1119
  });
@@ -938,7 +1141,7 @@ function getDefaultAstmProfile() {
938
1141
  function isEscapeSequenceAt(text, i, escape) {
939
1142
  return text.charAt(i) === escape && text.charAt(i + 2) === escape;
940
1143
  }
941
- function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
1144
+ function decodeEscapes(leaf, d, onUnknown, onUnpaired, onSwallowedDelimiter) {
942
1145
  const esc = d.escape;
943
1146
  if (!leaf.includes(esc)) return leaf;
944
1147
  let out = "";
@@ -961,6 +1164,7 @@ function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
961
1164
  if (replacement === void 0) {
962
1165
  out += esc + body + esc;
963
1166
  onUnknown?.();
1167
+ if (isSplittingDelimiter(body, d)) onSwallowedDelimiter?.();
964
1168
  } else {
965
1169
  out += replacement;
966
1170
  }
@@ -968,21 +1172,22 @@ function decodeEscapes(leaf, d, onUnknown, onUnpaired) {
968
1172
  }
969
1173
  return out;
970
1174
  }
1175
+ function isSplittingDelimiter(body, d) {
1176
+ return body === d.field || body === d.repeat || body === d.component;
1177
+ }
1178
+ var MNEMONIC_ROLES = {
1179
+ F: "field",
1180
+ S: "component",
1181
+ R: "repeat",
1182
+ E: "escape"
1183
+ };
1184
+ function isMnemonicBody(body) {
1185
+ return Object.hasOwn(MNEMONIC_ROLES, body);
1186
+ }
971
1187
  function escapeBody(body, d) {
972
- switch (body) {
973
- case "F":
974
- return d.field;
975
- case "S":
976
- return d.component;
977
- case "R":
978
- return d.repeat;
979
- case "E":
980
- return d.escape;
981
- default:
982
- return void 0;
983
- }
1188
+ return isMnemonicBody(body) ? d[MNEMONIC_ROLES[body]] : void 0;
984
1189
  }
985
- function splitEscapeAware(text, delimiter, escape) {
1190
+ function splitEscapeAware(text, delimiter, escape, onAmbiguousAlignment, onShiftedFields) {
986
1191
  if (text.length === 0) return [""];
987
1192
  const out = [];
988
1193
  let current = "";
@@ -990,6 +1195,13 @@ function splitEscapeAware(text, delimiter, escape) {
990
1195
  while (i < text.length) {
991
1196
  const ch = text.charAt(i);
992
1197
  if (ch === escape && isEscapeSequenceAt(text, i, escape)) {
1198
+ const contested = text.charAt(i + 3) === delimiter && text.charAt(i + 4) === escape;
1199
+ if (contested && !isMnemonicBody(text.charAt(i + 1))) {
1200
+ onAmbiguousAlignment?.(out.length);
1201
+ }
1202
+ if (contested && !isEscapeSequenceAt(text, i + 4, escape)) {
1203
+ onShiftedFields?.(out.length);
1204
+ }
993
1205
  current += text.slice(i, i + 3);
994
1206
  i += 3;
995
1207
  continue;
@@ -1008,18 +1220,26 @@ function splitEscapeAware(text, delimiter, escape) {
1008
1220
  }
1009
1221
 
1010
1222
  // src/records/tokenize.ts
1011
- function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape) {
1012
- const rawFields = splitEscapeAware(record, d.field, d.escape);
1223
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields) {
1224
+ const rawFields = splitEscapeAware(
1225
+ record,
1226
+ d.field,
1227
+ d.escape,
1228
+ (fieldIndex) => onAmbiguousAlignment?.(fieldIndex),
1229
+ (fieldIndex) => onAlignmentShiftedFields?.(fieldIndex)
1230
+ );
1013
1231
  return rawFields.map(
1014
1232
  (raw, fieldIndex) => toField(
1015
1233
  raw,
1016
1234
  d,
1017
1235
  () => onUnknownEscape?.(fieldIndex),
1018
- () => onUnpairedEscape?.(fieldIndex)
1236
+ () => onUnpairedEscape?.(fieldIndex),
1237
+ () => onSwallowedDelimiter?.(fieldIndex),
1238
+ () => onAmbiguousAlignment?.(fieldIndex)
1019
1239
  )
1020
1240
  );
1021
1241
  }
1022
- function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
1242
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields) {
1023
1243
  const defEnd = record.indexOf(d.field, 2);
1024
1244
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
1025
1245
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
@@ -1028,18 +1248,25 @@ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape) {
1028
1248
  record.slice(defEnd + 1),
1029
1249
  d,
1030
1250
  (i) => onUnknownEscape?.(i + 2),
1031
- (i) => onUnpairedEscape?.(i + 2)
1251
+ (i) => onUnpairedEscape?.(i + 2),
1252
+ (i) => onSwallowedDelimiter?.(i + 2),
1253
+ (i) => onAmbiguousAlignment?.(i + 2),
1254
+ (i) => onAlignmentShiftedFields?.(i + 2)
1032
1255
  );
1033
1256
  return [...head, ...data];
1034
1257
  }
1035
1258
  function opaqueField(raw) {
1036
1259
  return { raw, components: [raw], repeats: [[raw]] };
1037
1260
  }
1038
- function toField(raw, d, onUnknownEscape, onUnpairedEscape) {
1039
- const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
1261
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment) {
1262
+ const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape, () => {
1263
+ onAmbiguousAlignment();
1264
+ });
1040
1265
  const repeats = rawRepeats.map(
1041
- (rep) => splitEscapeAware(rep, d.component, d.escape).map(
1042
- (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape)
1266
+ (rep) => splitEscapeAware(rep, d.component, d.escape, () => {
1267
+ onAmbiguousAlignment();
1268
+ }).map(
1269
+ (comp) => decodeEscapes(comp, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter)
1043
1270
  )
1044
1271
  );
1045
1272
  const components = repeats[0] ?? [""];
@@ -1076,18 +1303,22 @@ function parseAstmRecords(raw, options = {}) {
1076
1303
  leadType !== void 0 && leadType.length > 0 ? { recordIndex: 0, recordType: leadType } : { recordIndex: 0 }
1077
1304
  );
1078
1305
  }
1079
- const delimiters = readDelimiters(first);
1080
- if (delimiters === void 0) {
1306
+ const declaration = readDelimiterDeclaration(first);
1307
+ if (!declaration.ok) {
1081
1308
  throw new AstmParseError(
1082
1309
  FATAL_CODES.ASTM_RECORD_UNDECLARED_DELIMITERS,
1083
- "Header record is too short to declare the four delimiters.",
1310
+ UNDECLARED_DELIMITER_MESSAGES[declaration.fault],
1084
1311
  { recordIndex: 0, recordType: "H" }
1085
1312
  );
1086
1313
  }
1314
+ const delimiters = declaration.delimiters;
1087
1315
  const warnings = [];
1088
1316
  if (isNonStandard(delimiters)) {
1089
1317
  warnings.push(nonStandardDelimiters({ recordIndex: 0, recordType: "H" }));
1090
1318
  }
1319
+ if (hasCollidingRoles(delimiters)) {
1320
+ warnings.push(delimiterRoleCollision({ recordIndex: 0, recordType: "H" }));
1321
+ }
1091
1322
  let active = delimiters;
1092
1323
  const built = lines.map((line2, recordIndex) => {
1093
1324
  if (recordIndex > 0 && line2.charAt(0) === "H") {
@@ -1119,6 +1350,12 @@ function parseAstmRecords(raw, options = {}) {
1119
1350
  } : { header, records, delimiters, classification, warnings: finalWarnings };
1120
1351
  return deepFreeze(message);
1121
1352
  }
1353
+ var UNDECLARED_DELIMITER_MESSAGES = {
1354
+ "not-a-header": "Header record does not begin with an H type letter.",
1355
+ "record-too-short": "Header record is too short to declare the four delimiters.",
1356
+ "definition-truncated": "Header record declares fewer than three delimiter-definition characters before its next field separator, so the repeat, component and escape roles are not all named.",
1357
+ "field-separator-reused": "Header record names its field separator in one of the other three delimiter roles, so the four roles are not distinguishable."
1358
+ };
1122
1359
  function resolveProfile(options) {
1123
1360
  if (options.profile === null) return void 0;
1124
1361
  if (options.profile !== void 0) return options.profile;
@@ -1134,6 +1371,7 @@ function adoptRedeclaredDelimiters(line2, active, recordIndex, warnings) {
1134
1371
  if (sameDelimiters(declared, active)) return active;
1135
1372
  warnings.push(delimitersRedeclared(position));
1136
1373
  if (isNonStandard(declared)) warnings.push(nonStandardDelimiters(position));
1374
+ if (hasCollidingRoles(declared)) warnings.push(delimiterRoleCollision(position));
1137
1375
  return declared;
1138
1376
  }
1139
1377
  function sameDelimiters(a, b) {
@@ -1159,7 +1397,38 @@ function buildRecord(line2, recordIndex, d, warnings) {
1159
1397
  unpairedEscapeCharacter({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1160
1398
  );
1161
1399
  };
1162
- const fields = rawType === "H" ? tokenizeHeader(line2, d, onUnknownEscape, onUnpairedEscape) : tokenizeRecord(line2, d, onUnknownEscape, onUnpairedEscape);
1400
+ const onSwallowedDelimiter = (fieldIndex) => {
1401
+ warnings.push(
1402
+ delimiterSwallowedByEscape({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1403
+ );
1404
+ };
1405
+ const onAmbiguousAlignment = (fieldIndex) => {
1406
+ warnings.push(
1407
+ ambiguousEscapeAlignment({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1408
+ );
1409
+ };
1410
+ const onAlignmentShiftedFields = (fieldIndex) => {
1411
+ warnings.push(
1412
+ alignmentShiftedFields({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1413
+ );
1414
+ };
1415
+ const fields = rawType === "H" ? tokenizeHeader(
1416
+ line2,
1417
+ d,
1418
+ onUnknownEscape,
1419
+ onUnpairedEscape,
1420
+ onSwallowedDelimiter,
1421
+ onAmbiguousAlignment,
1422
+ onAlignmentShiftedFields
1423
+ ) : tokenizeRecord(
1424
+ line2,
1425
+ d,
1426
+ onUnknownEscape,
1427
+ onUnpairedEscape,
1428
+ onSwallowedDelimiter,
1429
+ onAmbiguousAlignment,
1430
+ onAlignmentShiftedFields
1431
+ );
1163
1432
  if (fields.length === 1 && line2.slice(1).trim().length > 0) {
1164
1433
  warnings.push(fieldsUnseparated({ recordIndex, recordType: rawType }));
1165
1434
  }
@@ -2371,7 +2640,7 @@ function concatBytes3(chunks) {
2371
2640
  }
2372
2641
 
2373
2642
  // src/index.ts
2374
- var VERSION = "0.0.15";
2643
+ var VERSION = "0.0.17";
2375
2644
 
2376
2645
  exports.ALL_ASTM_WARNING_CODES = ALL_ASTM_WARNING_CODES;
2377
2646
  exports.AMBIGUOUS_CODES = AMBIGUOUS_CODES;
@@ -2395,6 +2664,8 @@ exports.SAFETY_CRITICAL_CODES = SAFETY_CRITICAL_CODES;
2395
2664
  exports.TOLERABLE_CODES = TOLERABLE_CODES;
2396
2665
  exports.VERSION = VERSION;
2397
2666
  exports.WARNING_CODES = WARNING_CODES;
2667
+ exports.alignmentShiftedFields = alignmentShiftedFields;
2668
+ exports.ambiguousEscapeAlignment = ambiguousEscapeAlignment;
2398
2669
  exports.ambiguousMessageKind = ambiguousMessageKind;
2399
2670
  exports.ambiguousValueSplit = ambiguousValueSplit;
2400
2671
  exports.applyAstmProfile = applyAstmProfile;
@@ -2414,6 +2685,8 @@ exports.decodeEscapes = decodeEscapes;
2414
2685
  exports.deepFreeze = deepFreeze;
2415
2686
  exports.defineAstmProfile = defineAstmProfile;
2416
2687
  exports.defineLivdCatalog = defineLivdCatalog;
2688
+ exports.delimiterRoleCollision = delimiterRoleCollision;
2689
+ exports.delimiterSwallowedByEscape = delimiterSwallowedByEscape;
2417
2690
  exports.delimitersRedeclared = delimitersRedeclared;
2418
2691
  exports.detectFraming = detectFraming;
2419
2692
  exports.encodeComponent = encodeComponent;
@@ -2425,6 +2698,7 @@ exports.frameSequenceGap = frameSequenceGap;
2425
2698
  exports.frameUnterminated = frameUnterminated;
2426
2699
  exports.getAstmProfile = getAstmProfile;
2427
2700
  exports.getDefaultAstmProfile = getDefaultAstmProfile;
2701
+ exports.hasCollidingRoles = hasCollidingRoles;
2428
2702
  exports.interpretAbnormalFlag = interpretAbnormalFlag;
2429
2703
  exports.interpretResultStatus = interpretResultStatus;
2430
2704
  exports.isNonStandard = isNonStandard;
@@ -2452,6 +2726,7 @@ exports.patient = patient;
2452
2726
  exports.primaryCode = primaryCode;
2453
2727
  exports.profileQuirkApplied = profileQuirkApplied;
2454
2728
  exports.query = query;
2729
+ exports.readDelimiterDeclaration = readDelimiterDeclaration;
2455
2730
  exports.readDelimiters = readDelimiters;
2456
2731
  exports.recognizeUniversalTestId = recognizeUniversalTestId;
2457
2732
  exports.resolveProfileTransport = resolveProfileTransport;