@cosyte/astm 0.0.17 → 0.0.18

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.d.ts CHANGED
@@ -238,6 +238,73 @@ declare const WARNING_CODES: {
238
238
  * silent and is correct about its own bytes. A clean re-read is not evidence.
239
239
  */
240
240
  readonly ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS: "ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS";
241
+ /**
242
+ * Two escape alignments of the same bytes disagreed about a **repeat** boundary, the reading
243
+ * taken kept it, and the escape character that reading resumes on **heads no sequence at all**.
244
+ * Same contested position and same tail test as
245
+ * {@link WARNING_CODES.ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS}, on a different delimiter role, and
246
+ * what it costs is a different thing, which is why it is a different code.
247
+ *
248
+ * **Nothing shifts, and that is not the same as nothing being lost.** No field-indexed slot
249
+ * moves: the units and the result status are read out of the same field numbers under either
250
+ * alignment. What this reports is that **the field is read as more repeats than the competing
251
+ * alignment gives it**, which is true wherever it fires. What that costs depends on **which**
252
+ * boundary was gained, and the two cases are stated separately rather than folded into one claim.
253
+ *
254
+ * **Where it is the FIRST boundary in the field, a modeled slot is lost**, because a field's
255
+ * modeled value and components are taken from its **first repeat alone**. Everything past the
256
+ * boundary stays on the wire and stays in `repeats`, and leaves every modeled slot:
257
+ *
258
+ * - **A value truncates.** Under `H|F^&`, `R|1|^^^687|28.6&S&F&U/L||||F` reads the value field as
259
+ * the two repeats `28.6^` and `&U/L`, so a consumer reads `28.6^` and the rest of the value is
260
+ * gone. The competing alignment reads one repeat carrying all of it.
261
+ * - **A modeled component list is deleted rather than shifted.** The same boundary inside a
262
+ * Universal Test ID leaves the components of the first repeat only: under `H|F^&`,
263
+ * `R|1|&F&F&687|28.6|U/L||||F` reads a UTID of one component holding a decoded field separator,
264
+ * with no coding scheme and no local code, where the bytes carry `687`. A patient name loses
265
+ * its given and middle names the same way. That is the half of this condition the shift report
266
+ * could not cover, because components are modeled **inside** a field.
267
+ *
268
+ * Before this code the only warnings on those streams were **tolerable** ones
269
+ * ({@link WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS} and, where the tail applies,
270
+ * {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER}), so the widest gate-legal profile plus
271
+ * `{ strict: true }` accepted a truncated value and an emptied Universal Test ID.
272
+ *
273
+ * **Where it is a LATER boundary, no modeled slot moves and this still fires.** The first repeat
274
+ * is identical under both alignments, so the value, the components and every slot taken from them
275
+ * read the same either way (`R|1|^^^687|5.0F28.6&S&F&U/L||||F` under `H|F^&` reads a value of
276
+ * `5.0` under both), and what differs is the repeat structure after the first. It fires there
277
+ * deliberately: the boundary is still one the bytes do not force and a consumer reading `repeats`
278
+ * is still reading an alignment guess. Relative to the modeled slots that is **over-reporting,
279
+ * never under-reporting**. Narrowing it to the first boundary would change which streams a
280
+ * published package refuses and wants its own measurement, so the bound is written down instead.
281
+ *
282
+ * **It is a report, not a repair.** The split is unchanged, every decoded byte is identical,
283
+ * `repeats` still carries every one of them, and the value read is the value that was always
284
+ * read. Picking the other alignment would be a different guess with no more evidence behind it,
285
+ * on a published package.
286
+ *
287
+ * **Two further deliberate bounds, both stated rather than left to be found.**
288
+ * - **Only the repeat role.** The **component** role reaches a modeled slot too, and differently:
289
+ * there the components stay in the record and move one slot along, so a local code reads as a
290
+ * coding scheme and a given name as a middle name. That is measured, reported by nothing where
291
+ * the earlier body is a recognized mnemonic, and open. It is not covered here, because wiring a
292
+ * sink to another split is another criterion wanting its own population measurement.
293
+ * - **The tail is weighed one construct deep**, exactly as for the shift report. Where the escape
294
+ * character the reading resumes on heads a sequence this codec *recognizes*, the bytes prefer
295
+ * the reading taken and the repeats are the ones the sender wrote: under a set naming the
296
+ * repeat separator `F`, `28.6&F&F&F&U/L` is that separator escaped, written, and escaped again,
297
+ * and refusing it would be an over-refusal of a well-formed stream. Where it heads a sequence
298
+ * whose body is *unrecognized*, the competing alignment would leave **two** escape characters
299
+ * bare, so the preference is stronger again, and this stays silent there even though the gained
300
+ * boundary is real. That last case is the named residue: measured, not overlooked.
301
+ *
302
+ * **Catch it on the first read.** Emit rewrites the preserved sequences into recognized
303
+ * mnemonics, and those bytes carry the reading that was taken unambiguously, so a
304
+ * second-generation read is silent and is correct about its own bytes. A clean re-read is not
305
+ * evidence.
306
+ */
307
+ readonly ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD: "ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD";
241
308
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
242
309
  readonly ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE";
243
310
  /**
@@ -511,6 +578,32 @@ declare function ambiguousEscapeAlignment(position: AstmPosition): AstmRecordWar
511
578
  * ```
512
579
  */
513
580
  declare function alignmentShiftedFields(position: AstmPosition): AstmRecordWarning;
581
+ /**
582
+ * Build an `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` warning. Emitted when a competing escape
583
+ * alignment decided a **repeat** boundary and the escape character the reading taken resumes on
584
+ * heads no sequence of its own, so the field is read as more repeats than the competing alignment
585
+ * gives it. No field-indexed slot moves, and that is the difference from
586
+ * {@link alignmentShiftedFields}. Where the gained boundary is the **first** in the field it still
587
+ * reaches a modeled slot, because a field's modeled value and components are taken from its first
588
+ * repeat alone: everything past the boundary stays in `repeats` and leaves every modeled slot, so a
589
+ * result value truncates and a Universal Test ID or a patient name loses the components that sat
590
+ * after it. At a **later** boundary nothing modeled moves and this still fires, which is
591
+ * over-reporting relative to those slots and never under-reporting; see
592
+ * {@link WARNING_CODES.ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD}.
593
+ *
594
+ * A profile may **not** tolerate this code. It fires alongside
595
+ * {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER}, which remains tolerable and reports the
596
+ * strictly weaker fact that one escape character was read as a literal, and alongside
597
+ * {@link WARNING_CODES.ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT} where that also applies. The reading
598
+ * is unchanged: this reports the gained boundary, it does not repair it.
599
+ *
600
+ * @example
601
+ * ```ts
602
+ * import { alignmentTruncatedField } from "@cosyte/astm";
603
+ * alignmentTruncatedField({ recordIndex: 2, recordType: "R", fieldIndex: 4 });
604
+ * ```
605
+ */
606
+ declare function alignmentTruncatedField(position: AstmPosition): AstmRecordWarning;
514
607
  /**
515
608
  * Build an `ASTM_RECORD_DELIMITERS_REDECLARED` warning. Emitted when a later `H`
516
609
  * record declares a delimiter set different from the one in force; the new set is
@@ -3247,6 +3340,13 @@ declare function classifyMessage(records: readonly AstmRecord[]): AstmMessageCla
3247
3340
  * repeat or component boundary divides one field and so moves no field-indexed
3248
3341
  * slot. That is a choice and not a consequence, because components are modeled
3249
3342
  * inside a field; see {@link ShiftedFieldsSink}.
3343
+ * @param onAlignmentTruncatedField - Called (with the 0-based field index) for that
3344
+ * same condition on the **repeat** split, where no field-indexed slot moves and the
3345
+ * field is instead read as more repeats than the competing alignment gives it.
3346
+ * Where that boundary is the **first** in the field the field's own modeled reading
3347
+ * stops at it, because a field is modeled out of its first repeat alone; at a later
3348
+ * one nothing modeled moves and this still fires. See {@link TruncatedFieldSink}.
3349
+ * The component split is wired to neither, deliberately.
3250
3350
  * @returns The record's fields.
3251
3351
  * @example
3252
3352
  * ```ts
@@ -3255,7 +3355,7 @@ declare function classifyMessage(records: readonly AstmRecord[]): AstmMessageCla
3255
3355
  * fields[3].components[0]; // "28.6"
3256
3356
  * ```
3257
3357
  */
3258
- declare function tokenizeRecord(record: string, d: Delimiters, onUnknownEscape?: (fieldIndex: number) => void, onUnpairedEscape?: (fieldIndex: number) => void, onSwallowedDelimiter?: (fieldIndex: number) => void, onAmbiguousAlignment?: (fieldIndex: number) => void, onAlignmentShiftedFields?: (fieldIndex: number) => void): AstmField[];
3358
+ declare function tokenizeRecord(record: string, d: Delimiters, onUnknownEscape?: (fieldIndex: number) => void, onUnpairedEscape?: (fieldIndex: number) => void, onSwallowedDelimiter?: (fieldIndex: number) => void, onAmbiguousAlignment?: (fieldIndex: number) => void, onAlignmentShiftedFields?: (fieldIndex: number) => void, onAlignmentTruncatedField?: (fieldIndex: number) => void): AstmField[];
3259
3359
  /**
3260
3360
  * Tokenize an `H` (header) record into its fields.
3261
3361
  *
@@ -3291,6 +3391,9 @@ declare function tokenizeRecord(record: string, d: Delimiters, onUnknownEscape?:
3291
3391
  * for each contested field boundary in the data portion whose reading resumes on
3292
3392
  * an escape character heading no sequence. The declaration is opaque, so it never
3293
3393
  * reports here either.
3394
+ * @param onAlignmentTruncatedField - Called with the 0-based whole-record field index
3395
+ * for each contested **repeat** boundary in the data portion on that same tail
3396
+ * test. The declaration is opaque, so it never reports here either.
3294
3397
  * @returns The header's fields.
3295
3398
  * @example
3296
3399
  * ```ts
@@ -3300,7 +3403,7 @@ declare function tokenizeRecord(record: string, d: Delimiters, onUnknownEscape?:
3300
3403
  * fields[4].raw; // "sender"
3301
3404
  * ```
3302
3405
  */
3303
- declare function tokenizeHeader(record: string, d: Delimiters, onUnknownEscape?: (fieldIndex: number) => void, onUnpairedEscape?: (fieldIndex: number) => void, onSwallowedDelimiter?: (fieldIndex: number) => void, onAmbiguousAlignment?: (fieldIndex: number) => void, onAlignmentShiftedFields?: (fieldIndex: number) => void): AstmField[];
3406
+ declare function tokenizeHeader(record: string, d: Delimiters, onUnknownEscape?: (fieldIndex: number) => void, onUnpairedEscape?: (fieldIndex: number) => void, onSwallowedDelimiter?: (fieldIndex: number) => void, onAmbiguousAlignment?: (fieldIndex: number) => void, onAlignmentShiftedFields?: (fieldIndex: number) => void, onAlignmentTruncatedField?: (fieldIndex: number) => void): AstmField[];
3304
3407
  /**
3305
3408
  * The primary scalar of a field: its first repeat's first component, decoded.
3306
3409
  * Returns `undefined` for a truly empty field so callers can distinguish
@@ -3483,9 +3586,19 @@ declare class AstmParseError extends Error {
3483
3586
  * status** are read out of slots the competing alignment does not put them in, and
3484
3587
  * a status of `final` can be a consequence of the alignment rather than something
3485
3588
  * the sender wrote there. That is `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`; see
3486
- * {@link ShiftedFieldsSink}, including the tail and the two other delimiter roles it
3487
- * deliberately stays silent on, and why neither silence is a claim that nothing was
3488
- * lost there.
3589
+ * {@link ShiftedFieldsSink}, including the tail it deliberately stays silent on, and
3590
+ * why that silence is not a claim that nothing was lost there.
3591
+ *
3592
+ * **The same question asked on the repeat separator has a different answer, so it
3593
+ * has a code of its own.** A gained repeat boundary moves no field-indexed slot, and
3594
+ * where it is the **first** boundary in the field it still reaches a modeled slot,
3595
+ * because a field's modeled reading is taken from its first repeat alone: everything
3596
+ * past that boundary leaves the record, so a result value truncates and a Universal
3597
+ * Test ID or a patient name loses the components that sat after it. That is
3598
+ * `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`; see {@link TruncatedFieldSink}, including
3599
+ * what it does at a later boundary, where it fires and no modeled slot moves. The
3600
+ * **component** separator is a third question again and is deliberately not answered
3601
+ * here, because there the components do not disappear, they shift.
3489
3602
  *
3490
3603
  * Re-escaping (the inverse, for spec-clean emit) lives in the serializer and is
3491
3604
  * deliberately not implemented here.
@@ -3587,11 +3700,14 @@ type AmbiguousAlignmentSink = (segmentIndex: number) => void;
3587
3700
  * reaches nothing outside it, so it moves no **field-indexed** slot and is
3588
3701
  * deliberately outside this. **That bound is a choice, not a consequence, and the
3589
3702
  * difference matters**: components are modeled *inside* a field (a Universal Test
3590
- * ID's coding scheme and local code, a patient name's parts), so a gained component
3591
- * boundary does move a modeled slot. That is measured, unreported by anything, and
3592
- * open, not something this sink covers. The callback lets the parser surface a
3593
- * value-free `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS` warning. Optional so the split
3594
- * can be used purely.
3703
+ * ID's coding scheme and local code, a patient name's parts), so a gained repeat or
3704
+ * component boundary does reach a modeled slot. The **repeat** half of that is
3705
+ * reported by {@link TruncatedFieldSink}, on the same tail test and under its own
3706
+ * code, because what it costs is a different thing. The **component** half is
3707
+ * measured, reported by nothing, and open: not something either sink covers. The
3708
+ * callback lets the parser surface a value-free
3709
+ * `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS` warning. Optional so the split can be used
3710
+ * purely.
3595
3711
  *
3596
3712
  * **It is a report, not a repair.** The split is unchanged, every decoded byte is
3597
3713
  * identical, and the status read is the status that was always read. What is new is
@@ -3629,6 +3745,68 @@ type AmbiguousAlignmentSink = (segmentIndex: number) => void;
3629
3745
  * than the competing alignment puts it.
3630
3746
  */
3631
3747
  type ShiftedFieldsSink = (segmentIndex: number) => void;
3748
+ /**
3749
+ * A callback the split calls on **exactly the condition {@link ShiftedFieldsSink}
3750
+ * reports**, wired instead to the split taken on the **repeat** separator. The two
3751
+ * sinks test the same predicate, because the predicate is about the bytes and the
3752
+ * split does not know which role it is being taken on; they are separate so the
3753
+ * caller that does know can name what the gained boundary costs, which is not the
3754
+ * same thing in the two roles. Do not "simplify" them into one: the codes would then
3755
+ * have to make one claim covering both, and the claim is different.
3756
+ *
3757
+ * **What a gained repeat boundary costs, and it is not a shift.** No field-indexed
3758
+ * slot moves: the units slot and the result-status slot are read out of the same
3759
+ * field numbers under either alignment. What the report says is that **the field is
3760
+ * read as more repeats than the competing alignment gives it**, and that is true
3761
+ * wherever it fires. What that costs depends on **which** boundary was gained, and
3762
+ * the two cases are stated separately rather than folded into one claim:
3763
+ *
3764
+ * - **The gained boundary is the FIRST in the field, and then a modeled slot is
3765
+ * lost.** A field's modeled value and its components are taken from its **first
3766
+ * repeat alone**, so everything past the boundary is still on the wire, still in
3767
+ * `repeats`, and gone from every modeled slot. Under a set naming the repeat
3768
+ * separator `F`, the value `28.6&S&F&U/L` reads as the two repeats `28.6^` and
3769
+ * `&U/L`, and every value extractor answers `28.6^`, while the competing alignment
3770
+ * reads one repeat carrying all of it. The same boundary inside a Universal Test ID
3771
+ * field leaves the components of the first repeat only, so a UTID whose bytes carry
3772
+ * a test code can come back as a single component holding a decoded delimiter, with
3773
+ * the coding scheme and local code read from nothing at all. A patient name loses
3774
+ * its given and middle names the same way.
3775
+ * - **The gained boundary is a LATER one, and then no modeled slot moves.** The first
3776
+ * repeat is identical under both alignments, so the value, the components and every
3777
+ * slot taken from them read the same either way; what differs is the repeat
3778
+ * structure after the first. **This still fires there, deliberately and measurably**
3779
+ * (`5.0F28.6&S&F&U/L` under that same set), because the boundary is still one the
3780
+ * bytes do not force and a consumer reading `repeats` is still reading an alignment
3781
+ * guess. Relative to the modeled slots that is **over-reporting, never
3782
+ * under-reporting**, which is the direction this package errs in. Narrowing the sink
3783
+ * to the first boundary would change which streams a published package refuses and
3784
+ * wants its own measurement, so the bound is written down instead of guessed at.
3785
+ *
3786
+ * **It is a report, not a repair.** The split is unchanged, every decoded byte is
3787
+ * identical, `repeats` still carries every one of them, and the value read is the
3788
+ * value that was always read. What is new is that the gained boundary is reported by
3789
+ * a code no profile may tolerate, where before it was covered only by tolerable ones.
3790
+ *
3791
+ * **The tail is weighed one construct deep, exactly as in {@link ShiftedFieldsSink},
3792
+ * and for the same reason.** Where the escape character the reading taken resumes on
3793
+ * heads a sequence this codec recognizes, the bytes prefer the reading taken and the
3794
+ * two repeats are the ones the sender wrote: under a set naming the repeat separator
3795
+ * `F`, `28.6&F&F&F&U/L` is that separator escaped, written, and escaped again.
3796
+ * Refusing that is the over-refusal that sank an earlier candidate criterion for this
3797
+ * family. Where it heads a sequence whose body is *unrecognized*, the competing
3798
+ * alignment would leave two escape characters bare, so the preference is stronger
3799
+ * again. **The truncation in that second case is real and this stays silent about
3800
+ * it**: a named residue, measured, not overlooked, and not closed by widening the
3801
+ * test, because widening it would report a boundary the bytes prefer.
3802
+ *
3803
+ * @param segmentIndex - The 0-based index of the repeat being accumulated when the
3804
+ * gained boundary was taken, so `0` is the boundary that ends the first repeat and
3805
+ * is the one that reaches a modeled slot. It is **not** a field index: the caller
3806
+ * splitting one field into repeats already knows which field it is in, and reports
3807
+ * that.
3808
+ */
3809
+ type TruncatedFieldSink = (segmentIndex: number) => void;
3632
3810
  /**
3633
3811
  * Decode the four recognized ASTM escape mnemonics in a single already-split
3634
3812
  * leaf (a component string), substituting the *active* delimiters read from the
@@ -3698,6 +3876,11 @@ declare function decodeEscapes(leaf: string, d: Delimiters, onUnknown?: UnknownE
3698
3876
  * was bought with a byte the reading cannot read. Wired only by the split taken on
3699
3877
  * the field separator: see {@link ShiftedFieldsSink} for why, and for the tail it
3700
3878
  * deliberately does not report.
3879
+ * @param onTruncatedField - Called on that same condition, wired only by the split
3880
+ * taken on the repeat separator, where the cost is not a shift but a field read as
3881
+ * more repeats than the competing alignment gives it. Where that boundary is the
3882
+ * **first** in the field the field's modeled reading stops at it; at a later one
3883
+ * nothing modeled moves and this still fires. See {@link TruncatedFieldSink}.
3701
3884
  * @returns The raw segments, in order.
3702
3885
  * @example
3703
3886
  * ```ts
@@ -3707,7 +3890,7 @@ declare function decodeEscapes(leaf: string, d: Delimiters, onUnknown?: UnknownE
3707
3890
  * splitEscapeAware("O&Brien^John", "^", "&"); // ["O&Brien", "John"] (unpaired: literal)
3708
3891
  * ```
3709
3892
  */
3710
- declare function splitEscapeAware(text: string, delimiter: string, escape: string, onAmbiguousAlignment?: AmbiguousAlignmentSink, onShiftedFields?: ShiftedFieldsSink): string[];
3893
+ declare function splitEscapeAware(text: string, delimiter: string, escape: string, onAmbiguousAlignment?: AmbiguousAlignmentSink, onShiftedFields?: ShiftedFieldsSink, onTruncatedField?: TruncatedFieldSink): string[];
3711
3894
 
3712
3895
  /**
3713
3896
  * Deep-freeze helper for the immutable ASTM model.
@@ -4089,6 +4272,27 @@ declare function resolveProfileTransport(profile: AstmProfile | undefined): Astm
4089
4272
  * in a value alters no boundary at all), and the case where it accompanies a shifted
4090
4273
  * field is now reported by a code no profile may re-badge.
4091
4274
  *
4275
+ * **`ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` landed next, on the same contested
4276
+ * position and the same tail test, and it reaches a modeled slot by the other
4277
+ * route.** It reports a contested alignment deciding a **repeat** boundary. Nothing
4278
+ * shifts: no field-indexed slot moves, which is exactly why the shift report could
4279
+ * not cover it. What it reports is that the field is read as more repeats than the
4280
+ * competing alignment gives it, and **where that gained boundary is the first one in
4281
+ * the field** it reaches a modeled slot, because a field's modeled value and
4282
+ * components are taken from its **first repeat alone**: everything past it stays in
4283
+ * `repeats` and leaves every modeled slot, so a result value truncates and a
4284
+ * Universal Test ID or a patient name comes back holding only the components that sat
4285
+ * before it. Until it existed the warnings on those streams were
4286
+ * `ASTM_NONSTANDARD_DELIMITERS` and `ASTM_UNPAIRED_ESCAPE_CHARACTER`, both on the list
4287
+ * below, so the widest gate-legal profile plus `{ strict: true }` accepted a truncated
4288
+ * value and an emptied test identity. **At a later boundary it fires and no modeled
4289
+ * slot moves**, which is over-reporting relative to those slots and never
4290
+ * under-reporting: measured, and stated on the code itself rather than left to be
4291
+ * found. It is safety-critical by construction and must never be admitted.
4292
+ * Re-reading the survivors against it moved no admission, and the reason is the one
4293
+ * the entry above already turns on: each of them re-badges the code it names and no
4294
+ * other, and what this condition now raises is a code no profile may name.
4295
+ *
4092
4296
  * **So the ones that remain are recorded with the reading each one survives**, not
4093
4297
  * merely with the value it preserves. Each was re-derived against **every** reader of
4094
4298
  * record structure named above (this sentence deliberately names no count: it has
@@ -4174,21 +4378,33 @@ declare function resolveProfileTransport(profile: AstmProfile | undefined): Astm
4174
4378
  * decoded value, so the value is identical with or without the profile. Note what
4175
4379
  * makes this admissible is a property of the *parse*, not of the warning: reading
4176
4380
  * the character as a literal is unconditional, and tolerating the code changes
4177
- * nothing about how the record splits. No reader sees it, on the same grounds as
4178
- * the entry above: the type letter is read before any decoding, the split reader
4381
+ * nothing about how the record splits. None of the three readers named at the top of
4382
+ * this file sees it: the type letter is read before any decoding, the split reader
4179
4383
  * counts fields the escape-aware tokenizer has already finished dividing, and a
4180
- * decoded value is never cut back into records. Contrast the condition it
4384
+ * decoded value is never cut back into records. **Two later readers DO see it**, and
4385
+ * they are named below rather than left out of this bullet. Contrast the condition it
4181
4386
  * replaced, which **would** have failed part 1: reading the same character as an
4182
4387
  * unterminated sequence merged every field after it into one, and a code reporting
4183
4388
  * that would have been a report of lost values.
4184
4389
  *
4185
- * **The alignment reader is not a reader of this condition, and the distinction is
4186
- * worth stating rather than leaving to be checked.** It asks whether the character
4187
- * two positions past a sequence *is* the escape character, never whether that
4188
- * character heads a sequence of its own, which is the question this code answers.
4189
- * The two are independent in both directions, measured: `28.6&Z&|&U/L` raises this
4190
- * code and the alignment code together, and `28.6&Z&|&U&L` raises the alignment
4191
- * code with no unpaired character anywhere in it.
4390
+ * **The alignment reader is not a reader of this condition**, and that much still
4391
+ * holds: it asks whether the character two positions past a sequence *is* the escape
4392
+ * character, never whether that character heads a sequence of its own. The two are
4393
+ * independent in both directions, measured: `28.6&Z&|&U/L` raises this code and the
4394
+ * alignment code together, and `28.6&Z&|&U&L` raises the alignment code with no
4395
+ * unpaired character anywhere in it.
4396
+ *
4397
+ * **The two TAIL readers are a different matter, and "no reader sees it" is
4398
+ * therefore FALSE of this entry and must not be restored.** Both
4399
+ * `ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS` and
4400
+ * `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` fire on `!isEscapeSequenceAt(text, i + 4,
4401
+ * escape)`, which is exactly the question this code answers, asked about the
4402
+ * character just past a contested boundary. That is a reader of the reported
4403
+ * condition, so this entry survives on the same narrower claim the unknown-sequence
4404
+ * entry above already uses, not on nothing reading it: a profile re-badges the code
4405
+ * it names and no other, and what those two readers raise is not tolerable in any
4406
+ * case. If a future reader of this condition ever produces something a profile
4407
+ * **can** tolerate, this entry stops being admissible and comes off the list.
4192
4408
  * - `ASTM_RECORD_UNINTERPRETED_QUERY_STATUS`: a request-information status carried
4193
4409
  * verbatim as a leaf field on a `Q` record; the code set is paywalled and is not
4194
4410
  * interpreted, profile or not. Classification reads whether a `Q` record is
@@ -4202,7 +4418,8 @@ declare function resolveProfileTransport(profile: AstmProfile | undefined): Astm
4202
4418
  * an unrecognized record type, a record the delimiters in force could not split, a
4203
4419
  * declaration naming one character in two roles, a delimiter an unrecognized escape
4204
4420
  * sequence kept out of the split, a boundary a competing escape alignment disagrees
4205
- * about, a bad frame checksum / sequence gap / unterminated
4421
+ * about, a field whose modeled reading stops at a boundary only one alignment has,
4422
+ * a bad frame checksum / sequence gap / unterminated
4206
4423
  * / oversize frame, an ambiguous transport, an unexpected protocol event, or a
4207
4424
  * rejected frame: is forbidden.
4208
4425
  *
@@ -4249,6 +4466,15 @@ declare function resolveProfileTransport(profile: AstmProfile | undefined): Astm
4249
4466
  * reading `final` that the competing alignment of the same bytes puts in no field at
4250
4467
  * all.
4251
4468
  *
4469
+ * `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD` is not on this list and must not be added
4470
+ * to it either. It reports the same contested alignment deciding a **repeat**
4471
+ * boundary, where no slot changes hands and a modeled reading is cut off instead: the
4472
+ * field is read out of its first repeat alone, so where that boundary is the first
4473
+ * one a value truncates and a Universal Test ID's coding scheme and local code are
4474
+ * read from bytes that are no longer in any modeled slot. Reporting a value that is
4475
+ * not the whole of what the sender wrote fails the first half of the two-clause test
4476
+ * outright.
4477
+ *
4252
4478
  * @example
4253
4479
  * ```ts
4254
4480
  * import { TOLERABLE_CODES } from "@cosyte/astm";
@@ -5593,4 +5819,4 @@ declare class AstmFrameStrictError extends Error {
5593
5819
  */
5594
5820
  declare const VERSION: string;
5595
5821
 
5596
- export { ALL_ASTM_WARNING_CODES, AMBIGUOUS_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, type AbnormalFlag, type AbnormalFlagCode, type AbnormalFlagMeaning, type AmbiguousAlignmentSink, type AmbiguousCode, type AnyAstmWarningCode, AstmAmbiguousStreamError, type AstmDate, type AstmDatePrecision, type AstmField, type AstmFrame, AstmFrameEncodeError, type AstmFrameEncodeErrorCode, type AstmFramePosition, AstmFrameStrictError, type AstmFrameWarning, type AstmFraming, type AstmLivdWarning, type AstmLtpWarning, type AstmMessage, type AstmMessageClassification, type AstmMessageKind, AstmParseError, type AstmParseOptions, type AstmPosition, type AstmProfile, AstmProfileDefinitionError, type AstmProfileProvenance, type AstmQuirkMatch, type AstmQuirkTolerance, type AstmRecord, type AstmRecordInput, type AstmRecordWarning, AstmSerializeError, type AstmSerializeErrorCode, type AstmStreamMessage, AstmStrictError, CANONICAL_DELIMITERS, type CommentInput, type CommentRecord, type ComposeFramesOptions, type DecodeAstmFramesResult, type DefineAstmProfileOptions, type DelimiterDeclarationFault, type DelimiterReadResult, type Delimiters, type DetectFramingOptions, type DetectFramingResult, FATAL_CODES, FRAME_WARNING_CODES, type FatalCode, type FrameChecksum, type FrameOptions, type FrameTerminator, type FrameWarningCode, type FramedAstmResult, type HeaderInput, type HeaderRecord, LIVD_WARNING_CODES, LTP_WARNING_CODES, type LivdAnnotation, type LivdCatalog, type LivdEntry, type LivdLookup, type LivdMapping, type LivdResult, type LivdWarningCode, type LtpAction, type LtpEvent, type LtpPhase, type LtpState, type LtpTransition, type LtpWarningCode, type ManufacturerRecord, type MessageInput, type OrderInput, type OrderRecord, type PatientInput, type PatientName, type PatientNameInput, type PatientRecord, type QueryInput, type QueryRecord, type ReferenceRange, type ReferenceRangeKind, type ResultInput, type ResultRecord, type ResultStatus, type ResultStatusCode, type ResultStatusMeaning, SAFETY_CRITICAL_CODES, type ScientificRecord, type ShiftedFieldsSink, type SwallowedDelimiterSink, TOLERABLE_CODES, type TerminatorRecord, type UniversalTestId, type UniversalTestIdProvenance, type UnknownEscapeSink, type UnpairedEscapeSink, type UnsupportedRecord, VERSION, type VerbatimInput, WARNING_CODES, type WarningCode, alignmentShiftedFields, ambiguousEscapeAlignment, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimiterRoleCollision, delimiterSwallowedByEscape, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, hasCollidingRoles, 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, readDelimiterDeclaration, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
5822
+ export { ALL_ASTM_WARNING_CODES, AMBIGUOUS_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, type AbnormalFlag, type AbnormalFlagCode, type AbnormalFlagMeaning, type AmbiguousAlignmentSink, type AmbiguousCode, type AnyAstmWarningCode, AstmAmbiguousStreamError, type AstmDate, type AstmDatePrecision, type AstmField, type AstmFrame, AstmFrameEncodeError, type AstmFrameEncodeErrorCode, type AstmFramePosition, AstmFrameStrictError, type AstmFrameWarning, type AstmFraming, type AstmLivdWarning, type AstmLtpWarning, type AstmMessage, type AstmMessageClassification, type AstmMessageKind, AstmParseError, type AstmParseOptions, type AstmPosition, type AstmProfile, AstmProfileDefinitionError, type AstmProfileProvenance, type AstmQuirkMatch, type AstmQuirkTolerance, type AstmRecord, type AstmRecordInput, type AstmRecordWarning, AstmSerializeError, type AstmSerializeErrorCode, type AstmStreamMessage, AstmStrictError, CANONICAL_DELIMITERS, type CommentInput, type CommentRecord, type ComposeFramesOptions, type DecodeAstmFramesResult, type DefineAstmProfileOptions, type DelimiterDeclarationFault, type DelimiterReadResult, type Delimiters, type DetectFramingOptions, type DetectFramingResult, FATAL_CODES, FRAME_WARNING_CODES, type FatalCode, type FrameChecksum, type FrameOptions, type FrameTerminator, type FrameWarningCode, type FramedAstmResult, type HeaderInput, type HeaderRecord, LIVD_WARNING_CODES, LTP_WARNING_CODES, type LivdAnnotation, type LivdCatalog, type LivdEntry, type LivdLookup, type LivdMapping, type LivdResult, type LivdWarningCode, type LtpAction, type LtpEvent, type LtpPhase, type LtpState, type LtpTransition, type LtpWarningCode, type ManufacturerRecord, type MessageInput, type OrderInput, type OrderRecord, type PatientInput, type PatientName, type PatientNameInput, type PatientRecord, type QueryInput, type QueryRecord, type ReferenceRange, type ReferenceRangeKind, type ResultInput, type ResultRecord, type ResultStatus, type ResultStatusCode, type ResultStatusMeaning, SAFETY_CRITICAL_CODES, type ScientificRecord, type ShiftedFieldsSink, type SwallowedDelimiterSink, TOLERABLE_CODES, type TerminatorRecord, type TruncatedFieldSink, type UniversalTestId, type UniversalTestIdProvenance, type UnknownEscapeSink, type UnpairedEscapeSink, type UnsupportedRecord, VERSION, type VerbatimInput, WARNING_CODES, type WarningCode, alignmentShiftedFields, alignmentTruncatedField, ambiguousEscapeAlignment, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimiterRoleCollision, delimiterSwallowedByEscape, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, hasCollidingRoles, 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, readDelimiterDeclaration, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
package/dist/index.mjs CHANGED
@@ -327,6 +327,73 @@ var WARNING_CODES = {
327
327
  * silent and is correct about its own bytes. A clean re-read is not evidence.
328
328
  */
329
329
  ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS: "ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS",
330
+ /**
331
+ * Two escape alignments of the same bytes disagreed about a **repeat** boundary, the reading
332
+ * taken kept it, and the escape character that reading resumes on **heads no sequence at all**.
333
+ * Same contested position and same tail test as
334
+ * {@link WARNING_CODES.ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS}, on a different delimiter role, and
335
+ * what it costs is a different thing, which is why it is a different code.
336
+ *
337
+ * **Nothing shifts, and that is not the same as nothing being lost.** No field-indexed slot
338
+ * moves: the units and the result status are read out of the same field numbers under either
339
+ * alignment. What this reports is that **the field is read as more repeats than the competing
340
+ * alignment gives it**, which is true wherever it fires. What that costs depends on **which**
341
+ * boundary was gained, and the two cases are stated separately rather than folded into one claim.
342
+ *
343
+ * **Where it is the FIRST boundary in the field, a modeled slot is lost**, because a field's
344
+ * modeled value and components are taken from its **first repeat alone**. Everything past the
345
+ * boundary stays on the wire and stays in `repeats`, and leaves every modeled slot:
346
+ *
347
+ * - **A value truncates.** Under `H|F^&`, `R|1|^^^687|28.6&S&F&U/L||||F` reads the value field as
348
+ * the two repeats `28.6^` and `&U/L`, so a consumer reads `28.6^` and the rest of the value is
349
+ * gone. The competing alignment reads one repeat carrying all of it.
350
+ * - **A modeled component list is deleted rather than shifted.** The same boundary inside a
351
+ * Universal Test ID leaves the components of the first repeat only: under `H|F^&`,
352
+ * `R|1|&F&F&687|28.6|U/L||||F` reads a UTID of one component holding a decoded field separator,
353
+ * with no coding scheme and no local code, where the bytes carry `687`. A patient name loses
354
+ * its given and middle names the same way. That is the half of this condition the shift report
355
+ * could not cover, because components are modeled **inside** a field.
356
+ *
357
+ * Before this code the only warnings on those streams were **tolerable** ones
358
+ * ({@link WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS} and, where the tail applies,
359
+ * {@link WARNING_CODES.ASTM_UNPAIRED_ESCAPE_CHARACTER}), so the widest gate-legal profile plus
360
+ * `{ strict: true }` accepted a truncated value and an emptied Universal Test ID.
361
+ *
362
+ * **Where it is a LATER boundary, no modeled slot moves and this still fires.** The first repeat
363
+ * is identical under both alignments, so the value, the components and every slot taken from them
364
+ * read the same either way (`R|1|^^^687|5.0F28.6&S&F&U/L||||F` under `H|F^&` reads a value of
365
+ * `5.0` under both), and what differs is the repeat structure after the first. It fires there
366
+ * deliberately: the boundary is still one the bytes do not force and a consumer reading `repeats`
367
+ * is still reading an alignment guess. Relative to the modeled slots that is **over-reporting,
368
+ * never under-reporting**. Narrowing it to the first boundary would change which streams a
369
+ * published package refuses and wants its own measurement, so the bound is written down instead.
370
+ *
371
+ * **It is a report, not a repair.** The split is unchanged, every decoded byte is identical,
372
+ * `repeats` still carries every one of them, and the value read is the value that was always
373
+ * read. Picking the other alignment would be a different guess with no more evidence behind it,
374
+ * on a published package.
375
+ *
376
+ * **Two further deliberate bounds, both stated rather than left to be found.**
377
+ * - **Only the repeat role.** The **component** role reaches a modeled slot too, and differently:
378
+ * there the components stay in the record and move one slot along, so a local code reads as a
379
+ * coding scheme and a given name as a middle name. That is measured, reported by nothing where
380
+ * the earlier body is a recognized mnemonic, and open. It is not covered here, because wiring a
381
+ * sink to another split is another criterion wanting its own population measurement.
382
+ * - **The tail is weighed one construct deep**, exactly as for the shift report. Where the escape
383
+ * character the reading resumes on heads a sequence this codec *recognizes*, the bytes prefer
384
+ * the reading taken and the repeats are the ones the sender wrote: under a set naming the
385
+ * repeat separator `F`, `28.6&F&F&F&U/L` is that separator escaped, written, and escaped again,
386
+ * and refusing it would be an over-refusal of a well-formed stream. Where it heads a sequence
387
+ * whose body is *unrecognized*, the competing alignment would leave **two** escape characters
388
+ * bare, so the preference is stronger again, and this stays silent there even though the gained
389
+ * boundary is real. That last case is the named residue: measured, not overlooked.
390
+ *
391
+ * **Catch it on the first read.** Emit rewrites the preserved sequences into recognized
392
+ * mnemonics, and those bytes carry the reading that was taken unambiguously, so a
393
+ * second-generation read is silent and is correct about its own bytes. A clean re-read is not
394
+ * evidence.
395
+ */
396
+ ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD: "ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD",
330
397
  /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&`: preserved verbatim. */
331
398
  ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
332
399
  /**
@@ -486,6 +553,13 @@ function alignmentShiftedFields(position) {
486
553
  position
487
554
  };
488
555
  }
556
+ function alignmentTruncatedField(position) {
557
+ return {
558
+ code: WARNING_CODES.ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD,
559
+ message: "A repeat 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. This field is read as more repeats than the other reading gives it, and its modeled value and components are taken from the first repeat alone, so where that boundary is the first one the rest of the field leaves every modeled slot. The reading was kept and every byte is preserved.",
560
+ position
561
+ };
562
+ }
489
563
  function delimitersRedeclared(position) {
490
564
  return {
491
565
  code: WARNING_CODES.ASTM_RECORD_DELIMITERS_REDECLARED,
@@ -1111,7 +1185,7 @@ var referenceCorpus = defineAstmProfile({
1111
1185
  tolerate: [
1112
1186
  {
1113
1187
  code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
1114
- 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."
1188
+ 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. Where that same gained boundary is a repeat boundary, nothing shifts but the field is read out of its first repeat alone, so a value truncates and a test identity empties, which raises ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD, untolerable here too. Both of those carry the same tail bound and stay silent where the escape character past the boundary heads a sequence of its own, recognized or not, which is much of this corpus: tolerating this code does not quiet them, but their absence is not a statement that nothing was lost."
1115
1189
  }
1116
1190
  ]
1117
1191
  });
@@ -1185,7 +1259,7 @@ function isMnemonicBody(body) {
1185
1259
  function escapeBody(body, d) {
1186
1260
  return isMnemonicBody(body) ? d[MNEMONIC_ROLES[body]] : void 0;
1187
1261
  }
1188
- function splitEscapeAware(text, delimiter, escape, onAmbiguousAlignment, onShiftedFields) {
1262
+ function splitEscapeAware(text, delimiter, escape, onAmbiguousAlignment, onShiftedFields, onTruncatedField) {
1189
1263
  if (text.length === 0) return [""];
1190
1264
  const out = [];
1191
1265
  let current = "";
@@ -1199,6 +1273,7 @@ function splitEscapeAware(text, delimiter, escape, onAmbiguousAlignment, onShift
1199
1273
  }
1200
1274
  if (contested && !isEscapeSequenceAt(text, i + 4, escape)) {
1201
1275
  onShiftedFields?.(out.length);
1276
+ onTruncatedField?.(out.length);
1202
1277
  }
1203
1278
  current += text.slice(i, i + 3);
1204
1279
  i += 3;
@@ -1218,7 +1293,7 @@ function splitEscapeAware(text, delimiter, escape, onAmbiguousAlignment, onShift
1218
1293
  }
1219
1294
 
1220
1295
  // src/records/tokenize.ts
1221
- function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields) {
1296
+ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields, onAlignmentTruncatedField) {
1222
1297
  const rawFields = splitEscapeAware(
1223
1298
  record,
1224
1299
  d.field,
@@ -1233,11 +1308,15 @@ function tokenizeRecord(record, d, onUnknownEscape, onUnpairedEscape, onSwallowe
1233
1308
  () => onUnknownEscape?.(fieldIndex),
1234
1309
  () => onUnpairedEscape?.(fieldIndex),
1235
1310
  () => onSwallowedDelimiter?.(fieldIndex),
1236
- () => onAmbiguousAlignment?.(fieldIndex)
1311
+ () => onAmbiguousAlignment?.(fieldIndex),
1312
+ // The repeat split runs INSIDE one field, so the index it hands back is a repeat index and is
1313
+ // discarded: what a caller needs is which field carries the contested boundary, and that is
1314
+ // the index this map is already iterating.
1315
+ () => onAlignmentTruncatedField?.(fieldIndex)
1237
1316
  )
1238
1317
  );
1239
1318
  }
1240
- function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields) {
1319
+ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentShiftedFields, onAlignmentTruncatedField) {
1241
1320
  const defEnd = record.indexOf(d.field, 2);
1242
1321
  const definition = defEnd === -1 ? record.slice(2) : record.slice(2, defEnd);
1243
1322
  const head = [opaqueField(record.slice(0, Math.min(1, record.length))), opaqueField(definition)];
@@ -1249,17 +1328,27 @@ function tokenizeHeader(record, d, onUnknownEscape, onUnpairedEscape, onSwallowe
1249
1328
  (i) => onUnpairedEscape?.(i + 2),
1250
1329
  (i) => onSwallowedDelimiter?.(i + 2),
1251
1330
  (i) => onAmbiguousAlignment?.(i + 2),
1252
- (i) => onAlignmentShiftedFields?.(i + 2)
1331
+ (i) => onAlignmentShiftedFields?.(i + 2),
1332
+ (i) => onAlignmentTruncatedField?.(i + 2)
1253
1333
  );
1254
1334
  return [...head, ...data];
1255
1335
  }
1256
1336
  function opaqueField(raw) {
1257
1337
  return { raw, components: [raw], repeats: [[raw]] };
1258
1338
  }
1259
- function toField(raw, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment) {
1260
- const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape, () => {
1261
- onAmbiguousAlignment();
1262
- });
1339
+ function toField(raw, d, onUnknownEscape, onUnpairedEscape, onSwallowedDelimiter, onAmbiguousAlignment, onAlignmentTruncatedField) {
1340
+ const rawRepeats = splitEscapeAware(
1341
+ raw,
1342
+ d.repeat,
1343
+ d.escape,
1344
+ () => {
1345
+ onAmbiguousAlignment();
1346
+ },
1347
+ void 0,
1348
+ () => {
1349
+ onAlignmentTruncatedField();
1350
+ }
1351
+ );
1263
1352
  const repeats = rawRepeats.map(
1264
1353
  (rep) => splitEscapeAware(rep, d.component, d.escape, () => {
1265
1354
  onAmbiguousAlignment();
@@ -1410,6 +1499,11 @@ function buildRecord(line2, recordIndex, d, warnings) {
1410
1499
  alignmentShiftedFields({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1411
1500
  );
1412
1501
  };
1502
+ const onAlignmentTruncatedField = (fieldIndex) => {
1503
+ warnings.push(
1504
+ alignmentTruncatedField({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
1505
+ );
1506
+ };
1413
1507
  const fields = rawType === "H" ? tokenizeHeader(
1414
1508
  line2,
1415
1509
  d,
@@ -1417,7 +1511,8 @@ function buildRecord(line2, recordIndex, d, warnings) {
1417
1511
  onUnpairedEscape,
1418
1512
  onSwallowedDelimiter,
1419
1513
  onAmbiguousAlignment,
1420
- onAlignmentShiftedFields
1514
+ onAlignmentShiftedFields,
1515
+ onAlignmentTruncatedField
1421
1516
  ) : tokenizeRecord(
1422
1517
  line2,
1423
1518
  d,
@@ -1425,7 +1520,8 @@ function buildRecord(line2, recordIndex, d, warnings) {
1425
1520
  onUnpairedEscape,
1426
1521
  onSwallowedDelimiter,
1427
1522
  onAmbiguousAlignment,
1428
- onAlignmentShiftedFields
1523
+ onAlignmentShiftedFields,
1524
+ onAlignmentTruncatedField
1429
1525
  );
1430
1526
  if (fields.length === 1 && line2.slice(1).trim().length > 0) {
1431
1527
  warnings.push(fieldsUnseparated({ recordIndex, recordType: rawType }));
@@ -2638,8 +2734,8 @@ function concatBytes3(chunks) {
2638
2734
  }
2639
2735
 
2640
2736
  // src/index.ts
2641
- var VERSION = "0.0.17";
2737
+ var VERSION = "0.0.18";
2642
2738
 
2643
- 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, alignmentShiftedFields, ambiguousEscapeAlignment, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimiterRoleCollision, delimiterSwallowedByEscape, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, hasCollidingRoles, 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, readDelimiterDeclaration, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
2739
+ 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, alignmentShiftedFields, alignmentTruncatedField, ambiguousEscapeAlignment, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, delimiterRoleCollision, delimiterSwallowedByEscape, delimitersRedeclared, detectFraming, encodeComponent, fieldScalar, fieldsUnseparated, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, hasCollidingRoles, 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, readDelimiterDeclaration, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeHeader, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unpairedEscapeCharacter, unparseableReferenceRange, unreadableRedeclaration };
2644
2740
  //# sourceMappingURL=index.mjs.map
2645
2741
  //# sourceMappingURL=index.mjs.map