@cosyte/script-utils 0.0.2 → 0.1.0

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,92 @@
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.0] - Unreleased
16
+
17
+ ### Changed
18
+
19
+ - **The package leaves the pre-alpha version ladder for the `0.1.x` line.** `isCliEntrypoint`
20
+ exports exactly what `0.0.2` exports. The phi-scan engine does NOT: see the entries below, which
21
+ are what this release actually carries on that subpath. The version policy itself is unchanged in
22
+ substance and the reasoning is in
23
+ [ADR 0002](../../documentation/decisions/0002-the-0-1-0-version-line.md). A consumer pinned at
24
+ `^0.0.2` does not resolve this release and has to widen its range once, which is recorded as break
25
+ candidate BC-2 in `documentation/release-0.1.0-audit.md`.
26
+ - **`phi-scan`: `all` mode refuses unless EVERY scan root yielded a file that was read**, naming the
27
+ starved roots. The completeness rule asks whether every target was read; nothing asked whether a
28
+ root produced a target at all, and one productive root made the whole run look productive.
29
+ Measured before the change: a caller supplying an `isWalkReadable` that admits nothing read no
30
+ file on either sweeping route and printed `OK: no hits` at the CLEAN code over a tracked file
31
+ carrying a live dashed identifier.
32
+ - **`phi-scan`: an UNTRACKED file removed mid-run is reported SKIPPED rather than refusing.** A
33
+ target the walk listed and that was gone by read time refused unconditionally, which made an
34
+ ordinary mid-run deletion look like an unaccounted-for file. Git carries no bytes at such a path,
35
+ so nothing the repository holds went unread. The exception is bounded: a tracked file, a
36
+ non-`ENOENT` failure, a path named on argv and a path that came BACK before the run ended all
37
+ still refuse.
38
+ - **`phi-scan`: a finding names a position and stops there.** A hit report prints the locus, the
39
+ locator inside it and the rule that fired, and no longer the token that matched:
40
+ `segment=(ssn) (dashed SSN pattern)` where it used to carry the identifier as well. stderr is a CI
41
+ log, and a diagnostic ABOUT a PHI leak that quotes the leak is a second copy of it somewhere with
42
+ no retention policy. `Hit.value` is now optional, a hit the engine reports never carries one, and
43
+ **a gate test asserting that the matched identifier reaches stderr asserts on the locator and the
44
+ rule instead.**
45
+ - **`phi-scan`: `all` mode reads the bytes git carries at EVERY tracked path.** `scanRoots` bounds
46
+ the WALK, which answers what is on disk under the roots you declared; the index answers what your
47
+ repository CARRIES, and a root list was narrowing that too. Measured on the first consumer to
48
+ adopt this engine: three real messages under an undeclared top-level directory were read by
49
+ neither route, and a tracked symbolic link outside every root was reached by neither. **A
50
+ repository whose roots are narrower than itself has more of it read after this release.** The two
51
+ READ filters are unchanged and still apply on both sweeping routes, so a tracked `.md` and an
52
+ `excludedPaths` entry mean what they meant. `--staged` is untouched.
53
+ - **`phi-scan`: the `--staged` route refuses what `isStagedReadable` admits, and nothing else.** Its
54
+ non-regular and unmerged refusals used to fire for any path under a scan root, so a repository
55
+ whose staged scope is narrower than its roots had commits blocked that its own gate had always let
56
+ through: what a commit is blocked on is a hook decision each repository takes for itself. **This
57
+ refuses LESS at the pre-commit hook.** A staged entry your staged scope declines is still refused
58
+ by `all` mode on both of its routes, so your own sweep catches it; widen `isStagedReadable` if you
59
+ want the hook to refuse it too. Recorded with its compensating control as N1 in
60
+ [ADR 0003](../../documentation/decisions/0003-the-phi-scan-engine-gap-settlements.md).
61
+ - **`phi-scan`: a hit in the bytes git carries says WHICH kind it is.** The single
62
+ `(as git carries it)` label becomes `(git index)` for a path whose working-tree copy the run never
63
+ read and `(git index; the working tree differs)` for one whose disk copy carries other bytes, with
64
+ a footer counting how many reported hits are in those bytes. The second kind reads clean in the
65
+ file a developer opens, so re-staging is part of its remedy and one label hid that. **A gate test
66
+ asserting the old label asserts one of the two new ones.**
67
+ - **`phi-scan`: an index-route refusal no longer discards the hits already in hand.** An unmerged
68
+ index entry or a tracked link refused the whole sweep before anything was read, so a consumer saw
69
+ a refusal with no indication that PHI had already been found. The walk sweeps first, prints what
70
+ it found, and the refusal follows at the same code.
71
+ - **`phi-scan`: a clean run that skipped a file says so on stdout**, as
72
+ `OK: no hits (N untracked file(s) skipped, see stderr)`. With nothing skipped the line is
73
+ byte-identical to what it was.
74
+ - **`phi-scan`: a scan root has one settled outcome per KIND.** A regular file is scanned as one
75
+ target, a symbolic link is refused rather than followed, and a directory that cannot be listed is
76
+ refused at your `refuse` code naming the path: that one used to escape `readdirSync` uncaught and
77
+ take node's own exit 1, the code this contract reserves for HITS FOUND. The kind stays DERIVED
78
+ from the filesystem rather than declared, and what that gives up is recorded as N2 in ADR 0003.
79
+ - **`phi-scan`: two refusals reworded because their remedies differ.** "git could not read this
80
+ repository's git index" and "the git index holds no entries" were one sentence. The starved-root
81
+ refusal says how many of how many roots went unobserved and names them.
82
+ - **`phi-scan`: which tier refuses `--allow-fixture` follows the MODE.** A bypass beside a positional
83
+ path names something the run does not enumerate; a lone bypass declares a path an `all`-mode run
84
+ would have read and is refused by the completeness rule. Both name the path, and neither reaches a
85
+ clean run, which is unchanged.
86
+ - The `README.md` in the tarball now opens its `## Status` section on the settled-line sentence
87
+ instead of the pre-alpha ladder one, so the policy text a consumer reads agrees with the version
88
+ printed beside it. `scripts/readme-check.mjs` grades that sentence against the release line the
89
+ pending changesets resolve to.
90
+
11
91
  ## [0.0.2] - 2026-08-11
12
92
 
13
93
  ### 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.
30
+
31
+ ## Status
4
32
 
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.
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 that was 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,135 @@ 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 that was read, and names it. A root that is missing, unreadable, or whose every file your read
363
+ filter drops used to leave the sweep quietly narrower than its own configuration said, and one
364
+ productive root made the whole run look productive. If you narrow `scanRoots`, this is the tier that
365
+ tells you what the narrowing stopped reading. A root naming a regular file is scanned as one target;
366
+ one naming a symbolic link is refused rather than followed; one naming a directory that cannot be
367
+ listed is refused, naming the path.
368
+
369
+ **`scanRoots` bounds the WALK, not the index.** `all` mode reads the bytes git carries at every
370
+ tracked path, whatever your roots say, because a root list answers what is on disk under it and the
371
+ index answers what your repository carries. A hit found there is labelled `(git index)` or
372
+ `(git index; the working tree differs)`, with a footer counting them: the second means the file on
373
+ disk reads clean, so re-staging is part of the remedy.
374
+
375
+ **`isStagedReadable` decides what `--staged` REFUSES, not only what it reads.** A staged entry that
376
+ is not a regular blob, or that has no stage-0 blob, is refused when this predicate admits it and left
377
+ alone when it does not, because what a commit is blocked on is your repository's decision. `all` mode
378
+ still refuses such an entry on both of its routes.
379
+
380
+ ### `runInternalRefsScan(config)`
381
+
382
+ `@cosyte/script-utils/internal-refs` keeps our own bookkeeping off every surface a consumer reads:
383
+ item identifiers, phase and wave language, ADR numbers, internal repository paths and traceability
384
+ markers, on the pages, the npm metadata and, optionally, the doc comments that compile into shipped
385
+ type declarations.
386
+
387
+ ```ts
388
+ import { runInternalRefsScan } from "@cosyte/script-utils/internal-refs";
389
+
390
+ process.exit(
391
+ runInternalRefsScan({
392
+ projectPrefixes: ["HL7", "CCDA", "MLLP"],
393
+ standardsDesignations: ["HL7-(?:V2|V3|CDA)", "FHIR-R\\d[A-Z]?"],
394
+ surfacePaths: ["README.md", "TRADEMARKS.md", "LICENSE", "docs-content"],
395
+ accountedTarballFiles: ["CHANGELOG.md", "dist"],
396
+ sourceDocComments: { enabled: true, paths: ["src/*.ts", "src/**/*.ts"] },
397
+ }),
398
+ );
399
+ ```
400
+
401
+ It returns an exit code rather than calling `process.exit`, so a test can drive it in process.
402
+
403
+ #### What exit 0 means, and what it does not
404
+
405
+ Exit 0 means the run completed, enumerated its configured surface, read every target it enumerated,
406
+ passed its own self-tests and found nothing. A hit, an enumerated target that was never read, an
407
+ unreadable input, an input a text matcher classifies as binary, a configured surface path the
408
+ repository does not track, a `files` entry the configuration does not account for, and a failed
409
+ self-test are each a non-zero exit with the cause on stderr. A hit report names the file, the line
410
+ and the rule.
411
+
412
+ #### The internal-reference axes
413
+
414
+ Four are required, and which four is the design rather than an oversight: each is the axis a port
415
+ gets wrong, and a default would be one repository's answer imposed on every other.
416
+
417
+ | Axis | Option | Required? |
418
+ | -------------------------- | ----------------------- | --------------------------------------------------------------------- |
419
+ | 1 Project prefixes | `projectPrefixes` | **Required**, empty refused. Keying on these is why `MSH-2` survives. |
420
+ | 2 Standards designations | `standardsDesignations` | **Required.** Never flagged. An empty list declares no collisions. |
421
+ | 3 Public surface | `surfacePaths` | **Required**, empty refused, every entry must be tracked. |
422
+ | 4 Tarball accounting | `accountedTarballFiles` | **Required.** The `files` entries already accounted for. |
423
+ | 5 Source doc comments | `sourceDocComments` | Defaulted OFF. Enable where the build copies doc text verbatim. |
424
+ | 6 Exit codes | `exitCodes` | Defaulted `{ clean: 0, hits: 1, refuse: 1 }`. `clean` must stay 0. |
425
+ | 7 Added detection | `extraRules` | Defaulted `[]`. Each added rule brings its own samples. |
426
+ | 8 Added self-test material | `extraSamples` | Defaulted `{}`. Additive only. |
427
+ | 9 Repository under test | `repoRoot` | Defaulted to the working directory, anchored at its git top level. |
428
+ | 10 Report destination | `write` | Defaulted to the process streams. |
429
+
430
+ `CONFIG_AXES` is the same table as data, so a consumer can enumerate it rather than transcribe it.
431
+
432
+ #### Two things worth knowing before you adopt this one
433
+
434
+ **The prefix set has to be extended by hand, and that is the cheaper mistake.** A new programme
435
+ means adding its prefix, and nothing catches it until someone does. The alternative is a `WORD-N`
436
+ shape rule, which flags the segment-field references a parser's documentation exists to provide.
437
+
438
+ **Every configured prefix has to be reachable.** The self-test floor proves each one still matches,
439
+ so a prefix shadowed by a standards designation refuses the run instead of silently contributing
440
+ nothing. That is the axis a caller edits most, and a dead entry there is a rule that stopped seeing.
441
+
442
+ ### `runAttwGate(options)`
443
+
444
+ The `attw` publish gate, run in the working directory of the package being checked. It returns the
445
+ exit code a caller should exit with: 0 when every net passed, attw's own status when attw itself
446
+ judged the package, and 1 for every refusal.
447
+
448
+ `attw --pack .` prints "This package does not contain types." and exits **0**, because
449
+ `getExitCode()` returns before it reads the problem list whenever the analysis found no types at
450
+ all. For a package that ships types, that is a broken publish reported as a pass, and the window it
451
+ arrives through is ordinary: `tsup` emits JS in one pass and declarations in a later one, so every
452
+ build has a moment where `dist/` holds `.mjs`/`.cjs` and no `.d.ts`.
453
+
454
+ Four nets, each catching something the others cannot:
455
+
456
+ 1. **Preflight**, over every relative artifact path the manifest promises. A path that is missing or
457
+ empty reds and is named. A manifest that declares NOTHING is refused rather than reported as
458
+ checked: three of the four nets grade that one set, so an empty set makes them vacuous at once.
459
+ 2. **The document attw prints**, never its prose. The gate forces `--format json` and asserts
460
+ `analysis.types.kind === "included"`, so a route that hides or reformats the output leaves stdout
461
+ unparseable, and unparseable fails closed.
462
+ 3. **npm's own pack listing.** A declared path can be on disk and out of the tarball, which net 1
463
+ cannot see and which a committed `.attw.json` can hide from attw's exit code. This net asks npm.
464
+ 4. **The manifest pnpm WOULD PUBLISH.** pnpm applies `publishConfig` as publish-time overrides and
465
+ rewrites the manifest inside the tarball; `npm pack` does not. This org publishes with pnpm, so
466
+ the document net 3 grades is not the one that ships.
467
+
468
+ What each net does and does not claim, and every measurement behind them, is in `attw.js`'s own
469
+ docblock. It is not restated here: a claim written down twice is a claim that drifts.
470
+
471
+ ## Compatibility
472
+
473
+ Node `>=22.14`, ESM only, published as source (`index.js`, `phi-scan.js`, `internal-refs.js` and
474
+ `attw.js` with hand-written `.d.ts` files) with no build step, so a consumer can import it by
475
+ relative path from a checkout as well as by specifier. `runPhiScan` shells out to `git` for the
476
+ index-backed half of its sweep, so `all` mode needs git on the path, `runInternalRefsScan` shells
477
+ out to `git` for its whole enumeration, and `runAttwGate` spawns the consuming package's own `attw`
478
+ binary, `npm pack` and, where a `publishConfig` object is present, `pnpm pack`.
479
+
480
+ ## Contributing
481
+
482
+ Questions, bug reports and proposals go to
483
+ [the issue tracker](https://github.com/cosyte/config/issues). Pull requests are welcome, in
484
+ [cosyte/config](https://github.com/cosyte/config), where this package lives.
485
+
486
+ A change has to clear the required `verify` job, and a change to the scan engine needs a changeset:
487
+ a new detection pattern reds a consumer that was passing, and a removed one silently stops catching
488
+ something. Never open an issue containing a real PHI value; describe the shape instead.
489
+
155
490
  ## License
156
491
 
157
- MIT
492
+ 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;