@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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@cosyte/script-utils",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
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,30 @@
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"
32
+ },
33
+ "./internal-refs": {
34
+ "types": "./internal-refs.d.ts",
35
+ "default": "./internal-refs.js"
36
+ },
37
+ "./attw": {
38
+ "types": "./attw.d.ts",
39
+ "default": "./attw.js"
27
40
  }
28
41
  },
29
42
  "files": [
30
43
  "index.js",
31
44
  "index.d.ts",
45
+ "phi-scan.js",
46
+ "phi-scan.d.ts",
47
+ "internal-refs.js",
48
+ "internal-refs.d.ts",
49
+ "attw.js",
50
+ "attw.d.ts",
51
+ "check-no-emdash.sh",
32
52
  "README.md",
33
53
  "CHANGELOG.md"
34
54
  ],
@@ -37,5 +57,12 @@
37
57
  },
38
58
  "publishConfig": {
39
59
  "access": "public"
60
+ },
61
+ "devDependencies": {
62
+ "vitest": "4.1.4"
63
+ },
64
+ "scripts": {
65
+ "build": "node ../../scripts/pack-emdash-gate.mjs",
66
+ "test": "vitest run"
40
67
  }
41
68
  }
package/phi-scan.d.ts ADDED
@@ -0,0 +1,271 @@
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
+ /**
11
+ * A reported PHI finding: a POSITION and a RULE, never the token that matched. `path` is the LOCUS
12
+ * the engine chose, never a path the caller invents.
13
+ *
14
+ * 🛑 THE MATCHED VALUE IS NOT PART OF A FINDING. The report goes to stderr, stderr is a CI log, and
15
+ * a diagnostic about a PHI leak that quotes the leak is a second copy of it somewhere worse
16
+ * (`phi-safety` P4). A detector may pass `value`; the engine drops it at the boundary rather than
17
+ * storing it, so no reporting path downstream can print what no record carries.
18
+ */
19
+ export interface Hit {
20
+ /** The reported locus: the target's repo-relative path, plus an origin label when it has one. */
21
+ path: string;
22
+ /** A locator inside the target: `(ssn)`, `(email)`, or a field id from a per-standard detector. */
23
+ segment: string;
24
+ /**
25
+ * The offending value, as a detector found it. ACCEPTED AND DISCARDED: the engine neither stores
26
+ * nor prints it, and a hit the engine reports never carries one. It stays in the surface so a
27
+ * detector that has the token in hand can keep saying so at its own call site without the engine
28
+ * becoming the place that publishes it.
29
+ */
30
+ value?: string;
31
+ /** Why it was raised, in a few words. This is what the report prints beside the locator. */
32
+ reason: string;
33
+ }
34
+
35
+ /** The positive declaration that specific identifiers are synthetic. */
36
+ export interface AllowList {
37
+ /** Uppercase synthetic person-name tokens. Consumed by a caller's structured name detector. */
38
+ names: Set<string>;
39
+ /** Synthetic dates of birth, in whatever form the caller's detector normalises to. */
40
+ dobs: Set<string>;
41
+ /** Synthetic id values (SSN / MRN / member-id shapes), uppercased. Also read by the SSN floor. */
42
+ ids: Set<string>;
43
+ /** Allowed email domains, lowercased. Anything else is a hit. Read by the email floor. */
44
+ emailDomains: Set<string>;
45
+ }
46
+
47
+ /** A stage-0 index entry: the mode git records, and the object it points at. */
48
+ export interface IndexEntry {
49
+ mode: string;
50
+ oid: string;
51
+ }
52
+
53
+ /** One thing the scan will read, and where its bytes come from. */
54
+ export interface Target {
55
+ /** Repo-relative, forward-slashed. Every filter, exclusion and completeness tier keys on this. */
56
+ path: string;
57
+ read: () => Buffer;
58
+ /**
59
+ * Where these bytes came from, when it is not simply the file at `path`. Set only by the index
60
+ * union, and it decorates the REPORTED LOCUS ONLY.
61
+ *
62
+ * TWO VALUES, BECAUSE THE REMEDY DIFFERS. `git index` is a path whose working-tree copy this run
63
+ * did not read at all; `git index; the working tree differs` is one whose disk copy WAS read and
64
+ * carries other bytes, so the file a developer opens is clean and re-staging is part of the fix.
65
+ */
66
+ origin?: string;
67
+ }
68
+
69
+ /** An entry the enumeration reached and cannot scan. Both fields are safe to print. */
70
+ export interface Unscannable {
71
+ path: string;
72
+ /** A token from the engine's own closed set. Never text off the other side of a link. */
73
+ kind: string;
74
+ }
75
+
76
+ /** What a per-standard field detector is handed for one target. */
77
+ export interface DetectContext {
78
+ /**
79
+ * The reported LOCUS, already carrying any origin label. Raise hits against this and nothing else:
80
+ * a hit naming a bare path whose working-tree copy is clean sends a developer to the wrong file.
81
+ */
82
+ path: string;
83
+ /** The target's bytes decoded as UTF-8. */
84
+ text: string;
85
+ /** The target's bytes, for a byte-strict format. */
86
+ bytes: Buffer;
87
+ /** The parsed allow-list, so a detector can honour a positive synthetic declaration. */
88
+ allow: AllowList;
89
+ /** Raise a hit. The locus is filled in by the engine. */
90
+ hit: (h: Omit<Hit, "path">) => void;
91
+ }
92
+
93
+ /**
94
+ * A per-standard field detector. Throwing REFUSES the scan rather than taking node's exit code.
95
+ *
96
+ * 🛑 THE THROWN MESSAGE IS PRINTED VERBATIM, so it reaches CI logs. Name the position, never the
97
+ * content: a parser that interpolates the record it choked on turns a diagnostic into a PHI
98
+ * surface. This is the one place the engine prints text it cannot vouch for, and it is disclosed
99
+ * rather than suppressed, because a refusal nobody can diagnose is its own defect.
100
+ */
101
+ export type DetectFn = (ctx: DetectContext) => void;
102
+
103
+ /** The three codes this repo's own exit contract assigns. All three must differ. */
104
+ export interface PhiScanExitCodes {
105
+ /**
106
+ * The scan ran, read every target it enumerated, and found nothing. The ONE subtraction is the
107
+ * enumeration TOCTOU window: an UNTRACKED file the walk listed, in `all` mode, that was gone by
108
+ * read time and is still gone at the end of the run, is reported SKIPPED. Git carries no bytes
109
+ * at such a path, so nothing this repository holds went unread. WHEN THAT HAPPENS THE CLEAN LINE
110
+ * ITSELF SAYS SO on stdout, counting the skipped files, so the subtraction is visible to a reader
111
+ * who never sees stderr; with nothing skipped the line is byte-identical to what it always was.
112
+ */
113
+ clean: number;
114
+ /** This corpus contains something that looks like PHI. */
115
+ hits: number;
116
+ /** Every state the engine RAISES in which the scan cannot account for something. */
117
+ refuse: number;
118
+ }
119
+
120
+ export interface PhiScanConfig {
121
+ /**
122
+ * AXIS 1, REQUIRED. There is deliberately no default: the sibling `@cosyte/*` scanners do not
123
+ * agree on these numbers, and a caller that branches on the code (CI does) must read this repo's
124
+ * own contract rather than an inherited one.
125
+ */
126
+ exitCodes: PhiScanExitCodes;
127
+
128
+ /**
129
+ * AXIS 2, REQUIRED: the roots `all` mode walks. `["."]` means the whole repository, which is the
130
+ * only honest setting for a repo that has not yet decided what its corpus is. A ROOT IS A SCOPE
131
+ * DECISION AND IT IS THE AXIS MOST LIKELY TO BE WRONG IN A PORT: measure what a narrowing stops
132
+ * reading, rather than assuming it stops reading nothing.
133
+ *
134
+ * Each entry is normalised the way every other path is, so `src`, `./src`, `src/` and an absolute
135
+ * path to it are one root. That normalization is a fix rather than a convenience: `["./src"]`
136
+ * used to walk correctly while matching no index path, which emptied the union, the index
137
+ * non-blob refusal and the unmerged refusal in silence. A root resolving OUTSIDE the repository
138
+ * is refused for the same reason.
139
+ *
140
+ * A ROOT MAY NAME A REGULAR FILE, AND THE KIND IS DERIVED FROM THE FILESYSTEM RATHER THAN
141
+ * DECLARED. This parameter is a plain `string[]` on purpose. One sibling declares its roots as
142
+ * `{ rel, shape: "directory" | "file" }` and lists a single file among them, and that shape is
143
+ * expressible here without the richer type: such a root is scanned as one target. It is a
144
+ * measured decision rather than a preference, because an earlier draft fed every root to
145
+ * `readdirSync` and a file root threw `ENOTDIR`, uncaught, taking node's exit 1, the code this
146
+ * contract reserves for HITS FOUND.
147
+ *
148
+ * WHAT DERIVING GIVES UP: a declaration can notice that a root is not the KIND it was meant to be
149
+ * and derivation cannot, so a root that changes kind is silently treated as what it now is. That
150
+ * is a recorded narrowing rather than an oversight, with its compensating control named:
151
+ * `documentation/decisions/0003-the-phi-scan-engine-gap-settlements.md`, N2. Every TRACKED file
152
+ * that lived under such a root is still read, because the index half of `all` mode is not narrowed
153
+ * by this parameter at all.
154
+ *
155
+ * ALL THREE KINDS HAVE ONE SETTLED OUTCOME. A REGULAR FILE is scanned as one target. A SYMBOLIC
156
+ * LINK is refused rather than followed, and so is anything that is neither a file nor a directory.
157
+ * A DIRECTORY THAT CANNOT BE ENUMERATED is refused at the caller's `refuse` code, naming the path
158
+ * and the errno; it used to escape `readdirSync` uncaught and take node's own exit 1, the code
159
+ * this contract reserves for HITS FOUND. A MISSING root is skipped by the WALK, which is unchanged
160
+ * from the copied scanners, and is then caught by the rule below.
161
+ *
162
+ * 🛑 EVERY ROOT MUST YIELD AT LEAST ONE FILE THAT WAS ACTUALLY READ, OR `all` MODE REFUSES AND
163
+ * NAMES THE STARVED ROOTS. That is what closes the class the paragraph above used to end with:
164
+ * a root that contributes nothing WITHOUT SAYING SO. All three of the known members are in it - a
165
+ * missing root, an UNREADABLE root (reported the same way a missing one is), and a root whose
166
+ * every file the read filter drops, which is what a `.md` file root does under the default
167
+ * `isWalkReadable`. A root is a scope decision, and a scope decision that silently selected
168
+ * nothing is the sweep reporting on a corpus it never had.
169
+ */
170
+ scanRoots: readonly string[];
171
+
172
+ /**
173
+ * AXIS 3, REQUIRED: the READ half of scope for `--staged`. Widening it changes what a COMMIT is
174
+ * blocked on, which is a hook decision each repo takes for itself.
175
+ *
176
+ * IT MUST STAY INSIDE `scanRoots`, AND THE ENGINE ENFORCES THAT RATHER THAN ASSUMING IT. This
177
+ * doc used to say "narrower than the root half by construction", and nothing constructed it:
178
+ * these are two independent keys. A reviewer measured the gap with roots at `["src"]` and this
179
+ * filter at `exemptsMarkdown`: a STAGED symbolic link under `test/fixtures/` was outside every
180
+ * scan root, so the non-regular refusal never saw it, and the route read the link's TARGET PATH
181
+ * as if it were content and reported clean at exit 0. A staged path this admits and no scan root
182
+ * covers is now REFUSED, naming the path.
183
+ *
184
+ * 🛑 THIS PREDICATE ALSO DECIDES WHAT THE `--staged` ROUTE REFUSES, which is a change. Its
185
+ * non-regular and unmerged refusals used to key on the ROOT half of scope, so a caller whose
186
+ * staged scope is narrower than its roots had commits blocked that its own gate had always let
187
+ * through. What a commit is blocked on is this key's job. THE COST: a staged non-regular entry
188
+ * this predicate declines is no longer refused HERE. `all` mode still refuses it twice over (the
189
+ * walk classifies it under a scan root, and the index route refuses the tracked record wherever it
190
+ * sits), so a repository's own sweep still does. Recorded as N1 in
191
+ * `documentation/decisions/0003-the-phi-scan-engine-gap-settlements.md`; widen this predicate if
192
+ * you want the wider refusal.
193
+ */
194
+ isStagedReadable: (relPath: string) => boolean;
195
+
196
+ /**
197
+ * AXIS 2, the subtractive half. Repo-relative paths NO route reads: not the walk, not the index
198
+ * union, not `--staged`.
199
+ *
200
+ * EXCLUDE A LITERAL PATH, NEVER A CLASS. A "skip binary blobs" predicate was measured to drop two
201
+ * of a sibling's hand-written sources, which embed NUL bytes as HMAC domain separators. A literal
202
+ * path is reviewable in a diff; a class quietly grows new members. Each entry is a file the scan
203
+ * has NO verdict about, so each one wants a comment saying why.
204
+ *
205
+ * @default new Set()
206
+ */
207
+ excludedPaths?: ReadonlySet<string>;
208
+
209
+ /**
210
+ * AXIS 2, the READ half of scope for the two SWEEPING routes. Defaults to the shared Markdown
211
+ * exemption, and the default is the point: moving that boundary is then one change here rather
212
+ * than one edit per repo.
213
+ *
214
+ * @default exemptsMarkdown
215
+ */
216
+ isWalkReadable?: (relPath: string) => boolean;
217
+
218
+ /**
219
+ * AXIS 4: git's modes for a regular blob. Every other mode names something with no bytes to read
220
+ * at that path, and is refused rather than skipped.
221
+ *
222
+ * @default new Set(["100644", "100755"])
223
+ */
224
+ regularBlobModes?: ReadonlySet<string>;
225
+
226
+ /** @default process.cwd() */
227
+ repoRoot?: string;
228
+
229
+ /** @default process.argv.slice(2) */
230
+ argv?: string[];
231
+
232
+ /** @default <repoRoot>/scripts/phi-allow-list.txt */
233
+ allowListPath?: string;
234
+
235
+ /** @default <repoRoot>/phi-scan-overrides.md */
236
+ overrideLogPath?: string;
237
+
238
+ /**
239
+ * The per-standard, field-level detection this engine deliberately does not own: names, DOB,
240
+ * MRN / member id, address, phone. Parse the wire format properly rather than bolting a blind text
241
+ * regex onto it; coded values produce false confidence.
242
+ */
243
+ detect?: DetectFn;
244
+ }
245
+
246
+ /**
247
+ * Run the PHI scan and return an exit code drawn from `config.exitCodes`. Nothing here calls
248
+ * `process.exit`, so a test can drive the engine in-process.
249
+ *
250
+ * @throws {TypeError} When a required axis is missing or malformed. That is a misconfigured scanner
251
+ * rather than a scan result, it lands on the author's first run, and it must not be reportable as
252
+ * a clean pass.
253
+ * @example
254
+ * process.exit(
255
+ * runPhiScan({
256
+ * exitCodes: { clean: 0, hits: 1, refuse: 2 },
257
+ * scanRoots: ["."],
258
+ * isStagedReadable: (p) => p.startsWith("test/fixtures/"),
259
+ * }),
260
+ * );
261
+ */
262
+ export declare function runPhiScan(config: PhiScanConfig): number;
263
+
264
+ /**
265
+ * The shared Markdown read-exemption, and the default value of `isWalkReadable`.
266
+ *
267
+ * Markdown is documentation rather than fixture data and may legitimately describe a violator value.
268
+ * The consequence, stated because it is route-dependent rather than file-dependent: a tracked `.md`
269
+ * is read by NEITHER sweeping route, while a `.md` named explicitly on argv IS scanned.
270
+ */
271
+ export declare function exemptsMarkdown(relPath: string): boolean;