@cosyte/hl7 0.0.8 → 0.0.9

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
@@ -1,17 +1,227 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
- Versions and publishing are managed with [Changesets](https://github.com/changesets/changesets);
8
- this file is maintained by hand (Changesets handles the version bump and publish only).
9
-
10
- ## [Unreleased]
11
-
12
- The first pre-alpha release (`0.0.1`) will ship the complete v1 API surface below. An earlier
13
- `0.1.0` tag was prepared but never published, so the package begins its public history at `0.0.x`,
14
- per the cosyte version ladder (`0.0.x` until first alpha).
3
+ ## 0.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 3ac0c28: `CHANGELOG.md`, which ships inside the tarball, is now written by the release instead of by hand, so it stops describing already-published code as unreleased.
8
+
9
+ `.changeset/config.json` set `"changelog": false`, so no release ever wrote a version heading
10
+ into `CHANGELOG.md` and nothing ever rolled `[Unreleased]` over. Every published version of this
11
+ package therefore carried a changelog with **no version headings at all**: one `[Unreleased]`
12
+ heading over the whole history, and a preamble stating that the first pre-alpha release "will
13
+ ship" the API surface listed below it, in a tarball that had shipped that surface several
14
+ versions earlier. `CHANGELOG.md` is listed in `package.json` `files`, so this was text on the
15
+ disk of everyone who installed the package, not internal bookkeeping.
16
+
17
+ **The flag is what changed, not the prose.** Correcting the sentence by hand would have left the
18
+ mechanism that wrote it, and the next release would have drifted the same way. `changelog` now
19
+ names the generator that ships with Changesets, so a release writes its own version heading and
20
+ its own entry from the changesets it consumed, and **the changeset summary is now the changelog
21
+ entry**. Nothing new is depended on: the generator is an entry point of `@changesets/cli`, which
22
+ was already a dev dependency.
23
+
24
+ **The file's shape changed with it, deliberately.** Changesets prepends a release by replacing
25
+ the first newline in the file, so exactly one line can sit above generated output. The
26
+ hand-written preamble sat on line 3, which means a release would have inserted itself between the
27
+ heading and the preamble and split the header in two. The hand-maintained history has therefore
28
+ moved under a `## Released before this file was generated` heading, with the false preamble
29
+ replaced by an accurate one. Three pieces of hand-workflow scaffolding were dropped and no entry
30
+ was reworded: the `[Unreleased]` heading, its link definition at the foot of the file, and the
31
+ five empty section stubs waiting for the next hand-written entry. The history itself is left as
32
+ it was written rather than re-sorted into version sections, because the file never recorded which
33
+ release each entry went out in and the text is already on disk in published copies.
34
+
35
+ Pinned by `test/scripts/changelog-generation.test.ts`, which runs the real `changeset version`
36
+ against the real `CHANGELOG.md` and the real config in a throwaway package rather than
37
+ reimplementing where the tool inserts text. Eight of its ten cases are red against the previous
38
+ state, measured on the tree this change was written against rather than recalled. **The rule it enforces is that nothing but the H1 sits above the first heading, and it is
39
+ asserted on the released document as well as the committed one**: a rule phrased as "the archive
40
+ heading comes second" holds only until the first release writes its own version heading there,
41
+ which would have redded the first Version PR this configuration ever opened. The archived history
42
+ is asserted byte identical across a release, so the run exercises this repo's own Prettier rather
43
+ than the one Changesets bundles as a fallback. Two further controls: the same inputs with
44
+ `"changelog": false` must write no version heading at all, so the flag is proved load-bearing
45
+ rather than incidental; and the old file shape must reproduce the split header, so the rule is
46
+ demonstrated rather than asserted.
47
+
48
+ One upstream behaviour is worth knowing before debugging a release, and is recorded in that
49
+ file: Changesets wraps the changelog write in a try/catch that only warns. A tree whose declared
50
+ Prettier config cannot be resolved bumps the version, consumes the changeset, and writes no
51
+ changelog at all. Reproduced here on this repo's inputs. A release that publishes with an
52
+ unchanged changelog is that failure, not a setting that quietly reverted.
53
+
54
+ `CONTRIBUTING.md` and `.changeset/README.md` said to add the entry to `CHANGELOG.md` by hand and
55
+ now say to write it in the changeset. No runtime code, no public API, no warning code and no
56
+ parse behaviour changed.
57
+
58
+ - d8d3be3: Repository PHI commit-gate only, with no runtime impact: the gate no longer reports a clean sweep over a scan root it never opened, where an absent or dangling `test/fixtures` used to print `OK: no hits` and exit 0 on the strength of `src/` alone.
59
+
60
+ `pnpm phi-scan` with no arguments is what CI runs. It walks two roots, `test/fixtures` and `src`,
61
+ and refused a sweep only when it had read **zero files in total**. That global rule is satisfied by
62
+ any one surviving file, so a single `src/` module vouched for the entire fixture corpus, and one
63
+ fixture vouched for the whole of `src`.
64
+
65
+ Reproduced before the fix, on this checkout, three ways, each printing `[phi-scan] OK: no hits` and
66
+ exiting **0**: with `test/fixtures` moved aside, with `test/fixtures` replaced by a **dangling
67
+ symlink**, and with `src` moved aside. The first two leave all 106 non-markdown fixtures unread;
68
+ the third leaves all 95 source files unread.
69
+
70
+ **The dangling case is the sharpest, and nothing else in the scanner can see it.** `existsSync`
71
+ **follows** the link and answers false, so `walk()` returns before `readdirSync` and the
72
+ not-a-regular-file refusal never fires. That rule only ever classifies entries found INSIDE a root,
73
+ and this is the root itself, so absent, dangling and empty are one state to the walk. This reporter
74
+ prints no file count either, so unlike the sibling this remedy was ported from there was not even a
75
+ suspicious denominator to notice.
76
+
77
+ **The rule is now per-root.** All-mode refuses (exit 2) unless every declared root yielded at least
78
+ one file that was actually READ, and the refusal names the starved roots. Observing nothing at all
79
+ is the all-starved case of that one rule rather than a second rule beside it, so the existing
80
+ wording carries. Hits found under the roots that DID yield are printed before the refusal, so an
81
+ incomplete sweep never swallows a real finding, and the exit code is still 2 regardless.
82
+
83
+ **Deliberately unchanged, and pinned:** `--staged` and named-path mode make no per-root promise,
84
+ because neither enumerates a root. `--staged` legitimately has nothing to scan when a commit
85
+ touches only markdown, and named-path mode is bounded by the caller's argv. Widening the rule to
86
+ either was run as a mutation rather than argued: widening it to `--staged` reds 7 cases, and
87
+ dropping the mode guard altogether reds 29.
88
+
89
+ **The granularity is the declared root and nothing finer**, which is a real bound and is recorded
90
+ rather than glossed. Three states still exit 0, each measured with the rule in place, each now in
91
+ the scanner's limits list with the command that produced it, and each pinned by a characterization
92
+ test so a change that closed one would have to say so: a directory missing from INSIDE a root
93
+ (`mv test/fixtures/canonical ..` still prints `OK: no hits` with 27 fixtures unread); a root that
94
+ is ITSELF a symlink, which is followed, so the rule is satisfied by whatever is on the other side
95
+ of it; and the rule is a floor of ONE file, whatever the root used to hold.
96
+
97
+ One shape often listed beside those is NOT open here, and it is written down so it is not ported in
98
+ from elsewhere as though it were: a root that is a regular file, or a link to one, refuses at exit
99
+ **2**, not the **1** this gate reserves for hits. `walk()` already wraps `readdirSync` and rethrows
100
+ the `ENOTDIR` as an invocation error on the scanner's own channel, and an existing test pins it.
101
+
102
+ Twelve tests added, on a throwaway repository so no starved root, no dangling link and no violator
103
+ is ever written into the committed corpus. Five are red against the superseded global rule; the
104
+ all-starved case is green on both, because it asserts the new rule SUBSUMES the old one rather than
105
+ sitting beside it; three assert the rule did not widen; three are characterizations of the limits
106
+ above. The scratch-repo helper now seeds both walk roots, which is load-bearing rather than
107
+ tidiness: with an empty `test/fixtures` every case built on it would refuse before reaching what it
108
+ meant to assert.
109
+
110
+ - be842bd: Repository PHI commit-gate only, with no runtime impact: the gate now reads tracked files directly under `test/`, reads an inline `PID|...` literal in hand-written source with the same field map a `.hl7` fixture gets, and no longer drops to a shape-only pass over a blob staged at exactly a scan root.
111
+
112
+ `pnpm phi-scan` with no arguments is what CI runs; `pnpm phi-scan --staged` is the pre-commit hook.
113
+ Four separate holes, each pre-existing and each measured on this checkout before the change.
114
+
115
+ **One: a tracked file directly under `test/` was enumerated by neither route.** The walk was rooted
116
+ at `test/fixtures` and `src`, and `--staged` matched `test/fixtures/**` plus `src/**.ts`. That
117
+ leaves **115 tracked files** in this repo scanned by nothing at all. A file at `test/<name>.ts`
118
+ carrying a live `PID-3`/`PID-5`/`PID-7`/`PID-11`/`PID-13` printed `OK: no hits` and exited **0** on
119
+ both routes, over the same bytes that give six segment-aware hits at a `.hl7` path.
120
+
121
+ **Two, and it is the half that a wider walk root does NOT buy: the recogniser assumes the file IS
122
+ the document, and an inline fixture is a TypeScript string literal.** Bringing `test/**` into scope
123
+ bought the conservative shape floor -- a dashed SSN and a non-test email domain -- over those 115
124
+ files and nothing else: no name, DOB, MRN, address or phone detection. **55 of the 115 carry an
125
+ inline `PID|` literal.** Widening the scope and widening the recogniser are two changes, and the
126
+ second is in addition to the first, never instead of it. So there is now an embedded-literal pass
127
+ that pulls `PID|...` runs out of source and hands them the same field map the whole-file parse uses.
128
+ Both halves are pinned with a case that is red before and green after, and the residual is pinned
129
+ too: the same identifying values written as PROSE in the same file are still not found, because a
130
+ name in narrative is not separable from clinical vocabulary without NLP.
131
+
132
+ The embedded pass is deliberately narrower than the whole-file parse, and each of its four limits
133
+ is stated rather than left to be discovered. Two of them are here because review refuted the first
134
+ draft of this paragraph, which is the reason to read them rather than trust the summary.
135
+
136
+ It uses the default delimiters only, because an embedded run has no reachable `MSH-2`: the encoding
137
+ characters are themselves source-escaped. It runs no unknown-segment backstop, which is right for a
138
+ known message and a noise cannon over program text, so an inline `Z...` segment carrying a name is
139
+ missed. It erases a `${...}` interpolation rather than reading the variable's identifier as a
140
+ person's name, and the bound there is any value reached through an interpolation, INCLUDING one
141
+ whose literal sits in the same file: measured, a surname in a same-file constant interpolated into a
142
+ PID line exits 1 on the MRN and the DOB and is silent on the name. This pass reads literals in field
143
+ position and evaluates nothing; the shape floor still reads the whole file, so a dashed SSN or a
144
+ non-test email in such a constant is caught either way. And a run is cut at the first source quote,
145
+ which cuts HL7 data as well as program text: the two-character literal `""` is HL7 v2's
146
+ explicit-null field value, so an inline fixture exercising null handling loses everything after it,
147
+ and an apostrophe inside a name truncates identically. No clause number is quoted for that, on
148
+ purpose: the behaviour is grounded in this package's own parser, whose null indicator is true iff
149
+ the field was exactly those two characters. That last one is a MISS, and it is written down rather than
150
+ patched, because the enclosing quote style is not knowable from a single line and a wrong guess
151
+ reads program text as patient data.
152
+
153
+ `src/` changed intent and not only scope, so the comment that said otherwise is corrected rather
154
+ than left standing: the embedded pass runs on EVERY non-fixture target, so a JSDoc `@example`
155
+ carrying a PID line with a non-allow-listed name now reds where it did not before. This package
156
+ requires an `@example` on every public export, so a new example must use allow-listed tokens or add
157
+ its own, exactly as a fixture does. The committed corpus is green as it stands.
158
+
159
+ **Three: a regular blob staged at exactly `test/fixtures` got the shape floor only, and `--staged`
160
+ is the pre-commit hook.** The scope and the mode check already passed it through; the DETECTOR gate
161
+ dropped it, because the fixture test wanted a `test/fixtures/` prefix or an `.hl7` suffix and the
162
+ root's own path has neither. Measured twice independently: the same payload gives six segment-aware
163
+ hits at `test/fixtures/<name>.hl7` and exit **0** at `test/fixtures`. A dashed SSN in that blob
164
+ exited **1** either way, so the floor held and only the segment-aware tier was missing -- which is
165
+ why the remedy is one clause and not a new rule.
166
+
167
+ **And a fourth, found by a sibling and reproduced here: `-B` is not inert, and this scanner's own
168
+ comment said it was.** That was the permissive half of a "do not add these flags" paragraph, so it
169
+ told the next reader that injecting `-B` was safe. The mechanism is sharper than "a `B` record the
170
+ filter drops": under `-B` a complete rewrite prints status letter **`M`** with a break score, one
171
+ path, which the raw-record parser handles happily -- but `--diff-filter` classifies a broken pair as
172
+ **`B`** whatever letter it prints, so `AMTU` deleted the record before anything saw it. Through the
173
+ scanner, over a staged dashed SSN in a wholly rewritten in-scope file: the shipped argv exits 1, the
174
+ same argv plus `-B` printed `OK: no hits` and exited 0. `B` is now in the filter. It costs the
175
+ enumeration nothing, because git cannot emit a broken pair without the flag. **The pin that let the
176
+ false claim survive was a pin on a rename**, the one shape `-B` really does leave alone: a case that
177
+ cannot fail proves nothing.
178
+
179
+ **Two behaviour changes that follow from `test` being a walk root, recorded rather than left to be
180
+ found.** The fixture root is now an ENTRY INSIDE another root, so a symlink there is classified by
181
+ `Dirent.isSymbolicLink()` and refused by name at exit 2, dangling or not: that closes, for
182
+ `test/fixtures` only, the residual the previous entry recorded as "a root that is itself a symlink
183
+ is followed". It does not generalise -- `src` and `test` have no walked parent, so both are still
184
+ followed, and the general rule is the parent, not the root. And the two roots nest, so coverage now
185
+ credits EVERY root a file sits under rather than the first: crediting one would leave the other
186
+ permanently starved, and which one depended on list order with nothing saying so. Walked entries are
187
+ de-duplicated for the same reason, or every fixture would be reported twice.
188
+
189
+ The scanner's own test file was the one place this could not be resolved with the allow-list, and it
190
+ is worth saying why: it holds deliberate violators, every one of them asserted on, so allow-listing
191
+ them would have made the positive tests vacuous and carving the file out of the scan would have been
192
+ the gate excusing itself from the rule it enforces. Its violator identities are assembled at runtime
193
+ instead -- the discipline the file already used for its SSN sentinel -- so the temp files it builds
194
+ still carry the full violator bytes and nothing in the tracked file is a PHI-shaped run. One
195
+ registrable `.org` email in it became a permanently unregistrable `.invalid` one on the way past.
196
+ Every other new allow-list token was surfaced by the gate rather than guessed at, and they are the
197
+ same species as the placeholder surnames already listed.
198
+
199
+ Twenty-three net new tests in the scanner's own suite (78 to 101), plus several existing ones
200
+ rewritten where the widening inverted their verdict, each change of verdict recorded rather than
201
+ deleted. All of them run on throwaway repositories, so no violator is ever written into the
202
+ committed corpus. Nothing here changes a single byte of the published package.
203
+
204
+ ## Released before this file was generated
205
+
206
+ Every release section above this heading is written by
207
+ [Changesets](https://github.com/changesets/changesets) from the changesets in `.changeset/`, newest
208
+ release first. The release writes its own version heading, so nothing above this line is maintained
209
+ by hand, and a change is recorded by adding a changeset rather than by editing this file.
210
+
211
+ Everything below this heading was maintained by hand. It sat under a single `[Unreleased]` heading
212
+ that no release ever rolled over, which is why it went on describing already-shipped code as
213
+ unreleased, inside the published tarball, for as long as it did. It is left as it was written
214
+ rather than re-sorted into version sections: the file never recorded which release each entry went
215
+ out in, and this is the text that installed copies already carry on disk. Only three things were
216
+ dropped, all of them scaffolding for the hand-written workflow that no longer runs: the
217
+ `[Unreleased]` heading itself, its link definition at the foot of the file, and the five empty
218
+ section stubs that existed to receive the next hand-written entry. No entry was reworded.
219
+
220
+ The entries below follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the generated
221
+ sections above use the format Changesets writes, which is a version heading and a list of the
222
+ changes that release consumed. Versions follow the cosyte pre-alpha ladder, `0.0.x` until first
223
+ alpha, rather than [Semantic Versioning](https://semver.org/spec/v2.0.0.html) alone. An earlier
224
+ `0.1.0` tag was prepared but never published, which is why the public history begins at `0.0.x`.
15
225
 
16
226
  ### Added
17
227
 
@@ -605,6 +815,128 @@ kind, value)`** sets one at a field or `field[rep]` dot-path. Component values a
605
815
 
606
816
  ### Fixed
607
817
 
818
+ - **The PHI scan reached neither `test/**` nor an inline fixture, and dropped to a shape-only pass
819
+ at a scan root's own path (`PHI-SCAN-WALK-ROOT-SCOPE`, plus `PHI-SCAN-RENAME-BLIND-AT-PRECOMMIT`▶(b)).** Four`PRE-EXISTING`holes, each measured on`d8d3be3` before the change.
820
+
821
+ **1. A tracked file directly under `test/` was enumerated by NEITHER route.** The walk was rooted
822
+ at `test/fixtures` + `src`; `--staged` matched `test/fixtures/**` + `src/**.ts`. That left **115
823
+ tracked files** scanned by nothing. A `test/<name>.ts` carrying a live
824
+ `PID-3`/`PID-5`/`PID-7`/`PID-11`/`PID-13` printed `OK: no hits` and exited **0** on both routes,
825
+ over bytes that give six segment-aware hits at a `.hl7` path. `SCAN_ROOTS` is now
826
+ `test/fixtures` + `test` + `src`, and `--staged` adds `test/**` (markdown excluded, copying
827
+ `walk()`'s rule) as a **union** clause, so nothing it enumerated before stops being enumerated.
828
+
829
+ **2. THE HALF A WIDER ROOT DOES NOT BUY: the recogniser assumes the file IS the document, and an
830
+ inline fixture is a `.ts` string literal.** Scope alone bought the dashed-SSN + email floor over
831
+ those 115 files and nothing else: no name, DOB, MRN, address or phone. **55 of the 115 carry an
832
+ inline `PID|` literal.** So there is now an embedded-literal pass (`scanEmbeddedHl7`) that pulls
833
+ `PID|…` runs out of source and hands them the **same field map** the whole-file parse uses; the
834
+ detectors are one shared function, not a second copy. Both halves are pinned RED-before /
835
+ GREEN-after, **and so is the residual**: the same values written as PROSE in the same file are
836
+ still not found. The pass is deliberately narrower than the whole-file parse and each limit is
837
+ stated, and two of them only after review refuted the first draft. Default delimiters only (an
838
+ embedded run has no reachable `MSH-2`); no unknown-segment backstop (a noise cannon over program
839
+ text, so an inline `Z…` name is missed); a `${…}` interpolation is **erased, not read**
840
+ (`familyName`, `content`, `marker` and `pidSurname` were each reported as a patient name), and the
841
+ bound there is **any value reached through an interpolation, including one whose literal is in the
842
+ same file** (measured: `const fam = "<surname>"` interpolated into a PID line exits 1 on the MRN
843
+ and DOB and is **silent on the name**); and **a run is cut at the first source quote, which cuts
844
+ HL7 data too** (the two-character literal `""` is HL7 v2's explicit-null field value, grounded in
845
+ this repo's own parser rather than a clause number nobody verified, so
846
+ `PID|1|""|MRN001||<surname>^<given>||<dob>|F` exits **1** at a `.hl7` path and **0** inline,
847
+ because the extracted run is `PID|1|`; `O'Brien` truncates identically). The last one is a MISS
848
+ and is written down rather than patched: the enclosing quote style is not knowable from one line,
849
+ and a wrong guess reads program text as patient data.
850
+
851
+ **`src/` changed intent, not just scope**, and the comment that said otherwise is corrected: the
852
+ embedded pass runs on **every** non-fixture target, so a JSDoc `@example` carrying a PID line with
853
+ a non-allow-listed name now reds (measured: exit 0 on `d8d3be3`, exit 1 here). This repo
854
+ hard-requires an `@example` on every public export, so a new example must use allow-listed tokens
855
+ or add its own, exactly like a fixture. The committed corpus is green as it stands.
856
+
857
+ **3. ▶(b): a regular blob staged at exactly `test/fixtures` got the shape floor only, and
858
+ `--staged` IS the pre-commit hook.** Scope and the mode check passed it through; the **detector
859
+ gate** dropped it, because `looksLikeHl7` wanted a `test/fixtures/` prefix or an `.hl7` suffix and
860
+ the root's own path has neither. Measured twice: six segment-aware hits at
861
+ `test/fixtures/<name>.hl7`, exit **0** at `test/fixtures`; a dashed SSN in the same blob exited
862
+ **1** either way, so the floor held and only the segment-aware tier was missing, so it is one clause, not
863
+ a new rule.
864
+
865
+ **4. `-B` IS NOT INERT, AND THIS SCANNER SAID IT WAS** (found by `terminology#45`, reproduced
866
+ here). The permissive half of a "do not add these flags" paragraph. The mechanism is sharper than
867
+ "a `B` record `AMTU` drops": a complete rewrite under `-B` prints status letter **`M`** with a
868
+ break score (the digits move with how much old content survives, so none are quoted here), one
869
+ path, which `RAW_RECORD` parses happily. But `--diff-filter`
870
+ classifies a broken pair as **`B`** whatever letter it prints. `--diff-filter=AMTU` returns empty;
871
+ `B` and `AMTUB` return the record. Through the scanner over a staged dashed SSN in a wholly
872
+ rewritten in-scope file: shipped argv exit **1**, same argv plus `-B` exit **0**. The filter is
873
+ now `AMTUB`, which costs the enumeration nothing (git cannot emit a broken pair without the flag).
874
+ **The pin that let the false claim live was a pin on a RENAME**, the one shape `-B` leaves alone.
875
+
876
+ **Two behaviour changes from `test` becoming a root.** `test/fixtures` is now an ENTRY INSIDE a
877
+ walked root, so a symlink there is refused by name at exit **2**, dangling or not: that closes,
878
+ **for `test/fixtures` only**, the "a root that is itself a symlink is followed" residual below.
879
+ It does not generalise: `src` and `test` have no walked parent, so both are still followed, and
880
+ the boundary is the PARENT, not the root. And the roots now NEST, so coverage credits **every**
881
+ root a file sits under rather than the first (crediting one would starve the other permanently,
882
+ and which one depended on list order with nothing saying so); walked entries are de-duplicated
883
+ for the same reason.
884
+
885
+ **The scanner's own test file could not be resolved with the allow-list**, and that is the
886
+ interesting part: it holds deliberate violators, every one asserted on, so allow-listing them
887
+ would make the positive tests vacuous and carving it out of the scan would be the gate excusing
888
+ itself from its own rule. Its violator identities are **assembled at runtime** instead (the
889
+ discipline the file already used for its SSN sentinel), so the temp files it builds still carry
890
+ the full violator bytes and the tracked file holds no PHI-shaped run. One registrable `.org`
891
+ email in it became a permanently unregistrable `.invalid` one on the way past. Every other new
892
+ allow-list token was **surfaced by the gate**, not guessed at. **23 net new tests** in
893
+ `test/scripts/phi-scan.test.ts` (78 to 101), plus several existing ones rewritten where the
894
+ widening inverted their verdict, each change of verdict recorded rather than deleted.
895
+
896
+ **Two `PRE-EXISTING` minors folded in with this item are ALREADY CLOSED here and were not
897
+ reopened**, measured rather than assumed: a missing or unreadable allow-list exits **2** (not the
898
+ **1** reserved for hits), `readdirSync` failing on a root exits **2**, and unmerged (`U`) entries
899
+ are already in the filter.
900
+
901
+ - **The PHI scan's observation rule was GLOBAL, so one root vouched for the other
902
+ (`PHI-SCAN-OBSERVED-NOTHING-IS-GLOBAL`).** `pnpm phi-scan` with no arguments (what CI runs) walks
903
+ two roots, `test/fixtures` and `src`, and refused a sweep only when it had read **zero files in
904
+ total**. Any one surviving file satisfied that, so a single `src/` module vouched for the whole
905
+ fixture corpus and vice versa. **Reproduced on `04e4bc6` before the fix, three ways**, each
906
+ printing `[phi-scan] OK: no hits` and exiting **0**: `test/fixtures` moved aside, `test/fixtures`
907
+ replaced by a **dangling symlink**, and `src` moved aside. The first two leave all 106
908
+ non-markdown fixtures unread; the third leaves all 95 source files unread.
909
+
910
+ **The dangling case is the sharpest, and nothing else in the scanner can see it.** `existsSync`
911
+ **follows** the link and answers false, so `walk()` returns before `readdirSync` and the
912
+ not-a-regular-file refusal never fires: that rule only ever classifies entries found **inside** a
913
+ root, and this is the root itself. Absent, dangling and empty are one state to the walk. This
914
+ reporter prints no file count either, so there was not even a suspicious denominator to notice.
915
+
916
+ **The rule is now per-root.** All-mode refuses (exit **2**) unless **every** declared root
917
+ yielded at least one file that was actually read, and the refusal names the starved roots.
918
+ Observing nothing at all is the all-starved case of that one rule, not a second rule beside it,
919
+ so the previous wording carries. Hits found under the roots that **did** yield are printed before
920
+ the refusal, so an incomplete sweep never swallows a real finding; the exit code is still 2.
921
+
922
+ **Deliberately unchanged:** `--staged` and named-path mode make no per-root promise, because
923
+ neither enumerates a root. `--staged` legitimately has nothing to scan when a commit touches only
924
+ markdown, and named-path mode is bounded by the caller's argv. Both boundaries are pinned by
925
+ tests, and widening the rule to either was run as a mutation rather than argued.
926
+
927
+ **The granularity is the declared root and nothing finer, which is a real bound, not a slogan.**
928
+ Three states still exit **0**, each measured with the rule in place and each now recorded in the
929
+ scanner's own limits list with the command that produced it and pinned by a characterization
930
+ test: a directory missing from **inside** a root (`mv test/fixtures/canonical ..` still prints
931
+ `OK: no hits`, 27 fixtures unread); a root that is **itself a symlink**, which is followed, so
932
+ the rule is satisfied by whatever is on the other side of it; and the floor is **one file**,
933
+ whatever the root used to hold. One shape often listed with those is **not** open here and is
934
+ written down so it is not ported in as if it were: a root that is a regular file, or a link to
935
+ one, refuses at exit **2** rather than the 1 this gate reserves for hits, because `walk()`
936
+ already wraps `readdirSync` and rethrows the `ENOTDIR` on the scanner's own channel.
937
+
938
+ Repository commit-gate only. No runtime or API change.
939
+
608
940
  - **A staged RENAME bypassed the PHI scan's pre-commit route entirely
609
941
  (`PHI-SCAN-RENAME-BLIND-AT-PRECOMMIT`).** `git diff --cached --raw --diff-filter=AMT` returns
610
942
  neither `R` (rename) nor `C` (copy), and rename detection is on by git's default, so
@@ -1640,15 +1972,3 @@ real JS object (literal,`Map`, `Proxy`, or otherwise) can hold two same-named en
1640
1972
  `src/helpers/`, `src/serialize/`, `src/builder/`. Lint, format, and
1641
1973
  TypeScript settings come from the shared `@cosyte/*` config packages
1642
1974
  (ESLint 9 + `typescript-eslint`). CI across Node 22 / 24.
1643
-
1644
- ### Changed
1645
-
1646
- ### Deprecated
1647
-
1648
- ### Removed
1649
-
1650
- ### Fixed
1651
-
1652
- ### Security
1653
-
1654
- [Unreleased]: https://github.com/cosyte/hl7/commits/main
package/dist/index.cjs CHANGED
@@ -6874,7 +6874,7 @@ async function* parseStream(source, optionsOrProfile) {
6874
6874
  }
6875
6875
 
6876
6876
  // src/index.ts
6877
- var VERSION = "0.0.8";
6877
+ var VERSION = "0.0.9";
6878
6878
 
6879
6879
  exports.ACK_CODES = ACK_CODES;
6880
6880
  exports.ACK_CONDITIONS = ACK_CONDITIONS;