tamperward 2.6.0 → 2.7.1

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 +1604 -400
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -415,7 +415,41 @@ function makeFinding(rule, policy, input) {
415
415
 
416
416
  // src/detectors/command.ts
417
417
  function segments(raw) {
418
- return raw.split(/(?:&&|\|\||[;&|\n])+/).map((s) => s.trim()).filter(Boolean);
418
+ const out2 = [];
419
+ let single = false;
420
+ let double = false;
421
+ let last = 0;
422
+ for (let i = 0; i < raw.length; i++) {
423
+ const ch = raw[i];
424
+ if (ch === "\\" && !single) {
425
+ i++;
426
+ continue;
427
+ }
428
+ if (ch === "'" && !double) {
429
+ single = !single;
430
+ continue;
431
+ }
432
+ if (ch === '"' && !single) {
433
+ double = !double;
434
+ continue;
435
+ }
436
+ if (single || double) continue;
437
+ const prev = raw[i - 1];
438
+ const next = raw[i + 1];
439
+ let len = 0;
440
+ if (ch === "&" && next === "&") len = 2;
441
+ else if (ch === "|" && next === "|") len = 2;
442
+ else if (ch === "|" && next === "&") len = 2;
443
+ else if (ch === ";" || ch === "\n") len = 1;
444
+ else if (ch === "|" && prev !== ">") len = 1;
445
+ else if (ch === "&" && prev !== ">" && next !== ">") len = 1;
446
+ if (!len) continue;
447
+ out2.push(raw.slice(last, i));
448
+ i += len - 1;
449
+ last = i + 1;
450
+ }
451
+ out2.push(raw.slice(last));
452
+ return out2.map((s) => s.trim()).filter(Boolean);
419
453
  }
420
454
  function tokens(seg) {
421
455
  return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
@@ -693,6 +727,9 @@ function defaultPolicy(version = 1) {
693
727
  hooks: [
694
728
  ".husky/**",
695
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.*",
696
733
  ".tamperward.yml",
697
734
  "**/.tamperward.yml",
698
735
  ".claude/settings.json",
@@ -1149,6 +1186,50 @@ function trackedFiles(ctx) {
1149
1186
  return files;
1150
1187
  }
1151
1188
  var branchCache = /* @__PURE__ */ new Map();
1189
+ var refsCache = /* @__PURE__ */ new Map();
1190
+ function branchRefs(ctx) {
1191
+ if (!ctx?.cwd) return null;
1192
+ const hit = refsCache.get(ctx.cwd);
1193
+ if (hit !== void 0) return hit;
1194
+ let refs = null;
1195
+ try {
1196
+ const out2 = execFileSync2("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
1197
+ cwd: ctx.cwd,
1198
+ encoding: "utf8",
1199
+ stdio: ["ignore", "pipe", "pipe"]
1200
+ });
1201
+ refs = new Set(out2.split("\n").map((l) => l.trim()).filter(Boolean));
1202
+ } catch {
1203
+ refs = null;
1204
+ }
1205
+ refsCache.set(ctx.cwd, refs);
1206
+ return refs;
1207
+ }
1208
+ function branchExists(name, ctx) {
1209
+ const refs = branchRefs(ctx);
1210
+ if (!refs) return null;
1211
+ if (refs.has(name)) return true;
1212
+ for (const r of refs) if (r.endsWith("/" + name)) return true;
1213
+ return false;
1214
+ }
1215
+ function revIsHead(rev, ctx) {
1216
+ if (!ctx?.cwd || rev.startsWith("-")) return null;
1217
+ const parse3 = (r) => {
1218
+ try {
1219
+ return execFileSync2("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1220
+ cwd: ctx.cwd,
1221
+ encoding: "utf8",
1222
+ stdio: ["ignore", "pipe", "pipe"]
1223
+ }).trim();
1224
+ } catch {
1225
+ return null;
1226
+ }
1227
+ };
1228
+ const head = parse3("HEAD");
1229
+ const target = parse3(rev);
1230
+ if (!head || !target) return null;
1231
+ return head === target;
1232
+ }
1152
1233
  function defaultBranch(ctx) {
1153
1234
  if (!ctx?.cwd) return null;
1154
1235
  const hit = branchCache.get(ctx.cwd);
@@ -1168,15 +1249,16 @@ function defaultBranch(ctx) {
1168
1249
  return name;
1169
1250
  }
1170
1251
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1171
- function containsProtected(token, policy, category, ctx) {
1252
+ function containsProtected(token, policy, category, ctx, except) {
1172
1253
  const dir = dirOf(token);
1173
1254
  if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1255
+ const counts = (p) => isProtected(p, policy, category) && !(except && isProtected(p, policy, except));
1174
1256
  const files = trackedFiles(ctx);
1175
1257
  if (files) {
1176
1258
  const prefix = dir + "/";
1177
- return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1259
+ return files.some((f) => f.startsWith(prefix) && counts(f));
1178
1260
  }
1179
- if (isProtected(dir + "/probe", policy, category)) return true;
1261
+ if (counts(dir + "/probe")) return true;
1180
1262
  const base = dir.split("/").pop() ?? "";
1181
1263
  return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1182
1264
  }
@@ -1279,6 +1361,10 @@ function parseThresholds(src) {
1279
1361
  if (!ts2.isPropertyAssignment(p)) continue;
1280
1362
  const k = keyName(p.name);
1281
1363
  if (k === null || isMetric(k)) continue;
1364
+ if (key2 === "thresholds" && k === "100" && p.initializer.kind === ts2.SyntaxKind.TrueKeyword) {
1365
+ res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
1366
+ continue;
1367
+ }
1282
1368
  const m = metricsOf(p.initializer, resolve5);
1283
1369
  if (!m) continue;
1284
1370
  if (k === "global") res.global = merge(res.global, m);
@@ -1372,7 +1458,7 @@ function sectionText(src, key2) {
1372
1458
  return "";
1373
1459
  }
1374
1460
  }
1375
- 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;
1461
+ 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;
1376
1462
  function compareMetrics(scope, before, after, out2) {
1377
1463
  if (!before) return;
1378
1464
  for (const m of METRICS) {
@@ -1516,20 +1602,28 @@ var SIMPLE_FILES = [
1516
1602
  ];
1517
1603
  var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
1518
1604
  var numOf = (s) => s === "auto" ? "auto" : Number(s);
1519
- function numbersOf(src, key2, scoped = false) {
1520
- const out2 = [];
1605
+ function* scopedLines(src) {
1521
1606
  const stack = [];
1522
1607
  for (const line of src.split("\n")) {
1523
- let scope = "";
1524
- if (scoped) {
1525
- const m = line.match(/^(\s*)([\w-]+):/);
1526
- if (m) {
1527
- const ind = m[1].length;
1528
- while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1529
- stack.push({ ind, key: m[2] });
1530
- }
1531
- scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1608
+ const m = line.match(/^(\s*)([\w-]+):/);
1609
+ if (m) {
1610
+ const ind = m[1].length;
1611
+ while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1612
+ stack.push({ ind, key: m[2] });
1532
1613
  }
1614
+ const scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1615
+ yield [line, scope];
1616
+ }
1617
+ }
1618
+ function scopedKeys(src, key2) {
1619
+ const out2 = [];
1620
+ for (const [line, scope] of scopedLines(src)) if (key2.test(line)) out2.push(scope);
1621
+ return out2;
1622
+ }
1623
+ function numbersOf(src, key2, scoped = false) {
1624
+ const out2 = [];
1625
+ for (const [line, inScope] of scopedLines(src)) {
1626
+ const scope = scoped ? inScope : "";
1533
1627
  if (key2.global) {
1534
1628
  for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
1535
1629
  } else {
@@ -1605,9 +1699,12 @@ function simpleNarrowings(path, before, after) {
1605
1699
  for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
1606
1700
  }
1607
1701
  if (CODECOV.test(path)) {
1608
- const off = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1609
- const had = off(before);
1610
- for (const s of off(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1702
+ const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1703
+ const had = off2(before);
1704
+ for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1705
+ const informational = (s) => new Set(scopedKeys(s, /^\s*informational:\s*true\b/).filter((scope) => scope !== "patch" && scope !== "changes"));
1706
+ const was = informational(before);
1707
+ 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`);
1611
1708
  }
1612
1709
  return out2;
1613
1710
  }
@@ -1651,13 +1748,14 @@ var SWITCHES = [
1651
1748
  re: /"?check-coverage"?\s*[:=]\s*false\b/,
1652
1749
  message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1653
1750
  remediation: "Keep check-coverage on; raise real coverage instead."
1654
- },
1655
- {
1656
- re: /^\s*informational:\s*true\b/,
1657
- message: "informational: true added \u2014 the codecov status can no longer fail.",
1658
- remediation: "Remove informational; a status that cannot fail is not a gate."
1659
1751
  }
1660
1752
  ];
1753
+ function dirHasSpec(configPath, policy, ctx) {
1754
+ const files = trackedFiles(ctx);
1755
+ if (!files) return null;
1756
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1757
+ return files.some((f) => f.startsWith(root) && isProtected(f, policy, "tests"));
1758
+ }
1661
1759
  var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1662
1760
  function gateMovedElsewhere(c, changes, policy) {
1663
1761
  const before = c.before ?? "";
@@ -1750,6 +1848,7 @@ var coverageLowering = {
1750
1848
  for (const s of SWITCHES) {
1751
1849
  if (!s.re.test(l.content)) continue;
1752
1850
  if (removed.some((r) => s.re.test(r.content))) continue;
1851
+ if (/passWithNoTests/.test(s.re.source) && dirHasSpec(c.path, policy, ctx) === false) continue;
1753
1852
  out2.push(
1754
1853
  makeFinding(RULE4, policy, {
1755
1854
  file: c.path,
@@ -1801,7 +1900,7 @@ var RULE5 = "ci-tampering";
1801
1900
  var USES = /^\s*-\s*uses:/;
1802
1901
  var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
1803
1902
  var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
1804
- var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward)\b/;
1903
+ 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/;
1805
1904
  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/;
1806
1905
  function isActiveWorkflow(path) {
1807
1906
  return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
@@ -1810,8 +1909,19 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1810
1909
  function commandCore(line) {
1811
1910
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1812
1911
  }
1912
+ function foldExpressions(s) {
1913
+ return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
1914
+ const v = foldConst(e.trim());
1915
+ if (v !== void 0 && v !== TRUTHY) return String(v);
1916
+ return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
1917
+ });
1918
+ }
1919
+ var EXPR_TOKEN = "__expr__";
1920
+ var MATRIX_TOKEN = "__matrix__";
1921
+ var MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
1813
1922
  function canonical(core) {
1814
- let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
1923
+ let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
1924
+ s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
1815
1925
  s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1816
1926
  s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1817
1927
  s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
@@ -1841,8 +1951,8 @@ function checkKind(core) {
1841
1951
  if (!invokesCheck(core)) return null;
1842
1952
  const s = canonical(core);
1843
1953
  if (/\btamperward\b/.test(s)) return "gate";
1844
- if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
1845
- if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
1954
+ if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
1955
+ if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
1846
1956
  return "test";
1847
1957
  }
1848
1958
  function survives(removedCore, isUses, afterCores, addedCores) {
@@ -1851,13 +1961,15 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1851
1961
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1852
1962
  }
1853
1963
  const r = canonical(removedCore);
1964
+ const kind = checkKind(removedCore);
1965
+ const narrowed = (args) => kind === "test" && narrowedByPositional(args);
1854
1966
  let neutralised;
1855
1967
  for (const raw of afterCores) {
1856
1968
  const a = canonical(raw);
1857
1969
  if (a === r) return { state: "kept" };
1858
1970
  if (a.startsWith(r + " ")) {
1859
1971
  const rest = a.slice(r.length);
1860
- if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
1972
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest)) neutralised ??= raw;
1861
1973
  else return { state: "kept" };
1862
1974
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1863
1975
  return { state: "kept" };
@@ -1866,7 +1978,6 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1866
1978
  if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
1867
1979
  }
1868
1980
  }
1869
- const kind = checkKind(removedCore);
1870
1981
  let respelled = false;
1871
1982
  for (const raw of addedCores) {
1872
1983
  if (!kind || checkKind(raw) !== kind) continue;
@@ -1876,7 +1987,7 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1876
1987
  continue;
1877
1988
  }
1878
1989
  const args = a.slice(a.indexOf(" ") + 1 || a.length);
1879
- if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
1990
+ if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowed(args))) neutralised ??= raw;
1880
1991
  else respelled = true;
1881
1992
  }
1882
1993
  if (respelled) return { state: "kept" };
@@ -2048,6 +2159,7 @@ function isTruthy(raw) {
2048
2159
  const f = foldConst(m[1]);
2049
2160
  return f !== void 0 && truthy(f);
2050
2161
  }
2162
+ var SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
2051
2163
  var indentOf = (l) => l.match(/^\s*/)[0].length;
2052
2164
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
2053
2165
  function stepOf(lines, i) {
@@ -2073,8 +2185,10 @@ function jobOf(lines, i) {
2073
2185
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
2074
2186
  return [h, e];
2075
2187
  }
2076
- var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
2077
- 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));
2188
+ var REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
2189
+ var usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
2190
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l));
2191
+ var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
2078
2192
  function runBlocks(lines) {
2079
2193
  const out2 = [];
2080
2194
  for (let i = 0; i < lines.length; i++) {
@@ -2121,8 +2235,8 @@ function parseTriggers(src) {
2121
2235
  const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
2122
2236
  const inline = uncommented(head);
2123
2237
  if (inline) {
2124
- const list = flowList(inline);
2125
- for (const e of list ?? [inline]) t.events.add(e);
2238
+ const list2 = flowList(inline);
2239
+ for (const e of list2 ?? [inline]) t.events.add(e);
2126
2240
  return t;
2127
2241
  }
2128
2242
  let event = null;
@@ -2153,8 +2267,8 @@ function parseTriggers(src) {
2153
2267
  if (event && key2) {
2154
2268
  listKey = key2;
2155
2269
  listInd = ind;
2156
- const list = flowList(kv[2]);
2157
- t[listKey].set(event, list ?? []);
2270
+ const list2 = flowList(kv[2]);
2271
+ t[listKey].set(event, list2 ?? []);
2158
2272
  } else listKey = null;
2159
2273
  }
2160
2274
  return t;
@@ -2195,31 +2309,32 @@ function triggerNarrowings(before, after, opts) {
2195
2309
  for (const e of CODE_EVENTS) {
2196
2310
  if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
2197
2311
  }
2198
- for (const [e, list] of after.pathsIgnore) {
2312
+ for (const [e, list2] of after.pathsIgnore) {
2199
2313
  const had = before.pathsIgnore.get(e) ?? [];
2200
- if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2314
+ if (list2.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2201
2315
  }
2202
- for (const [e, list] of after.paths) {
2316
+ for (const [e, list2] of after.paths) {
2203
2317
  if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
2204
2318
  const had = before.paths.get(e);
2205
2319
  if (had && !pathsMatchSource(had, opts.sources)) continue;
2206
- if (!pathsMatchSource(list, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list.join(", ")}]) \u2014 the workflow never runs on code`);
2320
+ if (!pathsMatchSource(list2, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list2.join(", ")}]) \u2014 the workflow never runs on code`);
2207
2321
  }
2208
- for (const [e, list] of after.types) {
2322
+ for (const [e, list2] of after.types) {
2209
2323
  if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
2210
2324
  const had = before.types.get(e);
2211
2325
  if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
2212
- if (!list.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
2326
+ if (!list2.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list2.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
2213
2327
  }
2214
- for (const [e, list] of after.branchesIgnore) {
2328
+ for (const [e, list2] of after.branchesIgnore) {
2215
2329
  const had = before.branchesIgnore.get(e) ?? [];
2216
- if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
2330
+ if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
2217
2331
  }
2218
2332
  for (const [e, was] of before.branches) {
2219
2333
  const now = after.branches.get(e);
2220
2334
  if (!now || !after.events.has(e)) continue;
2221
2335
  if (now.some(ALL)) continue;
2222
- const lostDefault = namesDefault(was) && !namesDefault(now);
2336
+ const renamed = (b) => DEFAULT_BRANCH.test(b) && opts.hasBranch(b) !== false;
2337
+ const lostDefault = namesDefault(was) && !namesDefault(now) && !now.some(renamed);
2223
2338
  const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
2224
2339
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
2225
2340
  }
@@ -2305,6 +2420,7 @@ var ciTampering = {
2305
2420
  }
2306
2421
  for (const l of addedLines(c)) {
2307
2422
  if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
2423
+ if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
2308
2424
  const step = stepOf(afterLines, l.newLine - 1);
2309
2425
  if (step && !stepHasCheck(afterLines, step)) continue;
2310
2426
  out2.push(
@@ -2318,7 +2434,7 @@ var ciTampering = {
2318
2434
  );
2319
2435
  }
2320
2436
  if (c.before != null) {
2321
- triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
2437
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
2322
2438
  for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
2323
2439
  out2.push(
2324
2440
  makeFinding(RULE5, policy, {
@@ -2343,7 +2459,7 @@ var ciTampering = {
2343
2459
  }
2344
2460
  for (const l of removedLines(c)) {
2345
2461
  if (/^\s*#/.test(l.content)) continue;
2346
- const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
2462
+ const isUsesLine = usesCheck(l.content);
2347
2463
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
2348
2464
  if (!isUsesLine && !isCheckCommand) continue;
2349
2465
  const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
@@ -2374,12 +2490,59 @@ var ciTampering = {
2374
2490
  };
2375
2491
 
2376
2492
  // src/detectors/hook-tampering.ts
2377
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
2378
- import { isAbsolute, join as join2, relative } from "node:path";
2493
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
2494
+ import { isAbsolute, join as join3, relative } from "node:path";
2495
+
2496
+ // src/wiring.ts
2497
+ import { readFileSync as readFileSync2 } from "node:fs";
2498
+ import { dirname, join as join2 } from "node:path";
2499
+ import { fileURLToPath } from "node:url";
2500
+ function shippedVersion() {
2501
+ try {
2502
+ const here = dirname(fileURLToPath(import.meta.url));
2503
+ for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2504
+ try {
2505
+ const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
2506
+ if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2507
+ } catch {
2508
+ }
2509
+ }
2510
+ } catch {
2511
+ }
2512
+ return "latest";
2513
+ }
2514
+ var TW_VERSION = shippedVersion();
2515
+ var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
2516
+ var PRE_MATCHER = PRE_TOOLS.join("|");
2517
+ var MARKER = "# tamperward: block agent shortcuts before they land";
2518
+ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2519
+ var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2520
+ var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2521
+ var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2522
+ var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
2523
+ function compareVersions(a, b) {
2524
+ if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
2525
+ const pa = a.split(".").map(Number);
2526
+ const pb = b.split(".").map(Number);
2527
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
2528
+ return 0;
2529
+ }
2530
+ function pinNotBelow(now, floor) {
2531
+ if (!now) return false;
2532
+ const c = compareVersions(now, floor);
2533
+ return c !== null && c >= 0;
2534
+ }
2535
+ var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
2536
+ function initScriptPin(src) {
2537
+ const m = src.match(INIT_SCRIPT);
2538
+ return m ? m[1] ?? "" : null;
2539
+ }
2540
+ var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
2541
+ var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
2379
2542
 
2380
2543
  // src/detectors/policy-diff.ts
2381
2544
  import { parse } from "yaml";
2382
- var globs2 = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2545
+ var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2383
2546
  function safeParse(src) {
2384
2547
  try {
2385
2548
  const v = parse(src);
@@ -2398,7 +2561,7 @@ function effective(raw) {
2398
2561
  const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
2399
2562
  rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
2400
2563
  }
2401
- const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs2(list)])) : void 0;
2564
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
2402
2565
  return {
2403
2566
  version,
2404
2567
  rules,
@@ -2519,7 +2682,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
2519
2682
  var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
2520
2683
  var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
2521
2684
  var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
2522
- var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2685
+ var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2523
2686
  function checkIdentity(segment) {
2524
2687
  const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
2525
2688
  const viaRunner = RUNNER.test(core);
@@ -2530,20 +2693,49 @@ function checkIdentity(segment) {
2530
2693
  if (tool === "tamperward") {
2531
2694
  const sub = toks[1];
2532
2695
  if (!sub || sub.startsWith("-")) return null;
2533
- return `tamperward ${sub}`;
2696
+ const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
2697
+ return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
2534
2698
  }
2535
2699
  if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
2536
2700
  return tool;
2537
2701
  }
2702
+ function pinOfSegment(segment) {
2703
+ const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
2704
+ return m ? m[1] ?? "" : null;
2705
+ }
2538
2706
  function split(stmt) {
2539
2707
  const segs = [];
2540
2708
  const ops = [];
2541
- const re = /(&&|\|\||;|\|)/g;
2709
+ let depth = 0;
2710
+ let single = false;
2711
+ let double = false;
2542
2712
  let last = 0;
2543
- for (const m of stmt.matchAll(re)) {
2544
- segs.push(stmt.slice(last, m.index));
2545
- ops.push(m[1]);
2546
- last = (m.index ?? 0) + m[1].length;
2713
+ for (let i = 0; i < stmt.length; i++) {
2714
+ const ch = stmt[i];
2715
+ if (ch === "'" && !double) {
2716
+ single = !single;
2717
+ continue;
2718
+ }
2719
+ if (ch === '"' && !single) {
2720
+ double = !double;
2721
+ continue;
2722
+ }
2723
+ if (single || double) continue;
2724
+ if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
2725
+ depth++;
2726
+ continue;
2727
+ }
2728
+ if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
2729
+ depth = Math.max(0, depth - 1);
2730
+ continue;
2731
+ }
2732
+ if (depth > 0) continue;
2733
+ const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
2734
+ if (!op) continue;
2735
+ segs.push(stmt.slice(last, i));
2736
+ ops.push(op);
2737
+ i += op.length - 1;
2738
+ last = i + 1;
2547
2739
  }
2548
2740
  segs.push(stmt.slice(last));
2549
2741
  return { segs, ops };
@@ -2592,106 +2784,602 @@ function alwaysFalse(cond) {
2592
2784
  if (/^(?:""|'')$/.test(inner)) return true;
2593
2785
  return false;
2594
2786
  }
2595
- var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
2787
+ var alwaysTrue = (cond) => {
2788
+ const c = cond.trim().replace(/;$/, "").trim();
2789
+ if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
2790
+ const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
2791
+ if (!m) return false;
2792
+ const inner = (m[1] ?? m[2] ?? m[3]).trim();
2793
+ if (inner.includes("$")) return false;
2794
+ const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
2795
+ if (parts) {
2796
+ const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
2797
+ if (parts[2].startsWith("-") && !ints) return false;
2798
+ return !alwaysFalse(c);
2799
+ }
2800
+ if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
2801
+ if (/^\S+$/.test(inner)) return unq(inner) !== "";
2802
+ return false;
2803
+ };
2596
2804
  var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
2597
2805
  var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
2598
- var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
2599
2806
  function insertsPassingExit(line) {
2600
2807
  if (isComment(line)) return false;
2601
- return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
2808
+ return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
2602
2809
  }
2603
2810
  function insertsDeadGuard(line) {
2604
2811
  if (isComment(line)) return false;
2605
2812
  const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2606
2813
  return !!m && alwaysFalse(m[1]);
2607
2814
  }
2608
- function invocations(lines) {
2815
+ function joinContinued(lines) {
2609
2816
  const out2 = [];
2610
- const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
2611
- const frames = [{ dead: false }];
2817
+ let cur = null;
2818
+ for (const l of lines) {
2819
+ cur = cur === null ? l : cur + " " + l.trimStart();
2820
+ const m = cur.match(/(\\+)$/);
2821
+ if (m && m[1].length % 2 === 1 && !isComment(cur)) {
2822
+ cur = cur.slice(0, -1);
2823
+ continue;
2824
+ }
2825
+ out2.push(cur);
2826
+ cur = null;
2827
+ }
2828
+ if (cur !== null) out2.push(cur);
2829
+ return out2;
2830
+ }
2831
+ function stripHeredocs(lines) {
2832
+ const out2 = [];
2833
+ for (let i = 0; i < lines.length; i++) {
2834
+ const l = lines[i];
2835
+ out2.push(l);
2836
+ if (isComment(l)) continue;
2837
+ const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
2838
+ if (!m) continue;
2839
+ const word = m[2] ?? m[3] ?? m[4];
2840
+ const dash = m[1] === "-";
2841
+ for (i++; i < lines.length; i++) {
2842
+ const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
2843
+ if (t === word) break;
2844
+ }
2845
+ }
2846
+ return out2;
2847
+ }
2848
+ function explodeKeywords(line) {
2849
+ if (isComment(line)) return [line];
2850
+ let s = stripComment(line);
2851
+ s = s.replace(/;;/g, "\n;;");
2852
+ s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
2853
+ s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
2854
+ s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
2855
+ s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
2856
+ return s.split("\n");
2857
+ }
2858
+ function closingBrace(line, depth) {
2859
+ let single = false;
2860
+ let double = false;
2861
+ let expansion = 0;
2862
+ for (let i = 0; i < line.length; i++) {
2863
+ const ch = line[i];
2864
+ if (ch === "'" && !double) single = !single;
2865
+ else if (ch === '"' && !single) double = !double;
2866
+ else if (single) continue;
2867
+ else if (ch === "$" && line[i + 1] === "{") {
2868
+ expansion++;
2869
+ i++;
2870
+ } else if (ch === "}" && expansion > 0) expansion--;
2871
+ else if (ch === "{" && !double) depth++;
2872
+ else if (ch === "}" && !double && --depth === 0) return i;
2873
+ }
2874
+ return -1;
2875
+ }
2876
+ function braceDelta(line) {
2877
+ let d = 0;
2878
+ let single = false;
2879
+ let double = false;
2880
+ let expansion = 0;
2881
+ for (let i = 0; i < line.length; i++) {
2882
+ const ch = line[i];
2883
+ if (ch === "'" && !double) single = !single;
2884
+ else if (ch === '"' && !single) double = !double;
2885
+ else if (single) continue;
2886
+ else if (ch === "$" && line[i + 1] === "{") {
2887
+ expansion++;
2888
+ i++;
2889
+ } else if (ch === "}" && expansion > 0) expansion--;
2890
+ else if (ch === "{" && !double) d++;
2891
+ else if (ch === "}" && !double) d--;
2892
+ }
2893
+ return d;
2894
+ }
2895
+ var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
2896
+ function prepare(lines, opts) {
2897
+ const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
2898
+ const top = [];
2899
+ const fns = /* @__PURE__ */ new Map();
2900
+ const shadowed = /* @__PURE__ */ new Set();
2901
+ let errexit = opts.errexit ?? false;
2902
+ let pipefail = false;
2903
+ if (flat[0]?.startsWith("#!")) {
2904
+ const sb = flat[0];
2905
+ if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
2906
+ if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
2907
+ }
2908
+ for (let i = 0; i < flat.length; i++) {
2909
+ const raw = flat[i];
2910
+ if (isComment(raw)) {
2911
+ top.push(raw);
2912
+ continue;
2913
+ }
2914
+ const stmt = stripComment(raw).trim();
2915
+ const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
2916
+ if (al) shadowed.add(al[1]);
2917
+ const def = stmt.match(FN_DEF);
2918
+ if (!def) {
2919
+ top.push(raw);
2920
+ continue;
2921
+ }
2922
+ const name = def[1] ?? def[3];
2923
+ shadowed.add(name);
2924
+ let rest = (def[2] ?? def[4] ?? "").trim();
2925
+ if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
2926
+ i++;
2927
+ rest = flat[i].trim();
2928
+ }
2929
+ const body = [];
2930
+ if (!rest.startsWith("{")) {
2931
+ fns.set(name, body);
2932
+ continue;
2933
+ }
2934
+ rest = rest.slice(1);
2935
+ const after = (line, close2) => {
2936
+ const t = line.slice(0, close2);
2937
+ if (t.trim()) body.push(t);
2938
+ const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
2939
+ if (remainder.trim()) top.push(remainder);
2940
+ };
2941
+ let close = closingBrace(rest, 1);
2942
+ if (close !== -1) {
2943
+ after(rest, close);
2944
+ } else {
2945
+ if (rest.trim()) body.push(rest);
2946
+ let depth = 1 + braceDelta(rest);
2947
+ for (i++; i < flat.length; i++) {
2948
+ const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
2949
+ close = closingBrace(line, depth);
2950
+ if (close !== -1) {
2951
+ after(line, close);
2952
+ break;
2953
+ }
2954
+ depth += braceDelta(line);
2955
+ body.push(flat[i]);
2956
+ }
2957
+ }
2958
+ fns.set(name, body);
2959
+ }
2960
+ return { top, fns, shadowed, pipefail, errexit };
2961
+ }
2962
+ var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
2963
+ function statusAfterFailure(seg, vars) {
2964
+ const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
2965
+ let status = "fail";
2966
+ for (const piece of inner.split(/;|&&|\|\||\|/)) {
2967
+ const w = piece.trim().replace(PREFIX, "").split(/\s+/);
2968
+ switch (w[0]) {
2969
+ case "":
2970
+ break;
2971
+ case "true":
2972
+ case ":":
2973
+ status = "ok";
2974
+ break;
2975
+ case "false":
2976
+ status = "fail";
2977
+ break;
2978
+ case "kill":
2979
+ status = w[1] === "-0" ? "ok" : "exit";
2980
+ break;
2981
+ case "exit":
2982
+ case "return": {
2983
+ const arg = unq(w[1] ?? "");
2984
+ if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
2985
+ if (/^0+$/.test(arg)) return "ok";
2986
+ if (/^[1-9]\d*$/.test(arg)) return "exit";
2987
+ const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
2988
+ const known = v ? vars.get(v[1]) : void 0;
2989
+ return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
2990
+ }
2991
+ default:
2992
+ status = "ok";
2993
+ }
2994
+ }
2995
+ return status;
2996
+ }
2997
+ function constStatus(seg) {
2998
+ const s = seg.trim().replace(/^!\s+/, "");
2999
+ if (alwaysTrue(s)) return "ok";
3000
+ if (alwaysFalse(s)) return "fail";
3001
+ return "unknown";
3002
+ }
3003
+ function reaches(segs, ops, i) {
3004
+ let status = "ok";
3005
+ let certain = true;
3006
+ for (let j = 0; j < i; j++) {
3007
+ const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
3008
+ if (!runs) continue;
3009
+ const s = constStatus(segs[j]);
3010
+ if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
3011
+ if (ops[j] === ";" || ops[j] === "|") {
3012
+ status = "ok";
3013
+ certain = certain && true;
3014
+ continue;
3015
+ }
3016
+ status = s;
3017
+ }
3018
+ const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
3019
+ if (!gate) return "no";
3020
+ if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
3021
+ if (!certain || status === "unknown") return "maybe";
3022
+ return "yes";
3023
+ }
3024
+ function literal(arg, vars) {
3025
+ const a = unq(arg.trim());
3026
+ if (/[`]|\$\(/.test(a)) return null;
3027
+ let unknown = false;
3028
+ const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
3029
+ const v = vars.get(n);
3030
+ if (v === void 0) unknown = true;
3031
+ return v ?? "";
3032
+ });
3033
+ return unknown ? null : r;
3034
+ }
3035
+ function cdStaysHome(arg, vars) {
3036
+ const a = arg.trim();
3037
+ if (/rev-parse\s+--show-toplevel/.test(a)) return true;
3038
+ const lit = literal(a, vars);
3039
+ if (lit === null) return null;
3040
+ return lit === "" || lit === "." || lit === "./";
3041
+ }
3042
+ function walk(lines, st, topIdx, depth) {
3043
+ const frames = [{ dead: false, exhausted: false, kind: "base" }];
2612
3044
  const dead = () => frames.some((f) => f.dead);
2613
- for (const raw of lines) {
3045
+ const conditional = () => frames.length > 1;
3046
+ let exits = false;
3047
+ for (let i = 0; i < lines.length; i++) {
3048
+ const raw = lines[i];
3049
+ const idx = topIdx(i);
2614
3050
  if (isComment(raw)) {
2615
3051
  const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
2616
- if (id) out2.push({ identity: id, line: raw, state: "comment" });
3052
+ if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
2617
3053
  continue;
2618
3054
  }
2619
- const stmt = stripComment(raw).trim();
3055
+ let stmt = stripComment(raw).trim();
2620
3056
  if (!stmt) continue;
2621
- const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
3057
+ const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
2622
3058
  if (ifm) {
2623
- if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
2624
- else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
3059
+ const cond = ifm[2].replace(/;\s*$/, "");
3060
+ const cid = checkIdentity(cond.replace(/^!\s+/, ""));
3061
+ if (cid) st.out.push({ identity: cid, line: raw, state: dead() ? "unreachable" : "neutered", why: "used as an `if` condition \u2014 its failure selects a branch instead of failing the hook", idx, errexit: st.errexit, passthrough: false });
3062
+ if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
3063
+ else if (frames.length > 1) {
3064
+ const f = frames[frames.length - 1];
3065
+ f.dead = f.exhausted || alwaysFalse(cond);
3066
+ f.exhausted = f.exhausted || alwaysTrue(cond);
3067
+ }
2625
3068
  continue;
2626
3069
  }
2627
3070
  if (/^else\b/.test(stmt)) {
2628
- if (frames.length > 1) frames[frames.length - 1].dead = false;
3071
+ if (frames.length > 1) {
3072
+ const f = frames[frames.length - 1];
3073
+ f.dead = f.exhausted;
3074
+ }
2629
3075
  continue;
2630
3076
  }
2631
- if (/^fi\b/.test(stmt)) {
3077
+ if (/^(?:fi|done|esac)\b/.test(stmt)) {
2632
3078
  if (frames.length > 1) frames.pop();
2633
3079
  continue;
2634
3080
  }
3081
+ if (/^(?:then|do)\b/.test(stmt)) continue;
3082
+ const loop = stmt.match(/^(while|until)\s+(.+)$/);
3083
+ if (loop) {
3084
+ const cond = loop[2].replace(/;\s*$/, "");
3085
+ frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
3086
+ continue;
3087
+ }
3088
+ const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
3089
+ if (forl) {
3090
+ const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
3091
+ frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
3092
+ continue;
3093
+ }
3094
+ const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
3095
+ if (casem) {
3096
+ frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
3097
+ continue;
3098
+ }
3099
+ const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
3100
+ if (pat) {
3101
+ const f = frames[frames.length - 1];
3102
+ const word = f.word ?? "";
3103
+ const patterns = pat[1].split("|").map((p) => unq(p.trim()));
3104
+ const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
3105
+ f.dead = !may;
3106
+ stmt = pat[2].trim();
3107
+ if (!stmt) continue;
3108
+ }
3109
+ if (/^;;$/.test(stmt)) continue;
3110
+ if (/^[{}()]$/.test(stmt)) continue;
3111
+ const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
3112
+ if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
2635
3113
  const { segs, ops } = split(stmt);
2636
- for (let i = 0; i < segs.length; i++) {
2637
- const seg = segs[i].trim().replace(PREFIX, "");
3114
+ for (let s = 0; s < segs.length; s++) {
3115
+ let seg = segs[s].trim();
3116
+ const inverted = /^!\s+/.test(seg);
3117
+ seg = seg.replace(/^!\s+/, "");
3118
+ const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
3119
+ seg = seg.replace(PREFIX, "");
3120
+ const reach = dead() ? "no" : reaches(segs, ops, s);
3121
+ const first = seg.split(/\s+/)[0] ?? "";
3122
+ if (reach !== "no") {
3123
+ const certain = reach === "yes" && !conditional();
3124
+ const set = seg.match(/^set\s+(.+)$/);
3125
+ if (set) {
3126
+ const toks = set[1].split(/\s+/);
3127
+ for (let t = 0; t < toks.length; t++) {
3128
+ const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
3129
+ if (!m) continue;
3130
+ const on = m[1] === "-";
3131
+ const flip = (cur) => on ? certain ? true : cur : false;
3132
+ if (m[2].includes("e")) st.errexit = flip(st.errexit);
3133
+ if (m[2].includes("o")) {
3134
+ const name = toks[++t];
3135
+ if (name === "errexit") st.errexit = flip(st.errexit);
3136
+ if (name === "pipefail") st.pipefail = flip(st.pipefail);
3137
+ }
3138
+ }
3139
+ }
3140
+ const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
3141
+ if (trap) {
3142
+ const action = trap[1] ?? trap[2] ?? trap[3];
3143
+ const sigs = trap[4].split(/\s+/);
3144
+ if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
3145
+ }
3146
+ const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
3147
+ if (asg) {
3148
+ const v = certain ? literal(asg[2], st.vars) : null;
3149
+ if (v === null) st.vars.delete(asg[1]);
3150
+ else st.vars.set(asg[1], v);
3151
+ }
3152
+ if (first === "cd") {
3153
+ const home = cdStaysHome(seg.slice(2), st.vars);
3154
+ if (home === false) st.cdAway = true;
3155
+ else if (home === true && certain) st.cdAway = false;
3156
+ }
3157
+ }
2638
3158
  const id = checkIdentity(seg);
2639
3159
  if (!id) {
2640
- if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
3160
+ if (reach === "yes") {
3161
+ if (EXIT_ANY.test(seg)) {
3162
+ if (!conditional()) {
3163
+ frames[0].dead = true;
3164
+ if (/^exit\b/.test(seg)) exits = true;
3165
+ } else if (/^exit\b/.test(seg) && depth > 0) {
3166
+ }
3167
+ } else if (execs && !conditional()) {
3168
+ frames[0].dead = true;
3169
+ } else if (st.fns.has(first) && reach === "yes") {
3170
+ const start = st.out.length;
3171
+ const body = st.fns.get(first) ?? [];
3172
+ const sub = { ...st, out: st.out };
3173
+ const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
3174
+ st.errexit = sub.errexit;
3175
+ st.pipefail = sub.pipefail;
3176
+ st.cdAway = sub.cdAway;
3177
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3178
+ for (let k = start; k < st.out.length; k++) {
3179
+ const inv = st.out[k];
3180
+ if (inv.state === "live" && verdict2.state !== "live") {
3181
+ inv.state = verdict2.state;
3182
+ inv.why = verdict2.why;
3183
+ }
3184
+ if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
3185
+ }
3186
+ if (fnExits && !conditional()) {
3187
+ frames[0].dead = true;
3188
+ exits = true;
3189
+ }
3190
+ }
3191
+ } else if (reach === "maybe" && st.fns.has(first)) {
3192
+ const start = st.out.length;
3193
+ const sub = { ...st, out: st.out };
3194
+ if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
3195
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3196
+ for (let k = start; k < st.out.length; k++) {
3197
+ const inv = st.out[k];
3198
+ if (inv.state === "live" && verdict2.state !== "live") {
3199
+ inv.state = verdict2.state;
3200
+ inv.why = verdict2.why;
3201
+ }
3202
+ }
3203
+ }
2641
3204
  continue;
2642
3205
  }
2643
- let state = dead() ? "unreachable" : "live";
2644
- if (state === "live" && i > 0) {
2645
- if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
2646
- if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
2647
- }
2648
- if (state === "live" && i < ops.length) {
2649
- const after = segs.slice(i + 1).join(" ");
2650
- if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
2651
- else if (ops[i] === "|" && !pipefail) state = "neutered";
2652
- else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
3206
+ let state = "live";
3207
+ let why;
3208
+ let passthrough = false;
3209
+ if (reach === "no") {
3210
+ state = "unreachable";
3211
+ why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
3212
+ } else {
3213
+ const v = chainVerdict(segs, ops, s, st, inverted, bg);
3214
+ state = v.state;
3215
+ why = v.why;
3216
+ passthrough = v.passthrough;
3217
+ if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
3218
+ state = "neutered";
3219
+ why = "runs from another directory after a `cd` away from the repository root";
3220
+ }
3221
+ if (state === "live") {
3222
+ const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
3223
+ if (words2.length) {
3224
+ state = "neutered";
3225
+ why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
3226
+ }
3227
+ }
2653
3228
  }
2654
- out2.push({ identity: id, line: raw, state });
3229
+ const pin = pinOfSegment(seg);
3230
+ st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
2655
3231
  }
2656
3232
  }
2657
- return out2;
3233
+ return exits;
3234
+ }
3235
+ function chainVerdict(segs, ops, i, st, inverted, bg) {
3236
+ if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
3237
+ if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
3238
+ let cur = "fail";
3239
+ for (let j = i; j < ops.length; j++) {
3240
+ const op = ops[j];
3241
+ const next = segs[j + 1];
3242
+ if (op === "|") {
3243
+ if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
3244
+ continue;
3245
+ }
3246
+ if (cur === "exit") break;
3247
+ if (op === "||") {
3248
+ if (cur === "fail") cur = statusAfterFailure(next, st.vars);
3249
+ } else if (op === "&&") {
3250
+ if (cur === "ok") cur = statusAfterFailure(next, st.vars);
3251
+ } else if (op === ";") {
3252
+ const rest = segs.slice(j + 1).map((x) => x.trim());
3253
+ if (st.errexit && cur === "fail") {
3254
+ if (rest.some((x) => /^(?:true|:)$/.test(x) || EXIT_ZERO.test(x))) return { state: "neutered", why: "followed by a passing statement in the same line", passthrough: false };
3255
+ return { state: "live", passthrough: false };
3256
+ }
3257
+ if (rest.every((x) => TRANSPARENT.test(x))) return { state: cur === "fail" ? "live" : "neutered", why: cur === "fail" ? void 0 : "the `||` chain after it ends in success", passthrough: cur === "fail" };
3258
+ return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
3259
+ }
3260
+ }
3261
+ if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
3262
+ if (cur === "exit") return { state: "live", passthrough: false };
3263
+ return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
3264
+ }
3265
+ function invocations(lines, opts = {}) {
3266
+ const prep = prepare(lines, opts);
3267
+ const st = {
3268
+ errexit: prep.errexit,
3269
+ pipefail: prep.pipefail,
3270
+ cdAway: false,
3271
+ vars: /* @__PURE__ */ new Map(),
3272
+ fns: prep.fns,
3273
+ shadowed: prep.shadowed,
3274
+ traps: [],
3275
+ out: [],
3276
+ lastReal: -1
3277
+ };
3278
+ for (let i = 0; i < prep.top.length; i++) {
3279
+ const t = stripComment(prep.top[i]).trim();
3280
+ if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
3281
+ }
3282
+ walk(prep.top, st, (i) => i, 0);
3283
+ const seen = new Set(st.out.map((o) => o.line));
3284
+ for (const [, body] of prep.fns) {
3285
+ const sub = { ...st, out: [], traps: [] };
3286
+ walk(body, sub, () => -1, 1);
3287
+ for (const inv of sub.out) if (!seen.has(inv.line) && inv.state !== "comment") st.out.push({ ...inv, state: "unreachable", why: "defined in a function that is never called" });
3288
+ }
3289
+ return st.out.map((inv) => {
3290
+ if (inv.state !== "live") return strip(inv);
3291
+ if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
3292
+ return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
3293
+ }
3294
+ if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
3295
+ return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
3296
+ }
3297
+ return strip(inv);
3298
+ });
2658
3299
  }
2659
- function scriptWeakening(before, after) {
3300
+ var strip = (inv) => {
3301
+ const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
3302
+ return rest;
3303
+ };
3304
+ function scriptWeakening(before, after, opts = {}) {
3305
+ const beforeInv = invocations(before, opts);
3306
+ const afterInv = invocations(after, opts);
2660
3307
  const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
2661
- const bl = live(invocations(before));
2662
- const afterInv = invocations(after);
3308
+ const bl = live(beforeInv);
2663
3309
  const al = live(afterInv);
2664
3310
  const out2 = [];
2665
3311
  for (const id of bl) {
2666
3312
  if (al.has(id)) continue;
2667
- const trace = afterInv.find((i) => i.identity === id);
2668
- const how = trace?.state === "neutered" ? "neutralised in place (its failure can no longer fail the hook)" : trace?.state === "unreachable" ? "made unreachable (behind an always-false guard or an early exit)" : trace?.state === "comment" ? "commented out" : "removed";
2669
- const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
3313
+ const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
3314
+ const how = trace?.state === "neutered" ? `neutralised in place (${trace.why ?? "its failure can no longer fail the hook"})` : trace?.state === "unreachable" ? `made unreachable (${trace.why ?? "behind an always-false guard or an early exit"})` : trace?.state === "comment" ? "commented out" : trace && trace.identity !== id ? `replaced by \`${trace.identity}\`` : "removed";
3315
+ const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
2670
3316
  out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
2671
3317
  }
3318
+ const floor = beforeInv.filter((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin)).map((i) => i.pin).sort((a, b) => compareVersions(a, b) ?? 0)[0];
3319
+ if (floor) {
3320
+ for (const inv of afterInv) {
3321
+ if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
3322
+ if ((compareVersions(inv.pin, floor) ?? 0) < 0) out2.push({ reason: `the gate's pin was lowered from ${floor} to ${inv.pin}`, evidence: inv.line.trim() });
3323
+ }
3324
+ }
2672
3325
  return out2;
2673
3326
  }
3327
+ function shebangProblem(src) {
3328
+ const first = src.split("\n")[0] ?? "";
3329
+ if (!first.startsWith("#!")) return null;
3330
+ const body = first.slice(2).trim();
3331
+ const toks = body.split(/\s+/);
3332
+ let i = 0;
3333
+ if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
3334
+ i = 1;
3335
+ while (i < toks.length && toks[i].startsWith("-")) i++;
3336
+ }
3337
+ const interp = (toks[i] ?? "").replace(/^.*\//, "");
3338
+ if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
3339
+ if (toks.slice(i + 1).some((t) => /^-[a-zA-Z]*c/.test(t))) return `the shebang passes \`-c\` \u2014 the interpreter runs the argument, not the script`;
3340
+ return null;
3341
+ }
2674
3342
  var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
2675
3343
  var GATE_CRITICAL = [
2676
- "/.github/workflows/",
3344
+ "/.github/workflows/tamperward.yml",
2677
3345
  "/.tamperward.yml",
2678
3346
  "/.github/CODEOWNERS",
2679
3347
  "/CODEOWNERS",
2680
3348
  "/docs/CODEOWNERS",
2681
- "/.husky/",
3349
+ "/.husky/pre-commit",
2682
3350
  "/.claude/settings.json",
2683
3351
  "/.claude/settings.local.json",
2684
3352
  "/.pre-commit-config.yaml",
2685
3353
  "/lefthook.yml",
2686
3354
  "/lefthook.yaml"
2687
3355
  ];
2688
- function covers(pattern, critical) {
2689
- if (pattern === "*" || pattern === "**") return true;
2690
- let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
2691
- if (!p.startsWith("/")) p = "/" + p;
2692
- if (p === critical) return true;
2693
- if (p.endsWith("/") && critical.startsWith(p)) return true;
2694
- return critical.startsWith(p + "/");
3356
+ function codeownersMatcher(pattern) {
3357
+ let p = pattern.trim();
3358
+ if (p === "*" || p === "**") return () => true;
3359
+ let anchored = p.startsWith("/");
3360
+ if (anchored) p = p.slice(1);
3361
+ const dir = p.endsWith("/");
3362
+ p = p.replace(/\/+$/, "");
3363
+ if (p.includes("/")) anchored = true;
3364
+ if (p.startsWith("**/")) {
3365
+ anchored = false;
3366
+ p = p.slice(3);
3367
+ }
3368
+ let re = "";
3369
+ for (let i = 0; i < p.length; i++) {
3370
+ const ch = p[i];
3371
+ if (ch === "*") {
3372
+ if (p[i + 1] === "*") {
3373
+ re += ".*";
3374
+ i++;
3375
+ } else re += "[^/]*";
3376
+ } else if (ch === "?") re += "[^/]";
3377
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3378
+ }
3379
+ const body = (anchored ? "^/" : "(?:^|/)") + re;
3380
+ const exact = new RegExp(body + "$");
3381
+ const under = new RegExp(body + "/");
3382
+ return (file) => !dir && exact.test(file) || under.test(file);
2695
3383
  }
2696
3384
  function ownersOf(content, critical) {
2697
3385
  let owners = null;
@@ -2699,13 +3387,20 @@ function ownersOf(content, critical) {
2699
3387
  const t = line.trim();
2700
3388
  if (!t || t.startsWith("#")) continue;
2701
3389
  const [pattern, ...rest] = t.split(/\s+/);
2702
- if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
3390
+ let matches;
3391
+ try {
3392
+ matches = codeownersMatcher(pattern)(critical);
3393
+ } catch {
3394
+ matches = false;
3395
+ }
3396
+ if (matches) owners = rest.filter((o) => !o.startsWith("#"));
2703
3397
  }
2704
3398
  return owners;
2705
3399
  }
2706
- function codeownersWeakening(before, after) {
3400
+ function codeownersWeakening(before, after, extra = []) {
2707
3401
  const out2 = [];
2708
- for (const critical of GATE_CRITICAL) {
3402
+ const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
3403
+ for (const critical of files) {
2709
3404
  const was = ownersOf(before, critical);
2710
3405
  if (!was || was.length === 0) continue;
2711
3406
  const now = ownersOf(after, critical);
@@ -2724,25 +3419,47 @@ function parseDoc(src) {
2724
3419
  }
2725
3420
  }
2726
3421
  var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
3422
+ var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
2727
3423
  var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
2728
3424
  var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
2729
3425
  var GATE = /\btamperward\b/;
2730
3426
  var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
3427
+ var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
3428
+ function runsGate(command) {
3429
+ return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
3430
+ }
3431
+ function mergeDocs(base, over) {
3432
+ const out2 = { ...base };
3433
+ for (const [k, v] of Object.entries(over)) {
3434
+ const b = out2[k];
3435
+ out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
3436
+ }
3437
+ return out2;
3438
+ }
3439
+ var off = (v) => v === void 0 || v === null || v === false;
2731
3440
  function lefthookEntries(doc) {
2732
3441
  const out2 = /* @__PURE__ */ new Map();
2733
3442
  for (const [section, v] of Object.entries(doc)) {
2734
3443
  if (!isDoc(v)) continue;
3444
+ const sectionExclude = list(v.exclude_tags);
2735
3445
  for (const group of ["commands", "scripts"]) {
2736
3446
  const g = v[group];
2737
3447
  if (!isDoc(g)) continue;
2738
3448
  for (const [name, cfg] of Object.entries(g)) {
2739
- if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
3449
+ if (!isDoc(cfg)) continue;
3450
+ const run = cfg.run ?? cfg.runner;
3451
+ const named = GATE.test(name);
3452
+ if (!GATE.test(str(run)) && !named) continue;
3453
+ const tags = list(cfg.tags);
2740
3454
  out2.set(`${section}.${group}.${name}`, {
3455
+ live: run === void 0 ? named : runsGate(str(run)),
2741
3456
  skip: cfg.skip,
3457
+ only: cfg.only,
2742
3458
  sectionSkip: v.skip,
3459
+ sectionOnly: v.only,
2743
3460
  glob: str(cfg.glob),
2744
3461
  exclude: str(cfg.exclude),
2745
- only: str(cfg.only)
3462
+ excludedByTag: tags.filter((t) => sectionExclude.includes(t))
2746
3463
  });
2747
3464
  }
2748
3465
  }
@@ -2754,33 +3471,46 @@ function lefthookWeakening(before, after) {
2754
3471
  const b = lefthookEntries(before);
2755
3472
  const a = lefthookEntries(after);
2756
3473
  for (const [key2, was] of b) {
3474
+ if (!was.live) continue;
2757
3475
  const now = a.get(key2);
2758
3476
  if (!now) {
2759
3477
  out2.push(`lefthook entry ${key2} (the gate) was removed`);
2760
3478
  continue;
2761
3479
  }
2762
- if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
2763
- if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
3480
+ if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
3481
+ if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
3482
+ if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
3483
+ if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
3484
+ if (!off(now.sectionOnly) && str(now.sectionOnly) !== str(was.sectionOnly)) out2.push(`the lefthook hook carrying ${key2} (the gate) gained or changed \`only\` (${str(now.sectionOnly)})`);
2764
3485
  if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
2765
3486
  if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2766
- if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
3487
+ if (now.excludedByTag.length && !was.excludedByTag.length) out2.push(`lefthook entry ${key2} (the gate) is tagged ${now.excludedByTag.join(", ")}, which the hook's \`exclude_tags\` switches off`);
2767
3488
  }
2768
3489
  return out2;
2769
3490
  }
2770
- var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
3491
+ var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
3492
+ var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
3493
+ var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
3494
+ var normStage = (s) => STAGE_ALIAS[s] ?? s;
2771
3495
  function preCommitEntries(doc) {
2772
3496
  const out2 = /* @__PURE__ */ new Map();
2773
3497
  const repos = Array.isArray(doc.repos) ? doc.repos : [];
3498
+ const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
2774
3499
  for (const repo of repos) {
2775
3500
  if (!isDoc(repo)) continue;
2776
3501
  const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
2777
3502
  for (const h of hooks) {
2778
3503
  if (!isDoc(h)) continue;
2779
3504
  if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
3505
+ const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
2780
3506
  out2.set(`${str(repo.repo)}:${str(h.id)}`, {
2781
- stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
3507
+ live,
3508
+ stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
3509
+ explicitStages: Array.isArray(h.stages),
2782
3510
  exclude: str(h.exclude),
2783
- files: str(h.files)
3511
+ files: str(h.files),
3512
+ types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
3513
+ alwaysRun: h.always_run === true
2784
3514
  });
2785
3515
  }
2786
3516
  }
@@ -2791,28 +3521,45 @@ function preCommitWeakening(before, after) {
2791
3521
  const b = preCommitEntries(before);
2792
3522
  const a = preCommitEntries(after);
2793
3523
  for (const [key2, was] of b) {
3524
+ if (!was.live) continue;
2794
3525
  const now = a.get(key2);
2795
3526
  if (!now) {
2796
3527
  out2.push(`pre-commit hook ${key2} (the gate) was removed`);
2797
3528
  continue;
2798
3529
  }
2799
- const wasStages = was.stages ?? [...COMMIT_STAGES];
2800
- if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
3530
+ if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
3531
+ const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
3532
+ if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
2801
3533
  out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
2802
- } else if (now.stages && was.stages) {
2803
- const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
2804
- if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
3534
+ } else if (lost.length) {
3535
+ out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
2805
3536
  }
2806
3537
  if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2807
3538
  if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
3539
+ if (now.types !== was.types && now.types && !now.alwaysRun) out2.push(`pre-commit hook ${key2} (the gate) is scoped by file type (${now.types}) without \`always_run\``);
2808
3540
  }
2809
3541
  const topB = str(before.exclude);
2810
3542
  const topA = str(after.exclude);
2811
3543
  if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
2812
3544
  return out2;
2813
3545
  }
2814
- var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
2815
3546
  var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
3547
+ function installerOf(script) {
3548
+ for (const seg of script.split(/&&|\|\||;|\n/)) {
3549
+ const raw = seg.trim();
3550
+ if (!raw) continue;
3551
+ if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
3552
+ const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
3553
+ const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
3554
+ if (!m) continue;
3555
+ const [, tool, sub] = m;
3556
+ if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
3557
+ if (tool === "lefthook" && sub === "install") return "lefthook install";
3558
+ if (tool === "pre-commit" && sub === "install") return "pre-commit install";
3559
+ if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
3560
+ }
3561
+ return null;
3562
+ }
2816
3563
  function packageJsonWeakening(before, after) {
2817
3564
  let b;
2818
3565
  let a;
@@ -2823,13 +3570,14 @@ function packageJsonWeakening(before, after) {
2823
3570
  return [];
2824
3571
  }
2825
3572
  if (!isDoc(b) || !isDoc(a)) return [];
2826
- const scripts = (d) => {
3573
+ const installers = (d) => {
2827
3574
  const sc = d.scripts;
2828
- return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
3575
+ if (!isDoc(sc)) return [];
3576
+ return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
2829
3577
  };
2830
- const was = scripts(b).match(INSTALLER)?.[0];
3578
+ const was = installers(b)[0];
2831
3579
  if (!was) return [];
2832
- if (INSTALLER.test(scripts(a))) return [];
3580
+ if (installers(a).length) return [];
2833
3581
  return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
2834
3582
  }
2835
3583
  var GIT_HOOKS = /* @__PURE__ */ new Set([
@@ -2856,7 +3604,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
2856
3604
  ]);
2857
3605
  function hookIdentity(path) {
2858
3606
  const base = path.split("/").pop() ?? "";
2859
- if (isLefthook(path)) return "lefthook";
3607
+ if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
2860
3608
  if (isPreCommitConfig(path)) return "pre-commit-config";
2861
3609
  if (base === "CODEOWNERS") return "codeowners";
2862
3610
  const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
@@ -2864,7 +3612,7 @@ function hookIdentity(path) {
2864
3612
  return null;
2865
3613
  }
2866
3614
  var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
2867
- var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
3615
+ var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
2868
3616
  function chmodDropsExec(toks) {
2869
3617
  const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
2870
3618
  if (at === -1) return false;
@@ -2877,31 +3625,113 @@ function chmodDropsExec(toks) {
2877
3625
  for (const clause of tok.split(",")) {
2878
3626
  const m = clause.match(SYMBOLIC);
2879
3627
  if (!m) continue;
2880
- const [, op, perms] = m;
2881
- if (op === "-" && /[xX]/.test(perms)) return true;
2882
- if (op === "=" && !/[xX]/.test(perms)) return true;
3628
+ const [, who, opsText] = m;
3629
+ if (who && !/[ua]/.test(who)) continue;
3630
+ let exec = null;
3631
+ for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
3632
+ const op = g[0];
3633
+ const perms = g.slice(1);
3634
+ if (/[ugo]/.test(perms)) {
3635
+ exec = false;
3636
+ continue;
3637
+ }
3638
+ if (op === "-" && /[xX]/.test(perms)) exec = false;
3639
+ else if (op === "+" && /[xX]/.test(perms)) exec = true;
3640
+ else if (op === "=") exec = /[xX]/.test(perms);
3641
+ }
3642
+ if (exec === false) return true;
2883
3643
  }
2884
3644
  }
2885
3645
  return false;
2886
3646
  }
3647
+ function setfaclDropsExec(toks) {
3648
+ if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
3649
+ return toks.some((t) => t.split(",").some((e) => {
3650
+ const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
3651
+ return !!m && !/[xX]/.test(m[1]);
3652
+ }));
3653
+ }
2887
3654
  var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2888
- var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
2889
- var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
2890
- function shellHookTarget(seg, toks, policy) {
3655
+ var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
3656
+ var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
3657
+ var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
3658
+ var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
3659
+ function nameGlob(pattern) {
3660
+ const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
3661
+ return (n) => re.test(n);
3662
+ }
3663
+ function destinations(cmd, args, isDir) {
3664
+ const positional = [];
3665
+ let target = null;
3666
+ for (let i = 0; i < args.length; i++) {
3667
+ const a = args[i];
3668
+ if (a === "--") {
3669
+ positional.push(...args.slice(i + 1));
3670
+ break;
3671
+ }
3672
+ if (a === "-t" || a === "--target-directory") {
3673
+ target = args[++i] ?? null;
3674
+ continue;
3675
+ }
3676
+ const eq = a.match(/^--target-directory=(.*)$/);
3677
+ if (eq) {
3678
+ target = eq[1];
3679
+ continue;
3680
+ }
3681
+ if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
3682
+ i++;
3683
+ continue;
3684
+ }
3685
+ if (a.startsWith("-")) continue;
3686
+ positional.push(a);
3687
+ }
3688
+ const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
3689
+ if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
3690
+ if (positional.length < 2) return [];
3691
+ const dest = positional[positional.length - 1];
3692
+ const srcs = positional.slice(0, -1);
3693
+ if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
3694
+ return [dest];
3695
+ }
3696
+ var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
3697
+ function hookTests(policy, ctx) {
3698
+ const rel = (t) => {
3699
+ let p = t.replace(/^["']|["']$/g, "");
3700
+ if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
3701
+ return p.replace(/^\.\/(?=.)/, "");
3702
+ };
3703
+ const hook = (t) => isProtected(rel(t), policy, "hooks");
3704
+ const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
3705
+ const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
3706
+ const hookish = (t) => hook(t) || holds(t);
3707
+ return { rel, hook, whole, holds, hookish };
3708
+ }
3709
+ function shellHookTarget(seg, toks, policy, ctx) {
3710
+ const { rel, hook, whole, holds } = hookTests(policy, ctx);
2891
3711
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2892
- const t = hookPath(m[1]);
2893
- if (isProtected(t, policy, "hooks")) return t;
3712
+ if (hook(m[1])) return rel(m[1]);
2894
3713
  }
2895
- for (const tok of toks) {
2896
- const t = hookPath(tok.replace(/^of=/, ""));
2897
- if (isProtected(t, policy, "hooks")) return t;
3714
+ const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
3715
+ const fileLike = (p) => {
3716
+ const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
3717
+ return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
3718
+ };
3719
+ for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
3720
+ for (const t of args) {
3721
+ if (t.startsWith("-") || !holds(t)) continue;
3722
+ const dir = rel(t).replace(/\/+$/, "");
3723
+ const under = whole(t) ? "" : dir + "/";
3724
+ const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
3725
+ if (listed) return listed;
3726
+ const guess = under + "pre-commit";
3727
+ return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
2898
3728
  }
2899
3729
  return null;
2900
3730
  }
2901
- function shellWritesHook(seg, toks, policy) {
2902
- const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
3731
+ function shellWritesHook(seg, toks, policy, ctx) {
3732
+ const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
2903
3733
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2904
- if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
3734
+ if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
2905
3735
  }
2906
3736
  let at = 0;
2907
3737
  while (at < toks.length && WRAPPERS.test(toks[at])) at++;
@@ -2911,21 +3741,28 @@ function shellWritesHook(seg, toks, policy) {
2911
3741
  const last = positional[positional.length - 1] ?? "";
2912
3742
  const anyHook = args.some(hook);
2913
3743
  const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
3744
+ const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
3745
+ const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
2914
3746
  switch (cmd) {
2915
3747
  case "rm":
2916
3748
  case "unlink":
2917
3749
  case "shred":
2918
- return anyHook ? "rm deletes a protected hook" : null;
3750
+ return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
2919
3751
  case "truncate":
2920
3752
  return anyHook ? "truncate empties a protected hook" : null;
2921
3753
  case "chmod":
2922
- return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3754
+ return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3755
+ case "setfacl":
3756
+ return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
2923
3757
  case "mv":
2924
- return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
3758
+ return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
2925
3759
  case "cp":
2926
3760
  case "install":
2927
3761
  case "ln":
2928
- return hook(last) ? `${cmd} replaces a protected hook in place` : null;
3762
+ case "rsync": {
3763
+ const dests = destinations(cmd, args, holds);
3764
+ return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
3765
+ }
2929
3766
  case "tee":
2930
3767
  case "sponge":
2931
3768
  return anyHook ? `${cmd} rewrites a protected hook in place` : null;
@@ -2933,20 +3770,45 @@ function shellWritesHook(seg, toks, policy) {
2933
3770
  return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
2934
3771
  case "sed":
2935
3772
  case "perl":
2936
- return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
3773
+ if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
3774
+ if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
3775
+ return null;
2937
3776
  case "awk":
2938
3777
  case "gawk":
2939
3778
  return anyHook && args.some((a, i) => a === "-i" && args[i + 1] === "inplace" || a === "--in-place" || a === "-i=inplace") ? "an in-place awk rewrites a protected hook" : null;
3779
+ case "patch":
3780
+ return anyHook ? "patch rewrites a protected hook" : null;
3781
+ case "xargs":
3782
+ return null;
3783
+ // judged by the caller, which sees the segments feeding it
3784
+ case "find": {
3785
+ const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
3786
+ if (!mutates) return null;
3787
+ if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
3788
+ const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
3789
+ const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
3790
+ const nameHits = names.length === 0 || names.some((n) => {
3791
+ try {
3792
+ const g = nameGlob(n.replace(/^.*\//, ""));
3793
+ return HOOK_BASENAMES.some(g);
3794
+ } catch {
3795
+ return true;
3796
+ }
3797
+ });
3798
+ const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
3799
+ return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
3800
+ }
2940
3801
  case "git": {
2941
3802
  let i = 0;
2942
3803
  while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
2943
3804
  const sub = args[i];
2944
3805
  const rest = args.slice(i + 1);
2945
- if (!rest.some(hook)) return null;
3806
+ if (sub === "apply" || sub === "am") return rest.some((a) => hook(a.replace(/^--(?:include|exclude|directory)=/, ""))) ? `git ${sub} applies a patch that names a protected hook` : null;
3807
+ if (!rest.some(hookish)) return null;
2946
3808
  if (sub === "checkout") {
2947
3809
  const dd = rest.indexOf("--");
2948
3810
  const before = dd === -1 ? rest : rest.slice(0, dd);
2949
- const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
3811
+ const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
2950
3812
  return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
2951
3813
  }
2952
3814
  if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
@@ -2955,108 +3817,290 @@ function shellWritesHook(seg, toks, policy) {
2955
3817
  return null;
2956
3818
  }
2957
3819
  default:
3820
+ if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
3821
+ if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
3822
+ if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
2958
3823
  return null;
2959
3824
  }
2960
3825
  }
3826
+ function xargsWritesHook(toks, fed, policy, ctx) {
3827
+ const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
3828
+ if (at === -1) return null;
3829
+ const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
3830
+ const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
3831
+ if (!fedHook) return null;
3832
+ let i = at + 1;
3833
+ while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
3834
+ const cmd = (toks[i] ?? "").replace(/^.*\//, "");
3835
+ const args = toks.slice(i + 1);
3836
+ if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
3837
+ if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
3838
+ if ((cmd === "sed" || cmd === "perl") && args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return `xargs ${cmd} -i rewrites a protected hook named earlier in the line`;
3839
+ return null;
3840
+ }
2961
3841
 
2962
3842
  // src/detectors/hook-tampering.ts
2963
3843
  var RULE6 = "hook-tampering";
2964
3844
  var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
2965
- var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
2966
- var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
2967
- function siblingSettings(p) {
2968
- return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
2969
- }
2970
- var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
2971
3845
  var EVENTS = ["PreToolUse", "Stop"];
3846
+ var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2972
3847
  function parseSettings(src) {
2973
3848
  try {
2974
3849
  const v = JSON.parse(src);
2975
- return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
3850
+ return isObj(v) ? v : null;
2976
3851
  } catch {
2977
3852
  return null;
2978
3853
  }
2979
3854
  }
2980
- function toolSet(matcher2) {
2981
- const m = typeof matcher2 === "string" ? matcher2.trim() : "";
2982
- if (m === "" || m === "*") return null;
2983
- return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2984
- }
2985
3855
  function eventEntries(s, event) {
2986
3856
  const hooks = s.hooks;
2987
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
3857
+ if (!isObj(hooks)) return null;
2988
3858
  const arr = hooks[event];
2989
3859
  return Array.isArray(arr) ? arr : null;
2990
3860
  }
2991
3861
  var declares = (s, event) => eventEntries(s, event) !== null;
2992
- function gateWiring(s, event) {
3862
+ var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
3863
+ var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
3864
+ var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
3865
+ var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
3866
+ var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
3867
+ function analyseGateCommand(cmd, event) {
3868
+ const want = event === "PreToolUse" ? "hook" : "sweep";
3869
+ const problems = [];
3870
+ let runs = true;
3871
+ const canonical2 = cmd.match(CANONICAL_CMD);
3872
+ if (canonical2) {
3873
+ if (canonical2[2] !== want) {
3874
+ problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
3875
+ runs = false;
3876
+ }
3877
+ if (!PLAIN_SEMVER.test(canonical2[1])) {
3878
+ problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
3879
+ runs = false;
3880
+ }
3881
+ return { runs, problems, pin: canonical2[1] };
3882
+ }
3883
+ const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
3884
+ if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
3885
+ if (inv[2] !== want || inv[3] !== "claude") {
3886
+ problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
3887
+ runs = false;
3888
+ }
3889
+ if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
3890
+ problems.push(`the pin \`${inv[1]}\` is not a plain version`);
3891
+ runs = false;
3892
+ }
3893
+ if (/[|;&<>`]|\$\(/.test(cmd)) {
3894
+ problems.push("text around the invocation (a redirect, pipe or chain)");
3895
+ runs = false;
3896
+ }
3897
+ if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
3898
+ problems.push("an environment assignment in front of the gate");
3899
+ runs = false;
3900
+ }
3901
+ if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
3902
+ problems.push("wrapped in `sh -c`");
3903
+ runs = false;
3904
+ }
3905
+ if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
3906
+ problems.push("`-p`/`--package` points npx at another package");
3907
+ runs = false;
3908
+ }
3909
+ const at = cmd.indexOf(inv[0]);
3910
+ const trailing = cmd.slice(at + inv[0].length).trim();
3911
+ if (trailing) {
3912
+ problems.push(`extra text after the invocation (\`${trailing}\`)`);
3913
+ runs = false;
3914
+ }
3915
+ const head = cmd.slice(0, at).trim();
3916
+ if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
3917
+ return { runs, problems, pin: inv[1] ?? "" };
3918
+ }
3919
+ function matcherSelection(m) {
3920
+ if (m === void 0) return { tools: null, problem: null };
3921
+ if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
3922
+ if (m === "" || m === "*") return { tools: null, problem: null };
3923
+ let problem = null;
3924
+ if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
3925
+ if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
3926
+ let re;
3927
+ try {
3928
+ re = new RegExp(m);
3929
+ } catch {
3930
+ return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
3931
+ }
3932
+ return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
3933
+ }
3934
+ function gateCandidates(s, event) {
2993
3935
  const out2 = [];
2994
- for (const m of eventEntries(s, event) ?? []) {
2995
- if (m === null || typeof m !== "object") continue;
2996
- const matcher2 = m;
2997
- if (!Array.isArray(matcher2.hooks)) continue;
2998
- for (const h of matcher2.hooks) {
2999
- if (h === null || typeof h !== "object") continue;
3000
- const entry = h;
3001
- if (entry.type !== void 0 && entry.type !== "command") continue;
3002
- if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
3003
- out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
3936
+ const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
3937
+ for (const group of eventEntries(s, event) ?? []) {
3938
+ if (!isObj(group)) {
3939
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
3940
+ continue;
3941
+ }
3942
+ if (!Array.isArray(group.hooks)) {
3943
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
3944
+ continue;
3945
+ }
3946
+ for (const h of group.hooks) {
3947
+ if (!isObj(h)) {
3948
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
3949
+ continue;
3950
+ }
3951
+ const cmd = typeof h.command === "string" ? h.command : null;
3952
+ if (cmd === null) {
3953
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
3954
+ continue;
3955
+ }
3956
+ if (!MENTIONS_GATE.test(cmd)) continue;
3957
+ const a = analyseGateCommand(cmd, event);
3958
+ const problems = [...a.problems];
3959
+ let runs = a.runs;
3960
+ if (h.type !== "command") {
3961
+ problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
3962
+ if (h.type !== void 0) runs = false;
3963
+ }
3964
+ for (const k of Object.keys(h)) {
3965
+ if (GATE_KEYS.has(k)) continue;
3966
+ problems.push(`carries \`${k}\``);
3967
+ runs = false;
3968
+ }
3969
+ if ("timeout" in h) {
3970
+ const t = h.timeout;
3971
+ if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
3972
+ problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
3973
+ runs = false;
3974
+ }
3975
+ }
3976
+ let tools = null;
3977
+ if (event === "PreToolUse") {
3978
+ const sel = matcherSelection(group.matcher);
3979
+ tools = sel.tools;
3980
+ if (sel.problem) {
3981
+ problems.push(sel.problem);
3982
+ }
3983
+ } else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
3984
+ problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
3985
+ runs = false;
3986
+ }
3987
+ out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
3004
3988
  }
3005
3989
  }
3006
3990
  return out2;
3007
3991
  }
3008
- function coverage(ws2) {
3009
- if (ws2.length === 0) return "none";
3010
- if (ws2.some((w) => w.tools === null)) return null;
3011
- return new Set(ws2.flatMap((w) => [...w.tools]));
3992
+ function otherCommands(s, event) {
3993
+ const out2 = [];
3994
+ for (const group of eventEntries(s, event) ?? []) {
3995
+ if (!isObj(group) || !Array.isArray(group.hooks)) continue;
3996
+ for (const h of group.hooks) {
3997
+ if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
3998
+ out2.push(h.command);
3999
+ }
4000
+ }
4001
+ return out2;
4002
+ }
4003
+ function coverage(cs) {
4004
+ const live = cs.filter((c) => c.runs);
4005
+ if (live.length === 0) return "none";
4006
+ if (live.some((c) => c.tools === null)) return null;
4007
+ return new Set(live.flatMap((c) => [...c.tools]));
4008
+ }
4009
+ function duplicateJsonKeys(src) {
4010
+ const dups = [];
4011
+ const stack = [];
4012
+ let expectKey = false;
4013
+ for (let i = 0; i < src.length; i++) {
4014
+ const ch = src[i];
4015
+ if (ch === '"') {
4016
+ let j = i + 1;
4017
+ let s = "";
4018
+ while (j < src.length && src[j] !== '"') {
4019
+ if (src[j] === "\\") {
4020
+ s += src[j + 1] ?? "";
4021
+ j += 2;
4022
+ } else {
4023
+ s += src[j];
4024
+ j++;
4025
+ }
4026
+ }
4027
+ if (expectKey) {
4028
+ const set = stack[stack.length - 1];
4029
+ if (set) {
4030
+ if (set.has(s)) dups.push(s);
4031
+ set.add(s);
4032
+ }
4033
+ expectKey = false;
4034
+ }
4035
+ i = j;
4036
+ continue;
4037
+ }
4038
+ if (ch === "{") {
4039
+ stack.push(/* @__PURE__ */ new Set());
4040
+ expectKey = true;
4041
+ } else if (ch === "[") {
4042
+ stack.push(null);
4043
+ expectKey = false;
4044
+ } else if (ch === "}" || ch === "]") {
4045
+ stack.pop();
4046
+ expectKey = false;
4047
+ } else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
4048
+ }
4049
+ return [...new Set(dups)];
3012
4050
  }
3013
- var hooksDisabled = (s) => s.disableAllHooks === true;
3014
- function settingsWeakening(before, after, sibling) {
4051
+ function settingsWeakening(before, after, local) {
3015
4052
  const reasons = [];
3016
- if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
3017
- reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
4053
+ const dis = after.disableAllHooks;
4054
+ if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
4055
+ reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
4056
+ }
4057
+ if (isObj(after.env)) {
4058
+ const was = before !== null && isObj(before.env) ? before.env : {};
4059
+ for (const [k, v] of Object.entries(after.env)) {
4060
+ if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
4061
+ reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
4062
+ }
4063
+ }
3018
4064
  }
3019
4065
  for (const event of EVENTS) {
3020
- const was = before ? coverage(gateWiring(before, event)) : "none";
3021
- const now = coverage(gateWiring(after, event));
4066
+ const bc = before !== null ? gateCandidates(before, event) : [];
4067
+ const ac = gateCandidates(after, event);
4068
+ const was = coverage(bc);
4069
+ const now = coverage(ac);
3022
4070
  if (was !== "none") {
3023
4071
  if (now === "none") {
3024
4072
  reasons.push(`the tamperward ${event} hook entry was removed`);
3025
4073
  } else if (event === "PreToolUse") {
3026
4074
  if (was === null && now !== null) {
3027
- reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
4075
+ reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
3028
4076
  } else if (was !== null && now !== null) {
3029
4077
  const lost = [...was].filter((t) => !now.has(t));
3030
4078
  if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
3031
4079
  }
3032
4080
  }
3033
- continue;
4081
+ } else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
4082
+ reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
4083
+ }
4084
+ const had = new Set(bc.map((c) => c.key));
4085
+ const fresh = ac.filter((c) => !had.has(c.key));
4086
+ for (const c of fresh) {
4087
+ if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
4088
+ }
4089
+ const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
4090
+ const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
4091
+ if (floor) {
4092
+ for (const c of fresh) {
4093
+ if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
4094
+ }
3034
4095
  }
3035
- if (now !== "none" || !declares(after, event)) continue;
3036
- if (before !== null && declares(before, event)) continue;
3037
- const empty2 = (eventEntries(after, event) ?? []).length === 0;
3038
- const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
3039
- if (empty2 || shadowed) {
3040
- reasons.push(
3041
- `${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
3042
- );
4096
+ const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4097
+ for (const cmd of otherCommands(after, event)) {
4098
+ if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
3043
4099
  }
3044
4100
  }
3045
4101
  return reasons;
3046
4102
  }
3047
- function siblingOf(c, changes, ctx) {
3048
- const path = siblingSettings(c.path);
3049
- const inSet = changes.find((o) => o.kind === "file" && o.path === path);
3050
- if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
3051
- if (!ctx?.cwd) return null;
3052
- try {
3053
- const p = join2(ctx.cwd, path);
3054
- return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
3055
- } catch {
3056
- return null;
3057
- }
3058
- }
3059
- function claudeSettingsFindings(c, changes, policy, ctx) {
4103
+ function claudeSettingsFindings(c, policy) {
3060
4104
  if (c.after == null) return null;
3061
4105
  const after = parseSettings(c.after);
3062
4106
  const before = c.before != null ? parseSettings(c.before) : null;
@@ -3064,14 +4108,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
3064
4108
  file: c.path,
3065
4109
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
3066
4110
  evidence: reason,
3067
- remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
4111
+ remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
3068
4112
  });
3069
4113
  if (c.before != null && before === null) return null;
3070
4114
  if (after === null) {
3071
4115
  if (c.before == null) return [];
3072
4116
  return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
3073
4117
  }
3074
- return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
4118
+ const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
4119
+ const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
4120
+ for (const k of duplicateJsonKeys(c.after)) {
4121
+ if (!had.has(k)) reasons.push(`the key "${k}" appears twice \u2014 which reading the runtime keeps is not something the gate can vouch for`);
4122
+ }
4123
+ return reasons.map(finding);
3075
4124
  }
3076
4125
  function policyFindings(c, policy) {
3077
4126
  if (c.after == null) return null;
@@ -3093,12 +4142,54 @@ function renameKept(c) {
3093
4142
  if (id === null || hookIdentity(c.path) !== id) return false;
3094
4143
  return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
3095
4144
  }
3096
- function configFindings(c, policy) {
3097
- if (c.before == null || c.after == null) return null;
3098
- const before = parseDoc(c.before);
3099
- const after = parseDoc(c.after);
3100
- if (before === null) return null;
3101
- const reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4145
+ function lefthookBase(c, changes, ctx) {
4146
+ const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
4147
+ const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
4148
+ const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
4149
+ if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
4150
+ if (!ctx?.cwd) return null;
4151
+ for (const n of names) {
4152
+ try {
4153
+ const p = join3(ctx.cwd, n);
4154
+ if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
4155
+ } catch {
4156
+ }
4157
+ }
4158
+ return null;
4159
+ }
4160
+ function syntheticBase(over) {
4161
+ const base = {};
4162
+ for (const [section, v] of Object.entries(over)) {
4163
+ if (!isObj(v)) continue;
4164
+ for (const group of ["commands", "scripts"]) {
4165
+ const g = v[group];
4166
+ if (!isObj(g)) continue;
4167
+ for (const name of Object.keys(g)) {
4168
+ if (!/tamperward/.test(name)) continue;
4169
+ const sec = base[section] ??= {};
4170
+ const grp = sec[group] ??= {};
4171
+ grp[name] = { run: "npx tamperward check --staged" };
4172
+ }
4173
+ }
4174
+ }
4175
+ return base;
4176
+ }
4177
+ function configFindings(c, changes, policy, ctx) {
4178
+ if (c.after == null) return null;
4179
+ let reasons;
4180
+ if (isLefthookLocal(c.path)) {
4181
+ const overBefore = c.before != null ? parseDoc(c.before) : {};
4182
+ const overAfter = parseDoc(c.after);
4183
+ if (overBefore === null) return null;
4184
+ const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
4185
+ reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
4186
+ } else {
4187
+ if (c.before == null) return null;
4188
+ const before = parseDoc(c.before);
4189
+ const after = parseDoc(c.after);
4190
+ if (before === null) return null;
4191
+ reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4192
+ }
3102
4193
  return reasons.map(
3103
4194
  (reason) => makeFinding(RULE6, policy, {
3104
4195
  file: c.path,
@@ -3108,6 +4199,11 @@ function configFindings(c, policy) {
3108
4199
  })
3109
4200
  );
3110
4201
  }
4202
+ function repoGateFiles(ctx) {
4203
+ const files = trackedFiles(ctx);
4204
+ if (!files) return [];
4205
+ return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
4206
+ }
3111
4207
  function updateIndexHiding(toks, policy) {
3112
4208
  if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
3113
4209
  const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
@@ -3129,6 +4225,7 @@ function repoRelative(path, ctx) {
3129
4225
  const rel = relative(ctx.cwd, path);
3130
4226
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
3131
4227
  }
4228
+ var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
3132
4229
  var hookTampering = {
3133
4230
  id: RULE6,
3134
4231
  surface: ["file", "command"],
@@ -3138,7 +4235,7 @@ var hookTampering = {
3138
4235
  for (const c of changes) {
3139
4236
  let semantic;
3140
4237
  if (c.kind === "file") {
3141
- const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
4238
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
3142
4239
  if (!targetsHook) {
3143
4240
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
3144
4241
  for (const reason of packageJsonWeakening(c.before, c.after)) {
@@ -3182,12 +4279,12 @@ var hookTampering = {
3182
4279
  remediation: "Renaming a hook out of place disables it. Restore the original path."
3183
4280
  })
3184
4281
  );
3185
- } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, changes, policy, ctx)) !== null) {
4282
+ } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
3186
4283
  out2.push(...semantic);
3187
4284
  } else if (c.op === "rename") {
3188
4285
  } else if (isCodeowners(c.path)) {
3189
4286
  if (c.before != null && c.after != null) {
3190
- for (const reason of codeownersWeakening(c.before, c.after)) {
4287
+ for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
3191
4288
  out2.push(
3192
4289
  makeFinding(RULE6, policy, {
3193
4290
  file: c.path,
@@ -3198,9 +4295,40 @@ var hookTampering = {
3198
4295
  );
3199
4296
  }
3200
4297
  }
3201
- } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
4298
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
3202
4299
  out2.push(...semantic);
3203
4300
  } else if (!c.path.endsWith(POLICY_FILE)) {
4301
+ const husky = isHuskyScript(c.path);
4302
+ const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
4303
+ if (beforePin !== null) {
4304
+ const afterPin = initScriptPin(c.after);
4305
+ const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
4306
+ if (!kept) {
4307
+ out2.push(
4308
+ makeFinding(RULE6, policy, {
4309
+ file: c.path,
4310
+ message: "A protected hook script no longer matches the shape `tamperward init` writes.",
4311
+ evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
4312
+ remediation: "The pre-commit script init writes is the marker line and the pinned staged check, nothing else. Re-run `tamperward init` to restore it, or sign off on the customised script."
4313
+ })
4314
+ );
4315
+ }
4316
+ continue;
4317
+ }
4318
+ if (!husky && c.after != null) {
4319
+ const problem = shebangProblem(c.after);
4320
+ if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
4321
+ out2.push(
4322
+ makeFinding(RULE6, policy, {
4323
+ file: c.path,
4324
+ line: 1,
4325
+ message: `A protected hook's interpreter was changed: ${problem}.`,
4326
+ evidence: c.after.split("\n")[0],
4327
+ remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
4328
+ })
4329
+ );
4330
+ }
4331
+ }
3204
4332
  const added = addedLines(c);
3205
4333
  const earlyExit = added.find((l) => insertsPassingExit(l.content));
3206
4334
  if (earlyExit) {
@@ -3228,7 +4356,7 @@ var hookTampering = {
3228
4356
  }
3229
4357
  const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
3230
4358
  const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
3231
- const lost = scriptWeakening(beforeLines, afterLines);
4359
+ const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
3232
4360
  if (lost.length) {
3233
4361
  out2.push(
3234
4362
  makeFinding(RULE6, policy, {
@@ -3272,6 +4400,7 @@ var hookTampering = {
3272
4400
  }
3273
4401
  }
3274
4402
  } else {
4403
+ const fed = [];
3275
4404
  for (const seg of segments(c.raw)) {
3276
4405
  const toks = tokens(seg).map(unquote);
3277
4406
  const hiding = updateIndexHiding(toks, policy);
@@ -3284,11 +4413,12 @@ var hookTampering = {
3284
4413
  remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
3285
4414
  })
3286
4415
  );
4416
+ fed.push(...toks);
3287
4417
  continue;
3288
4418
  }
3289
- const why = shellWritesHook(seg, toks, policy);
4419
+ const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
3290
4420
  if (why) {
3291
- const target = shellHookTarget(seg, toks, policy);
4421
+ const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
3292
4422
  out2.push(
3293
4423
  makeFinding(RULE6, policy, {
3294
4424
  ...target ? { file: repoRelative(target, ctx) } : {},
@@ -3298,6 +4428,7 @@ var hookTampering = {
3298
4428
  })
3299
4429
  );
3300
4430
  }
4431
+ fed.push(...toks);
3301
4432
  }
3302
4433
  }
3303
4434
  }
@@ -3390,21 +4521,20 @@ function collect(root, runner) {
3390
4521
  const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
3391
4522
  sel.ignore = [...sel.ignore ?? [], ...entries];
3392
4523
  };
4524
+ let projectList = null;
3393
4525
  const projects = (e) => {
3394
4526
  sel.present = true;
3395
- if (!ts3.isArrayLiteralExpression(e)) {
4527
+ projectList = e;
4528
+ };
4529
+ const visit = (node) => {
4530
+ if (ts3.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
4531
+ sel.present = true;
3396
4532
  sel.opaque = true;
3397
- return;
3398
4533
  }
3399
- const list = [];
3400
- for (const el of e.elements) {
3401
- if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
3402
- else sel.opaque = true;
4534
+ if (ts3.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts3.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
4535
+ sel.present = true;
4536
+ sel.opaque = true;
3403
4537
  }
3404
- sel.projects = list;
3405
- if (list.some((p) => p.opaque)) sel.opaque = true;
3406
- };
3407
- const visit = (node) => {
3408
4538
  if (ts3.isPropertyAssignment(node)) {
3409
4539
  const k = keyName2(node.name);
3410
4540
  if (runner === "jest") {
@@ -3424,7 +4554,7 @@ function collect(root, runner) {
3424
4554
  } else if (ownerKey2(node) === "test") {
3425
4555
  if (k === "include") sel.include = take(k, node.initializer);
3426
4556
  else if (k === "exclude") ignores(k, node.initializer);
3427
- else if (k === "dir") {
4557
+ else if (k === "dir" || k === "root") {
3428
4558
  const { dir, opaque } = dirValue(node.initializer);
3429
4559
  sel.present = true;
3430
4560
  if (opaque) sel.opaque = true;
@@ -3438,8 +4568,68 @@ function collect(root, runner) {
3438
4568
  ts3.forEachChild(node, visit);
3439
4569
  };
3440
4570
  visit(root);
4571
+ if (projectList) sel.projects = readProjects(projectList, sel, runner);
4572
+ if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
3441
4573
  return sel;
3442
4574
  }
4575
+ function unwrapConfigCall(e) {
4576
+ let x = e;
4577
+ while (ts3.isParenthesizedExpression(x) || ts3.isAsExpression(x) || x.kind === ts3.SyntaxKind.SatisfiesExpression) {
4578
+ x = x.expression;
4579
+ }
4580
+ if (ts3.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
4581
+ return x;
4582
+ }
4583
+ var JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
4584
+ function spreadsIntoConfig(node, runner) {
4585
+ const obj = node.parent;
4586
+ if (!ts3.isObjectLiteralExpression(obj)) return false;
4587
+ const keys = obj.properties.map((p) => ts3.isPropertyAssignment(p) ? keyName2(p.name) : null);
4588
+ if (runner === "vitest") {
4589
+ if (keys.includes("test")) return true;
4590
+ let q = obj.parent;
4591
+ while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
4592
+ return !!q && ts3.isPropertyAssignment(q) && keyName2(q.name) === "test";
4593
+ }
4594
+ return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
4595
+ }
4596
+ function extendsOf(el) {
4597
+ for (const p of el.properties) {
4598
+ if (!ts3.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
4599
+ if (p.initializer.kind === ts3.SyntaxKind.TrueKeyword) return true;
4600
+ if (p.initializer.kind === ts3.SyntaxKind.FalseKeyword) return null;
4601
+ return "path";
4602
+ }
4603
+ return null;
4604
+ }
4605
+ function readProject(el, root, runner) {
4606
+ const p = collect(el, runner);
4607
+ const ext = extendsOf(el);
4608
+ if (ext === "path") p.opaque = true;
4609
+ else if (ext === true && root) {
4610
+ p.include ??= root.include;
4611
+ p.regex ??= root.regex;
4612
+ p.ignore ??= root.ignore;
4613
+ p.roots ??= root.roots;
4614
+ p.base ??= root.base;
4615
+ if (root.opaque) p.opaque = true;
4616
+ }
4617
+ p.present = true;
4618
+ return p;
4619
+ }
4620
+ function readProjects(e, root, runner) {
4621
+ if (!ts3.isArrayLiteralExpression(e)) {
4622
+ root.opaque = true;
4623
+ return [];
4624
+ }
4625
+ const list2 = [];
4626
+ for (const el of e.elements) {
4627
+ const lit = unwrapConfigCall(el);
4628
+ if (ts3.isObjectLiteralExpression(lit)) list2.push(readProject(lit, root, runner));
4629
+ else root.opaque = true;
4630
+ }
4631
+ return list2;
4632
+ }
3443
4633
  function workspaceArray(sf) {
3444
4634
  for (const st of sf.statements) {
3445
4635
  let e;
@@ -3461,14 +4651,9 @@ function parseSelection(src, runner, path = "") {
3461
4651
  if (runner === "vitest" && isWorkspaceFile(path)) {
3462
4652
  const arr = workspaceArray(sf);
3463
4653
  if (arr) {
3464
- const list = [];
3465
- for (const el of arr.elements) {
3466
- if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
3467
- else sel.opaque = true;
3468
- }
3469
- sel.projects = list;
4654
+ sel.projects = readProjects(arr, sel, runner);
3470
4655
  sel.present = true;
3471
- if (list.some((p) => p.opaque)) sel.opaque = true;
4656
+ if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
3472
4657
  } else if (src.trim()) sel.opaque = true;
3473
4658
  } else sel = collect(sf, runner);
3474
4659
  } catch {
@@ -3528,8 +4713,8 @@ function predicate(sel, runner) {
3528
4713
  if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
3529
4714
  const hit = ignoredBy(r);
3530
4715
  if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
3531
- const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3532
- return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
4716
+ const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
4717
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
3533
4718
  }
3534
4719
  };
3535
4720
  }
@@ -3568,8 +4753,21 @@ function calleeName(expr) {
3568
4753
  }
3569
4754
  return null;
3570
4755
  }
3571
- var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
3572
- var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
4756
+ var TABLE_METHOD = /^(?:each|for)$/;
4757
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && TABLE_METHOD.test(expr.name.text);
4758
+ var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && TABLE_METHOD.test(expr.name.text);
4759
+ function loopRows(iterable) {
4760
+ if (ts4.isArrayLiteralExpression(iterable)) {
4761
+ let n = 0;
4762
+ let open = false;
4763
+ for (const el of iterable.elements) {
4764
+ if (ts4.isSpreadElement(el)) open = true;
4765
+ else n++;
4766
+ }
4767
+ return { n: Math.max(n, open ? 0 : 1), open };
4768
+ }
4769
+ return { n: 1, open: true };
4770
+ }
3573
4771
  function templateRows(node) {
3574
4772
  const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3575
4773
  return Math.max(rows, 1);
@@ -3607,7 +4805,23 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
3607
4805
  let n = 0;
3608
4806
  let open = false;
3609
4807
  const visit = (node, mult) => {
4808
+ if (ts4.isForOfStatement(node)) {
4809
+ const r = loopRows(node.expression);
4810
+ if (r.open) open = true;
4811
+ visit(node.statement, mult * r.n);
4812
+ return;
4813
+ }
4814
+ if (ts4.isForStatement(node) || ts4.isForInStatement(node) || ts4.isWhileStatement(node) || ts4.isDoStatement(node)) {
4815
+ open = true;
4816
+ }
3610
4817
  if (ts4.isCallExpression(node)) {
4818
+ const callee = node.expression;
4819
+ if (ts4.isPropertyAccessExpression(callee) && callee.name.text === "forEach") {
4820
+ const r = loopRows(callee.expression);
4821
+ if (r.open) open = true;
4822
+ for (const a of node.arguments) visit(a, mult * r.n);
4823
+ return;
4824
+ }
3611
4825
  const head = node.expression;
3612
4826
  if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
3613
4827
  const r = eachRows(head);
@@ -3655,6 +4869,12 @@ function runnerSamples(configPath, policy, ctx) {
3655
4869
  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));
3656
4870
  return own.length ? own : CANONICAL_SAMPLES;
3657
4871
  }
4872
+ var WHOLE_SUITE_SCRIPT = /^test(?::ci)?$/;
4873
+ function excludesOtherRunner(flag, cmd) {
4874
+ if (flag !== "--exclude") return false;
4875
+ const values = [...cmd.matchAll(/--exclude(?:=|\s+)(['"]?)(\S+?)\1(?=\s|$)/g)].map((m) => m[2]);
4876
+ return values.length > 0 && values.every((v) => OTHER_RUNNER_DIR.test(v.replace(/^\.\//, "").replace(/(?:\/\*+)*\/?$/, "") + "/"));
4877
+ }
3658
4878
  function scriptNarrowings(before, after, path) {
3659
4879
  if ((path.split("/").pop() ?? path) !== "package.json") return [];
3660
4880
  const scriptsOf = (src) => {
@@ -3669,7 +4889,7 @@ function scriptNarrowings(before, after, path) {
3669
4889
  const now = scriptsOf(after);
3670
4890
  const out2 = [];
3671
4891
  for (const [name, cmd] of Object.entries(now)) {
3672
- if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
4892
+ if (!WHOLE_SUITE_SCRIPT.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
3673
4893
  const prev = was[name] ?? "";
3674
4894
  const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
3675
4895
  for (const m of cmd.matchAll(flags)) {
@@ -3677,6 +4897,7 @@ function scriptNarrowings(before, after, path) {
3677
4897
  if (!flag) continue;
3678
4898
  const one = new RegExp(SUITE_NARROWING_FLAGS.source);
3679
4899
  if (one.test(prev) && prev.includes(flag)) continue;
4900
+ if (excludesOtherRunner(flag, cmd)) continue;
3680
4901
  out2.push({ script: name, flag, before: prev, after: cmd });
3681
4902
  break;
3682
4903
  }
@@ -3708,10 +4929,11 @@ var testDeletion = {
3708
4929
  certainty: "mechanical",
3709
4930
  run(changes, policy, _view, ctx) {
3710
4931
  const out2 = [];
4932
+ const isSpec = (p) => isProtected(p, policy, "tests") && !isProtected(p, policy, "snapshots");
3711
4933
  const addedTestLines = /* @__PURE__ */ new Set();
3712
4934
  let addedTestBlocks = 0;
3713
4935
  for (const c of changes) {
3714
- if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
4936
+ if (c.kind !== "file" || c.after == null || !isSpec(c.path)) continue;
3715
4937
  if (c.op === "add") {
3716
4938
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
3717
4939
  addedTestBlocks += countTests(c.after, c.path, true).min;
@@ -3729,7 +4951,7 @@ var testDeletion = {
3729
4951
  };
3730
4952
  for (const c of changes) {
3731
4953
  if (c.kind === "file") {
3732
- const isTest = isProtected(c.path, policy, "tests");
4954
+ const isTest = isSpec(c.path);
3733
4955
  if (c.op === "delete" && isTest) {
3734
4956
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
3735
4957
  out2.push(
@@ -3740,7 +4962,7 @@ var testDeletion = {
3740
4962
  remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
3741
4963
  })
3742
4964
  );
3743
- } else if (c.op === "rename" && c.oldPath && isProtected(c.oldPath, policy, "tests") && !isTest) {
4965
+ } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
3744
4966
  out2.push(
3745
4967
  makeFinding(RULE7, policy, {
3746
4968
  file: c.path,
@@ -3801,22 +5023,22 @@ var testDeletion = {
3801
5023
  const testToks = toks.filter((t) => {
3802
5024
  const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
3803
5025
  const probe = resolved ?? t;
3804
- if (!isProtected(probe, policy, "tests")) return false;
5026
+ if (!isSpec(probe)) return false;
3805
5027
  if (!listing || root === null) return true;
3806
5028
  const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
3807
5029
  if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
3808
- return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
5030
+ return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx, "snapshots");
3809
5031
  });
3810
- const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
5032
+ const rootHasProtected = () => listing ? listing.some(isSpec) : true;
3811
5033
  const dirToks = toks.filter((t) => {
3812
5034
  if (t.startsWith("-") || testToks.includes(t)) return false;
3813
5035
  const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
3814
5036
  if (!whole && /[*?{}[\]]/.test(t)) return false;
3815
- if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
5037
+ if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx, "snapshots");
3816
5038
  const dir = whole ? root : joinRoot(root, t);
3817
5039
  if (dir === null) return false;
3818
5040
  if (dir === "") return rootHasProtected();
3819
- return containsProtected(dir, policy, "tests", ctx);
5041
+ return containsProtected(dir, policy, "tests", ctx, "snapshots");
3820
5042
  });
3821
5043
  if (testToks.length === 0 && dirToks.length === 0) continue;
3822
5044
  const named = [...testToks, ...dirToks];
@@ -3824,13 +5046,13 @@ var testDeletion = {
3824
5046
  let why = null;
3825
5047
  let evidence = seg;
3826
5048
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
3827
- const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
5049
+ const redirectsOntoTest = redirectTarget ? isSpec(unquote(redirectTarget)) : false;
3828
5050
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3829
5051
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3830
5052
  const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
3831
5053
  const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
3832
5054
  const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
3833
- if (!rev || rev === "HEAD") return false;
5055
+ if (!rev || rev === "HEAD" || rev === "@" || revIsHead(rev, ctx) === true) return false;
3834
5056
  if (src || seg.includes(" -- ")) return true;
3835
5057
  return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
3836
5058
  })();
@@ -3846,7 +5068,7 @@ var testDeletion = {
3846
5068
  why = "truncate empties a test file";
3847
5069
  } else if (redirectsOntoTest) {
3848
5070
  why = "a redirect overwrites/empties a test file";
3849
- } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
5071
+ } else if (cmd === "cp" && testToks.length > 0 && isSpec(toks[toks.length - 1] ?? "") && !isSpec(toks[toks.length - 2] ?? "")) {
3850
5072
  why = "cp overwrites a test file with something else";
3851
5073
  } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
3852
5074
  why = "tee overwrites a test file";
@@ -3856,7 +5078,7 @@ var testDeletion = {
3856
5078
  why = "git checkout <rev> replaces a test file with an older version";
3857
5079
  } else if (/\bmv\b/.test(seg)) {
3858
5080
  const dest = toks[toks.length - 1];
3859
- if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
5081
+ if (dest && testToks.length > 0 && !isSpec(dest)) {
3860
5082
  why = "mv renames a test file out of the test glob";
3861
5083
  evidence = `${testToks[0]} \u2192 ${dest}`;
3862
5084
  } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
@@ -3893,7 +5115,7 @@ function eachTables(src, path) {
3893
5115
  try {
3894
5116
  const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
3895
5117
  const visit = (node) => {
3896
- 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)) {
5118
+ 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)) {
3897
5119
  const arg = node.arguments[0];
3898
5120
  if (arg && ts5.isArrayLiteralExpression(arg)) {
3899
5121
  for (const el of arg.elements) {
@@ -3914,6 +5136,7 @@ function eachTables(src, path) {
3914
5136
  }
3915
5137
  return { rows, open };
3916
5138
  }
5139
+ var TEST_DIR_FILE = /(?:^|\/)(?:test|tests|__tests__|spec|specs)\//;
3917
5140
  function significantLinesOrdered(src, path) {
3918
5141
  const out2 = [];
3919
5142
  const lang = langOf(path);
@@ -3934,11 +5157,14 @@ var testContentRemoval = {
3934
5157
  for (const c of changes) {
3935
5158
  if (c.kind !== "file" || c.after == null) continue;
3936
5159
  if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3937
- if (c.op === "add") addedPool += ws(c.after);
5160
+ let addedHere = "";
5161
+ if (c.op === "add") addedHere = ws(c.after);
3938
5162
  else if (c.before != null) {
3939
5163
  const had = new Set(c.before.split("\n").map((l) => l.trim()));
3940
- addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
5164
+ addedHere = ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
3941
5165
  }
5166
+ addedPool += addedHere;
5167
+ if (!isProtected(c.path, policy, "tests") && TEST_DIR_FILE.test(c.path) && langOf(c.path) === "js") keptPool += addedHere;
3942
5168
  }
3943
5169
  for (const c of changes) {
3944
5170
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
@@ -4101,7 +5327,7 @@ var GUARDED_RULE = "hook-tampering";
4101
5327
  var BASELINE_HOOKS = defaultPolicy().protected.hooks;
4102
5328
  function isGuardedFinding(f) {
4103
5329
  if (f.rule !== GUARDED_RULE || !f.file) return false;
4104
- return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
5330
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
4105
5331
  }
4106
5332
  function key(f) {
4107
5333
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
@@ -4160,8 +5386,8 @@ function hasBlocking(findings) {
4160
5386
  }
4161
5387
 
4162
5388
  // src/policy-load.ts
4163
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
4164
- import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
5389
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
5390
+ import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
4165
5391
  import { parse as parse2 } from "yaml";
4166
5392
  var PolicyError = class extends Error {
4167
5393
  };
@@ -4242,8 +5468,8 @@ function parsePolicy(raw, where = POLICY_FILE) {
4242
5468
  const version = normalizeVersion(r.version, where);
4243
5469
  validate(r, where);
4244
5470
  const base = defaultPolicy(version);
4245
- const globs4 = (list) => list?.map(normalizeGlob);
4246
- const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs4(list) ?? []])) : void 0;
5471
+ const globs4 = (list2) => list2?.map(normalizeGlob);
5472
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
4247
5473
  const userRules = r.rules ? Object.fromEntries(
4248
5474
  Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
4249
5475
  ) : void 0;
@@ -4285,9 +5511,9 @@ function parseOrThrow(src, where) {
4285
5511
  return parsePolicy(raw, where);
4286
5512
  }
4287
5513
  function loadPolicy(cwd = process.cwd()) {
4288
- const path = join3(cwd, POLICY_FILE);
5514
+ const path = join4(cwd, POLICY_FILE);
4289
5515
  if (!existsSync2(path)) return defaultPolicy();
4290
- return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5516
+ return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
4291
5517
  }
4292
5518
  function loadPolicyAt(rev, cwd) {
4293
5519
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -4297,8 +5523,8 @@ function loadPolicyAt(rev, cwd) {
4297
5523
 
4298
5524
  // src/signoff.ts
4299
5525
  import { createHash } from "node:crypto";
4300
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
4301
- import { dirname, join as join4 } from "node:path";
5526
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
5527
+ import { dirname as dirname2, join as join5 } from "node:path";
4302
5528
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
4303
5529
  function fingerprint(rule, file, evidence) {
4304
5530
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -4309,13 +5535,13 @@ function ledgerPath(cwd, policy) {
4309
5535
  if (!ledgerInsideRepo(rel)) {
4310
5536
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
4311
5537
  }
4312
- return join4(cwd, rel);
5538
+ return join5(cwd, rel);
4313
5539
  }
4314
5540
  function readLedger(cwd, policy) {
4315
5541
  const p = ledgerPath(cwd, policy);
4316
5542
  if (!existsSync3(p)) return [];
4317
5543
  const out2 = [];
4318
- for (const line of readFileSync4(p, "utf8").split("\n")) {
5544
+ for (const line of readFileSync5(p, "utf8").split("\n")) {
4319
5545
  if (!line.trim()) continue;
4320
5546
  try {
4321
5547
  const e = JSON.parse(line);
@@ -4327,7 +5553,7 @@ function readLedger(cwd, policy) {
4327
5553
  }
4328
5554
  function appendEntry(cwd, policy, e) {
4329
5555
  const p = ledgerPath(cwd, policy);
4330
- mkdirSync(dirname(p), { recursive: true });
5556
+ mkdirSync(dirname2(p), { recursive: true });
4331
5557
  appendFileSync(p, JSON.stringify(e) + "\n");
4332
5558
  }
4333
5559
  function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
@@ -4684,21 +5910,21 @@ function runCheck(opts) {
4684
5910
  }
4685
5911
 
4686
5912
  // src/cli/hook.ts
4687
- import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
5913
+ import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
4688
5914
  import { createHash as createHash4 } from "node:crypto";
4689
- import { join as join9 } from "node:path";
5915
+ import { join as join10 } from "node:path";
4690
5916
 
4691
5917
  // src/adapters/claude/changes.ts
4692
5918
  import { execFileSync as execFileSync3 } from "node:child_process";
4693
- import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
5919
+ import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
4694
5920
  import { tmpdir } from "node:os";
4695
- import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
5921
+ import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
4696
5922
  function asStr(v) {
4697
5923
  return typeof v === "string" ? v : "";
4698
5924
  }
4699
5925
  function readDisk(path) {
4700
5926
  try {
4701
- return existsSync4(path) ? readFileSync5(path, "utf8") : null;
5927
+ return existsSync4(path) ? readFileSync6(path, "utf8") : null;
4702
5928
  } catch {
4703
5929
  return null;
4704
5930
  }
@@ -4721,11 +5947,11 @@ function synthFileChange(displayPath, before, after) {
4721
5947
  if (before === null) op = "add";
4722
5948
  else if (after === null) op = "delete";
4723
5949
  else op = "modify";
4724
- const dir = mkdtempSync(join5(tmpdir(), "hf-"));
5950
+ const dir = mkdtempSync(join6(tmpdir(), "hf-"));
4725
5951
  let raw = "";
4726
5952
  try {
4727
- const a = join5(dir, "a");
4728
- const b = join5(dir, "b");
5953
+ const a = join6(dir, "a");
5954
+ const b = join6(dir, "b");
4729
5955
  writeFileSync(a, before ?? "");
4730
5956
  writeFileSync(b, after ?? "");
4731
5957
  try {
@@ -4810,14 +6036,14 @@ function formatDenial(blocks) {
4810
6036
  }
4811
6037
 
4812
6038
  // src/session.ts
4813
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
4814
- import { dirname as dirname2, join as join6 } from "node:path";
6039
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
6040
+ import { dirname as dirname3, join as join7 } from "node:path";
4815
6041
  var UNSAFE = /[^A-Za-z0-9_-]/g;
4816
6042
  var SHA = /^[0-9a-f]{7,40}$/;
4817
6043
  function baselinePath(cwd, sessionId) {
4818
6044
  const gd = gitDir(cwd);
4819
6045
  if (!gd) return null;
4820
- return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
6046
+ return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
4821
6047
  }
4822
6048
  function turnBaseline(cwd, sessionId) {
4823
6049
  if (!sessionId) return null;
@@ -4825,12 +6051,12 @@ function turnBaseline(cwd, sessionId) {
4825
6051
  const p = baselinePath(cwd, sessionId);
4826
6052
  if (!p) return null;
4827
6053
  if (existsSync5(p)) {
4828
- const v = readFileSync6(p, "utf8").trim();
6054
+ const v = readFileSync7(p, "utf8").trim();
4829
6055
  if (SHA.test(v)) return v;
4830
6056
  }
4831
6057
  const head = headSha(cwd);
4832
6058
  if (!head) return null;
4833
- mkdirSync2(dirname2(p), { recursive: true });
6059
+ mkdirSync2(dirname3(p), { recursive: true });
4834
6060
  writeFileSync2(p, head);
4835
6061
  return head;
4836
6062
  } catch {
@@ -4843,7 +6069,7 @@ function advanceTurnBaseline(cwd, sessionId) {
4843
6069
  const p = baselinePath(cwd, sessionId);
4844
6070
  const head = headSha(cwd);
4845
6071
  if (!p || !head) return;
4846
- mkdirSync2(dirname2(p), { recursive: true });
6072
+ mkdirSync2(dirname3(p), { recursive: true });
4847
6073
  writeFileSync2(p, head);
4848
6074
  } catch {
4849
6075
  }
@@ -4851,8 +6077,8 @@ function advanceTurnBaseline(cwd, sessionId) {
4851
6077
 
4852
6078
  // src/effect.ts
4853
6079
  import { createHash as createHash2 } from "node:crypto";
4854
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4855
- import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
6080
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
6081
+ import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
4856
6082
  import { execFileSync as execFileSync4 } from "node:child_process";
4857
6083
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
4858
6084
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -4864,7 +6090,7 @@ function gitDir2(cwd) {
4864
6090
  stdio: ["ignore", "pipe", "pipe"]
4865
6091
  }).trim();
4866
6092
  if (!d) return null;
4867
- return isAbsolute4(d) ? d : join7(cwd, d);
6093
+ return isAbsolute4(d) ? d : join8(cwd, d);
4868
6094
  } catch {
4869
6095
  return null;
4870
6096
  }
@@ -4873,8 +6099,8 @@ function statePath(cwd, sessionId, kind) {
4873
6099
  if (!sessionId) return null;
4874
6100
  const gd = gitDir2(cwd);
4875
6101
  if (!gd) return null;
4876
- const dir = join7(gd, "tamperward");
4877
- return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
6102
+ const dir = join8(gd, "tamperward");
6103
+ return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
4878
6104
  }
4879
6105
  function ptreePath(cwd, sessionId) {
4880
6106
  return statePath(cwd, sessionId, "ptree");
@@ -4888,8 +6114,8 @@ function contentHash(content) {
4888
6114
  }
4889
6115
  function snapshotProtected(cwd, policy, prev) {
4890
6116
  const out2 = {};
4891
- const walk = (rel) => {
4892
- const abs2 = rel ? join7(cwd, rel) : cwd;
6117
+ const walk2 = (rel) => {
6118
+ const abs2 = rel ? join8(cwd, rel) : cwd;
4893
6119
  let names;
4894
6120
  try {
4895
6121
  names = readdirSync(abs2);
@@ -4901,15 +6127,15 @@ function snapshotProtected(cwd, policy, prev) {
4901
6127
  const r = rel ? `${rel}/${name}` : name;
4902
6128
  let st;
4903
6129
  try {
4904
- st = statSync(join7(cwd, r));
6130
+ st = statSync(join8(cwd, r));
4905
6131
  } catch {
4906
6132
  continue;
4907
6133
  }
4908
- if (st.isDirectory()) walk(r);
6134
+ if (st.isDirectory()) walk2(r);
4909
6135
  else if (st.isFile() && isProtected(r, policy)) {
4910
6136
  let hash = "";
4911
6137
  try {
4912
- hash = sha(readFileSync7(join7(cwd, r)));
6138
+ hash = sha(readFileSync8(join8(cwd, r)));
4913
6139
  } catch {
4914
6140
  hash = "unreadable";
4915
6141
  }
@@ -4917,7 +6143,7 @@ function snapshotProtected(cwd, policy, prev) {
4917
6143
  }
4918
6144
  }
4919
6145
  };
4920
- walk("");
6146
+ walk2("");
4921
6147
  return out2;
4922
6148
  }
4923
6149
  function driftBetween(expected, current) {
@@ -4937,7 +6163,7 @@ function driftBetween(expected, current) {
4937
6163
  function loadTree(p) {
4938
6164
  if (!p || !existsSync6(p)) return null;
4939
6165
  try {
4940
- return JSON.parse(readFileSync7(p, "utf8"));
6166
+ return JSON.parse(readFileSync8(p, "utf8"));
4941
6167
  } catch {
4942
6168
  return null;
4943
6169
  }
@@ -4945,7 +6171,7 @@ function loadTree(p) {
4945
6171
  function saveTree(p, tree) {
4946
6172
  if (!p) return;
4947
6173
  try {
4948
- mkdirSync3(join7(p, ".."), { recursive: true });
6174
+ mkdirSync3(join8(p, ".."), { recursive: true });
4949
6175
  const tmp = `${p}.${process.pid}.tmp`;
4950
6176
  writeFileSync3(tmp, JSON.stringify(tree));
4951
6177
  renameSync(tmp, p);
@@ -4976,15 +6202,15 @@ function dropTurnTree(cwd, sessionId) {
4976
6202
  }
4977
6203
 
4978
6204
  // src/cli/watch.ts
4979
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
4980
- import { join as join8 } from "node:path";
6205
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
6206
+ import { join as join9 } from "node:path";
4981
6207
  import { createHash as createHash3 } from "node:crypto";
4982
6208
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
4983
6209
  function snap(abs2) {
4984
6210
  try {
4985
6211
  const st = statSync2(abs2);
4986
6212
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
4987
- const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
6213
+ const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
4988
6214
  return { mode: st.mode, size: st.size, hash };
4989
6215
  } catch {
4990
6216
  return { mode: null, size: null, hash: null };
@@ -4992,7 +6218,7 @@ function snap(abs2) {
4992
6218
  }
4993
6219
  function defaultEventLog(cwd) {
4994
6220
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
4995
- return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
6221
+ return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
4996
6222
  }
4997
6223
  function watchTree(dir, cb) {
4998
6224
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -5009,12 +6235,12 @@ function watchTree(dir, cb) {
5009
6235
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
5010
6236
  let w;
5011
6237
  try {
5012
- w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
6238
+ w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
5013
6239
  if (!fname) return;
5014
6240
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
5015
6241
  cb(kind, child);
5016
6242
  try {
5017
- if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
6243
+ if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
5018
6244
  } catch {
5019
6245
  }
5020
6246
  });
@@ -5023,11 +6249,11 @@ function watchTree(dir, cb) {
5023
6249
  }
5024
6250
  watchers.set(rel, w);
5025
6251
  };
5026
- const walk = (rel) => {
6252
+ const walk2 = (rel) => {
5027
6253
  addDir(rel);
5028
6254
  let names = [];
5029
6255
  try {
5030
- names = readdirSync2(rel ? join8(dir, rel) : dir);
6256
+ names = readdirSync2(rel ? join9(dir, rel) : dir);
5031
6257
  } catch {
5032
6258
  return;
5033
6259
  }
@@ -5035,25 +6261,25 @@ function watchTree(dir, cb) {
5035
6261
  const child = rel ? `${rel}/${name}` : name;
5036
6262
  if (SKIP2.test(child + "/")) continue;
5037
6263
  try {
5038
- if (statSync2(join8(dir, child)).isDirectory()) walk(child);
6264
+ if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
5039
6265
  } catch {
5040
6266
  }
5041
6267
  }
5042
6268
  };
5043
- walk("");
6269
+ walk2("");
5044
6270
  return { close: () => {
5045
6271
  for (const w of watchers.values()) w.close();
5046
6272
  } };
5047
6273
  }
5048
6274
  function startWatcher(dir, log, policy) {
5049
6275
  try {
5050
- mkdirSync4(join8(log, ".."), { recursive: true });
6276
+ mkdirSync4(join9(log, ".."), { recursive: true });
5051
6277
  } catch {
5052
6278
  }
5053
6279
  const last = /* @__PURE__ */ new Map();
5054
6280
  return watchTree(dir, (kind, rel) => {
5055
6281
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
5056
- const s = snap(join8(dir, rel));
6282
+ const s = snap(join9(dir, rel));
5057
6283
  const key2 = `${s.hash}:${s.mode}`;
5058
6284
  if (last.get(rel) === key2) return;
5059
6285
  last.set(rel, key2);
@@ -5080,7 +6306,7 @@ function runWatch(args) {
5080
6306
  }
5081
6307
 
5082
6308
  // src/detectors/fs-events.ts
5083
- import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
6309
+ import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
5084
6310
  var RULE11 = "transient-protected-mutation";
5085
6311
  function readEvents(log, offset) {
5086
6312
  let size = 0;
@@ -5090,7 +6316,7 @@ function readEvents(log, offset) {
5090
6316
  return { events: [], newOffset: offset };
5091
6317
  }
5092
6318
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
5093
- const raw = readFileSync9(log, "utf8").slice(offset);
6319
+ const raw = readFileSync10(log, "utf8").slice(offset);
5094
6320
  const events = [];
5095
6321
  for (const line of raw.split("\n")) {
5096
6322
  if (!line.trim()) continue;
@@ -5143,7 +6369,7 @@ var HookInputError = class extends Error {
5143
6369
  };
5144
6370
  function readStdin() {
5145
6371
  try {
5146
- return readFileSync10(0, "utf8");
6372
+ return readFileSync11(0, "utf8");
5147
6373
  } catch (e) {
5148
6374
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
5149
6375
  }
@@ -5202,7 +6428,7 @@ function fileChanges(changes) {
5202
6428
  }
5203
6429
  function hashOnDisk(cwd, rel) {
5204
6430
  try {
5205
- return contentHash(readFileSync10(join9(cwd, rel)));
6431
+ return contentHash(readFileSync11(join10(cwd, rel)));
5206
6432
  } catch {
5207
6433
  return null;
5208
6434
  }
@@ -5391,7 +6617,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
5391
6617
  if (!existsSync7(log)) return none;
5392
6618
  let offset = 0;
5393
6619
  if (cp && existsSync7(cp)) {
5394
- const n = Number(readFileSync10(cp, "utf8"));
6620
+ const n = Number(readFileSync11(cp, "utf8"));
5395
6621
  offset = Number.isFinite(n) ? n : 0;
5396
6622
  }
5397
6623
  const { events, newOffset } = readEvents(log, offset);
@@ -5405,7 +6631,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
5405
6631
  const finalHash = (path) => {
5406
6632
  try {
5407
6633
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
5408
- return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
6634
+ return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
5409
6635
  } catch {
5410
6636
  return null;
5411
6637
  }
@@ -5548,9 +6774,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
5548
6774
  // src/cli/init.ts
5549
6775
  import { execFileSync as execFileSync5 } from "node:child_process";
5550
6776
  import { createHash as createHash5 } from "node:crypto";
5551
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
5552
- import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
5553
- import { fileURLToPath } from "node:url";
6777
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
6778
+ import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
5554
6779
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
5555
6780
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5556
6781
  function gitConfig(cwd, key2) {
@@ -5560,12 +6785,9 @@ function gitConfig(cwd, key2) {
5560
6785
  return null;
5561
6786
  }
5562
6787
  }
5563
- var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
5564
- var MARKER = "# tamperward: block agent shortcuts before they land";
5565
6788
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
5566
6789
  var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
5567
6790
  var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
5568
- var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
5569
6791
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
5570
6792
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
5571
6793
  function coveredBy(existing, critical) {
@@ -5609,24 +6831,6 @@ version: 1
5609
6831
  # snapshot-rewrite: { severity: block }
5610
6832
  # ignore: [] # visible blind spots \u2014 the count is always reported
5611
6833
  `;
5612
- function shippedVersion() {
5613
- try {
5614
- const here = dirname3(fileURLToPath(import.meta.url));
5615
- for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
5616
- try {
5617
- const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
5618
- if (pkg.name === "tamperward" && pkg.version) return pkg.version;
5619
- } catch {
5620
- }
5621
- }
5622
- } catch {
5623
- }
5624
- return "latest";
5625
- }
5626
- var TW_VERSION = shippedVersion();
5627
- var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
5628
- var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
5629
- var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
5630
6834
  function pinOf(command) {
5631
6835
  const m = command.match(OURS);
5632
6836
  return m ? m[1] ?? "" : null;
@@ -5721,13 +6925,13 @@ function readWorkflowMark(src) {
5721
6925
  if (!m) return null;
5722
6926
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
5723
6927
  }
5724
- function toolSet2(matcher2) {
6928
+ function toolSet(matcher2) {
5725
6929
  const m = String(matcher2 ?? "").trim();
5726
6930
  if (m === "" || m === "*") return null;
5727
6931
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
5728
6932
  }
5729
6933
  function missingTools(matcher2) {
5730
- const have = toolSet2(matcher2);
6934
+ const have = toolSet(matcher2);
5731
6935
  if (have === null) return [];
5732
6936
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
5733
6937
  }
@@ -5747,7 +6951,7 @@ function hooksShapeError(hooks) {
5747
6951
  return null;
5748
6952
  }
5749
6953
  function planPolicy(cwd) {
5750
- const path = join10(cwd, POLICY_FILE);
6954
+ const path = join11(cwd, POLICY_FILE);
5751
6955
  if (existsSync8(path)) {
5752
6956
  try {
5753
6957
  loadPolicy(cwd);
@@ -5766,12 +6970,12 @@ function planPolicy(cwd) {
5766
6970
  }
5767
6971
  function planClaudeHooks(cwd) {
5768
6972
  const rel = ".claude/settings.json";
5769
- const path = join10(cwd, rel);
6973
+ const path = join11(cwd, rel);
5770
6974
  let settings = {};
5771
6975
  if (existsSync8(path)) {
5772
6976
  let parsed;
5773
6977
  try {
5774
- parsed = JSON.parse(readFileSync11(path, "utf8"));
6978
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
5775
6979
  } catch {
5776
6980
  return {
5777
6981
  item: "agent",
@@ -5824,12 +7028,12 @@ function planClaudeHooks(cwd) {
5824
7028
  });
5825
7029
  }
5826
7030
  for (const m of stale) {
5827
- m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
7031
+ m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
5828
7032
  }
5829
7033
  if (needStop) {
5830
7034
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
5831
7035
  }
5832
- mkdirSync5(dirname3(path), { recursive: true });
7036
+ mkdirSync5(dirname4(path), { recursive: true });
5833
7037
  writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
5834
7038
  }
5835
7039
  };
@@ -5864,7 +7068,7 @@ function hooksDir(cwd) {
5864
7068
  if (out2) return resolve2(cwd, out2);
5865
7069
  } catch {
5866
7070
  }
5867
- return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
7071
+ return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
5868
7072
  }
5869
7073
  function display(cwd, path) {
5870
7074
  const rel = relative3(cwd, path);
@@ -5891,30 +7095,30 @@ function planPreCommit(cwd) {
5891
7095
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
5892
7096
  let path;
5893
7097
  let note;
5894
- if (existsSync8(join10(cwd, ".husky"))) {
5895
- path = join10(cwd, ".husky", "pre-commit");
7098
+ if (existsSync8(join11(cwd, ".husky"))) {
7099
+ path = join11(cwd, ".husky", "pre-commit");
5896
7100
  note = "husky";
5897
7101
  } else {
5898
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
7102
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
5899
7103
  if (managed) {
5900
7104
  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}`);
5901
7105
  }
5902
7106
  const dir = hooksDir(cwd);
5903
7107
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
5904
- if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
5905
- path = join10(dirname3(dir), "pre-commit");
7108
+ if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
7109
+ path = join11(dirname4(dir), "pre-commit");
5906
7110
  note = "husky";
5907
7111
  } else {
5908
- path = join10(dir, "pre-commit");
7112
+ path = join11(dir, "pre-commit");
5909
7113
  const rel2 = display(cwd, dir);
5910
- 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)`;
7114
+ 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)`;
5911
7115
  }
5912
7116
  }
5913
7117
  const rel = display(cwd, path);
5914
7118
  if (existsSync8(path) && !statSync5(path).isFile()) {
5915
7119
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
5916
7120
  }
5917
- const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
7121
+ const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
5918
7122
  if (existing === null) {
5919
7123
  return {
5920
7124
  item: "pre-commit",
@@ -5922,7 +7126,7 @@ function planPreCommit(cwd) {
5922
7126
  status: "create",
5923
7127
  detail: `create via ${note}`,
5924
7128
  apply: () => {
5925
- mkdirSync5(dirname3(path), { recursive: true });
7129
+ mkdirSync5(dirname4(path), { recursive: true });
5926
7130
  writeFileSync5(path, `#!/bin/sh
5927
7131
  ${block.join("\n")}
5928
7132
  `);
@@ -6003,10 +7207,10 @@ ${block.join("\n")}
6003
7207
  function planCodeowners(cwd) {
6004
7208
  const rel = ".github/CODEOWNERS";
6005
7209
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
6006
- (r) => existsSync8(join10(cwd, r))
7210
+ (r) => existsSync8(join11(cwd, r))
6007
7211
  );
6008
- const path = join10(cwd, existingRel ?? rel);
6009
- const existing = existingRel ? readFileSync11(path, "utf8") : null;
7212
+ const path = join11(cwd, existingRel ?? rel);
7213
+ const existing = existingRel ? readFileSync12(path, "utf8") : null;
6010
7214
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
6011
7215
  if (missing.length === 0) {
6012
7216
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -6032,16 +7236,16 @@ ${CODEOWNERS_MARK}
6032
7236
  warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
6033
7237
  } : {},
6034
7238
  apply: () => {
6035
- mkdirSync5(dirname3(path), { recursive: true });
7239
+ mkdirSync5(dirname4(path), { recursive: true });
6036
7240
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
6037
7241
  }
6038
7242
  };
6039
7243
  }
6040
7244
  function planWorkflow(cwd, force) {
6041
7245
  const rel = ".github/workflows/tamperward.yml";
6042
- const path = join10(cwd, rel);
7246
+ const path = join11(cwd, rel);
6043
7247
  const write = () => {
6044
- mkdirSync5(dirname3(path), { recursive: true });
7248
+ mkdirSync5(dirname4(path), { recursive: true });
6045
7249
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
6046
7250
  };
6047
7251
  if (!existsSync8(path)) {
@@ -6055,7 +7259,7 @@ function planWorkflow(cwd, force) {
6055
7259
  }
6056
7260
  let src;
6057
7261
  try {
6058
- src = readFileSync11(path, "utf8");
7262
+ src = readFileSync12(path, "utf8");
6059
7263
  } catch {
6060
7264
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
6061
7265
  }
@@ -6154,15 +7358,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
6154
7358
  // src/cli/verify.ts
6155
7359
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
6156
7360
  import { createHash as createHash8 } from "node:crypto";
6157
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7361
+ 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";
6158
7362
  import { tmpdir as tmpdir2 } from "node:os";
6159
- import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
7363
+ import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
6160
7364
 
6161
7365
  // src/git/trusted.ts
6162
7366
  import { execFileSync as execFileSync6 } from "node:child_process";
6163
7367
  import { createHash as createHash6 } from "node:crypto";
6164
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
6165
- import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
7368
+ import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
7369
+ import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
6166
7370
  function trustedGitEnv() {
6167
7371
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
6168
7372
  }
@@ -6175,7 +7379,7 @@ function gitDirOf(cwd) {
6175
7379
  env: trustedGitEnv()
6176
7380
  }).trim();
6177
7381
  if (!d) return null;
6178
- return isAbsolute6(d) ? d : join11(cwd, d);
7382
+ return isAbsolute6(d) ? d : join12(cwd, d);
6179
7383
  } catch {
6180
7384
  return null;
6181
7385
  }
@@ -6200,9 +7404,9 @@ function objectRewriteState(cwd) {
6200
7404
  parts.push(`${rel}:<no-git-dir>`);
6201
7405
  continue;
6202
7406
  }
6203
- const p = join11(dir, rel);
7407
+ const p = join12(dir, rel);
6204
7408
  try {
6205
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
7409
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
6206
7410
  } catch {
6207
7411
  parts.push(`${rel}:<unreadable>`);
6208
7412
  }
@@ -6213,8 +7417,8 @@ function objectRewriteState(cwd) {
6213
7417
  // src/fingerprint.ts
6214
7418
  import { execFileSync as execFileSync7 } from "node:child_process";
6215
7419
  import { createHash as createHash7 } from "node:crypto";
6216
- import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
6217
- import { join as join12 } from "node:path";
7420
+ import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
7421
+ import { join as join13 } from "node:path";
6218
7422
  function treeFingerprint(cwd) {
6219
7423
  const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
6220
7424
  cwd,
@@ -6227,7 +7431,7 @@ function treeFingerprint(cwd) {
6227
7431
  h.update(rel);
6228
7432
  h.update("\0");
6229
7433
  try {
6230
- h.update(readFileSync13(join12(cwd, rel)));
7434
+ h.update(readFileSync14(join13(cwd, rel)));
6231
7435
  } catch {
6232
7436
  h.update("<unreadable>");
6233
7437
  }
@@ -6236,9 +7440,9 @@ function treeFingerprint(cwd) {
6236
7440
  return h.digest("hex");
6237
7441
  }
6238
7442
  function depsFingerprint(cwd) {
6239
- const root = join12(cwd, "node_modules");
7443
+ const root = join13(cwd, "node_modules");
6240
7444
  const h = createHash7("sha256");
6241
- const walk = (dir) => {
7445
+ const walk2 = (dir) => {
6242
7446
  let entries;
6243
7447
  try {
6244
7448
  entries = readdirSync3(dir, { withFileTypes: true });
@@ -6246,12 +7450,12 @@ function depsFingerprint(cwd) {
6246
7450
  return;
6247
7451
  }
6248
7452
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
6249
- const p = join12(dir, e.name);
6250
- if (e.isDirectory()) walk(p);
7453
+ const p = join13(dir, e.name);
7454
+ if (e.isDirectory()) walk2(p);
6251
7455
  else if (e.isFile()) {
6252
7456
  h.update(p);
6253
7457
  try {
6254
- h.update(readFileSync13(p));
7458
+ h.update(readFileSync14(p));
6255
7459
  h.update(String(statSync6(p).mode));
6256
7460
  } catch {
6257
7461
  h.update("<unreadable>");
@@ -6271,7 +7475,7 @@ function depsFingerprint(cwd) {
6271
7475
  } catch {
6272
7476
  return "none";
6273
7477
  }
6274
- walk(root);
7478
+ walk2(root);
6275
7479
  return h.digest("hex");
6276
7480
  }
6277
7481
 
@@ -6366,7 +7570,7 @@ function baseIsAncestorOfHead(base, cwd) {
6366
7570
  function materialize(cwd, dest) {
6367
7571
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
6368
7572
  for (const rel of listed) {
6369
- const src = join13(cwd, rel);
7573
+ const src = join14(cwd, rel);
6370
7574
  let st;
6371
7575
  try {
6372
7576
  st = statSync7(src);
@@ -6374,18 +7578,18 @@ function materialize(cwd, dest) {
6374
7578
  continue;
6375
7579
  }
6376
7580
  if (!st.isFile()) continue;
6377
- const out2 = join13(dest, rel);
6378
- mkdirSync6(dirname4(out2), { recursive: true });
7581
+ const out2 = join14(dest, rel);
7582
+ mkdirSync6(dirname5(out2), { recursive: true });
6379
7583
  dropSymlink(out2);
6380
7584
  if (lstatSync(src).isSymbolicLink()) {
6381
- writeFileSync6(out2, readFileSync14(src));
7585
+ writeFileSync6(out2, readFileSync15(src));
6382
7586
  } else {
6383
7587
  cpSync(src, out2, { dereference: true });
6384
7588
  }
6385
7589
  chmodSync2(out2, st.mode);
6386
7590
  }
6387
7591
  const nm = resolve3(cwd, "node_modules");
6388
- if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7592
+ if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
6389
7593
  }
6390
7594
  function dropSymlink(p) {
6391
7595
  try {
@@ -6418,11 +7622,11 @@ function overlayDigest(dest, paths) {
6418
7622
  for (const rel of [...paths].sort()) {
6419
7623
  h.update(rel);
6420
7624
  h.update("\0");
6421
- const p = join13(dest, rel);
7625
+ const p = join14(dest, rel);
6422
7626
  try {
6423
7627
  const st = lstatSync(p);
6424
7628
  h.update(String(st.mode));
6425
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
7629
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
6426
7630
  } catch {
6427
7631
  h.update("<gone>");
6428
7632
  }
@@ -6445,8 +7649,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
6445
7649
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
6446
7650
  }
6447
7651
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
6448
- const out2 = join13(dest, e.path);
6449
- mkdirSync6(dirname4(out2), { recursive: true });
7652
+ const out2 = join14(dest, e.path);
7653
+ mkdirSync6(dirname5(out2), { recursive: true });
6450
7654
  rmSync3(out2, { force: true });
6451
7655
  if (e.mode === "120000") {
6452
7656
  symlinkSync(content.toString("utf8"), out2);
@@ -6460,7 +7664,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
6460
7664
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
6461
7665
  for (const rel of inCopy) {
6462
7666
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
6463
- rmSync3(join13(dest, rel), { force: true });
7667
+ rmSync3(join14(dest, rel), { force: true });
6464
7668
  removed++;
6465
7669
  }
6466
7670
  return { restored, removed };
@@ -6502,8 +7706,8 @@ function suiteEnv(scratch) {
6502
7706
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
6503
7707
  env[k] = v;
6504
7708
  }
6505
- const userRc = join13(scratch, "npmrc-user");
6506
- const globalRc = join13(scratch, "npmrc-global");
7709
+ const userRc = join14(scratch, "npmrc-user");
7710
+ const globalRc = join14(scratch, "npmrc-global");
6507
7711
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
6508
7712
  env.npm_config_userconfig = userRc;
6509
7713
  env.npm_config_globalconfig = globalRc;
@@ -6513,8 +7717,8 @@ function suiteEnv(scratch) {
6513
7717
  }
6514
7718
  function runSuite(dir, cmd, budgetSecs) {
6515
7719
  const t0 = Date.now();
6516
- const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
6517
- const outFile = join13(outDir, "result.json");
7720
+ const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
7721
+ const outFile = join14(outDir, "result.json");
6518
7722
  try {
6519
7723
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
6520
7724
  cwd: dir,
@@ -6527,7 +7731,7 @@ function runSuite(dir, cmd, budgetSecs) {
6527
7731
  const secs = Math.round((Date.now() - t0) / 1e3);
6528
7732
  let r;
6529
7733
  try {
6530
- r = JSON.parse(readFileSync14(outFile, "utf8"));
7734
+ r = JSON.parse(readFileSync15(outFile, "utf8"));
6531
7735
  } catch {
6532
7736
  return { exit: null, secs };
6533
7737
  }
@@ -6578,8 +7782,8 @@ function runVerify(opts) {
6578
7782
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
6579
7783
  return 2;
6580
7784
  }
6581
- const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
6582
- const visDir = join13(visRoot, "t");
7785
+ const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
7786
+ const visDir = join14(visRoot, "t");
6583
7787
  const cleanup = (dirs) => {
6584
7788
  if (opts.keep) return;
6585
7789
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -6607,8 +7811,8 @@ function runVerify(opts) {
6607
7811
  out2("pristine run executes through it too. Failing closed, not open.");
6608
7812
  return 2;
6609
7813
  }
6610
- const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
6611
- const priDir = join13(priRoot, "t");
7814
+ const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
7815
+ const priDir = join14(priRoot, "t");
6612
7816
  let restored = [];
6613
7817
  let removedAdded = 0;
6614
7818
  try {
@@ -6720,7 +7924,7 @@ function parseVerify(args) {
6720
7924
 
6721
7925
  // src/cli/run.ts
6722
7926
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
6723
- import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
7927
+ import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
6724
7928
  import { resolve as resolve4 } from "node:path";
6725
7929
  var out = (s) => process.stdout.write(s + "\n");
6726
7930
  var err = (s) => process.stderr.write(s + "\n");
@@ -6729,7 +7933,7 @@ function git3(args, cwd) {
6729
7933
  }
6730
7934
  function nowTicks() {
6731
7935
  try {
6732
- return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
7936
+ return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
6733
7937
  } catch {
6734
7938
  return Number.POSITIVE_INFINITY;
6735
7939
  }
@@ -6759,7 +7963,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
6759
7963
  }
6760
7964
  if (held !== real && !held.startsWith(real + "/")) continue;
6761
7965
  try {
6762
- const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
7966
+ const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
6763
7967
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
6764
7968
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
6765
7969
  } catch {