@cosyte/hl7 0.0.3 → 0.0.5

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,45 @@ 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
+
35
+ **SUPERSEDED on 2026-07-31 by `1aee04b`, which replaced this banner with the shared Cosyte lockup
36
+ in a `<picture>` block.** The two paragraphs above are left exactly as they shipped rather than
37
+ corrected, because they were true of the tree and right on the evidence available when they were
38
+ written, and they record a real state the package passed through. This note says what changed
39
+ under them. It shipped verbatim inside the `@cosyte/hl7@0.0.4` tarball, so it is text a consumer
40
+ already has on disk.
41
+
42
+ **The claim the new evidence overturns is the second one: "whether npm's markdown sanitizer
43
+ preserves a `<picture>` element is unverified".** That is measured now. The first claim, that the
44
+ banner is a markdown image and "not `<img>` and not `<picture>`", was a consequence of the second
45
+ rather than an independent choice, so it went when its premise did; it is no longer true of the
46
+ tree either way, since `README.md` now opens with a `<picture>`. What was measured: **GitHub
47
+ honours the `prefers-color-scheme` switch**, verified on `@cosyte/astm` in dark mode, where the
48
+ rendered image's `currentSrc` resolves to the on-dark tile and its parent element is `PICTURE`.
49
+ **On the npm package page the `<img>` is hoisted out of its `<picture>` by the anchor wrapper**,
50
+ so the inner fallback renders rather than the element being stripped or broken; and **npmjs.com
51
+ has no dark mode** (founder-confirmed), so the light cut is the correct image there regardless.
52
+ The GitHub and npm measurements above were taken on `astm` and reported into this repo rather
53
+ than re-taken here; what was re-checked directly for `hl7` was that both tile URLs return
54
+ `200 image/png`. **PNG only** and **alt text is content, not decoration** are both unchanged and
55
+ still hold; only the element choice and its stated premise moved.
56
+
18
57
  - **The published JSDoc is swept, and `src/` doc comments are gated
19
58
  (`pnpm check:no-internal-refs` third pass; `PUBLIC-SURFACE-HYGIENE`).** Doc comments compile
20
59
  into `dist/index.d.ts` and `dist/index.d.cts`, `dist` is the first entry in `files`, and every
@@ -397,6 +436,29 @@ kind, value)`** sets one at a field or `field[rep]` dot-path. Component values a
397
436
 
398
437
  ### Changed
399
438
 
439
+ - **`README.md` now opens with the shared Cosyte lockup in a `<picture>` block, replacing the
440
+ per-package banner (`1aee04b`).** The dark-ground org tile sits behind a
441
+ `prefers-color-scheme: dark` media query with the light-ground tile as the inner `<img>`, so on a
442
+ renderer that honours the switch the mark sits on a ground matching the page it is read on. One
443
+ markdown image line out, four lines in: the `# @cosyte/hl7` H1, the blockquote tagline, the badge
444
+ row and everything below are untouched.
445
+
446
+ **Why the per-package banner goes.** It baked the package name and the one-line tagline into
447
+ pixels, and the two lines directly beneath it repeated both. The shared lockup reads "Cosyte"
448
+ while the H1 reads `@cosyte/hl7`, so the strings differ, the duplication goes away, and the
449
+ heading stays.
450
+
451
+ **The failure mode is safe**: a renderer that strips `<source>` renders the inner `<img>`, so the
452
+ worst case is a light-ground mark on a dark page, never a missing or broken image. The block was
453
+ copied out of `astm/README.md` rather than retyped and the two URLs then diffed byte for byte
454
+ against that file, because a transcription error in one of them is a broken image on a public
455
+ package page. Both were re-verified `200 image/png` (10513 and 10455 bytes) immediately before
456
+ the push: the `live` flag in the `assets` manifest is a declaration made on evidence from another
457
+ repo, not a fact checked here. The alt text describes the mark rather than the package, since it
458
+ is what a screen reader on the npm page reads out and what a reader gets when the image fails.
459
+ This supersedes the `ASSETS-P8` banner entry under **Added** above, which is annotated in place
460
+ rather than rewritten. Documentation only: no runtime, API or packaging change.
461
+
400
462
  - **Corrected stale "not yet published to npm / unpublished" language in the docs site
401
463
  (README-ORG-SWEEP).** `@cosyte/hl7` is now published on npm at `0.0.1` and public, so the
402
464
  `docs-content/installation.md` Status blockquote ("not yet published… the shape it will take at
@@ -433,6 +495,153 @@ kind, value)`** sets one at a field or `field[rep]` dot-path. Component values a
433
495
 
434
496
  ### Fixed
435
497
 
498
+ - **A warning or error message could carry clinical text into a log line (`HL7-WARN-MSG-PHI`).**
499
+ No options were required to reach it: plain `parseHL7(raw)` on lenient defaults.
500
+
501
+ **The mechanism.** `normalize()` rewrites every `\r\n` and `\n` in the input to `\r` before
502
+ segment splitting, unconditionally and with no field awareness. HL7 v2 Ch. 2 fixes the segment
503
+ terminator as `<CR>` and provides `\.br\` (§2.7.6) precisely because a literal line break cannot
504
+ appear inside a field, but real senders emit raw breaks inside narrative fields (`OBX-5`, notes,
505
+ embedded documents), which is exactly the class of sender violation this parser exists to
506
+ tolerate. When one occurs, the remainder of that field becomes a forged segment, and `tokenize()`
507
+ takes the whole line as its segment identifier because a line with no field separator offers no
508
+ other candidate. `UNKNOWN_SEGMENT` then interpolated that "identifier" uncapped, putting synthetic
509
+ name, date of birth and medical record number into `Hl7ParseWarning.message`. Under
510
+ `{ strict: true }` the same text reached the thrown `Hl7ParseError.message`, and therefore
511
+ `.stack`.
512
+
513
+ **The disclosure was inverted, which is what made it consequential.** Guidance said to redact
514
+ `Hl7ParseError.snippet`. On the strict path `snippet` holds the head of the message (usually the
515
+ MSH header) while `message` held the payload, so a consumer following the documented mitigation
516
+ exactly still logged the data. `message` is also the field a logger prints by default and the one
517
+ `stack` embeds.
518
+
519
+ **The fix: bound at the factory, not at the call site.** A token taken from input is echoed only
520
+ when it matches the form the spec defines for it, and becomes `<withheld>` otherwise. A length cap
521
+ was rejected because truncating still emits the first N characters of a patient name. Segment
522
+ identifiers are held to exactly three alphanumerics (HL7 v2 Ch. 2 §2.5), with equivalent forms for
523
+ MSH-9 types and MSH-12 versions. Conforming tokens render byte-identically, so `PID`, `ZZZ` and
524
+ every legitimate Z-segment name are unchanged and well-formed input sees no behaviour change:
525
+ 1653 existing tests pass untouched. Placing the check in the factories rather than at the one live
526
+ call site also covers the six factories currently exported but unreached, and consumers who
527
+ construct warnings themselves.
528
+
529
+ **Charset labels are bounded by registry membership, not by spelling, and that distinction was
530
+ earned the hard way.** The first version of this fix used a shape test for them too, and a refuter
531
+ broke it: the shape admitted all five of the PHI-shaped markers this repo's own property suite
532
+ defines, including an 18-character patient name. Table 0211 is a **closed** table and
533
+ `UNKNOWN_CHARSET` fires precisely when a label is absent from it, so at the moment of the warning
534
+ there is no spec-defined spelling left to test against, and `UNICODE UTF-8` and a patient name are
535
+ shape-siblings. No regex can separate them. A label is now echoed only when `resolveCharset`
536
+ recognises it.
537
+
538
+ **What is bounded, stated without overclaiming.** `snippet` is the only field carrying input
539
+ verbatim and unbounded, so it is the one to redact first. A message cannot carry a field's value,
540
+ but it can carry a residue of up to three characters when a malformed line happens to look like a
541
+ segment identifier. No absolute "never carries field content" claim is made anywhere in this
542
+ package, because the code cannot honour one.
543
+
544
+ **Two adjacent surfaces bounded in the same pass.** `UNKNOWN_CHARSET` and `ENCODING_MISMATCH`
545
+ interpolate a value read as MSH-18, which on a message carrying no segment terminators is not
546
+ MSH-18 at all but the 18th `|`-token of the flattened input, commonly a data field. That path is
547
+ Buffer-only, and `parseStream` and `splitBatch` re-encode each split message to a Buffer, which is
548
+ the ordinary MLLP and file ingestion shape, so it is not an exotic corner.
549
+ `MISSING_EXPECTED_GROUP` and `MERGE_MISSING_PRIOR_OR_SURVIVOR` interpolate MSH-9.
550
+
551
+ **The test gap is the real finding.** The PHI property suite could not reach this position: every
552
+ generated marker sat after a `|`, so it was always a field value to the tokenizer, while the only
553
+ position that leaks is a line's **leading** token. A green suite over an unreachable case is
554
+ indistinguishable from a correct one. New generators emit the real trigger (a field-internal `\n`
555
+ and `\r\n`) across the lenient and strict arms, asserting on `.stack` separately from `.message`
556
+ because most error reporters ship the stack off-box, and pinning the escalated code on both
557
+ strict properties so neither can pass vacuously if some other warning escalates first. A second
558
+ set covers the Buffer/MSH-18 path, which every string-input generator in the suite structurally
559
+ misses, and a third asserts the invariant on the parsed **model**. All ten new tests fail on the
560
+ code they were written against and pass on this one.
561
+
562
+ **The same lesson applied to this fix's own first draft, which is why it now bounds the model
563
+ too.** Bounding the warning message protected this package's diagnostics and nothing else.
564
+ `Segment.type` still carried the entire forged line, so a consumer that reads the model and builds
565
+ its own report from that "identifier" wrote clinical narrative into it, on lenient defaults, with
566
+ no options set. **A diagnostic-surface fix does not protect a downstream package that reads your
567
+ model and builds its own diagnostics from it. Bounding at the model protects both.**
568
+ `Segment.type`, `Hl7Message.version` and the unrecognized branch of `MessageStructure` now carry
569
+ bounded identifiers, and the shared primitive moved to `src/parser/tokens.ts` so the parser and the
570
+ model use one bound rather than two.
571
+
572
+ **One bound deliberately not taken, named rather than left implicit.** On the _recognized_ branch
573
+ of `MessageStructure`, `triggerEvent` is echoed verbatim, and a definition matching on message code
574
+ alone (`ACK`) accepts an arbitrary MSH-9.2. Left unbounded on purpose: no real sender putting
575
+ narrative in MSH-9.2 is grounded in a de-identified document, and widening the guard there is a
576
+ separate change, not a silent extension of this one. An earlier comment in that file asserted the
577
+ branch returned registry values and could not carry input, which was false; the claim is corrected
578
+ rather than the guard grown.
579
+
580
+ **What stays unbounded, deliberately, because bounding it would be a mis-parse.** The model
581
+ separates **identifiers** from **values**. `Meta.*` is the documented value view of the MSH header;
582
+ `Segment.fields`, `Segment.raw` and `Hl7Message.rawSegments` are the raw tokenization the
583
+ byte-verbatim round-trip serializes from; `toString()` / `toJSON()` / `prettyPrint()` are the
584
+ message itself. Those are content surfaces and say so. `meta.version` in particular keeps MSH-12
585
+ exactly as it arrived and is what the v2.7 MRG field-scoping reads, so bounding
586
+ `Hl7Message.version` changes no clinical field selection.
587
+
588
+ **Docs corrected in both directions.** `docs-content/troubleshooting.md` asserted that warning
589
+ messages never echo a field's value, an overclaim; it and the equivalent assurances in
590
+ `normalize.ts` and `warnings.ts` now state the check that makes it true. The `Hl7ParseError`
591
+ disclosure now says `snippet` is the field to redact first, rather than claiming a message is
592
+ clean. `README.md` called the snippet an excerpt of "the offending input" when it is always
593
+ taken from the start of the message; the wording is corrected rather than the behaviour, because
594
+ re-pointing the snippet at the deviation's own segment would move more clinical content into the
595
+ single unredacted field.
596
+
597
+ **Not a mis-parse.** Parsed values were always correct; this was a diagnostic-surface leak.
598
+ Consumers on any published version up to and including `0.0.3` should treat
599
+ `Hl7ParseWarning.message` and `Hl7ParseError.message` as potentially carrying field content for
600
+ feeds they do not control until they upgrade.
601
+
602
+ - **Stale counts in `docs-content/`, found while correcting the PHI wording.** The warning-code
603
+ count was given as 19 in three places (it is 20), and two pages quoted a published npm version
604
+ that was two releases stale. Both now point at the registry rather than restating it, which is the
605
+ only way a doc page stops drifting from it.
606
+
607
+ - **Four false claims in the README's feature list, each re-derived from source rather than from
608
+ memory (`README-CLAIM-DRIFT`).** Every number and version below was measured against the thing it
609
+ describes, in the order a consumer would have to trust it:
610
+ 1. **Node 18+ to Node 22+**, in the badge (`node-%3E%3D18`) and in the prose. `engines.node` is
611
+ `>=22.0.0` in `package.json` and in the registry (`npm view @cosyte/hl7 engines`), and CI runs
612
+ the 22 + 24 matrix. This was the one with a consumer-visible consequence: `npm i @cosyte/hl7`
613
+ on Node 18 warns on engines while the package page says 18 is supported.
614
+ 2. **ES2022 to ES2023.** `@cosyte/tsconfig/base.json` sets `"target": "ES2023"` and
615
+ `"lib": ["ES2023"]`, and this repo's `tsconfig.json` extends it without override.
616
+ 3. **18 to 20 stable warning codes.** `WARNING_CODES` in `src/parser/warnings.ts` has 20 keys, and
617
+ `test/warning-codes.snapshot.test.ts` already asserted `toHaveLength(20)`. A green test suite
618
+ and a wrong package page coexisted. **No other surface says 18, but that is not the same as the
619
+ count now being consistent**: further down `README.md`, an inline prose list of 16 code names is
620
+ still introduced as "the full list of Tier-2 codes", and `docs-content/` says 19 in three
621
+ places. Those are pre-existing, outside this feature list, and left for their own change rather
622
+ than folded in here.
623
+ 4. **"Every public export has JSDoc + `@example`" to "every public function and class."** Measured
624
+ over the 213 names in the built `dist/index.d.ts` export list: all **79 functions** and all
625
+ **5 classes** carry both, but of 17 value constants one (`ERR_CONDITION_CODE_SYSTEM`) has JSDoc
626
+ with no `@example`, and while all **109 type-only exports carry JSDoc, only 73 carry an
627
+ `@example`**. The three aliased namespace exports (`HL7`, `conformance`, `text`) fail the old
628
+ wording outright: two carry no JSDoc at all and the third has no `@example`. The narrowed
629
+ sentence is exactly true; the old one was not.
630
+
631
+ **The rest of the feature list was checked the same way and is correct**, so it is unchanged:
632
+ 4 fatal codes (`FATAL_CODES` has exactly 4 keys), 8 built-in vendor profiles (the frozen `profiles`
633
+ namespace has 8, and the eight vendor names match), zero runtime dependencies (`dependencies` is
634
+ present and empty, and there is no `peerDependencies`), `noUncheckedIndexedAccess` (true in the
635
+ inherited base config), dual ESM + CJS (the `exports` map resolves `import` to `.mjs`/`.d.ts` and
636
+ `require` to `.cjs`/`.d.cts`, and `attw` passes node10, node16-CJS, node16-ESM and bundler), and
637
+ the two linked paths (`examples/profile-starter-kit/`, `docs-content/spec-notes-escapes.md`) both
638
+ resolve.
639
+
640
+ **`CLAUDE.md` already said Node >= 22 and Target ES2023, so the repo contradicted itself and
641
+ nothing caught it.** The two files drifted in opposite directions because only one of them is read
642
+ by a consumer, and it is the one that drifted. No gate compares a README claim to the config it
643
+ describes, which is why these were found by hand.
644
+
436
645
  - **`scripts/sync-version.mjs` hardened against two latent defects, and gated in CI
437
646
  (SYNC-VERSION-HARDENING).** Follow-up hardening on the VERSION-SYNC script; ported byte-identically
438
647
  across `hl7`, `x12`, and `mllp`. (1) The version was spliced into `src/index.ts` via
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
3
+ <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ </picture>
5
+
1
6
  # @cosyte/hl7
2
7
 
3
8
  > Parse real-world, vendor-quirky HL7 v2 messages and extract the fields you need in one line, without reading the spec.
@@ -5,7 +10,7 @@
5
10
  [![npm version](https://img.shields.io/npm/v/@cosyte/hl7.svg)](https://www.npmjs.com/package/@cosyte/hl7)
6
11
  [![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
12
  [![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)
13
+ [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
9
14
 
10
15
  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
16
 
@@ -37,10 +42,10 @@ That's the whole pitch: no config, no schema upload, no spec lookup. The parser
37
42
 
38
43
  - **One-line extraction**: `msg.patient.mrn`, `msg.meta.timestamp`, `msg.observations()`, and friends. No segment or field numbers to memorise.
39
44
  - **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.
45
+ - **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
46
  - **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
47
  - **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.
48
+ - **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
49
  - **Warnings carry stable codes + positional context**: react programmatically by `w.code`, with `segmentIndex`/`fieldIndex`/etc. attached.
45
50
 
46
51
  ---
@@ -739,7 +744,7 @@ The library throws exactly three error types, all exported from the package barr
739
744
 
740
745
  ### `Hl7ParseError`
741
746
 
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.
747
+ 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
748
 
744
749
  ```ts
745
750
  import { parseHL7, Hl7ParseError, FATAL_CODES } from "@cosyte/hl7";
@@ -760,7 +765,13 @@ if (err instanceof Hl7ParseError && err.code === FATAL_CODES.NO_MSH_SEGMENT) {
760
765
  }
761
766
  ```
762
767
 
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.
768
+ `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.
769
+
770
+ **`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.
771
+
772
+ `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.
773
+
774
+ 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
775
 
765
776
  ### `Hl7ParseWarning`
766
777
 
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.5";
6812
6878
 
6813
6879
  exports.ACK_CODES = ACK_CODES;
6814
6880
  exports.ACK_CONDITIONS = ACK_CONDITIONS;