@cosyte/script-utils 0.0.1 → 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/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,6 +277,216 @@ 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
 
280
+ ### `runPhiScan(config)`
281
+
282
+ `@cosyte/script-utils/phi-scan` is the shared machinery of the `@cosyte/*` PHI commit-gate.
283
+
284
+ ```ts
285
+ import { exemptsMarkdown, runPhiScan } from "@cosyte/script-utils/phi-scan";
286
+
287
+ process.exit(
288
+ runPhiScan({
289
+ exitCodes: { clean: 0, hits: 1, refuse: 2 },
290
+ scanRoots: ["."],
291
+ isStagedReadable: exemptsMarkdown,
292
+ detect: (ctx) => {
293
+ // Your standard's field-level detection. Check every PHI-bearing field
294
+ // against ctx.allow, and raise findings with ctx.hit(...).
295
+ },
296
+ }),
297
+ );
298
+ ```
299
+
300
+ It returns an exit code rather than calling `process.exit`, so a test can drive it in process.
301
+
302
+ #### Why it is a dependency and not a template file
303
+
304
+ `scripts/parser-template/` is a **scaffold**, not a dependency: the scaffolder copies it, so fixing
305
+ the template fixes no existing repo. That produced thirteen byte-distinct copies of one scanner, and
306
+ a newly-found escape therefore cost one pull request and one adversarial review **per repo**. Three
307
+ escape classes were paid for that way before this package existed. Here it is one pull request and a
308
+ version bump.
309
+
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.
320
+
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).
326
+
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.
331
+
332
+ #### The five per-repo axes
333
+
334
+ Which ones are required is the design, not an oversight.
335
+
336
+ | Axis | Option | Required? |
337
+ | ------------------- | --------------------------------- | --------------------------------------------------------------- |
338
+ | 1 Exit codes | `exitCodes` | **Required.** The siblings disagree; a default would be a port. |
339
+ | 2 Roots | `scanRoots` | **Required.** `["."]` is the whole repository. |
340
+ | 2 Roots (subtract) | `excludedPaths`, `isWalkReadable` | Defaulted. Moving the shared boundary is one change here. |
341
+ | 3 `--staged` scope | `isStagedReadable` | **Required.** It decides what a commit is blocked on. |
342
+ | 4 Gitlinks | `regularBlobModes` | Defaulted to git's two regular-blob modes. |
343
+ | 5 EOL normalization | none | Machinery. A port must check it, not set it. |
344
+
345
+ A required axis has no default **because** it is the thing a port gets wrong: carrying an exit code
346
+ across a repo boundary is how a caller ends up branching on a meaning that repo never assigned.
347
+ A defaulted axis is the opposite case: every repo wants the same answer, so the answer lives here
348
+ and reaches all of them through a version bump.
349
+
350
+ #### Two things worth knowing before you adopt
351
+
352
+ **A detector that consults nothing has no remedy.** The whole-file `--allow-fixture` bypass is
353
+ recorded and then refused, so it cannot reach a clean run. Check every PHI-bearing field against
354
+ `ctx.allow`, or a developer meeting your detector has nowhere to go. The engine's own floor does
355
+ this on both branches.
356
+
357
+ **`all` mode needs a git index.** It refuses when git cannot name the index or names it empty,
358
+ because without it the sweep is the working-tree walk's word alone. A freshly scaffolded repo has to
359
+ `git init` and commit before an `all`-mode run means anything.
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
+
84
490
  ## License
85
491
 
86
- 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;