@cosyte/terminology 0.0.8 → 0.0.10

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
@@ -9,6 +9,266 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Fixed
13
+
14
+ - **The PHI scan had NO observation rule at all, so the all-mode sweep reported clean over a scan
15
+ root it never opened (`PHI-SCAN-OBSERVED-NOTHING-IS-GLOBAL`).** Development tooling only:
16
+ `scripts/phi-scan.ts` ships in no tarball, and the public surface, build output and runtime
17
+ behaviour are unchanged. `pnpm phi-scan` with no arguments is what CI runs. It walked two roots,
18
+ `test/fixtures` and `src`, and refused nothing on the strength of what it had read — not the
19
+ global "observed zero files in total" rule the siblings carried, but no rule of any kind.
20
+
21
+ **The starved state was LIVE here, not hypothetical.** `test/fixtures` has never existed in this
22
+ repository (`git log -- test/fixtures` is empty; every test is inline TypeScript and
23
+ `find test -type f -not -name '*.ts'` returns nothing; the throwaway-repo helper in
24
+ `test/scripts/phi-scan.test.ts` has only ever created `scripts/` and `src/`), so one of the two
25
+ declared roots was unopened on every run this gate has ever made. **Reproduced on `0fe4b84` on a
26
+ clone, three ways**, each printing `[phi-scan] OK — no hits` and exiting **0**: `src` moved aside,
27
+ `src` replaced by a **dangling symlink** — each leaving all 39 source files unread — and the
28
+ fixture root, which needed no manipulation at all.
29
+
30
+ **The dangling case is the sharpest, and nothing else in the scanner can see it.** `existsSync`
31
+ **follows** the link and answers false, so `walk()` returns before `readdirSync` and the
32
+ not-a-regular-file refusal never fires: that rule only classifies entries found **inside** a root,
33
+ and this is the root itself. Absent, dangling and empty are one state to the walk. **This reporter
34
+ prints no denominator either** — `report` writes `OK — no hits` and no file count — so nothing on
35
+ stdout looked wrong. Adding a count would be a real improvement and is **not** part of this
36
+ change.
37
+
38
+ **The rule is now per-root.** All-mode refuses (exit **2**) unless **every** declared root yielded
39
+ at least one file that was actually read, and the refusal names the starved roots. It is written
40
+ per-root rather than in the global form the siblings started from, so that shape — one surviving
41
+ file vouching for every other root — cannot arrive along with the second root.
42
+
43
+ **`test/fixtures` is no longer declared as a walk root, and its removal is not a narrowing.** If
44
+ readable, non-ignored content arrives there, all-mode **refuses** (exit 2) and names the
45
+ declaration rather than reporting clean over content nothing walks. **That refusal fires only
46
+ where the remedy it prints would help.** It uses the two predicates the walk already has:
47
+ `rootOf`, so declaring the path silences it by construction, and a content test mirroring the
48
+ walk's own rules, so an **empty** `test/fixtures`, a **markdown-only** one (the walk skips
49
+ markdown) and a **gitignored** one are all left alone at exit 0, exactly as before this change. An
50
+ `existsSync`-only guard refused over all three and kept refusing after the printed remedy was
51
+ followed — a gate that cannot be satisfied — and it invented a second, stricter gitignore boundary
52
+ beside the single one this file keeps. **Two exceptions are recorded rather than papered over**,
53
+ both counting as content because a scan that cannot account for something must not report clean: a
54
+ path the walk cannot list, where declaring it exits 1; and a path holding only a non-regular
55
+ entry, where declaring it lands on the not-a-regular-entry refusal at exit 2. Both behave the same
56
+ before this change, so neither is a regression. The `--staged` predicate is deliberately
57
+ untouched and still enumerates `test/fixtures/**` and that path's own name, so the pre-commit
58
+ route is unchanged in both directions.
59
+
60
+ **The STARVATION refusal never swallows a finding.** Hits from the roots that DID yield are
61
+ printed before it, and the exit code is still 2. That is scoped to the starvation branch and no
62
+ wider: the reappearing-corpus refusal, like the not-a-regular-entry and unmerged refusals beside
63
+ it, throws out of `buildTargetsForAll` before anything is scanned and so reports nothing found on
64
+ the way — fail-closed, and unchanged in that respect. With one root declared, starved means an empty target list, so that
65
+ line is unreachable from this repository's own configuration — it is therefore exercised against a
66
+ copy of the scanner with a second root declared, rather than shipped as an untested promise, which
67
+ is exactly how the defect being fixed here survived.
68
+
69
+ **Deliberately unchanged:** `--staged` and named-path mode make no per-root promise, because
70
+ neither enumerates a root. Both boundaries are pinned, and widening the rule to them was run as a
71
+ mutation rather than argued — dropping the mode guard reds 10 cases; removing the per-root rule
72
+ reds 4; removing the reappearing-corpus refusal reds 3; removing the print-hits-first line reds 1;
73
+ emptying `SCAN_ROOTS` reds 14.
74
+
75
+ **The granularity is the declared root and nothing finer, which is a real bound, not a slogan.**
76
+ Four states are still not refused, each measured **with** the rule in place, each now in the
77
+ scanner's own limits list with the command that produced it and each pinned by a characterization
78
+ test: a directory missing from **inside** a root (`mv src/ucum ..` still prints `OK — no hits`
79
+ with 6 sources unread, while a planted violator in it exits 1 with it present); a root that is
80
+ **itself a symlink** to a directory, which is followed, so the rule is satisfied by whatever is on
81
+ the other side; the floor is **one** file, whatever the root used to hold; and **any directory the
82
+ walk cannot list** exits **1** — the code this gate reserves for hits — because `walk()` does not
83
+ wrap `readdirSync` and the error reaches node's default handler with a v8 stack trace in place of
84
+ the scanner's own diagnostic. That last one is **not only about roots**: measured as `ENOTDIR` on
85
+ a root that is a regular file and on a root linking to one, and as `EACCES` on a `chmod 000`
86
+ directory at depth with the per-root rule otherwise satisfied. **It is CLOSED in the sibling this
87
+ rule was ported from and OPEN here**, and it is recorded so it is not ported back out as though it
88
+ were fixed; it belongs to the separate exit-code work.
89
+
90
+ **Two more bounds are recorded so none of the above reads as completeness, and both are
91
+ `PRE-EXISTING`.** The reappearing-corpus refusal sees only what `existsSync` can resolve: silent
92
+ on a dangling link, which is deliberate because a dangling link holds no corpus, but also silent
93
+ when a real corpus sits behind a directory that cannot be traversed (`chmod 000 test` with
94
+ `test/fixtures/leak.txt` present — measured, and identical before this change). And **all-mode
95
+ walks `src` and nothing else**, so the observation rule can be fully satisfied while every tracked
96
+ file under `test/` goes unread: measured back to back, `pnpm phi-scan` clean at exit 0 while
97
+ naming a file under `test/` directly returns hits at exit 1 over the same bytes
98
+ (`PHI-SCAN-WALK-ROOT-SCOPE`). Widening the walk is separate work with its own two-sided trap,
99
+ because this floor is SSN/email only.
100
+
101
+ Tests were added on throwaway repositories only, so no starved root, no dangling link and no
102
+ violator is ever written into the committed corpus. **No count of them is recorded here**: one
103
+ was, it was correct when written, two more cases were added in the same slice, and it was then
104
+ wrong. Several are red against the superseded scanner — every case asserting one of the new
105
+ refusals — and one is an explicit **anti-vacuity** control asserting the starved corpus really did
106
+ hold a detectable hit, because a starvation test over an empty corpus proves exactly nothing,
107
+ which is the defect itself.
108
+
109
+ - **The PHI commit-gate's `--staged` route was blind to a staged RENAME or COPY, so an ordinary
110
+ `git mv` into a scan root printed its clean line and exited 0 at pre-commit**
111
+ (`PHI-SCAN-RENAME-BLIND-AT-PRECOMMIT`). Development tooling only: `scripts/phi-scan.ts` ships in
112
+ no tarball, and the public surface, build output and runtime behaviour are unchanged.
113
+
114
+ **The hole.** Rename detection is on by git's default, and `git diff --cached --raw
115
+ --diff-filter=AMT` returns neither `R` (rename) nor `C` (copy). So `git mv <tracked link>
116
+ test/fixtures/<name>` staged as `:120000 120000 <sha> <sha> R100` with **two** paths and the
117
+ status filter deleted the record outright: measured on git 2.39.5 in a throwaway repository, the
118
+ index held a mode-120000 entry under a scan root and `pnpm phi-scan --staged` reported clean. It
119
+ was never only a **mode** gap. A rename that also SUBSTITUTES a real-looking value into the moved
120
+ file passed identically, measured at mode 100644, while naming the same destination
121
+ directly returned a hit. The gap was at **pre-commit**, where `simple-git-hooks` runs
122
+ `pnpm phi-scan --staged`; the all-mode sweep is the backstop and saw both.
123
+
124
+ **The remedy is `--no-renames`, with no record-stride work.** With detection off git emits no `R`
125
+ and no `C` at all: the destination arrives as an ordinary single-path `A` and the source as a `D`
126
+ the filter already drops. **This route's own previous disclosure — that admitting `R`/`C` needed
127
+ the two-path record shape handled, "which is a scope decision" — was measured FALSE and is
128
+ withdrawn in place rather than deleted, because it shipped.** Verified under
129
+ `diff.renames=true|copies|false|1` with `diff.renameLimit=1`: every one yields the same
130
+ single-path records, so the two-field stride is STRUCTURAL rather than conditional on the caller's
131
+ configuration. **State the relation exactly, and SCOPE it. Of `--no-renames` alone, holding the
132
+ rest of the argv fixed: the new enumeration CONTAINS the old one — EQUAL whenever git emitted no
133
+ `R` and no `C`, larger only when it did.** It is a superset, not a strictly larger set. **That
134
+ equality is NOT a claim about the route as a whole** — the whole-argv change also adds the
135
+ unmerged and gitlink records below, so on an index holding an unmerged path the two enumerations
136
+ differ while git emitted no `R` and no `C` at all. The corollary, because it is easy to misread as "nobody was affected": a
137
+ caller who had already set `diff.renames=false` was covered before this, and everybody on git's
138
+ default was not.
139
+
140
+ **The `C` half is real and no rename fixture reaches it.** Under `diff.renames=copies`, copying an
141
+ out-of-scope file carrying the same payload INTO a scan root stages as a genuine `C100` two-path
142
+ record and was dropped identically: exit 0 before, exit 1 after. One precondition, recorded because
143
+ it is easy to state too broadly: configuration-only copy detection also needs the copy SOURCE
144
+ modified in the same staged diff, or git finds no copy source, the record arrives as an ordinary
145
+ `A` that even the previous argv enumerated, and the fixture proves nothing.
146
+
147
+ **Three further enumeration holes in the same route, each measured open here rather than assumed.**
148
+ An index entry at exactly `test/fixtures` or exactly `src` escaped a prefix test that required the
149
+ trailing slash (exit 0 over a staged mode-120000 entry at each), so a whole scan root replaced by a
150
+ link went unjudged — **the one ADDITION to this route's path scope in this change, named as an
151
+ addition rather than filed under narrowing.** `diff.ignoreSubmodules=all` ERASED a staged gitlink
152
+ from the enumeration entirely, so a refusal the route already had never fired (exit 2 on the
153
+ default, exit 0 with that config, same index); closed by `--ignore-submodules=none`, the same
154
+ remedy family. And an UNMERGED path was returned by neither `AM` nor `AMT`, so a conflicted
155
+ in-scope path made the route report clean over an index it structurally cannot read; it is now
156
+ enumerated so it can be REFUSED. **`U` is closed by a DIFFERENT mechanism and the two must not be
157
+ conflated: by being in `--diff-filter=AMTU`, not by `--no-renames`.** Git itself refuses to commit
158
+ while a path is unmerged, so that one was never a route to a committed leak; what it was is the
159
+ gate attesting clean over a state it never observed.
160
+
161
+ **One refusal message was false and is corrected, not softened.** It asserted that `git show
162
+ :<path>` "hands back its target path rather than any content", which holds only for mode 120000;
163
+ on a staged gitlink it fails outright, and the same sentence was emitted for gitlinks and for the
164
+ mode fallback. It now says what the INDEX holds instead of what `git show` would answer.
165
+
166
+ **Twelve cases added**, most of them asserting git's own premise before the scanner's behaviour,
167
+ because the remedy rests on what git emits. Eight run red against the previous scanner; the other four
168
+ are the configuration sweep, two flag-coupling premises and an out-of-scope control, green on both
169
+ by design. **The argv coupling is pinned, and a first draft of this entry had half of it BACKWARDS.**
170
+ `-M`, `-C` and `--find-copies-harder` each turn detection back on over the top of `--no-renames`
171
+ and empty the route again; `--find-copies-harder` does so in EITHER order, so nothing here says a
172
+ later `--no-renames` undoes them. **`-B` IS NOT INERT, AND CALLING IT SO WAS THE PERMISSIVE HALF OF
173
+ A STANDING DIRECTIVE.** That reading came from a rename stage, the one stage where `-B` does
174
+ nothing. On a COMPLETE REWRITE `-B` breaks the pairing, the `--diff-filter` letter is `B` and not
175
+ `M`, `AMTU` drops the record outright, and the gate reports clean over a staged dashed SSN
176
+ (measured, same index: exit 1 without the flag, exit 0 with it). It empties this route through the
177
+ status FILTER rather than by re-enabling detection — a different mechanism, the same answer: do not
178
+ add it. Corrected in place rather than deleted, because an "inert" reading is exactly what gets
179
+ ported onward, and a case now pins it. That case guards GIT's premise rather than the scanner's argv, so it does not by
180
+ itself fail if someone adds `-M` to the scanner; the suite as a whole does. **Every flag is
181
+ counterfactually measured against the final suite of 34:** injecting `-M` reds 2 cases, `-C` reds
182
+ 3, `--find-copies-harder` reds 3 and `-B` reds 1; removing `--ignore-submodules=none` reds 1, and
183
+ dropping `U` from the filter reds 1. Before the `-B` case was added, injecting `-B` red nothing,
184
+ which is how the false "inert" claim survived a first pass. Every case builds a
185
+ throwaway git repository, so no rename, copy, conflict or violator enters the committed corpus.
186
+ **No case runs `git merge`:** it resolves the COMMITTER IDENTITY up front and exits 128 before
187
+ merging anything when it cannot find one, which passes on a developer's box and fails on a runner,
188
+ so the unmerged fixtures are built with `git update-index --index-info` against an index state —
189
+ what this route actually reads, and `git status` still reports `UU`. The suite was re-run under
190
+ `env -i` with no discoverable git identity: 34 pass.
191
+
192
+ **Measured and deliberately NOT closed here, both PRE-EXISTING and identical before this change.**
193
+ With a scan root replaced by a link to a regular file the all-mode walk raises an uncaught
194
+ `ENOTDIR` and exits **1**, a code this contract reserves for HITS, with a stack trace in place of
195
+ the scanner's own diagnostic; a missing allow-list throws out of the same route the same way. Both
196
+ are fail-closed rather than silent, and belong to the separate exit-code work. And one property
197
+ that is newly true rather than unchanged, because such a blob was previously out of scope
198
+ entirely: a
199
+ regular blob staged at exactly a scan root is scanned at the SAME tier as one under it, which is
200
+ true **here** only because `scanTarget` has one tier — the SSN/email floor — and keys on nothing
201
+ about the path. Implementing the fenced TODO section with a path-keyed dispatch would break that,
202
+ and a case now says so.
203
+
204
+ - **Nothing pinned that the publish gate FORWARDS `--no-definitely-typed`, so a gate that accepted
205
+ the flag and then dropped it would have looked exactly like one that forwards it**
206
+ (`TERMINOLOGY-ATTW-FORWARDING-UNPINNED`). Development tooling only: `scripts/attw.mjs` ships in no
207
+ tarball, and the public surface, build output and runtime behaviour are unchanged. No executable
208
+ line of the gate moved; the only edit to that file is one docblock paragraph.
209
+
210
+ **The gap was structural, not one forgotten assertion.** Every case in
211
+ `test/scripts/attw-gate.test.ts` passes `--no-definitely-typed`, to keep the gate off the network,
212
+ so the flag rode along on every run and no assertion ever depended on it arriving. The allow-list
213
+ covered the acceptance half incidentally, since removing the flag from it kills every other case at
214
+ the argument guard; the forwarding half was covered by nothing at all. The one test whose name said
215
+ otherwise, `"forwards the two arguments it does accept"`, measured `--profile` alone, and its name
216
+ is corrected here rather than left overclaiming.
217
+
218
+ **`attw` itself cannot settle it, which is why the answer is a shim.** The CLI reports on a packed
219
+ tarball, never on its own arguments, and on the `--pack` path this gate always takes the flag
220
+ suppresses a DefinitelyTyped lookup that is never made. Inert means unobservable: accepting the
221
+ flag and dropping it produces byte-identical output to forwarding it. The pin therefore runs this
222
+ repo's own `scripts/attw.mjs` against a fixture package whose `node_modules/.bin/attw` prints the
223
+ argv it was handed. The wrapper resolves its binary beside its own file rather than from the
224
+ working directory, so the probe needs its own copy of the script, and the suite asserts that copy
225
+ is byte-identical to the real one instead of trusting it.
226
+
227
+ **The negative controls are the load-bearing half, and they are committed rather than merely run.**
228
+ A test that would pass whether or not the flag is forwarded proves nothing. Given no arguments the
229
+ shim must see `--pack .` and nothing else, which reds a gate that hard-codes the flag into its own
230
+ spawn, which a positive assertion alone cannot tell apart. A second control
231
+ asserts that a refused argument never reaches the shim at all; that one sharpens the existing
232
+ refusal cases rather than catching anything they miss.
233
+
234
+ **Three counterfactuals were run against the real script, over the whole of
235
+ `test/scripts/attw-gate.test.ts` rather than the new block alone.** Dropping the forwarding reds
236
+ three cases, the new positive pin among them, and leaves both new controls green. Hard-coding the
237
+ flag into the spawn and discarding the argv also reds three, but the pairing is what matters: the
238
+ positive pin stays GREEN while the bare-`--pack .` control reds. That is the precise failure this
239
+ change exists to make visible.
240
+ Bypassing the allow-list so the whole argv is forwarded reds eighteen, of which seventeen are
241
+ refusal cases that were here already; the new refusal control is the eighteenth and adds only "and
242
+ `attw` never saw it". **So that control sharpens an existing guard rather than being what stops a
243
+ forward-everything gate from passing**, and it is recorded that way instead of being credited with
244
+ the catch.
245
+
246
+ **Unchanged, and not claimed to be closed: the `.attw.json` route.** `readConfig()` applies a
247
+ committed config file after argv and sets an option value for every key it carries, so a config
248
+ file wins regardless of the argument allow-list and no argv-level pin can reach it. This change
249
+ narrows nothing there, and the gate's docblock keeps stating it as the known limit it is.
250
+
251
+ ## Released, pending a per-version split
252
+
253
+ > **Everything between this heading and `[0.0.1]` has already SHIPPED.** It accumulated under
254
+ > `[Unreleased]` across the releases from `0.0.2` through `0.0.9`, because this file is maintained by
255
+ > hand and the Changesets changelog generator is off (`"changelog": false` in
256
+ > `.changeset/config.json`), so no release run ever moved an entry out of that section. Reading any
257
+ > of it as work still awaiting a release was wrong, which is why the heading changed and the entries
258
+ > did not.
259
+ >
260
+ > **What is deliberately NOT done here, and is tracked as `TERMINOLOGY-CHANGELOG-GAP`:** splitting
261
+ > this into one `## [x.y.z]` section per published version, and the founder-owned question of whether
262
+ > to turn the generator on across the repos that share this layout. No version from `0.0.2` to
263
+ > `0.0.9` has a section of its own below, and none has a compare link. The `[Unreleased]` link at the
264
+ > foot of this file now spans `v0.0.9...HEAD`, which is what that section actually holds; it spanned
265
+ > `v0.0.1...HEAD` while this block was mislabelled.
266
+ >
267
+ > **`0.0.3` never reached npm, and LEAVING it that way is the deliberate part.** The gap itself was
268
+ > a consequence, not a choice: a leftover changeset meant that release run only opened the next
269
+ > version PR, and the content shipped in `0.0.4`. It is not a hole to be filled, because publishing a
270
+ > `0.0.3` now would place it after `0.0.4` in registry order.
271
+
12
272
  ### Changed
13
273
 
14
274
  - **Breaking: `ExpansionDiagnostic.system` is replaced by `ExpansionDiagnostic.path`.** The field
@@ -23,8 +283,169 @@ this file is maintained by hand (Changesets handles the version bump and publish
23
283
  without evaluating a part expansion still has to report. Reading `d.system` is now a type error.
24
284
  The rationale, and the other three sites, are under **Fixed** below.
25
285
 
286
+ - **The test suite no longer sets a global timeout, and its one repeated start-up tax is gone**
287
+ (`PARSER-TESTTIMEOUT-ASSERTS-AN-IDLE-BOX`). Development tooling only — `vitest.config.ts` and the
288
+ test suite. No source file, no build output and no part of the public surface is touched.
289
+
290
+ **The trim came first, and it was the larger win.** `test/scripts/phi-scan.test.ts` spawns the
291
+ scanner at twenty call sites, and the cost of each spawn was start-up, not scanning — the fixtures
292
+ are a few hundred bytes. Measured on a 12-CPU cgroup quota, warmed runs of each against the same
293
+ scanner and argv: a `tsx` start had a median around **470–500 ms** against **141–200 ms** for
294
+ `node` running the same TypeScript through its native type stripping — roughly 300 ms a spawn.
295
+ That roughly halved the file: a plain base run measured **13,925 ms** over 21 cases, and plain runs
296
+ after the change measured **5,657 ms and 6,853 ms** over 22. Both runners were checked to produce
297
+ byte-identical stdout and stderr and the same exit code, on a clean file and on a violator, before
298
+ the change; several cases assert on stderr exactly.
299
+
300
+ `pnpm phi-scan` still runs `tsx`, so one case still spawns it and asserts the two runners **agree**
301
+ on exit code, stdout and stderr — a parity assertion, so a divergence reds rather than letting the
302
+ sweep quietly test something the gate does not run. Node's type stripping is on by default from
303
+ **22.18** while `engines.node` is `>=22.0.0`; the CI matrix is 22 + 24 and both resolve above that,
304
+ but a developer on 22.0–22.17 would need a newer 22 to run the suite.
305
+
306
+ **`testTimeout: 10_000` and `hookTimeout: 10_000` are removed**; the shared `@cosyte/vitest-config`
307
+ sets no timeout at all. A global timeout asserts the **machine**, not the code: sized for the
308
+ slowest test in the repo and then inherited by every other test, so a genuinely hung fast test
309
+ looks merely slow instead of broken. `hookTimeout` was pure noise — Vitest's default hook timeout
310
+ is **exactly** 10,000 ms, so the literal restated it. Both defaults were established by measuring a
311
+ deliberately over-running test and hook, not by reading the documentation.
312
+
313
+ **Per-test, never global.** Every test whose cost is not fixed now declares its own budget beside
314
+ the work. The `attw-gate` cases that run a real `npm pack` have carried an explicit 60 s one all
315
+ along; three groups were added — the two 200,000-row bulk cases in
316
+ `test/rxnorm/term-types.test.ts`, the single `tsx`-parity case, and **all of `test/property/**`**,
317
+ whose runtime varies with the fast-check draw as well as with the box, since this package pins no
318
+ seed by design. With those declared, every remaining test peaked at **689 ms\*\*, a ~7x margin
319
+ against the 5 s default the config now inherits.
320
+
321
+ **The method is part of the claim.** Ten full runs on a contended box (12-CPU cgroup quota,
322
+ fourteen-worker fleet), of which **six were `vitest run --coverage`**. That is not optional: CI
323
+ gates on `pnpm test` _and_ `pnpm test:coverage`, and the instrumented run is materially slower,
324
+ roughly doubling the property-suite peaks. An earlier draft of this change measured only the plain
325
+ run, recorded 499 ms, and was refuted for it.
326
+
327
+ **Two things measurement corrected.** The argument-refusal cases in `attw-gate` look like they
328
+ belong with the slow ones and do not: the wrapper rejects the flag before invoking `attw`, so they
329
+ run no `npm pack` and finish in ~200 ms. And the case that actually approached the old ceiling —
330
+ **9,318 ms against 10,000 ms** under load, a 682 ms margin on correct code — was an `attw` case
331
+ that already had its own 60 s budget, so the global was never what stood between it and a false
332
+ red. The `attw` cost itself is genuine work (a real `npm pack` runs ~1,550 ms), so it is bounded
333
+ rather than trimmed. These figures describe one box on one day and are recorded so the next reader
334
+ re-measures rather than inherits them.
335
+
26
336
  ### Fixed
27
337
 
338
+ - **The publish gate's argument guard refused a fixed LIST of blinding spellings, and `-fjson` was
339
+ not on it — a value fused to a short flag hid the one sentence the gate reads, and it handed back a
340
+ pass over a tarball carrying no types** (`ATTW-GUARD-ALLOWLIST`). Development tooling only —
341
+ `scripts/attw.mjs` ships in no tarball, and the public surface, build output and runtime behaviour
342
+ are unchanged.
343
+
344
+ **The rules of the gate are stated once, in `scripts/attw.mjs`'s docblock. This entry points at it
345
+ rather than repeating them**, and `CLAUDE.md`'s paragraph now does the same. The guard was
346
+ described in several committed files at once and the copies drifted: `CLAUDE.md` claimed a refusal "by
347
+ option name, wholesale" that a fused short option walked past, and called `--config-path` refused
348
+ "by inference, not measurement" when the real-file half takes one command to measure.
349
+
350
+ **The remedy is an ALLOW-LIST, so no spelling is enumerated.** The gate forwards `--profile` and
351
+ `--no-definitely-typed` and refuses everything else, including options that blind nothing:
352
+ "harmless" is not a judgement a script can make from an option name. Measured on `7bdd5f5` against
353
+ this repo's pinned `@arethetypeswrong/cli@0.18.4`, on a fixture whose tarball carries no types —
354
+ `-fjson`, `-Pf json` and `-Pfjson` each exited **0** with the untyped sentence absent and a
355
+ non-empty transcript, so the empty-output net could not backstop them; `--help`, `-h`, `--version`
356
+ and `-V` did the same. `--config-path` naming a real file that sets `format` blinded identically,
357
+ while the nonexistent path the old test named blinds **nothing** — `readConfig()` swallows the
358
+ `ENOENT` and the sentence is still printed, so that test asserted a refusal over a case that could
359
+ not have hurt the gate. Both halves are pinned now.
360
+
361
+ **The preflight walks two promises it used to skip**, each measured to leave bare `attw` at exit 0:
362
+ `bin`, which `attw` does not look at at all, so a manifest could promise a command absent from the
363
+ tarball; and a path written without a leading `./`, which is legal and is the spelling npm's own
364
+ documentation uses, so `"main": "dist/index.js"` was dropped silently while the gate reported it
365
+ had checked. This package declares no `bin` — that half is pinned on a fixture that does, because
366
+ every sibling copies this file.
367
+
368
+ **A false explanation of a correct red is deleted rather than reworded.** The preflight used to
369
+ name the exit code `attw` "would have" produced. It reads the manifest and never the tarball, and
370
+ the tarball decides: `analysis.types` is `containsTypes()` in `@arethetypeswrong/core`, which is
371
+ `listFiles(directory).some(ts.hasTSFileExtension)` — any TypeScript-extension file in the packed
372
+ tarball, computed before an entrypoint is resolved. A package packing a whole `dist/` can lose
373
+ every declared declaration and still hand `attw` an undeclared chunk declaration, at which point it
374
+ exits 1 and the sentence is false. The failure now says what it checked and that it cannot speak
375
+ for `attw`.
376
+
377
+ **A limit found while grading this, filed rather than closed.** The `.attw.json` refusal names
378
+ `quiet` and `format`, but `readConfig()` sets an option value for **every** key except
379
+ `configPath`, `help` and `version` — so a committed config reaches options this gate does not name,
380
+ `definitelyTyped` pointed at a `.tgz` among them, which merges those types in and can make an
381
+ untyped tarball analyse as typed. It predates this change, and the allow-list closes only its argv
382
+ half — `--definitely-typed` on argv is now refused, where the deny-list forwarded it — while the
383
+ config route is untouched. Adding a key would rebuild, on the config side, the deny-list this
384
+ change retires on the argument side.
385
+
386
+ - **A symbolic link under a scan root read CLEAN on BOTH of the PHI scanner's enumerating routes, so
387
+ a link pointing at a file full of real identifiers passed the gate twice over**
388
+ (`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`). Development tooling only — `scripts/phi-scan.ts` ships in
389
+ no tarball and the package's public surface is unchanged.
390
+
391
+ Reproduced on `80a8cc7` before any fix, with a synthetic name-bearing payload (a person name, a
392
+ DOB, a dashed SSN and an email at a non-test domain) placed outside the walk roots and a link to it
393
+ at `src/leak.ts`. `pnpm phi-scan` printed `OK — no hits` and exited **0**; `pnpm phi-scan --staged`,
394
+ after `git add`, printed `OK — no hits` and exited **0**. Naming the link's target explicitly on the
395
+ command line found both shapes and exited 1, so the payload was always detectable — the two routes
396
+ simply never looked at it.
397
+
398
+ The two blindnesses are separate mechanisms and needed separate fixes. The walk enumerates
399
+ `Dirent.isFile()`, which is an **lstat** answer, so a link is neither a file nor a directory and
400
+ fell out of the loop with no record that anything had been skipped — and `isDirectory()` answers
401
+ false for a linked directory too, so a whole subtree could disappear the same way. The `--staged`
402
+ route reads content with `git show :<path>`, and **git stores a symbolic link as its target path
403
+ under mode `120000`**, so it was handed the target's path text and scanned that. That second route
404
+ is this repo's `pre-commit` hook, which is exactly where the claim that `--staged` covers a link
405
+ would have been trusted.
406
+
407
+ **Neither route is made to follow the link.** Following would read bytes the enumeration does not
408
+ control — outside the repo, a loop, a device, a FIFO that blocks the gate forever — and git does not
409
+ carry those bytes anyway, so a hit on them would be a claim about something no commit contains.
410
+ Instead the enumeration is narrowed: an entry under a scan root that is not a regular file **refuses
411
+ the scan** (exit 2, the existing "could not complete" code), naming every offender rather than the
412
+ first. The walk classifies by `Dirent` (symbolic link, FIFO, socket, block device, character
413
+ device); `--staged` now reads `git diff --cached --raw -z` instead of `--name-only` so the
414
+ destination mode is visible, and refuses mode `120000` and `160000` (a gitlink) wherever that
415
+ route's own path scope reaches them. A `--raw` record that does not parse refuses as well, rather
416
+ than being skipped into a silently shortened list.
417
+
418
+ **`T` (typechange) is in the `--diff-filter`, and leaving it out made the mode check unreachable
419
+ whenever the file being replaced was already tracked.** Replacing a **tracked** regular file with a link is neither an add nor a
420
+ modify: git raises `:100644 120000 <sha> <sha> T`, so `--diff-filter=AM` deleted the record before
421
+ any mode could be read and the hook passed the link green. Measured on git 2.39.5: with `AM` the
422
+ raw output for that stage is empty. Typechange carries a single path, exactly like `A` and `M`, so
423
+ admitting it costs the record stride nothing — and it means the reverse typechange, a link replaced
424
+ by a real file, is now scanned as the ordinary file it became.
425
+
426
+ **A refusal names the entry's own repo-relative path and an engine-owned token for its kind, and
427
+ never the link target** — a target path is text off the working tree and can itself carry PHI. That
428
+ is asserted, not asserted-in-prose: the pinning payload and the target's own filename both carry a
429
+ synthetic person name, and every refusal message is checked to contain none of it.
430
+
431
+ **What this does not cover, stated narrowly, each measured.** Explicit-path mode already read
432
+ through a link and reported the target's hits; it is unchanged. The `--staged` path scope is
433
+ unchanged — still `test/fixtures/**` and `src/**.ts` — so a staged link outside it is still not
434
+ looked at; narrowing what a scope admits is not widening the scope. That scope also bounds the
435
+ gitlink half: a submodule staged at `test/fixtures/nested` is refused, while one at `src/nested` is
436
+ a directory name that fails the `.ts` suffix and is not looked at. `R` (rename) and `C` (copy) are
437
+ still **not** enumerated by `--staged` at all, so a staged rename that also appends PHI passes that
438
+ route — pre-existing, unchanged here, and admitting it needs the two-path record shape handled,
439
+ which is a scope decision rather than this one; CI's all-mode sweep does read the resulting
440
+ worktree file. **[Superseded — see the `[Unreleased]` entry above. `R`/`C` ARE now enumerated, and
441
+ the reason given here for deferring them — that it needs the two-path record shape handled, "which
442
+ is a scope decision" — was MEASURED FALSE: `--no-renames` closes it with no stride work. This
443
+ sentence is left standing rather than rewritten because it shipped, and because it is the sentence
444
+ this repo exported to the ecosystem backlog and to sibling repos.]** A tracked file **absent from the worktree** is still caught at `git add` time only.
445
+ And the scanner still has no tolerance for a file that vanishes between enumeration and read, so an
446
+ untracked transient appearing under a walk root can still refuse a whole sweep; that is a different
447
+ defect, it fails closed, and it is not addressed here.
448
+
28
449
  - **A loaded GEM map, complex map and RxNorm drug graph froze their wrapper and handed out plain
29
450
  `Map`s inside it, so whoever held one could delete a diagnosis mapping, add a target the steward's
30
451
  file never authored, or empty a medication's ingredient edges** (`TERMINOLOGY-INNER-MAPS-UNSEALED`).
@@ -901,5 +1322,5 @@ ladder (`0.0.x` until first alpha).
901
1322
  `WARNING_CODES` entry, and the parse-oriented option/warning types). This package is an engine, not
902
1323
  a parser; that placeholder surface never shipped.
903
1324
 
904
- [Unreleased]: https://github.com/cosyte/terminology/compare/v0.0.1...HEAD
1325
+ [Unreleased]: https://github.com/cosyte/terminology/compare/v0.0.9...HEAD
905
1326
  [0.0.1]: https://github.com/cosyte/terminology/releases/tag/v0.0.1
package/dist/index.cjs CHANGED
@@ -4776,7 +4776,7 @@ function approximateMatch(graph, query, options = {}) {
4776
4776
  }
4777
4777
 
4778
4778
  // src/index.ts
4779
- var VERSION = "0.0.8";
4779
+ var VERSION = "0.0.10";
4780
4780
 
4781
4781
  exports.COMPLEX_MAP_SOURCE_SYSTEM = COMPLEX_MAP_SOURCE_SYSTEM;
4782
4782
  exports.DIAGNOSTIC_CODES = DIAGNOSTIC_CODES;