@cosyte/hl7 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,23 @@ per the cosyte version ladder (`0.0.x` until first alpha).
15
15
 
16
16
  ### Added
17
17
 
18
+ - **The cosyte brand banner heads `README.md` (`ASSETS-P8`, the consuming half).** A plain markdown
19
+ image on the first line of the file, above the H1, pointing at the absolute HTTPS URL published
20
+ for `hl7` in the `assets` repo's `published-urls.json` contract
21
+ (`https://cosyte.com/social/cosyte-banner-hl7-1200x300.png`, `status: live` on
22
+ `website#59 01f988b`). Re-verified `200 image/png`, 18316 bytes, immediately before the push.
23
+
24
+ **The shape is deliberate, and `hl7` sets it for the other thirteen consuming READMEs.** It is a
25
+ markdown image, **not `<img>` and not `<picture>`**: whether npm's markdown sanitizer preserves a
26
+ `<picture>` element is unverified, which is precisely why the banner artwork is self-grounded
27
+ (opaque ground, correct against a light or dark page) and depends on no such element. A markdown
28
+ image with an absolute HTTPS URL is the construct we are willing to assert renders on both npm and
29
+ GitHub, so it carries no width or height attributes either. **PNG only** is a bounded policy (the
30
+ format we assert renders on every README surface), not a demonstrated impossibility for others.
31
+ The **alt text is content, not decoration**: it names the package and its one-line purpose,
32
+ because it is what a screen reader on the npm page reads out, so "banner", "logo", or the file
33
+ name would all be a wasted line.
34
+
18
35
  - **The published JSDoc is swept, and `src/` doc comments are gated
19
36
  (`pnpm check:no-internal-refs` third pass; `PUBLIC-SURFACE-HYGIENE`).** Doc comments compile
20
37
  into `dist/index.d.ts` and `dist/index.d.cts`, `dist` is the first entry in `files`, and every
@@ -433,6 +450,153 @@ kind, value)`** sets one at a field or `field[rep]` dot-path. Component values a
433
450
 
434
451
  ### Fixed
435
452
 
453
+ - **A warning or error message could carry clinical text into a log line (`HL7-WARN-MSG-PHI`).**
454
+ No options were required to reach it: plain `parseHL7(raw)` on lenient defaults.
455
+
456
+ **The mechanism.** `normalize()` rewrites every `\r\n` and `\n` in the input to `\r` before
457
+ segment splitting, unconditionally and with no field awareness. HL7 v2 Ch. 2 fixes the segment
458
+ terminator as `<CR>` and provides `\.br\` (§2.7.6) precisely because a literal line break cannot
459
+ appear inside a field, but real senders emit raw breaks inside narrative fields (`OBX-5`, notes,
460
+ embedded documents), which is exactly the class of sender violation this parser exists to
461
+ tolerate. When one occurs, the remainder of that field becomes a forged segment, and `tokenize()`
462
+ takes the whole line as its segment identifier because a line with no field separator offers no
463
+ other candidate. `UNKNOWN_SEGMENT` then interpolated that "identifier" uncapped, putting synthetic
464
+ name, date of birth and medical record number into `Hl7ParseWarning.message`. Under
465
+ `{ strict: true }` the same text reached the thrown `Hl7ParseError.message`, and therefore
466
+ `.stack`.
467
+
468
+ **The disclosure was inverted, which is what made it consequential.** Guidance said to redact
469
+ `Hl7ParseError.snippet`. On the strict path `snippet` holds the head of the message (usually the
470
+ MSH header) while `message` held the payload, so a consumer following the documented mitigation
471
+ exactly still logged the data. `message` is also the field a logger prints by default and the one
472
+ `stack` embeds.
473
+
474
+ **The fix: bound at the factory, not at the call site.** A token taken from input is echoed only
475
+ when it matches the form the spec defines for it, and becomes `<withheld>` otherwise. A length cap
476
+ was rejected because truncating still emits the first N characters of a patient name. Segment
477
+ identifiers are held to exactly three alphanumerics (HL7 v2 Ch. 2 §2.5), with equivalent forms for
478
+ MSH-9 types and MSH-12 versions. Conforming tokens render byte-identically, so `PID`, `ZZZ` and
479
+ every legitimate Z-segment name are unchanged and well-formed input sees no behaviour change:
480
+ 1653 existing tests pass untouched. Placing the check in the factories rather than at the one live
481
+ call site also covers the six factories currently exported but unreached, and consumers who
482
+ construct warnings themselves.
483
+
484
+ **Charset labels are bounded by registry membership, not by spelling, and that distinction was
485
+ earned the hard way.** The first version of this fix used a shape test for them too, and a refuter
486
+ broke it: the shape admitted all five of the PHI-shaped markers this repo's own property suite
487
+ defines, including an 18-character patient name. Table 0211 is a **closed** table and
488
+ `UNKNOWN_CHARSET` fires precisely when a label is absent from it, so at the moment of the warning
489
+ there is no spec-defined spelling left to test against, and `UNICODE UTF-8` and a patient name are
490
+ shape-siblings. No regex can separate them. A label is now echoed only when `resolveCharset`
491
+ recognises it.
492
+
493
+ **What is bounded, stated without overclaiming.** `snippet` is the only field carrying input
494
+ verbatim and unbounded, so it is the one to redact first. A message cannot carry a field's value,
495
+ but it can carry a residue of up to three characters when a malformed line happens to look like a
496
+ segment identifier. No absolute "never carries field content" claim is made anywhere in this
497
+ package, because the code cannot honour one.
498
+
499
+ **Two adjacent surfaces bounded in the same pass.** `UNKNOWN_CHARSET` and `ENCODING_MISMATCH`
500
+ interpolate a value read as MSH-18, which on a message carrying no segment terminators is not
501
+ MSH-18 at all but the 18th `|`-token of the flattened input, commonly a data field. That path is
502
+ Buffer-only, and `parseStream` and `splitBatch` re-encode each split message to a Buffer, which is
503
+ the ordinary MLLP and file ingestion shape, so it is not an exotic corner.
504
+ `MISSING_EXPECTED_GROUP` and `MERGE_MISSING_PRIOR_OR_SURVIVOR` interpolate MSH-9.
505
+
506
+ **The test gap is the real finding.** The PHI property suite could not reach this position: every
507
+ generated marker sat after a `|`, so it was always a field value to the tokenizer, while the only
508
+ position that leaks is a line's **leading** token. A green suite over an unreachable case is
509
+ indistinguishable from a correct one. New generators emit the real trigger (a field-internal `\n`
510
+ and `\r\n`) across the lenient and strict arms, asserting on `.stack` separately from `.message`
511
+ because most error reporters ship the stack off-box, and pinning the escalated code on both
512
+ strict properties so neither can pass vacuously if some other warning escalates first. A second
513
+ set covers the Buffer/MSH-18 path, which every string-input generator in the suite structurally
514
+ misses, and a third asserts the invariant on the parsed **model**. All ten new tests fail on the
515
+ code they were written against and pass on this one.
516
+
517
+ **The same lesson applied to this fix's own first draft, which is why it now bounds the model
518
+ too.** Bounding the warning message protected this package's diagnostics and nothing else.
519
+ `Segment.type` still carried the entire forged line, so a consumer that reads the model and builds
520
+ its own report from that "identifier" wrote clinical narrative into it, on lenient defaults, with
521
+ no options set. **A diagnostic-surface fix does not protect a downstream package that reads your
522
+ model and builds its own diagnostics from it. Bounding at the model protects both.**
523
+ `Segment.type`, `Hl7Message.version` and the unrecognized branch of `MessageStructure` now carry
524
+ bounded identifiers, and the shared primitive moved to `src/parser/tokens.ts` so the parser and the
525
+ model use one bound rather than two.
526
+
527
+ **One bound deliberately not taken, named rather than left implicit.** On the _recognized_ branch
528
+ of `MessageStructure`, `triggerEvent` is echoed verbatim, and a definition matching on message code
529
+ alone (`ACK`) accepts an arbitrary MSH-9.2. Left unbounded on purpose: no real sender putting
530
+ narrative in MSH-9.2 is grounded in a de-identified document, and widening the guard there is a
531
+ separate change, not a silent extension of this one. An earlier comment in that file asserted the
532
+ branch returned registry values and could not carry input, which was false; the claim is corrected
533
+ rather than the guard grown.
534
+
535
+ **What stays unbounded, deliberately, because bounding it would be a mis-parse.** The model
536
+ separates **identifiers** from **values**. `Meta.*` is the documented value view of the MSH header;
537
+ `Segment.fields`, `Segment.raw` and `Hl7Message.rawSegments` are the raw tokenization the
538
+ byte-verbatim round-trip serializes from; `toString()` / `toJSON()` / `prettyPrint()` are the
539
+ message itself. Those are content surfaces and say so. `meta.version` in particular keeps MSH-12
540
+ exactly as it arrived and is what the v2.7 MRG field-scoping reads, so bounding
541
+ `Hl7Message.version` changes no clinical field selection.
542
+
543
+ **Docs corrected in both directions.** `docs-content/troubleshooting.md` asserted that warning
544
+ messages never echo a field's value, an overclaim; it and the equivalent assurances in
545
+ `normalize.ts` and `warnings.ts` now state the check that makes it true. The `Hl7ParseError`
546
+ disclosure now says `snippet` is the field to redact first, rather than claiming a message is
547
+ clean. `README.md` called the snippet an excerpt of "the offending input" when it is always
548
+ taken from the start of the message; the wording is corrected rather than the behaviour, because
549
+ re-pointing the snippet at the deviation's own segment would move more clinical content into the
550
+ single unredacted field.
551
+
552
+ **Not a mis-parse.** Parsed values were always correct; this was a diagnostic-surface leak.
553
+ Consumers on any published version up to and including `0.0.3` should treat
554
+ `Hl7ParseWarning.message` and `Hl7ParseError.message` as potentially carrying field content for
555
+ feeds they do not control until they upgrade.
556
+
557
+ - **Stale counts in `docs-content/`, found while correcting the PHI wording.** The warning-code
558
+ count was given as 19 in three places (it is 20), and two pages quoted a published npm version
559
+ that was two releases stale. Both now point at the registry rather than restating it, which is the
560
+ only way a doc page stops drifting from it.
561
+
562
+ - **Four false claims in the README's feature list, each re-derived from source rather than from
563
+ memory (`README-CLAIM-DRIFT`).** Every number and version below was measured against the thing it
564
+ describes, in the order a consumer would have to trust it:
565
+ 1. **Node 18+ to Node 22+**, in the badge (`node-%3E%3D18`) and in the prose. `engines.node` is
566
+ `>=22.0.0` in `package.json` and in the registry (`npm view @cosyte/hl7 engines`), and CI runs
567
+ the 22 + 24 matrix. This was the one with a consumer-visible consequence: `npm i @cosyte/hl7`
568
+ on Node 18 warns on engines while the package page says 18 is supported.
569
+ 2. **ES2022 to ES2023.** `@cosyte/tsconfig/base.json` sets `"target": "ES2023"` and
570
+ `"lib": ["ES2023"]`, and this repo's `tsconfig.json` extends it without override.
571
+ 3. **18 to 20 stable warning codes.** `WARNING_CODES` in `src/parser/warnings.ts` has 20 keys, and
572
+ `test/warning-codes.snapshot.test.ts` already asserted `toHaveLength(20)`. A green test suite
573
+ and a wrong package page coexisted. **No other surface says 18, but that is not the same as the
574
+ count now being consistent**: further down `README.md`, an inline prose list of 16 code names is
575
+ still introduced as "the full list of Tier-2 codes", and `docs-content/` says 19 in three
576
+ places. Those are pre-existing, outside this feature list, and left for their own change rather
577
+ than folded in here.
578
+ 4. **"Every public export has JSDoc + `@example`" to "every public function and class."** Measured
579
+ over the 213 names in the built `dist/index.d.ts` export list: all **79 functions** and all
580
+ **5 classes** carry both, but of 17 value constants one (`ERR_CONDITION_CODE_SYSTEM`) has JSDoc
581
+ with no `@example`, and while all **109 type-only exports carry JSDoc, only 73 carry an
582
+ `@example`**. The three aliased namespace exports (`HL7`, `conformance`, `text`) fail the old
583
+ wording outright: two carry no JSDoc at all and the third has no `@example`. The narrowed
584
+ sentence is exactly true; the old one was not.
585
+
586
+ **The rest of the feature list was checked the same way and is correct**, so it is unchanged:
587
+ 4 fatal codes (`FATAL_CODES` has exactly 4 keys), 8 built-in vendor profiles (the frozen `profiles`
588
+ namespace has 8, and the eight vendor names match), zero runtime dependencies (`dependencies` is
589
+ present and empty, and there is no `peerDependencies`), `noUncheckedIndexedAccess` (true in the
590
+ inherited base config), dual ESM + CJS (the `exports` map resolves `import` to `.mjs`/`.d.ts` and
591
+ `require` to `.cjs`/`.d.cts`, and `attw` passes node10, node16-CJS, node16-ESM and bundler), and
592
+ the two linked paths (`examples/profile-starter-kit/`, `docs-content/spec-notes-escapes.md`) both
593
+ resolve.
594
+
595
+ **`CLAUDE.md` already said Node >= 22 and Target ES2023, so the repo contradicted itself and
596
+ nothing caught it.** The two files drifted in opposite directions because only one of them is read
597
+ by a consumer, and it is the one that drifted. No gate compares a README claim to the config it
598
+ describes, which is why these were found by hand.
599
+
436
600
  - **`scripts/sync-version.mjs` hardened against two latent defects, and gated in CI
437
601
  (SYNC-VERSION-HARDENING).** Follow-up hardening on the VERSION-SYNC script; ported byte-identically
438
602
  across `hl7`, `x12`, and `mllp`. (1) The version was spliced into `src/index.ts` via
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![@cosyte/hl7: parse real-world, vendor-quirky HL7 v2 messages and extract the fields you need in one line](https://cosyte.com/social/cosyte-banner-hl7-1200x300.png)
2
+
1
3
  # @cosyte/hl7
2
4
 
3
5
  > Parse real-world, vendor-quirky HL7 v2 messages and extract the fields you need in one line, without reading the spec.
@@ -5,7 +7,7 @@
5
7
  [![npm version](https://img.shields.io/npm/v/@cosyte/hl7.svg)](https://www.npmjs.com/package/@cosyte/hl7)
6
8
  [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/hl7/ci.yml?branch=main&label=CI)](https://github.com/cosyte/hl7/actions/workflows/ci.yml)
7
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
- [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
10
+ [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
9
11
 
10
12
  A developer-focused HL7 v2 parser and utility library for Node.js and TypeScript. Optimised for the 10% of HL7 you actually use, with the other 90% still one accessor away when you need it.
11
13
 
@@ -37,10 +39,10 @@ That's the whole pitch: no config, no schema upload, no spec lookup. The parser
37
39
 
38
40
  - **One-line extraction**: `msg.patient.mrn`, `msg.meta.timestamp`, `msg.observations()`, and friends. No segment or field numbers to memorise.
39
41
  - **Three access patterns**: named helpers, dot-paths (`msg.get("PID.5.1")`), or structural traversal (`msg.segments("OBX")[0].field(3)`). Pick the level of ceremony you need.
40
- - **Real-world tolerance, four-tier**: lenient default parses vendor-quirky messages; 18 stable warning codes flag what was tolerated; strict mode escalates every deviation for CI validators; only 4 truly-structural failures are fatal.
42
+ - **Real-world tolerance, four-tier**: lenient default parses vendor-quirky messages; 20 stable warning codes flag what was tolerated; strict mode escalates every deviation for CI validators; only 4 truly-structural failures are fatal.
41
43
  - **First-class profile system**: `defineProfile()` API, 8 built-in vendor profiles (Epic, Cerner, Meditech, athenahealth, generic lab, Visage 7 imaging/PACS, Philips Vue PACS, VA VistA Radiology/NucMed), plus a [publishable starter kit](./examples/profile-starter-kit/) you copy-and-ship.
42
44
  - **Round-trip safe, byte-verbatim escapes**: `parse -> modify -> toString()` emits spec-clean HL7 regardless of input quirks (Postel's Law: liberal parser, conservative emitter), and a parsed field's escape sequences (`\H\`, `\X41\`, charset/vendor escapes) re-emit **byte-for-byte**. See [Escapes & round-trip](./docs-content/spec-notes-escapes.md).
43
- - **Strict TypeScript, zero runtime deps**: ES2022, `noUncheckedIndexedAccess`, dual ESM + CJS, Node 18+. Every public export has JSDoc + `@example` that feeds your editor's IntelliSense.
45
+ - **Strict TypeScript, zero runtime deps**: ES2023, `noUncheckedIndexedAccess`, dual ESM + CJS, Node 22+. Every public function and class has JSDoc + `@example` that feeds your editor's IntelliSense.
44
46
  - **Warnings carry stable codes + positional context**: react programmatically by `w.code`, with `segmentIndex`/`fieldIndex`/etc. attached.
45
47
 
46
48
  ---
@@ -739,7 +741,7 @@ The library throws exactly three error types, all exported from the package barr
739
741
 
740
742
  ### `Hl7ParseError`
741
743
 
742
- Thrown by `parseHL7` when the input hits one of the 4 Tier-3 fatal codes (see above). Carries positional context plus a short snippet of the offending input.
744
+ Thrown by `parseHL7` when the input hits one of the 4 Tier-3 fatal codes (see above). Carries positional context plus a short snippet taken from the start of the input (up to 40 characters, plus an ellipsis when truncated).
743
745
 
744
746
  ```ts
745
747
  import { parseHL7, Hl7ParseError, FATAL_CODES } from "@cosyte/hl7";
@@ -760,7 +762,13 @@ if (err instanceof Hl7ParseError && err.code === FATAL_CODES.NO_MSH_SEGMENT) {
760
762
  }
761
763
  ```
762
764
 
763
- `Hl7ParseError.snippet` may contain PHI when parsing real clinical messages. The library does NOT redact. Redact at your call site if compliance demands it.
765
+ `Hl7ParseError.snippet` may contain PHI when parsing real clinical messages. The library does NOT redact it. Redact it at your call site if compliance demands it.
766
+
767
+ **`snippet` is the only field carrying input verbatim and unfiltered** (capped at 40 characters plus an ellipsis, but not shape-checked in any way), so redact it first.
768
+
769
+ `message` is bounded but not absolutely content-free, which is worth stating because `message` is what a logger prints by default and what `stack` embeds. A token taken from the input is echoed only when it matches the form the spec defines for it: a three-character segment identifier, an MSH-9 type, an MSH-12 version, or a charset label the closed Table 0211 actually contains. Anything else becomes `<withheld>`. So a message cannot carry a field's value, but it can carry a residue of up to three characters when a malformed line happens to look like a segment identifier. The other shapes are narrower in practice than their patterns allow, because the library only ever feeds them registry-matched values; the patterns themselves admit more (a message type up to 26 characters, a version up to 14), which matters only if you construct warnings yourself.
770
+
771
+ Under `{ strict: true }` an escalated Tier-2 warning is thrown as an `Hl7ParseError` carrying that same bounded message, plus a `snippet` of the first 40 characters of the input rather than of the deviation's own segment.
764
772
 
765
773
  ### `Hl7ParseWarning`
766
774
 
package/dist/index.cjs CHANGED
@@ -255,7 +255,104 @@ var KNOWN_SEGMENTS = /* @__PURE__ */ new Set([
255
255
  "CSS"
256
256
  ]);
257
257
 
258
+ // src/parser/charset.ts
259
+ function normalizeLabel(raw) {
260
+ return raw.trim().replace(/\s+/gu, " ").toUpperCase();
261
+ }
262
+ function decodeEntry(canonical, decoder) {
263
+ return { canonical, treatment: "decode", decoder };
264
+ }
265
+ function verbatimEntry(canonical) {
266
+ return { canonical, treatment: "verbatim", decoder: "" };
267
+ }
268
+ function buildTable() {
269
+ const table = /* @__PURE__ */ new Map();
270
+ const add = (aliases, entry) => {
271
+ for (const alias of aliases) table.set(normalizeLabel(alias), entry);
272
+ };
273
+ add(["ASCII", "US-ASCII", "ISO IR6"], decodeEntry("ASCII", "utf-8"));
274
+ add(["UTF-8", "UTF8", "UNICODE UTF-8", "UNICODE"], decodeEntry("UTF-8", "utf-8"));
275
+ add(["8859/1", "ISO-8859-1", "ISO 8859-1", "ISO8859-1"], decodeEntry("8859/1", "latin1"));
276
+ for (const n of [2, 3, 4, 5, 6, 7, 8, 10, 13, 14, 15, 16]) {
277
+ add(
278
+ [`8859/${n}`, `ISO-8859-${n}`, `ISO 8859-${n}`, `ISO8859-${n}`],
279
+ decodeEntry(`8859/${n}`, `iso-8859-${n}`)
280
+ );
281
+ }
282
+ add(["8859/9", "ISO-8859-9", "ISO 8859-9", "ISO8859-9"], verbatimEntry("8859/9"));
283
+ add(["8859/11", "ISO-8859-11", "ISO 8859-11", "ISO8859-11"], verbatimEntry("8859/11"));
284
+ add(["ISO IR14", "JIS X 0201"], verbatimEntry("ISO IR14"));
285
+ add(["ISO IR87", "JIS X 0208"], verbatimEntry("ISO IR87"));
286
+ add(["ISO IR159", "JIS X 0212"], verbatimEntry("ISO IR159"));
287
+ add(["GB 18030-2000", "GB18030", "GB 18030"], verbatimEntry("GB 18030-2000"));
288
+ add(["KS X 1001", "KSX1001", "KS C 5601"], verbatimEntry("KS X 1001"));
289
+ add(["CNS 11643-1992", "CNS 11643"], verbatimEntry("CNS 11643-1992"));
290
+ add(["BIG-5", "BIG5"], verbatimEntry("BIG-5"));
291
+ add(["UNICODE UTF-16", "UTF-16"], verbatimEntry("UNICODE UTF-16"));
292
+ add(["UNICODE UTF-32", "UTF-32"], verbatimEntry("UNICODE UTF-32"));
293
+ return table;
294
+ }
295
+ var CHARSET_TABLE = buildTable();
296
+ var DEFAULT_RESOLUTION = Object.freeze({
297
+ canonical: "ASCII",
298
+ treatment: "decode",
299
+ decoder: "utf-8",
300
+ recognized: true
301
+ });
302
+ function resolveCharset(raw) {
303
+ if (raw === void 0) return DEFAULT_RESOLUTION;
304
+ const key = normalizeLabel(raw);
305
+ if (key === "") return DEFAULT_RESOLUTION;
306
+ const entry = CHARSET_TABLE.get(key);
307
+ if (entry !== void 0) {
308
+ return {
309
+ canonical: entry.canonical,
310
+ treatment: entry.treatment,
311
+ decoder: entry.decoder,
312
+ recognized: true
313
+ };
314
+ }
315
+ return { canonical: key, treatment: "verbatim", decoder: "", recognized: false };
316
+ }
317
+ function canonicalCharset(raw) {
318
+ return resolveCharset(raw).canonical;
319
+ }
320
+
321
+ // src/parser/tokens.ts
322
+ var DERIVED_TOKEN_SHAPES = {
323
+ /**
324
+ * HL7 v2 Ch. 2 §2.5 segment identifier: three characters, the first
325
+ * alphabetic.
326
+ *
327
+ * Two deliberate tightenings, each measured rather than assumed. The first
328
+ * version allowed four characters as "vendor tolerance", which was invented
329
+ * rather than grounded: all 91 `KNOWN_SEGMENTS` are exactly three, and
330
+ * `defineProfile` independently validates custom Z-segments to
331
+ * `/^Z[A-Z0-9]{2}$/`, so nothing legitimate is longer. The second requires a
332
+ * leading letter, which every known segment and every legal Z-segment
333
+ * satisfies, and which excludes a **bare numeric** residue. That matters out
334
+ * of proportion to its size: a forged line reading `120` is a glucose, a
335
+ * blood pressure or a dose, which is the most clinical meaning three
336
+ * characters can carry.
337
+ */
338
+ segmentId: /^[A-Za-z][A-Za-z0-9]{2}$/,
339
+ /** MSH-9 message type / trigger, e.g. `ADT^A01` or `A40`. */
340
+ messageType: /^[A-Za-z0-9]{1,8}(?:\^[A-Za-z0-9]{1,8}){0,2}$/,
341
+ /** MSH-12 version ID, e.g. `2.5` or `2.5.1`. */
342
+ version: /^[0-9]{1,2}(?:\.[0-9]{1,3}){0,3}$/
343
+ };
344
+ function safeDerivedToken(value, kind) {
345
+ return DERIVED_TOKEN_SHAPES[kind].test(value) ? value : WITHHELD;
346
+ }
347
+ var WITHHELD = "<withheld>";
348
+ function boundedIdentifier(value, kind) {
349
+ return value === "" ? "" : safeDerivedToken(value, kind);
350
+ }
351
+
258
352
  // src/parser/warnings.ts
353
+ function safeCharsetLabel(value) {
354
+ return resolveCharset(value).recognized ? value : WITHHELD;
355
+ }
259
356
  var WARNING_CODES = {
260
357
  MLLP_FRAMING_STRIPPED: "MLLP_FRAMING_STRIPPED",
261
358
  FIELD_WHITESPACE_TRIMMED: "FIELD_WHITESPACE_TRIMMED",
@@ -340,28 +437,28 @@ function timestampFallbackFormat(position, matchedFormat) {
340
437
  function segmentCase(position, observed) {
341
438
  return {
342
439
  code: WARNING_CODES.SEGMENT_CASE,
343
- message: `Segment identifier "${observed}" is not uppercase; normalized to "${observed.toUpperCase()}".`,
440
+ message: `Segment identifier "${safeDerivedToken(observed, "segmentId")}" is not uppercase; normalized to "${safeDerivedToken(observed.toUpperCase(), "segmentId")}".`,
344
441
  position
345
442
  };
346
443
  }
347
444
  function extraFields(position, segmentName2, extraCount) {
348
445
  return {
349
446
  code: WARNING_CODES.EXTRA_FIELDS,
350
- message: `Segment "${segmentName2}" has ${String(extraCount)} more field(s) than the profile declares.`,
447
+ message: `Segment "${safeDerivedToken(segmentName2, "segmentId")}" has ${String(extraCount)} more field(s) than the profile declares.`,
351
448
  position
352
449
  };
353
450
  }
354
451
  function unknownSegment(position, segmentName2) {
355
452
  return {
356
453
  code: WARNING_CODES.UNKNOWN_SEGMENT,
357
- message: `Unknown segment "${segmentName2}": not in HL7 spec and no profile claim.`,
454
+ message: `Unknown segment "${safeDerivedToken(segmentName2, "segmentId")}": not in HL7 spec and no profile claim.`,
358
455
  position
359
456
  };
360
457
  }
361
458
  function duplicateRequiredSegment(position, segmentName2) {
362
459
  return {
363
460
  code: WARNING_CODES.DUPLICATE_REQUIRED_SEGMENT,
364
- message: `Required singleton segment "${segmentName2}" appears more than once.`,
461
+ message: `Required singleton segment "${safeDerivedToken(segmentName2, "segmentId")}" appears more than once.`,
365
462
  position
366
463
  };
367
464
  }
@@ -375,42 +472,42 @@ function encodingMismatch(position, detail) {
375
472
  function missingRequiredField(position, segmentName2, fieldIndex) {
376
473
  return {
377
474
  code: WARNING_CODES.MISSING_REQUIRED_FIELD,
378
- message: `Required field ${segmentName2}-${String(fieldIndex)} is missing or empty.`,
475
+ message: `Required field ${safeDerivedToken(segmentName2, "segmentId")}-${String(fieldIndex)} is missing or empty.`,
379
476
  position
380
477
  };
381
478
  }
382
479
  function missingExpectedGroup(position, messageType, groupName, anchorSegments) {
383
480
  return {
384
481
  code: WARNING_CODES.MISSING_EXPECTED_GROUP,
385
- message: `Message type "${messageType}" is missing its expected "${groupName}" segment group (no ${anchorSegments.join("/")}); message may be truncated or misrouted.`,
482
+ message: `Message type "${safeDerivedToken(messageType, "messageType")}" is missing its expected "${groupName}" segment group (no ${anchorSegments.join("/")}); message may be truncated or misrouted.`,
386
483
  position
387
484
  };
388
485
  }
389
486
  function outOfOrderSegment(position, segmentName2) {
390
487
  return {
391
488
  code: WARNING_CODES.OUT_OF_ORDER_SEGMENT,
392
- message: `Segment "${segmentName2}" appears out of the order declared by the active profile.`,
489
+ message: `Segment "${safeDerivedToken(segmentName2, "segmentId")}" appears out of the order declared by the active profile.`,
393
490
  position
394
491
  };
395
492
  }
396
493
  function versionMismatch(position, declared, expected) {
397
494
  return {
398
495
  code: WARNING_CODES.VERSION_MISMATCH,
399
- message: `HL7 version mismatch: message declares "${declared}", expected "${expected}".`,
496
+ message: `HL7 version mismatch: message declares "${safeDerivedToken(declared, "version")}", expected "${expected}".`,
400
497
  position
401
498
  };
402
499
  }
403
500
  function unknownCharset(position, requested) {
404
501
  return {
405
502
  code: WARNING_CODES.UNKNOWN_CHARSET,
406
- message: `Unrecognized character set "${requested}"; not decoded, bytes read as latin1.`,
503
+ message: `Unrecognized character set "${safeCharsetLabel(requested)}"; not decoded, bytes read as latin1.`,
407
504
  position
408
505
  };
409
506
  }
410
507
  function unsupportedCharset(position, code) {
411
508
  return {
412
509
  code: WARNING_CODES.UNSUPPORTED_CHARSET,
413
- message: `Character set "${code}" was not decoded; bytes read as latin1.`,
510
+ message: `Character set "${safeCharsetLabel(code)}" was not decoded; bytes read as latin1.`,
414
511
  position
415
512
  };
416
513
  }
@@ -425,7 +522,7 @@ function mergeMissingPriorOrSurvivor(position, eventType, missing) {
425
522
  const detail = missing === "prior" ? "no MRG segment carries a usable prior (non-surviving) identifier" : "no PID segment carries a usable surviving identifier";
426
523
  return {
427
524
  code: WARNING_CODES.MERGE_MISSING_PRIOR_OR_SURVIVOR,
428
- message: `Identity event "${eventType}" is missing its ${missing} role: ${detail}. Surfacing what is present; the MRG->PID merge direction is not guessed.`,
525
+ message: `Identity event "${safeDerivedToken(eventType, "messageType")}" is missing its ${missing} role: ${detail}. Surfacing what is present; the MRG->PID merge direction is not guessed.`,
429
526
  position
430
527
  };
431
528
  }
@@ -471,69 +568,6 @@ function emitIfFramed(result, emit, position) {
471
568
  }
472
569
  }
473
570
 
474
- // src/parser/charset.ts
475
- function normalizeLabel(raw) {
476
- return raw.trim().replace(/\s+/gu, " ").toUpperCase();
477
- }
478
- function decodeEntry(canonical, decoder) {
479
- return { canonical, treatment: "decode", decoder };
480
- }
481
- function verbatimEntry(canonical) {
482
- return { canonical, treatment: "verbatim", decoder: "" };
483
- }
484
- function buildTable() {
485
- const table = /* @__PURE__ */ new Map();
486
- const add = (aliases, entry) => {
487
- for (const alias of aliases) table.set(normalizeLabel(alias), entry);
488
- };
489
- add(["ASCII", "US-ASCII", "ISO IR6"], decodeEntry("ASCII", "utf-8"));
490
- add(["UTF-8", "UTF8", "UNICODE UTF-8", "UNICODE"], decodeEntry("UTF-8", "utf-8"));
491
- add(["8859/1", "ISO-8859-1", "ISO 8859-1", "ISO8859-1"], decodeEntry("8859/1", "latin1"));
492
- for (const n of [2, 3, 4, 5, 6, 7, 8, 10, 13, 14, 15, 16]) {
493
- add(
494
- [`8859/${n}`, `ISO-8859-${n}`, `ISO 8859-${n}`, `ISO8859-${n}`],
495
- decodeEntry(`8859/${n}`, `iso-8859-${n}`)
496
- );
497
- }
498
- add(["8859/9", "ISO-8859-9", "ISO 8859-9", "ISO8859-9"], verbatimEntry("8859/9"));
499
- add(["8859/11", "ISO-8859-11", "ISO 8859-11", "ISO8859-11"], verbatimEntry("8859/11"));
500
- add(["ISO IR14", "JIS X 0201"], verbatimEntry("ISO IR14"));
501
- add(["ISO IR87", "JIS X 0208"], verbatimEntry("ISO IR87"));
502
- add(["ISO IR159", "JIS X 0212"], verbatimEntry("ISO IR159"));
503
- add(["GB 18030-2000", "GB18030", "GB 18030"], verbatimEntry("GB 18030-2000"));
504
- add(["KS X 1001", "KSX1001", "KS C 5601"], verbatimEntry("KS X 1001"));
505
- add(["CNS 11643-1992", "CNS 11643"], verbatimEntry("CNS 11643-1992"));
506
- add(["BIG-5", "BIG5"], verbatimEntry("BIG-5"));
507
- add(["UNICODE UTF-16", "UTF-16"], verbatimEntry("UNICODE UTF-16"));
508
- add(["UNICODE UTF-32", "UTF-32"], verbatimEntry("UNICODE UTF-32"));
509
- return table;
510
- }
511
- var CHARSET_TABLE = buildTable();
512
- var DEFAULT_RESOLUTION = Object.freeze({
513
- canonical: "ASCII",
514
- treatment: "decode",
515
- decoder: "utf-8",
516
- recognized: true
517
- });
518
- function resolveCharset(raw) {
519
- if (raw === void 0) return DEFAULT_RESOLUTION;
520
- const key = normalizeLabel(raw);
521
- if (key === "") return DEFAULT_RESOLUTION;
522
- const entry = CHARSET_TABLE.get(key);
523
- if (entry !== void 0) {
524
- return {
525
- canonical: entry.canonical,
526
- treatment: entry.treatment,
527
- decoder: entry.decoder,
528
- recognized: true
529
- };
530
- }
531
- return { canonical: key, treatment: "verbatim", decoder: "", recognized: false };
532
- }
533
- function canonicalCharset(raw) {
534
- return resolveCharset(raw).canonical;
535
- }
536
-
537
571
  // src/parser/normalize.ts
538
572
  var CHARSET_POSITION = { segmentIndex: 0, fieldIndex: 18 };
539
573
  function preserveVerbatim(input) {
@@ -928,8 +962,8 @@ function analyzeMessageStructure(messageCode, triggerEvent, presentSegmentNames)
928
962
  if (def === void 0) {
929
963
  return Object.freeze({
930
964
  recognized: false,
931
- messageCode,
932
- triggerEvent,
965
+ messageCode: boundedIdentifier(messageCode, "segmentId"),
966
+ triggerEvent: boundedIdentifier(triggerEvent, "segmentId"),
933
967
  expectedGroups: Object.freeze([]),
934
968
  missingGroups: Object.freeze([])
935
969
  });
@@ -2161,7 +2195,24 @@ var EMPTY_REP = Object.freeze({
2161
2195
 
2162
2196
  // src/model/segment.ts
2163
2197
  var Segment = class {
2164
- /** Segment name (3 chars, e.g. `"PID"`, `"OBX"`, `"ZPI"`). */
2198
+ /**
2199
+ * Segment identifier: three characters with a leading letter, e.g. `"PID"`,
2200
+ * `"OBX"`, `"ZPI"`.
2201
+ *
2202
+ * **Bounded, and it is `"<withheld>"` when the raw name is not that shape.**
2203
+ * A line with no field separator has its whole content read as a segment
2204
+ * name, so an unescaped line break inside a narrative field forges a
2205
+ * "segment" whose name is clinical text. This field presents itself as a
2206
+ * structural identifier and consumers interpolate it into labels, loci and
2207
+ * reports, so it never carries that text. `""` still means absent, which is
2208
+ * a different fact from withheld.
2209
+ *
2210
+ * Use {@link Segment.raw}`.name` when you need the verbatim text: it is the
2211
+ * unbounded value, and it is what serialization emits, so the byte-verbatim
2212
+ * round-trip is unaffected. One consequence worth knowing: a segment whose
2213
+ * raw name fails the shape (a 4-character vendor Z-segment, which HL7 v2
2214
+ * Ch. 2 §2.5 does not permit) is not matched by `msg.segments(name)`.
2215
+ */
2165
2216
  type;
2166
2217
  /** Reference to the underlying `RawSegment.fields`: 1-indexed per HL7 convention. */
2167
2218
  fields;
@@ -2194,7 +2245,7 @@ var Segment = class {
2194
2245
  */
2195
2246
  constructor(raw, enc, absoluteIndex, customFields) {
2196
2247
  this.raw = raw;
2197
- this.type = raw.name;
2248
+ this.type = boundedIdentifier(raw.name, "segmentId");
2198
2249
  this.fields = raw.fields;
2199
2250
  this.enc = enc;
2200
2251
  this.absoluteIndex = absoluteIndex;
@@ -3885,6 +3936,16 @@ var Hl7Message = class {
3885
3936
  */
3886
3937
  rawSegments;
3887
3938
  encodingCharacters;
3939
+ /**
3940
+ * HL7 version the model asserts for this message, from MSH-12.1.1, e.g.
3941
+ * `"2.5"` or `"2.5.1"`.
3942
+ *
3943
+ * **Bounded, and it is `"<withheld>"` when MSH-12 does not hold a version.**
3944
+ * Same reasoning as {@link Segment.type}: it presents as an identifier and
3945
+ * consumers label with it. `meta.version` keeps MSH-12 exactly as it
3946
+ * arrived, and is what version-scoped field selection reads, so this bound
3947
+ * changes no parsing behaviour.
3948
+ */
3888
3949
  version;
3889
3950
  warnings;
3890
3951
  profile;
@@ -3955,7 +4016,7 @@ var Hl7Message = class {
3955
4016
  constructor(init) {
3956
4017
  this.rawSegments = init.segments;
3957
4018
  this.encodingCharacters = init.encodingCharacters;
3958
- this.version = init.version;
4019
+ this.version = boundedIdentifier(init.version, "version");
3959
4020
  this.warnings = Object.freeze(init.warnings.slice());
3960
4021
  this.profile = init.profile;
3961
4022
  if (init.customSegments !== void 0) this._customSegments = init.customSegments;
@@ -4793,7 +4854,12 @@ function resolveBufferCharset(raw, options, emit) {
4793
4854
  emit(
4794
4855
  encodingMismatch(
4795
4856
  { segmentIndex: 0 },
4796
- `options.charset="${override}" disagrees with MSH-18="${declared}"`
4857
+ // `declared` is MSH-18 read off the wire. On a message with no
4858
+ // segment terminators it is not MSH-18 at all but the 18th
4859
+ // `|`-token of the flattened message, commonly a data field, so it
4860
+ // is shape-checked before it reaches a log surface. `override` is
4861
+ // the caller's own `options.charset`, not input, and is left alone.
4862
+ `options.charset="${override}" disagrees with MSH-18="${safeCharsetLabel(declared)}"`
4797
4863
  )
4798
4864
  );
4799
4865
  }
@@ -6808,7 +6874,7 @@ async function* parseStream(source, optionsOrProfile) {
6808
6874
  }
6809
6875
 
6810
6876
  // src/index.ts
6811
- var VERSION = "0.0.3";
6877
+ var VERSION = "0.0.4";
6812
6878
 
6813
6879
  exports.ACK_CODES = ACK_CODES;
6814
6880
  exports.ACK_CONDITIONS = ACK_CONDITIONS;