@cosyte/script-utils 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,12 +2,106 @@
2
2
 
3
3
  All notable changes to `@cosyte/script-utils` are documented here, following
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions are managed with Changesets;
5
- this file is hand-maintained (Changesets' own changelog generation is disabled). The package stays on
6
- the **`0.0.x`-until-first-alpha** ladder.
5
+ this file is hand-maintained (Changesets' own changelog generation is disabled). The package is on the
6
+ **`0.1.x`** line: its surface is settled, and bump types follow ordinary semver rather than a
7
+ pre-alpha rule. See
8
+ [ADR 0002](../../documentation/decisions/0002-the-0-1-0-version-line.md).
7
9
 
8
10
  > Because the generator is disabled, **`[Unreleased]` is promoted to a version heading BY HAND**, in
9
11
  > the pull request that adds the changeset. Nothing does it automatically.
10
12
 
13
+ ## [Unreleased]
14
+
15
+ ## [0.1.1] - Unreleased
16
+
17
+ ### Fixed
18
+
19
+ - **`phi-scan`: the per-root observation rule is answered from the WALK's reads only.** It was
20
+ answered from every read the run took, including the index-union reads of the bytes git carries,
21
+ so a scan root that was missing or empty on disk was credited whenever git tracked a file under
22
+ it. In a real repository every root holds tracked files, so the rule never fired for the state it
23
+ exists to catch. Measured before the change: two repositories with `src` emptied on disk, differing
24
+ only in whether `src/index.ts` was tracked, exited at the refuse code and at the clean code with
25
+ `OK: no hits`. **Such a root now refuses at the `refuse` code, naming it.** Those tracked files are
26
+ still read from the bytes git carries and a hit in them is printed before the refusal; the
27
+ completeness rule still counts every read, and a root the walk reads is not refused.
28
+
29
+ ## [0.1.0] - Unreleased
30
+
31
+ ### Changed
32
+
33
+ - **The package leaves the pre-alpha version ladder for the `0.1.x` line.** `isCliEntrypoint`
34
+ exports exactly what `0.0.2` exports. The phi-scan engine does NOT: see the entries below, which
35
+ are what this release actually carries on that subpath. The version policy itself is unchanged in
36
+ substance and the reasoning is in
37
+ [ADR 0002](../../documentation/decisions/0002-the-0-1-0-version-line.md). A consumer pinned at
38
+ `^0.0.2` does not resolve this release and has to widen its range once, which is recorded as break
39
+ candidate BC-2 in `documentation/release-0.1.0-audit.md`.
40
+ - **`phi-scan`: `all` mode refuses unless EVERY scan root yielded a file that was read**, naming the
41
+ starved roots. The completeness rule asks whether every target was read; nothing asked whether a
42
+ root produced a target at all, and one productive root made the whole run look productive.
43
+ Measured before the change: a caller supplying an `isWalkReadable` that admits nothing read no
44
+ file on either sweeping route and printed `OK: no hits` at the CLEAN code over a tracked file
45
+ carrying a live dashed identifier.
46
+ - **`phi-scan`: an UNTRACKED file removed mid-run is reported SKIPPED rather than refusing.** A
47
+ target the walk listed and that was gone by read time refused unconditionally, which made an
48
+ ordinary mid-run deletion look like an unaccounted-for file. Git carries no bytes at such a path,
49
+ so nothing the repository holds went unread. The exception is bounded: a tracked file, a
50
+ non-`ENOENT` failure, a path named on argv and a path that came BACK before the run ended all
51
+ still refuse.
52
+ - **`phi-scan`: a finding names a position and stops there.** A hit report prints the locus, the
53
+ locator inside it and the rule that fired, and no longer the token that matched:
54
+ `segment=(ssn) (dashed SSN pattern)` where it used to carry the identifier as well. stderr is a CI
55
+ log, and a diagnostic ABOUT a PHI leak that quotes the leak is a second copy of it somewhere with
56
+ no retention policy. `Hit.value` is now optional, a hit the engine reports never carries one, and
57
+ **a gate test asserting that the matched identifier reaches stderr asserts on the locator and the
58
+ rule instead.**
59
+ - **`phi-scan`: `all` mode reads the bytes git carries at EVERY tracked path.** `scanRoots` bounds
60
+ the WALK, which answers what is on disk under the roots you declared; the index answers what your
61
+ repository CARRIES, and a root list was narrowing that too. Measured on the first consumer to
62
+ adopt this engine: three real messages under an undeclared top-level directory were read by
63
+ neither route, and a tracked symbolic link outside every root was reached by neither. **A
64
+ repository whose roots are narrower than itself has more of it read after this release.** The two
65
+ READ filters are unchanged and still apply on both sweeping routes, so a tracked `.md` and an
66
+ `excludedPaths` entry mean what they meant. `--staged` is untouched.
67
+ - **`phi-scan`: the `--staged` route refuses what `isStagedReadable` admits, and nothing else.** Its
68
+ non-regular and unmerged refusals used to fire for any path under a scan root, so a repository
69
+ whose staged scope is narrower than its roots had commits blocked that its own gate had always let
70
+ through: what a commit is blocked on is a hook decision each repository takes for itself. **This
71
+ refuses LESS at the pre-commit hook.** A staged entry your staged scope declines is still refused
72
+ by `all` mode on both of its routes, so your own sweep catches it; widen `isStagedReadable` if you
73
+ want the hook to refuse it too. Recorded with its compensating control as N1 in
74
+ [ADR 0003](../../documentation/decisions/0003-the-phi-scan-engine-gap-settlements.md).
75
+ - **`phi-scan`: a hit in the bytes git carries says WHICH kind it is.** The single
76
+ `(as git carries it)` label becomes `(git index)` for a path whose working-tree copy the run never
77
+ read and `(git index; the working tree differs)` for one whose disk copy carries other bytes, with
78
+ a footer counting how many reported hits are in those bytes. The second kind reads clean in the
79
+ file a developer opens, so re-staging is part of its remedy and one label hid that. **A gate test
80
+ asserting the old label asserts one of the two new ones.**
81
+ - **`phi-scan`: an index-route refusal no longer discards the hits already in hand.** An unmerged
82
+ index entry or a tracked link refused the whole sweep before anything was read, so a consumer saw
83
+ a refusal with no indication that PHI had already been found. The walk sweeps first, prints what
84
+ it found, and the refusal follows at the same code.
85
+ - **`phi-scan`: a clean run that skipped a file says so on stdout**, as
86
+ `OK: no hits (N untracked file(s) skipped, see stderr)`. With nothing skipped the line is
87
+ byte-identical to what it was.
88
+ - **`phi-scan`: a scan root has one settled outcome per KIND.** A regular file is scanned as one
89
+ target, a symbolic link is refused rather than followed, and a directory that cannot be listed is
90
+ refused at your `refuse` code naming the path: that one used to escape `readdirSync` uncaught and
91
+ take node's own exit 1, the code this contract reserves for HITS FOUND. The kind stays DERIVED
92
+ from the filesystem rather than declared, and what that gives up is recorded as N2 in ADR 0003.
93
+ - **`phi-scan`: two refusals reworded because their remedies differ.** "git could not read this
94
+ repository's git index" and "the git index holds no entries" were one sentence. The starved-root
95
+ refusal says how many of how many roots went unobserved and names them.
96
+ - **`phi-scan`: which tier refuses `--allow-fixture` follows the MODE.** A bypass beside a positional
97
+ path names something the run does not enumerate; a lone bypass declares a path an `all`-mode run
98
+ would have read and is refused by the completeness rule. Both name the path, and neither reaches a
99
+ clean run, which is unchanged.
100
+ - The `README.md` in the tarball now opens its `## Status` section on the settled-line sentence
101
+ instead of the pre-alpha ladder one, so the policy text a consumer reads agrees with the version
102
+ printed beside it. `scripts/readme-check.mjs` grades that sentence against the release line the
103
+ pending changesets resolve to.
104
+
11
105
  ## [0.0.2] - 2026-08-11
12
106
 
13
107
  ### Added
package/README.md CHANGED
@@ -1,10 +1,41 @@
1
+ <a href="https://cosyte.com">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
+ <img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
+ </picture>
6
+ </a>
7
+
1
8
  # @cosyte/script-utils
2
9
 
3
- Zero-dependency helpers for the repo-local gate scripts every cosyte repo keeps in `scripts/`.
10
+ > The two things every repo-local gate script gets wrong, fixed once and shared.
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@cosyte/script-utils.svg)](https://www.npmjs.com/package/@cosyte/script-utils)
13
+ [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/config/ci.yml?branch=main&label=CI)](https://github.com/cosyte/config/actions/workflows/ci.yml)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/cosyte/config/blob/main/LICENSE)
15
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.14-brightgreen.svg)](https://nodejs.org)
16
+
17
+ Zero-dependency helpers for the repo-local gate scripts in @cosyte/\* repos.
18
+
19
+ ## Why this exists
20
+
21
+ A gate script that exits 0 without having checked anything is worse than no gate, because the run
22
+ conclusion is the only thing anyone reads. Two ways of producing one turned out to be universal
23
+ across the cosyte repositories: an entrypoint guard written by hand that answers `false` for ordinary
24
+ invocations, and thirteen byte-distinct copies of one PHI scanner, each of which had to be fixed
25
+ separately when an escape was found.
26
+
27
+ The nearest alternative is `scripts/parser-template/`, and it is deliberately not this. A scaffold is
28
+ COPIED, so fixing the template fixes no repository that already exists. Here a fix is one pull
29
+ request and a version bump.
4
30
 
5
- No runtime dependencies and no build step. Both are load-bearing for the gates in `config` itself,
6
- which run **before `pnpm install`** on purpose, so that a broken or hostile install cannot decide
7
- whether a release gate runs.
31
+ ## Status
32
+
33
+ `@cosyte/script-utils` is on the cosyte 0.1.x line: the public API is settled and bump types follow ordinary semver.
34
+
35
+ Still moving: the `runPhiScan` option surface, which is where the per-repo axes below are still being
36
+ argued about, and the cross-cutting detection floor, which gains patterns as escapes are found. A new
37
+ pattern is a new red in a consumer that was passing, so pin an exact version if a green commit gate
38
+ matters more to you than a new detection.
8
39
 
9
40
  ## Install
10
41
 
@@ -12,13 +43,17 @@ whether a release gate runs.
12
43
  pnpm add -D @cosyte/script-utils
13
44
  ```
14
45
 
46
+ Node `>=22.14`. ESM only, no runtime dependencies, and no build step. Both of those are load-bearing
47
+ rather than minimalism: the gates in `config` itself run **before `pnpm install`** on purpose, so
48
+ that a broken or hostile install cannot decide whether a release gate runs.
49
+
15
50
  ### If your gate runs before `pnpm install`, do not use the bare specifier
16
51
 
17
52
  Read this before adopting the package, because it is the one thing that does not generalise.
18
53
 
19
54
  A bare `import ... from "@cosyte/script-utils"` resolves through `node_modules`, so it only works
20
- once the install has happened. `config`'s own two gates run before their install and therefore
21
- import this file by **relative path** instead, which needs nothing on disk but the checkout.
55
+ once the install has happened. `config`'s own gates run before their install and therefore import
56
+ this file by **relative path** instead, which needs nothing on disk but the checkout.
22
57
 
23
58
  So check when your gate runs, and pick accordingly:
24
59
 
@@ -29,26 +64,187 @@ So check when your gate runs, and pick accordingly:
29
64
 
30
65
  Installing the package does not, on its own, give a pre-install gate the property described above.
31
66
 
32
- ## `isCliEntrypoint(import.meta.url)`
67
+ Every entry point is published as source with no build step, so the relative-path route works for
68
+ all three: the files are `index.js`, `phi-scan.js` and `internal-refs.js`, each beside its own
69
+ hand-written `.d.ts`. A vendored copy is one of those files and nothing else.
33
70
 
34
- Is this module the file Node was pointed at, rather than one imported by something else?
71
+ ## Usage
35
72
 
36
- ```js
73
+ Guard a script's CLI side effect so a test can import it for its exports. That guard wraps the exit,
74
+ so this is the whole shape of a real gate:
75
+
76
+ ```ts runnable
37
77
  import { isCliEntrypoint } from "@cosyte/script-utils";
38
78
 
39
- export function main(argv) {
40
- /* ... */
79
+ /** Your gate's body, exported so a test can import it without the CLI running. */
80
+ export function main(argv: string[]): number {
81
+ return argv.length;
41
82
  }
42
83
 
43
- if (isCliEntrypoint(import.meta.url)) {
44
- process.exit(main(process.argv.slice(2)));
45
- }
84
+ // This module was imported, not pointed at, so the guard is closed: `main` does not run and nothing
85
+ // exits. The assertion is deliberately ahead of the branch, so the example cannot exit a test
86
+ // process even if that answer ever changed.
87
+ const runAsCli = isCliEntrypoint(import.meta.url);
88
+ runAsCli; // => false
89
+ if (runAsCli) process.exit(main(process.argv.slice(2)));
46
90
  ```
47
91
 
48
92
  That guard is what lets a test import the module for its exports without the CLI executing as a side
49
93
  effect, while the script still runs normally when invoked.
50
94
 
51
- ### Why not compare the strings
95
+ The PHI scanner is the other half, on its own subpath. Its shared read filter is exported so a
96
+ caller can compose with it rather than restate it:
97
+
98
+ ```ts runnable
99
+ import { exemptsMarkdown } from "@cosyte/script-utils/phi-scan";
100
+
101
+ exemptsMarkdown("docs/adopting.md"); // => false
102
+ exemptsMarkdown("src/patient.ts"); // => true
103
+ ```
104
+
105
+ The internal-reference gate is the third, and it is configured entirely through one object. The
106
+ axis names below are the whole interface a consumer needs, so they are checked here rather than
107
+ described:
108
+
109
+ ```ts runnable
110
+ import { canonicalRuleNames, resolveConfig } from "@cosyte/script-utils/internal-refs";
111
+
112
+ const config = {
113
+ projectPrefixes: ["HL7", "CCDA"],
114
+ standardsDesignations: ["HL7-(?:V2|V3|CDA)"],
115
+ surfacePaths: ["README.md", "docs-content"],
116
+ accountedTarballFiles: ["CHANGELOG.md", "dist"],
117
+ };
118
+
119
+ // Hand that to `runInternalRefsScan(config)` and it returns an exit code. Checked here without
120
+ // touching a repository, so this example reds if an axis is ever renamed under a consumer.
121
+ resolveConfig(config).problems; // => undefined
122
+ canonicalRuleNames().length; // => 6
123
+ ```
124
+
125
+ The `attw` publish gate is the fourth. It exists because `attw` prints "This package does not
126
+ contain types." and exits **0**: for a package that ships types, that sentence means the
127
+ declarations were not in the tarball, which is a broken publish reported as a pass. Your repo keeps
128
+ `scripts/attw.mjs` and an `attw` script whose body is exactly `node scripts/attw.mjs`; that file is
129
+ a caller, and this is the whole of it:
130
+
131
+ ```ts runnable
132
+ import { runAttwGate } from "@cosyte/script-utils/attw";
133
+
134
+ // The one line a consuming repo's `scripts/attw.mjs` carries:
135
+ //
136
+ // process.exitCode = runAttwGate({ callerUrl: import.meta.url });
137
+ //
138
+ // `callerUrl` is required and has no default. `attw` is resolved as `../node_modules/.bin/attw`
139
+ // from it, so the binary that runs is YOUR package's own; defaulted to this module's location it
140
+ // would resolve inside `node_modules/@cosyte/`, where your package has no attw at all. The gate
141
+ // RETURNS the code to exit with and never calls `process.exit`, so it is testable in-process.
142
+ typeof runAttwGate; // => "function"
143
+ ```
144
+
145
+ ## Entry points
146
+
147
+ | entry point | what it is |
148
+ | ------------------------------------ | ------------------------------------------------------------------------ |
149
+ | `@cosyte/script-utils` | `isCliEntrypoint(moduleUrl)`, the entry-point guard |
150
+ | `@cosyte/script-utils/phi-scan` | `runPhiScan(config)` and `exemptsMarkdown(relPath)`, the shared PHI gate |
151
+ | `@cosyte/script-utils/internal-refs` | `runInternalRefsScan(config)`, the shared internal-reference gate |
152
+ | `@cosyte/script-utils/attw` | `runAttwGate(options)`, the shared `attw` publish gate |
153
+
154
+ They are separate subpaths because they are separately adoptable: a repo can take the entry-point
155
+ guard without taking a position on PHI scanning, a repo can take the internal-reference gate without
156
+ taking either, and importing one never loads another.
157
+
158
+ ### The em-dash gate, which is a file rather than a subpath
159
+
160
+ This package also ships `check-no-emdash.sh`, the one implementation of the em-dash gate for every
161
+ `@cosyte/*` repository. It is a shell script and not a module, so it is not in the `exports` map:
162
+ nothing imports it, a repository EXECUTES it.
163
+
164
+ ```sh
165
+ # The package script every consuming repo keeps, which forwards its arguments:
166
+ # "check:no-emdash": "bash node_modules/@cosyte/script-utils/check-no-emdash.sh"
167
+ bash node_modules/@cosyte/script-utils/check-no-emdash.sh # scan every tracked file
168
+ bash node_modules/@cosyte/script-utils/check-no-emdash.sh --stdin PR # scan text on stdin
169
+ bash node_modules/@cosyte/script-utils/check-no-emdash.sh --list-scanned
170
+ bash node_modules/@cosyte/script-utils/check-no-emdash.sh --self-id
171
+ ```
172
+
173
+ Exit 0 means the mode completed and found nothing banned. Exit 1 means anything else: a banned form
174
+ was found, an input could not be read, an input was not scanned, a declaration was unusable, or an
175
+ argument was not recognised. There is no third code. Hits and diagnostics go to stderr in every mode.
176
+
177
+ `--self-id` prints the sha256 of the copy that is executing, so a repository can prove it is running
178
+ these bytes rather than a fork of them. `--list-scanned` prints the NUL-separated, repo-root-relative
179
+ path of every tracked file the default scan reads, so a repository adopting the gate can compare its
180
+ coverage against whatever it ran before rather than compare two filter expressions.
181
+
182
+ A repository narrows WHAT IS SCANNED, and only that, through a tracked file at its own top level,
183
+ `scripts/check-no-emdash.exclude`: one entry per line, `#` comments and blank lines ignored, an entry
184
+ ending in `/` covering every tracked path beneath it, and every entry anchored at the repository
185
+ root. An entry that matches no tracked path is refused rather than ignored, because an exclusion that
186
+ has outlived its subject is a hole nobody is looking at. Nothing narrows WHAT IS MATCHED: the six
187
+ banned forms are fixed in the gate.
188
+
189
+ ## Overrides
190
+
191
+ `isCliEntrypoint` has no options and cannot be overridden. It answers one question and its
192
+ tie-breaking direction is the whole design, so a knob to invert it would be a knob to turn the gate
193
+ off.
194
+
195
+ `runPhiScan` is configured entirely through its `config` argument, along the five axes tabulated
196
+ under [the five per-repo axes](#the-five-per-repo-axes) below: three are required because they are
197
+ the ones a port gets wrong, and the defaulted ones move for every repo at once through a version
198
+ bump.
199
+
200
+ The engine's own cross-cutting detection floor is not on that list, and it is not overridable.
201
+ Neither is the completeness rule that refuses a run which enumerated a target and never read it. A
202
+ caller can widen what is scanned and can add detection, and cannot subtract either of those two.
203
+
204
+ `runInternalRefsScan` follows the same bargain along [its own axes](#the-internal-reference-axes):
205
+ four required, six defaulted, and every per-repository difference supplied by the caller. What it
206
+ will not accept is a configuration that SUBTRACTS. The canonical rule set, the self-test floor and
207
+ every completeness refusal are not caller inputs, and an option that reaches for one of them is
208
+ refused by name rather than ignored. So is an option the gate has never heard of: an ignored setting
209
+ reads, from the caller's side, exactly like an honoured one.
210
+
211
+ `runAttwGate` takes the narrowest bargain of the four, and deliberately. A caller supplies where it
212
+ lives (`callerUrl`, required, no default) and may supply the arguments to forward; everything else
213
+ is the gate's. The arguments it will forward are an ALLOW-LIST of two (`--profile` with its value,
214
+ and `--no-definitely-typed`), because a deny-list of blinding spellings bought exactly one more
215
+ evasion per round: each of `--quiet`, `-q`, `--format json`, `-fjson`, `-qP`, `--config-path`,
216
+ `--help` and `--version` was measured making attw exit 0 with nothing for the gate to read.
217
+ Widening that set is a deliberate one-line edit in this package, reviewed here, and not something a
218
+ caller can do. No net can be switched off.
219
+
220
+ ## PHI and safety
221
+
222
+ This package ships the machinery of the PHI commit-gate, so it is the one package here that reads
223
+ files which may contain patient data. What it does with them, stated narrowly:
224
+
225
+ - It reads your repository's own files, in your own process, to look for PHI-shaped content. It sends
226
+ nothing anywhere and opens no network connection.
227
+ - It **does not log, echo or persist a matched value**. A finding names a path, a locator inside it
228
+ (`(ssn)`, `(email)`, or the field id your own detector raised it against) and the rule that fired;
229
+ the matched bytes are not written into the report, because a gate that prints the secret it found
230
+ has published it into a CI log. A `value` your detector passes to `ctx.hit` is dropped at the
231
+ boundary rather than stored, so nothing downstream can print what no record carries.
232
+ - The allow-list and the override log record DECLARATIONS about paths, never content.
233
+ - Detection is a floor, not a proof: this package owns a dashed SSN shape and an email at an
234
+ undeclared domain, and everything field-level is yours to supply through `detect`. A clean run
235
+ means nothing this configuration looked for was found, which is not the same as no PHI.
236
+
237
+ The consumer still owns the rest: deciding what a PHI-bearing field is for its standard, keeping real
238
+ patient data out of the repository in the first place, and never illustrating a rule, a fixture or an
239
+ issue report with a real value.
240
+
241
+ ## API
242
+
243
+ ### `isCliEntrypoint(import.meta.url)`
244
+
245
+ Is this module the file Node was pointed at, rather than one imported by something else?
246
+
247
+ #### Why not compare the strings
52
248
 
53
249
  The obvious spelling is a one-liner, and several cosyte repos wrote it by hand:
54
250
 
@@ -72,7 +268,7 @@ the defect the gate was written to catch, because the run conclusion is the only
72
268
  `isCliEntrypoint` compares canonical paths, resolves symlinks, and treats an `argv[1]` that names no
73
269
  file on disk as a specifier Node had to resolve.
74
270
 
75
- ### Which way it errs
271
+ #### Which way it errs
76
272
 
77
273
  When the answer is genuinely ambiguous it returns `true` and the CLI runs. A false positive runs a
78
274
  gate during an import, which is loud and immediately visible; a false negative skips a gate and
@@ -81,7 +277,7 @@ exits 0, which is silent. Those are not symmetric, so the tie goes to running.
81
277
  It throws a `TypeError` rather than returning `false` when handed something that is not a non-empty
82
278
  string, for the same reason.
83
279
 
84
- ## `runPhiScan(config)`
280
+ ### `runPhiScan(config)`
85
281
 
86
282
  `@cosyte/script-utils/phi-scan` is the shared machinery of the `@cosyte/*` PHI commit-gate.
87
283
 
@@ -103,7 +299,7 @@ process.exit(
103
299
 
104
300
  It returns an exit code rather than calling `process.exit`, so a test can drive it in process.
105
301
 
106
- ### Why it is a dependency and not a template file
302
+ #### Why it is a dependency and not a template file
107
303
 
108
304
  `scripts/parser-template/` is a **scaffold**, not a dependency: the scaffolder copies it, so fixing
109
305
  the template fixes no existing repo. That produced thirteen byte-distinct copies of one scanner, and
@@ -111,19 +307,29 @@ a newly-found escape therefore cost one pull request and one adversarial review
111
307
  escape classes were paid for that way before this package existed. Here it is one pull request and a
112
308
  version bump.
113
309
 
114
- ### What it owns
310
+ #### The boundary: what the engine owns, and what stays in your repo
311
+
312
+ **The ENGINE, shared and shipped here.** Argument parsing and the three modes (`--staged`, explicit
313
+ paths, and the `all`-mode sweep); reading the allow-list and the override log; target enumeration;
314
+ the union of the working-tree walk with the bytes git carries at every index path, deduplicated **by
315
+ content** under git's own `blob <len>\0` framing; the completeness rule (a target the run enumerated
316
+ and never read refuses, naming the paths); the per-root observation rule (`all` mode refuses unless
317
+ every `scanRoots` entry yielded a file the WALK read, naming the starved roots); every refusal; and a
318
+ cross-cutting detection floor that finds a dashed SSN shape and an email at an undeclared domain over
319
+ every file, always.
115
320
 
116
- Argument parsing and the three modes (`--staged`, explicit paths, and the `all`-mode sweep); the
117
- allow-list and the override log; target enumeration; the union of the working-tree walk with the
118
- bytes git carries, deduplicated **by content** under git's own `blob <len>\0` framing; the
119
- completeness rule (a target the run enumerated and never read refuses, naming the paths); every
120
- refusal; and a cross-cutting floor that detects a dashed SSN shape and an email at an undeclared
121
- domain.
321
+ **The RULES, local to your repo, never moved and never shipped in this package.**
322
+ `scripts/phi-allow-list.txt` (the positive declaration that a token is synthetic, tagged `NAME`,
323
+ `DOB`, `ADDR`, `ID`, `EMAILDOMAIN`); `phi-scan-overrides.md` (the `--allow-fixture` audit log, and
324
+ whatever category-to-field map your detector reads); and the per-standard field detector you supply
325
+ through `detect` (names, DOB, MRN / member id, address, phone, parsed out of your own wire format).
122
326
 
123
- It does **not** own per-standard field detection: names, DOB, MRN / member id, address, phone.
124
- Those differ per healthcare standard and are supplied through `detect`.
327
+ The split is not a convenience. **This package's `files` list ships no repo's rules**, so a
328
+ declaration one consumer makes can never travel to another consumer through a version bump: with no
329
+ file at `allowListPath` the engine treats every PHI-shaped token as undeclared, and refuses rather
330
+ than reporting clean. A rules file is a reviewed, committed act in the repository it describes.
125
331
 
126
- ### The five per-repo axes
332
+ #### The five per-repo axes
127
333
 
128
334
  Which ones are required is the design, not an oversight.
129
335
 
@@ -141,7 +347,7 @@ across a repo boundary is how a caller ends up branching on a meaning that repo
141
347
  A defaulted axis is the opposite case: every repo wants the same answer, so the answer lives here
142
348
  and reaches all of them through a version bump.
143
349
 
144
- ### Two things worth knowing before you adopt
350
+ #### Two things worth knowing before you adopt
145
351
 
146
352
  **A detector that consults nothing has no remedy.** The whole-file `--allow-fixture` bypass is
147
353
  recorded and then refused, so it cannot reach a clean run. Check every PHI-bearing field against
@@ -152,6 +358,136 @@ this on both branches.
152
358
  because without it the sweep is the working-tree walk's word alone. A freshly scaffolded repo has to
153
359
  `git init` and commit before an `all`-mode run means anything.
154
360
 
361
+ **Every scan root has to produce something.** `all` mode refuses when a `scanRoots` entry yielded no
362
+ file the walk read, and names it, even when git tracks files under it: those are still read from the
363
+ index, but what git carries cannot vouch for a directory on disk. A root that is missing, unreadable,
364
+ or whose every file your read filter drops used to leave the sweep quietly narrower than its own
365
+ configuration said, and one productive root made the whole run look productive. If you narrow
366
+ `scanRoots`, this is the tier that tells you what the narrowing stopped reading. A root naming a
367
+ regular file is scanned as one target; one naming a symbolic link is refused rather than followed;
368
+ one naming a directory that cannot be listed is refused, naming the path.
369
+
370
+ **`scanRoots` bounds the WALK, not the index.** `all` mode reads the bytes git carries at every
371
+ tracked path, whatever your roots say, because a root list answers what is on disk under it and the
372
+ index answers what your repository carries. A hit found there is labelled `(git index)` or
373
+ `(git index; the working tree differs)`, with a footer counting them: the second means the file on
374
+ disk reads clean, so re-staging is part of the remedy.
375
+
376
+ **`isStagedReadable` decides what `--staged` REFUSES, not only what it reads.** A staged entry that
377
+ is not a regular blob, or that has no stage-0 blob, is refused when this predicate admits it and left
378
+ alone when it does not, because what a commit is blocked on is your repository's decision. `all` mode
379
+ still refuses such an entry on both of its routes.
380
+
381
+ ### `runInternalRefsScan(config)`
382
+
383
+ `@cosyte/script-utils/internal-refs` keeps our own bookkeeping off every surface a consumer reads:
384
+ item identifiers, phase and wave language, ADR numbers, internal repository paths and traceability
385
+ markers, on the pages, the npm metadata and, optionally, the doc comments that compile into shipped
386
+ type declarations.
387
+
388
+ ```ts
389
+ import { runInternalRefsScan } from "@cosyte/script-utils/internal-refs";
390
+
391
+ process.exit(
392
+ runInternalRefsScan({
393
+ projectPrefixes: ["HL7", "CCDA", "MLLP"],
394
+ standardsDesignations: ["HL7-(?:V2|V3|CDA)", "FHIR-R\\d[A-Z]?"],
395
+ surfacePaths: ["README.md", "TRADEMARKS.md", "LICENSE", "docs-content"],
396
+ accountedTarballFiles: ["CHANGELOG.md", "dist"],
397
+ sourceDocComments: { enabled: true, paths: ["src/*.ts", "src/**/*.ts"] },
398
+ }),
399
+ );
400
+ ```
401
+
402
+ It returns an exit code rather than calling `process.exit`, so a test can drive it in process.
403
+
404
+ #### What exit 0 means, and what it does not
405
+
406
+ Exit 0 means the run completed, enumerated its configured surface, read every target it enumerated,
407
+ passed its own self-tests and found nothing. A hit, an enumerated target that was never read, an
408
+ unreadable input, an input a text matcher classifies as binary, a configured surface path the
409
+ repository does not track, a `files` entry the configuration does not account for, and a failed
410
+ self-test are each a non-zero exit with the cause on stderr. A hit report names the file, the line
411
+ and the rule.
412
+
413
+ #### The internal-reference axes
414
+
415
+ Four are required, and which four is the design rather than an oversight: each is the axis a port
416
+ gets wrong, and a default would be one repository's answer imposed on every other.
417
+
418
+ | Axis | Option | Required? |
419
+ | -------------------------- | ----------------------- | --------------------------------------------------------------------- |
420
+ | 1 Project prefixes | `projectPrefixes` | **Required**, empty refused. Keying on these is why `MSH-2` survives. |
421
+ | 2 Standards designations | `standardsDesignations` | **Required.** Never flagged. An empty list declares no collisions. |
422
+ | 3 Public surface | `surfacePaths` | **Required**, empty refused, every entry must be tracked. |
423
+ | 4 Tarball accounting | `accountedTarballFiles` | **Required.** The `files` entries already accounted for. |
424
+ | 5 Source doc comments | `sourceDocComments` | Defaulted OFF. Enable where the build copies doc text verbatim. |
425
+ | 6 Exit codes | `exitCodes` | Defaulted `{ clean: 0, hits: 1, refuse: 1 }`. `clean` must stay 0. |
426
+ | 7 Added detection | `extraRules` | Defaulted `[]`. Each added rule brings its own samples. |
427
+ | 8 Added self-test material | `extraSamples` | Defaulted `{}`. Additive only. |
428
+ | 9 Repository under test | `repoRoot` | Defaulted to the working directory, anchored at its git top level. |
429
+ | 10 Report destination | `write` | Defaulted to the process streams. |
430
+
431
+ `CONFIG_AXES` is the same table as data, so a consumer can enumerate it rather than transcribe it.
432
+
433
+ #### Two things worth knowing before you adopt this one
434
+
435
+ **The prefix set has to be extended by hand, and that is the cheaper mistake.** A new programme
436
+ means adding its prefix, and nothing catches it until someone does. The alternative is a `WORD-N`
437
+ shape rule, which flags the segment-field references a parser's documentation exists to provide.
438
+
439
+ **Every configured prefix has to be reachable.** The self-test floor proves each one still matches,
440
+ so a prefix shadowed by a standards designation refuses the run instead of silently contributing
441
+ nothing. That is the axis a caller edits most, and a dead entry there is a rule that stopped seeing.
442
+
443
+ ### `runAttwGate(options)`
444
+
445
+ The `attw` publish gate, run in the working directory of the package being checked. It returns the
446
+ exit code a caller should exit with: 0 when every net passed, attw's own status when attw itself
447
+ judged the package, and 1 for every refusal.
448
+
449
+ `attw --pack .` prints "This package does not contain types." and exits **0**, because
450
+ `getExitCode()` returns before it reads the problem list whenever the analysis found no types at
451
+ all. For a package that ships types, that is a broken publish reported as a pass, and the window it
452
+ arrives through is ordinary: `tsup` emits JS in one pass and declarations in a later one, so every
453
+ build has a moment where `dist/` holds `.mjs`/`.cjs` and no `.d.ts`.
454
+
455
+ Four nets, each catching something the others cannot:
456
+
457
+ 1. **Preflight**, over every relative artifact path the manifest promises. A path that is missing or
458
+ empty reds and is named. A manifest that declares NOTHING is refused rather than reported as
459
+ checked: three of the four nets grade that one set, so an empty set makes them vacuous at once.
460
+ 2. **The document attw prints**, never its prose. The gate forces `--format json` and asserts
461
+ `analysis.types.kind === "included"`, so a route that hides or reformats the output leaves stdout
462
+ unparseable, and unparseable fails closed.
463
+ 3. **npm's own pack listing.** A declared path can be on disk and out of the tarball, which net 1
464
+ cannot see and which a committed `.attw.json` can hide from attw's exit code. This net asks npm.
465
+ 4. **The manifest pnpm WOULD PUBLISH.** pnpm applies `publishConfig` as publish-time overrides and
466
+ rewrites the manifest inside the tarball; `npm pack` does not. This org publishes with pnpm, so
467
+ the document net 3 grades is not the one that ships.
468
+
469
+ What each net does and does not claim, and every measurement behind them, is in `attw.js`'s own
470
+ docblock. It is not restated here: a claim written down twice is a claim that drifts.
471
+
472
+ ## Compatibility
473
+
474
+ Node `>=22.14`, ESM only, published as source (`index.js`, `phi-scan.js`, `internal-refs.js` and
475
+ `attw.js` with hand-written `.d.ts` files) with no build step, so a consumer can import it by
476
+ relative path from a checkout as well as by specifier. `runPhiScan` shells out to `git` for the
477
+ index-backed half of its sweep, so `all` mode needs git on the path, `runInternalRefsScan` shells
478
+ out to `git` for its whole enumeration, and `runAttwGate` spawns the consuming package's own `attw`
479
+ binary, `npm pack` and, where a `publishConfig` object is present, `pnpm pack`.
480
+
481
+ ## Contributing
482
+
483
+ Questions, bug reports and proposals go to
484
+ [the issue tracker](https://github.com/cosyte/config/issues). Pull requests are welcome, in
485
+ [cosyte/config](https://github.com/cosyte/config), where this package lives.
486
+
487
+ A change has to clear the required `verify` job, and a change to the scan engine needs a changeset:
488
+ a new detection pattern reds a consumer that was passing, and a removed one silently stops catching
489
+ something. Never open an issue containing a real PHI value; describe the shape instead.
490
+
155
491
  ## License
156
492
 
157
- MIT
493
+ MIT, copyright Cosyte. See [LICENSE](https://github.com/cosyte/config/blob/main/LICENSE).
package/attw.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Type declarations for `@cosyte/script-utils/attw`, the shared `attw` publish gate.
3
+ *
4
+ * The behaviour, every net, every refusal and every measurement behind them are documented in
5
+ * `attw.js`'s own docblock, which is the single authoritative description. Nothing is restated
6
+ * here: a claim written down twice is a claim that drifts.
7
+ */
8
+
9
+ /** What a caller must tell the gate, and the one thing it may. */
10
+ export interface AttwGateOptions {
11
+ /**
12
+ * The CALLER's `import.meta.url`. REQUIRED, and deliberately without a default.
13
+ *
14
+ * `attw` is resolved as `../node_modules/.bin/attw` from this URL, so the binary the gate spawns
15
+ * is the CONSUMING package's own. Defaulted to this module's URL it would resolve inside
16
+ * `node_modules/@cosyte/`, where a consumer has no attw at all. A call that omits it is refused.
17
+ */
18
+ callerUrl: string;
19
+ /**
20
+ * The arguments to grade against the allow-list and forward to attw. Defaults to this process's
21
+ * own (`process.argv.slice(2)`). Only `--profile` (with its value) and `--no-definitely-typed`
22
+ * are accepted; everything else is refused by the allow-list rather than forwarded.
23
+ */
24
+ argv?: string[];
25
+ }
26
+
27
+ /**
28
+ * Run the gate in the current working directory, which must be the package being checked.
29
+ *
30
+ * Nothing here calls `process.exit`: the code is RETURNED, and a caller is expected to set
31
+ * `process.exitCode` from it.
32
+ *
33
+ * @returns 0 when every net passed, attw's own status when attw itself judged the package, and 1
34
+ * for every refusal the gate raises.
35
+ */
36
+ export declare function runAttwGate(options: AttwGateOptions): number;