tamperward 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +1858 -291
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -560,12 +560,12 @@ function matcher(glob) {
560
560
  }
561
561
  return m;
562
562
  }
563
- function matchesAny(path, globs2) {
564
- return !!globs2 && globs2.some((g) => matcher(g)(path));
563
+ function matchesAny(path, globs3) {
564
+ return !!globs3 && globs3.some((g) => matcher(g)(path));
565
565
  }
566
566
  function protectedCategory(path, policy) {
567
- for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
568
- if (matchesAny(path, globs2)) return cat;
567
+ for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
568
+ if (matchesAny(path, globs3)) return cat;
569
569
  }
570
570
  return null;
571
571
  }
@@ -581,9 +581,9 @@ function isIgnored(path, policy) {
581
581
  }
582
582
  function mergeProtected(base, user) {
583
583
  const out2 = { ...base };
584
- for (const [cat, globs2] of Object.entries(user ?? {})) {
584
+ for (const [cat, globs3] of Object.entries(user ?? {})) {
585
585
  const have = new Set(out2[cat] ?? []);
586
- out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
586
+ out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
587
587
  }
588
588
  return out2;
589
589
  }
@@ -631,7 +631,19 @@ function defaultPolicy(version = 1) {
631
631
  "**/tsconfig*.json",
632
632
  "**/.eslintrc*",
633
633
  "**/eslint.config.*",
634
- "**/package.json"
634
+ "**/package.json",
635
+ // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
636
+ // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
637
+ // codecov `target` lived in files no glob protected, so lowering them fired
638
+ // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
639
+ // as a strengthening). pyproject.toml carries [tool.coverage.report].
640
+ "**/.coveragerc",
641
+ "**/pyproject.toml",
642
+ "**/.nycrc",
643
+ "**/.nycrc.*",
644
+ "**/nyc.config.*",
645
+ "**/codecov.yml",
646
+ "**/.codecov.yml"
635
647
  ],
636
648
  ci: [".github/workflows/**"],
637
649
  // Recorded expected outputs. An assertion stored as data is still an assertion;
@@ -735,9 +747,11 @@ var IMPORTS = {
735
747
  cs: /^(?:using\b|namespace\b)/
736
748
  };
737
749
  var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
750
+ var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
738
751
  function isSignificantLine(trimmed, lang) {
739
752
  if (trimmed.length < 10) return false;
740
753
  if (COMMON.test(trimmed)) return false;
754
+ if (DEBUG_ONLY.test(trimmed)) return false;
741
755
  const l = lang ?? "js";
742
756
  if (IMPORTS[l].test(trimmed)) return false;
743
757
  if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
@@ -1075,7 +1089,47 @@ var testSkip = {
1075
1089
  };
1076
1090
 
1077
1091
  // src/detectors/coverage-lowering.ts
1092
+ import picomatch2 from "picomatch";
1078
1093
  import ts2 from "typescript";
1094
+
1095
+ // src/detectors/repo.ts
1096
+ import { execFileSync as execFileSync2 } from "node:child_process";
1097
+ var cache2 = /* @__PURE__ */ new Map();
1098
+ function trackedFiles(ctx) {
1099
+ if (!ctx) return null;
1100
+ if (ctx.trackedFiles) return ctx.trackedFiles;
1101
+ if (!ctx.cwd) return null;
1102
+ const hit = cache2.get(ctx.cwd);
1103
+ if (hit !== void 0) return hit;
1104
+ let files = null;
1105
+ try {
1106
+ files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1107
+ cwd: ctx.cwd,
1108
+ encoding: "utf8",
1109
+ maxBuffer: 64 * 1024 * 1024,
1110
+ stdio: ["ignore", "pipe", "pipe"]
1111
+ }).split("\0").filter(Boolean);
1112
+ } catch {
1113
+ files = null;
1114
+ }
1115
+ cache2.set(ctx.cwd, files);
1116
+ return files;
1117
+ }
1118
+ var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1119
+ function containsProtected(token, policy, category, ctx) {
1120
+ const dir = dirOf(token);
1121
+ if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1122
+ const files = trackedFiles(ctx);
1123
+ if (files) {
1124
+ const prefix = dir + "/";
1125
+ return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1126
+ }
1127
+ if (isProtected(dir + "/probe", policy, category)) return true;
1128
+ const base = dir.split("/").pop() ?? "";
1129
+ return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1130
+ }
1131
+
1132
+ // src/detectors/coverage-lowering.ts
1079
1133
  var RULE4 = "coverage-lowering";
1080
1134
  var METRICS = ["branches", "functions", "lines", "statements"];
1081
1135
  var isMetric = (k) => METRICS.includes(k);
@@ -1086,29 +1140,36 @@ function keyName(name) {
1086
1140
  }
1087
1141
  function numericOf(e) {
1088
1142
  if (ts2.isNumericLiteral(e)) return Number(e.text);
1143
+ if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
1089
1144
  if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
1090
1145
  return -Number(e.operand.text);
1091
1146
  }
1092
1147
  return void 0;
1093
1148
  }
1094
- function metricsOf(obj) {
1149
+ function metricsOf(expr, resolve5) {
1150
+ const obj = resolve5(expr);
1095
1151
  if (!ts2.isObjectLiteralExpression(obj)) return void 0;
1096
- const out2 = {};
1152
+ const out2 = { values: {}, opaque: false };
1097
1153
  let any = false;
1098
1154
  for (const p of obj.properties) {
1155
+ if (ts2.isSpreadAssignment(p)) {
1156
+ out2.opaque = true;
1157
+ any = true;
1158
+ continue;
1159
+ }
1099
1160
  if (!ts2.isPropertyAssignment(p)) continue;
1100
1161
  const k = keyName(p.name);
1101
1162
  if (k === null || !isMetric(k)) continue;
1102
- const n = numericOf(p.initializer);
1103
- if (n === void 0) continue;
1104
- out2[k] = n;
1105
1163
  any = true;
1164
+ const n = numericOf(resolve5(p.initializer));
1165
+ if (n === void 0) out2.opaque = true;
1166
+ else out2.values[k] = n;
1106
1167
  }
1107
1168
  return any ? out2 : void 0;
1108
1169
  }
1109
- function underCoverageKey(node) {
1170
+ function underKey(node, key2) {
1110
1171
  for (let p = node.parent; p; p = p.parent) {
1111
- if (ts2.isPropertyAssignment(p) && keyName(p.name) === "coverage") return true;
1172
+ if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
1112
1173
  }
1113
1174
  return false;
1114
1175
  }
@@ -1116,25 +1177,54 @@ function asExpression(src) {
1116
1177
  const t = src.trim();
1117
1178
  return t.startsWith("{") ? `(${t})` : src;
1118
1179
  }
1180
+ function sourceOf(src) {
1181
+ return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1182
+ }
1183
+ function resolverFor(sf) {
1184
+ const consts = /* @__PURE__ */ new Map();
1185
+ for (const st of sf.statements) {
1186
+ if (!ts2.isVariableStatement(st)) continue;
1187
+ for (const d of st.declarationList.declarations) {
1188
+ if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
1189
+ }
1190
+ }
1191
+ return (e) => {
1192
+ if (ts2.isIdentifier(e)) {
1193
+ const bound = consts.get(e.text);
1194
+ return bound ?? e;
1195
+ }
1196
+ return e;
1197
+ };
1198
+ }
1119
1199
  function parseThresholds(src) {
1120
- const res = { paths: /* @__PURE__ */ new Map(), present: false };
1200
+ const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
1121
1201
  try {
1122
- const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1202
+ const sf = sourceOf(src);
1203
+ const resolve5 = resolverFor(sf);
1123
1204
  const visit = (node) => {
1124
- if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
1205
+ if (ts2.isPropertyAssignment(node)) {
1125
1206
  const key2 = keyName(node.name);
1126
- if (key2 === "coverageThreshold" || key2 === "thresholds" && underCoverageKey(node)) {
1207
+ if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
1127
1208
  res.present = true;
1128
- const flat = metricsOf(node.initializer);
1129
- if (flat) res.global = { ...res.global ?? {}, ...flat };
1130
- for (const p of node.initializer.properties) {
1131
- if (!ts2.isPropertyAssignment(p)) continue;
1132
- const k = keyName(p.name);
1133
- if (k === null || isMetric(k)) continue;
1134
- const m = metricsOf(p.initializer);
1135
- if (!m) continue;
1136
- if (k === "global") res.global = { ...res.global ?? {}, ...m };
1137
- else res.paths.set(norm(k), m);
1209
+ const init = resolve5(node.initializer);
1210
+ if (!ts2.isObjectLiteralExpression(init)) {
1211
+ res.opaque = true;
1212
+ } else {
1213
+ const flat = metricsOf(init, resolve5);
1214
+ if (flat) res.global = merge(res.global, flat);
1215
+ for (const p of init.properties) {
1216
+ if (ts2.isSpreadAssignment(p)) {
1217
+ res.opaque = true;
1218
+ continue;
1219
+ }
1220
+ if (!ts2.isPropertyAssignment(p)) continue;
1221
+ const k = keyName(p.name);
1222
+ if (k === null || isMetric(k)) continue;
1223
+ const m = metricsOf(p.initializer, resolve5);
1224
+ if (!m) continue;
1225
+ if (k === "global") res.global = merge(res.global, m);
1226
+ else res.paths.set(norm(k), m);
1227
+ }
1138
1228
  }
1139
1229
  }
1140
1230
  }
@@ -1145,41 +1235,242 @@ function parseThresholds(src) {
1145
1235
  }
1146
1236
  return res;
1147
1237
  }
1148
- var eff = (t, path) => t.paths.get(norm(path)) ?? t.global;
1238
+ function merge(a, b) {
1239
+ return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
1240
+ }
1241
+ function literals(e) {
1242
+ if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
1243
+ if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
1244
+ const items = [];
1245
+ let opaque = false;
1246
+ for (const el of e.elements) {
1247
+ if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
1248
+ else opaque = true;
1249
+ }
1250
+ return { items, opaque };
1251
+ }
1252
+ function parseLists(src) {
1253
+ const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1254
+ try {
1255
+ const sf = sourceOf(src);
1256
+ const resolve5 = resolverFor(sf);
1257
+ const visit = (node) => {
1258
+ if (ts2.isPropertyAssignment(node)) {
1259
+ const k = keyName(node.name);
1260
+ if (k === "collectCoverageFrom") {
1261
+ const { items, opaque } = literals(resolve5(node.initializer));
1262
+ res.collectFrom = items;
1263
+ if (opaque) res.opaqueCollect = true;
1264
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1265
+ const { items, opaque } = literals(resolve5(node.initializer));
1266
+ res.ignore.push(...items);
1267
+ if (opaque) res.opaqueIgnore = true;
1268
+ }
1269
+ }
1270
+ ts2.forEachChild(node, visit);
1271
+ };
1272
+ visit(sf);
1273
+ } catch {
1274
+ }
1275
+ return res;
1276
+ }
1277
+ 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;
1149
1278
  function compareMetrics(scope, before, after, out2) {
1150
1279
  if (!before) return;
1151
1280
  for (const m of METRICS) {
1152
- const bv = before[m];
1281
+ const bv = before.values[m];
1153
1282
  if (bv === void 0) continue;
1154
- const av = after?.[m];
1155
- if (av === void 0) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
1156
- else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
1283
+ const av = after?.values[m];
1284
+ if (av === void 0) {
1285
+ if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
1286
+ } else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
1287
+ }
1288
+ }
1289
+ function overrideTargetExists(key2, configPath, ctx) {
1290
+ const files = trackedFiles(ctx);
1291
+ if (!files) return null;
1292
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1293
+ const rel = norm(key2);
1294
+ let m;
1295
+ try {
1296
+ m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
1297
+ } catch {
1298
+ return null;
1157
1299
  }
1300
+ return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1158
1301
  }
1159
- function weakenings(before, after) {
1302
+ function weakenings(before, after, configPath, ctx) {
1160
1303
  const out2 = [];
1161
1304
  if (before.present && !after.present) {
1162
1305
  out2.push("the coverage threshold gate was removed");
1163
1306
  return out2;
1164
1307
  }
1308
+ if (after.opaque && !after.global && after.paths.size === 0) return out2;
1165
1309
  compareMetrics("global", before.global, after.global, out2);
1166
1310
  const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
1167
1311
  for (const p of paths) {
1168
- compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p), out2);
1312
+ const scope = `required coverage for ${p}:`;
1313
+ const b = before.paths.get(p);
1314
+ const a = after.paths.get(p);
1315
+ if (b && a) {
1316
+ compareMetrics(scope, b, a, out2);
1317
+ } else if (a) {
1318
+ const g = before.global;
1319
+ if (!g) continue;
1320
+ for (const m of METRICS) {
1321
+ const gv = g.values[m];
1322
+ const av = a.values[m];
1323
+ if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
1324
+ }
1325
+ } else if (b) {
1326
+ if (overrideTargetExists(p, configPath, ctx) === false) continue;
1327
+ compareMetrics(scope, b, after.global, out2);
1328
+ }
1329
+ }
1330
+ return out2;
1331
+ }
1332
+ function narrowings(before, after) {
1333
+ const out2 = [];
1334
+ if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
1335
+ const kept = new Set(after.collectFrom);
1336
+ for (const g of before.collectFrom) {
1337
+ if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
1338
+ }
1339
+ const had2 = new Set(before.collectFrom);
1340
+ for (const g of after.collectFrom) {
1341
+ if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
1342
+ }
1343
+ }
1344
+ const had = new Set(before.ignore);
1345
+ for (const g of after.ignore) {
1346
+ if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
1347
+ }
1348
+ return out2;
1349
+ }
1350
+ var SIMPLE_FILES = [
1351
+ {
1352
+ // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1353
+ // [tool.coverage.report] (pyproject.toml)
1354
+ file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1355
+ keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1356
+ removable: true
1357
+ },
1358
+ {
1359
+ // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1360
+ file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
1361
+ keys: METRICS.map((m) => ({
1362
+ key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1363
+ label: m,
1364
+ weakens: "lower"
1365
+ })),
1366
+ removable: false
1367
+ },
1368
+ {
1369
+ // codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
1370
+ // permitted drop (raising weakens)
1371
+ file: /(?:^|\/)\.?codecov\.yml$/,
1372
+ keys: [
1373
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1374
+ { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1375
+ ],
1376
+ removable: false
1377
+ }
1378
+ ];
1379
+ function numbersOf(src, key2) {
1380
+ const out2 = [];
1381
+ for (const line of src.split("\n")) {
1382
+ if (key2.global) {
1383
+ for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
1384
+ } else {
1385
+ const m = line.match(key2);
1386
+ if (m) out2.push(Number(m[1]));
1387
+ }
1388
+ }
1389
+ return out2;
1390
+ }
1391
+ function simpleWeakenings(path, before, after) {
1392
+ const out2 = [];
1393
+ const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1394
+ if (!spec) return out2;
1395
+ for (const k of spec.keys) {
1396
+ const b = numbersOf(before, k.key).sort((x, y) => y - x);
1397
+ const a = numbersOf(after, k.key).sort((x, y) => y - x);
1398
+ if (b.length === 0) continue;
1399
+ if (a.length === 0) {
1400
+ if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
1401
+ continue;
1402
+ }
1403
+ if (k.weakens === "lower") {
1404
+ for (let i = 0; i < b.length; i++) {
1405
+ const av = a[Math.min(i, a.length - 1)];
1406
+ if (av < b[i]) {
1407
+ out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
1408
+ break;
1409
+ }
1410
+ }
1411
+ } else {
1412
+ const bMax = b[0];
1413
+ const aMax = a[0];
1414
+ if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1415
+ }
1169
1416
  }
1170
1417
  return out2;
1171
1418
  }
1419
+ var SWITCHES = [
1420
+ {
1421
+ re: /--passWithNoTests\b/,
1422
+ message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
1423
+ remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1424
+ },
1425
+ {
1426
+ re: /\bpassWithNoTests"?\s*:\s*true\b/,
1427
+ message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
1428
+ remediation: "Remove passWithNoTests; an empty suite must not be a green check."
1429
+ },
1430
+ {
1431
+ re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
1432
+ message: "coverage switched off on the command line (--coverage=false).",
1433
+ remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
1434
+ },
1435
+ {
1436
+ re: /--coverageThreshold[= ]/,
1437
+ message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
1438
+ remediation: "Remove the override; the configured threshold is the gate."
1439
+ },
1440
+ {
1441
+ re: /\bautoUpdate"?\s*:\s*true\b/,
1442
+ message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
1443
+ remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
1444
+ },
1445
+ {
1446
+ re: /"?check-coverage"?\s*[:=]\s*false\b/,
1447
+ message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1448
+ remediation: "Keep check-coverage on; raise real coverage instead."
1449
+ },
1450
+ {
1451
+ re: /^\s*informational:\s*true\b/,
1452
+ message: "informational: true added \u2014 the codecov status can no longer fail.",
1453
+ remediation: "Remove informational; a status that cannot fail is not a gate."
1454
+ }
1455
+ ];
1456
+ var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1172
1457
  var coverageLowering = {
1173
1458
  id: RULE4,
1174
1459
  surface: ["file"],
1175
1460
  certainty: "mechanical",
1176
- run(changes, policy) {
1461
+ run(changes, policy, _view, ctx) {
1177
1462
  const out2 = [];
1178
1463
  for (const c of changes) {
1179
1464
  if (c.kind !== "file") continue;
1180
1465
  if (!isProtected(c.path, policy, "config")) continue;
1181
1466
  if (c.after != null && c.op !== "delete") {
1182
- for (const reason of weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after))) {
1467
+ const reasons = [];
1468
+ if (isJsConfig(c.path)) {
1469
+ reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
1470
+ reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
1471
+ }
1472
+ reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1473
+ for (const reason of reasons) {
1183
1474
  out2.push(
1184
1475
  makeFinding(RULE4, policy, {
1185
1476
  file: c.path,
@@ -1190,7 +1481,9 @@ var coverageLowering = {
1190
1481
  );
1191
1482
  }
1192
1483
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1193
- if (parseThresholds(c.before ?? "").present) {
1484
+ const before = c.before ?? "";
1485
+ const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
1486
+ if (gated) {
1194
1487
  out2.push(
1195
1488
  makeFinding(RULE4, policy, {
1196
1489
  file: c.path,
@@ -1215,20 +1508,22 @@ var coverageLowering = {
1215
1508
  );
1216
1509
  }
1217
1510
  for (const l of added) {
1218
- if (/--passWithNoTests\b/.test(l.content) || /\bpassWithNoTests\s*:\s*true\b/.test(l.content)) {
1511
+ for (const s of SWITCHES) {
1512
+ if (!s.re.test(l.content)) continue;
1513
+ if (removed.some((r) => s.re.test(r.content))) continue;
1219
1514
  out2.push(
1220
1515
  makeFinding(RULE4, policy, {
1221
1516
  file: c.path,
1222
1517
  line: l.newLine ?? void 0,
1223
- message: "passWithNoTests added \u2014 the suite can now pass with zero tests.",
1518
+ message: s.message,
1224
1519
  evidence: l.content.trim(),
1225
- remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1520
+ remediation: s.remediation
1226
1521
  })
1227
1522
  );
1228
1523
  }
1229
1524
  }
1230
1525
  if (c.after == null) {
1231
- const KEY = /\b(branches|functions|lines|statements)\b\s*:\s*(\d+(?:\.\d+)?)/;
1526
+ const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
1232
1527
  const oldNums = /* @__PURE__ */ new Map();
1233
1528
  for (const l of removed) {
1234
1529
  const m = l.content.match(KEY);
@@ -1254,7 +1549,7 @@ var coverageLowering = {
1254
1549
  }
1255
1550
  const seen = /* @__PURE__ */ new Set();
1256
1551
  return out2.filter((f) => {
1257
- const k = `${f.rule}|${f.message}`;
1552
+ const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
1258
1553
  if (seen.has(k)) return false;
1259
1554
  seen.add(k);
1260
1555
  return true;
@@ -1276,23 +1571,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1276
1571
  function commandCore(line) {
1277
1572
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1278
1573
  }
1574
+ function canonical(core) {
1575
+ let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
1576
+ s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1577
+ s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1578
+ s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
1579
+ s = s.replace(/^exec\s+/, "");
1580
+ return s.replace(/\s+--\s*$/, "").trim();
1581
+ }
1582
+ var usesRef = (core) => core.replace(/@.*$/, "");
1583
+ var NEUTRALISING_SUFFIX = /\|\||;|(?<!&)&(?!&)|\|(?!\|)|--passWithNoTests\b|--coverage=false\b|--coverageThreshold\b|--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b/;
1584
+ function survives(removedCore, isUses, afterCores) {
1585
+ if (isUses) {
1586
+ const ref = usesRef(removedCore);
1587
+ return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1588
+ }
1589
+ const r = canonical(removedCore);
1590
+ let neutralised;
1591
+ for (const raw of afterCores) {
1592
+ const a = canonical(raw);
1593
+ if (a === r) return { state: "kept" };
1594
+ if (a.startsWith(r + " ")) {
1595
+ const rest = a.slice(r.length);
1596
+ if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1597
+ else return { state: "kept" };
1598
+ } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1599
+ return { state: "kept" };
1600
+ }
1601
+ }
1602
+ return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1603
+ }
1279
1604
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1280
- function isAlwaysFalse(raw) {
1281
- const v = uncommented(raw);
1282
- if (/^false$/i.test(v)) return true;
1605
+ function lex(src) {
1606
+ const out2 = [];
1607
+ let i = 0;
1608
+ while (i < src.length) {
1609
+ const ch = src[i];
1610
+ if (/\s/.test(ch)) {
1611
+ i++;
1612
+ continue;
1613
+ }
1614
+ if (ch === "'") {
1615
+ let j = i + 1;
1616
+ let s = "";
1617
+ while (j < src.length) {
1618
+ if (src[j] === "'" && src[j + 1] === "'") {
1619
+ s += "'";
1620
+ j += 2;
1621
+ } else if (src[j] === "'") break;
1622
+ else s += src[j++];
1623
+ }
1624
+ if (j >= src.length) return null;
1625
+ out2.push({ t: "str", v: s });
1626
+ i = j + 1;
1627
+ continue;
1628
+ }
1629
+ const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
1630
+ if (num) {
1631
+ out2.push({ t: "num", v: num[0] });
1632
+ i += num[0].length;
1633
+ continue;
1634
+ }
1635
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1636
+ if (op) {
1637
+ out2.push({ t: "op", v: op[0] });
1638
+ i += op[0].length;
1639
+ continue;
1640
+ }
1641
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1642
+ if (id) {
1643
+ out2.push({ t: "id", v: id[0] });
1644
+ i += id[0].length;
1645
+ continue;
1646
+ }
1647
+ return null;
1648
+ }
1649
+ return out2;
1650
+ }
1651
+ function foldConst(src) {
1652
+ const toks = lex(src);
1653
+ if (!toks) return void 0;
1654
+ let p = 0;
1655
+ const peek = () => toks[p];
1656
+ const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
1657
+ const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
1658
+ const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1659
+ const eq = (a, b) => {
1660
+ if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1661
+ if (a === null && b === null) return true;
1662
+ const x = num(a);
1663
+ const y = num(b);
1664
+ return !Number.isNaN(x) && x === y;
1665
+ };
1666
+ const or = () => {
1667
+ let l = and();
1668
+ while (eat("||")) {
1669
+ const r = and();
1670
+ if (l === void 0 || r === void 0) return void 0;
1671
+ l = truthy(l) ? l : r;
1672
+ }
1673
+ return l;
1674
+ };
1675
+ const and = () => {
1676
+ let l = cmp();
1677
+ while (eat("&&")) {
1678
+ const r = cmp();
1679
+ if (l === void 0 || r === void 0) return void 0;
1680
+ l = truthy(l) ? r : l;
1681
+ }
1682
+ return l;
1683
+ };
1684
+ const cmp = () => {
1685
+ let l = unary();
1686
+ for (; ; ) {
1687
+ const t = peek();
1688
+ if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1689
+ p++;
1690
+ const r = unary();
1691
+ if (l === void 0 || r === void 0) return void 0;
1692
+ if (t.v === "==") l = eq(l, r);
1693
+ else if (t.v === "!=") l = !eq(l, r);
1694
+ else {
1695
+ const x = num(l);
1696
+ const y = num(r);
1697
+ if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
1698
+ l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
1699
+ }
1700
+ }
1701
+ };
1702
+ const unary = () => {
1703
+ if (eat("!")) {
1704
+ const v2 = unary();
1705
+ return v2 === void 0 ? void 0 : !truthy(v2);
1706
+ }
1707
+ return primary();
1708
+ };
1709
+ const primary = () => {
1710
+ const t = peek();
1711
+ if (!t) return void 0;
1712
+ if (eat("(")) {
1713
+ const v2 = or();
1714
+ return eat(")") ? v2 : void 0;
1715
+ }
1716
+ p++;
1717
+ if (t.t === "str") return t.v;
1718
+ if (t.t === "num") return Number(t.v);
1719
+ if (t.t === "id") {
1720
+ if (/^true$/i.test(t.v)) return true;
1721
+ if (/^false$/i.test(t.v)) return false;
1722
+ if (/^null$/i.test(t.v)) return null;
1723
+ return void 0;
1724
+ }
1725
+ return void 0;
1726
+ };
1727
+ const v = or();
1728
+ return p === toks.length ? v : void 0;
1729
+ }
1730
+ function expressionOf(raw) {
1731
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1283
1732
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1284
- if (!m) return false;
1285
- const e = m[1];
1286
- if (/^(false|0|''|"")$/i.test(e)) return true;
1287
- const cmp = e.match(/^(-?\d+)\s*(==|!=)\s*(-?\d+)$/);
1288
- if (cmp) return cmp[2] === "==" ? cmp[1] !== cmp[3] : cmp[1] === cmp[3];
1289
- return false;
1733
+ return m ? m[1] : v;
1734
+ }
1735
+ function isAlwaysFalse(raw) {
1736
+ const e = expressionOf(raw);
1737
+ if (e === "") return false;
1738
+ const v = foldConst(e);
1739
+ return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
1290
1740
  }
1291
1741
  function isTruthy(raw) {
1292
- const v = uncommented(raw);
1293
- if (/^true$/i.test(v)) return true;
1742
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1743
+ if (/^(?:true|yes|on|1)$/i.test(v)) return true;
1294
1744
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1295
- return !!m && /^(true|1)$/i.test(m[1]);
1745
+ if (!m) return false;
1746
+ const f = foldConst(m[1]);
1747
+ return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
1748
+ }
1749
+ var indentOf = (l) => l.match(/^\s*/)[0].length;
1750
+ var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
1751
+ function stepOf(lines, i) {
1752
+ const ind = indentOf(lines[i]);
1753
+ let s = i - 1;
1754
+ while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
1755
+ if (s < 0 || !STEP_START.test(lines[s])) return null;
1756
+ const stepInd = indentOf(lines[s]);
1757
+ let e = i + 1;
1758
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1759
+ return [s, e];
1760
+ }
1761
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
1762
+ function runBlocks(lines) {
1763
+ const out2 = [];
1764
+ for (let i = 0; i < lines.length; i++) {
1765
+ if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
1766
+ const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
1767
+ let e = i + 1;
1768
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
1769
+ out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
1770
+ }
1771
+ return out2;
1772
+ }
1773
+ var NEUTRALISERS = [
1774
+ { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
1775
+ { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
1776
+ { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1777
+ { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1778
+ ];
1779
+ function flowList(v) {
1780
+ const m = v.trim().match(/^\[(.*)\]$/);
1781
+ if (!m) return null;
1782
+ return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1783
+ }
1784
+ function parseTriggers(src) {
1785
+ const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
1786
+ const lines = src.split("\n");
1787
+ const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1788
+ if (i < 0) return t;
1789
+ t.present = true;
1790
+ const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
1791
+ const inline = uncommented(head);
1792
+ if (inline) {
1793
+ const list = flowList(inline);
1794
+ for (const e of list ?? [inline]) t.events.add(e);
1795
+ return t;
1796
+ }
1797
+ let event = null;
1798
+ let listKey = null;
1799
+ let listInd = -1;
1800
+ for (let j = i + 1; j < lines.length; j++) {
1801
+ const l = lines[j];
1802
+ if (l.trim() === "" || /^\s*#/.test(l)) continue;
1803
+ const ind = indentOf(l);
1804
+ if (ind === 0) break;
1805
+ const body = uncommented(l);
1806
+ const item = body.match(/^\s*-\s+(.+)$/);
1807
+ if (item && listKey && event && ind > listInd) {
1808
+ const target = t[listKey].get(event) ?? [];
1809
+ target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
1810
+ t[listKey].set(event, target);
1811
+ continue;
1812
+ }
1813
+ const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
1814
+ if (!kv) continue;
1815
+ if (ind === 2 || event === null && ind < 4) {
1816
+ event = kv[1];
1817
+ t.events.add(event);
1818
+ listKey = null;
1819
+ continue;
1820
+ }
1821
+ if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1822
+ listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
1823
+ listInd = ind;
1824
+ const list = flowList(kv[2]);
1825
+ t[listKey].set(event, list ?? []);
1826
+ } else listKey = null;
1827
+ }
1828
+ return t;
1829
+ }
1830
+ var DEFAULT_BRANCH = /^(?:main|master)$/;
1831
+ var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1832
+ function triggerNarrowings(before, after) {
1833
+ const out2 = [];
1834
+ if (!before.present || !after.present) return out2;
1835
+ for (const e of ["push", "pull_request", "pull_request_target"]) {
1836
+ if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
1837
+ }
1838
+ for (const [e, list] of after.pathsIgnore) {
1839
+ const had = before.pathsIgnore.get(e) ?? [];
1840
+ 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}`);
1841
+ }
1842
+ for (const [e, list] of after.branchesIgnore) {
1843
+ const had = before.branchesIgnore.get(e) ?? [];
1844
+ if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1845
+ }
1846
+ for (const [e, was] of before.branches) {
1847
+ const now = after.branches.get(e);
1848
+ if (!now || !after.events.has(e)) continue;
1849
+ if (now.some(ALL)) continue;
1850
+ const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
1851
+ const replaced = was.length > 0 && !was.some((b) => now.includes(b));
1852
+ if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1853
+ }
1854
+ return out2;
1296
1855
  }
1297
1856
  var ciTampering = {
1298
1857
  id: RULE5,
@@ -1314,9 +1873,14 @@ var ciTampering = {
1314
1873
  continue;
1315
1874
  }
1316
1875
  if (!isProtected(c.path, policy, "ci")) continue;
1876
+ const afterLines = c.after != null ? c.after.split("\n") : null;
1877
+ const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
1317
1878
  for (const l of addedLines(c)) {
1318
1879
  const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
1319
1880
  const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
1881
+ if (!coe && !cond) continue;
1882
+ const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1883
+ if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
1320
1884
  if (coe && isTruthy(coe[1])) {
1321
1885
  out2.push(
1322
1886
  makeFinding(RULE5, policy, {
@@ -1339,15 +1903,72 @@ var ciTampering = {
1339
1903
  );
1340
1904
  }
1341
1905
  }
1342
- const afterCores = new Set(
1343
- (c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
1344
- );
1906
+ if (afterLines) {
1907
+ for (const b of runBlocks(afterLines)) {
1908
+ if (!b.body.some(invokesCheck)) continue;
1909
+ const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
1910
+ for (let k = 0; k < b.body.length; k++) {
1911
+ const idx = b.start + 1 + k;
1912
+ if (!addedAt.has(idx + 1)) continue;
1913
+ const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
1914
+ if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
1915
+ out2.push(
1916
+ makeFinding(RULE5, policy, {
1917
+ file: c.path,
1918
+ line: idx + 1,
1919
+ message: `A check's run block was neutralised: ${n.what}.`,
1920
+ evidence: b.body[k].trim(),
1921
+ remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
1922
+ })
1923
+ );
1924
+ }
1925
+ }
1926
+ for (const l of addedLines(c)) {
1927
+ if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
1928
+ const step = stepOf(afterLines, l.newLine - 1);
1929
+ if (step && !stepHasCheck(afterLines, step)) continue;
1930
+ out2.push(
1931
+ makeFinding(RULE5, policy, {
1932
+ file: c.path,
1933
+ line: l.newLine,
1934
+ message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
1935
+ evidence: l.content.trim(),
1936
+ remediation: "Keep the default shell (fail-fast) on steps that run checks."
1937
+ })
1938
+ );
1939
+ }
1940
+ if (c.before != null) {
1941
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
1942
+ out2.push(
1943
+ makeFinding(RULE5, policy, {
1944
+ file: c.path,
1945
+ message: `The workflow's triggers were narrowed: ${reason}.`,
1946
+ evidence: reason,
1947
+ remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
1948
+ })
1949
+ );
1950
+ }
1951
+ }
1952
+ }
1953
+ const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
1345
1954
  for (const l of removedLines(c)) {
1346
1955
  if (/^\s*#/.test(l.content)) continue;
1347
1956
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1348
1957
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
1349
1958
  if (!isUsesLine && !isCheckCommand) continue;
1350
- if (afterCores.has(commandCore(l.content))) continue;
1959
+ const s = survives(commandCore(l.content), isUsesLine, afterCores);
1960
+ if (s.state === "kept") continue;
1961
+ if (s.state === "neutralised") {
1962
+ out2.push(
1963
+ makeFinding(RULE5, policy, {
1964
+ file: c.path,
1965
+ message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1966
+ evidence: `${l.content.trim()} \u2192 ${s.by}`,
1967
+ remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
1968
+ })
1969
+ );
1970
+ continue;
1971
+ }
1351
1972
  out2.push(
1352
1973
  makeFinding(RULE5, policy, {
1353
1974
  file: c.path,
@@ -1362,6 +1983,10 @@ var ciTampering = {
1362
1983
  }
1363
1984
  };
1364
1985
 
1986
+ // src/detectors/hook-tampering.ts
1987
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1988
+ import { join as join2 } from "node:path";
1989
+
1365
1990
  // src/detectors/policy-diff.ts
1366
1991
  import { parse } from "yaml";
1367
1992
  var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
@@ -1472,19 +2097,390 @@ function policyWeakening(before, after) {
1472
2097
  }
1473
2098
  return reasons;
1474
2099
  }
2100
+ function policyAddWeakening(after) {
2101
+ let doc;
2102
+ try {
2103
+ doc = parse(after);
2104
+ } catch {
2105
+ return ["the added policy is not valid YAML \u2014 the gate fails closed until it is fixed"];
2106
+ }
2107
+ if (doc === null || doc === void 0) return [];
2108
+ if (typeof doc !== "object" || Array.isArray(doc)) {
2109
+ return ["the added policy is not a policy mapping \u2014 the gate fails closed until it is fixed"];
2110
+ }
2111
+ return policyWeakening("{}", after) ?? [];
2112
+ }
1475
2113
 
1476
- // src/detectors/hook-tampering.ts
1477
- var RULE6 = "hook-tampering";
1478
- var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
1479
- var isShellComment = (s) => /^\s*#/.test(s);
1480
- var ownerExec = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
2114
+ // src/detectors/hook-wiring.ts
2115
+ import { parse as parseYaml } from "yaml";
2116
+ function stripComment(line) {
2117
+ let single = false;
2118
+ let double = false;
2119
+ for (let i = 0; i < line.length; i++) {
2120
+ const ch = line[i];
2121
+ if (ch === "'" && !double) single = !single;
2122
+ else if (ch === '"' && !single) double = !double;
2123
+ else if (ch === "#" && !single && !double && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
2124
+ }
2125
+ return line;
2126
+ }
2127
+ var isComment = (line) => /^\s*#/.test(line);
2128
+ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:--yes|-y|--no-install|--silent|-s|--)\s+)*/;
2129
+ var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
2130
+ var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
2131
+ var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
2132
+ var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2133
+ function checkIdentity(segment) {
2134
+ const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
2135
+ const viaRunner = RUNNER.test(core);
2136
+ const rest = core.replace(RUNNER, "");
2137
+ const tool = rest.match(TOOL)?.[1] ?? (viaRunner ? rest.match(SCRIPT)?.[1] : void 0);
2138
+ if (!tool) return null;
2139
+ const toks = rest.replace(/^tamperward@\S+/, "tamperward").split(" ");
2140
+ if (tool === "tamperward") {
2141
+ const sub = toks[1];
2142
+ if (!sub || sub.startsWith("-")) return null;
2143
+ return `tamperward ${sub}`;
2144
+ }
2145
+ if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
2146
+ return tool;
2147
+ }
2148
+ function split(stmt) {
2149
+ const segs = [];
2150
+ const ops = [];
2151
+ const re = /(&&|\|\||;|\|)/g;
2152
+ let last = 0;
2153
+ for (const m of stmt.matchAll(re)) {
2154
+ segs.push(stmt.slice(last, m.index));
2155
+ ops.push(m[1]);
2156
+ last = (m.index ?? 0) + m[1].length;
2157
+ }
2158
+ segs.push(stmt.slice(last));
2159
+ return { segs, ops };
2160
+ }
2161
+ var unq = (s) => s.replace(/^["']|["']$/g, "");
2162
+ function alwaysFalse(cond) {
2163
+ const c = cond.trim().replace(/;$/, "").trim();
2164
+ if (/^(?:false|!\s*true|\(\s*exit\s+[1-9]\d*\s*\))$/.test(c)) return true;
2165
+ const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
2166
+ if (!m) return false;
2167
+ const inner = (m[1] ?? m[2] ?? m[3]).trim();
2168
+ if (inner.includes("$")) return false;
2169
+ const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
2170
+ if (parts) {
2171
+ const [, a, op, b] = parts;
2172
+ const x = unq(a);
2173
+ const y = unq(b);
2174
+ const nx = Number(x);
2175
+ const ny = Number(y);
2176
+ const ints = /^-?\d+$/.test(x) && /^-?\d+$/.test(y);
2177
+ switch (op) {
2178
+ case "-eq":
2179
+ return ints && nx !== ny;
2180
+ case "-ne":
2181
+ return ints && nx === ny;
2182
+ case "-lt":
2183
+ return ints && !(nx < ny);
2184
+ case "-le":
2185
+ return ints && !(nx <= ny);
2186
+ case "-gt":
2187
+ return ints && !(nx > ny);
2188
+ case "-ge":
2189
+ return ints && !(nx >= ny);
2190
+ case "=":
2191
+ case "==":
2192
+ return x !== y;
2193
+ case "!=":
2194
+ return x === y;
2195
+ }
2196
+ }
2197
+ const unary = inner.match(/^(-n|-z)\s+(\S+)$/);
2198
+ if (unary) {
2199
+ const v = unq(unary[2]);
2200
+ return unary[1] === "-n" ? v === "" : v !== "";
2201
+ }
2202
+ if (/^(?:""|'')$/.test(inner)) return true;
2203
+ return false;
2204
+ }
2205
+ var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
2206
+ var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
2207
+ var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
2208
+ var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
2209
+ function insertsPassingExit(line) {
2210
+ if (isComment(line)) return false;
2211
+ return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
2212
+ }
2213
+ function insertsDeadGuard(line) {
2214
+ if (isComment(line)) return false;
2215
+ const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2216
+ return !!m && alwaysFalse(m[1]);
2217
+ }
2218
+ function invocations(lines) {
2219
+ const out2 = [];
2220
+ const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
2221
+ const frames = [{ dead: false }];
2222
+ const dead = () => frames.some((f) => f.dead);
2223
+ for (const raw of lines) {
2224
+ if (isComment(raw)) {
2225
+ const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
2226
+ if (id) out2.push({ identity: id, line: raw, state: "comment" });
2227
+ continue;
2228
+ }
2229
+ const stmt = stripComment(raw).trim();
2230
+ if (!stmt) continue;
2231
+ const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2232
+ if (ifm) {
2233
+ if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
2234
+ else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
2235
+ continue;
2236
+ }
2237
+ if (/^else\b/.test(stmt)) {
2238
+ if (frames.length > 1) frames[frames.length - 1].dead = false;
2239
+ continue;
2240
+ }
2241
+ if (/^fi\b/.test(stmt)) {
2242
+ if (frames.length > 1) frames.pop();
2243
+ continue;
2244
+ }
2245
+ const { segs, ops } = split(stmt);
2246
+ for (let i = 0; i < segs.length; i++) {
2247
+ const seg = segs[i].trim().replace(PREFIX, "");
2248
+ const id = checkIdentity(seg);
2249
+ if (!id) {
2250
+ if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
2251
+ continue;
2252
+ }
2253
+ let state = dead() ? "unreachable" : "live";
2254
+ if (state === "live" && i > 0) {
2255
+ if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
2256
+ if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
2257
+ }
2258
+ if (state === "live" && i < ops.length) {
2259
+ const after = segs.slice(i + 1).join(" ");
2260
+ if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
2261
+ else if (ops[i] === "|" && !pipefail) state = "neutered";
2262
+ else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
2263
+ }
2264
+ out2.push({ identity: id, line: raw, state });
2265
+ }
2266
+ }
2267
+ return out2;
2268
+ }
2269
+ function scriptWeakening(before, after) {
2270
+ const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
2271
+ const bl = live(invocations(before));
2272
+ const afterInv = invocations(after);
2273
+ const al = live(afterInv);
2274
+ const out2 = [];
2275
+ for (const id of bl) {
2276
+ if (al.has(id)) continue;
2277
+ const trace = afterInv.find((i) => i.identity === id);
2278
+ 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";
2279
+ const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
2280
+ out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
2281
+ }
2282
+ return out2;
2283
+ }
2284
+ var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
2285
+ var GATE_CRITICAL = [
2286
+ "/.github/workflows/",
2287
+ "/.tamperward.yml",
2288
+ "/.github/CODEOWNERS",
2289
+ "/CODEOWNERS",
2290
+ "/docs/CODEOWNERS",
2291
+ "/.husky/",
2292
+ "/.claude/settings.json",
2293
+ "/.claude/settings.local.json",
2294
+ "/.pre-commit-config.yaml",
2295
+ "/lefthook.yml",
2296
+ "/lefthook.yaml"
2297
+ ];
2298
+ function covers(pattern, critical) {
2299
+ if (pattern === "*" || pattern === "**") return true;
2300
+ let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
2301
+ if (!p.startsWith("/")) p = "/" + p;
2302
+ if (p === critical) return true;
2303
+ if (p.endsWith("/") && critical.startsWith(p)) return true;
2304
+ return critical.startsWith(p + "/");
2305
+ }
2306
+ function ownersOf(content, critical) {
2307
+ let owners = null;
2308
+ for (const line of content.split("\n")) {
2309
+ const t = line.trim();
2310
+ if (!t || t.startsWith("#")) continue;
2311
+ const [pattern, ...rest] = t.split(/\s+/);
2312
+ if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
2313
+ }
2314
+ return owners;
2315
+ }
2316
+ function codeownersWeakening(before, after) {
2317
+ const out2 = [];
2318
+ for (const critical of GATE_CRITICAL) {
2319
+ const was = ownersOf(before, critical);
2320
+ if (!was || was.length === 0) continue;
2321
+ const now = ownersOf(after, critical);
2322
+ if (!now) out2.push(`the code-owner rule covering ${critical} was removed \u2014 the gate's own files no longer require a human reviewer`);
2323
+ else if (now.length === 0) out2.push(`the code-owner rule covering ${critical} lost its owners \u2014 the gate's own files no longer require a human reviewer`);
2324
+ }
2325
+ return out2;
2326
+ }
2327
+ var isDoc = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2328
+ function parseDoc(src) {
2329
+ try {
2330
+ const v = parseYaml(src);
2331
+ return isDoc(v) ? v : null;
2332
+ } catch {
2333
+ return null;
2334
+ }
2335
+ }
2336
+ var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
2337
+ var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
2338
+ var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
2339
+ var GATE = /\btamperward\b/;
2340
+ var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
2341
+ function lefthookEntries(doc) {
2342
+ const out2 = /* @__PURE__ */ new Map();
2343
+ for (const [section, v] of Object.entries(doc)) {
2344
+ if (!isDoc(v)) continue;
2345
+ for (const group of ["commands", "scripts"]) {
2346
+ const g = v[group];
2347
+ if (!isDoc(g)) continue;
2348
+ for (const [name, cfg] of Object.entries(g)) {
2349
+ if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
2350
+ out2.set(`${section}.${group}.${name}`, {
2351
+ skip: cfg.skip,
2352
+ sectionSkip: v.skip,
2353
+ glob: str(cfg.glob),
2354
+ exclude: str(cfg.exclude),
2355
+ only: str(cfg.only)
2356
+ });
2357
+ }
2358
+ }
2359
+ }
2360
+ return out2;
2361
+ }
2362
+ function lefthookWeakening(before, after) {
2363
+ const out2 = [];
2364
+ const b = lefthookEntries(before);
2365
+ const a = lefthookEntries(after);
2366
+ for (const [key2, was] of b) {
2367
+ const now = a.get(key2);
2368
+ if (!now) {
2369
+ out2.push(`lefthook entry ${key2} (the gate) was removed`);
2370
+ continue;
2371
+ }
2372
+ if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
2373
+ if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
2374
+ 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`);
2375
+ if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2376
+ if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
2377
+ }
2378
+ return out2;
2379
+ }
2380
+ var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
2381
+ function preCommitEntries(doc) {
2382
+ const out2 = /* @__PURE__ */ new Map();
2383
+ const repos = Array.isArray(doc.repos) ? doc.repos : [];
2384
+ for (const repo of repos) {
2385
+ if (!isDoc(repo)) continue;
2386
+ const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
2387
+ for (const h of hooks) {
2388
+ if (!isDoc(h)) continue;
2389
+ if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
2390
+ out2.set(`${str(repo.repo)}:${str(h.id)}`, {
2391
+ stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
2392
+ exclude: str(h.exclude),
2393
+ files: str(h.files)
2394
+ });
2395
+ }
2396
+ }
2397
+ return out2;
2398
+ }
2399
+ function preCommitWeakening(before, after) {
2400
+ const out2 = [];
2401
+ const b = preCommitEntries(before);
2402
+ const a = preCommitEntries(after);
2403
+ for (const [key2, was] of b) {
2404
+ const now = a.get(key2);
2405
+ if (!now) {
2406
+ out2.push(`pre-commit hook ${key2} (the gate) was removed`);
2407
+ continue;
2408
+ }
2409
+ const wasStages = was.stages ?? [...COMMIT_STAGES];
2410
+ if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
2411
+ out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
2412
+ } else if (now.stages && was.stages) {
2413
+ const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
2414
+ if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
2415
+ }
2416
+ if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2417
+ if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
2418
+ }
2419
+ const topB = str(before.exclude);
2420
+ const topA = str(after.exclude);
2421
+ 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`);
2422
+ return out2;
2423
+ }
2424
+ var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
2425
+ var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
2426
+ function packageJsonWeakening(before, after) {
2427
+ let b;
2428
+ let a;
2429
+ try {
2430
+ b = JSON.parse(before);
2431
+ a = JSON.parse(after);
2432
+ } catch {
2433
+ return [];
2434
+ }
2435
+ if (!isDoc(b) || !isDoc(a)) return [];
2436
+ const scripts = (d) => {
2437
+ const sc = d.scripts;
2438
+ return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
2439
+ };
2440
+ const was = scripts(b).match(INSTALLER)?.[0];
2441
+ if (!was) return [];
2442
+ if (INSTALLER.test(scripts(a))) return [];
2443
+ return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
2444
+ }
2445
+ var GIT_HOOKS = /* @__PURE__ */ new Set([
2446
+ "applypatch-msg",
2447
+ "pre-applypatch",
2448
+ "post-applypatch",
2449
+ "pre-commit",
2450
+ "pre-merge-commit",
2451
+ "prepare-commit-msg",
2452
+ "commit-msg",
2453
+ "post-commit",
2454
+ "pre-rebase",
2455
+ "post-checkout",
2456
+ "post-merge",
2457
+ "pre-push",
2458
+ "pre-receive",
2459
+ "update",
2460
+ "post-receive",
2461
+ "post-update",
2462
+ "push-to-checkout",
2463
+ "pre-auto-gc",
2464
+ "post-rewrite",
2465
+ "sendemail-validate"
2466
+ ]);
2467
+ function hookIdentity(path) {
2468
+ const base = path.split("/").pop() ?? "";
2469
+ if (isLefthook(path)) return "lefthook";
2470
+ if (isPreCommitConfig(path)) return "pre-commit-config";
2471
+ if (base === "CODEOWNERS") return "codeowners";
2472
+ const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
2473
+ if (husky && GIT_HOOKS.has(husky[1])) return `husky:${husky[1]}`;
2474
+ return null;
2475
+ }
2476
+ var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
1481
2477
  var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
1482
- function chmodTouchesExec(seg) {
1483
- const toks = tokens(seg).map(unquote);
1484
- const at = toks.findIndex((t) => /^chmod$/.test(t));
2478
+ function chmodDropsExec(toks) {
2479
+ const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
1485
2480
  if (at === -1) return false;
1486
2481
  for (const tok of toks.slice(at + 1)) {
1487
- if (/^[0-7]{3,4}$/.test(tok)) {
2482
+ if (/^--reference=/.test(tok)) return true;
2483
+ if (/^[0-7]{1,5}$/.test(tok)) {
1488
2484
  if (!ownerExec(tok)) return true;
1489
2485
  continue;
1490
2486
  }
@@ -1492,23 +2488,260 @@ function chmodTouchesExec(seg) {
1492
2488
  const m = clause.match(SYMBOLIC);
1493
2489
  if (!m) continue;
1494
2490
  const [, op, perms] = m;
1495
- const namesExec = /[xX]/.test(perms);
1496
- if (op === "=" ? !namesExec : namesExec) return true;
2491
+ if (op === "-" && /[xX]/.test(perms)) return true;
2492
+ if (op === "=" && !/[xX]/.test(perms)) return true;
1497
2493
  }
1498
2494
  }
1499
2495
  return false;
1500
2496
  }
2497
+ var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2498
+ function shellWritesHook(seg, toks, policy) {
2499
+ const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
2500
+ for (const m of seg.matchAll(/(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g)) {
2501
+ if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
2502
+ }
2503
+ let at = 0;
2504
+ while (at < toks.length && WRAPPERS.test(toks[at])) at++;
2505
+ const cmd = (toks[at] ?? "").replace(/^.*\//, "");
2506
+ const args = toks.slice(at + 1);
2507
+ const positional = args.filter((a) => a !== "--" && !a.startsWith("-"));
2508
+ const last = positional[positional.length - 1] ?? "";
2509
+ const anyHook = args.some(hook);
2510
+ const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
2511
+ switch (cmd) {
2512
+ case "rm":
2513
+ case "unlink":
2514
+ case "shred":
2515
+ return anyHook ? "rm deletes a protected hook" : null;
2516
+ case "truncate":
2517
+ return anyHook ? "truncate empties a protected hook" : null;
2518
+ case "chmod":
2519
+ return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
2520
+ case "mv":
2521
+ return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
2522
+ case "cp":
2523
+ case "install":
2524
+ case "ln":
2525
+ return hook(last) ? `${cmd} replaces a protected hook in place` : null;
2526
+ case "tee":
2527
+ case "sponge":
2528
+ return anyHook ? `${cmd} rewrites a protected hook in place` : null;
2529
+ case "dd":
2530
+ return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
2531
+ case "sed":
2532
+ case "perl":
2533
+ return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
2534
+ case "awk":
2535
+ case "gawk":
2536
+ 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;
2537
+ case "git": {
2538
+ let i = 0;
2539
+ while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
2540
+ const sub = args[i];
2541
+ const rest = args.slice(i + 1);
2542
+ if (!rest.some(hook)) return null;
2543
+ if (sub === "checkout") {
2544
+ const dd = rest.indexOf("--");
2545
+ const before = dd === -1 ? rest : rest.slice(0, dd);
2546
+ const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
2547
+ return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
2548
+ }
2549
+ if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
2550
+ return "git restore --source=<rev> <hook> puts an older version of a protected hook back";
2551
+ }
2552
+ return null;
2553
+ }
2554
+ default:
2555
+ return null;
2556
+ }
2557
+ }
2558
+
2559
+ // src/detectors/hook-tampering.ts
2560
+ var RULE6 = "hook-tampering";
2561
+ var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
2562
+ var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
2563
+ var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
2564
+ function siblingSettings(p) {
2565
+ return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
2566
+ }
2567
+ var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
2568
+ var EVENTS = ["PreToolUse", "Stop"];
2569
+ function parseSettings(src) {
2570
+ try {
2571
+ const v = JSON.parse(src);
2572
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
2573
+ } catch {
2574
+ return null;
2575
+ }
2576
+ }
2577
+ function toolSet(matcher2) {
2578
+ const m = typeof matcher2 === "string" ? matcher2.trim() : "";
2579
+ if (m === "" || m === "*") return null;
2580
+ return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2581
+ }
2582
+ function eventEntries(s, event) {
2583
+ const hooks = s.hooks;
2584
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
2585
+ const arr = hooks[event];
2586
+ return Array.isArray(arr) ? arr : null;
2587
+ }
2588
+ var declares = (s, event) => eventEntries(s, event) !== null;
2589
+ function gateWiring(s, event) {
2590
+ const out2 = [];
2591
+ for (const m of eventEntries(s, event) ?? []) {
2592
+ if (m === null || typeof m !== "object") continue;
2593
+ const matcher2 = m;
2594
+ if (!Array.isArray(matcher2.hooks)) continue;
2595
+ for (const h of matcher2.hooks) {
2596
+ if (h === null || typeof h !== "object") continue;
2597
+ const entry = h;
2598
+ if (entry.type !== void 0 && entry.type !== "command") continue;
2599
+ if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
2600
+ out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
2601
+ }
2602
+ }
2603
+ return out2;
2604
+ }
2605
+ function coverage(ws2) {
2606
+ if (ws2.length === 0) return "none";
2607
+ if (ws2.some((w) => w.tools === null)) return null;
2608
+ return new Set(ws2.flatMap((w) => [...w.tools]));
2609
+ }
2610
+ var hooksDisabled = (s) => s.disableAllHooks === true;
2611
+ function settingsWeakening(before, after, sibling) {
2612
+ const reasons = [];
2613
+ if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
2614
+ reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
2615
+ }
2616
+ for (const event of EVENTS) {
2617
+ const was = before ? coverage(gateWiring(before, event)) : "none";
2618
+ const now = coverage(gateWiring(after, event));
2619
+ if (was !== "none") {
2620
+ if (now === "none") {
2621
+ reasons.push(`the tamperward ${event} hook entry was removed`);
2622
+ } else if (event === "PreToolUse") {
2623
+ if (was === null && now !== null) {
2624
+ reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
2625
+ } else if (was !== null && now !== null) {
2626
+ const lost = [...was].filter((t) => !now.has(t));
2627
+ if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
2628
+ }
2629
+ }
2630
+ continue;
2631
+ }
2632
+ if (now !== "none" || !declares(after, event)) continue;
2633
+ if (before !== null && declares(before, event)) continue;
2634
+ const empty = (eventEntries(after, event) ?? []).length === 0;
2635
+ const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
2636
+ if (empty || shadowed) {
2637
+ reasons.push(
2638
+ `${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
2639
+ );
2640
+ }
2641
+ }
2642
+ return reasons;
2643
+ }
2644
+ function siblingOf(c, changes, ctx) {
2645
+ const path = siblingSettings(c.path);
2646
+ const inSet = changes.find((o) => o.kind === "file" && o.path === path);
2647
+ if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
2648
+ if (!ctx?.cwd) return null;
2649
+ try {
2650
+ const p = join2(ctx.cwd, path);
2651
+ return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
2652
+ } catch {
2653
+ return null;
2654
+ }
2655
+ }
2656
+ function claudeSettingsFindings(c, changes, policy, ctx) {
2657
+ if (c.after == null) return null;
2658
+ const after = parseSettings(c.after);
2659
+ const before = c.before != null ? parseSettings(c.before) : null;
2660
+ const finding = (reason) => makeFinding(RULE6, policy, {
2661
+ file: c.path,
2662
+ message: `The Claude Code hook wiring was weakened: ${reason}.`,
2663
+ evidence: reason,
2664
+ 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."
2665
+ });
2666
+ if (c.before != null && before === null) return null;
2667
+ if (after === null) {
2668
+ if (c.before == null) return [];
2669
+ return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
2670
+ }
2671
+ return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
2672
+ }
2673
+ function policyFindings(c, policy) {
2674
+ if (c.after == null) return null;
2675
+ const added = c.before == null;
2676
+ const reasons = added ? policyAddWeakening(c.after) : policyWeakening(c.before, c.after);
2677
+ if (reasons === null) return null;
2678
+ return reasons.map(
2679
+ (reason) => makeFinding(RULE6, policy, {
2680
+ file: c.path,
2681
+ message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
2682
+ evidence: reason,
2683
+ remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
2684
+ })
2685
+ );
2686
+ }
2687
+ function renameKept(c) {
2688
+ if (!c.oldPath) return false;
2689
+ const id = hookIdentity(c.oldPath);
2690
+ if (id === null || hookIdentity(c.path) !== id) return false;
2691
+ return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
2692
+ }
2693
+ function configFindings(c, policy) {
2694
+ if (c.before == null || c.after == null) return null;
2695
+ const before = parseDoc(c.before);
2696
+ const after = parseDoc(c.after);
2697
+ if (before === null) return null;
2698
+ 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);
2699
+ return reasons.map(
2700
+ (reason) => makeFinding(RULE6, policy, {
2701
+ file: c.path,
2702
+ message: `The hook configuration was weakened: ${reason}.`,
2703
+ evidence: reason,
2704
+ remediation: "Leave the entry that runs the gate as `tamperward init` wrote it. Skipping, staging-off or scoping it is disabling it."
2705
+ })
2706
+ );
2707
+ }
2708
+ function updateIndexHiding(toks, policy) {
2709
+ if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
2710
+ const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
2711
+ if (hide && toks.some((t) => isProtected(t, policy))) {
2712
+ return `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`;
2713
+ }
2714
+ if (toks.some((t) => /^--chmod=-x$/.test(t)) && toks.some((t) => isProtected(t, policy, "hooks"))) {
2715
+ return "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it";
2716
+ }
2717
+ return null;
2718
+ }
1501
2719
  var hookTampering = {
1502
2720
  id: RULE6,
1503
2721
  surface: ["file", "command"],
1504
2722
  certainty: "mechanical",
1505
- run(changes, policy) {
2723
+ run(changes, policy, _view, ctx) {
1506
2724
  const out2 = [];
1507
2725
  for (const c of changes) {
2726
+ let semantic;
1508
2727
  if (c.kind === "file") {
1509
2728
  const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
1510
- if (!targetsHook) continue;
1511
- if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec(c.oldMode) && !ownerExec(c.newMode)) {
2729
+ if (!targetsHook) {
2730
+ if (isPackageJson(c.path) && c.before != null && c.after != null) {
2731
+ for (const reason of packageJsonWeakening(c.before, c.after)) {
2732
+ out2.push(
2733
+ makeFinding(RULE6, policy, {
2734
+ file: c.path,
2735
+ message: `The hook installation was weakened: ${reason}.`,
2736
+ evidence: reason,
2737
+ remediation: "Keep the lifecycle script that installs the git hooks. Without it a clone runs no hook at all."
2738
+ })
2739
+ );
2740
+ }
2741
+ }
2742
+ continue;
2743
+ }
2744
+ if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
1512
2745
  out2.push(
1513
2746
  makeFinding(RULE6, policy, {
1514
2747
  file: c.path,
@@ -1527,7 +2760,7 @@ var hookTampering = {
1527
2760
  remediation: "Restore it. The hooks and the policy are protected assets, not obstacles to remove."
1528
2761
  })
1529
2762
  );
1530
- } else if (c.op === "rename") {
2763
+ } else if (c.op === "rename" && !renameKept(c)) {
1531
2764
  out2.push(
1532
2765
  makeFinding(RULE6, policy, {
1533
2766
  file: c.path,
@@ -1536,22 +2769,27 @@ var hookTampering = {
1536
2769
  remediation: "Renaming a hook out of place disables it. Restore the original path."
1537
2770
  })
1538
2771
  );
1539
- } else if (c.path.endsWith(POLICY_FILE) && c.before != null && c.after != null && policyWeakening(c.before, c.after) !== null) {
1540
- for (const reason of policyWeakening(c.before, c.after) ?? []) {
1541
- out2.push(
1542
- makeFinding(RULE6, policy, {
1543
- file: c.path,
1544
- message: `The policy was weakened: ${reason}.`,
1545
- evidence: reason,
1546
- remediation: "A change that weakens the guardrail is a high-risk edit requiring human sign-off, not an automated pass."
1547
- })
1548
- );
2772
+ } 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) {
2773
+ out2.push(...semantic);
2774
+ } else if (c.op === "rename") {
2775
+ } else if (isCodeowners(c.path)) {
2776
+ if (c.before != null && c.after != null) {
2777
+ for (const reason of codeownersWeakening(c.before, c.after)) {
2778
+ out2.push(
2779
+ makeFinding(RULE6, policy, {
2780
+ file: c.path,
2781
+ message: `CODEOWNERS was weakened: ${reason}.`,
2782
+ evidence: reason,
2783
+ remediation: "Keep a code owner on the workflows, the policy and the hooks. That rule is the only thing requiring a human on a change to the gate itself."
2784
+ })
2785
+ );
2786
+ }
1549
2787
  }
2788
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
2789
+ out2.push(...semantic);
1550
2790
  } else if (!c.path.endsWith(POLICY_FILE)) {
1551
2791
  const added = addedLines(c);
1552
- const removed = removedLines(c);
1553
- const live = added.filter((l) => !isShellComment(l.content));
1554
- const earlyExit = added.find((l) => /^\s*exit\s+0\s*$/.test(l.content));
2792
+ const earlyExit = added.find((l) => insertsPassingExit(l.content));
1555
2793
  if (earlyExit) {
1556
2794
  out2.push(
1557
2795
  makeFinding(RULE6, policy, {
@@ -1563,20 +2801,30 @@ var hookTampering = {
1563
2801
  })
1564
2802
  );
1565
2803
  }
1566
- for (const l of removed) {
1567
- if (isShellComment(l.content) || !l.content.trim()) continue;
1568
- const m = l.content.match(HOOK_INVOCATION);
1569
- if (!m) continue;
1570
- if (live.some((a) => a.content.includes(m[0]))) continue;
2804
+ const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
2805
+ if (deadGuard) {
1571
2806
  out2.push(
1572
2807
  makeFinding(RULE6, policy, {
1573
2808
  file: c.path,
1574
- message: `A check invocation (\`${m[0]}\`) was removed from a protected hook.`,
1575
- evidence: l.content.trim(),
1576
- remediation: "Restore the check. Removing or commenting out the gate's invocation disables it just as surely as deleting the hook."
2809
+ line: deadGuard.newLine ?? void 0,
2810
+ message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
2811
+ evidence: deadGuard.content.trim(),
2812
+ remediation: "Remove the dead condition. A check behind `if false` is a removed check."
2813
+ })
2814
+ );
2815
+ }
2816
+ const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
2817
+ const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
2818
+ const lost = scriptWeakening(beforeLines, afterLines);
2819
+ if (lost.length) {
2820
+ out2.push(
2821
+ makeFinding(RULE6, policy, {
2822
+ file: c.path,
2823
+ message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
2824
+ evidence: lost[0].evidence,
2825
+ remediation: "Restore the check. Removing, commenting out, guarding off or swallowing the gate's exit status disables it just as surely as deleting the hook."
1577
2826
  })
1578
2827
  );
1579
- break;
1580
2828
  }
1581
2829
  } else {
1582
2830
  const INLINE = /^\s*([A-Za-z0-9_-]+):\s*\{[^}]*\bseverity:\s*(block|warn)\b/;
@@ -1612,18 +2860,19 @@ var hookTampering = {
1612
2860
  }
1613
2861
  } else {
1614
2862
  for (const seg of segments(c.raw)) {
1615
- const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
1616
- if (!hitsHook) continue;
1617
- let why = null;
1618
- if (chmodTouchesExec(seg)) {
1619
- why = "chmod alters execute permission on a hook, which can disable it";
1620
- } else if (/\brm\b/.test(seg)) {
1621
- why = "rm deletes a protected hook";
1622
- } else if (/\btruncate\b/.test(seg) || /(?:^|\s)>\s*\S/.test(seg)) {
1623
- why = "a redirect/truncate empties a protected hook";
1624
- } else if (/\b(tee|sed|cp|dd|install|mv)\b/.test(seg)) {
1625
- why = "a shell rewrite (tee/sed/cp/dd/install/mv) replaces a protected hook in place";
2863
+ const toks = tokens(seg).map(unquote);
2864
+ const hiding = updateIndexHiding(toks, policy);
2865
+ if (hiding) {
2866
+ out2.push(
2867
+ makeFinding(RULE6, policy, {
2868
+ message: `Hook tampering via shell: ${hiding}.`,
2869
+ evidence: seg,
2870
+ 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."
2871
+ })
2872
+ );
2873
+ continue;
1626
2874
  }
2875
+ const why = shellWritesHook(seg, toks, policy);
1627
2876
  if (why) {
1628
2877
  out2.push(
1629
2878
  makeFinding(RULE6, policy, {
@@ -1641,44 +2890,215 @@ var hookTampering = {
1641
2890
  };
1642
2891
 
1643
2892
  // src/detectors/test-deletion.ts
2893
+ import ts4 from "typescript";
2894
+
2895
+ // src/detectors/suite-config.ts
2896
+ import picomatch3 from "picomatch";
1644
2897
  import ts3 from "typescript";
2898
+ var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
2899
+ var JEST_DEFAULT_IGNORE = ["/node_modules/"];
2900
+ var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
2901
+ var VITEST_DEFAULT_EXCLUDE = [
2902
+ "**/node_modules/**",
2903
+ "**/dist/**",
2904
+ "**/cypress/**",
2905
+ "**/.{idea,git,cache,output,temp}/**",
2906
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
2907
+ ];
2908
+ var CANONICAL_SAMPLES = [
2909
+ "src/a.test.ts",
2910
+ "src/a.spec.ts",
2911
+ "test/a.test.ts",
2912
+ "tests/a.test.js",
2913
+ "src/__tests__/a.ts",
2914
+ "a.test.tsx",
2915
+ "a.spec.js",
2916
+ "test/a.test.mjs"
2917
+ ];
2918
+ function keyName2(name) {
2919
+ if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2920
+ return null;
2921
+ }
2922
+ function literals2(e) {
2923
+ if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
2924
+ if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2925
+ const items = [];
2926
+ let opaque = false;
2927
+ for (const el of e.elements) {
2928
+ if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
2929
+ else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2930
+ items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2931
+ } else opaque = true;
2932
+ }
2933
+ return { items, opaque };
2934
+ }
2935
+ function underKey2(node, key2) {
2936
+ for (let p = node.parent; p; p = p.parent) {
2937
+ if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
2938
+ }
2939
+ return false;
2940
+ }
2941
+ function asExpression2(src) {
2942
+ const t = src.trim();
2943
+ return t.startsWith("{") ? `(${t})` : src;
2944
+ }
2945
+ function runnerOf(path) {
2946
+ const base = path.split("/").pop() ?? path;
2947
+ if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
2948
+ if (/^vitest\.config\./.test(base)) return "vitest";
2949
+ return null;
2950
+ }
2951
+ function parseSelection(src, runner) {
2952
+ const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
2953
+ try {
2954
+ const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
2955
+ const seen = /* @__PURE__ */ new Set();
2956
+ const take = (key2, e) => {
2957
+ const { items, opaque } = literals2(e);
2958
+ if (opaque || seen.has(key2)) sel.opaque = true;
2959
+ seen.add(key2);
2960
+ sel.present = true;
2961
+ return items.map((g) => g.replace(/^<rootDir>\//, ""));
2962
+ };
2963
+ const visit = (node) => {
2964
+ if (ts3.isPropertyAssignment(node)) {
2965
+ const k = keyName2(node.name);
2966
+ if (runner === "jest") {
2967
+ if (k === "testMatch") sel.include = take(k, node.initializer);
2968
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
2969
+ else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
2970
+ } else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
2971
+ if (k === "include") sel.include = take(k, node.initializer);
2972
+ else if (k === "exclude") sel.ignore = take(k, node.initializer);
2973
+ }
2974
+ }
2975
+ ts3.forEachChild(node, visit);
2976
+ };
2977
+ visit(sf);
2978
+ } catch {
2979
+ }
2980
+ return sel;
2981
+ }
2982
+ var globs2 = (patterns) => {
2983
+ const ms = patterns.map((g) => {
2984
+ try {
2985
+ return picomatch3(g, { dot: true });
2986
+ } catch {
2987
+ return () => false;
2988
+ }
2989
+ });
2990
+ return (path) => ms.some((m) => m(path));
2991
+ };
2992
+ var regexes = (patterns) => {
2993
+ const rs = patterns.flatMap((p) => {
2994
+ try {
2995
+ return [new RegExp(p.replace(/<rootDir>/g, ""))];
2996
+ } catch {
2997
+ return [];
2998
+ }
2999
+ });
3000
+ return (path) => rs.some((r) => r.test("/" + path));
3001
+ };
3002
+ function predicate(sel, runner) {
3003
+ const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
3004
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
3005
+ const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
3006
+ const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
3007
+ return {
3008
+ selects: (p) => included(p) && !ignored(p),
3009
+ why: (p) => {
3010
+ if (ignored(p)) {
3011
+ const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
3012
+ const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
3013
+ return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
3014
+ }
3015
+ const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3016
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3017
+ }
3018
+ };
3019
+ }
3020
+ function suiteNarrowings(before, after, path, samples) {
3021
+ const runner = runnerOf(path);
3022
+ if (!runner) return [];
3023
+ const b = parseSelection(before ?? "", runner);
3024
+ const a = parseSelection(after, runner);
3025
+ if (!a.present || a.opaque) return [];
3026
+ const pb = predicate(b, runner);
3027
+ const pa = predicate(a, runner);
3028
+ const out2 = [];
3029
+ for (const s of samples) {
3030
+ if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
3031
+ }
3032
+ return out2;
3033
+ }
3034
+
3035
+ // src/detectors/test-deletion.ts
1645
3036
  var RULE7 = "test-deletion";
3037
+ var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
1646
3038
  var TEST_DEFS = {
1647
3039
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
1648
3040
  go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
1649
3041
  rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
1650
3042
  rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
1651
- java: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
1652
- kt: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
3043
+ java: JVM_TEST,
3044
+ kt: JVM_TEST,
1653
3045
  php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
1654
3046
  cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
1655
3047
  };
1656
3048
  function calleeName(expr) {
1657
- if (ts3.isIdentifier(expr)) return expr.text;
1658
- if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
3049
+ if (ts4.isIdentifier(expr)) return expr.text;
3050
+ if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
1659
3051
  return expr.expression.text;
1660
3052
  }
1661
3053
  return null;
1662
3054
  }
1663
- function countTestBlocks(src, path = "spec.ts") {
3055
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
3056
+ function eachRows(call) {
3057
+ const arg = call.arguments[0];
3058
+ if (!arg) return { n: 1, open: true };
3059
+ if (ts4.isArrayLiteralExpression(arg)) {
3060
+ let n = 0;
3061
+ let open = false;
3062
+ for (const el of arg.elements) {
3063
+ if (ts4.isSpreadElement(el)) open = true;
3064
+ else n++;
3065
+ }
3066
+ return { n: Math.max(n, open ? 0 : 1), open };
3067
+ }
3068
+ return { n: 1, open: true };
3069
+ }
3070
+ function countTests(src, path = "spec.ts") {
1664
3071
  const lang = langOf(path);
1665
3072
  if (lang && lang !== "js") {
1666
3073
  const re = TEST_DEFS[lang];
1667
3074
  let n2 = 0;
1668
3075
  for (const line of src.split("\n")) if (re.test(line)) n2++;
1669
- return n2;
3076
+ return { min: n2, open: false };
1670
3077
  }
1671
- const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
3078
+ const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
1672
3079
  let n = 0;
3080
+ let open = false;
1673
3081
  const visit = (node) => {
1674
- if (ts3.isCallExpression(node)) {
1675
- const name = calleeName(node.expression);
1676
- if (name === "it" || name === "test") n++;
3082
+ if (ts4.isCallExpression(node)) {
3083
+ if (isEachOf(node.expression)) {
3084
+ const r = eachRows(node);
3085
+ n += r.n;
3086
+ if (r.open) open = true;
3087
+ } else {
3088
+ const name = calleeName(node.expression);
3089
+ if (name === "it" || name === "test") n++;
3090
+ }
3091
+ } else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
3092
+ const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3093
+ n += Math.max(rows, 1);
1677
3094
  }
1678
- ts3.forEachChild(node, visit);
3095
+ ts4.forEachChild(node, visit);
1679
3096
  };
1680
3097
  visit(sf);
1681
- return n;
3098
+ return { min: n, open };
3099
+ }
3100
+ function countTestBlocks(src, path = "spec.ts") {
3101
+ return countTests(src, path).min;
1682
3102
  }
1683
3103
  function significantLines(src, path) {
1684
3104
  const out2 = /* @__PURE__ */ new Set();
@@ -1689,32 +3109,63 @@ function significantLines(src, path) {
1689
3109
  }
1690
3110
  return out2;
1691
3111
  }
3112
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
3113
+ function runnerSamples(configPath, policy, ctx) {
3114
+ const files = trackedFiles(ctx);
3115
+ if (!files) return CANONICAL_SAMPLES;
3116
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
3117
+ 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));
3118
+ return own.length ? own : CANONICAL_SAMPLES;
3119
+ }
3120
+ function joinRoot(root, to) {
3121
+ const parts = root ? root.split("/") : [];
3122
+ for (const seg of to.replace(/\/+$/, "").split("/")) {
3123
+ if (seg === "" || seg === ".") continue;
3124
+ if (seg === "..") {
3125
+ if (parts.length === 0) return null;
3126
+ parts.pop();
3127
+ } else parts.push(seg);
3128
+ }
3129
+ return parts.join("/");
3130
+ }
3131
+ function leavesGlob(dir, dest, policy, ctx) {
3132
+ const files = trackedFiles(ctx);
3133
+ if (!files || !dir) return false;
3134
+ const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3135
+ const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3136
+ return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
3137
+ }
3138
+ var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
1692
3139
  var testDeletion = {
1693
3140
  id: RULE7,
1694
3141
  surface: ["file", "command"],
1695
3142
  certainty: "mechanical",
1696
- run(changes, policy) {
3143
+ run(changes, policy, _view, ctx) {
1697
3144
  const out2 = [];
1698
3145
  const addedTestLines = /* @__PURE__ */ new Set();
1699
3146
  let addedTestBlocks = 0;
1700
3147
  for (const c of changes) {
1701
- if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
3148
+ if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3149
+ if (c.op === "add") {
1702
3150
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
1703
3151
  addedTestBlocks += countTestBlocks(c.after, c.path);
3152
+ } else if (c.before != null) {
3153
+ const before = significantLines(c.before, c.path);
3154
+ for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
3155
+ addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
1704
3156
  }
1705
3157
  }
1706
- const isRelocation = (before, path) => {
1707
- const sig = significantLines(before, path);
1708
- if (sig.size === 0 || addedTestLines.size === 0) return false;
3158
+ const isRelocation = (removed, blocksRemoved) => {
3159
+ if (removed.size === 0 || addedTestLines.size === 0) return false;
1709
3160
  let hit = 0;
1710
- for (const l of sig) if (addedTestLines.has(l)) hit++;
1711
- return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
3161
+ for (const l of removed) if (addedTestLines.has(l)) hit++;
3162
+ return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
1712
3163
  };
1713
3164
  for (const c of changes) {
1714
3165
  if (c.kind === "file") {
1715
3166
  const isTest = isProtected(c.path, policy, "tests");
1716
3167
  if (c.op === "delete" && isTest) {
1717
- if (c.before != null && isRelocation(c.before, c.path)) continue;
3168
+ if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
1718
3169
  out2.push(
1719
3170
  makeFinding(RULE7, policy, {
1720
3171
  file: c.path,
@@ -1733,41 +3184,95 @@ var testDeletion = {
1733
3184
  })
1734
3185
  );
1735
3186
  } else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
1736
- const before = countTestBlocks(c.before, c.path);
1737
- const after = countTestBlocks(c.after, c.path);
1738
- if (after < before) {
3187
+ const before = countTests(c.before, c.path);
3188
+ const after = countTests(c.after, c.path);
3189
+ if (after.min < before.min && !after.open) {
3190
+ const afterLines = significantLines(c.after, c.path);
3191
+ const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
3192
+ if (isRelocation(removed, before.min - after.min)) continue;
1739
3193
  out2.push(
1740
3194
  makeFinding(RULE7, policy, {
1741
3195
  file: c.path,
1742
- message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
1743
- evidence: `${before - after} test block(s) removed from ${c.path}`,
3196
+ message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
3197
+ evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
1744
3198
  remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
1745
3199
  })
1746
3200
  );
1747
3201
  }
3202
+ } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
3203
+ for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3204
+ out2.push(
3205
+ makeFinding(RULE7, policy, {
3206
+ file: c.path,
3207
+ message: `The runner no longer selects ${n.path}: ${n.reason}.`,
3208
+ evidence: `${n.path} dropped from the suite by ${c.path}`,
3209
+ remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
3210
+ })
3211
+ );
3212
+ }
1748
3213
  }
1749
3214
  } else {
3215
+ let root = "";
1750
3216
  for (const seg of segments(c.raw)) {
1751
3217
  const toks = tokens(seg).map(unquote);
1752
- const testToks = toks.filter((t) => isProtected(t, policy, "tests"));
1753
- if (testToks.length === 0) continue;
3218
+ const cdTo = toks[0] === "cd" ? toks[1] : null;
3219
+ if (cdTo !== null) {
3220
+ root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
3221
+ continue;
3222
+ }
3223
+ const inRepo = (t) => root ? `${root}/${t}` : t;
3224
+ const listing = trackedFiles(ctx);
3225
+ const testToks = toks.filter((t) => {
3226
+ if (!isProtected(t, policy, "tests")) return false;
3227
+ if (!listing || root === null) return true;
3228
+ const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
3229
+ if (looksLikeFile || listing.includes(inRepo(t))) return true;
3230
+ return containsProtected(inRepo(t), policy, "tests", ctx);
3231
+ });
3232
+ const dirToks = toks.filter(
3233
+ (t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
3234
+ );
3235
+ if (testToks.length === 0 && dirToks.length === 0) continue;
3236
+ const named = [...testToks, ...dirToks];
3237
+ const cmd = toks[0] ?? "";
1754
3238
  let why = null;
1755
3239
  let evidence = seg;
1756
3240
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
1757
3241
  const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
1758
- if (/\brm\b/.test(seg)) {
3242
+ const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3243
+ const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3244
+ const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
3245
+ const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
3246
+ return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
3247
+ })();
3248
+ if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
1759
3249
  why = "rm deletes a test file";
1760
- } else if (/\bsed\b/.test(seg) && /(?:^|\s)-i\b/.test(seg)) {
3250
+ } else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
3251
+ why = "git rm deletes a test file";
3252
+ } else if (cmd === "sed" && SED_INPLACE.test(seg)) {
1761
3253
  why = "sed -i rewrites a test file in place";
3254
+ } else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
3255
+ why = "perl -i rewrites a test file in place";
1762
3256
  } else if (/\btruncate\b/.test(seg)) {
1763
3257
  why = "truncate empties a test file";
1764
3258
  } else if (redirectsOntoTest) {
1765
3259
  why = "a redirect overwrites/empties a test file";
3260
+ } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
3261
+ why = "cp overwrites a test file with something else";
3262
+ } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
3263
+ why = "tee overwrites a test file";
3264
+ } else if (findDeletes) {
3265
+ why = "find -delete removes test files";
3266
+ } else if (gitRestoresOld) {
3267
+ why = "git checkout <rev> replaces a test file with an older version";
1766
3268
  } else if (/\bmv\b/.test(seg)) {
1767
3269
  const dest = toks[toks.length - 1];
1768
- if (dest && !isProtected(dest, policy, "tests")) {
3270
+ if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
1769
3271
  why = "mv renames a test file out of the test glob";
1770
3272
  evidence = `${testToks[0]} \u2192 ${dest}`;
3273
+ } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
3274
+ why = "mv renames a test directory so its specs leave the test glob";
3275
+ evidence = `${dirToks[0]} \u2192 ${dest}`;
1771
3276
  }
1772
3277
  }
1773
3278
  if (why) {
@@ -1787,9 +3292,40 @@ var testDeletion = {
1787
3292
  };
1788
3293
 
1789
3294
  // src/detectors/test-content-removal.ts
3295
+ import ts5 from "typescript";
1790
3296
  var RULE8 = "test-content-removal";
1791
3297
  var MIN_REMOVED_LINES = 3;
3298
+ var MIN_REMOVED_ROWS = 2;
1792
3299
  var ws = (s) => s.replace(/\s+/g, "");
3300
+ function eachTables(src, path) {
3301
+ const rows = /* @__PURE__ */ new Set();
3302
+ let open = false;
3303
+ if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3304
+ try {
3305
+ const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
3306
+ const visit = (node) => {
3307
+ 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)) {
3308
+ const arg = node.arguments[0];
3309
+ if (arg && ts5.isArrayLiteralExpression(arg)) {
3310
+ for (const el of arg.elements) {
3311
+ if (ts5.isSpreadElement(el)) {
3312
+ open = true;
3313
+ continue;
3314
+ }
3315
+ for (const raw of el.getText(sf).split("\n")) {
3316
+ const l = raw.trim().replace(/,$/, "");
3317
+ if (isSignificantLine(l, "js")) rows.add(l);
3318
+ }
3319
+ }
3320
+ } else open = true;
3321
+ }
3322
+ ts5.forEachChild(node, visit);
3323
+ };
3324
+ visit(sf);
3325
+ } catch {
3326
+ }
3327
+ return { rows, open };
3328
+ }
1793
3329
  function significantLinesOrdered(src, path) {
1794
3330
  const out2 = [];
1795
3331
  const lang = langOf(path);
@@ -1806,16 +3342,23 @@ var testContentRemoval = {
1806
3342
  run(changes, policy) {
1807
3343
  const out2 = [];
1808
3344
  let keptPool = "";
3345
+ let addedPool = "";
1809
3346
  for (const c of changes) {
1810
- if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
1811
- keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3347
+ if (c.kind !== "file" || c.after == null) continue;
3348
+ if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3349
+ if (c.op === "add") addedPool += ws(c.after);
3350
+ else if (c.before != null) {
3351
+ const had = new Set(c.before.split("\n").map((l) => l.trim()));
3352
+ addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
1812
3353
  }
1813
3354
  }
1814
3355
  for (const c of changes) {
1815
3356
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
1816
3357
  if (!isProtected(c.path, policy, "tests")) continue;
1817
3358
  if (isProtected(c.path, policy, "snapshots")) continue;
1818
- if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
3359
+ const blocksBefore = countTests(c.before, c.path);
3360
+ const blocksAfter = countTests(c.after, c.path);
3361
+ if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
1819
3362
  const beforeSig = significantLinesOrdered(c.before, c.path);
1820
3363
  const afterSig = significantLinesOrdered(c.after, c.path);
1821
3364
  const netDrop = beforeSig.length - afterSig.length;
@@ -1826,6 +3369,19 @@ var testContentRemoval = {
1826
3369
  if (keptPool.includes(ws(line))) continue;
1827
3370
  gone.push(line);
1828
3371
  }
3372
+ const table = eachTables(c.before, c.path);
3373
+ const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
3374
+ if (goneRows.length >= MIN_REMOVED_ROWS) {
3375
+ out2.push(
3376
+ makeFinding(RULE8, policy, {
3377
+ file: c.path,
3378
+ message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that now spreads from elsewhere, and they reappear nowhere in the change.`,
3379
+ evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3380
+ remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
3381
+ })
3382
+ );
3383
+ continue;
3384
+ }
1829
3385
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
1830
3386
  out2.push(
1831
3387
  makeFinding(RULE8, policy, {
@@ -1843,7 +3399,7 @@ var testContentRemoval = {
1843
3399
 
1844
3400
  // src/detectors/snapshot-rewrite.ts
1845
3401
  var RULE9 = "snapshot-rewrite";
1846
- var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
3402
+ var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
1847
3403
  var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
1848
3404
  var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
1849
3405
  var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
@@ -1870,7 +3426,7 @@ var snapshotRewrite = {
1870
3426
  for (const seg of segments(c.raw)) {
1871
3427
  let why = null;
1872
3428
  if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1873
- else if ((RUNNER.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
3429
+ else if ((RUNNER2.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
1874
3430
  else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
1875
3431
  else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
1876
3432
  else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
@@ -1952,6 +3508,12 @@ var allDetectors = [
1952
3508
  ];
1953
3509
 
1954
3510
  // src/engine.ts
3511
+ var GUARDED_RULE = "hook-tampering";
3512
+ var BASELINE_HOOKS = defaultPolicy().protected.hooks;
3513
+ function isGuardedFinding(f) {
3514
+ if (f.rule !== GUARDED_RULE || !f.file) return false;
3515
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
3516
+ }
1955
3517
  function key(f) {
1956
3518
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
1957
3519
  }
@@ -1963,13 +3525,13 @@ function isSuppressed(c, policy) {
1963
3525
  function activeChanges(changes, policy) {
1964
3526
  return changes.filter((c) => !isSuppressed(c, policy));
1965
3527
  }
1966
- function evaluate(changes, policy, detectors = allDetectors, view) {
3528
+ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
1967
3529
  const active = activeChanges(changes, policy);
1968
3530
  const out2 = [];
1969
3531
  for (const d of detectors) {
1970
- if (!isEnabled(d.id, policy)) continue;
3532
+ if (d.id !== GUARDED_RULE && !isEnabled(d.id, policy)) continue;
1971
3533
  try {
1972
- out2.push(...d.run(active, policy, view));
3534
+ out2.push(...d.run(active, policy, view, ctx));
1973
3535
  } catch (e) {
1974
3536
  process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
1975
3537
  `);
@@ -1992,20 +3554,25 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
1992
3554
  seen.add(k);
1993
3555
  return true;
1994
3556
  });
1995
- return deduped.filter((f) => {
1996
- if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
3557
+ return deduped.map(pinGuarded).filter((f) => {
3558
+ if (f.rule === "detector-error" || isGuardedFinding(f)) return true;
3559
+ if (!isEnabled(f.rule, policy)) return false;
1997
3560
  const ex = policy.rules[f.rule]?.exclude;
1998
3561
  if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
1999
3562
  return !matchesAny(f.file, ex);
2000
3563
  });
2001
3564
  }
3565
+ function pinGuarded(f) {
3566
+ if (!isGuardedFinding(f) || f.severity === "block") return f;
3567
+ return { ...f, severity: "block", signoff: { ...f.signoff, required: true } };
3568
+ }
2002
3569
  function hasBlocking(findings) {
2003
3570
  return findings.some((f) => f.severity === "block");
2004
3571
  }
2005
3572
 
2006
3573
  // src/policy-load.ts
2007
- import { readFileSync as readFileSync2, existsSync } from "node:fs";
2008
- import { isAbsolute, join as join2, posix } from "node:path";
3574
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
3575
+ import { isAbsolute, join as join3, posix } from "node:path";
2009
3576
  import { parse as parse2 } from "yaml";
2010
3577
  var PolicyError = class extends Error {
2011
3578
  };
@@ -2053,8 +3620,8 @@ function validate(r, where) {
2053
3620
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
2054
3621
  if (r.protected !== void 0) {
2055
3622
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
2056
- for (const [cat, globs2] of Object.entries(r.protected)) {
2057
- if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
3623
+ for (const [cat, globs3] of Object.entries(r.protected)) {
3624
+ if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
2058
3625
  }
2059
3626
  }
2060
3627
  if (r.signoff !== void 0) {
@@ -2086,10 +3653,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
2086
3653
  const version = normalizeVersion(r.version, where);
2087
3654
  validate(r, where);
2088
3655
  const base = defaultPolicy(version);
2089
- const globs2 = (list) => list?.map(normalizeGlob);
2090
- const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
3656
+ const globs3 = (list) => list?.map(normalizeGlob);
3657
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
2091
3658
  const userRules = r.rules ? Object.fromEntries(
2092
- Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
3659
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
2093
3660
  ) : void 0;
2094
3661
  return {
2095
3662
  version,
@@ -2099,7 +3666,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
2099
3666
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
2100
3667
  protected: mergeProtected(base.protected, userProtected),
2101
3668
  rules: mergeRules(base.rules, userRules),
2102
- ignore: globs2(r.ignore) ?? base.ignore,
3669
+ ignore: globs3(r.ignore) ?? base.ignore,
2103
3670
  signoff: {
2104
3671
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
2105
3672
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -2129,9 +3696,9 @@ function parseOrThrow(src, where) {
2129
3696
  return parsePolicy(raw, where);
2130
3697
  }
2131
3698
  function loadPolicy(cwd = process.cwd()) {
2132
- const path = join2(cwd, POLICY_FILE);
2133
- if (!existsSync(path)) return defaultPolicy();
2134
- return parseOrThrow(readFileSync2(path, "utf8"), POLICY_FILE);
3699
+ const path = join3(cwd, POLICY_FILE);
3700
+ if (!existsSync2(path)) return defaultPolicy();
3701
+ return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
2135
3702
  }
2136
3703
  function loadPolicyAt(rev, cwd) {
2137
3704
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -2141,8 +3708,8 @@ function loadPolicyAt(rev, cwd) {
2141
3708
 
2142
3709
  // src/signoff.ts
2143
3710
  import { createHash } from "node:crypto";
2144
- import { appendFileSync, mkdirSync, readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
2145
- import { dirname, join as join3 } from "node:path";
3711
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
3712
+ import { dirname, join as join4 } from "node:path";
2146
3713
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2147
3714
  function fingerprint(rule, file, evidence) {
2148
3715
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -2153,13 +3720,13 @@ function ledgerPath(cwd, policy) {
2153
3720
  if (!ledgerInsideRepo(rel)) {
2154
3721
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
2155
3722
  }
2156
- return join3(cwd, rel);
3723
+ return join4(cwd, rel);
2157
3724
  }
2158
3725
  function readLedger(cwd, policy) {
2159
3726
  const p = ledgerPath(cwd, policy);
2160
- if (!existsSync2(p)) return [];
3727
+ if (!existsSync3(p)) return [];
2161
3728
  const out2 = [];
2162
- for (const line of readFileSync3(p, "utf8").split("\n")) {
3729
+ for (const line of readFileSync4(p, "utf8").split("\n")) {
2163
3730
  if (!line.trim()) continue;
2164
3731
  try {
2165
3732
  const e = JSON.parse(line);
@@ -2502,7 +4069,7 @@ function check(opts) {
2502
4069
  return 2;
2503
4070
  }
2504
4071
  const ignoredFiles = changes.filter((c) => isSuppressed(c, policy)).length;
2505
- let findings = evaluate(changes, policy, void 0, view);
4072
+ let findings = evaluate(changes, policy, void 0, view, { cwd });
2506
4073
  const { findings: remaining, cleared } = layer === "local" ? applyLocalSignoffs(findings, cwd, policy) : applyOobSignoffs(findings, oobFromEnv(), oobHeadFromEnv());
2507
4074
  findings = remaining;
2508
4075
  if (cleared.length) {
@@ -2527,21 +4094,21 @@ function runCheck(opts) {
2527
4094
  }
2528
4095
 
2529
4096
  // src/cli/hook.ts
2530
- import { readFileSync as readFileSync9, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
4097
+ import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
2531
4098
  import { createHash as createHash4 } from "node:crypto";
2532
- import { join as join8 } from "node:path";
4099
+ import { join as join9 } from "node:path";
2533
4100
 
2534
4101
  // src/adapters/claude/changes.ts
2535
- import { execFileSync as execFileSync2 } from "node:child_process";
2536
- import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
4102
+ import { execFileSync as execFileSync3 } from "node:child_process";
4103
+ import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2537
4104
  import { tmpdir } from "node:os";
2538
- import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
4105
+ import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
2539
4106
  function asStr(v) {
2540
4107
  return typeof v === "string" ? v : "";
2541
4108
  }
2542
4109
  function readDisk(path) {
2543
4110
  try {
2544
- return existsSync3(path) ? readFileSync4(path, "utf8") : null;
4111
+ return existsSync4(path) ? readFileSync5(path, "utf8") : null;
2545
4112
  } catch {
2546
4113
  return null;
2547
4114
  }
@@ -2564,15 +4131,15 @@ function synthFileChange(displayPath, before, after) {
2564
4131
  if (before === null) op = "add";
2565
4132
  else if (after === null) op = "delete";
2566
4133
  else op = "modify";
2567
- const dir = mkdtempSync(join4(tmpdir(), "hf-"));
4134
+ const dir = mkdtempSync(join5(tmpdir(), "hf-"));
2568
4135
  let raw = "";
2569
4136
  try {
2570
- const a = join4(dir, "a");
2571
- const b = join4(dir, "b");
4137
+ const a = join5(dir, "a");
4138
+ const b = join5(dir, "b");
2572
4139
  writeFileSync(a, before ?? "");
2573
4140
  writeFileSync(b, after ?? "");
2574
4141
  try {
2575
- raw = execFileSync2("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
4142
+ raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
2576
4143
  encoding: "utf8",
2577
4144
  maxBuffer: 256 * 1024 * 1024
2578
4145
  });
@@ -2653,22 +4220,22 @@ function formatDenial(blocks) {
2653
4220
  }
2654
4221
 
2655
4222
  // src/session.ts
2656
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
2657
- import { dirname as dirname2, join as join5 } from "node:path";
4223
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
4224
+ import { dirname as dirname2, join as join6 } from "node:path";
2658
4225
  var UNSAFE = /[^A-Za-z0-9_-]/g;
2659
4226
  var SHA = /^[0-9a-f]{7,40}$/;
2660
4227
  function baselinePath(cwd, sessionId) {
2661
4228
  const gd = gitDir(cwd);
2662
4229
  if (!gd) return null;
2663
- return join5(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
4230
+ return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
2664
4231
  }
2665
4232
  function turnBaseline(cwd, sessionId) {
2666
4233
  if (!sessionId) return null;
2667
4234
  try {
2668
4235
  const p = baselinePath(cwd, sessionId);
2669
4236
  if (!p) return null;
2670
- if (existsSync4(p)) {
2671
- const v = readFileSync5(p, "utf8").trim();
4237
+ if (existsSync5(p)) {
4238
+ const v = readFileSync6(p, "utf8").trim();
2672
4239
  if (SHA.test(v)) return v;
2673
4240
  }
2674
4241
  const head = headSha(cwd);
@@ -2694,20 +4261,20 @@ function advanceTurnBaseline(cwd, sessionId) {
2694
4261
 
2695
4262
  // src/effect.ts
2696
4263
  import { createHash as createHash2 } from "node:crypto";
2697
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2698
- import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
2699
- import { execFileSync as execFileSync3 } from "node:child_process";
4264
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4265
+ import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
4266
+ import { execFileSync as execFileSync4 } from "node:child_process";
2700
4267
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2701
4268
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
2702
4269
  function gitDir2(cwd) {
2703
4270
  try {
2704
- const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
4271
+ const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
2705
4272
  cwd,
2706
4273
  encoding: "utf8",
2707
4274
  stdio: ["ignore", "pipe", "pipe"]
2708
4275
  }).trim();
2709
4276
  if (!d) return null;
2710
- return isAbsolute3(d) ? d : join6(cwd, d);
4277
+ return isAbsolute3(d) ? d : join7(cwd, d);
2711
4278
  } catch {
2712
4279
  return null;
2713
4280
  }
@@ -2716,8 +4283,8 @@ function statePath(cwd, sessionId, kind) {
2716
4283
  if (!sessionId) return null;
2717
4284
  const gd = gitDir2(cwd);
2718
4285
  if (!gd) return null;
2719
- const dir = join6(gd, "tamperward");
2720
- return join6(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
4286
+ const dir = join7(gd, "tamperward");
4287
+ return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
2721
4288
  }
2722
4289
  function ptreePath(cwd, sessionId) {
2723
4290
  return statePath(cwd, sessionId, "ptree");
@@ -2732,7 +4299,7 @@ function contentHash(content) {
2732
4299
  function snapshotProtected(cwd, policy, prev) {
2733
4300
  const out2 = {};
2734
4301
  const walk = (rel) => {
2735
- const abs2 = rel ? join6(cwd, rel) : cwd;
4302
+ const abs2 = rel ? join7(cwd, rel) : cwd;
2736
4303
  let names;
2737
4304
  try {
2738
4305
  names = readdirSync(abs2);
@@ -2744,7 +4311,7 @@ function snapshotProtected(cwd, policy, prev) {
2744
4311
  const r = rel ? `${rel}/${name}` : name;
2745
4312
  let st;
2746
4313
  try {
2747
- st = statSync(join6(cwd, r));
4314
+ st = statSync(join7(cwd, r));
2748
4315
  } catch {
2749
4316
  continue;
2750
4317
  }
@@ -2752,7 +4319,7 @@ function snapshotProtected(cwd, policy, prev) {
2752
4319
  else if (st.isFile() && isProtected(r, policy)) {
2753
4320
  let hash = "";
2754
4321
  try {
2755
- hash = sha(readFileSync6(join6(cwd, r)));
4322
+ hash = sha(readFileSync7(join7(cwd, r)));
2756
4323
  } catch {
2757
4324
  hash = "unreadable";
2758
4325
  }
@@ -2774,9 +4341,9 @@ function driftBetween(expected, current) {
2774
4341
  return { changed, deleted };
2775
4342
  }
2776
4343
  function loadTree(p) {
2777
- if (!p || !existsSync5(p)) return null;
4344
+ if (!p || !existsSync6(p)) return null;
2778
4345
  try {
2779
- return JSON.parse(readFileSync6(p, "utf8"));
4346
+ return JSON.parse(readFileSync7(p, "utf8"));
2780
4347
  } catch {
2781
4348
  return null;
2782
4349
  }
@@ -2784,7 +4351,7 @@ function loadTree(p) {
2784
4351
  function saveTree(p, tree) {
2785
4352
  if (!p) return;
2786
4353
  try {
2787
- mkdirSync3(join6(p, ".."), { recursive: true });
4354
+ mkdirSync3(join7(p, ".."), { recursive: true });
2788
4355
  writeFileSync3(p, JSON.stringify(tree));
2789
4356
  } catch {
2790
4357
  }
@@ -2803,15 +4370,15 @@ function saveTurnTree(cwd, sessionId, tree) {
2803
4370
  }
2804
4371
 
2805
4372
  // src/cli/watch.ts
2806
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2807
- import { join as join7 } from "node:path";
4373
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
4374
+ import { join as join8 } from "node:path";
2808
4375
  import { createHash as createHash3 } from "node:crypto";
2809
4376
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
2810
4377
  function snap(abs2) {
2811
4378
  try {
2812
4379
  const st = statSync2(abs2);
2813
4380
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
2814
- const hash = createHash3("sha256").update(readFileSync7(abs2)).digest("hex").slice(0, 16);
4381
+ const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
2815
4382
  return { mode: st.mode, size: st.size, hash };
2816
4383
  } catch {
2817
4384
  return { mode: null, size: null, hash: null };
@@ -2819,7 +4386,7 @@ function snap(abs2) {
2819
4386
  }
2820
4387
  function defaultEventLog(cwd) {
2821
4388
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
2822
- return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
4389
+ return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
2823
4390
  }
2824
4391
  function watchTree(dir, cb) {
2825
4392
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -2836,12 +4403,12 @@ function watchTree(dir, cb) {
2836
4403
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
2837
4404
  let w;
2838
4405
  try {
2839
- w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
4406
+ w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
2840
4407
  if (!fname) return;
2841
4408
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
2842
4409
  cb(kind, child);
2843
4410
  try {
2844
- if (statSync2(join7(dir, child)).isDirectory()) addDir(child);
4411
+ if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
2845
4412
  } catch {
2846
4413
  }
2847
4414
  });
@@ -2854,7 +4421,7 @@ function watchTree(dir, cb) {
2854
4421
  addDir(rel);
2855
4422
  let names = [];
2856
4423
  try {
2857
- names = readdirSync2(rel ? join7(dir, rel) : dir);
4424
+ names = readdirSync2(rel ? join8(dir, rel) : dir);
2858
4425
  } catch {
2859
4426
  return;
2860
4427
  }
@@ -2862,7 +4429,7 @@ function watchTree(dir, cb) {
2862
4429
  const child = rel ? `${rel}/${name}` : name;
2863
4430
  if (SKIP2.test(child + "/")) continue;
2864
4431
  try {
2865
- if (statSync2(join7(dir, child)).isDirectory()) walk(child);
4432
+ if (statSync2(join8(dir, child)).isDirectory()) walk(child);
2866
4433
  } catch {
2867
4434
  }
2868
4435
  }
@@ -2874,13 +4441,13 @@ function watchTree(dir, cb) {
2874
4441
  }
2875
4442
  function startWatcher(dir, log, policy) {
2876
4443
  try {
2877
- mkdirSync4(join7(log, ".."), { recursive: true });
4444
+ mkdirSync4(join8(log, ".."), { recursive: true });
2878
4445
  } catch {
2879
4446
  }
2880
4447
  const last = /* @__PURE__ */ new Map();
2881
4448
  return watchTree(dir, (kind, rel) => {
2882
4449
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
2883
- const s = snap(join7(dir, rel));
4450
+ const s = snap(join8(dir, rel));
2884
4451
  const key2 = `${s.hash}:${s.mode}`;
2885
4452
  if (last.get(rel) === key2) return;
2886
4453
  last.set(rel, key2);
@@ -2907,7 +4474,7 @@ function runWatch(args) {
2907
4474
  }
2908
4475
 
2909
4476
  // src/detectors/fs-events.ts
2910
- import { readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
4477
+ import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
2911
4478
  var RULE11 = "transient-protected-mutation";
2912
4479
  function readEvents(log, offset) {
2913
4480
  let size = 0;
@@ -2917,7 +4484,7 @@ function readEvents(log, offset) {
2917
4484
  return { events: [], newOffset: offset };
2918
4485
  }
2919
4486
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
2920
- const raw = readFileSync8(log, "utf8").slice(offset);
4487
+ const raw = readFileSync9(log, "utf8").slice(offset);
2921
4488
  const events = [];
2922
4489
  for (const line of raw.split("\n")) {
2923
4490
  if (!line.trim()) continue;
@@ -2970,7 +4537,7 @@ var HookInputError = class extends Error {
2970
4537
  };
2971
4538
  function readStdin() {
2972
4539
  try {
2973
- return readFileSync9(0, "utf8");
4540
+ return readFileSync10(0, "utf8");
2974
4541
  } catch (e) {
2975
4542
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
2976
4543
  }
@@ -3029,7 +4596,7 @@ function fileChanges(changes) {
3029
4596
  }
3030
4597
  function hashOnDisk(cwd, rel) {
3031
4598
  try {
3032
- return contentHash(readFileSync9(join8(cwd, rel)));
4599
+ return contentHash(readFileSync10(join9(cwd, rel)));
3033
4600
  } catch {
3034
4601
  return null;
3035
4602
  }
@@ -3139,7 +4706,7 @@ function effectDriftBlocks(cwd, sessionId, policy) {
3139
4706
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
3140
4707
  );
3141
4708
  const gap = uncoveredDrift(cwd, policy, expected, current, scoped, base, loadTurnTree(cwd, sessionId));
3142
- const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn").filter((f) => f.severity === "block").concat(gap.blocks);
4709
+ const blocks = evaluate([...scoped, ...gap.changes], policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(gap.blocks);
3143
4710
  if (blocks.length > 0) return blocks;
3144
4711
  savePtree(cwd, sessionId, current);
3145
4712
  return null;
@@ -3172,7 +4739,7 @@ function preToolUseVerdict(input) {
3172
4739
  const driftBlocks = effectDriftBlocks(cwd, input.session_id, policy);
3173
4740
  if (driftBlocks) return verdict(driftBlocks, "PreToolUse");
3174
4741
  const changes = changesFromClaudeHook(input, cwd);
3175
- const blocks = evaluate(changes, policy, void 0, "tool-call").filter((f) => f.severity === "block");
4742
+ const blocks = evaluate(changes, policy, void 0, "tool-call", { cwd }).filter((f) => f.severity === "block");
3176
4743
  if (blocks.length === 0) sanctionPredictedWrites(cwd, input.session_id, policy, changes);
3177
4744
  return verdict(blocks, "PreToolUse");
3178
4745
  } catch (e) {
@@ -3188,10 +4755,10 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3188
4755
  } };
3189
4756
  const log = defaultEventLog(cwd);
3190
4757
  const cp = cursorPath(cwd, sessionId);
3191
- if (!existsSync6(log)) return none;
4758
+ if (!existsSync7(log)) return none;
3192
4759
  let offset = 0;
3193
- if (cp && existsSync6(cp)) {
3194
- const n = Number(readFileSync9(cp, "utf8"));
4760
+ if (cp && existsSync7(cp)) {
4761
+ const n = Number(readFileSync10(cp, "utf8"));
3195
4762
  offset = Number.isFinite(n) ? n : 0;
3196
4763
  }
3197
4764
  const { events, newOffset } = readEvents(log, offset);
@@ -3205,7 +4772,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
3205
4772
  const finalHash = (path) => {
3206
4773
  try {
3207
4774
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
3208
- return createHash4("sha256").update(readFileSync9(`${cwd}/${path}`)).digest("hex").slice(0, 16);
4775
+ return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
3209
4776
  } catch {
3210
4777
  return null;
3211
4778
  }
@@ -3249,7 +4816,7 @@ function stopVerdict(input) {
3249
4816
  changes = changes.concat(gap.changes);
3250
4817
  hiddenBlocks = gap.blocks;
3251
4818
  }
3252
- blocks = evaluate(changes, policy, void 0, "turn").filter((f) => f.severity === "block").concat(hiddenBlocks);
4819
+ blocks = evaluate(changes, policy, void 0, "turn", { cwd }).filter((f) => f.severity === "block").concat(hiddenBlocks);
3253
4820
  const transient = turnTransientBlocks(cwd, input.session_id, policy, changes);
3254
4821
  blocks = blocks.concat(transient.blocks);
3255
4822
  commitCursor = transient.commit;
@@ -3313,7 +4880,7 @@ function runAllow(opts) {
3313
4880
  const policy = loadPolicy(cwd);
3314
4881
  let findings;
3315
4882
  try {
3316
- findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree");
4883
+ findings = evaluate(diffWorktree({ cwd }), policy, void 0, "worktree", { cwd });
3317
4884
  } catch {
3318
4885
  process.stderr.write("tamperward: cannot read the working tree (not a git repo?).\n");
3319
4886
  return 2;
@@ -3346,16 +4913,16 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3346
4913
  }
3347
4914
 
3348
4915
  // src/cli/init.ts
3349
- import { execFileSync as execFileSync4 } from "node:child_process";
4916
+ import { execFileSync as execFileSync5 } from "node:child_process";
3350
4917
  import { createHash as createHash5 } from "node:crypto";
3351
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3352
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join9, relative as relative2, resolve as resolve2 } from "node:path";
4918
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
4919
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
3353
4920
  import { fileURLToPath } from "node:url";
3354
4921
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3355
4922
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3356
4923
  function gitConfig(cwd, key2) {
3357
4924
  try {
3358
- return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
4925
+ return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
3359
4926
  } catch {
3360
4927
  return null;
3361
4928
  }
@@ -3414,7 +4981,7 @@ function shippedVersion() {
3414
4981
  const here = dirname3(fileURLToPath(import.meta.url));
3415
4982
  for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
3416
4983
  try {
3417
- const pkg = JSON.parse(readFileSync10(join9(here, rel), "utf8"));
4984
+ const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
3418
4985
  if (pkg.name === "tamperward" && pkg.version) return pkg.version;
3419
4986
  } catch {
3420
4987
  }
@@ -3521,13 +5088,13 @@ function readWorkflowMark(src) {
3521
5088
  if (!m) return null;
3522
5089
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
3523
5090
  }
3524
- function toolSet(matcher2) {
5091
+ function toolSet2(matcher2) {
3525
5092
  const m = String(matcher2 ?? "").trim();
3526
5093
  if (m === "" || m === "*") return null;
3527
5094
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
3528
5095
  }
3529
5096
  function missingTools(matcher2) {
3530
- const have = toolSet(matcher2);
5097
+ const have = toolSet2(matcher2);
3531
5098
  if (have === null) return [];
3532
5099
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3533
5100
  }
@@ -3547,8 +5114,8 @@ function hooksShapeError(hooks) {
3547
5114
  return null;
3548
5115
  }
3549
5116
  function planPolicy(cwd) {
3550
- const path = join9(cwd, POLICY_FILE);
3551
- if (existsSync7(path)) {
5117
+ const path = join10(cwd, POLICY_FILE);
5118
+ if (existsSync8(path)) {
3552
5119
  try {
3553
5120
  loadPolicy(cwd);
3554
5121
  } catch (e) {
@@ -3566,12 +5133,12 @@ function planPolicy(cwd) {
3566
5133
  }
3567
5134
  function planClaudeHooks(cwd) {
3568
5135
  const rel = ".claude/settings.json";
3569
- const path = join9(cwd, rel);
5136
+ const path = join10(cwd, rel);
3570
5137
  let settings = {};
3571
- if (existsSync7(path)) {
5138
+ if (existsSync8(path)) {
3572
5139
  let parsed;
3573
5140
  try {
3574
- parsed = JSON.parse(readFileSync10(path, "utf8"));
5141
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
3575
5142
  } catch {
3576
5143
  return {
3577
5144
  item: "agent",
@@ -3605,7 +5172,7 @@ function planClaudeHooks(cwd) {
3605
5172
  return {
3606
5173
  item: "agent",
3607
5174
  path: rel,
3608
- status: existsSync7(path) ? "update" : "create",
5175
+ status: existsSync8(path) ? "update" : "create",
3609
5176
  detail: [
3610
5177
  needPre && "wire PreToolUse deny",
3611
5178
  needStop && "wire Stop sweep",
@@ -3624,7 +5191,7 @@ function planClaudeHooks(cwd) {
3624
5191
  });
3625
5192
  }
3626
5193
  for (const m of stale) {
3627
- m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
5194
+ m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
3628
5195
  }
3629
5196
  if (needStop) {
3630
5197
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
@@ -3660,11 +5227,11 @@ var MANAGED_HOOKS = [
3660
5227
  ];
3661
5228
  function hooksDir(cwd) {
3662
5229
  try {
3663
- const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5230
+ const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3664
5231
  if (out2) return resolve2(cwd, out2);
3665
5232
  } catch {
3666
5233
  }
3667
- return existsSync7(join9(cwd, ".git")) ? join9(cwd, ".git", "hooks") : null;
5234
+ return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
3668
5235
  }
3669
5236
  function display(cwd, path) {
3670
5237
  const rel = relative2(cwd, path);
@@ -3691,30 +5258,30 @@ function planPreCommit(cwd) {
3691
5258
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3692
5259
  let path;
3693
5260
  let note;
3694
- if (existsSync7(join9(cwd, ".husky"))) {
3695
- path = join9(cwd, ".husky", "pre-commit");
5261
+ if (existsSync8(join10(cwd, ".husky"))) {
5262
+ path = join10(cwd, ".husky", "pre-commit");
3696
5263
  note = "husky";
3697
5264
  } else {
3698
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join9(cwd, c))));
5265
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
3699
5266
  if (managed) {
3700
5267
  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}`);
3701
5268
  }
3702
5269
  const dir = hooksDir(cwd);
3703
5270
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3704
5271
  if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3705
- path = join9(dirname3(dir), "pre-commit");
5272
+ path = join10(dirname3(dir), "pre-commit");
3706
5273
  note = "husky";
3707
5274
  } else {
3708
- path = join9(dir, "pre-commit");
5275
+ path = join10(dir, "pre-commit");
3709
5276
  const rel2 = display(cwd, dir);
3710
- note = rel2 === join9(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
5277
+ 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)`;
3711
5278
  }
3712
5279
  }
3713
5280
  const rel = display(cwd, path);
3714
- if (existsSync7(path) && !statSync5(path).isFile()) {
5281
+ if (existsSync8(path) && !statSync5(path).isFile()) {
3715
5282
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
3716
5283
  }
3717
- const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
5284
+ const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
3718
5285
  if (existing === null) {
3719
5286
  return {
3720
5287
  item: "pre-commit",
@@ -3803,10 +5370,10 @@ ${block.join("\n")}
3803
5370
  function planCodeowners(cwd) {
3804
5371
  const rel = ".github/CODEOWNERS";
3805
5372
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
3806
- (r) => existsSync7(join9(cwd, r))
5373
+ (r) => existsSync8(join10(cwd, r))
3807
5374
  );
3808
- const path = join9(cwd, existingRel ?? rel);
3809
- const existing = existingRel ? readFileSync10(path, "utf8") : null;
5375
+ const path = join10(cwd, existingRel ?? rel);
5376
+ const existing = existingRel ? readFileSync11(path, "utf8") : null;
3810
5377
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
3811
5378
  if (missing.length === 0) {
3812
5379
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -3839,12 +5406,12 @@ ${CODEOWNERS_MARK}
3839
5406
  }
3840
5407
  function planWorkflow(cwd, force) {
3841
5408
  const rel = ".github/workflows/tamperward.yml";
3842
- const path = join9(cwd, rel);
5409
+ const path = join10(cwd, rel);
3843
5410
  const write = () => {
3844
5411
  mkdirSync5(dirname3(path), { recursive: true });
3845
5412
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
3846
5413
  };
3847
- if (!existsSync7(path)) {
5414
+ if (!existsSync8(path)) {
3848
5415
  return {
3849
5416
  item: "ci",
3850
5417
  path: rel,
@@ -3855,7 +5422,7 @@ function planWorkflow(cwd, force) {
3855
5422
  }
3856
5423
  let src;
3857
5424
  try {
3858
- src = readFileSync10(path, "utf8");
5425
+ src = readFileSync11(path, "utf8");
3859
5426
  } catch {
3860
5427
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
3861
5428
  }
@@ -3952,30 +5519,30 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3952
5519
  }
3953
5520
 
3954
5521
  // src/cli/verify.ts
3955
- import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
5522
+ import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
3956
5523
  import { createHash as createHash8 } from "node:crypto";
3957
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
5524
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
3958
5525
  import { tmpdir as tmpdir2 } from "node:os";
3959
- import { dirname as dirname4, join as join12, resolve as resolve3 } from "node:path";
5526
+ import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
3960
5527
 
3961
5528
  // src/git/trusted.ts
3962
- import { execFileSync as execFileSync5 } from "node:child_process";
5529
+ import { execFileSync as execFileSync6 } from "node:child_process";
3963
5530
  import { createHash as createHash6 } from "node:crypto";
3964
- import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3965
- import { isAbsolute as isAbsolute5, join as join10 } from "node:path";
5531
+ import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
5532
+ import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
3966
5533
  function trustedGitEnv() {
3967
5534
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3968
5535
  }
3969
5536
  function gitDirOf(cwd) {
3970
5537
  try {
3971
- const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
5538
+ const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
3972
5539
  cwd,
3973
5540
  encoding: "utf8",
3974
5541
  stdio: ["ignore", "pipe", "pipe"],
3975
5542
  env: trustedGitEnv()
3976
5543
  }).trim();
3977
5544
  if (!d) return null;
3978
- return isAbsolute5(d) ? d : join10(cwd, d);
5545
+ return isAbsolute5(d) ? d : join11(cwd, d);
3979
5546
  } catch {
3980
5547
  return null;
3981
5548
  }
@@ -3984,7 +5551,7 @@ function objectRewriteState(cwd) {
3984
5551
  const parts = [];
3985
5552
  try {
3986
5553
  parts.push(
3987
- "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
5554
+ "replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3988
5555
  cwd,
3989
5556
  encoding: "utf8",
3990
5557
  stdio: ["ignore", "pipe", "pipe"],
@@ -4000,9 +5567,9 @@ function objectRewriteState(cwd) {
4000
5567
  parts.push(`${rel}:<no-git-dir>`);
4001
5568
  continue;
4002
5569
  }
4003
- const p = join10(dir, rel);
5570
+ const p = join11(dir, rel);
4004
5571
  try {
4005
- parts.push(`${rel}:` + (existsSync8(p) ? readFileSync11(p, "utf8").trim() : ""));
5572
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
4006
5573
  } catch {
4007
5574
  parts.push(`${rel}:<unreadable>`);
4008
5575
  }
@@ -4011,12 +5578,12 @@ function objectRewriteState(cwd) {
4011
5578
  }
4012
5579
 
4013
5580
  // src/fingerprint.ts
4014
- import { execFileSync as execFileSync6 } from "node:child_process";
5581
+ import { execFileSync as execFileSync7 } from "node:child_process";
4015
5582
  import { createHash as createHash7 } from "node:crypto";
4016
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
4017
- import { join as join11 } from "node:path";
5583
+ import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
5584
+ import { join as join12 } from "node:path";
4018
5585
  function treeFingerprint(cwd) {
4019
- const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
5586
+ const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
4020
5587
  cwd,
4021
5588
  encoding: "utf8",
4022
5589
  maxBuffer: 1 << 28,
@@ -4027,7 +5594,7 @@ function treeFingerprint(cwd) {
4027
5594
  h.update(rel);
4028
5595
  h.update("\0");
4029
5596
  try {
4030
- h.update(readFileSync12(join11(cwd, rel)));
5597
+ h.update(readFileSync13(join12(cwd, rel)));
4031
5598
  } catch {
4032
5599
  h.update("<unreadable>");
4033
5600
  }
@@ -4036,7 +5603,7 @@ function treeFingerprint(cwd) {
4036
5603
  return h.digest("hex");
4037
5604
  }
4038
5605
  function depsFingerprint(cwd) {
4039
- const root = join11(cwd, "node_modules");
5606
+ const root = join12(cwd, "node_modules");
4040
5607
  const h = createHash7("sha256");
4041
5608
  const walk = (dir) => {
4042
5609
  let entries;
@@ -4046,12 +5613,12 @@ function depsFingerprint(cwd) {
4046
5613
  return;
4047
5614
  }
4048
5615
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
4049
- const p = join11(dir, e.name);
5616
+ const p = join12(dir, e.name);
4050
5617
  if (e.isDirectory()) walk(p);
4051
5618
  else if (e.isFile()) {
4052
5619
  h.update(p);
4053
5620
  try {
4054
- h.update(readFileSync12(p));
5621
+ h.update(readFileSync13(p));
4055
5622
  h.update(String(statSync6(p).mode));
4056
5623
  } catch {
4057
5624
  h.update("<unreadable>");
@@ -4148,7 +5715,7 @@ var VERIFICATION_SURFACE = [
4148
5715
  "**/*.runsettings"
4149
5716
  ];
4150
5717
  function git2(args, cwd) {
4151
- return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
5718
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
4152
5719
  }
4153
5720
  function resolveBase(base, cwd) {
4154
5721
  assertRev(base);
@@ -4166,7 +5733,7 @@ function baseIsAncestorOfHead(base, cwd) {
4166
5733
  function materialize(cwd, dest) {
4167
5734
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4168
5735
  for (const rel of listed) {
4169
- const src = join12(cwd, rel);
5736
+ const src = join13(cwd, rel);
4170
5737
  let st;
4171
5738
  try {
4172
5739
  st = statSync7(src);
@@ -4174,18 +5741,18 @@ function materialize(cwd, dest) {
4174
5741
  continue;
4175
5742
  }
4176
5743
  if (!st.isFile()) continue;
4177
- const out2 = join12(dest, rel);
5744
+ const out2 = join13(dest, rel);
4178
5745
  mkdirSync6(dirname4(out2), { recursive: true });
4179
5746
  dropSymlink(out2);
4180
5747
  if (lstatSync(src).isSymbolicLink()) {
4181
- writeFileSync6(out2, readFileSync13(src));
5748
+ writeFileSync6(out2, readFileSync14(src));
4182
5749
  } else {
4183
5750
  cpSync(src, out2, { dereference: true });
4184
5751
  }
4185
5752
  chmodSync2(out2, st.mode);
4186
5753
  }
4187
5754
  const nm = resolve3(cwd, "node_modules");
4188
- if (existsSync9(nm)) symlinkSync(nm, join12(dest, "node_modules"), "dir");
5755
+ if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
4189
5756
  }
4190
5757
  function dropSymlink(p) {
4191
5758
  try {
@@ -4200,9 +5767,9 @@ function verifierInputs(cmd, atBase, policy) {
4200
5767
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
4201
5768
  if (tok && baseFiles.has(tok)) picked.add(tok);
4202
5769
  }
4203
- const globs2 = policy.verify?.inputs;
4204
- if (globs2 && globs2.length) {
4205
- for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
5770
+ const globs3 = policy.verify?.inputs;
5771
+ if (globs3 && globs3.length) {
5772
+ for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
4206
5773
  }
4207
5774
  return picked;
4208
5775
  }
@@ -4218,11 +5785,11 @@ function overlayDigest(dest, paths) {
4218
5785
  for (const rel of [...paths].sort()) {
4219
5786
  h.update(rel);
4220
5787
  h.update("\0");
4221
- const p = join12(dest, rel);
5788
+ const p = join13(dest, rel);
4222
5789
  try {
4223
5790
  const st = lstatSync(p);
4224
5791
  h.update(String(st.mode));
4225
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync13(p));
5792
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
4226
5793
  } catch {
4227
5794
  h.update("<gone>");
4228
5795
  }
@@ -4244,8 +5811,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4244
5811
  if (e.type === "commit") {
4245
5812
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4246
5813
  }
4247
- const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4248
- const out2 = join12(dest, e.path);
5814
+ const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
5815
+ const out2 = join13(dest, e.path);
4249
5816
  mkdirSync6(dirname4(out2), { recursive: true });
4250
5817
  rmSync2(out2, { force: true });
4251
5818
  if (e.mode === "120000") {
@@ -4260,7 +5827,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4260
5827
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4261
5828
  for (const rel of inCopy) {
4262
5829
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4263
- rmSync2(join12(dest, rel), { force: true });
5830
+ rmSync2(join13(dest, rel), { force: true });
4264
5831
  removed++;
4265
5832
  }
4266
5833
  return { restored, removed };
@@ -4302,8 +5869,8 @@ function suiteEnv(scratch) {
4302
5869
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4303
5870
  env[k] = v;
4304
5871
  }
4305
- const userRc = join12(scratch, "npmrc-user");
4306
- const globalRc = join12(scratch, "npmrc-global");
5872
+ const userRc = join13(scratch, "npmrc-user");
5873
+ const globalRc = join13(scratch, "npmrc-global");
4307
5874
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4308
5875
  env.npm_config_userconfig = userRc;
4309
5876
  env.npm_config_globalconfig = globalRc;
@@ -4313,8 +5880,8 @@ function suiteEnv(scratch) {
4313
5880
  }
4314
5881
  function runSuite(dir, cmd, budgetSecs) {
4315
5882
  const t0 = Date.now();
4316
- const outDir = mkdtempSync2(join12(tmpdir2(), "tw-verify-run-"));
4317
- const outFile = join12(outDir, "result.json");
5883
+ const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
5884
+ const outFile = join13(outDir, "result.json");
4318
5885
  try {
4319
5886
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
4320
5887
  cwd: dir,
@@ -4327,7 +5894,7 @@ function runSuite(dir, cmd, budgetSecs) {
4327
5894
  const secs = Math.round((Date.now() - t0) / 1e3);
4328
5895
  let r;
4329
5896
  try {
4330
- r = JSON.parse(readFileSync13(outFile, "utf8"));
5897
+ r = JSON.parse(readFileSync14(outFile, "utf8"));
4331
5898
  } catch {
4332
5899
  return { exit: null, secs };
4333
5900
  }
@@ -4378,8 +5945,8 @@ function runVerify(opts) {
4378
5945
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
4379
5946
  return 2;
4380
5947
  }
4381
- const visRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-vis-"));
4382
- const visDir = join12(visRoot, "t");
5948
+ const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
5949
+ const visDir = join13(visRoot, "t");
4383
5950
  const cleanup = (dirs) => {
4384
5951
  if (opts.keep) return;
4385
5952
  for (const d of dirs) rmSync2(d, { recursive: true, force: true });
@@ -4407,8 +5974,8 @@ function runVerify(opts) {
4407
5974
  out2("pristine run executes through it too. Failing closed, not open.");
4408
5975
  return 2;
4409
5976
  }
4410
- const priRoot = mkdtempSync2(join12(tmpdir2(), "tw-verify-pri-"));
4411
- const priDir = join12(priRoot, "t");
5977
+ const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
5978
+ const priDir = join13(priRoot, "t");
4412
5979
  let restored = [];
4413
5980
  let removedAdded = 0;
4414
5981
  try {
@@ -4519,17 +6086,17 @@ function parseVerify(args) {
4519
6086
  }
4520
6087
 
4521
6088
  // src/cli/run.ts
4522
- import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
4523
- import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
6089
+ import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
6090
+ import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
4524
6091
  import { resolve as resolve4 } from "node:path";
4525
6092
  var out = (s) => process.stdout.write(s + "\n");
4526
6093
  var err = (s) => process.stderr.write(s + "\n");
4527
6094
  function git3(args, cwd) {
4528
- return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
6095
+ return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
4529
6096
  }
4530
6097
  function nowTicks() {
4531
6098
  try {
4532
- return Math.floor(parseFloat(readFileSync14("/proc/uptime", "utf8").split(" ")[0]) * 100);
6099
+ return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
4533
6100
  } catch {
4534
6101
  return Number.POSITIVE_INFINITY;
4535
6102
  }
@@ -4559,7 +6126,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
4559
6126
  }
4560
6127
  if (held !== real && !held.startsWith(real + "/")) continue;
4561
6128
  try {
4562
- const stat = readFileSync14(`/proc/${pid}/stat`, "utf8");
6129
+ const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
4563
6130
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
4564
6131
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
4565
6132
  } catch {
@@ -4650,7 +6217,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
4650
6217
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
4651
6218
  if (opts.settle && opts.settle > 0) {
4652
6219
  try {
4653
- execFileSync8("sleep", [String(opts.settle)]);
6220
+ execFileSync9("sleep", [String(opts.settle)]);
4654
6221
  } catch {
4655
6222
  }
4656
6223
  }