@cosyte/script-utils 0.0.1 → 0.0.2

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
@@ -8,6 +8,98 @@ the **`0.0.x`-until-first-alpha** ladder.
8
8
  > Because the generator is disabled, **`[Unreleased]` is promoted to a version heading BY HAND**, in
9
9
  > the pull request that adds the changeset. Nothing does it automatically.
10
10
 
11
+ ## [0.0.2] - 2026-08-11
12
+
13
+ ### Added
14
+
15
+ - **`@cosyte/script-utils/phi-scan`: the shared machinery of the `@cosyte/*` PHI commit-gate, as a
16
+ parameterised engine.** `scripts/parser-template/` is a SCAFFOLD rather than a dependency, so
17
+ `scripts/phi-scan.ts` was COPIED into every parser repo. **Thirteen repos, thirteen byte-distinct
18
+ copies**, so a newly-found escape cost one pull request and one adversarial review PER REPO, and
19
+ three escape classes had already been paid for that way. `runPhiScan(config)` owns argument
20
+ parsing, the allow-list and override log, target enumeration on all three routes, the union of the
21
+ working-tree walk with the bytes git carries, content deduplication under git's own
22
+ `blob <len>\0` framing, the completeness rule, every refusal, and the cross-cutting SSN/email
23
+ floor.
24
+
25
+ **THE FIVE PER-REPO AXES ARE PARAMETERS, NOT FORKS**, and which ones are required is itself the
26
+ design. `exitCodes`, `scanRoots` and `isStagedReadable` are **REQUIRED**: the siblings genuinely
27
+ disagree on all three, and a default would be the porting mistake the gate exists to catch (at
28
+ least one sibling uses 2 where another uses 1). `excludedPaths`, `isWalkReadable` (the Markdown
29
+ read exemption) and `regularBlobModes` are **DEFAULTED**, so moving one of those boundaries is a
30
+ change to this package plus a version bump rather than an edit in thirteen repos. EOL
31
+ normalization has **no parameter at all** and stays machinery: the walk/index deduplication is by
32
+ CONTENT, so where a `text` attribute or `core.autocrlf` makes the index carry LF and the working
33
+ tree CRLF, BOTH forms are scanned.
34
+
35
+ **Per-standard field detection stays with the caller**, through `detect`. It is handed the
36
+ reported LOCUS rather than the target path, so a hit found in the bytes git carries cannot be
37
+ reported against a working-tree copy a developer would open and find clean. That used to be a
38
+ sentence in a comment; it is now the only path a caller can reach.
39
+
40
+ - **A scan root may name a regular FILE, and the kind is DERIVED from the filesystem rather than
41
+ declared**, which is what keeps the parameter a plain `string[]`. Measured against the thirteen
42
+ live copies: they declare roots in at least six different shapes, and one declares
43
+ `{ rel, shape: "directory" | "file" }` with a single file among them. An earlier draft fed every
44
+ root to `readdirSync`, so such a root threw `ENOTDIR`, uncaught, and the run took node's exit 1,
45
+ the code this contract reserves for HITS FOUND. **What deriving gives up is stated rather than
46
+ left to be found**: a declaration can notice a root is not the KIND it was meant to be and
47
+ derivation cannot. A root that is neither a file nor a directory is still refused, a root naming a
48
+ symbolic link is refused rather than followed, and a MISSING root is skipped, which is unchanged
49
+ from the copied scanners and is named as the one remaining silently-empty root state.
50
+ - **A scan root of `"."` means the whole repository**, with gitignored directories pruned during
51
+ descent and `.git` skipped by literal name, which is what makes a whole-repository root usable at
52
+ all rather than a walk through `node_modules`. Pruning is equivalent to filtering afterwards
53
+ because **`git check-ignore` is index-aware at directory granularity**, measured both ways on git
54
+ 2.39.5: with nothing tracked underneath, a gitignored directory is reported ignored and pruned;
55
+ with one file force-added underneath it is reported NOT ignored, so the walk descends and still
56
+ reads it. An earlier draft justified this with the gitignore-pattern rule that a path under an
57
+ excluded directory cannot be re-included, which does not settle it, because the filter being
58
+ replaced asked `check-ignore` too.
59
+
60
+ ### Changed
61
+
62
+ - **Two containments the first draft ASSERTED are now ENFORCED**, both falsified by this slice's
63
+ adversarial review and both reproduced against the pre-consolidation scanner too, so neither is a
64
+ regression this package introduced. What was introduced was the sentence claiming they held, on the
65
+ API contract thirteen migrations are about to be written against.
66
+ - **A staged path `isStagedReadable` admits that no scan root covers is REFUSED.** The type said
67
+ the `--staged` filter was "narrower than the root half by construction"; nothing constructed it,
68
+ because they are two independent keys. Measured with roots at `["src"]` and the filter at the
69
+ shared Markdown exemption: a STAGED mode-120000 entry under `test/fixtures/` was outside every
70
+ scan root, so the non-regular refusal never saw it, and the route enumerated it, READ it, handed
71
+ the link's TARGET PATH to the detector as if it were content, counted the scan complete and
72
+ printed `OK: no hits` at exit 0. Narrowing silently to the intersection would have been the wrong
73
+ repair: it hides a misconfiguration in the one place the gate blocks a commit.
74
+ - **A scan root is normalised the way every other path is, and one resolving outside the repository
75
+ is refused.** `"./src"` is a spelling the type documents as valid; it walked correctly while
76
+ `isUnderScanRoot` compared it against the normalized index path and never matched, emptying the
77
+ union, the index non-blob refusal and the unmerged refusal in silence. Measured: `["src"]`
78
+ refused a tracked mode-120000 entry at exit 2 while `["./src"]` reported clean at exit 0 over the
79
+ same repository.
80
+ - **The optional axes are shape-checked.** `excludedPaths: ["a"]`, a plausible reading of
81
+ "repo-relative paths", used to survive normalization, reach `.has(...)` inside enumeration, and
82
+ take node's exit 1 from there, which the exit contract reserves for HITS FOUND.
83
+ - **The floor's dashed-SSN branch now consults `allow.ids`, in either rendering.** This is a
84
+ correction rather than a port: with the whole-file `--allow-fixture` bypass closed, a detector
85
+ that consults nothing leaves a developer with a hit and **no remedy at all**, and a sibling
86
+ shipped a footer claiming the token allow-list was the only remedy that reaches a clean run while
87
+ that was false for exactly this branch. One `ID` entry now covers both the dashed and the undashed
88
+ rendering, so a repo does not have to guess which one a fixture uses.
89
+ - **A fatal partway through the sweep prints the hits already found BEFORE the refusal**, instead of
90
+ discarding them. Measured on a mutant that throws after the reads: the old ordering produced a
91
+ refusal with **zero** `HIT:` lines over a corpus containing a real hit. The refusal still wins the
92
+ exit code and the clean line is still unreachable from there, so nothing is reported as accounted
93
+ for that is not.
94
+ - **The hit footer is scoped to what the engine can know.** It says the cross-cutting floor consults
95
+ the allow-list and that a repo's own detectors are answerable that way **only if** they consult
96
+ it. The engine cannot see inside a caller's `detect`, so a wider claim would be one it has no
97
+ evidence for.
98
+ - **A detector that throws REFUSES the scan** rather than escaping to node's own exit code, which
99
+ this contract reserves for HITS FOUND. A misconfigured scanner throws a `TypeError` rather than
100
+ returning a code: at the point a required axis is missing, `exitCodes` is itself the thing that
101
+ was not supplied, so there is no trustworthy code to return.
102
+
11
103
  ## [0.0.1] - 2026-08-05
12
104
 
13
105
  ### Added
package/README.md CHANGED
@@ -81,6 +81,77 @@ exits 0, which is silent. Those are not symmetric, so the tie goes to running.
81
81
  It throws a `TypeError` rather than returning `false` when handed something that is not a non-empty
82
82
  string, for the same reason.
83
83
 
84
+ ## `runPhiScan(config)`
85
+
86
+ `@cosyte/script-utils/phi-scan` is the shared machinery of the `@cosyte/*` PHI commit-gate.
87
+
88
+ ```ts
89
+ import { exemptsMarkdown, runPhiScan } from "@cosyte/script-utils/phi-scan";
90
+
91
+ process.exit(
92
+ runPhiScan({
93
+ exitCodes: { clean: 0, hits: 1, refuse: 2 },
94
+ scanRoots: ["."],
95
+ isStagedReadable: exemptsMarkdown,
96
+ detect: (ctx) => {
97
+ // Your standard's field-level detection. Check every PHI-bearing field
98
+ // against ctx.allow, and raise findings with ctx.hit(...).
99
+ },
100
+ }),
101
+ );
102
+ ```
103
+
104
+ It returns an exit code rather than calling `process.exit`, so a test can drive it in process.
105
+
106
+ ### Why it is a dependency and not a template file
107
+
108
+ `scripts/parser-template/` is a **scaffold**, not a dependency: the scaffolder copies it, so fixing
109
+ the template fixes no existing repo. That produced thirteen byte-distinct copies of one scanner, and
110
+ a newly-found escape therefore cost one pull request and one adversarial review **per repo**. Three
111
+ escape classes were paid for that way before this package existed. Here it is one pull request and a
112
+ version bump.
113
+
114
+ ### What it owns
115
+
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.
122
+
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`.
125
+
126
+ ### The five per-repo axes
127
+
128
+ Which ones are required is the design, not an oversight.
129
+
130
+ | Axis | Option | Required? |
131
+ | ------------------- | --------------------------------- | --------------------------------------------------------------- |
132
+ | 1 Exit codes | `exitCodes` | **Required.** The siblings disagree; a default would be a port. |
133
+ | 2 Roots | `scanRoots` | **Required.** `["."]` is the whole repository. |
134
+ | 2 Roots (subtract) | `excludedPaths`, `isWalkReadable` | Defaulted. Moving the shared boundary is one change here. |
135
+ | 3 `--staged` scope | `isStagedReadable` | **Required.** It decides what a commit is blocked on. |
136
+ | 4 Gitlinks | `regularBlobModes` | Defaulted to git's two regular-blob modes. |
137
+ | 5 EOL normalization | none | Machinery. A port must check it, not set it. |
138
+
139
+ A required axis has no default **because** it is the thing a port gets wrong: carrying an exit code
140
+ across a repo boundary is how a caller ends up branching on a meaning that repo never assigned.
141
+ A defaulted axis is the opposite case: every repo wants the same answer, so the answer lives here
142
+ and reaches all of them through a version bump.
143
+
144
+ ### Two things worth knowing before you adopt
145
+
146
+ **A detector that consults nothing has no remedy.** The whole-file `--allow-fixture` bypass is
147
+ recorded and then refused, so it cannot reach a clean run. Check every PHI-bearing field against
148
+ `ctx.allow`, or a developer meeting your detector has nowhere to go. The engine's own floor does
149
+ this on both branches.
150
+
151
+ **`all` mode needs a git index.** It refuses when git cannot name the index or names it empty,
152
+ because without it the sweep is the working-tree walk's word alone. A freshly scaffolded repo has to
153
+ `git init` and commit before an `all`-mode run means anything.
154
+
84
155
  ## License
85
156
 
86
157
  MIT
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "entrypoint",
8
8
  "import-meta",
9
+ "phi",
9
10
  "scripts",
10
11
  "cosyte"
11
12
  ],
@@ -24,11 +25,17 @@
24
25
  ".": {
25
26
  "types": "./index.d.ts",
26
27
  "default": "./index.js"
28
+ },
29
+ "./phi-scan": {
30
+ "types": "./phi-scan.d.ts",
31
+ "default": "./phi-scan.js"
27
32
  }
28
33
  },
29
34
  "files": [
30
35
  "index.js",
31
36
  "index.d.ts",
37
+ "phi-scan.js",
38
+ "phi-scan.d.ts",
32
39
  "README.md",
33
40
  "CHANGELOG.md"
34
41
  ],
package/phi-scan.d.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Types for `@cosyte/script-utils/phi-scan`: the shared machinery of the `@cosyte/*` PHI
3
+ * commit-gate.
4
+ *
5
+ * The engine owns enumeration, the union of the working-tree walk with the bytes git carries,
6
+ * content deduplication, the completeness rule, the refusals and the cross-cutting SSN/email floor.
7
+ * The caller owns the five per-repo axes below and the per-standard field detectors.
8
+ */
9
+
10
+ /** A reported PHI finding. `path` is the LOCUS the engine chose, never a path the caller invents. */
11
+ export interface Hit {
12
+ /** The reported locus: the target's repo-relative path, plus an origin label when it has one. */
13
+ path: string;
14
+ /** A locator inside the target: `(ssn)`, `(email)`, or a field id from a per-standard detector. */
15
+ segment: string;
16
+ /** The offending value, as found. */
17
+ value: string;
18
+ /** Why it was raised, in a few words. */
19
+ reason: string;
20
+ }
21
+
22
+ /** The positive declaration that specific identifiers are synthetic. */
23
+ export interface AllowList {
24
+ /** Uppercase synthetic person-name tokens. Consumed by a caller's structured name detector. */
25
+ names: Set<string>;
26
+ /** Synthetic dates of birth, in whatever form the caller's detector normalises to. */
27
+ dobs: Set<string>;
28
+ /** Synthetic id values (SSN / MRN / member-id shapes), uppercased. Also read by the SSN floor. */
29
+ ids: Set<string>;
30
+ /** Allowed email domains, lowercased. Anything else is a hit. Read by the email floor. */
31
+ emailDomains: Set<string>;
32
+ }
33
+
34
+ /** A stage-0 index entry: the mode git records, and the object it points at. */
35
+ export interface IndexEntry {
36
+ mode: string;
37
+ oid: string;
38
+ }
39
+
40
+ /** One thing the scan will read, and where its bytes come from. */
41
+ export interface Target {
42
+ /** Repo-relative, forward-slashed. Every filter, exclusion and completeness tier keys on this. */
43
+ path: string;
44
+ read: () => Buffer;
45
+ /**
46
+ * Where these bytes came from, when it is not simply the file at `path`. Set only by the index
47
+ * union, and it decorates the REPORTED LOCUS ONLY.
48
+ */
49
+ origin?: string;
50
+ }
51
+
52
+ /** An entry the enumeration reached and cannot scan. Both fields are safe to print. */
53
+ export interface Unscannable {
54
+ path: string;
55
+ /** A token from the engine's own closed set. Never text off the other side of a link. */
56
+ kind: string;
57
+ }
58
+
59
+ /** What a per-standard field detector is handed for one target. */
60
+ export interface DetectContext {
61
+ /**
62
+ * The reported LOCUS, already carrying any origin label. Raise hits against this and nothing else:
63
+ * a hit naming a bare path whose working-tree copy is clean sends a developer to the wrong file.
64
+ */
65
+ path: string;
66
+ /** The target's bytes decoded as UTF-8. */
67
+ text: string;
68
+ /** The target's bytes, for a byte-strict format. */
69
+ bytes: Buffer;
70
+ /** The parsed allow-list, so a detector can honour a positive synthetic declaration. */
71
+ allow: AllowList;
72
+ /** Raise a hit. The locus is filled in by the engine. */
73
+ hit: (h: Omit<Hit, "path">) => void;
74
+ }
75
+
76
+ /**
77
+ * A per-standard field detector. Throwing REFUSES the scan rather than taking node's exit code.
78
+ *
79
+ * 🛑 THE THROWN MESSAGE IS PRINTED VERBATIM, so it reaches CI logs. Name the position, never the
80
+ * content: a parser that interpolates the record it choked on turns a diagnostic into a PHI
81
+ * surface. This is the one place the engine prints text it cannot vouch for, and it is disclosed
82
+ * rather than suppressed, because a refusal nobody can diagnose is its own defect.
83
+ */
84
+ export type DetectFn = (ctx: DetectContext) => void;
85
+
86
+ /** The three codes this repo's own exit contract assigns. All three must differ. */
87
+ export interface PhiScanExitCodes {
88
+ /** The scan ran, read every target it enumerated, and found nothing. */
89
+ clean: number;
90
+ /** This corpus contains something that looks like PHI. */
91
+ hits: number;
92
+ /** Every state the engine RAISES in which the scan cannot account for something. */
93
+ refuse: number;
94
+ }
95
+
96
+ export interface PhiScanConfig {
97
+ /**
98
+ * AXIS 1, REQUIRED. There is deliberately no default: the sibling `@cosyte/*` scanners do not
99
+ * agree on these numbers, and a caller that branches on the code (CI does) must read this repo's
100
+ * own contract rather than an inherited one.
101
+ */
102
+ exitCodes: PhiScanExitCodes;
103
+
104
+ /**
105
+ * AXIS 2, REQUIRED: the roots `all` mode walks. `["."]` means the whole repository, which is the
106
+ * only honest setting for a repo that has not yet decided what its corpus is. A ROOT IS A SCOPE
107
+ * DECISION AND IT IS THE AXIS MOST LIKELY TO BE WRONG IN A PORT: measure what a narrowing stops
108
+ * reading, rather than assuming it stops reading nothing.
109
+ *
110
+ * Each entry is normalised the way every other path is, so `src`, `./src`, `src/` and an absolute
111
+ * path to it are one root. That normalization is a fix rather than a convenience: `["./src"]`
112
+ * used to walk correctly while matching no index path, which emptied the union, the index
113
+ * non-blob refusal and the unmerged refusal in silence. A root resolving OUTSIDE the repository
114
+ * is refused for the same reason.
115
+ *
116
+ * A ROOT MAY NAME A REGULAR FILE, AND THE KIND IS DERIVED FROM THE FILESYSTEM RATHER THAN
117
+ * DECLARED. This parameter is a plain `string[]` on purpose. One sibling declares its roots as
118
+ * `{ rel, shape: "directory" | "file" }` and lists a single file among them, and that shape is
119
+ * expressible here without the richer type: such a root is scanned as one target. It is a
120
+ * measured decision rather than a preference, because an earlier draft fed every root to
121
+ * `readdirSync` and a file root threw `ENOTDIR`, uncaught, taking node's exit 1, the code this
122
+ * contract reserves for HITS FOUND.
123
+ *
124
+ * WHAT DERIVING GIVES UP: a declaration can notice that a root is not the KIND it was meant to be
125
+ * and derivation cannot, so a root that changes kind is silently treated as what it now is. A root
126
+ * that is NEITHER a file nor a directory is still refused, and a root naming a symbolic link is
127
+ * refused rather than followed. A MISSING root is skipped, which is unchanged from the copied
128
+ * scanners. It is not the last state in which a root contributes nothing without saying so, and
129
+ * two others are known: a file root the read filter drops (a `.md` file root reads nothing under
130
+ * the default `isWalkReadable`, so porting one means overriding that filter too), and an
131
+ * UNREADABLE root, which is reported the same way a missing one is.
132
+ */
133
+ scanRoots: readonly string[];
134
+
135
+ /**
136
+ * AXIS 3, REQUIRED: the READ half of scope for `--staged`. Widening it changes what a COMMIT is
137
+ * blocked on, which is a hook decision each repo takes for itself.
138
+ *
139
+ * IT MUST STAY INSIDE `scanRoots`, AND THE ENGINE ENFORCES THAT RATHER THAN ASSUMING IT. This
140
+ * doc used to say "narrower than the root half by construction", and nothing constructed it:
141
+ * these are two independent keys. A reviewer measured the gap with roots at `["src"]` and this
142
+ * filter at `exemptsMarkdown`: a STAGED symbolic link under `test/fixtures/` was outside every
143
+ * scan root, so the non-regular refusal never saw it, and the route read the link's TARGET PATH
144
+ * as if it were content and reported clean at exit 0. A staged path this admits and no scan root
145
+ * covers is now REFUSED, naming the path.
146
+ */
147
+ isStagedReadable: (relPath: string) => boolean;
148
+
149
+ /**
150
+ * AXIS 2, the subtractive half. Repo-relative paths NO route reads: not the walk, not the index
151
+ * union, not `--staged`.
152
+ *
153
+ * EXCLUDE A LITERAL PATH, NEVER A CLASS. A "skip binary blobs" predicate was measured to drop two
154
+ * of a sibling's hand-written sources, which embed NUL bytes as HMAC domain separators. A literal
155
+ * path is reviewable in a diff; a class quietly grows new members. Each entry is a file the scan
156
+ * has NO verdict about, so each one wants a comment saying why.
157
+ *
158
+ * @default new Set()
159
+ */
160
+ excludedPaths?: ReadonlySet<string>;
161
+
162
+ /**
163
+ * AXIS 2, the READ half of scope for the two SWEEPING routes. Defaults to the shared Markdown
164
+ * exemption, and the default is the point: moving that boundary is then one change here rather
165
+ * than one edit per repo.
166
+ *
167
+ * @default exemptsMarkdown
168
+ */
169
+ isWalkReadable?: (relPath: string) => boolean;
170
+
171
+ /**
172
+ * AXIS 4: git's modes for a regular blob. Every other mode names something with no bytes to read
173
+ * at that path, and is refused rather than skipped.
174
+ *
175
+ * @default new Set(["100644", "100755"])
176
+ */
177
+ regularBlobModes?: ReadonlySet<string>;
178
+
179
+ /** @default process.cwd() */
180
+ repoRoot?: string;
181
+
182
+ /** @default process.argv.slice(2) */
183
+ argv?: string[];
184
+
185
+ /** @default <repoRoot>/scripts/phi-allow-list.txt */
186
+ allowListPath?: string;
187
+
188
+ /** @default <repoRoot>/phi-scan-overrides.md */
189
+ overrideLogPath?: string;
190
+
191
+ /**
192
+ * The per-standard, field-level detection this engine deliberately does not own: names, DOB,
193
+ * MRN / member id, address, phone. Parse the wire format properly rather than bolting a blind text
194
+ * regex onto it; coded values produce false confidence.
195
+ */
196
+ detect?: DetectFn;
197
+ }
198
+
199
+ /**
200
+ * Run the PHI scan and return an exit code drawn from `config.exitCodes`. Nothing here calls
201
+ * `process.exit`, so a test can drive the engine in-process.
202
+ *
203
+ * @throws {TypeError} When a required axis is missing or malformed. That is a misconfigured scanner
204
+ * rather than a scan result, it lands on the author's first run, and it must not be reportable as
205
+ * a clean pass.
206
+ * @example
207
+ * process.exit(
208
+ * runPhiScan({
209
+ * exitCodes: { clean: 0, hits: 1, refuse: 2 },
210
+ * scanRoots: ["."],
211
+ * isStagedReadable: (p) => p.startsWith("test/fixtures/"),
212
+ * }),
213
+ * );
214
+ */
215
+ export declare function runPhiScan(config: PhiScanConfig): number;
216
+
217
+ /**
218
+ * The shared Markdown read-exemption, and the default value of `isWalkReadable`.
219
+ *
220
+ * Markdown is documentation rather than fixture data and may legitimately describe a violator value.
221
+ * The consequence, stated because it is route-dependent rather than file-dependent: a tracked `.md`
222
+ * is read by NEITHER sweeping route, while a `.md` named explicitly on argv IS scanned.
223
+ */
224
+ export declare function exemptsMarkdown(relPath: string): boolean;