tamperward 2.7.0 → 2.8.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.
Files changed (2) hide show
  1. package/dist/cli/index.js +779 -434
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -2,9 +2,376 @@
2
2
 
3
3
  // src/git/build.ts
4
4
  import { execFileSync } from "node:child_process";
5
- import { readFileSync } from "node:fs";
5
+
6
+ // src/disk.ts
7
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readlinkSync, realpathSync } from "node:fs";
6
8
  import { join } from "node:path";
7
9
 
10
+ // src/policy.ts
11
+ import picomatch from "picomatch";
12
+ var POLICY_FILE = ".tamperward.yml";
13
+ var BLOCK_SINCE = {};
14
+ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
15
+ const out2 = { ...rules };
16
+ for (const [rule, since] of Object.entries(gates)) {
17
+ if (since > version && out2[rule]?.severity === "block") {
18
+ out2[rule] = { ...out2[rule], severity: "warn" };
19
+ }
20
+ }
21
+ return out2;
22
+ }
23
+ function normalizeGlob(glob) {
24
+ return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
25
+ }
26
+ function mergeRules(base, user) {
27
+ const out2 = { ...base };
28
+ for (const [name, cfg] of Object.entries(user ?? {})) {
29
+ out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
30
+ }
31
+ return out2;
32
+ }
33
+ var CONTROL = /[\u0000-\u001f\u007f]/;
34
+ function hasControlChar(path) {
35
+ return CONTROL.test(path);
36
+ }
37
+ function escapeControl(path) {
38
+ return path.replace(/[\u0000-\u001f\u007f]/g, (ch) => {
39
+ const c = ch.charCodeAt(0);
40
+ if (c === 10) return "\\n";
41
+ if (c === 13) return "\\r";
42
+ if (c === 9) return "\\t";
43
+ return `\\x${c.toString(16).padStart(2, "0")}`;
44
+ });
45
+ }
46
+ var listCache = /* @__PURE__ */ new WeakMap();
47
+ function listMatcher(globs4) {
48
+ let re = listCache.get(globs4);
49
+ if (re === void 0) {
50
+ re = globs4.length === 0 ? null : new RegExp(globs4.map((g) => `(?:${picomatch.makeRe(g, { dot: true }).source})`).join("|"));
51
+ listCache.set(globs4, re);
52
+ }
53
+ return re;
54
+ }
55
+ function matchesAny(path, globs4) {
56
+ if (!globs4) return false;
57
+ const re = listMatcher(globs4);
58
+ return re !== null && re.test(path);
59
+ }
60
+ function protectedCategory(path, policy) {
61
+ const cats = Object.entries(policy.protected ?? {});
62
+ if (hasControlChar(path)) return cats[0]?.[0] ?? "tests";
63
+ for (const [cat, globs4] of cats) {
64
+ if (matchesAny(path, globs4)) return cat;
65
+ }
66
+ return null;
67
+ }
68
+ var anyCache = /* @__PURE__ */ new WeakMap();
69
+ function allProtectedGlobs(policy) {
70
+ const p = policy.protected ?? {};
71
+ let all = anyCache.get(p);
72
+ if (!all) {
73
+ all = Object.values(p).flat();
74
+ anyCache.set(p, all);
75
+ }
76
+ return all;
77
+ }
78
+ function isProtected(path, policy, category) {
79
+ if (hasControlChar(path)) return true;
80
+ if (category) return matchesAny(path, policy.protected?.[category]);
81
+ return matchesAny(path, allProtectedGlobs(policy));
82
+ }
83
+ function isPolicyFile(path) {
84
+ return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
85
+ }
86
+ function isIgnored(path, policy) {
87
+ return matchesAny(path, policy.ignore);
88
+ }
89
+ function mergeProtected(base, user) {
90
+ const out2 = { ...base };
91
+ for (const [cat, globs4] of Object.entries(user ?? {})) {
92
+ const have = new Set(out2[cat] ?? []);
93
+ out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
94
+ }
95
+ return out2;
96
+ }
97
+ function defaultPolicy(version = 1) {
98
+ const p = {
99
+ version,
100
+ protected: {
101
+ // Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
102
+ // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
103
+ // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
104
+ // strengthening, not a narrowing. picomatch expands the brace.
105
+ // test-skip, test-deletion and test-content-removal all gate on this
106
+ // class, so a repo it does not match gets rules that can NEVER fire —
107
+ // protection that is silently absent rather than visibly off. The JS/TS
108
+ // list shipped alone; these are the conventional layouts of the other
109
+ // ecosystems `init` is run in. (P2-13, external review.)
110
+ tests: [
111
+ // JavaScript / TypeScript
112
+ "**/*.test.ts",
113
+ "**/*.spec.ts",
114
+ "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
115
+ "**/__tests__/**",
116
+ // Python (pytest / unittest layouts)
117
+ "**/test_*.py",
118
+ "**/*_test.py",
119
+ "**/conftest.py",
120
+ // Go
121
+ "**/*_test.go",
122
+ // Rust (integration tests live in tests/, unit tests inline)
123
+ "**/tests/**/*.rs",
124
+ // Ruby (RSpec / minitest)
125
+ "**/*_spec.rb",
126
+ "**/*_test.rb",
127
+ // Java / Kotlin / Scala (Maven & Gradle convention)
128
+ "**/src/test/**",
129
+ // PHP (PHPUnit)
130
+ "**/*Test.php",
131
+ // C# (.NET)
132
+ "**/*Tests.cs",
133
+ "**/*Test.cs"
134
+ ],
135
+ config: [
136
+ "**/jest.config.*",
137
+ "**/vitest.config.*",
138
+ // A `vitest.workspace.*` file REPLACES the root config's selection with the
139
+ // union of its projects, and a `vite.config.*` carrying a `test:` key is the
140
+ // vitest config when no vitest.config exists — both are where a spec can be
141
+ // dropped from the suite without a line of it changing. ADDITIVE, like every
142
+ // baseline widening (the policy-diff reads it as a strengthening).
143
+ "**/vitest.workspace.*",
144
+ "**/vite.config.*",
145
+ "**/tsconfig*.json",
146
+ "**/.eslintrc*",
147
+ "**/eslint.config.*",
148
+ "**/package.json",
149
+ // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
150
+ // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
151
+ // codecov `target` lived in files no glob protected, so lowering them fired
152
+ // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
153
+ // as a strengthening). pyproject.toml carries [tool.coverage.report].
154
+ "**/.coveragerc",
155
+ "**/pyproject.toml",
156
+ "**/.nycrc",
157
+ "**/.nycrc.*",
158
+ "**/nyc.config.*",
159
+ "**/codecov.yml",
160
+ "**/.codecov.yml"
161
+ ],
162
+ ci: [".github/workflows/**"],
163
+ // Recorded expected outputs. An assertion stored as data is still an assertion;
164
+ // rewriting it from current output is the snapshot-update move the affordance
165
+ // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
166
+ // tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
167
+ // live there as .cjs and were rewritten unseen (PHASE3.md §4).
168
+ snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
169
+ // The wiring IS the enforcement: settings.json carries the PreToolUse and
170
+ // Stop hooks, and a pre-commit config carries the commit-time gate. These
171
+ // were installed by `init` and protected by nothing, so editing them out
172
+ // fired no rule at all. (.git/hooks/** is outside every git view and so
173
+ // cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
174
+ hooks: [
175
+ ".husky/**",
176
+ "**/lefthook.*",
177
+ // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
178
+ // there switches the gate off without touching the file init pointed at.
179
+ "**/lefthook-local.*",
180
+ ".tamperward.yml",
181
+ "**/.tamperward.yml",
182
+ ".claude/settings.json",
183
+ ".claude/settings.local.json",
184
+ "**/.pre-commit-config.yaml",
185
+ // CODEOWNERS is enforcement wiring, not documentation: it is the only
186
+ // thing that can require a human on a change to the workflow that
187
+ // decides whether the gate runs at all. GitHub reads it from any of
188
+ // these three locations. (H2.)
189
+ ".github/CODEOWNERS",
190
+ "CODEOWNERS",
191
+ "docs/CODEOWNERS"
192
+ ]
193
+ },
194
+ rules: {
195
+ "test-deletion": { severity: "block" },
196
+ // content gutted from a surviving spec — the class Phase 3 measured walking
197
+ // through the block-count rule (rows, assertion arguments, setup). Severity
198
+ // backed by the real-commit corpus measurement recorded in the rule's SPEC row.
199
+ "test-content-removal": { severity: "block" },
200
+ "test-skip": { severity: "block" },
201
+ "ts-any-cast": { severity: "block" },
202
+ // the unambiguous explicit casts + ts-suppression directives
203
+ // broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
204
+ // block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
205
+ "ts-any-launder": { severity: "warn" },
206
+ "lint-suppression": { severity: "block" },
207
+ "coverage-lowering": { severity: "block" },
208
+ "ci-tampering": { severity: "block" },
209
+ "hook-tampering": { severity: "block" },
210
+ "no-verify": { severity: "block" },
211
+ // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
212
+ // when intended output changes. WARN until the §7 evidence path earns block.
213
+ "snapshot-rewrite": { severity: "warn" },
214
+ // the watcher's transient class (modified-and-restored within a turn). WARN by
215
+ // default: a Stop block already forces restores, and a compliant restore reads
216
+ // as churn — block-by-default would deadlock the honest path. Harnesses that own
217
+ // that semantics raise it via TAMPERWARD_TRANSIENT=block.
218
+ "transient-protected-mutation": { severity: "warn" },
219
+ // the narrow graduation candidate: snapshot moved with NO accompanying change.
220
+ // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
221
+ // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
222
+ "snapshot-only-rewrite": { severity: "warn" },
223
+ // heuristic — warn until precision is measured (SPEC §7)
224
+ "assertion-weakening": { severity: "warn" },
225
+ "guard-removal": { severity: "warn" }
226
+ },
227
+ ignore: [],
228
+ signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
229
+ };
230
+ p.rules = applyVersionGates(p.rules, version);
231
+ return p;
232
+ }
233
+
234
+ // src/disk.ts
235
+ var READ_CAP = 64 * 1024 * 1024;
236
+ var NO_META = { mode: 0, size: 0, mtimeMs: 0 };
237
+ function errText(e) {
238
+ return e instanceof Error ? e.message : String(e);
239
+ }
240
+ function errCode(e) {
241
+ return String(e?.code ?? "");
242
+ }
243
+ function irregularName(st) {
244
+ if (st.isFIFO()) return "fifo";
245
+ if (st.isSocket()) return "socket";
246
+ if (st.isCharacterDevice()) return "character device";
247
+ if (st.isBlockDevice()) return "block device";
248
+ return "unknown file type";
249
+ }
250
+ function meta(st) {
251
+ return { mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
252
+ }
253
+ var O_READ = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
254
+ function readRegular(abs2, st) {
255
+ let fd;
256
+ try {
257
+ fd = openSync(abs2, O_READ);
258
+ } catch (e) {
259
+ if (errCode(e) === "ELOOP") return inspectOnce(abs2);
260
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
261
+ }
262
+ try {
263
+ const now = fstatSync(fd);
264
+ if (!now.isFile()) return { kind: "irregular", content: null, ...meta(now), detail: irregularName(now) };
265
+ if (now.size > READ_CAP) return { kind: "oversize", content: null, ...meta(now), detail: `${now.size} bytes` };
266
+ const buf = Buffer.allocUnsafe(now.size);
267
+ let off2 = 0;
268
+ while (off2 < now.size) {
269
+ const n = readSync(fd, buf, off2, now.size - off2, off2);
270
+ if (n === 0) break;
271
+ off2 += n;
272
+ }
273
+ return { kind: "file", content: off2 === now.size ? buf : buf.subarray(0, off2), mode: now.mode, size: off2, mtimeMs: now.mtimeMs, detail: "" };
274
+ } catch (e) {
275
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
276
+ } finally {
277
+ closeSync(fd);
278
+ }
279
+ }
280
+ function classify(abs2, st) {
281
+ if (st.isSymbolicLink()) {
282
+ try {
283
+ const target = readlinkSync(abs2);
284
+ return { kind: "symlink", content: Buffer.from(target, "utf8"), ...meta(st), detail: target };
285
+ } catch (e) {
286
+ return { kind: "unreadable", content: null, ...meta(st), detail: errText(e) };
287
+ }
288
+ }
289
+ if (st.isDirectory()) return { kind: "directory", content: null, ...meta(st), detail: "directory" };
290
+ if (!st.isFile()) return { kind: "irregular", content: null, ...meta(st), detail: irregularName(st) };
291
+ if (st.size > READ_CAP) return { kind: "oversize", content: null, ...meta(st), detail: `${st.size} bytes` };
292
+ return readRegular(abs2, st);
293
+ }
294
+ function inspectOnce(abs2) {
295
+ let st;
296
+ try {
297
+ st = lstatSync(abs2);
298
+ } catch (e) {
299
+ const code = errCode(e);
300
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
301
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
302
+ }
303
+ if (st.isSymbolicLink() || !st.isFile()) return classify(abs2, st);
304
+ return { kind: "unreadable", content: null, ...meta(st), detail: "the path changed shape while it was being read" };
305
+ }
306
+ function inspectPath(abs2) {
307
+ let st;
308
+ try {
309
+ st = lstatSync(abs2);
310
+ } catch (e) {
311
+ const code = errCode(e);
312
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
313
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
314
+ }
315
+ return classify(abs2, st);
316
+ }
317
+ function inspectRel(cwd, rel) {
318
+ return inspectPath(join(cwd, rel));
319
+ }
320
+ function inspectResolved(abs2) {
321
+ let real;
322
+ try {
323
+ real = realpathSync(abs2);
324
+ } catch (e) {
325
+ const code = errCode(e);
326
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent", content: null, ...NO_META, detail: "" };
327
+ return { kind: "unreadable", content: null, ...NO_META, detail: errText(e) };
328
+ }
329
+ return inspectPath(real);
330
+ }
331
+ function textOf(e) {
332
+ return e.content == null ? null : e.content.toString("utf8");
333
+ }
334
+ function describe(e, shown) {
335
+ switch (e.kind) {
336
+ case "symlink":
337
+ return `Protected path ${shown} is a symbolic link to ${escapeControl(e.detail)}. The gate never follows a link, so what the runner will read there cannot be judged.`;
338
+ case "directory":
339
+ return `Protected path ${shown} is a directory where a file is expected, so it cannot be judged.`;
340
+ case "irregular":
341
+ return `Protected path ${shown} is a ${e.detail}, not a regular file, so it cannot be read to judge it.`;
342
+ case "oversize":
343
+ return `Protected file ${shown} is ${e.detail}, above the ${READ_CAP / (1024 * 1024)} MiB the gate reads, so it is judged by name.`;
344
+ default:
345
+ return `Protected file ${shown} cannot be read to judge it (${e.detail}).`;
346
+ }
347
+ }
348
+ function unjudgeableFinding(cwd, rel) {
349
+ const e = inspectRel(cwd, rel);
350
+ if (e.kind === "file" || e.kind === "absent") return null;
351
+ const shown = escapeControl(rel);
352
+ return {
353
+ rule: "hidden-drift",
354
+ severity: "block",
355
+ file: rel,
356
+ message: describe(e, shown),
357
+ evidence: `${shown}: ${e.kind}${e.detail ? ` (${escapeControl(e.detail)})` : ""}`,
358
+ remediation: "Put a regular file the gate can read at the path \u2014 no symbolic link, FIFO, socket or device, and under 64 MiB \u2014 through a tool call the gate can see, or remove it.",
359
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${shown} --reason "..."` }
360
+ };
361
+ }
362
+ function unjudgeableProtected(cwd, policy, changes) {
363
+ const out2 = [];
364
+ const seen = /* @__PURE__ */ new Set();
365
+ for (const c of changes) {
366
+ if (c.kind !== "file" || c.op === "delete" || seen.has(c.path)) continue;
367
+ seen.add(c.path);
368
+ if (!isProtected(c.path, policy)) continue;
369
+ const f = unjudgeableFinding(cwd, c.path);
370
+ if (f) out2.push(f);
371
+ }
372
+ return out2;
373
+ }
374
+
8
375
  // src/diff/parse.ts
9
376
  var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
10
377
  var C_ESCAPES = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, '"': 34 };
@@ -251,11 +618,7 @@ function blobAt(rev, path, cwd) {
251
618
  }
252
619
  }
253
620
  function fromDisk(path, cwd) {
254
- try {
255
- return readFileSync(join(cwd ?? process.cwd(), path), "utf8");
256
- } catch {
257
- return null;
258
- }
621
+ return textOf(inspectRel(cwd ?? process.cwd(), path));
259
622
  }
260
623
  function enrich(c, beforeReader, afterReader) {
261
624
  if (c.kind !== "file") return c;
@@ -353,6 +716,9 @@ function underSkippedDir(rel) {
353
716
  return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
354
717
  }
355
718
  function ignoredPaths(opts = {}, keep) {
719
+ return ignoredTree(opts, keep).files;
720
+ }
721
+ function ignoredTree(opts = {}, keep) {
356
722
  const files = [];
357
723
  const dirs = [];
358
724
  for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
@@ -364,7 +730,7 @@ function ignoredPaths(opts = {}, keep) {
364
730
  const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
365
731
  for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
366
732
  }
367
- return files.filter(keep);
733
+ return { dirs, files: files.filter(keep) };
368
734
  }
369
735
  function ignoredAdds(opts = {}, keep, exclude) {
370
736
  return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
@@ -576,212 +942,16 @@ var noVerify = {
576
942
  };
577
943
 
578
944
  // src/detectors/ts-any-cast.ts
579
- import ts from "typescript";
580
-
581
- // src/diff/select.ts
582
- function addedLines(c) {
583
- if (c.kind !== "file") return [];
584
- return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
585
- }
586
- function removedLines(c) {
587
- if (c.kind !== "file") return [];
588
- return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
589
- }
590
-
591
- // src/policy.ts
592
- import picomatch from "picomatch";
593
- var POLICY_FILE = ".tamperward.yml";
594
- var BLOCK_SINCE = {};
595
- function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
596
- const out2 = { ...rules };
597
- for (const [rule, since] of Object.entries(gates)) {
598
- if (since > version && out2[rule]?.severity === "block") {
599
- out2[rule] = { ...out2[rule], severity: "warn" };
600
- }
601
- }
602
- return out2;
603
- }
604
- function normalizeGlob(glob) {
605
- return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
606
- }
607
- function mergeRules(base, user) {
608
- const out2 = { ...base };
609
- for (const [name, cfg] of Object.entries(user ?? {})) {
610
- out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
611
- }
612
- return out2;
613
- }
614
- var cache = /* @__PURE__ */ new Map();
615
- function matcher(glob) {
616
- let m = cache.get(glob);
617
- if (!m) {
618
- m = picomatch(glob, { dot: true });
619
- cache.set(glob, m);
620
- }
621
- return m;
622
- }
623
- function matchesAny(path, globs4) {
624
- return !!globs4 && globs4.some((g) => matcher(g)(path));
625
- }
626
- function protectedCategory(path, policy) {
627
- for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
628
- if (matchesAny(path, globs4)) return cat;
629
- }
630
- return null;
631
- }
632
- function isProtected(path, policy, category) {
633
- if (category) return matchesAny(path, policy.protected?.[category]);
634
- return protectedCategory(path, policy) !== null;
635
- }
636
- function isPolicyFile(path) {
637
- return path === POLICY_FILE || path.endsWith("/" + POLICY_FILE);
638
- }
639
- function isIgnored(path, policy) {
640
- return matchesAny(path, policy.ignore);
641
- }
642
- function mergeProtected(base, user) {
643
- const out2 = { ...base };
644
- for (const [cat, globs4] of Object.entries(user ?? {})) {
645
- const have = new Set(out2[cat] ?? []);
646
- out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
647
- }
648
- return out2;
945
+ import ts from "typescript";
946
+
947
+ // src/diff/select.ts
948
+ function addedLines(c) {
949
+ if (c.kind !== "file") return [];
950
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "add"));
649
951
  }
650
- function defaultPolicy(version = 1) {
651
- const p = {
652
- version,
653
- protected: {
654
- // Cover every JS/TS test extension, not just .test.ts — the multi-repo FP study found
655
- // .test.tsx/.spec.tsx (hono, zustand) slipped the glob, so legit test-file casts blocked
656
- // instead of warning. Kept ADDITIVE (originals + new extensions) so the policy-diff sees a
657
- // strengthening, not a narrowing. picomatch expands the brace.
658
- // test-skip, test-deletion and test-content-removal all gate on this
659
- // class, so a repo it does not match gets rules that can NEVER fire —
660
- // protection that is silently absent rather than visibly off. The JS/TS
661
- // list shipped alone; these are the conventional layouts of the other
662
- // ecosystems `init` is run in. (P2-13, external review.)
663
- tests: [
664
- // JavaScript / TypeScript
665
- "**/*.test.ts",
666
- "**/*.spec.ts",
667
- "**/*.{test,spec}.{tsx,cts,mts,js,jsx,cjs,mjs}",
668
- "**/__tests__/**",
669
- // Python (pytest / unittest layouts)
670
- "**/test_*.py",
671
- "**/*_test.py",
672
- "**/conftest.py",
673
- // Go
674
- "**/*_test.go",
675
- // Rust (integration tests live in tests/, unit tests inline)
676
- "**/tests/**/*.rs",
677
- // Ruby (RSpec / minitest)
678
- "**/*_spec.rb",
679
- "**/*_test.rb",
680
- // Java / Kotlin / Scala (Maven & Gradle convention)
681
- "**/src/test/**",
682
- // PHP (PHPUnit)
683
- "**/*Test.php",
684
- // C# (.NET)
685
- "**/*Tests.cs",
686
- "**/*Test.cs"
687
- ],
688
- config: [
689
- "**/jest.config.*",
690
- "**/vitest.config.*",
691
- // A `vitest.workspace.*` file REPLACES the root config's selection with the
692
- // union of its projects, and a `vite.config.*` carrying a `test:` key is the
693
- // vitest config when no vitest.config exists — both are where a spec can be
694
- // dropped from the suite without a line of it changing. ADDITIVE, like every
695
- // baseline widening (the policy-diff reads it as a strengthening).
696
- "**/vitest.workspace.*",
697
- "**/vite.config.*",
698
- "**/tsconfig*.json",
699
- "**/.eslintrc*",
700
- "**/eslint.config.*",
701
- "**/package.json",
702
- // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
703
- // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
704
- // codecov `target` lived in files no glob protected, so lowering them fired
705
- // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
706
- // as a strengthening). pyproject.toml carries [tool.coverage.report].
707
- "**/.coveragerc",
708
- "**/pyproject.toml",
709
- "**/.nycrc",
710
- "**/.nycrc.*",
711
- "**/nyc.config.*",
712
- "**/codecov.yml",
713
- "**/.codecov.yml"
714
- ],
715
- ci: [".github/workflows/**"],
716
- // Recorded expected outputs. An assertion stored as data is still an assertion;
717
- // rewriting it from current output is the snapshot-update move the affordance
718
- // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
719
- // tap-snapshots/** added from Phase-3 evidence: node-tar's recorded expectations
720
- // live there as .cjs and were rewritten unseen (PHASE3.md §4).
721
- snapshots: ["**/*.snap", "**/__snapshots__/**", "**/tap-snapshots/**", "**/golden/**", "**/*.golden.*"],
722
- // The wiring IS the enforcement: settings.json carries the PreToolUse and
723
- // Stop hooks, and a pre-commit config carries the commit-time gate. These
724
- // were installed by `init` and protected by nothing, so editing them out
725
- // fired no rule at all. (.git/hooks/** is outside every git view and so
726
- // cannot be covered here — noted in SECURITY-ENVELOPE.md.) (P1-4, review.)
727
- hooks: [
728
- ".husky/**",
729
- "**/lefthook.*",
730
- // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
731
- // there switches the gate off without touching the file init pointed at.
732
- "**/lefthook-local.*",
733
- ".tamperward.yml",
734
- "**/.tamperward.yml",
735
- ".claude/settings.json",
736
- ".claude/settings.local.json",
737
- "**/.pre-commit-config.yaml",
738
- // CODEOWNERS is enforcement wiring, not documentation: it is the only
739
- // thing that can require a human on a change to the workflow that
740
- // decides whether the gate runs at all. GitHub reads it from any of
741
- // these three locations. (H2.)
742
- ".github/CODEOWNERS",
743
- "CODEOWNERS",
744
- "docs/CODEOWNERS"
745
- ]
746
- },
747
- rules: {
748
- "test-deletion": { severity: "block" },
749
- // content gutted from a surviving spec — the class Phase 3 measured walking
750
- // through the block-count rule (rows, assertion arguments, setup). Severity
751
- // backed by the real-commit corpus measurement recorded in the rule's SPEC row.
752
- "test-content-removal": { severity: "block" },
753
- "test-skip": { severity: "block" },
754
- "ts-any-cast": { severity: "block" },
755
- // the unambiguous explicit casts + ts-suppression directives
756
- // broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
757
- // block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
758
- "ts-any-launder": { severity: "warn" },
759
- "lint-suppression": { severity: "block" },
760
- "coverage-lowering": { severity: "block" },
761
- "ci-tampering": { severity: "block" },
762
- "hook-tampering": { severity: "block" },
763
- "no-verify": { severity: "block" },
764
- // mechanical but intent-ambiguous: updating a snapshot is the legitimate workflow
765
- // when intended output changes. WARN until the §7 evidence path earns block.
766
- "snapshot-rewrite": { severity: "warn" },
767
- // the watcher's transient class (modified-and-restored within a turn). WARN by
768
- // default: a Stop block already forces restores, and a compliant restore reads
769
- // as churn — block-by-default would deadlock the honest path. Harnesses that own
770
- // that semantics raise it via TAMPERWARD_TRANSIENT=block.
771
- "transient-protected-mutation": { severity: "warn" },
772
- // the narrow graduation candidate: snapshot moved with NO accompanying change.
773
- // ~0.06% FP on 1,652 audited mainline commits, 7/7 TP on observed tampers
774
- // (harness/fp-study/). warn until a version: gate graduates it (BLOCK_SINCE).
775
- "snapshot-only-rewrite": { severity: "warn" },
776
- // heuristic — warn until precision is measured (SPEC §7)
777
- "assertion-weakening": { severity: "warn" },
778
- "guard-removal": { severity: "warn" }
779
- },
780
- ignore: [],
781
- signoff: { requiredFor: ["block"], ledger: ".tamperward/ledger.jsonl" }
782
- };
783
- p.rules = applyVersionGates(p.rules, version);
784
- return p;
952
+ function removedLines(c) {
953
+ if (c.kind !== "file") return [];
954
+ return c.hunks.flatMap((h) => h.lines.filter((l) => l.type === "del"));
785
955
  }
786
956
 
787
957
  // src/detectors/files.ts
@@ -1164,12 +1334,12 @@ import ts2 from "typescript";
1164
1334
 
1165
1335
  // src/detectors/repo.ts
1166
1336
  import { execFileSync as execFileSync2 } from "node:child_process";
1167
- var cache2 = /* @__PURE__ */ new Map();
1337
+ var cache = /* @__PURE__ */ new Map();
1168
1338
  function trackedFiles(ctx) {
1169
1339
  if (!ctx) return null;
1170
1340
  if (ctx.trackedFiles) return ctx.trackedFiles;
1171
1341
  if (!ctx.cwd) return null;
1172
- const hit = cache2.get(ctx.cwd);
1342
+ const hit = cache.get(ctx.cwd);
1173
1343
  if (hit !== void 0) return hit;
1174
1344
  let files = null;
1175
1345
  try {
@@ -1182,10 +1352,54 @@ function trackedFiles(ctx) {
1182
1352
  } catch {
1183
1353
  files = null;
1184
1354
  }
1185
- cache2.set(ctx.cwd, files);
1355
+ cache.set(ctx.cwd, files);
1186
1356
  return files;
1187
1357
  }
1188
1358
  var branchCache = /* @__PURE__ */ new Map();
1359
+ var refsCache = /* @__PURE__ */ new Map();
1360
+ function branchRefs(ctx) {
1361
+ if (!ctx?.cwd) return null;
1362
+ const hit = refsCache.get(ctx.cwd);
1363
+ if (hit !== void 0) return hit;
1364
+ let refs = null;
1365
+ try {
1366
+ const out2 = execFileSync2("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
1367
+ cwd: ctx.cwd,
1368
+ encoding: "utf8",
1369
+ stdio: ["ignore", "pipe", "pipe"]
1370
+ });
1371
+ refs = new Set(out2.split("\n").map((l) => l.trim()).filter(Boolean));
1372
+ } catch {
1373
+ refs = null;
1374
+ }
1375
+ refsCache.set(ctx.cwd, refs);
1376
+ return refs;
1377
+ }
1378
+ function branchExists(name, ctx) {
1379
+ const refs = branchRefs(ctx);
1380
+ if (!refs) return null;
1381
+ if (refs.has(name)) return true;
1382
+ for (const r of refs) if (r.endsWith("/" + name)) return true;
1383
+ return false;
1384
+ }
1385
+ function revIsHead(rev, ctx) {
1386
+ if (!ctx?.cwd || rev.startsWith("-")) return null;
1387
+ const parse3 = (r) => {
1388
+ try {
1389
+ return execFileSync2("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1390
+ cwd: ctx.cwd,
1391
+ encoding: "utf8",
1392
+ stdio: ["ignore", "pipe", "pipe"]
1393
+ }).trim();
1394
+ } catch {
1395
+ return null;
1396
+ }
1397
+ };
1398
+ const head = parse3("HEAD");
1399
+ const target = parse3(rev);
1400
+ if (!head || !target) return null;
1401
+ return head === target;
1402
+ }
1189
1403
  function defaultBranch(ctx) {
1190
1404
  if (!ctx?.cwd) return null;
1191
1405
  const hit = branchCache.get(ctx.cwd);
@@ -1205,15 +1419,16 @@ function defaultBranch(ctx) {
1205
1419
  return name;
1206
1420
  }
1207
1421
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1208
- function containsProtected(token, policy, category, ctx) {
1422
+ function containsProtected(token, policy, category, ctx, except) {
1209
1423
  const dir = dirOf(token);
1210
1424
  if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1425
+ const counts = (p) => isProtected(p, policy, category) && !(except && isProtected(p, policy, except));
1211
1426
  const files = trackedFiles(ctx);
1212
1427
  if (files) {
1213
1428
  const prefix = dir + "/";
1214
- return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1429
+ return files.some((f) => f.startsWith(prefix) && counts(f));
1215
1430
  }
1216
- if (isProtected(dir + "/probe", policy, category)) return true;
1431
+ if (counts(dir + "/probe")) return true;
1217
1432
  const base = dir.split("/").pop() ?? "";
1218
1433
  return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1219
1434
  }
@@ -1316,6 +1531,10 @@ function parseThresholds(src) {
1316
1531
  if (!ts2.isPropertyAssignment(p)) continue;
1317
1532
  const k = keyName(p.name);
1318
1533
  if (k === null || isMetric(k)) continue;
1534
+ if (key2 === "thresholds" && k === "100" && p.initializer.kind === ts2.SyntaxKind.TrueKeyword) {
1535
+ res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
1536
+ continue;
1537
+ }
1319
1538
  const m = metricsOf(p.initializer, resolve5);
1320
1539
  if (!m) continue;
1321
1540
  if (k === "global") res.global = merge(res.global, m);
@@ -1409,7 +1628,7 @@ function sectionText(src, key2) {
1409
1628
  return "";
1410
1629
  }
1411
1630
  }
1412
- var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1631
+ var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings|generated|__generated__|codegen|migrations?)(?:\/|$|\*)|\.(?:test|spec|stories|d|gen|generated|pb|e2e-spec)\.|\.(?:config|setup)\.|(?:^|[/!*])(?:manage|wsgi|asgi|__init__)\.py$|(?:^|[/!*])main\.[jt]sx?$|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1413
1632
  function compareMetrics(scope, before, after, out2) {
1414
1633
  if (!before) return;
1415
1634
  for (const m of METRICS) {
@@ -1553,20 +1772,28 @@ var SIMPLE_FILES = [
1553
1772
  ];
1554
1773
  var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
1555
1774
  var numOf = (s) => s === "auto" ? "auto" : Number(s);
1556
- function numbersOf(src, key2, scoped = false) {
1557
- const out2 = [];
1775
+ function* scopedLines(src) {
1558
1776
  const stack = [];
1559
1777
  for (const line of src.split("\n")) {
1560
- let scope = "";
1561
- if (scoped) {
1562
- const m = line.match(/^(\s*)([\w-]+):/);
1563
- if (m) {
1564
- const ind = m[1].length;
1565
- while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1566
- stack.push({ ind, key: m[2] });
1567
- }
1568
- scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1778
+ const m = line.match(/^(\s*)([\w-]+):/);
1779
+ if (m) {
1780
+ const ind = m[1].length;
1781
+ while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1782
+ stack.push({ ind, key: m[2] });
1569
1783
  }
1784
+ const scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1785
+ yield [line, scope];
1786
+ }
1787
+ }
1788
+ function scopedKeys(src, key2) {
1789
+ const out2 = [];
1790
+ for (const [line, scope] of scopedLines(src)) if (key2.test(line)) out2.push(scope);
1791
+ return out2;
1792
+ }
1793
+ function numbersOf(src, key2, scoped = false) {
1794
+ const out2 = [];
1795
+ for (const [line, inScope] of scopedLines(src)) {
1796
+ const scope = scoped ? inScope : "";
1570
1797
  if (key2.global) {
1571
1798
  for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
1572
1799
  } else {
@@ -1645,6 +1872,9 @@ function simpleNarrowings(path, before, after) {
1645
1872
  const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1646
1873
  const had = off2(before);
1647
1874
  for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1875
+ const informational = (s) => new Set(scopedKeys(s, /^\s*informational:\s*true\b/).filter((scope) => scope !== "patch" && scope !== "changes"));
1876
+ const was = informational(before);
1877
+ for (const scope of informational(after)) if (!was.has(scope)) out2.push(`codecov ${scope || "project"} status informational: true added \u2014 the status can no longer fail`);
1648
1878
  }
1649
1879
  return out2;
1650
1880
  }
@@ -1688,13 +1918,14 @@ var SWITCHES = [
1688
1918
  re: /"?check-coverage"?\s*[:=]\s*false\b/,
1689
1919
  message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1690
1920
  remediation: "Keep check-coverage on; raise real coverage instead."
1691
- },
1692
- {
1693
- re: /^\s*informational:\s*true\b/,
1694
- message: "informational: true added \u2014 the codecov status can no longer fail.",
1695
- remediation: "Remove informational; a status that cannot fail is not a gate."
1696
1921
  }
1697
1922
  ];
1923
+ function dirHasSpec(configPath, policy, ctx) {
1924
+ const files = trackedFiles(ctx);
1925
+ if (!files) return null;
1926
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1927
+ return files.some((f) => f.startsWith(root) && isProtected(f, policy, "tests"));
1928
+ }
1698
1929
  var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1699
1930
  function gateMovedElsewhere(c, changes, policy) {
1700
1931
  const before = c.before ?? "";
@@ -1787,6 +2018,7 @@ var coverageLowering = {
1787
2018
  for (const s of SWITCHES) {
1788
2019
  if (!s.re.test(l.content)) continue;
1789
2020
  if (removed.some((r) => s.re.test(r.content))) continue;
2021
+ if (/passWithNoTests/.test(s.re.source) && dirHasSpec(c.path, policy, ctx) === false) continue;
1790
2022
  out2.push(
1791
2023
  makeFinding(RULE4, policy, {
1792
2024
  file: c.path,
@@ -1838,7 +2070,7 @@ var RULE5 = "ci-tampering";
1838
2070
  var USES = /^\s*-\s*uses:/;
1839
2071
  var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
1840
2072
  var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
1841
- var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward)\b/;
2073
+ var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward|mocha|ava|oxlint|tsgo|mypy|golangci-lint|node\s+--test|biome\s+(?:ci|check|lint)|deno\s+(?:test|lint|check)|ruff\s+check)\b/;
1842
2074
  var INVOKES_SCRIPT = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|tests|lint|typecheck|type-check|coverage)\b|\b(?:make|cargo|go)\s+test\b|\bgradle\w*\s+(?:test|check)\b/;
1843
2075
  function isActiveWorkflow(path) {
1844
2076
  return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
@@ -1847,8 +2079,19 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1847
2079
  function commandCore(line) {
1848
2080
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1849
2081
  }
2082
+ function foldExpressions(s) {
2083
+ return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
2084
+ const v = foldConst(e.trim());
2085
+ if (v !== void 0 && v !== TRUTHY) return String(v);
2086
+ return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
2087
+ });
2088
+ }
2089
+ var EXPR_TOKEN = "__expr__";
2090
+ var MATRIX_TOKEN = "__matrix__";
2091
+ var MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
1850
2092
  function canonical(core) {
1851
- let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
2093
+ let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
2094
+ s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
1852
2095
  s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1853
2096
  s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1854
2097
  s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
@@ -1878,8 +2121,8 @@ function checkKind(core) {
1878
2121
  if (!invokesCheck(core)) return null;
1879
2122
  const s = canonical(core);
1880
2123
  if (/\btamperward\b/.test(s)) return "gate";
1881
- if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
1882
- if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
2124
+ if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
2125
+ if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
1883
2126
  return "test";
1884
2127
  }
1885
2128
  function survives(removedCore, isUses, afterCores, addedCores) {
@@ -1888,13 +2131,15 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1888
2131
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1889
2132
  }
1890
2133
  const r = canonical(removedCore);
2134
+ const kind = checkKind(removedCore);
2135
+ const narrowed = (args) => kind === "test" && narrowedByPositional(args);
1891
2136
  let neutralised;
1892
2137
  for (const raw of afterCores) {
1893
2138
  const a = canonical(raw);
1894
2139
  if (a === r) return { state: "kept" };
1895
2140
  if (a.startsWith(r + " ")) {
1896
2141
  const rest = a.slice(r.length);
1897
- if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
2142
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest)) neutralised ??= raw;
1898
2143
  else return { state: "kept" };
1899
2144
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1900
2145
  return { state: "kept" };
@@ -1903,7 +2148,6 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1903
2148
  if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
1904
2149
  }
1905
2150
  }
1906
- const kind = checkKind(removedCore);
1907
2151
  let respelled = false;
1908
2152
  for (const raw of addedCores) {
1909
2153
  if (!kind || checkKind(raw) !== kind) continue;
@@ -1913,7 +2157,7 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1913
2157
  continue;
1914
2158
  }
1915
2159
  const args = a.slice(a.indexOf(" ") + 1 || a.length);
1916
- if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
2160
+ if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowed(args))) neutralised ??= raw;
1917
2161
  else respelled = true;
1918
2162
  }
1919
2163
  if (respelled) return { state: "kept" };
@@ -2085,6 +2329,7 @@ function isTruthy(raw) {
2085
2329
  const f = foldConst(m[1]);
2086
2330
  return f !== void 0 && truthy(f);
2087
2331
  }
2332
+ var SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
2088
2333
  var indentOf = (l) => l.match(/^\s*/)[0].length;
2089
2334
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
2090
2335
  function stepOf(lines, i) {
@@ -2110,8 +2355,10 @@ function jobOf(lines, i) {
2110
2355
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
2111
2356
  return [h, e];
2112
2357
  }
2113
- var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
2114
- var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l) || /^\s*uses:\s*\S/.test(l));
2358
+ var REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
2359
+ var usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
2360
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l));
2361
+ var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
2115
2362
  function runBlocks(lines) {
2116
2363
  const out2 = [];
2117
2364
  for (let i = 0; i < lines.length; i++) {
@@ -2256,7 +2503,8 @@ function triggerNarrowings(before, after, opts) {
2256
2503
  const now = after.branches.get(e);
2257
2504
  if (!now || !after.events.has(e)) continue;
2258
2505
  if (now.some(ALL)) continue;
2259
- const lostDefault = namesDefault(was) && !namesDefault(now);
2506
+ const renamed = (b) => DEFAULT_BRANCH.test(b) && opts.hasBranch(b) !== false;
2507
+ const lostDefault = namesDefault(was) && !namesDefault(now) && !now.some(renamed);
2260
2508
  const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
2261
2509
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
2262
2510
  }
@@ -2342,6 +2590,7 @@ var ciTampering = {
2342
2590
  }
2343
2591
  for (const l of addedLines(c)) {
2344
2592
  if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
2593
+ if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
2345
2594
  const step = stepOf(afterLines, l.newLine - 1);
2346
2595
  if (step && !stepHasCheck(afterLines, step)) continue;
2347
2596
  out2.push(
@@ -2355,7 +2604,7 @@ var ciTampering = {
2355
2604
  );
2356
2605
  }
2357
2606
  if (c.before != null) {
2358
- triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
2607
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
2359
2608
  for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
2360
2609
  out2.push(
2361
2610
  makeFinding(RULE5, policy, {
@@ -2380,7 +2629,7 @@ var ciTampering = {
2380
2629
  }
2381
2630
  for (const l of removedLines(c)) {
2382
2631
  if (/^\s*#/.test(l.content)) continue;
2383
- const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
2632
+ const isUsesLine = usesCheck(l.content);
2384
2633
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
2385
2634
  if (!isUsesLine && !isCheckCommand) continue;
2386
2635
  const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
@@ -2411,11 +2660,11 @@ var ciTampering = {
2411
2660
  };
2412
2661
 
2413
2662
  // src/detectors/hook-tampering.ts
2414
- import { existsSync, readFileSync as readFileSync3 } from "node:fs";
2663
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
2415
2664
  import { isAbsolute, join as join3, relative } from "node:path";
2416
2665
 
2417
2666
  // src/wiring.ts
2418
- import { readFileSync as readFileSync2 } from "node:fs";
2667
+ import { readFileSync } from "node:fs";
2419
2668
  import { dirname, join as join2 } from "node:path";
2420
2669
  import { fileURLToPath } from "node:url";
2421
2670
  function shippedVersion() {
@@ -2423,7 +2672,7 @@ function shippedVersion() {
2423
2672
  const here = dirname(fileURLToPath(import.meta.url));
2424
2673
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2425
2674
  try {
2426
- const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
2675
+ const pkg = JSON.parse(readFileSync(join2(here, rel), "utf8"));
2427
2676
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2428
2677
  } catch {
2429
2678
  }
@@ -4072,7 +4321,7 @@ function lefthookBase(c, changes, ctx) {
4072
4321
  for (const n of names) {
4073
4322
  try {
4074
4323
  const p = join3(ctx.cwd, n);
4075
- if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
4324
+ if (existsSync(p)) return parseDoc(readFileSync2(p, "utf8"));
4076
4325
  } catch {
4077
4326
  }
4078
4327
  }
@@ -4442,21 +4691,20 @@ function collect(root, runner) {
4442
4691
  const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
4443
4692
  sel.ignore = [...sel.ignore ?? [], ...entries];
4444
4693
  };
4694
+ let projectList = null;
4445
4695
  const projects = (e) => {
4446
4696
  sel.present = true;
4447
- if (!ts3.isArrayLiteralExpression(e)) {
4697
+ projectList = e;
4698
+ };
4699
+ const visit = (node) => {
4700
+ if (ts3.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
4701
+ sel.present = true;
4448
4702
  sel.opaque = true;
4449
- return;
4450
4703
  }
4451
- const list2 = [];
4452
- for (const el of e.elements) {
4453
- if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
4454
- else sel.opaque = true;
4704
+ if (ts3.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts3.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
4705
+ sel.present = true;
4706
+ sel.opaque = true;
4455
4707
  }
4456
- sel.projects = list2;
4457
- if (list2.some((p) => p.opaque)) sel.opaque = true;
4458
- };
4459
- const visit = (node) => {
4460
4708
  if (ts3.isPropertyAssignment(node)) {
4461
4709
  const k = keyName2(node.name);
4462
4710
  if (runner === "jest") {
@@ -4476,7 +4724,7 @@ function collect(root, runner) {
4476
4724
  } else if (ownerKey2(node) === "test") {
4477
4725
  if (k === "include") sel.include = take(k, node.initializer);
4478
4726
  else if (k === "exclude") ignores(k, node.initializer);
4479
- else if (k === "dir") {
4727
+ else if (k === "dir" || k === "root") {
4480
4728
  const { dir, opaque } = dirValue(node.initializer);
4481
4729
  sel.present = true;
4482
4730
  if (opaque) sel.opaque = true;
@@ -4490,8 +4738,68 @@ function collect(root, runner) {
4490
4738
  ts3.forEachChild(node, visit);
4491
4739
  };
4492
4740
  visit(root);
4741
+ if (projectList) sel.projects = readProjects(projectList, sel, runner);
4742
+ if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
4493
4743
  return sel;
4494
4744
  }
4745
+ function unwrapConfigCall(e) {
4746
+ let x = e;
4747
+ while (ts3.isParenthesizedExpression(x) || ts3.isAsExpression(x) || x.kind === ts3.SyntaxKind.SatisfiesExpression) {
4748
+ x = x.expression;
4749
+ }
4750
+ if (ts3.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
4751
+ return x;
4752
+ }
4753
+ var JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
4754
+ function spreadsIntoConfig(node, runner) {
4755
+ const obj = node.parent;
4756
+ if (!ts3.isObjectLiteralExpression(obj)) return false;
4757
+ const keys = obj.properties.map((p) => ts3.isPropertyAssignment(p) ? keyName2(p.name) : null);
4758
+ if (runner === "vitest") {
4759
+ if (keys.includes("test")) return true;
4760
+ let q = obj.parent;
4761
+ while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
4762
+ return !!q && ts3.isPropertyAssignment(q) && keyName2(q.name) === "test";
4763
+ }
4764
+ return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
4765
+ }
4766
+ function extendsOf(el) {
4767
+ for (const p of el.properties) {
4768
+ if (!ts3.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
4769
+ if (p.initializer.kind === ts3.SyntaxKind.TrueKeyword) return true;
4770
+ if (p.initializer.kind === ts3.SyntaxKind.FalseKeyword) return null;
4771
+ return "path";
4772
+ }
4773
+ return null;
4774
+ }
4775
+ function readProject(el, root, runner) {
4776
+ const p = collect(el, runner);
4777
+ const ext = extendsOf(el);
4778
+ if (ext === "path") p.opaque = true;
4779
+ else if (ext === true && root) {
4780
+ p.include ??= root.include;
4781
+ p.regex ??= root.regex;
4782
+ p.ignore ??= root.ignore;
4783
+ p.roots ??= root.roots;
4784
+ p.base ??= root.base;
4785
+ if (root.opaque) p.opaque = true;
4786
+ }
4787
+ p.present = true;
4788
+ return p;
4789
+ }
4790
+ function readProjects(e, root, runner) {
4791
+ if (!ts3.isArrayLiteralExpression(e)) {
4792
+ root.opaque = true;
4793
+ return [];
4794
+ }
4795
+ const list2 = [];
4796
+ for (const el of e.elements) {
4797
+ const lit = unwrapConfigCall(el);
4798
+ if (ts3.isObjectLiteralExpression(lit)) list2.push(readProject(lit, root, runner));
4799
+ else root.opaque = true;
4800
+ }
4801
+ return list2;
4802
+ }
4495
4803
  function workspaceArray(sf) {
4496
4804
  for (const st of sf.statements) {
4497
4805
  let e;
@@ -4513,14 +4821,9 @@ function parseSelection(src, runner, path = "") {
4513
4821
  if (runner === "vitest" && isWorkspaceFile(path)) {
4514
4822
  const arr = workspaceArray(sf);
4515
4823
  if (arr) {
4516
- const list2 = [];
4517
- for (const el of arr.elements) {
4518
- if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
4519
- else sel.opaque = true;
4520
- }
4521
- sel.projects = list2;
4824
+ sel.projects = readProjects(arr, sel, runner);
4522
4825
  sel.present = true;
4523
- if (list2.some((p) => p.opaque)) sel.opaque = true;
4826
+ if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
4524
4827
  } else if (src.trim()) sel.opaque = true;
4525
4828
  } else sel = collect(sf, runner);
4526
4829
  } catch {
@@ -4580,8 +4883,8 @@ function predicate(sel, runner) {
4580
4883
  if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
4581
4884
  const hit = ignoredBy(r);
4582
4885
  if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
4583
- const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
4584
- return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
4886
+ const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
4887
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
4585
4888
  }
4586
4889
  };
4587
4890
  }
@@ -4620,8 +4923,21 @@ function calleeName(expr) {
4620
4923
  }
4621
4924
  return null;
4622
4925
  }
4623
- var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
4624
- var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
4926
+ var TABLE_METHOD = /^(?:each|for)$/;
4927
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && TABLE_METHOD.test(expr.name.text);
4928
+ var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && TABLE_METHOD.test(expr.name.text);
4929
+ function loopRows(iterable) {
4930
+ if (ts4.isArrayLiteralExpression(iterable)) {
4931
+ let n = 0;
4932
+ let open = false;
4933
+ for (const el of iterable.elements) {
4934
+ if (ts4.isSpreadElement(el)) open = true;
4935
+ else n++;
4936
+ }
4937
+ return { n: Math.max(n, open ? 0 : 1), open };
4938
+ }
4939
+ return { n: 1, open: true };
4940
+ }
4625
4941
  function templateRows(node) {
4626
4942
  const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
4627
4943
  return Math.max(rows, 1);
@@ -4659,7 +4975,23 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
4659
4975
  let n = 0;
4660
4976
  let open = false;
4661
4977
  const visit = (node, mult) => {
4978
+ if (ts4.isForOfStatement(node)) {
4979
+ const r = loopRows(node.expression);
4980
+ if (r.open) open = true;
4981
+ visit(node.statement, mult * r.n);
4982
+ return;
4983
+ }
4984
+ if (ts4.isForStatement(node) || ts4.isForInStatement(node) || ts4.isWhileStatement(node) || ts4.isDoStatement(node)) {
4985
+ open = true;
4986
+ }
4662
4987
  if (ts4.isCallExpression(node)) {
4988
+ const callee = node.expression;
4989
+ if (ts4.isPropertyAccessExpression(callee) && callee.name.text === "forEach") {
4990
+ const r = loopRows(callee.expression);
4991
+ if (r.open) open = true;
4992
+ for (const a of node.arguments) visit(a, mult * r.n);
4993
+ return;
4994
+ }
4663
4995
  const head = node.expression;
4664
4996
  if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
4665
4997
  const r = eachRows(head);
@@ -4707,6 +5039,12 @@ function runnerSamples(configPath, policy, ctx) {
4707
5039
  const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
4708
5040
  return own.length ? own : CANONICAL_SAMPLES;
4709
5041
  }
5042
+ var WHOLE_SUITE_SCRIPT = /^test(?::ci)?$/;
5043
+ function excludesOtherRunner(flag, cmd) {
5044
+ if (flag !== "--exclude") return false;
5045
+ const values = [...cmd.matchAll(/--exclude(?:=|\s+)(['"]?)(\S+?)\1(?=\s|$)/g)].map((m) => m[2]);
5046
+ return values.length > 0 && values.every((v) => OTHER_RUNNER_DIR.test(v.replace(/^\.\//, "").replace(/(?:\/\*+)*\/?$/, "") + "/"));
5047
+ }
4710
5048
  function scriptNarrowings(before, after, path) {
4711
5049
  if ((path.split("/").pop() ?? path) !== "package.json") return [];
4712
5050
  const scriptsOf = (src) => {
@@ -4721,7 +5059,7 @@ function scriptNarrowings(before, after, path) {
4721
5059
  const now = scriptsOf(after);
4722
5060
  const out2 = [];
4723
5061
  for (const [name, cmd] of Object.entries(now)) {
4724
- if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
5062
+ if (!WHOLE_SUITE_SCRIPT.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
4725
5063
  const prev = was[name] ?? "";
4726
5064
  const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
4727
5065
  for (const m of cmd.matchAll(flags)) {
@@ -4729,6 +5067,7 @@ function scriptNarrowings(before, after, path) {
4729
5067
  if (!flag) continue;
4730
5068
  const one = new RegExp(SUITE_NARROWING_FLAGS.source);
4731
5069
  if (one.test(prev) && prev.includes(flag)) continue;
5070
+ if (excludesOtherRunner(flag, cmd)) continue;
4732
5071
  out2.push({ script: name, flag, before: prev, after: cmd });
4733
5072
  break;
4734
5073
  }
@@ -4760,10 +5099,11 @@ var testDeletion = {
4760
5099
  certainty: "mechanical",
4761
5100
  run(changes, policy, _view, ctx) {
4762
5101
  const out2 = [];
5102
+ const isSpec = (p) => isProtected(p, policy, "tests") && !isProtected(p, policy, "snapshots");
4763
5103
  const addedTestLines = /* @__PURE__ */ new Set();
4764
5104
  let addedTestBlocks = 0;
4765
5105
  for (const c of changes) {
4766
- if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
5106
+ if (c.kind !== "file" || c.after == null || !isSpec(c.path)) continue;
4767
5107
  if (c.op === "add") {
4768
5108
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
4769
5109
  addedTestBlocks += countTests(c.after, c.path, true).min;
@@ -4781,7 +5121,7 @@ var testDeletion = {
4781
5121
  };
4782
5122
  for (const c of changes) {
4783
5123
  if (c.kind === "file") {
4784
- const isTest = isProtected(c.path, policy, "tests");
5124
+ const isTest = isSpec(c.path);
4785
5125
  if (c.op === "delete" && isTest) {
4786
5126
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
4787
5127
  out2.push(
@@ -4792,7 +5132,7 @@ var testDeletion = {
4792
5132
  remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
4793
5133
  })
4794
5134
  );
4795
- } else if (c.op === "rename" && c.oldPath && isProtected(c.oldPath, policy, "tests") && !isTest) {
5135
+ } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
4796
5136
  out2.push(
4797
5137
  makeFinding(RULE7, policy, {
4798
5138
  file: c.path,
@@ -4853,22 +5193,22 @@ var testDeletion = {
4853
5193
  const testToks = toks.filter((t) => {
4854
5194
  const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
4855
5195
  const probe = resolved ?? t;
4856
- if (!isProtected(probe, policy, "tests")) return false;
5196
+ if (!isSpec(probe)) return false;
4857
5197
  if (!listing || root === null) return true;
4858
5198
  const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
4859
5199
  if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
4860
- return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
5200
+ return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx, "snapshots");
4861
5201
  });
4862
- const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
5202
+ const rootHasProtected = () => listing ? listing.some(isSpec) : true;
4863
5203
  const dirToks = toks.filter((t) => {
4864
5204
  if (t.startsWith("-") || testToks.includes(t)) return false;
4865
5205
  const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
4866
5206
  if (!whole && /[*?{}[\]]/.test(t)) return false;
4867
- if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
5207
+ if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx, "snapshots");
4868
5208
  const dir = whole ? root : joinRoot(root, t);
4869
5209
  if (dir === null) return false;
4870
5210
  if (dir === "") return rootHasProtected();
4871
- return containsProtected(dir, policy, "tests", ctx);
5211
+ return containsProtected(dir, policy, "tests", ctx, "snapshots");
4872
5212
  });
4873
5213
  if (testToks.length === 0 && dirToks.length === 0) continue;
4874
5214
  const named = [...testToks, ...dirToks];
@@ -4876,13 +5216,13 @@ var testDeletion = {
4876
5216
  let why = null;
4877
5217
  let evidence = seg;
4878
5218
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
4879
- const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
5219
+ const redirectsOntoTest = redirectTarget ? isSpec(unquote(redirectTarget)) : false;
4880
5220
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
4881
5221
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
4882
5222
  const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
4883
5223
  const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
4884
5224
  const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
4885
- if (!rev || rev === "HEAD") return false;
5225
+ if (!rev || rev === "HEAD" || rev === "@" || revIsHead(rev, ctx) === true) return false;
4886
5226
  if (src || seg.includes(" -- ")) return true;
4887
5227
  return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
4888
5228
  })();
@@ -4898,7 +5238,7 @@ var testDeletion = {
4898
5238
  why = "truncate empties a test file";
4899
5239
  } else if (redirectsOntoTest) {
4900
5240
  why = "a redirect overwrites/empties a test file";
4901
- } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
5241
+ } else if (cmd === "cp" && testToks.length > 0 && isSpec(toks[toks.length - 1] ?? "") && !isSpec(toks[toks.length - 2] ?? "")) {
4902
5242
  why = "cp overwrites a test file with something else";
4903
5243
  } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
4904
5244
  why = "tee overwrites a test file";
@@ -4908,7 +5248,7 @@ var testDeletion = {
4908
5248
  why = "git checkout <rev> replaces a test file with an older version";
4909
5249
  } else if (/\bmv\b/.test(seg)) {
4910
5250
  const dest = toks[toks.length - 1];
4911
- if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
5251
+ if (dest && testToks.length > 0 && !isSpec(dest)) {
4912
5252
  why = "mv renames a test file out of the test glob";
4913
5253
  evidence = `${testToks[0]} \u2192 ${dest}`;
4914
5254
  } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
@@ -4945,7 +5285,7 @@ function eachTables(src, path) {
4945
5285
  try {
4946
5286
  const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
4947
5287
  const visit = (node) => {
4948
- if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
5288
+ if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && /^(?:each|for)$/.test(node.expression.name.text) && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
4949
5289
  const arg = node.arguments[0];
4950
5290
  if (arg && ts5.isArrayLiteralExpression(arg)) {
4951
5291
  for (const el of arg.elements) {
@@ -4966,6 +5306,7 @@ function eachTables(src, path) {
4966
5306
  }
4967
5307
  return { rows, open };
4968
5308
  }
5309
+ var TEST_DIR_FILE = /(?:^|\/)(?:test|tests|__tests__|spec|specs)\//;
4969
5310
  function significantLinesOrdered(src, path) {
4970
5311
  const out2 = [];
4971
5312
  const lang = langOf(path);
@@ -4986,11 +5327,14 @@ var testContentRemoval = {
4986
5327
  for (const c of changes) {
4987
5328
  if (c.kind !== "file" || c.after == null) continue;
4988
5329
  if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
4989
- if (c.op === "add") addedPool += ws(c.after);
5330
+ let addedHere = "";
5331
+ if (c.op === "add") addedHere = ws(c.after);
4990
5332
  else if (c.before != null) {
4991
5333
  const had = new Set(c.before.split("\n").map((l) => l.trim()));
4992
- addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
5334
+ addedHere = ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
4993
5335
  }
5336
+ addedPool += addedHere;
5337
+ if (!isProtected(c.path, policy, "tests") && TEST_DIR_FILE.test(c.path) && langOf(c.path) === "js") keptPool += addedHere;
4994
5338
  }
4995
5339
  for (const c of changes) {
4996
5340
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
@@ -5212,7 +5556,7 @@ function hasBlocking(findings) {
5212
5556
  }
5213
5557
 
5214
5558
  // src/policy-load.ts
5215
- import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
5559
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
5216
5560
  import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
5217
5561
  import { parse as parse2 } from "yaml";
5218
5562
  var PolicyError = class extends Error {
@@ -5339,7 +5683,7 @@ function parseOrThrow(src, where) {
5339
5683
  function loadPolicy(cwd = process.cwd()) {
5340
5684
  const path = join4(cwd, POLICY_FILE);
5341
5685
  if (!existsSync2(path)) return defaultPolicy();
5342
- return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
5686
+ return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5343
5687
  }
5344
5688
  function loadPolicyAt(rev, cwd) {
5345
5689
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -5349,7 +5693,7 @@ function loadPolicyAt(rev, cwd) {
5349
5693
 
5350
5694
  // src/signoff.ts
5351
5695
  import { createHash } from "node:crypto";
5352
- import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
5696
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
5353
5697
  import { dirname as dirname2, join as join5 } from "node:path";
5354
5698
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
5355
5699
  function fingerprint(rule, file, evidence) {
@@ -5367,7 +5711,7 @@ function readLedger(cwd, policy) {
5367
5711
  const p = ledgerPath(cwd, policy);
5368
5712
  if (!existsSync3(p)) return [];
5369
5713
  const out2 = [];
5370
- for (const line of readFileSync5(p, "utf8").split("\n")) {
5714
+ for (const line of readFileSync4(p, "utf8").split("\n")) {
5371
5715
  if (!line.trim()) continue;
5372
5716
  try {
5373
5717
  const e = JSON.parse(line);
@@ -5520,7 +5864,7 @@ function renderText(input, opts) {
5520
5864
  for (const f of findings) {
5521
5865
  const isBlock = f.severity === "block";
5522
5866
  const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
5523
- const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
5867
+ const loc = f.file ? `${stripControl(escapeControl(f.file))}${f.line ? `:${f.line}` : ""}` : "(command)";
5524
5868
  out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
5525
5869
  for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
5526
5870
  const row = (key2, lines) => {
@@ -5712,6 +6056,7 @@ function check(opts) {
5712
6056
  }
5713
6057
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
5714
6058
  let findings = evaluate(changes, policy, void 0, view, { cwd });
6059
+ if (view === "worktree") findings = findings.concat(unjudgeableProtected(cwd, policy, changes));
5715
6060
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
5716
6061
  findings = remaining;
5717
6062
  if (cleared.length) {
@@ -5736,24 +6081,19 @@ function runCheck(opts) {
5736
6081
  }
5737
6082
 
5738
6083
  // src/cli/hook.ts
5739
- import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
6084
+ import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "node:fs";
5740
6085
  import { createHash as createHash4 } from "node:crypto";
5741
- import { join as join10 } from "node:path";
5742
6086
 
5743
6087
  // src/adapters/claude/changes.ts
5744
6088
  import { execFileSync as execFileSync3 } from "node:child_process";
5745
- import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
6089
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
5746
6090
  import { tmpdir } from "node:os";
5747
6091
  import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
5748
6092
  function asStr(v) {
5749
6093
  return typeof v === "string" ? v : "";
5750
6094
  }
5751
6095
  function readDisk(path) {
5752
- try {
5753
- return existsSync4(path) ? readFileSync6(path, "utf8") : null;
5754
- } catch {
5755
- return null;
5756
- }
6096
+ return textOf(inspectResolved(path));
5757
6097
  }
5758
6098
  function abs(path, cwd) {
5759
6099
  return resolve(cwd, path);
@@ -5847,7 +6187,7 @@ function formatDenial(blocks) {
5847
6187
  const lead = blocks[0];
5848
6188
  const lines = ["Tamperward blocked this change \u2014 it weakens a protected safety net to pass checks.", ""];
5849
6189
  for (const f of blocks) {
5850
- const loc = f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : "";
6190
+ const loc = f.file ? ` (${escapeControl(f.file)}${f.line ? `:${f.line}` : ""})` : "";
5851
6191
  lines.push(` \u2022 ${f.rule}${loc}: ${f.message}`);
5852
6192
  }
5853
6193
  lines.push("");
@@ -5862,7 +6202,7 @@ function formatDenial(blocks) {
5862
6202
  }
5863
6203
 
5864
6204
  // src/session.ts
5865
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
6205
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
5866
6206
  import { dirname as dirname3, join as join7 } from "node:path";
5867
6207
  var UNSAFE = /[^A-Za-z0-9_-]/g;
5868
6208
  var SHA = /^[0-9a-f]{7,40}$/;
@@ -5876,8 +6216,8 @@ function turnBaseline(cwd, sessionId) {
5876
6216
  try {
5877
6217
  const p = baselinePath(cwd, sessionId);
5878
6218
  if (!p) return null;
5879
- if (existsSync5(p)) {
5880
- const v = readFileSync7(p, "utf8").trim();
6219
+ if (existsSync4(p)) {
6220
+ const v = readFileSync5(p, "utf8").trim();
5881
6221
  if (SHA.test(v)) return v;
5882
6222
  }
5883
6223
  const head = headSha(cwd);
@@ -5903,7 +6243,7 @@ function advanceTurnBaseline(cwd, sessionId) {
5903
6243
 
5904
6244
  // src/effect.ts
5905
6245
  import { createHash as createHash2 } from "node:crypto";
5906
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
6246
+ import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
5907
6247
  import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
5908
6248
  import { execFileSync as execFileSync4 } from "node:child_process";
5909
6249
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
@@ -5938,8 +6278,22 @@ var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
5938
6278
  function contentHash(content) {
5939
6279
  return sha(Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"));
5940
6280
  }
6281
+ function entryOf(e) {
6282
+ const hash = e.content != null ? sha(e.content) : e.kind === "unreadable" ? "unreadable" : `${e.kind}:${e.detail}`;
6283
+ return { hash, mode: e.mode, size: e.size, mtimeMs: e.mtimeMs };
6284
+ }
5941
6285
  function snapshotProtected(cwd, policy, prev) {
6286
+ void prev;
5942
6287
  const out2 = {};
6288
+ const keep = (rel) => isProtected(rel, policy);
6289
+ let pruned = /* @__PURE__ */ new Set();
6290
+ let listed = [];
6291
+ try {
6292
+ const t = ignoredTree({ cwd }, keep);
6293
+ pruned = new Set(t.dirs.map((d) => d.replace(/\/$/, "")));
6294
+ listed = t.files;
6295
+ } catch {
6296
+ }
5943
6297
  const walk2 = (rel) => {
5944
6298
  const abs2 = rel ? join8(cwd, rel) : cwd;
5945
6299
  let names;
@@ -5951,25 +6305,19 @@ function snapshotProtected(cwd, policy, prev) {
5951
6305
  for (const name of names) {
5952
6306
  if (SKIP_DIRS2.has(name)) continue;
5953
6307
  const r = rel ? `${rel}/${name}` : name;
6308
+ if (pruned.has(r)) continue;
5954
6309
  let st;
5955
6310
  try {
5956
- st = statSync(join8(cwd, r));
6311
+ st = lstatSync2(join8(cwd, r));
5957
6312
  } catch {
5958
6313
  continue;
5959
6314
  }
5960
6315
  if (st.isDirectory()) walk2(r);
5961
- else if (st.isFile() && isProtected(r, policy)) {
5962
- let hash = "";
5963
- try {
5964
- hash = sha(readFileSync8(join8(cwd, r)));
5965
- } catch {
5966
- hash = "unreadable";
5967
- }
5968
- out2[r] = { hash, mode: st.mode, size: st.size, mtimeMs: st.mtimeMs };
5969
- }
6316
+ else if (keep(r)) out2[r] = entryOf(inspectRel(cwd, r));
5970
6317
  }
5971
6318
  };
5972
6319
  walk2("");
6320
+ for (const rel of listed) if (out2[rel] === void 0) out2[rel] = entryOf(inspectRel(cwd, rel));
5973
6321
  return out2;
5974
6322
  }
5975
6323
  function driftBetween(expected, current) {
@@ -5987,9 +6335,9 @@ function driftBetween(expected, current) {
5987
6335
  return { changed, deleted, added };
5988
6336
  }
5989
6337
  function loadTree(p) {
5990
- if (!p || !existsSync6(p)) return null;
6338
+ if (!p || !existsSync5(p)) return null;
5991
6339
  try {
5992
- return JSON.parse(readFileSync8(p, "utf8"));
6340
+ return JSON.parse(readFileSync6(p, "utf8"));
5993
6341
  } catch {
5994
6342
  return null;
5995
6343
  }
@@ -6028,19 +6376,16 @@ function dropTurnTree(cwd, sessionId) {
6028
6376
  }
6029
6377
 
6030
6378
  // src/cli/watch.ts
6031
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
6379
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync, watch } from "node:fs";
6032
6380
  import { join as join9 } from "node:path";
6033
6381
  import { createHash as createHash3 } from "node:crypto";
6034
6382
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
6035
6383
  function snap(abs2) {
6036
- try {
6037
- const st = statSync2(abs2);
6038
- if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
6039
- const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
6040
- return { mode: st.mode, size: st.size, hash };
6041
- } catch {
6042
- return { mode: null, size: null, hash: null };
6043
- }
6384
+ const e = inspectPath(abs2);
6385
+ if (e.kind === "absent" || e.kind === "unreadable") return { mode: null, size: null, hash: null };
6386
+ if (e.kind !== "file" || e.content == null) return { mode: e.mode, size: null, hash: null };
6387
+ const hash = createHash3("sha256").update(e.content).digest("hex").slice(0, 16);
6388
+ return { mode: e.mode, size: e.size, hash };
6044
6389
  }
6045
6390
  function defaultEventLog(cwd) {
6046
6391
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
@@ -6066,7 +6411,7 @@ function watchTree(dir, cb) {
6066
6411
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
6067
6412
  cb(kind, child);
6068
6413
  try {
6069
- if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
6414
+ if (statSync(join9(dir, child)).isDirectory()) addDir(child);
6070
6415
  } catch {
6071
6416
  }
6072
6417
  });
@@ -6087,7 +6432,7 @@ function watchTree(dir, cb) {
6087
6432
  const child = rel ? `${rel}/${name}` : name;
6088
6433
  if (SKIP2.test(child + "/")) continue;
6089
6434
  try {
6090
- if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
6435
+ if (statSync(join9(dir, child)).isDirectory()) walk2(child);
6091
6436
  } catch {
6092
6437
  }
6093
6438
  }
@@ -6132,17 +6477,17 @@ function runWatch(args) {
6132
6477
  }
6133
6478
 
6134
6479
  // src/detectors/fs-events.ts
6135
- import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
6480
+ import { readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
6136
6481
  var RULE11 = "transient-protected-mutation";
6137
6482
  function readEvents(log, offset) {
6138
6483
  let size = 0;
6139
6484
  try {
6140
- size = statSync3(log).size;
6485
+ size = statSync2(log).size;
6141
6486
  } catch {
6142
6487
  return { events: [], newOffset: offset };
6143
6488
  }
6144
6489
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
6145
- const raw = readFileSync10(log, "utf8").slice(offset);
6490
+ const raw = readFileSync8(log, "utf8").slice(offset);
6146
6491
  const events = [];
6147
6492
  for (const line of raw.split("\n")) {
6148
6493
  if (!line.trim()) continue;
@@ -6195,7 +6540,7 @@ var HookInputError = class extends Error {
6195
6540
  };
6196
6541
  function readStdin() {
6197
6542
  try {
6198
- return readFileSync11(0, "utf8");
6543
+ return readFileSync9(0, "utf8");
6199
6544
  } catch (e) {
6200
6545
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
6201
6546
  }
@@ -6221,7 +6566,7 @@ function recordDenylog(blocks) {
6221
6566
  } catch {
6222
6567
  }
6223
6568
  }
6224
- function errText(e) {
6569
+ function errText2(e) {
6225
6570
  const m = e instanceof Error ? e.message : String(e);
6226
6571
  return m.length > 200 ? m.slice(0, 200) + "\u2026" : m;
6227
6572
  }
@@ -6253,11 +6598,8 @@ function fileChanges(changes) {
6253
6598
  return changes.filter((c) => c.kind === "file");
6254
6599
  }
6255
6600
  function hashOnDisk(cwd, rel) {
6256
- try {
6257
- return contentHash(readFileSync11(join10(cwd, rel)));
6258
- } catch {
6259
- return null;
6260
- }
6601
+ const e = inspectRel(cwd, rel);
6602
+ return e.content == null ? null : contentHash(e.content);
6261
6603
  }
6262
6604
  function gitMode(mode) {
6263
6605
  return (mode & 64) !== 0 ? "100755" : "100644";
@@ -6304,6 +6646,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
6304
6646
  const candidates = base ? [base, "HEAD", ""] : ["HEAD", ""];
6305
6647
  for (const rel of [...d.changed, ...d.deleted]) {
6306
6648
  if (seen.has(rel)) continue;
6649
+ const unjudgeable = unjudgeableFinding(cwd, rel);
6650
+ if (unjudgeable) {
6651
+ blocks.push(unjudgeable);
6652
+ continue;
6653
+ }
6307
6654
  const after = fileOnDisk(rel, { cwd });
6308
6655
  const trustedBlob = fileAt(trusted, rel, { cwd });
6309
6656
  const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
@@ -6352,6 +6699,11 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
6352
6699
  }
6353
6700
  for (const rel of d.added) {
6354
6701
  if (seen.has(rel)) continue;
6702
+ const unjudgeable = unjudgeableFinding(cwd, rel);
6703
+ if (unjudgeable) {
6704
+ blocks.push(unjudgeable);
6705
+ continue;
6706
+ }
6355
6707
  const after = fileOnDisk(rel, { cwd });
6356
6708
  if (after == null) {
6357
6709
  blocks.push({
@@ -6391,7 +6743,8 @@ function effectDriftBlocks(cwd, sessionId, policy) {
6391
6743
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
6392
6744
  );
6393
6745
  const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
6394
- const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
6746
+ const judged = [...scoped, ...gap.changes];
6747
+ const blocks = evaluate(judged, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks, unjudgeableProtected(cwd, policy, judged));
6395
6748
  if (blocks.length > 0) return blocks;
6396
6749
  savePtree(cwd, sessionId, current);
6397
6750
  return null;
@@ -6428,7 +6781,7 @@ function preToolUseVerdict(input) {
6428
6781
  if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
6429
6782
  return verdict(blocks, "PreToolUse");
6430
6783
  } catch (e) {
6431
- return failClosed("PreToolUse", errText(e));
6784
+ return failClosed("PreToolUse", errText2(e));
6432
6785
  }
6433
6786
  }
6434
6787
  function cursorPath(cwd, sessionId) {
@@ -6440,10 +6793,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
6440
6793
  } };
6441
6794
  const log = defaultEventLog(cwd);
6442
6795
  const cp = cursorPath(cwd, sessionId);
6443
- if (!existsSync7(log)) return none;
6796
+ if (!existsSync6(log)) return none;
6444
6797
  let offset = 0;
6445
- if (cp && existsSync7(cp)) {
6446
- const n = Number(readFileSync11(cp, "utf8"));
6798
+ if (cp && existsSync6(cp)) {
6799
+ const n = Number(readFileSync9(cp, "utf8"));
6447
6800
  offset = Number.isFinite(n) ? n : 0;
6448
6801
  }
6449
6802
  const { events, newOffset } = readEvents(log, offset);
@@ -6455,12 +6808,8 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
6455
6808
  } };
6456
6809
  const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
6457
6810
  const finalHash = (path) => {
6458
- try {
6459
- if (!statSync4(`${cwd}/${path}`).isFile()) return null;
6460
- return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
6461
- } catch {
6462
- return null;
6463
- }
6811
+ const e = inspectRel(cwd, path);
6812
+ return e.kind === "file" && e.content != null ? contentHash(e.content) : null;
6464
6813
  };
6465
6814
  const findings = transientFindings(events, persistent, policy, finalHash);
6466
6815
  recordWarns(findings.filter((f) => f.severity !== "block"));
@@ -6501,7 +6850,7 @@ function stopVerdict(input) {
6501
6850
  changes = changes.concat(gap.changes);
6502
6851
  hiddenBlocks = gap.blocks;
6503
6852
  }
6504
- blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
6853
+ blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks, unjudgeableProtected(cwd, policy, changes));
6505
6854
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
6506
6855
  blocks = blocks.concat(transient.blocks);
6507
6856
  commitCursor = transient.commit;
@@ -6510,7 +6859,7 @@ function stopVerdict(input) {
6510
6859
  saveTurnTree(cwd, input.session_id, current);
6511
6860
  }
6512
6861
  } catch (e) {
6513
- return failClosed("Stop", errText(e));
6862
+ return failClosed("Stop", errText2(e));
6514
6863
  }
6515
6864
  if (blocks.length === 0) {
6516
6865
  advanceTurnBaseline(cwd, input.session_id);
@@ -6526,28 +6875,28 @@ function preToolUseFromRaw(raw) {
6526
6875
  try {
6527
6876
  return preToolUseVerdict(parseInput(raw));
6528
6877
  } catch (e) {
6529
- return failClosed("PreToolUse", errText(e));
6878
+ return failClosed("PreToolUse", errText2(e));
6530
6879
  }
6531
6880
  }
6532
6881
  function stopFromRaw(raw) {
6533
6882
  try {
6534
6883
  return stopVerdict(parseInput(raw));
6535
6884
  } catch (e) {
6536
- return failClosed("Stop", errText(e));
6885
+ return failClosed("Stop", errText2(e));
6537
6886
  }
6538
6887
  }
6539
6888
  function runHookClaude() {
6540
6889
  try {
6541
6890
  return emit(preToolUseFromRaw(readStdin()));
6542
6891
  } catch (e) {
6543
- return emit(failClosed("PreToolUse", errText(e)));
6892
+ return emit(failClosed("PreToolUse", errText2(e)));
6544
6893
  }
6545
6894
  }
6546
6895
  function runSweepClaude() {
6547
6896
  try {
6548
6897
  return emit(stopFromRaw(readStdin()));
6549
6898
  } catch (e) {
6550
- return emit(failClosed("Stop", errText(e)));
6899
+ return emit(failClosed("Stop", errText2(e)));
6551
6900
  }
6552
6901
  }
6553
6902
 
@@ -6600,9 +6949,9 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
6600
6949
  // src/cli/init.ts
6601
6950
  import { execFileSync as execFileSync5 } from "node:child_process";
6602
6951
  import { createHash as createHash5 } from "node:crypto";
6603
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
6604
- import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
6605
- var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
6952
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync3 } from "node:fs";
6953
+ import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
6954
+ var errText3 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
6606
6955
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6607
6956
  function gitConfig(cwd, key2) {
6608
6957
  try {
@@ -6751,13 +7100,13 @@ function readWorkflowMark(src) {
6751
7100
  if (!m) return null;
6752
7101
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
6753
7102
  }
6754
- function toolSet(matcher2) {
6755
- const m = String(matcher2 ?? "").trim();
7103
+ function toolSet(matcher) {
7104
+ const m = String(matcher ?? "").trim();
6756
7105
  if (m === "" || m === "*") return null;
6757
7106
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
6758
7107
  }
6759
- function missingTools(matcher2) {
6760
- const have = toolSet(matcher2);
7108
+ function missingTools(matcher) {
7109
+ const have = toolSet(matcher);
6761
7110
  if (have === null) return [];
6762
7111
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
6763
7112
  }
@@ -6777,12 +7126,12 @@ function hooksShapeError(hooks) {
6777
7126
  return null;
6778
7127
  }
6779
7128
  function planPolicy(cwd) {
6780
- const path = join11(cwd, POLICY_FILE);
6781
- if (existsSync8(path)) {
7129
+ const path = join10(cwd, POLICY_FILE);
7130
+ if (existsSync7(path)) {
6782
7131
  try {
6783
7132
  loadPolicy(cwd);
6784
7133
  } catch (e) {
6785
- return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText2(e)}; fix it, then re-run init (left untouched)` };
7134
+ return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText3(e)}; fix it, then re-run init (left untouched)` };
6786
7135
  }
6787
7136
  return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
6788
7137
  }
@@ -6796,12 +7145,12 @@ function planPolicy(cwd) {
6796
7145
  }
6797
7146
  function planClaudeHooks(cwd) {
6798
7147
  const rel = ".claude/settings.json";
6799
- const path = join11(cwd, rel);
7148
+ const path = join10(cwd, rel);
6800
7149
  let settings = {};
6801
- if (existsSync8(path)) {
7150
+ if (existsSync7(path)) {
6802
7151
  let parsed;
6803
7152
  try {
6804
- parsed = JSON.parse(readFileSync12(path, "utf8"));
7153
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
6805
7154
  } catch {
6806
7155
  return {
6807
7156
  item: "agent",
@@ -6835,7 +7184,7 @@ function planClaudeHooks(cwd) {
6835
7184
  return {
6836
7185
  item: "agent",
6837
7186
  path: rel,
6838
- status: existsSync8(path) ? "update" : "create",
7187
+ status: existsSync7(path) ? "update" : "create",
6839
7188
  detail: [
6840
7189
  needPre && "wire PreToolUse deny",
6841
7190
  needStop && "wire Stop sweep",
@@ -6894,7 +7243,7 @@ function hooksDir(cwd) {
6894
7243
  if (out2) return resolve2(cwd, out2);
6895
7244
  } catch {
6896
7245
  }
6897
- return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
7246
+ return existsSync7(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
6898
7247
  }
6899
7248
  function display(cwd, path) {
6900
7249
  const rel = relative3(cwd, path);
@@ -6921,30 +7270,30 @@ function planPreCommit(cwd) {
6921
7270
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
6922
7271
  let path;
6923
7272
  let note;
6924
- if (existsSync8(join11(cwd, ".husky"))) {
6925
- path = join11(cwd, ".husky", "pre-commit");
7273
+ if (existsSync7(join10(cwd, ".husky"))) {
7274
+ path = join10(cwd, ".husky", "pre-commit");
6926
7275
  note = "husky";
6927
7276
  } else {
6928
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
7277
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join10(cwd, c))));
6929
7278
  if (managed) {
6930
7279
  return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
6931
7280
  }
6932
7281
  const dir = hooksDir(cwd);
6933
7282
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
6934
7283
  if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
6935
- path = join11(dirname4(dir), "pre-commit");
7284
+ path = join10(dirname4(dir), "pre-commit");
6936
7285
  note = "husky";
6937
7286
  } else {
6938
- path = join11(dir, "pre-commit");
7287
+ path = join10(dir, "pre-commit");
6939
7288
  const rel2 = display(cwd, dir);
6940
- note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
7289
+ note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
6941
7290
  }
6942
7291
  }
6943
7292
  const rel = display(cwd, path);
6944
- if (existsSync8(path) && !statSync5(path).isFile()) {
7293
+ if (existsSync7(path) && !statSync3(path).isFile()) {
6945
7294
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
6946
7295
  }
6947
- const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
7296
+ const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
6948
7297
  if (existing === null) {
6949
7298
  return {
6950
7299
  item: "pre-commit",
@@ -7033,10 +7382,10 @@ ${block.join("\n")}
7033
7382
  function planCodeowners(cwd) {
7034
7383
  const rel = ".github/CODEOWNERS";
7035
7384
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
7036
- (r) => existsSync8(join11(cwd, r))
7385
+ (r) => existsSync7(join10(cwd, r))
7037
7386
  );
7038
- const path = join11(cwd, existingRel ?? rel);
7039
- const existing = existingRel ? readFileSync12(path, "utf8") : null;
7387
+ const path = join10(cwd, existingRel ?? rel);
7388
+ const existing = existingRel ? readFileSync10(path, "utf8") : null;
7040
7389
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
7041
7390
  if (missing.length === 0) {
7042
7391
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -7069,12 +7418,12 @@ ${CODEOWNERS_MARK}
7069
7418
  }
7070
7419
  function planWorkflow(cwd, force) {
7071
7420
  const rel = ".github/workflows/tamperward.yml";
7072
- const path = join11(cwd, rel);
7421
+ const path = join10(cwd, rel);
7073
7422
  const write = () => {
7074
7423
  mkdirSync5(dirname4(path), { recursive: true });
7075
7424
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
7076
7425
  };
7077
- if (!existsSync8(path)) {
7426
+ if (!existsSync7(path)) {
7078
7427
  return {
7079
7428
  item: "ci",
7080
7429
  path: rel,
@@ -7085,7 +7434,7 @@ function planWorkflow(cwd, force) {
7085
7434
  }
7086
7435
  let src;
7087
7436
  try {
7088
- src = readFileSync12(path, "utf8");
7437
+ src = readFileSync10(path, "utf8");
7089
7438
  } catch {
7090
7439
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
7091
7440
  }
@@ -7123,7 +7472,7 @@ function planned(item, path, plan) {
7123
7472
  try {
7124
7473
  return plan();
7125
7474
  } catch (e) {
7126
- return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
7475
+ return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText3(e)}` };
7127
7476
  }
7128
7477
  }
7129
7478
  function planInit(cwd, opts = {}) {
@@ -7147,7 +7496,7 @@ function runInit(opts) {
7147
7496
  applied++;
7148
7497
  } catch (e) {
7149
7498
  a.status = "error";
7150
- a.detail = `could not be written \u2014 ${errText2(e)}`;
7499
+ a.detail = `could not be written \u2014 ${errText3(e)}`;
7151
7500
  a.apply = void 0;
7152
7501
  }
7153
7502
  }
@@ -7184,15 +7533,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
7184
7533
  // src/cli/verify.ts
7185
7534
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
7186
7535
  import { createHash as createHash8 } from "node:crypto";
7187
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7536
+ import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync3, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync3, rmSync as rmSync3, statSync as statSync5, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
7188
7537
  import { tmpdir as tmpdir2 } from "node:os";
7189
- import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
7538
+ import { dirname as dirname5, join as join13, resolve as resolve3 } from "node:path";
7190
7539
 
7191
7540
  // src/git/trusted.ts
7192
7541
  import { execFileSync as execFileSync6 } from "node:child_process";
7193
7542
  import { createHash as createHash6 } from "node:crypto";
7194
- import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
7195
- import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
7543
+ import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
7544
+ import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
7196
7545
  function trustedGitEnv() {
7197
7546
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
7198
7547
  }
@@ -7205,7 +7554,7 @@ function gitDirOf(cwd) {
7205
7554
  env: trustedGitEnv()
7206
7555
  }).trim();
7207
7556
  if (!d) return null;
7208
- return isAbsolute6(d) ? d : join12(cwd, d);
7557
+ return isAbsolute6(d) ? d : join11(cwd, d);
7209
7558
  } catch {
7210
7559
  return null;
7211
7560
  }
@@ -7230,9 +7579,9 @@ function objectRewriteState(cwd) {
7230
7579
  parts.push(`${rel}:<no-git-dir>`);
7231
7580
  continue;
7232
7581
  }
7233
- const p = join12(dir, rel);
7582
+ const p = join11(dir, rel);
7234
7583
  try {
7235
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
7584
+ parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
7236
7585
  } catch {
7237
7586
  parts.push(`${rel}:<unreadable>`);
7238
7587
  }
@@ -7243,8 +7592,8 @@ function objectRewriteState(cwd) {
7243
7592
  // src/fingerprint.ts
7244
7593
  import { execFileSync as execFileSync7 } from "node:child_process";
7245
7594
  import { createHash as createHash7 } from "node:crypto";
7246
- import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
7247
- import { join as join13 } from "node:path";
7595
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync as readlinkSync2, statSync as statSync4 } from "node:fs";
7596
+ import { join as join12 } from "node:path";
7248
7597
  function treeFingerprint(cwd) {
7249
7598
  const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
7250
7599
  cwd,
@@ -7256,17 +7605,13 @@ function treeFingerprint(cwd) {
7256
7605
  for (const rel of files) {
7257
7606
  h.update(rel);
7258
7607
  h.update("\0");
7259
- try {
7260
- h.update(readFileSync14(join13(cwd, rel)));
7261
- } catch {
7262
- h.update("<unreadable>");
7263
- }
7608
+ h.update(inspectRel(cwd, rel).content ?? "<unreadable>");
7264
7609
  h.update("\0");
7265
7610
  }
7266
7611
  return h.digest("hex");
7267
7612
  }
7268
7613
  function depsFingerprint(cwd) {
7269
- const root = join13(cwd, "node_modules");
7614
+ const root = join12(cwd, "node_modules");
7270
7615
  const h = createHash7("sha256");
7271
7616
  const walk2 = (dir) => {
7272
7617
  let entries;
@@ -7276,20 +7621,20 @@ function depsFingerprint(cwd) {
7276
7621
  return;
7277
7622
  }
7278
7623
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
7279
- const p = join13(dir, e.name);
7624
+ const p = join12(dir, e.name);
7280
7625
  if (e.isDirectory()) walk2(p);
7281
7626
  else if (e.isFile()) {
7282
7627
  h.update(p);
7283
7628
  try {
7284
- h.update(readFileSync14(p));
7285
- h.update(String(statSync6(p).mode));
7629
+ h.update(readFileSync12(p));
7630
+ h.update(String(statSync4(p).mode));
7286
7631
  } catch {
7287
7632
  h.update("<unreadable>");
7288
7633
  }
7289
7634
  } else if (e.isSymbolicLink()) {
7290
7635
  h.update(p);
7291
7636
  try {
7292
- h.update(readlinkSync(p));
7637
+ h.update(readlinkSync2(p));
7293
7638
  } catch {
7294
7639
  h.update("<unreadable-link>");
7295
7640
  }
@@ -7297,7 +7642,7 @@ function depsFingerprint(cwd) {
7297
7642
  }
7298
7643
  };
7299
7644
  try {
7300
- if (!statSync6(root).isDirectory()) return "none";
7645
+ if (!statSync4(root).isDirectory()) return "none";
7301
7646
  } catch {
7302
7647
  return "none";
7303
7648
  }
@@ -7396,30 +7741,30 @@ function baseIsAncestorOfHead(base, cwd) {
7396
7741
  function materialize(cwd, dest) {
7397
7742
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
7398
7743
  for (const rel of listed) {
7399
- const src = join14(cwd, rel);
7744
+ const src = join13(cwd, rel);
7400
7745
  let st;
7401
7746
  try {
7402
- st = statSync7(src);
7747
+ st = statSync5(src);
7403
7748
  } catch {
7404
7749
  continue;
7405
7750
  }
7406
7751
  if (!st.isFile()) continue;
7407
- const out2 = join14(dest, rel);
7752
+ const out2 = join13(dest, rel);
7408
7753
  mkdirSync6(dirname5(out2), { recursive: true });
7409
7754
  dropSymlink(out2);
7410
- if (lstatSync(src).isSymbolicLink()) {
7411
- writeFileSync6(out2, readFileSync15(src));
7755
+ if (lstatSync3(src).isSymbolicLink()) {
7756
+ writeFileSync6(out2, readFileSync13(src));
7412
7757
  } else {
7413
7758
  cpSync(src, out2, { dereference: true });
7414
7759
  }
7415
7760
  chmodSync2(out2, st.mode);
7416
7761
  }
7417
7762
  const nm = resolve3(cwd, "node_modules");
7418
- if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
7763
+ if (existsSync9(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7419
7764
  }
7420
7765
  function dropSymlink(p) {
7421
7766
  try {
7422
- if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
7767
+ if (lstatSync3(p).isSymbolicLink()) rmSync3(p, { force: true });
7423
7768
  } catch {
7424
7769
  }
7425
7770
  }
@@ -7448,11 +7793,11 @@ function overlayDigest(dest, paths) {
7448
7793
  for (const rel of [...paths].sort()) {
7449
7794
  h.update(rel);
7450
7795
  h.update("\0");
7451
- const p = join14(dest, rel);
7796
+ const p = join13(dest, rel);
7452
7797
  try {
7453
- const st = lstatSync(p);
7798
+ const st = lstatSync3(p);
7454
7799
  h.update(String(st.mode));
7455
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
7800
+ h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync13(p));
7456
7801
  } catch {
7457
7802
  h.update("<gone>");
7458
7803
  }
@@ -7475,7 +7820,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
7475
7820
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
7476
7821
  }
7477
7822
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
7478
- const out2 = join14(dest, e.path);
7823
+ const out2 = join13(dest, e.path);
7479
7824
  mkdirSync6(dirname5(out2), { recursive: true });
7480
7825
  rmSync3(out2, { force: true });
7481
7826
  if (e.mode === "120000") {
@@ -7490,7 +7835,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
7490
7835
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
7491
7836
  for (const rel of inCopy) {
7492
7837
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
7493
- rmSync3(join14(dest, rel), { force: true });
7838
+ rmSync3(join13(dest, rel), { force: true });
7494
7839
  removed++;
7495
7840
  }
7496
7841
  return { restored, removed };
@@ -7532,8 +7877,8 @@ function suiteEnv(scratch) {
7532
7877
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
7533
7878
  env[k] = v;
7534
7879
  }
7535
- const userRc = join14(scratch, "npmrc-user");
7536
- const globalRc = join14(scratch, "npmrc-global");
7880
+ const userRc = join13(scratch, "npmrc-user");
7881
+ const globalRc = join13(scratch, "npmrc-global");
7537
7882
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
7538
7883
  env.npm_config_userconfig = userRc;
7539
7884
  env.npm_config_globalconfig = globalRc;
@@ -7543,8 +7888,8 @@ function suiteEnv(scratch) {
7543
7888
  }
7544
7889
  function runSuite(dir, cmd, budgetSecs) {
7545
7890
  const t0 = Date.now();
7546
- const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
7547
- const outFile = join14(outDir, "result.json");
7891
+ const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
7892
+ const outFile = join13(outDir, "result.json");
7548
7893
  try {
7549
7894
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
7550
7895
  cwd: dir,
@@ -7557,7 +7902,7 @@ function runSuite(dir, cmd, budgetSecs) {
7557
7902
  const secs = Math.round((Date.now() - t0) / 1e3);
7558
7903
  let r;
7559
7904
  try {
7560
- r = JSON.parse(readFileSync15(outFile, "utf8"));
7905
+ r = JSON.parse(readFileSync13(outFile, "utf8"));
7561
7906
  } catch {
7562
7907
  return { exit: null, secs };
7563
7908
  }
@@ -7608,8 +7953,8 @@ function runVerify(opts) {
7608
7953
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
7609
7954
  return 2;
7610
7955
  }
7611
- const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
7612
- const visDir = join14(visRoot, "t");
7956
+ const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
7957
+ const visDir = join13(visRoot, "t");
7613
7958
  const cleanup = (dirs) => {
7614
7959
  if (opts.keep) return;
7615
7960
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -7637,8 +7982,8 @@ function runVerify(opts) {
7637
7982
  out2("pristine run executes through it too. Failing closed, not open.");
7638
7983
  return 2;
7639
7984
  }
7640
- const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
7641
- const priDir = join14(priRoot, "t");
7985
+ const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
7986
+ const priDir = join13(priRoot, "t");
7642
7987
  let restored = [];
7643
7988
  let removedAdded = 0;
7644
7989
  try {
@@ -7750,7 +8095,7 @@ function parseVerify(args) {
7750
8095
 
7751
8096
  // src/cli/run.ts
7752
8097
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
7753
- import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
8098
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync as realpathSync2 } from "node:fs";
7754
8099
  import { resolve as resolve4 } from "node:path";
7755
8100
  var out = (s) => process.stdout.write(s + "\n");
7756
8101
  var err = (s) => process.stderr.write(s + "\n");
@@ -7759,7 +8104,7 @@ function git3(args, cwd) {
7759
8104
  }
7760
8105
  function nowTicks() {
7761
8106
  try {
7762
- return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
8107
+ return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
7763
8108
  } catch {
7764
8109
  return Number.POSITIVE_INFINITY;
7765
8110
  }
@@ -7768,7 +8113,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
7768
8113
  const out2 = [];
7769
8114
  let real;
7770
8115
  try {
7771
- real = realpathSync(cwd);
8116
+ real = realpathSync2(cwd);
7772
8117
  } catch {
7773
8118
  return out2;
7774
8119
  }
@@ -7783,13 +8128,13 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
7783
8128
  if (n === process.pid) continue;
7784
8129
  let held;
7785
8130
  try {
7786
- held = realpathSync(`/proc/${pid}/cwd`);
8131
+ held = realpathSync2(`/proc/${pid}/cwd`);
7787
8132
  } catch {
7788
8133
  continue;
7789
8134
  }
7790
8135
  if (held !== real && !held.startsWith(real + "/")) continue;
7791
8136
  try {
7792
- const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
8137
+ const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
7793
8138
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
7794
8139
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
7795
8140
  } catch {