@cosyte/astm 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1977 @@
1
+ // src/common/errors.ts
2
+ var FATAL_CODES = {
3
+ /** Input was empty or whitespace-only — there is nothing to parse. Shared across layers. */
4
+ EMPTY_INPUT: "EMPTY_INPUT",
5
+ /** The first record is not an `H` (header) record — an ASTM message must lead with `H`. */
6
+ ASTM_RECORD_NO_HEADER: "ASTM_RECORD_NO_HEADER",
7
+ /** The `H` record is too short to declare the four delimiters (field/repeat/component/escape). */
8
+ ASTM_RECORD_UNDECLARED_DELIMITERS: "ASTM_RECORD_UNDECLARED_DELIMITERS"
9
+ };
10
+ var AstmParseError = class extends Error {
11
+ code;
12
+ position;
13
+ /**
14
+ * Construct a new `AstmParseError`. Both fields are required so every thrower
15
+ * populates positional context and no PHI-bearing snippet is ever attached.
16
+ *
17
+ * @internal
18
+ */
19
+ constructor(code, message, position) {
20
+ super(message);
21
+ this.name = "AstmParseError";
22
+ this.code = code;
23
+ this.position = position;
24
+ }
25
+ };
26
+
27
+ // src/common/freeze.ts
28
+ function deepFreeze(value) {
29
+ if (value === null || typeof value !== "object") return value;
30
+ if (Object.isFrozen(value)) return value;
31
+ Object.freeze(value);
32
+ for (const key of Object.keys(value)) {
33
+ deepFreeze(value[key]);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ // src/common/delimiters.ts
39
+ var CANONICAL_DELIMITERS = {
40
+ field: "|",
41
+ repeat: "\\",
42
+ component: "^",
43
+ escape: "&"
44
+ };
45
+ function readDelimiters(headerRecord) {
46
+ if (headerRecord.length < 5) return void 0;
47
+ if (headerRecord.charAt(0) !== "H") return void 0;
48
+ const field = headerRecord.charAt(1);
49
+ const defEnd = headerRecord.indexOf(field, 2);
50
+ const definition = headerRecord.slice(2, defEnd === -1 ? headerRecord.length : defEnd);
51
+ if (definition.length < 3) return void 0;
52
+ const repeat = definition.charAt(0);
53
+ const component = definition.charAt(1);
54
+ const escape = definition.charAt(2);
55
+ if (field === repeat || field === component || field === escape) return void 0;
56
+ return { field, repeat, component, escape };
57
+ }
58
+ function isNonStandard(d) {
59
+ return d.field !== CANONICAL_DELIMITERS.field || d.repeat !== CANONICAL_DELIMITERS.repeat || d.component !== CANONICAL_DELIMITERS.component || d.escape !== CANONICAL_DELIMITERS.escape;
60
+ }
61
+
62
+ // src/common/dates.ts
63
+ function parseAstmDate(raw) {
64
+ const s = raw.trim();
65
+ if (!/^\d{4,}$/u.test(s)) return void 0;
66
+ const year = Number(s.slice(0, 4));
67
+ const truncated = s.length < 14 && s.length % 2 === 1;
68
+ const base = { raw: s, year, ...truncated ? { truncated: true } : {} };
69
+ if (s.length < 6) return { ...base, precision: "year" };
70
+ const month = Number(s.slice(4, 6));
71
+ if (s.length < 8) return { ...base, month, precision: "month" };
72
+ const day = Number(s.slice(6, 8));
73
+ if (s.length < 10) return { ...base, month, day, precision: "day" };
74
+ const hour = Number(s.slice(8, 10));
75
+ if (s.length < 12) return { ...base, month, day, hour, precision: "hour" };
76
+ const minute = Number(s.slice(10, 12));
77
+ if (s.length < 14) return { ...base, month, day, hour, minute, precision: "minute" };
78
+ const second = Number(s.slice(12, 14));
79
+ return { ...base, month, day, hour, minute, second, precision: "second" };
80
+ }
81
+ function astmDateToLocalISO(d) {
82
+ const p2 = (n) => String(n).padStart(2, "0");
83
+ let out = String(d.year).padStart(4, "0");
84
+ if (d.month !== void 0) out += `-${p2(d.month)}`;
85
+ if (d.day !== void 0) out += `-${p2(d.day)}`;
86
+ if (d.hour !== void 0) out += `T${p2(d.hour)}`;
87
+ if (d.minute !== void 0) out += `:${p2(d.minute)}`;
88
+ if (d.second !== void 0) out += `:${p2(d.second)}`;
89
+ return out;
90
+ }
91
+
92
+ // src/common/coding-system.ts
93
+ function recognizeUniversalTestId(components) {
94
+ const nonEmpty = (i) => {
95
+ const c = components[i];
96
+ return c !== void 0 && c.length > 0 ? c : void 0;
97
+ };
98
+ const loincCandidate = nonEmpty(0);
99
+ const testName = nonEmpty(1);
100
+ const codingScheme = nonEmpty(2);
101
+ const localCode = nonEmpty(3);
102
+ let provenance;
103
+ if (loincCandidate !== void 0) provenance = "inline-loinc-candidate";
104
+ else if (localCode !== void 0) provenance = "local-code";
105
+ else if (testName !== void 0) provenance = "name-only";
106
+ else provenance = "empty";
107
+ const base = { components, provenance };
108
+ return {
109
+ ...base,
110
+ ...loincCandidate !== void 0 ? { loincCandidate } : {},
111
+ ...testName !== void 0 ? { testName } : {},
112
+ ...codingScheme !== void 0 ? { codingScheme } : {},
113
+ ...localCode !== void 0 ? { localCode } : {}
114
+ };
115
+ }
116
+ function primaryCode(u) {
117
+ return u.loincCandidate ?? u.localCode;
118
+ }
119
+
120
+ // src/common/warnings.ts
121
+ var WARNING_CODES = {
122
+ /** A record's type letter is not one of the modeled types — surfaced as an unsupported record. */
123
+ ASTM_RECORD_UNKNOWN_TYPE: "ASTM_RECORD_UNKNOWN_TYPE",
124
+ /** The header declared delimiters other than the canonical `H|\^&` — tolerated, noted. */
125
+ ASTM_NONSTANDARD_DELIMITERS: "ASTM_NONSTANDARD_DELIMITERS",
126
+ /** An escape sequence body was not one of `&F&`/`&S&`/`&R&`/`&E&` — preserved verbatim. */
127
+ ASTM_UNKNOWN_ESCAPE_SEQUENCE: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
128
+ /**
129
+ * A result value field carried an *unescaped* component delimiter, so it split into more than one
130
+ * component. Both the full raw value and the split are surfaced and this warning fires — the
131
+ * ambiguity is never resolved silently into a truncated value.
132
+ */
133
+ ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT: "ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT",
134
+ /**
135
+ * A result's abnormal-flag field (`R` field 7) carried a letter outside HL7 Table 0078. The flag is
136
+ * surfaced as `undefined` — never dropped, and **never coerced to `normal`** (a clinical error).
137
+ */
138
+ ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG: "ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG",
139
+ /**
140
+ * A result's status field (`R` field 9) carried a letter that is not a recognized status. It is
141
+ * surfaced as `undefined` and, like every non-`F` status, never reads as active-final.
142
+ */
143
+ ASTM_RECORD_UNDEFINED_RESULT_STATUS: "ASTM_RECORD_UNDEFINED_RESULT_STATUS",
144
+ /**
145
+ * A result's reference-range field (`R` field 6) did not match a recognized form (`low-high`,
146
+ * `<high`, `>low`). The text is surfaced verbatim as `unparsed` — a bound is **never fabricated**.
147
+ */
148
+ ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE: "ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE",
149
+ /**
150
+ * A result carried a numeric value but no units (`R` field 5 empty). Units are vendor free text
151
+ * (not UCUM); a missing unit is flagged here and **never defaulted, guessed, or converted**.
152
+ */
153
+ ASTM_RECORD_UNITS_ABSENT: "ASTM_RECORD_UNITS_ABSENT",
154
+ /**
155
+ * A `C` (comment) record had no valid preceding `H`/`P`/`O`/`R` parent — an **orphan**. The comment
156
+ * is attached to the message root (`attachedToRoot: true`) and surfaced, **never dropped**.
157
+ */
158
+ ASTM_RECORD_ORPHAN_COMMENT: "ASTM_RECORD_ORPHAN_COMMENT",
159
+ /**
160
+ * A `YYYYMMDDHHMMSS` timestamp had an odd digit run that truncates a two-digit component in half
161
+ * (e.g. a partial day/hour). The raw run is preserved and the structured value stops at the last
162
+ * **complete** component — the dangling digit is **never zero-filled into a fabricated time**.
163
+ */
164
+ ASTM_RECORD_PARTIAL_TIMESTAMP: "ASTM_RECORD_PARTIAL_TIMESTAMP",
165
+ /**
166
+ * A `Q` (request-information) record carried a request-information status code (field 13). The code
167
+ * *set* is `[OSS-derived / paywalled]` with no publicly-groundable enumeration, so the parser
168
+ * interprets **none** of them: the status is surfaced verbatim and this value-free warning flags that
169
+ * it was passed through **uninterpreted** — never mapped to a guessed meaning.
170
+ */
171
+ ASTM_RECORD_UNINTERPRETED_QUERY_STATUS: "ASTM_RECORD_UNINTERPRETED_QUERY_STATUS",
172
+ /**
173
+ * A message carried **both** a `Q` (request) and an `R` (result) record — a contradictory shape. The
174
+ * message is classified `host-query` (the `Q` **dominates**, so it is never read as a result set) and
175
+ * this warning flags the anomaly. Positional context only; no field value.
176
+ */
177
+ ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND: "ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND",
178
+ /**
179
+ * The downgraded form an active vendor {@link AstmProfile} produces from a deviation it *expects*
180
+ * (see `src/profiles/`). The original warning is **never dropped**: its code moves to
181
+ * {@link AstmRecordWarning.toleratedCode}, the warning is re-badged `PROFILE_QUIRK_APPLIED` with
182
+ * `expected: true` and the tolerating profile named, so a consumer can filter known, grounded noise
183
+ * while the fact of the deviation — and where it was — survives. A profile can only ever reach this
184
+ * path for a **non-safety-critical** code (enforced at profile-definition time); a safety-critical
185
+ * deviation (a result value, flag, status, patient identifier, code system, or a frame-integrity
186
+ * warning) can **never** be tolerated, so it can never be re-badged here.
187
+ */
188
+ PROFILE_QUIRK_APPLIED: "PROFILE_QUIRK_APPLIED"
189
+ };
190
+ function unknownRecordType(position) {
191
+ return {
192
+ code: WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
193
+ message: "Unrecognized record type \u2014 surfaced verbatim as an unsupported record.",
194
+ position
195
+ };
196
+ }
197
+ function nonStandardDelimiters(position) {
198
+ return {
199
+ code: WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
200
+ message: "Header declared non-canonical delimiters \u2014 read from the header and honored.",
201
+ position
202
+ };
203
+ }
204
+ function unknownEscapeSequence(position) {
205
+ return {
206
+ code: WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
207
+ message: "Unrecognized escape sequence preserved verbatim.",
208
+ position
209
+ };
210
+ }
211
+ function ambiguousValueSplit(position) {
212
+ return {
213
+ code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_VALUE_SPLIT,
214
+ message: "Result value contained an unescaped component delimiter \u2014 full raw value and split both surfaced.",
215
+ position
216
+ };
217
+ }
218
+ function undefinedAbnormalFlag(position) {
219
+ return {
220
+ code: WARNING_CODES.ASTM_RECORD_UNDEFINED_ABNORMAL_FLAG,
221
+ message: "Abnormal flag is not in HL7 Table 0078 \u2014 surfaced as undefined, never as normal.",
222
+ position
223
+ };
224
+ }
225
+ function undefinedResultStatus(position) {
226
+ return {
227
+ code: WARNING_CODES.ASTM_RECORD_UNDEFINED_RESULT_STATUS,
228
+ message: "Result status is not a recognized status letter \u2014 surfaced as undefined.",
229
+ position
230
+ };
231
+ }
232
+ function unparseableReferenceRange(position) {
233
+ return {
234
+ code: WARNING_CODES.ASTM_RECORD_UNPARSEABLE_REFERENCE_RANGE,
235
+ message: "Reference range did not match a recognized form \u2014 surfaced verbatim, no bound invented.",
236
+ position
237
+ };
238
+ }
239
+ function unitsAbsent(position) {
240
+ return {
241
+ code: WARNING_CODES.ASTM_RECORD_UNITS_ABSENT,
242
+ message: "Numeric result value carried no units \u2014 never defaulted, guessed, or converted.",
243
+ position
244
+ };
245
+ }
246
+ function orphanComment(position) {
247
+ return {
248
+ code: WARNING_CODES.ASTM_RECORD_ORPHAN_COMMENT,
249
+ message: "Comment had no valid preceding parent \u2014 attached to the message root, never dropped.",
250
+ position
251
+ };
252
+ }
253
+ function partialTimestamp(position) {
254
+ return {
255
+ code: WARNING_CODES.ASTM_RECORD_PARTIAL_TIMESTAMP,
256
+ message: "Timestamp digit run truncates a component \u2014 preserved verbatim, never zero-filled.",
257
+ position
258
+ };
259
+ }
260
+ function uninterpretedQueryStatus(position) {
261
+ return {
262
+ code: WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS,
263
+ message: "Query request-information status surfaced verbatim \u2014 code set paywalled, never interpreted.",
264
+ position
265
+ };
266
+ }
267
+ function ambiguousMessageKind(position) {
268
+ return {
269
+ code: WARNING_CODES.ASTM_RECORD_AMBIGUOUS_MESSAGE_KIND,
270
+ message: "Message carried both a Q (request) and an R (result) record \u2014 classified host-query; Q dominates.",
271
+ position
272
+ };
273
+ }
274
+ function profileQuirkApplied(original, profileName) {
275
+ return {
276
+ code: WARNING_CODES.PROFILE_QUIRK_APPLIED,
277
+ message: `Profile "${profileName}" expected ${original.code}: ${original.message}`,
278
+ position: original.position,
279
+ expected: true,
280
+ profile: profileName,
281
+ toleratedCode: original.code
282
+ };
283
+ }
284
+
285
+ // src/records/result-semantics.ts
286
+ var ABNORMAL_FLAG_MEANINGS = {
287
+ L: "below-normal",
288
+ H: "above-normal",
289
+ LL: "critically-below-normal",
290
+ HH: "critically-above-normal",
291
+ "<": "below-scale",
292
+ ">": "above-scale",
293
+ N: "normal",
294
+ A: "abnormal",
295
+ AA: "very-abnormal",
296
+ U: "significant-change-up",
297
+ D: "significant-change-down",
298
+ B: "better",
299
+ W: "worse",
300
+ S: "susceptible",
301
+ R: "resistant",
302
+ I: "intermediate"
303
+ };
304
+ function interpretAbnormalFlag(raw) {
305
+ const key = raw.trim();
306
+ if (isAbnormalFlagCode(key)) {
307
+ return { raw, code: key, meaning: ABNORMAL_FLAG_MEANINGS[key], recognized: true };
308
+ }
309
+ return { raw, meaning: "undefined", recognized: false };
310
+ }
311
+ function isAbnormalFlagCode(value) {
312
+ return Object.prototype.hasOwnProperty.call(ABNORMAL_FLAG_MEANINGS, value);
313
+ }
314
+ var RESULT_STATUS_MEANINGS = {
315
+ F: "final",
316
+ C: "correction",
317
+ P: "preliminary",
318
+ R: "previously-transmitted",
319
+ S: "partial",
320
+ I: "pending",
321
+ X: "cancelled"
322
+ };
323
+ function interpretResultStatus(raw) {
324
+ if (raw === void 0 || raw.trim().length === 0) {
325
+ return {
326
+ meaning: "unspecified",
327
+ recognized: false,
328
+ isActiveFinal: false,
329
+ supersedes: false,
330
+ cancelled: false
331
+ };
332
+ }
333
+ const key = raw.trim();
334
+ if (isResultStatusCode(key)) {
335
+ return {
336
+ raw,
337
+ code: key,
338
+ meaning: RESULT_STATUS_MEANINGS[key],
339
+ recognized: true,
340
+ isActiveFinal: key === "F",
341
+ supersedes: key === "C",
342
+ cancelled: key === "X"
343
+ };
344
+ }
345
+ return {
346
+ raw,
347
+ meaning: "undefined",
348
+ recognized: false,
349
+ isActiveFinal: false,
350
+ supersedes: false,
351
+ cancelled: false
352
+ };
353
+ }
354
+ function isResultStatusCode(value) {
355
+ return Object.prototype.hasOwnProperty.call(RESULT_STATUS_MEANINGS, value);
356
+ }
357
+ var NUMBER = "-?\\d+(?:\\.\\d+)?";
358
+ var OPEN_LOW = new RegExp(`^<\\s*(${NUMBER})$`, "u");
359
+ var OPEN_HIGH = new RegExp(`^>\\s*(${NUMBER})$`, "u");
360
+ var CLOSED = new RegExp(`^(${NUMBER})\\s*-\\s*(${NUMBER})$`, "u");
361
+ function parseReferenceRange(raw) {
362
+ const text = raw.trim();
363
+ const low = OPEN_LOW.exec(text);
364
+ if (low?.[1] !== void 0) return { raw, high: low[1], kind: "open-low" };
365
+ const high = OPEN_HIGH.exec(text);
366
+ if (high?.[1] !== void 0) return { raw, low: high[1], kind: "open-high" };
367
+ const closed = CLOSED.exec(text);
368
+ if (closed?.[1] !== void 0 && closed[2] !== void 0) {
369
+ return { raw, low: closed[1], high: closed[2], kind: "closed" };
370
+ }
371
+ return { raw, kind: "unparsed" };
372
+ }
373
+
374
+ // src/records/host-query.ts
375
+ function classifyMessage(records) {
376
+ let hasQuery = false;
377
+ let hasResults = false;
378
+ let hasOrders = false;
379
+ for (const r of records) {
380
+ if (r.type === "Q") hasQuery = true;
381
+ else if (r.type === "R") hasResults = true;
382
+ else if (r.type === "O") hasOrders = true;
383
+ }
384
+ let kind;
385
+ if (hasQuery) kind = "host-query";
386
+ else if (hasResults) kind = "results";
387
+ else if (hasOrders) kind = "orders";
388
+ else kind = "indeterminate";
389
+ return { kind, hasQuery, hasResults, hasOrders, isHostQueryRequest: kind === "host-query" };
390
+ }
391
+
392
+ // src/profiles/apply.ts
393
+ function toleranceApplies(tolerance, warning) {
394
+ if (tolerance.code !== warning.code) return false;
395
+ const match = tolerance.match;
396
+ if (match === void 0) return true;
397
+ if (match.recordType !== void 0 && warning.position.recordType !== match.recordType) {
398
+ return false;
399
+ }
400
+ if (match.fieldIndex !== void 0 && warning.position.fieldIndex !== match.fieldIndex) {
401
+ return false;
402
+ }
403
+ return true;
404
+ }
405
+ function applyAstmProfile(profile, warning) {
406
+ if (warning.expected === true) return warning;
407
+ for (const tolerance of profile.tolerate) {
408
+ if (toleranceApplies(tolerance, warning)) {
409
+ return profileQuirkApplied(warning, profile.name);
410
+ }
411
+ }
412
+ return warning;
413
+ }
414
+ function applyAstmProfileToWarnings(warnings, profile) {
415
+ if (profile === void 0) return [...warnings];
416
+ return warnings.map((w) => applyAstmProfile(profile, w));
417
+ }
418
+ function resolveProfileTransport(profile) {
419
+ return profile?.transport;
420
+ }
421
+
422
+ // src/profiles/describe.ts
423
+ function buildDescribe(p) {
424
+ const lines = [`Profile '${p.name}'`];
425
+ if (p.description !== void 0) {
426
+ lines.push(` description: ${p.description}`);
427
+ }
428
+ lines.push(` lineage: ${p.lineage.join(" \u2192 ")}`);
429
+ if (p.transport !== void 0) {
430
+ lines.push(` transport: forces ${p.transport} framing (detection override)`);
431
+ }
432
+ if (p.provenance !== void 0) {
433
+ lines.push(` grounded in: ${p.provenance.source} (${p.provenance.reference})`);
434
+ }
435
+ if (p.tolerate.length === 0) {
436
+ lines.push(" tolerates: nothing (conservative baseline)");
437
+ } else {
438
+ lines.push(` tolerates ${String(p.tolerate.length)} quirk(s):`);
439
+ for (const t of p.tolerate) {
440
+ const scope = t.match?.recordType !== void 0 ? ` @record ${t.match.recordType}${t.match.fieldIndex !== void 0 ? `.${String(t.match.fieldIndex)}` : ""}` : t.match?.fieldIndex !== void 0 ? ` @field ${String(t.match.fieldIndex)}` : "";
441
+ lines.push(` - ${t.code}${scope}: ${t.rationale}`);
442
+ }
443
+ }
444
+ return lines.join("\n");
445
+ }
446
+
447
+ // src/profiles/merge.ts
448
+ function normaliseParents(ext) {
449
+ if (ext === void 0) return [];
450
+ if (Array.isArray(ext)) return ext;
451
+ return [ext];
452
+ }
453
+ function mergeLineage(parents, selfName) {
454
+ const seen = /* @__PURE__ */ new Set();
455
+ const out = [];
456
+ for (const p of parents) {
457
+ const parentLineage = p.lineage.length > 0 ? p.lineage : [p.name];
458
+ for (const n of parentLineage) {
459
+ if (!seen.has(n)) {
460
+ seen.add(n);
461
+ out.push(n);
462
+ }
463
+ }
464
+ }
465
+ if (!seen.has(selfName)) out.push(selfName);
466
+ return Object.freeze(out);
467
+ }
468
+ function toleranceKey(t) {
469
+ return `${t.code} ${t.match?.recordType ?? ""} ${String(t.match?.fieldIndex ?? "")}`;
470
+ }
471
+ function mergeTolerations(parents, self) {
472
+ const order = [];
473
+ const byKey = /* @__PURE__ */ new Map();
474
+ const layer = (list) => {
475
+ for (const t of list) {
476
+ const key = toleranceKey(t);
477
+ if (!byKey.has(key)) order.push(key);
478
+ byKey.set(key, t);
479
+ }
480
+ };
481
+ for (const p of parents) layer(p.tolerate);
482
+ layer(self);
483
+ return Object.freeze(order.map((k) => byKey.get(k)));
484
+ }
485
+ function mergeProvenance(parents, self) {
486
+ if (self !== void 0) return self;
487
+ for (let i = parents.length - 1; i >= 0; i--) {
488
+ const p = parents[i];
489
+ if (p?.provenance !== void 0) return p.provenance;
490
+ }
491
+ return void 0;
492
+ }
493
+ function mergeDescription(parents, self) {
494
+ if (self !== void 0) return self;
495
+ for (let i = parents.length - 1; i >= 0; i--) {
496
+ const p = parents[i];
497
+ if (p?.description !== void 0) return p.description;
498
+ }
499
+ return void 0;
500
+ }
501
+ function mergeTransport(parents, self) {
502
+ if (self !== void 0) return self;
503
+ for (let i = parents.length - 1; i >= 0; i--) {
504
+ const p = parents[i];
505
+ if (p?.transport !== void 0) return p.transport;
506
+ }
507
+ return void 0;
508
+ }
509
+
510
+ // src/profiles/errors.ts
511
+ var AstmProfileDefinitionError = class extends Error {
512
+ /** The offending profile's `name`, when it could be read. */
513
+ profileName;
514
+ /**
515
+ * Construct a new `AstmProfileDefinitionError`.
516
+ *
517
+ * @param message - A human-readable explanation of what was invalid.
518
+ * @param profileName - The offending profile's name, when known.
519
+ * @internal
520
+ */
521
+ constructor(message, profileName) {
522
+ super(message);
523
+ this.name = "AstmProfileDefinitionError";
524
+ if (profileName !== void 0) this.profileName = profileName;
525
+ }
526
+ };
527
+
528
+ // src/frames/warnings.ts
529
+ var FRAME_WARNING_CODES = {
530
+ /**
531
+ * A frame's two-hex-char checksum did not match the modulo-256 sum recomputed over its bytes. The
532
+ * frame is surfaced with `trusted: false` and its text is **never merged** into a reassembled
533
+ * record (default **warn** in lenient mode, escalated to a thrown error in `strict`). Corruption is
534
+ * never silently trusted — the "checksums are routinely not validated" claim was refuted; we
535
+ * validate.
536
+ */
537
+ ASTM_FRAME_BAD_CHECKSUM: "ASTM_FRAME_BAD_CHECKSUM",
538
+ /**
539
+ * A frame's sequence number was not the expected next value (`1 → … → 7 → 0 → …`) — a frame was
540
+ * possibly dropped. The stream is **never silently concatenated across the gap** as if contiguous;
541
+ * the in-progress record is tainted and not emitted as a clean reassembly.
542
+ */
543
+ ASTM_FRAME_SEQUENCE_GAP: "ASTM_FRAME_SEQUENCE_GAP",
544
+ /**
545
+ * A frame opened with `STX` but no valid terminator + checksum was found before the stream ended
546
+ * (or before the next `STX`), or a record's frames ended on an intermediate `ETB` with no final
547
+ * `ETX`. The partial bytes are surfaced flagged untrusted and **no partial record is invented**
548
+ * (warn in lenient, thrown in `strict`).
549
+ */
550
+ ASTM_FRAME_UNTERMINATED: "ASTM_FRAME_UNTERMINATED",
551
+ /**
552
+ * A frame's record text exceeded the 240-byte limit without a split. The frame is still surfaced
553
+ * (and, if its checksum validates, reassembled) — the deviation is flagged, not silently dropped
554
+ * (warn in lenient, thrown in `strict`).
555
+ */
556
+ ASTM_FRAME_OVERSIZE: "ASTM_FRAME_OVERSIZE"
557
+ };
558
+ function frameBadChecksum(position) {
559
+ return {
560
+ code: FRAME_WARNING_CODES.ASTM_FRAME_BAD_CHECKSUM,
561
+ message: "Frame checksum mismatch \u2014 surfaced untrusted, never merged into a record.",
562
+ position
563
+ };
564
+ }
565
+ function frameSequenceGap(position) {
566
+ return {
567
+ code: FRAME_WARNING_CODES.ASTM_FRAME_SEQUENCE_GAP,
568
+ message: "Frame number out of sequence \u2014 possible dropped frame, never silently bridged.",
569
+ position
570
+ };
571
+ }
572
+ function frameUnterminated(position) {
573
+ return {
574
+ code: FRAME_WARNING_CODES.ASTM_FRAME_UNTERMINATED,
575
+ message: "Unterminated frame \u2014 partial bytes surfaced untrusted, no partial record invented.",
576
+ position
577
+ };
578
+ }
579
+ function frameOversize(position) {
580
+ return {
581
+ code: FRAME_WARNING_CODES.ASTM_FRAME_OVERSIZE,
582
+ message: "Frame text exceeded the 240-byte limit without a split \u2014 flagged, never dropped.",
583
+ position
584
+ };
585
+ }
586
+
587
+ // src/ltp/warnings.ts
588
+ var LTP_WARNING_CODES = {
589
+ /**
590
+ * The transport detector could not tell a framed stream from a raw (unframed) one from its leading
591
+ * byte — it was neither `STX`/`ENQ` nor a bare record letter. It **defaults to framed** and warns,
592
+ * never guessing silently into data loss; a profile override forces the mode. (cobas b121 drops
593
+ * framing over TCP; cobas 4800 / Iguana retain it — both realities exist, so an unrecognizable lead
594
+ * is defaulted, not assumed.)
595
+ */
596
+ ASTM_LTP_AMBIGUOUS_TRANSPORT: "ASTM_LTP_AMBIGUOUS_TRANSPORT",
597
+ /**
598
+ * A control event arrived in a protocol state that did not expect it — e.g. an inbound `ACK`/`NAK`
599
+ * at a receiver (which sends, never receives, those), or an `ENQ` mid-transfer. The event is
600
+ * surfaced and handled defensively (an unexpected `ACK`/`NAK` is **never** read as acceptance of
601
+ * data); it never advances the transfer as if valid.
602
+ */
603
+ ASTM_LTP_UNEXPECTED_EVENT: "ASTM_LTP_UNEXPECTED_EVENT",
604
+ /**
605
+ * The receiver rejected a received frame with a `NAK` instead of accepting it — because its
606
+ * checksum failed, it was unterminated, or its frame number was out of sequence. The frame's text
607
+ * is **never** appended to the record and the transfer does **not** advance; the receiver awaits the
608
+ * sender's retransmit. This is the protocol-level face of the frame codec's fail-safe: a bad frame
609
+ * drives retransmit, not acceptance.
610
+ */
611
+ ASTM_LTP_FRAME_REJECTED: "ASTM_LTP_FRAME_REJECTED"
612
+ };
613
+ function ltpAmbiguousTransport() {
614
+ return {
615
+ code: LTP_WARNING_CODES.ASTM_LTP_AMBIGUOUS_TRANSPORT,
616
+ message: "Leading byte was neither a frame start (STX/ENQ) nor a record letter \u2014 defaulted to framed; override with a profile to force raw."
617
+ };
618
+ }
619
+ function ltpUnexpectedEvent(frameNumber) {
620
+ return {
621
+ code: LTP_WARNING_CODES.ASTM_LTP_UNEXPECTED_EVENT,
622
+ message: "Control event arrived in a state that did not expect it \u2014 handled defensively, never read as acceptance.",
623
+ ...frameNumber !== void 0 ? { frameNumber } : {}
624
+ };
625
+ }
626
+ function ltpFrameRejected(frameNumber) {
627
+ return {
628
+ code: LTP_WARNING_CODES.ASTM_LTP_FRAME_REJECTED,
629
+ message: "Frame rejected (bad checksum, unterminated, or out of sequence) \u2014 NAK sent, retransmit expected, never accepted.",
630
+ ...frameNumber !== void 0 ? { frameNumber } : {}
631
+ };
632
+ }
633
+
634
+ // src/profiles/safety.ts
635
+ var TOLERABLE_CODES = Object.freeze(
636
+ /* @__PURE__ */ new Set([
637
+ WARNING_CODES.ASTM_RECORD_UNKNOWN_TYPE,
638
+ WARNING_CODES.ASTM_NONSTANDARD_DELIMITERS,
639
+ WARNING_CODES.ASTM_UNKNOWN_ESCAPE_SEQUENCE,
640
+ WARNING_CODES.ASTM_RECORD_UNINTERPRETED_QUERY_STATUS
641
+ ])
642
+ );
643
+ var ALL_WARNING_CODES = [
644
+ ...Object.values(WARNING_CODES),
645
+ ...Object.values(FRAME_WARNING_CODES),
646
+ ...Object.values(LTP_WARNING_CODES)
647
+ ];
648
+ var ALL_ASTM_WARNING_CODES = Object.freeze(
649
+ new Set(ALL_WARNING_CODES)
650
+ );
651
+ var SAFETY_CRITICAL_CODES = Object.freeze(
652
+ new Set(ALL_WARNING_CODES.filter((c) => !TOLERABLE_CODES.has(c)))
653
+ );
654
+ function isSafetyCriticalCode(code) {
655
+ return SAFETY_CRITICAL_CODES.has(code);
656
+ }
657
+
658
+ // src/profiles/validate.ts
659
+ var KNOWN_OPTION_KEYS = [
660
+ "name",
661
+ "description",
662
+ "tolerate",
663
+ "transport",
664
+ "provenance",
665
+ "extends"
666
+ ];
667
+ var KNOWN_TRANSPORTS = ["framed", "raw"];
668
+ function levenshtein(a, b) {
669
+ if (a === b) return 0;
670
+ if (a.length === 0) return b.length;
671
+ if (b.length === 0) return a.length;
672
+ let prev = [];
673
+ for (let j = 0; j <= b.length; j++) prev.push(j);
674
+ for (let i = 1; i <= a.length; i++) {
675
+ const curr = [i];
676
+ for (let j = 1; j <= b.length; j++) {
677
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
678
+ curr.push(Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost));
679
+ }
680
+ prev = curr;
681
+ }
682
+ return prev[b.length] ?? 0;
683
+ }
684
+ function validateProfileName(opts) {
685
+ if (opts === null || opts === void 0) {
686
+ throw new AstmProfileDefinitionError(
687
+ `defineAstmProfile: options is required and must be an object. Received: ${String(opts)}.`
688
+ );
689
+ }
690
+ if (typeof opts.name !== "string") {
691
+ throw new AstmProfileDefinitionError(
692
+ `defineAstmProfile: 'name' is required and must be a non-empty string. Received: ${JSON.stringify(opts.name)}.`
693
+ );
694
+ }
695
+ if (opts.name.trim().length === 0) {
696
+ throw new AstmProfileDefinitionError(
697
+ `defineAstmProfile: 'name' is required and must be a non-empty string. Received: ${JSON.stringify(opts.name)}.`,
698
+ opts.name
699
+ );
700
+ }
701
+ }
702
+ function validateOptionKeys(opts) {
703
+ for (const key of Object.keys(opts)) {
704
+ if (KNOWN_OPTION_KEYS.includes(key)) continue;
705
+ let hint;
706
+ for (const known of KNOWN_OPTION_KEYS) {
707
+ if (levenshtein(key, known) <= 2) {
708
+ hint = known;
709
+ break;
710
+ }
711
+ }
712
+ throw new AstmProfileDefinitionError(
713
+ `Profile '${opts.name}' has unknown option key '${key}'. ` + (hint !== void 0 ? `Did you mean '${hint}'? ` : "") + `Known keys: ${KNOWN_OPTION_KEYS.join(", ")}.`,
714
+ opts.name
715
+ );
716
+ }
717
+ }
718
+ function validateTransport(opts) {
719
+ if (opts.transport === void 0) return;
720
+ if (typeof opts.transport !== "string" || !KNOWN_TRANSPORTS.includes(opts.transport)) {
721
+ throw new AstmProfileDefinitionError(
722
+ `Profile '${opts.name}' has an invalid 'transport' ${JSON.stringify(opts.transport)}. It must be one of: ${KNOWN_TRANSPORTS.join(", ")}.`,
723
+ opts.name
724
+ );
725
+ }
726
+ }
727
+ function validateTolerations(tolerate, profileName) {
728
+ for (const t of tolerate) {
729
+ if (typeof t.code !== "string" || !ALL_ASTM_WARNING_CODES.has(t.code)) {
730
+ throw new AstmProfileDefinitionError(
731
+ `Profile '${profileName}' tolerate entry has unknown warning code ${JSON.stringify(t.code)}. Only real codes from the record / frame / LTP registries may be named.`,
732
+ profileName
733
+ );
734
+ }
735
+ if (isSafetyCriticalCode(t.code)) {
736
+ throw new AstmProfileDefinitionError(
737
+ `Profile '${profileName}' may not tolerate '${t.code}' \u2014 it is a safety-critical warning code (a result value / flag / status / range / units, patient or comment context, message-kind ambiguity, code system, or a frame / LTP integrity warning). A profile quiets benign structural noise, never a deviation that could change a clinical reading or corrupt the wire \u2014 it can never make a bad checksum "ok" or a cancelled result read "final."`,
738
+ profileName
739
+ );
740
+ }
741
+ if (typeof t.rationale !== "string" || t.rationale.trim().length === 0) {
742
+ throw new AstmProfileDefinitionError(
743
+ `Profile '${profileName}' tolerate entry for '${t.code}' needs a non-empty 'rationale' documenting the real, grounded deviation it expects.`,
744
+ profileName
745
+ );
746
+ }
747
+ }
748
+ }
749
+
750
+ // src/profiles/define.ts
751
+ function defineAstmProfile(opts) {
752
+ validateProfileName(opts);
753
+ validateOptionKeys(opts);
754
+ validateTransport(opts);
755
+ const selfTolerate = opts.tolerate ?? [];
756
+ validateTolerations(selfTolerate, opts.name);
757
+ const parents = normaliseParents(opts.extends);
758
+ const lineage = mergeLineage(parents, opts.name);
759
+ const tolerate = mergeTolerations(parents, selfTolerate);
760
+ const transport = mergeTransport(parents, opts.transport);
761
+ const provenance = mergeProvenance(parents, opts.provenance);
762
+ const description = mergeDescription(parents, opts.description);
763
+ validateTolerations(tolerate, opts.name);
764
+ const profile = {
765
+ name: opts.name,
766
+ lineage,
767
+ tolerate
768
+ };
769
+ if (description !== void 0) profile.description = description;
770
+ if (transport !== void 0) profile.transport = transport;
771
+ if (provenance !== void 0) profile.provenance = provenance;
772
+ const finalised = profile;
773
+ profile.describe = () => buildDescribe(finalised);
774
+ return Object.freeze(profile);
775
+ }
776
+
777
+ // src/profiles/default.ts
778
+ var defaultProfile = defineAstmProfile({
779
+ name: "default",
780
+ description: "Conservative baseline \u2014 tolerates nothing; every deviation surfaces as a warning.",
781
+ tolerate: []
782
+ });
783
+
784
+ // src/profiles/reference-corpus.ts
785
+ var referenceCorpus = defineAstmProfile({
786
+ name: "referenceCorpus",
787
+ description: "Non-standard-escape tolerance grounded firsthand in the escape-agnostic OSS reference corpus (python-astm / senaite). Syntactic encoding noise only \u2014 the value is preserved verbatim, never a safety-critical value.",
788
+ provenance: {
789
+ source: "kxepal/python-astm codec.py (BSD) + senaite.astm / senaite.lis2a",
790
+ reference: "https://github.com/kxepal/python-astm/blob/master/astm/codec.py ; https://github.com/senaite/senaite.astm",
791
+ retrieved: "2026-07-21",
792
+ note: "Firsthand-read: decode_record/decode_component/decode_repeated_component split on raw delimiters and never un-escape &F&/&S&/&R&/&E&."
793
+ },
794
+ tolerate: [
795
+ {
796
+ code: "ASTM_UNKNOWN_ESCAPE_SEQUENCE",
797
+ rationale: "The OSS reference stack (python-astm codec.py, firsthand-verified) treats '&' as literal data and never decodes escape sequences, so streams from that ecosystem carry non-standard '&\u2026&' bodies our escape-aware tokenizer flags. The body is preserved byte-for-byte in the value; the deviation is recognizable, syntactic, and non-clinical."
798
+ }
799
+ ]
800
+ });
801
+
802
+ // src/profiles/registry.ts
803
+ var BUILT_INS = /* @__PURE__ */ new Map([
804
+ [defaultProfile.name, defaultProfile],
805
+ [referenceCorpus.name, referenceCorpus]
806
+ ]);
807
+ function getAstmProfile(name) {
808
+ return BUILT_INS.get(name);
809
+ }
810
+ function listAstmProfiles() {
811
+ return Object.freeze([...BUILT_INS.keys()]);
812
+ }
813
+ var _defaultProfile = void 0;
814
+ function setDefaultAstmProfile(profile) {
815
+ _defaultProfile = profile ?? void 0;
816
+ }
817
+ function getDefaultAstmProfile() {
818
+ return _defaultProfile;
819
+ }
820
+
821
+ // src/common/escapes.ts
822
+ function decodeEscapes(leaf, d, onUnknown) {
823
+ const esc = d.escape;
824
+ if (!leaf.includes(esc)) return leaf;
825
+ let out = "";
826
+ let i = 0;
827
+ while (i < leaf.length) {
828
+ const ch = leaf.charAt(i);
829
+ if (ch !== esc) {
830
+ out += ch;
831
+ i += 1;
832
+ continue;
833
+ }
834
+ const close = leaf.indexOf(esc, i + 1);
835
+ if (close === -1) {
836
+ out += leaf.slice(i);
837
+ break;
838
+ }
839
+ const body = leaf.slice(i + 1, close);
840
+ const replacement = escapeBody(body, d);
841
+ if (replacement === void 0) {
842
+ out += esc + body + esc;
843
+ onUnknown?.();
844
+ } else {
845
+ out += replacement;
846
+ }
847
+ i = close + 1;
848
+ }
849
+ return out;
850
+ }
851
+ function escapeBody(body, d) {
852
+ switch (body) {
853
+ case "F":
854
+ return d.field;
855
+ case "S":
856
+ return d.component;
857
+ case "R":
858
+ return d.repeat;
859
+ case "E":
860
+ return d.escape;
861
+ default:
862
+ return void 0;
863
+ }
864
+ }
865
+ function splitEscapeAware(text, delimiter, escape) {
866
+ if (text.length === 0) return [""];
867
+ const out = [];
868
+ let current = "";
869
+ let i = 0;
870
+ while (i < text.length) {
871
+ const ch = text.charAt(i);
872
+ if (ch === escape) {
873
+ const close = text.indexOf(escape, i + 1);
874
+ if (close === -1) {
875
+ current += text.slice(i);
876
+ break;
877
+ }
878
+ current += text.slice(i, close + 1);
879
+ i = close + 1;
880
+ continue;
881
+ }
882
+ if (ch === delimiter) {
883
+ out.push(current);
884
+ current = "";
885
+ i += 1;
886
+ continue;
887
+ }
888
+ current += ch;
889
+ i += 1;
890
+ }
891
+ out.push(current);
892
+ return out;
893
+ }
894
+
895
+ // src/records/tokenize.ts
896
+ function tokenizeRecord(record, d, onUnknownEscape) {
897
+ const rawFields = splitEscapeAware(record, d.field, d.escape);
898
+ return rawFields.map((raw, fieldIndex) => toField(raw, d, () => onUnknownEscape?.(fieldIndex)));
899
+ }
900
+ function toField(raw, d, onUnknownEscape) {
901
+ const rawRepeats = splitEscapeAware(raw, d.repeat, d.escape);
902
+ const repeats = rawRepeats.map(
903
+ (rep) => splitEscapeAware(rep, d.component, d.escape).map(
904
+ (comp) => decodeEscapes(comp, d, onUnknownEscape)
905
+ )
906
+ );
907
+ const components = repeats[0] ?? [""];
908
+ return { raw, components, repeats };
909
+ }
910
+ function fieldScalar(field) {
911
+ if (field === void 0) return void 0;
912
+ const first = field.components[0];
913
+ return first !== void 0 && first.length > 0 ? first : void 0;
914
+ }
915
+
916
+ // src/records/parse.ts
917
+ var AstmStrictError = class extends Error {
918
+ warnings;
919
+ /** @internal */
920
+ constructor(warnings) {
921
+ super(`Strict mode: ${String(warnings.length)} tolerated deviation(s) rejected.`);
922
+ this.name = "AstmStrictError";
923
+ this.warnings = warnings;
924
+ }
925
+ };
926
+ function parseAstmRecords(raw, options = {}) {
927
+ const text = typeof raw === "string" ? raw : decodeBytes(raw);
928
+ if (text.trim().length === 0) {
929
+ throw new AstmParseError(FATAL_CODES.EMPTY_INPUT, "Input is empty.", { recordIndex: 0 });
930
+ }
931
+ const lines = text.split(/\r\n|\r|\n/u).filter((l) => l.length > 0);
932
+ const first = lines[0];
933
+ if (first === void 0 || first.charAt(0) !== "H") {
934
+ const leadType = first?.charAt(0);
935
+ throw new AstmParseError(
936
+ FATAL_CODES.ASTM_RECORD_NO_HEADER,
937
+ "First record is not an H (header) record.",
938
+ leadType !== void 0 && leadType.length > 0 ? { recordIndex: 0, recordType: leadType } : { recordIndex: 0 }
939
+ );
940
+ }
941
+ const delimiters = readDelimiters(first);
942
+ if (delimiters === void 0) {
943
+ throw new AstmParseError(
944
+ FATAL_CODES.ASTM_RECORD_UNDECLARED_DELIMITERS,
945
+ "Header record is too short to declare the four delimiters.",
946
+ { recordIndex: 0, recordType: "H" }
947
+ );
948
+ }
949
+ const warnings = [];
950
+ if (isNonStandard(delimiters)) {
951
+ warnings.push(nonStandardDelimiters({ recordIndex: 0, recordType: "H" }));
952
+ }
953
+ const built = lines.map(
954
+ (line2, recordIndex) => buildRecord(line2, recordIndex, delimiters, warnings)
955
+ );
956
+ const records = attachComments(built, warnings);
957
+ const classification = classifyMessage(records);
958
+ if (classification.hasQuery && classification.hasResults) {
959
+ warnings.push(ambiguousMessageKind({ recordIndex: 0, recordType: "H" }));
960
+ }
961
+ const profile = resolveProfile(options);
962
+ const finalWarnings = applyAstmProfileToWarnings(warnings, profile);
963
+ if (options.strict === true) {
964
+ const escalating = finalWarnings.filter((w) => w.expected !== true);
965
+ if (escalating.length > 0) {
966
+ throw new AstmStrictError(escalating);
967
+ }
968
+ }
969
+ const header = records[0];
970
+ const message = profile !== void 0 ? {
971
+ header,
972
+ records,
973
+ delimiters,
974
+ classification,
975
+ warnings: finalWarnings,
976
+ profile: { name: profile.name, lineage: profile.lineage }
977
+ } : { header, records, delimiters, classification, warnings: finalWarnings };
978
+ return deepFreeze(message);
979
+ }
980
+ function resolveProfile(options) {
981
+ if (options.profile === null) return void 0;
982
+ if (options.profile !== void 0) return options.profile;
983
+ return getDefaultAstmProfile();
984
+ }
985
+ function decodeBytes(bytes) {
986
+ let out = "";
987
+ for (const b of bytes) out += String.fromCharCode(b);
988
+ return out;
989
+ }
990
+ function astmField(fields, n) {
991
+ return fields[n - 1];
992
+ }
993
+ function buildRecord(line2, recordIndex, d, warnings) {
994
+ const rawType = line2.charAt(0);
995
+ const fields = tokenizeRecord(line2, d, (fieldIndex) => {
996
+ warnings.push(
997
+ unknownEscapeSequence({ recordIndex, recordType: rawType, fieldIndex: fieldIndex + 1 })
998
+ );
999
+ });
1000
+ switch (rawType) {
1001
+ case "H":
1002
+ return {
1003
+ type: "H",
1004
+ recordIndex,
1005
+ fields,
1006
+ delimiters: d,
1007
+ rawLine: line2
1008
+ };
1009
+ case "P":
1010
+ return buildPatient(recordIndex, fields, warnings);
1011
+ case "O":
1012
+ return buildOrder(recordIndex, fields);
1013
+ case "R":
1014
+ return buildResult(recordIndex, fields, warnings);
1015
+ case "C":
1016
+ return buildComment(recordIndex, fields);
1017
+ case "Q":
1018
+ return buildQuery(recordIndex, fields, warnings);
1019
+ // `M` (manufacturer) and `S` (scientific) are vendor-defined free-form data surfaced VERBATIM and
1020
+ // NEVER interpreted into typed clinical fields — the exact wire line is preserved for round-trip.
1021
+ case "M":
1022
+ return { type: "M", recordIndex, fields, rawLine: line2 };
1023
+ case "S":
1024
+ return { type: "S", recordIndex, fields, rawLine: line2 };
1025
+ case "L":
1026
+ return { type: "L", recordIndex, fields };
1027
+ default: {
1028
+ warnings.push(unknownRecordType({ recordIndex, recordType: rawType }));
1029
+ return { type: "unsupported", rawType, recordIndex, fields };
1030
+ }
1031
+ }
1032
+ }
1033
+ function buildPatient(recordIndex, fields, warnings) {
1034
+ const nameField = astmField(fields, 6);
1035
+ const name = nameField !== void 0 && fieldScalar(nameField) !== void 0 ? {
1036
+ raw: nameField.raw,
1037
+ ...definedString("last", nameField.components[0]),
1038
+ ...definedString("first", nameField.components[1]),
1039
+ ...definedString("middle", nameField.components[2])
1040
+ } : void 0;
1041
+ const birthDate = parseDateField(
1042
+ fieldScalar(astmField(fields, 8)),
1043
+ recordIndex,
1044
+ "P",
1045
+ 8,
1046
+ warnings
1047
+ );
1048
+ return {
1049
+ type: "P",
1050
+ recordIndex,
1051
+ fields,
1052
+ ...definedString("seq", fieldScalar(astmField(fields, 2))),
1053
+ // The three patient identifiers stay DISTINCT — practice-assigned (3), laboratory-assigned (4),
1054
+ // and a third ID (5) never collapse into one; conflating them is the primary misfiling path.
1055
+ ...definedString("practiceAssignedId", fieldScalar(astmField(fields, 3))),
1056
+ ...definedString("laboratoryAssignedId", fieldScalar(astmField(fields, 4))),
1057
+ ...definedString("patientIdThree", fieldScalar(astmField(fields, 5))),
1058
+ ...name !== void 0 ? { name } : {},
1059
+ ...definedString("mothersMaidenName", fieldScalar(astmField(fields, 7))),
1060
+ ...birthDate !== void 0 ? { birthDate } : {},
1061
+ ...definedString("sex", fieldScalar(astmField(fields, 9)))
1062
+ };
1063
+ }
1064
+ function buildOrder(recordIndex, fields) {
1065
+ const utidField = astmField(fields, 5);
1066
+ return {
1067
+ type: "O",
1068
+ recordIndex,
1069
+ fields,
1070
+ ...definedString("seq", fieldScalar(astmField(fields, 2))),
1071
+ ...definedString("specimenId", fieldScalar(astmField(fields, 3))),
1072
+ ...definedString("instrumentSpecimenId", fieldScalar(astmField(fields, 4))),
1073
+ ...hasContent(utidField) ? { universalTestId: recognizeUniversalTestId(utidField.components) } : {},
1074
+ // Priority (6), action code (~12), and report type (~26) are surfaced verbatim. The `~` field
1075
+ // indices and the code sets are `[OSS-derived]` (paywalled), so they are never mapped to a
1076
+ // guessed meaning — see the JSDoc on `OrderRecord`.
1077
+ ...definedString("priority", fieldScalar(astmField(fields, 6))),
1078
+ ...definedString("actionCode", fieldScalar(astmField(fields, 12))),
1079
+ ...definedString("reportType", fieldScalar(astmField(fields, 26)))
1080
+ };
1081
+ }
1082
+ function hasContent(field) {
1083
+ return field !== void 0 && field.raw.length > 0;
1084
+ }
1085
+ function buildResult(recordIndex, fields, warnings) {
1086
+ const utidField = astmField(fields, 3);
1087
+ const valueField = astmField(fields, 4);
1088
+ const startedAt = parseDateField(
1089
+ fieldScalar(astmField(fields, 12)),
1090
+ recordIndex,
1091
+ "R",
1092
+ 12,
1093
+ warnings
1094
+ );
1095
+ const completedAt = parseDateField(
1096
+ fieldScalar(astmField(fields, 13)),
1097
+ recordIndex,
1098
+ "R",
1099
+ 13,
1100
+ warnings
1101
+ );
1102
+ const ambiguous = valueField !== void 0 && valueField.components.length > 1;
1103
+ const valueComponents = ambiguous ? valueField.components : void 0;
1104
+ const value = ambiguous ? valueField.raw : fieldScalar(valueField);
1105
+ if (ambiguous) {
1106
+ warnings.push(ambiguousValueSplit({ recordIndex, recordType: "R", fieldIndex: 4 }));
1107
+ }
1108
+ const units = fieldScalar(astmField(fields, 5));
1109
+ const rangeRaw = fieldRaw(astmField(fields, 6));
1110
+ const flagRaw = fieldScalar(astmField(fields, 7));
1111
+ const statusRaw = fieldScalar(astmField(fields, 9));
1112
+ if (units === void 0 && value !== void 0 && isNumericValue(value)) {
1113
+ warnings.push(unitsAbsent({ recordIndex, recordType: "R", fieldIndex: 5 }));
1114
+ }
1115
+ const range = rangeRaw !== void 0 ? parseReferenceRange(rangeRaw) : void 0;
1116
+ if (range?.kind === "unparsed") {
1117
+ warnings.push(unparseableReferenceRange({ recordIndex, recordType: "R", fieldIndex: 6 }));
1118
+ }
1119
+ const flag = flagRaw !== void 0 ? interpretAbnormalFlag(flagRaw) : void 0;
1120
+ if (flag !== void 0 && !flag.recognized) {
1121
+ warnings.push(undefinedAbnormalFlag({ recordIndex, recordType: "R", fieldIndex: 7 }));
1122
+ }
1123
+ const status = interpretResultStatus(statusRaw);
1124
+ if (statusRaw !== void 0 && !status.recognized) {
1125
+ warnings.push(undefinedResultStatus({ recordIndex, recordType: "R", fieldIndex: 9 }));
1126
+ }
1127
+ return {
1128
+ type: "R",
1129
+ recordIndex,
1130
+ fields,
1131
+ ...definedString("seq", fieldScalar(astmField(fields, 2))),
1132
+ ...hasContent(utidField) ? { universalTestId: recognizeUniversalTestId(utidField.components) } : {},
1133
+ ...definedString("value", value),
1134
+ ...valueComponents !== void 0 ? { valueComponents } : {},
1135
+ ...definedString("units", units),
1136
+ ...definedString("referenceRange", rangeRaw),
1137
+ ...range !== void 0 ? { range } : {},
1138
+ ...definedString("abnormalFlags", flagRaw),
1139
+ ...flag !== void 0 ? { flag } : {},
1140
+ ...definedString("resultStatus", statusRaw),
1141
+ status,
1142
+ ...definedString("operator", fieldScalar(astmField(fields, 11))),
1143
+ ...startedAt !== void 0 ? { startedAt } : {},
1144
+ ...completedAt !== void 0 ? { completedAt } : {},
1145
+ ...definedString("instrument", fieldScalar(astmField(fields, 14)))
1146
+ };
1147
+ }
1148
+ function buildComment(recordIndex, fields) {
1149
+ const textField = astmField(fields, 4);
1150
+ const textComponents = textField !== void 0 && textField.components.length > 1 ? textField.components : void 0;
1151
+ const text = textField !== void 0 && textField.raw.length > 0 ? textField.raw : void 0;
1152
+ return {
1153
+ type: "C",
1154
+ recordIndex,
1155
+ fields,
1156
+ attachedToRoot: false,
1157
+ ...definedString("seq", fieldScalar(astmField(fields, 2))),
1158
+ ...definedString("source", fieldScalar(astmField(fields, 3))),
1159
+ ...definedString("text", text),
1160
+ ...textComponents !== void 0 ? { textComponents } : {},
1161
+ ...definedString("commentType", fieldScalar(astmField(fields, 5)))
1162
+ };
1163
+ }
1164
+ function buildQuery(recordIndex, fields, warnings) {
1165
+ const utidField = astmField(fields, 5);
1166
+ const utidRaw = fieldRaw(utidField);
1167
+ const queriesAllTests = utidRaw !== void 0 && utidRaw.trim().toUpperCase() === "ALL";
1168
+ const requestInformationStatus = fieldRaw(astmField(fields, 13));
1169
+ if (requestInformationStatus !== void 0) {
1170
+ warnings.push(uninterpretedQueryStatus({ recordIndex, recordType: "Q", fieldIndex: 13 }));
1171
+ }
1172
+ return {
1173
+ type: "Q",
1174
+ recordIndex,
1175
+ fields,
1176
+ ...definedString("seq", fieldScalar(astmField(fields, 2))),
1177
+ // Range IDs are surfaced from the FULL field text (never truncated to a component); their internal
1178
+ // caret structure is [OSS-derived]/paywalled and is not interpreted here.
1179
+ ...definedString("startingRangeId", fieldRaw(astmField(fields, 3))),
1180
+ ...definedString("endingRangeId", fieldRaw(astmField(fields, 4))),
1181
+ ...hasContent(utidField) && !queriesAllTests ? { universalTestId: recognizeUniversalTestId(utidField.components) } : {},
1182
+ queriesAllTests,
1183
+ ...definedString("requestInformationStatus", requestInformationStatus)
1184
+ };
1185
+ }
1186
+ function attachComments(records, warnings) {
1187
+ let lastParentIndex;
1188
+ return records.map((rec) => {
1189
+ if (rec.type === "H" || rec.type === "P" || rec.type === "O" || rec.type === "R") {
1190
+ lastParentIndex = rec.recordIndex;
1191
+ return rec;
1192
+ }
1193
+ if (rec.type !== "C") return rec;
1194
+ if (lastParentIndex === void 0) {
1195
+ warnings.push(orphanComment({ recordIndex: rec.recordIndex, recordType: "C" }));
1196
+ return { ...rec, attachedToRoot: true };
1197
+ }
1198
+ return { ...rec, parentIndex: lastParentIndex, attachedToRoot: false };
1199
+ });
1200
+ }
1201
+ function parseDateField(raw, recordIndex, recordType, fieldIndex, warnings) {
1202
+ if (raw === void 0) return void 0;
1203
+ const date = parseAstmDate(raw);
1204
+ if (date?.truncated === true) {
1205
+ warnings.push(partialTimestamp({ recordIndex, recordType, fieldIndex }));
1206
+ }
1207
+ return date;
1208
+ }
1209
+ function isNumericValue(value) {
1210
+ return /^\s*[<>]?\s*-?\d+(?:\.\d+)?\s*$/u.test(value);
1211
+ }
1212
+ function fieldRaw(field) {
1213
+ return field !== void 0 && field.raw.length > 0 ? field.raw : void 0;
1214
+ }
1215
+ function definedString(key, value) {
1216
+ return value === void 0 ? {} : { [key]: value };
1217
+ }
1218
+
1219
+ // src/records/serialize.ts
1220
+ var AstmSerializeError = class extends Error {
1221
+ /** Stable discriminant; `ASTM_EMIT_UNENCODABLE_VALUE` for a `CR`/`LF` in a value. */
1222
+ code;
1223
+ /** 0-based ordinal of the record within the message, when known. */
1224
+ recordIndex;
1225
+ /** @internal */
1226
+ constructor(message, recordIndex) {
1227
+ super(message);
1228
+ this.name = "AstmSerializeError";
1229
+ this.code = "ASTM_EMIT_UNENCODABLE_VALUE";
1230
+ if (recordIndex !== void 0) this.recordIndex = recordIndex;
1231
+ }
1232
+ };
1233
+ function encodeComponent(leaf, d, recordIndex) {
1234
+ if (leaf.includes("\r") || leaf.includes("\n")) {
1235
+ throw new AstmSerializeError(
1236
+ "A value contains a record terminator (CR/LF), which cannot be escaped without breaking framing.",
1237
+ recordIndex
1238
+ );
1239
+ }
1240
+ return leaf.split(d.escape).join(d.escape + "E" + d.escape).split(d.field).join(d.escape + "F" + d.escape).split(d.component).join(d.escape + "S" + d.escape).split(d.repeat).join(d.escape + "R" + d.escape);
1241
+ }
1242
+ function encodeField(repeats, d, recordIndex) {
1243
+ return repeats.map((rep) => rep.map((c) => encodeComponent(c, d, recordIndex)).join(d.component)).join(d.repeat);
1244
+ }
1245
+ function serializeAstmRecord(record, d = CANONICAL_DELIMITERS) {
1246
+ if (record.type === "M" || record.type === "S") return record.rawLine;
1247
+ if (record.type === "H") return serializeHeader(record, d);
1248
+ return record.fields.map((f) => encodeField(f.repeats, d, record.recordIndex)).join(d.field);
1249
+ }
1250
+ function serializeHeader(header, d) {
1251
+ const head = "H" + d.field + d.repeat + d.component + d.escape;
1252
+ const src = header.delimiters;
1253
+ const defEnd = header.rawLine.indexOf(src.field, 2);
1254
+ if (defEnd === -1) return head;
1255
+ const dataPortion = header.rawLine.slice(defEnd + 1);
1256
+ const dataFields = tokenizeRecord(dataPortion, src);
1257
+ const rest = dataFields.map((f) => d.field + encodeField(f.repeats, d, header.recordIndex)).join("");
1258
+ return head + rest;
1259
+ }
1260
+ function serializeAstmRecords(input, d = CANONICAL_DELIMITERS) {
1261
+ const records = Array.isArray(input) ? input : input.records;
1262
+ return records.map((r) => serializeAstmRecord(r, d) + "\r").join("");
1263
+ }
1264
+ function serializeField(field, d = CANONICAL_DELIMITERS) {
1265
+ return encodeField(field.repeats, d);
1266
+ }
1267
+
1268
+ // src/records/build.ts
1269
+ function line(fields) {
1270
+ const d = CANONICAL_DELIMITERS;
1271
+ const encoded = fields.map((comps) => comps.map((c) => encodeComponent(c, d)).join(d.component));
1272
+ let end = encoded.length;
1273
+ while (end > 0 && encoded[end - 1] === "") end -= 1;
1274
+ return encoded.slice(0, end).join(d.field);
1275
+ }
1276
+ function utid(components) {
1277
+ return components === void 0 ? [""] : [...components];
1278
+ }
1279
+ function at(fields, position1, value) {
1280
+ if (value === void 0) return;
1281
+ fields[position1 - 1] = [value];
1282
+ }
1283
+ function buildPatientLine(input, seq) {
1284
+ const f = [["P"], [seq]];
1285
+ at(f, 3, input.practiceAssignedId);
1286
+ at(f, 4, input.laboratoryAssignedId);
1287
+ at(f, 5, input.patientIdThree);
1288
+ if (input.name !== void 0) {
1289
+ f[5] = [input.name.last ?? "", input.name.first ?? "", input.name.middle ?? ""];
1290
+ }
1291
+ at(f, 7, input.mothersMaidenName);
1292
+ at(f, 8, input.birthDate);
1293
+ at(f, 9, input.sex);
1294
+ return line(padGaps(f));
1295
+ }
1296
+ function buildOrderLine(input, seq) {
1297
+ const f = [["O"], [seq]];
1298
+ at(f, 3, input.specimenId);
1299
+ at(f, 4, input.instrumentSpecimenId);
1300
+ if (input.universalTestId !== void 0) f[4] = [...utid(input.universalTestId)];
1301
+ at(f, 6, input.priority);
1302
+ at(f, 12, input.actionCode);
1303
+ at(f, 26, input.reportType);
1304
+ return line(padGaps(f));
1305
+ }
1306
+ function buildResultLine(input, seq) {
1307
+ const f = [["R"], [seq]];
1308
+ if (input.universalTestId !== void 0) f[2] = [...utid(input.universalTestId)];
1309
+ at(f, 4, input.value);
1310
+ at(f, 5, input.units);
1311
+ at(f, 6, input.referenceRange);
1312
+ at(f, 7, input.abnormalFlags);
1313
+ at(f, 9, input.resultStatus);
1314
+ at(f, 11, input.operator);
1315
+ at(f, 12, input.startedAt);
1316
+ at(f, 13, input.completedAt);
1317
+ at(f, 14, input.instrument);
1318
+ return line(padGaps(f));
1319
+ }
1320
+ function buildCommentLine(input, seq) {
1321
+ const f = [["C"], [seq]];
1322
+ at(f, 3, input.source);
1323
+ if (input.textComponents !== void 0) f[3] = [...input.textComponents];
1324
+ else at(f, 4, input.text);
1325
+ at(f, 5, input.commentType);
1326
+ return line(padGaps(f));
1327
+ }
1328
+ function buildQueryLine(input, seq) {
1329
+ const f = [["Q"], [seq]];
1330
+ at(f, 3, input.startingRangeId);
1331
+ at(f, 4, input.endingRangeId);
1332
+ if (input.queriesAllTests === true) f[4] = ["ALL"];
1333
+ else if (input.universalTestId !== void 0) f[4] = [...utid(input.universalTestId)];
1334
+ at(f, 13, input.requestInformationStatus);
1335
+ return line(padGaps(f));
1336
+ }
1337
+ function buildVerbatimLine(input, seq) {
1338
+ return [input.type, seq, ...input.fields.map(guardWireSafe)].join(CANONICAL_DELIMITERS.field);
1339
+ }
1340
+ function guardWireSafe(value) {
1341
+ if (value.includes("\r") || value.includes("\n")) {
1342
+ throw new AstmSerializeError(
1343
+ "A verbatim field contains a record terminator (CR/LF), which would break framing."
1344
+ );
1345
+ }
1346
+ return value;
1347
+ }
1348
+ function padGaps(fields) {
1349
+ const out = [];
1350
+ for (let i = 0; i < fields.length; i += 1) out[i] = fields[i] ?? [""];
1351
+ return out;
1352
+ }
1353
+ function buildAstmMessage(input) {
1354
+ const lines = [];
1355
+ const d = CANONICAL_DELIMITERS;
1356
+ const headerFields = input.header?.fields ?? [];
1357
+ const headerRest = headerFields.map((v) => d.field + guardWireSafe(v)).join("");
1358
+ lines.push("H" + d.field + d.repeat + d.component + d.escape + headerRest);
1359
+ const counters = /* @__PURE__ */ new Map();
1360
+ const nextSeq = (type, override) => {
1361
+ if (override !== void 0) return override;
1362
+ const n = (counters.get(type) ?? 0) + 1;
1363
+ counters.set(type, n);
1364
+ return String(n);
1365
+ };
1366
+ for (const rec of input.records) {
1367
+ switch (rec.type) {
1368
+ case "P":
1369
+ lines.push(buildPatientLine(rec, nextSeq("P", rec.seq)));
1370
+ break;
1371
+ case "O":
1372
+ lines.push(buildOrderLine(rec, nextSeq("O", rec.seq)));
1373
+ break;
1374
+ case "R":
1375
+ lines.push(buildResultLine(rec, nextSeq("R", rec.seq)));
1376
+ break;
1377
+ case "C":
1378
+ lines.push(buildCommentLine(rec, nextSeq("C", rec.seq)));
1379
+ break;
1380
+ case "Q":
1381
+ lines.push(buildQueryLine(rec, nextSeq("Q", rec.seq)));
1382
+ break;
1383
+ case "M":
1384
+ case "S":
1385
+ lines.push(buildVerbatimLine(rec, nextSeq(rec.type)));
1386
+ break;
1387
+ default: {
1388
+ const bad = rec;
1389
+ throw new TypeError(`Unknown ASTM record type to build: ${String(bad.type)}`);
1390
+ }
1391
+ }
1392
+ }
1393
+ const term = [["L"], ["1"]];
1394
+ if (input.terminationCode !== void 0) term[2] = [input.terminationCode];
1395
+ lines.push(line(padGaps(term)));
1396
+ return lines.map((l) => l + "\r").join("");
1397
+ }
1398
+
1399
+ // src/records/extractors.ts
1400
+ function results(msg) {
1401
+ return msg.records.filter((r) => r.type === "R");
1402
+ }
1403
+ function patient(msg) {
1404
+ return msg.records.find((r) => r.type === "P");
1405
+ }
1406
+ function orders(msg) {
1407
+ return msg.records.filter((r) => r.type === "O");
1408
+ }
1409
+ function comments(msg) {
1410
+ return msg.records.filter((r) => r.type === "C");
1411
+ }
1412
+ function commentsFor(msg, parent) {
1413
+ return msg.records.filter(
1414
+ (r) => r.type === "C" && r.parentIndex === parent.recordIndex
1415
+ );
1416
+ }
1417
+ function query(msg) {
1418
+ return msg.records.filter((r) => r.type === "Q");
1419
+ }
1420
+
1421
+ // src/profiles/index.ts
1422
+ var astmProfiles = Object.freeze({
1423
+ default: defaultProfile,
1424
+ referenceCorpus
1425
+ });
1426
+
1427
+ // src/terminology/catalog.ts
1428
+ function defineLivdCatalog(entries) {
1429
+ const index = /* @__PURE__ */ new Map();
1430
+ for (const entry of entries) {
1431
+ const bucket = index.get(entry.vendorCode);
1432
+ if (bucket) bucket.push(entry);
1433
+ else index.set(entry.vendorCode, [entry]);
1434
+ }
1435
+ const catalog = {
1436
+ size: index.size,
1437
+ lookup(vendorCode) {
1438
+ const bucket = index.get(vendorCode);
1439
+ if (!bucket || bucket.length === 0) return { status: "unmapped" };
1440
+ const distinct = [...new Set(bucket.map((e) => e.loinc))];
1441
+ if (distinct.length > 1) {
1442
+ return deepFreeze({ status: "ambiguous", candidates: distinct });
1443
+ }
1444
+ const [first] = bucket;
1445
+ if (first === void 0) return { status: "unmapped" };
1446
+ return deepFreeze({
1447
+ status: "mapped",
1448
+ loinc: first.loinc,
1449
+ ...first.loincLongName !== void 0 ? { loincLongName: first.loincLongName } : {}
1450
+ });
1451
+ }
1452
+ };
1453
+ return Object.freeze(catalog);
1454
+ }
1455
+
1456
+ // src/terminology/warnings.ts
1457
+ var LIVD_WARNING_CODES = {
1458
+ /**
1459
+ * A record carried a vendor/local test code, but the consumer-supplied LIVD catalog held **no**
1460
+ * entry for it. The code stays surfaced verbatim and the mapping is `unmapped` — a LOINC is
1461
+ * **never** guessed. Purely advisory: the raw code and value are untouched.
1462
+ */
1463
+ ASTM_LIVD_UNMAPPED_CODE: "ASTM_LIVD_UNMAPPED_CODE",
1464
+ /**
1465
+ * A vendor/local test code matched **more than one distinct LOINC** in the catalog (e.g. the same
1466
+ * transmission code used by two devices for different analytes). The mapping is `ambiguous` and the
1467
+ * candidate LOINCs are surfaced for inspection, but **none is chosen** — refusing to pick is the
1468
+ * fail-safe, since guessing wrong mis-identifies the test.
1469
+ */
1470
+ ASTM_LIVD_AMBIGUOUS_MAPPING: "ASTM_LIVD_AMBIGUOUS_MAPPING"
1471
+ };
1472
+ function livdUnmappedCode(position) {
1473
+ return {
1474
+ code: LIVD_WARNING_CODES.ASTM_LIVD_UNMAPPED_CODE,
1475
+ message: "Reported test code had no LIVD mapping \u2014 surfaced unmapped, never a guessed LOINC.",
1476
+ position
1477
+ };
1478
+ }
1479
+ function livdAmbiguousMapping(position) {
1480
+ return {
1481
+ code: LIVD_WARNING_CODES.ASTM_LIVD_AMBIGUOUS_MAPPING,
1482
+ message: "Reported test code mapped to multiple distinct LOINCs \u2014 surfaced ambiguous, never one guessed.",
1483
+ position
1484
+ };
1485
+ }
1486
+
1487
+ // src/terminology/apply.ts
1488
+ function mapTestId(uid, catalog) {
1489
+ if (uid.loincCandidate !== void 0) {
1490
+ return { status: "inline-loinc", loinc: uid.loincCandidate, source: "wire" };
1491
+ }
1492
+ const code = primaryCode(uid);
1493
+ if (code === void 0) return { status: "no-code" };
1494
+ const hit = catalog.lookup(code);
1495
+ switch (hit.status) {
1496
+ case "mapped":
1497
+ return {
1498
+ status: "mapped",
1499
+ loinc: hit.loinc,
1500
+ ...hit.loincLongName !== void 0 ? { loincLongName: hit.loincLongName } : {},
1501
+ source: "livd",
1502
+ derived: true
1503
+ };
1504
+ case "ambiguous":
1505
+ return { status: "ambiguous", candidates: hit.candidates };
1506
+ case "unmapped":
1507
+ return { status: "unmapped" };
1508
+ }
1509
+ }
1510
+ function testIdOf(record) {
1511
+ return record.universalTestId;
1512
+ }
1513
+ function lookupLivdForRecord(record, catalog) {
1514
+ const uid = testIdOf(record);
1515
+ const recognized = uid ?? recognizeUniversalTestId([]);
1516
+ const mapping = mapTestId(recognized, catalog);
1517
+ const reportedCode = primaryCode(recognized);
1518
+ return {
1519
+ recordIndex: record.recordIndex,
1520
+ recordType: record.type,
1521
+ ...reportedCode !== void 0 ? { reportedCode } : {},
1522
+ provenance: recognized.provenance,
1523
+ mapping
1524
+ };
1525
+ }
1526
+ function applyLivd(msg, catalog) {
1527
+ const annotations = [];
1528
+ const warnings = [];
1529
+ for (const record of msg.records) {
1530
+ if (record.type !== "R" && record.type !== "O") continue;
1531
+ const annotation = lookupLivdForRecord(record, catalog);
1532
+ annotations.push(annotation);
1533
+ const position = { recordIndex: record.recordIndex, recordType: record.type };
1534
+ if (annotation.mapping.status === "unmapped") {
1535
+ warnings.push(livdUnmappedCode(position));
1536
+ } else if (annotation.mapping.status === "ambiguous") {
1537
+ warnings.push(livdAmbiguousMapping(position));
1538
+ }
1539
+ }
1540
+ return deepFreeze({ annotations, warnings });
1541
+ }
1542
+
1543
+ // src/ltp/constants.ts
1544
+ var ENQ = 5;
1545
+ var ACK = 6;
1546
+ var NAK = 21;
1547
+ var EOT = 4;
1548
+ var RECORD_TYPE_LETTERS = new Set(
1549
+ [..."HPORCQMSL"].map((c) => c.charCodeAt(0))
1550
+ );
1551
+
1552
+ // src/frames/constants.ts
1553
+ var STX = 2;
1554
+ var ETX = 3;
1555
+ var ETB = 23;
1556
+ var CR = 13;
1557
+ var LF = 10;
1558
+ var FN_ZERO = 48;
1559
+ var FN_SEVEN = 55;
1560
+ var MAX_FRAME_TEXT = 240;
1561
+ var FRAME_NUMBER_MODULUS = 8;
1562
+ var FIRST_FRAME_NUMBER = 1;
1563
+
1564
+ // src/ltp/transport.ts
1565
+ function detectFraming(bytes, options = {}) {
1566
+ if (options.override !== void 0) {
1567
+ return { framing: options.override, defaulted: false, warnings: [] };
1568
+ }
1569
+ const lead = bytes[0];
1570
+ if (lead === STX || lead === ENQ) {
1571
+ return { framing: "framed", defaulted: false, warnings: [] };
1572
+ }
1573
+ if (lead !== void 0 && RECORD_TYPE_LETTERS.has(lead)) {
1574
+ return { framing: "raw", defaulted: false, warnings: [] };
1575
+ }
1576
+ return { framing: "framed", defaulted: true, warnings: [ltpAmbiguousTransport()] };
1577
+ }
1578
+
1579
+ // src/ltp/reducer.ts
1580
+ var EMPTY = new Uint8Array(0);
1581
+ function ltpInitialState() {
1582
+ return freezeState({
1583
+ phase: "neutral",
1584
+ expectedFrame: FIRST_FRAME_NUMBER,
1585
+ records: [],
1586
+ recordOpen: false,
1587
+ openRecord: EMPTY
1588
+ });
1589
+ }
1590
+ function ltpReduce(state, event) {
1591
+ switch (event.type) {
1592
+ case "enq":
1593
+ return establish(state);
1594
+ case "eot":
1595
+ return terminate(state);
1596
+ case "ack":
1597
+ case "nak":
1598
+ return { state, actions: [], warnings: [ltpUnexpectedEvent()] };
1599
+ case "frame":
1600
+ return onFrame(state, event.frame);
1601
+ }
1602
+ }
1603
+ function establish(state) {
1604
+ const warnings = state.phase === "transfer" ? [ltpUnexpectedEvent()] : [];
1605
+ const next = freezeState({
1606
+ phase: "transfer",
1607
+ expectedFrame: FIRST_FRAME_NUMBER,
1608
+ records: state.records,
1609
+ recordOpen: false,
1610
+ openRecord: EMPTY
1611
+ });
1612
+ return { state: next, actions: [{ type: "sendAck" }], warnings };
1613
+ }
1614
+ function terminate(state) {
1615
+ const next = freezeState({
1616
+ phase: "neutral",
1617
+ expectedFrame: FIRST_FRAME_NUMBER,
1618
+ records: state.records,
1619
+ recordOpen: false,
1620
+ openRecord: EMPTY
1621
+ });
1622
+ return { state: next, actions: [], warnings: [] };
1623
+ }
1624
+ function onFrame(state, frame) {
1625
+ if (state.phase === "neutral") {
1626
+ const established = freezeState({
1627
+ phase: "transfer",
1628
+ expectedFrame: FIRST_FRAME_NUMBER,
1629
+ records: state.records,
1630
+ recordOpen: false,
1631
+ openRecord: EMPTY
1632
+ });
1633
+ const step = processTransferFrame(established, frame);
1634
+ return {
1635
+ state: step.state,
1636
+ actions: step.actions,
1637
+ warnings: [ltpUnexpectedEvent(frame.frameNumber), ...step.warnings]
1638
+ };
1639
+ }
1640
+ return processTransferFrame(state, frame);
1641
+ }
1642
+ function processTransferFrame(state, frame) {
1643
+ if (!frame.trusted) {
1644
+ return reject(state, frame.frameNumber);
1645
+ }
1646
+ const fn = frame.frameNumber;
1647
+ if (fn === void 0) {
1648
+ return reject(state, void 0);
1649
+ }
1650
+ if (state.lastAcceptedFrame !== void 0 && fn === state.lastAcceptedFrame) {
1651
+ return { state, actions: [{ type: "sendAck" }], warnings: [] };
1652
+ }
1653
+ if (fn !== state.expectedFrame) {
1654
+ return reject(state, fn);
1655
+ }
1656
+ return acceptFrame(state, frame, fn);
1657
+ }
1658
+ function acceptFrame(state, frame, fn) {
1659
+ const nextExpected = (fn + 1) % FRAME_NUMBER_MODULUS;
1660
+ const accumulated = concatBytes([state.openRecord, frame.text]);
1661
+ const actions = [{ type: "sendAck" }];
1662
+ if (frame.terminator === "ETX") {
1663
+ actions.push({ type: "deliverRecord", record: accumulated.slice() });
1664
+ const next2 = freezeState({
1665
+ phase: "transfer",
1666
+ expectedFrame: nextExpected,
1667
+ lastAcceptedFrame: fn,
1668
+ records: [...state.records, accumulated],
1669
+ recordOpen: false,
1670
+ openRecord: EMPTY
1671
+ });
1672
+ return { state: next2, actions, warnings: [] };
1673
+ }
1674
+ const next = freezeState({
1675
+ phase: "transfer",
1676
+ expectedFrame: nextExpected,
1677
+ lastAcceptedFrame: fn,
1678
+ records: state.records,
1679
+ recordOpen: true,
1680
+ openRecord: accumulated
1681
+ });
1682
+ return { state: next, actions, warnings: [] };
1683
+ }
1684
+ function reject(state, frameNumber) {
1685
+ return {
1686
+ state,
1687
+ actions: [{ type: "sendNak" }],
1688
+ warnings: [ltpFrameRejected(frameNumber)]
1689
+ };
1690
+ }
1691
+ function freezeState(fields) {
1692
+ const state = {
1693
+ phase: fields.phase,
1694
+ expectedFrame: fields.expectedFrame,
1695
+ ...fields.lastAcceptedFrame !== void 0 ? { lastAcceptedFrame: fields.lastAcceptedFrame } : {},
1696
+ records: Object.freeze([...fields.records]),
1697
+ recordOpen: fields.recordOpen,
1698
+ openRecord: fields.openRecord
1699
+ };
1700
+ return Object.freeze(state);
1701
+ }
1702
+ function concatBytes(chunks) {
1703
+ let total = 0;
1704
+ for (const c of chunks) total += c.length;
1705
+ const out = new Uint8Array(total);
1706
+ let at2 = 0;
1707
+ for (const c of chunks) {
1708
+ out.set(c, at2);
1709
+ at2 += c.length;
1710
+ }
1711
+ return out;
1712
+ }
1713
+
1714
+ // src/frames/checksum.ts
1715
+ function computeChecksum(bytes, start, endInclusive) {
1716
+ let sum = 0;
1717
+ for (let i = start; i <= endInclusive; i++) {
1718
+ sum = sum + (bytes[i] ?? 0) & 255;
1719
+ }
1720
+ return sum;
1721
+ }
1722
+ function toChecksumHex(checksum) {
1723
+ return (checksum & 255).toString(16).toUpperCase().padStart(2, "0");
1724
+ }
1725
+ function hexNibble(code) {
1726
+ if (code === void 0) return void 0;
1727
+ if (code >= 48 && code <= 57) return code - 48;
1728
+ if (code >= 65 && code <= 70) return code - 65 + 10;
1729
+ if (code >= 97 && code <= 102) return code - 97 + 10;
1730
+ return void 0;
1731
+ }
1732
+ function parseChecksumHex(bytes, i0, i1) {
1733
+ const hi = hexNibble(bytes[i0]);
1734
+ const lo = hexNibble(bytes[i1]);
1735
+ if (hi === void 0 || lo === void 0) return void 0;
1736
+ return hi << 4 | lo;
1737
+ }
1738
+
1739
+ // src/frames/errors.ts
1740
+ var AstmFrameStrictError = class extends Error {
1741
+ warnings;
1742
+ /** @internal */
1743
+ constructor(warnings) {
1744
+ super(`Strict mode: ${String(warnings.length)} frame deviation(s) rejected.`);
1745
+ this.name = "AstmFrameStrictError";
1746
+ this.warnings = warnings;
1747
+ }
1748
+ };
1749
+
1750
+ // src/frames/decode.ts
1751
+ function decodeAstmFrames(bytes, options = {}) {
1752
+ if (bytes.length === 0) {
1753
+ throw new AstmParseError(FATAL_CODES.EMPTY_INPUT, "Framed input is empty.", { recordIndex: 0 });
1754
+ }
1755
+ const frames = [];
1756
+ const records = [];
1757
+ const warnings = [];
1758
+ let recordChunks = [];
1759
+ let recordTainted = false;
1760
+ let recordOpen = false;
1761
+ let expected = FIRST_FRAME_NUMBER;
1762
+ const n = bytes.length;
1763
+ let i = 0;
1764
+ while (i < n) {
1765
+ if (bytes[i] !== STX) {
1766
+ i++;
1767
+ continue;
1768
+ }
1769
+ const stxOffset = i;
1770
+ const fnIndex = i + 1;
1771
+ const frameNumber = fnIndex < n ? readFrameNumber(bytes[fnIndex]) : void 0;
1772
+ let termIndex = -1;
1773
+ let oversize = false;
1774
+ for (let k = i + 2; k < n; k++) {
1775
+ const b = bytes[k];
1776
+ if (b === ETB || b === ETX) {
1777
+ termIndex = k;
1778
+ break;
1779
+ }
1780
+ if (b === STX) break;
1781
+ if (k - (i + 2) + 1 > MAX_FRAME_TEXT) oversize = true;
1782
+ }
1783
+ const checksumTruncated = termIndex !== -1 && termIndex + 2 >= n;
1784
+ if (termIndex === -1 || checksumTruncated) {
1785
+ const textEnd = termIndex !== -1 ? termIndex : nextStxOrEnd(bytes, i + 2, n);
1786
+ const text2 = bytes.slice(Math.min(i + 2, textEnd), textEnd);
1787
+ frames.push({
1788
+ ...frameNumber !== void 0 ? { frameNumber } : {},
1789
+ text: text2,
1790
+ byteOffset: stxOffset,
1791
+ checksum: { computed: 0, valid: false },
1792
+ trusted: false,
1793
+ unterminated: true,
1794
+ oversize
1795
+ });
1796
+ warnings.push(frameUnterminated(framePosition(frameNumber, stxOffset)));
1797
+ recordTainted = true;
1798
+ recordOpen = false;
1799
+ i = termIndex === -1 ? textEnd : n;
1800
+ continue;
1801
+ }
1802
+ const termKind = bytes[termIndex] === ETB ? "ETB" : "ETX";
1803
+ const computed = computeChecksum(bytes, fnIndex, termIndex);
1804
+ const declared = parseChecksumHex(bytes, termIndex + 1, termIndex + 2);
1805
+ const valid = declared !== void 0 && declared === computed;
1806
+ const checksum = declared !== void 0 ? { computed, declared, valid } : { computed, valid };
1807
+ const text = bytes.slice(i + 2, termIndex);
1808
+ const frame = {
1809
+ ...frameNumber !== void 0 ? { frameNumber } : {},
1810
+ terminator: termKind,
1811
+ text,
1812
+ byteOffset: stxOffset,
1813
+ checksum,
1814
+ trusted: valid,
1815
+ unterminated: false,
1816
+ oversize
1817
+ };
1818
+ frames.push(frame);
1819
+ if (frameNumber !== void 0 && isFrameDigit(frameNumber) && frameNumber === expected) {
1820
+ expected = (frameNumber + 1) % FRAME_NUMBER_MODULUS;
1821
+ } else {
1822
+ warnings.push(frameSequenceGap(framePosition(frameNumber, stxOffset)));
1823
+ recordTainted = true;
1824
+ if (frameNumber !== void 0 && isFrameDigit(frameNumber)) {
1825
+ expected = (frameNumber + 1) % FRAME_NUMBER_MODULUS;
1826
+ }
1827
+ }
1828
+ if (oversize) warnings.push(frameOversize(framePosition(frameNumber, stxOffset)));
1829
+ if (!valid) {
1830
+ warnings.push(frameBadChecksum(framePosition(frameNumber, stxOffset)));
1831
+ recordTainted = true;
1832
+ } else {
1833
+ recordChunks.push(text);
1834
+ }
1835
+ if (termKind === "ETX") {
1836
+ if (!recordTainted) records.push(concatBytes2(recordChunks));
1837
+ recordChunks = [];
1838
+ recordTainted = false;
1839
+ recordOpen = false;
1840
+ } else {
1841
+ recordOpen = true;
1842
+ }
1843
+ i = termIndex + 3;
1844
+ if (bytes[i] === CR) i++;
1845
+ if (bytes[i] === LF) i++;
1846
+ }
1847
+ if (recordOpen) {
1848
+ warnings.push(frameUnterminated({ byteOffset: n }));
1849
+ }
1850
+ if (options.strict === true && warnings.length > 0) {
1851
+ throw new AstmFrameStrictError(warnings);
1852
+ }
1853
+ return freezeResult({ records, frames, warnings });
1854
+ }
1855
+ function freezeResult(result) {
1856
+ for (const f of result.frames) {
1857
+ Object.freeze(f.checksum);
1858
+ Object.freeze(f);
1859
+ }
1860
+ for (const w of result.warnings) {
1861
+ Object.freeze(w.position);
1862
+ Object.freeze(w);
1863
+ }
1864
+ Object.freeze(result.records);
1865
+ Object.freeze(result.frames);
1866
+ Object.freeze(result.warnings);
1867
+ return Object.freeze(result);
1868
+ }
1869
+ function readFrameNumber(fnByte) {
1870
+ if (fnByte === void 0) return void 0;
1871
+ return fnByte - FN_ZERO;
1872
+ }
1873
+ function isFrameDigit(frameNumber) {
1874
+ return frameNumber >= 0 && frameNumber <= FN_SEVEN - FN_ZERO;
1875
+ }
1876
+ function framePosition(frameNumber, byteOffset) {
1877
+ return frameNumber !== void 0 ? { frameNumber, byteOffset } : { byteOffset };
1878
+ }
1879
+ function nextStxOrEnd(bytes, from, end) {
1880
+ for (let k = from; k < end; k++) {
1881
+ if (bytes[k] === STX) return k;
1882
+ }
1883
+ return end;
1884
+ }
1885
+ function concatBytes2(chunks) {
1886
+ let total = 0;
1887
+ for (const c of chunks) total += c.length;
1888
+ const out = new Uint8Array(total);
1889
+ let at2 = 0;
1890
+ for (const c of chunks) {
1891
+ out.set(c, at2);
1892
+ at2 += c.length;
1893
+ }
1894
+ return out;
1895
+ }
1896
+
1897
+ // src/frames/encode.ts
1898
+ var AstmFrameEncodeError = class extends Error {
1899
+ /** Stable discriminant. */
1900
+ code;
1901
+ /** Index of the offending record within the input, when applicable. */
1902
+ recordIndex;
1903
+ /** @internal */
1904
+ constructor(message, recordIndex) {
1905
+ super(message);
1906
+ this.name = "AstmFrameEncodeError";
1907
+ this.code = "ASTM_FRAME_EMPTY_RECORD";
1908
+ if (recordIndex !== void 0) this.recordIndex = recordIndex;
1909
+ }
1910
+ };
1911
+ function toBytes(input) {
1912
+ if (typeof input !== "string") return input;
1913
+ const out = new Uint8Array(input.length);
1914
+ for (let i = 0; i < input.length; i += 1) out[i] = input.charCodeAt(i) & 255;
1915
+ return out;
1916
+ }
1917
+ function encodeFrame(text, frameNumber, isFinal) {
1918
+ const term = isFinal ? ETX : ETB;
1919
+ const bytes = [STX, FN_ZERO + frameNumber, ...text, term];
1920
+ const cs = computeChecksum(Uint8Array.from(bytes), 1, bytes.length - 1);
1921
+ const hex = toChecksumHex(cs);
1922
+ return [...bytes, hex.charCodeAt(0), hex.charCodeAt(1), CR, LF];
1923
+ }
1924
+ function composeAstmFrames(records, options = {}) {
1925
+ if (records.length === 0) {
1926
+ throw new AstmFrameEncodeError("Cannot frame an empty record list \u2014 nothing to transmit.");
1927
+ }
1928
+ const out = [];
1929
+ let frameNumber = options.startFrameNumber ?? FIRST_FRAME_NUMBER;
1930
+ records.forEach((record, recordIndex) => {
1931
+ const bytes = toBytes(record);
1932
+ if (bytes.length === 0) {
1933
+ throw new AstmFrameEncodeError(
1934
+ "Cannot frame an empty record \u2014 a frame must carry record bytes.",
1935
+ recordIndex
1936
+ );
1937
+ }
1938
+ for (let offset = 0; offset < bytes.length; offset += MAX_FRAME_TEXT) {
1939
+ const chunk = bytes.subarray(offset, offset + MAX_FRAME_TEXT);
1940
+ const isFinal = offset + MAX_FRAME_TEXT >= bytes.length;
1941
+ out.push(...encodeFrame(chunk, frameNumber, isFinal));
1942
+ frameNumber = (frameNumber + 1) % FRAME_NUMBER_MODULUS;
1943
+ }
1944
+ });
1945
+ return Uint8Array.from(out);
1946
+ }
1947
+
1948
+ // src/frames/compose.ts
1949
+ function parseFramedAstm(bytes, options = {}) {
1950
+ const { records, frames, warnings } = decodeAstmFrames(bytes, options);
1951
+ const joined = concatBytes3(records);
1952
+ const message = parseAstmRecords(joined, options);
1953
+ return { message, frames, frameWarnings: warnings };
1954
+ }
1955
+ function serializeFramedAstm(input, options = {}) {
1956
+ const records = Array.isArray(input) ? input : input.records;
1957
+ const recordBytes = records.map((r) => serializeAstmRecord(r) + "\r");
1958
+ return composeAstmFrames(recordBytes, options);
1959
+ }
1960
+ function concatBytes3(chunks) {
1961
+ let total = 0;
1962
+ for (const c of chunks) total += c.length;
1963
+ const out = new Uint8Array(total);
1964
+ let at2 = 0;
1965
+ for (const c of chunks) {
1966
+ out.set(c, at2);
1967
+ at2 += c.length;
1968
+ }
1969
+ return out;
1970
+ }
1971
+
1972
+ // src/index.ts
1973
+ var VERSION = "0.0.0";
1974
+
1975
+ export { ALL_ASTM_WARNING_CODES, ACK as ASTM_ACK, ENQ as ASTM_ENQ, EOT as ASTM_EOT, NAK as ASTM_NAK, AstmFrameEncodeError, AstmFrameStrictError, AstmParseError, AstmProfileDefinitionError, AstmSerializeError, AstmStrictError, CANONICAL_DELIMITERS, FATAL_CODES, FRAME_WARNING_CODES, LIVD_WARNING_CODES, LTP_WARNING_CODES, SAFETY_CRITICAL_CODES, TOLERABLE_CODES, VERSION, WARNING_CODES, ambiguousMessageKind, ambiguousValueSplit, applyAstmProfile, applyAstmProfileToWarnings, applyLivd, astmDateToLocalISO, astmProfiles, attachComments, buildAstmMessage, classifyMessage, comments, commentsFor, composeAstmFrames, computeChecksum, decodeAstmFrames, decodeEscapes, deepFreeze, defineAstmProfile, defineLivdCatalog, detectFraming, encodeComponent, fieldScalar, frameBadChecksum, frameOversize, frameSequenceGap, frameUnterminated, getAstmProfile, getDefaultAstmProfile, interpretAbnormalFlag, interpretResultStatus, isNonStandard, isSafetyCriticalCode, listAstmProfiles, livdAmbiguousMapping, livdUnmappedCode, lookupLivdForRecord, ltpAmbiguousTransport, ltpFrameRejected, ltpInitialState, ltpReduce, ltpUnexpectedEvent, nonStandardDelimiters, orders, orphanComment, parseAstmDate, parseAstmRecords, parseChecksumHex, parseFramedAstm, parseReferenceRange, partialTimestamp, patient, primaryCode, profileQuirkApplied, query, readDelimiters, recognizeUniversalTestId, resolveProfileTransport, results, serializeAstmRecord, serializeAstmRecords, serializeField, serializeFramedAstm, setDefaultAstmProfile, splitEscapeAware, toChecksumHex, tokenizeRecord, undefinedAbnormalFlag, undefinedResultStatus, uninterpretedQueryStatus, unitsAbsent, unknownEscapeSequence, unknownRecordType, unparseableReferenceRange };
1976
+ //# sourceMappingURL=index.mjs.map
1977
+ //# sourceMappingURL=index.mjs.map