tamperward 2.4.1 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +1197 -173
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -330,26 +330,52 @@ function diffWorktree(opts = {}) {
330
330
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
331
331
  );
332
332
  }
333
+ function addOf(rel, cwd) {
334
+ const after = fromDisk(rel, cwd);
335
+ return {
336
+ kind: "file",
337
+ path: rel,
338
+ oldPath: null,
339
+ op: "add",
340
+ before: null,
341
+ after,
342
+ binary: false,
343
+ hunks: after == null ? [] : addHunks(after)
344
+ };
345
+ }
333
346
  function untrackedAdds(opts = {}, keep, exclude) {
334
347
  const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
335
- return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => {
336
- const after = fromDisk(rel, opts.cwd);
337
- return {
338
- kind: "file",
339
- path: rel,
340
- oldPath: null,
341
- op: "add",
342
- before: null,
343
- after,
344
- binary: false,
345
- hunks: after == null ? [] : addHunks(after)
346
- };
347
- });
348
+ return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => addOf(rel, opts.cwd));
348
349
  }
349
- function diffWorktreeWithUntracked(opts = {}) {
350
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
351
+ var IGNORED_LS = ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"];
352
+ function underSkippedDir(rel) {
353
+ return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
354
+ }
355
+ function ignoredPaths(opts = {}, keep) {
356
+ const files = [];
357
+ const dirs = [];
358
+ for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
359
+ if (underSkippedDir(entry)) continue;
360
+ if (entry.endsWith("/")) dirs.push(entry);
361
+ else files.push(entry);
362
+ }
363
+ if (dirs.length > 0) {
364
+ const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
365
+ for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
366
+ }
367
+ return files.filter(keep);
368
+ }
369
+ function ignoredAdds(opts = {}, keep, exclude) {
370
+ return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
371
+ }
372
+ function diffWorktreeWithUntracked(opts = {}, keepIgnored) {
350
373
  const tracked = diffWorktree(opts);
351
374
  const seen = new Set(tracked.filter((c) => c.kind === "file").map((c) => c.path));
352
- return [...tracked, ...untrackedAdds(opts, void 0, seen)];
375
+ const untracked = untrackedAdds(opts, void 0, seen);
376
+ for (const c of untracked) if (c.kind === "file") seen.add(c.path);
377
+ const ignored = keepIgnored ? ignoredAdds(opts, keepIgnored, seen) : [];
378
+ return [...tracked, ...untracked, ...ignored];
353
379
  }
354
380
  function hiddenTrackedPaths(opts = {}) {
355
381
  const out2 = [];
@@ -560,12 +586,12 @@ function matcher(glob) {
560
586
  }
561
587
  return m;
562
588
  }
563
- function matchesAny(path, globs2) {
564
- return !!globs2 && globs2.some((g) => matcher(g)(path));
589
+ function matchesAny(path, globs3) {
590
+ return !!globs3 && globs3.some((g) => matcher(g)(path));
565
591
  }
566
592
  function protectedCategory(path, policy) {
567
- for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
568
- if (matchesAny(path, globs2)) return cat;
593
+ for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
594
+ if (matchesAny(path, globs3)) return cat;
569
595
  }
570
596
  return null;
571
597
  }
@@ -581,9 +607,9 @@ function isIgnored(path, policy) {
581
607
  }
582
608
  function mergeProtected(base, user) {
583
609
  const out2 = { ...base };
584
- for (const [cat, globs2] of Object.entries(user ?? {})) {
610
+ for (const [cat, globs3] of Object.entries(user ?? {})) {
585
611
  const have = new Set(out2[cat] ?? []);
586
- out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
612
+ out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
587
613
  }
588
614
  return out2;
589
615
  }
@@ -631,7 +657,19 @@ function defaultPolicy(version = 1) {
631
657
  "**/tsconfig*.json",
632
658
  "**/.eslintrc*",
633
659
  "**/eslint.config.*",
634
- "**/package.json"
660
+ "**/package.json",
661
+ // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
662
+ // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
663
+ // codecov `target` lived in files no glob protected, so lowering them fired
664
+ // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
665
+ // as a strengthening). pyproject.toml carries [tool.coverage.report].
666
+ "**/.coveragerc",
667
+ "**/pyproject.toml",
668
+ "**/.nycrc",
669
+ "**/.nycrc.*",
670
+ "**/nyc.config.*",
671
+ "**/codecov.yml",
672
+ "**/.codecov.yml"
635
673
  ],
636
674
  ci: [".github/workflows/**"],
637
675
  // Recorded expected outputs. An assertion stored as data is still an assertion;
@@ -735,9 +773,11 @@ var IMPORTS = {
735
773
  cs: /^(?:using\b|namespace\b)/
736
774
  };
737
775
  var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
776
+ var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
738
777
  function isSignificantLine(trimmed, lang) {
739
778
  if (trimmed.length < 10) return false;
740
779
  if (COMMON.test(trimmed)) return false;
780
+ if (DEBUG_ONLY.test(trimmed)) return false;
741
781
  const l = lang ?? "js";
742
782
  if (IMPORTS[l].test(trimmed)) return false;
743
783
  if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
@@ -1075,7 +1115,47 @@ var testSkip = {
1075
1115
  };
1076
1116
 
1077
1117
  // src/detectors/coverage-lowering.ts
1118
+ import picomatch2 from "picomatch";
1078
1119
  import ts2 from "typescript";
1120
+
1121
+ // src/detectors/repo.ts
1122
+ import { execFileSync as execFileSync2 } from "node:child_process";
1123
+ var cache2 = /* @__PURE__ */ new Map();
1124
+ function trackedFiles(ctx) {
1125
+ if (!ctx) return null;
1126
+ if (ctx.trackedFiles) return ctx.trackedFiles;
1127
+ if (!ctx.cwd) return null;
1128
+ const hit = cache2.get(ctx.cwd);
1129
+ if (hit !== void 0) return hit;
1130
+ let files = null;
1131
+ try {
1132
+ files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1133
+ cwd: ctx.cwd,
1134
+ encoding: "utf8",
1135
+ maxBuffer: 64 * 1024 * 1024,
1136
+ stdio: ["ignore", "pipe", "pipe"]
1137
+ }).split("\0").filter(Boolean);
1138
+ } catch {
1139
+ files = null;
1140
+ }
1141
+ cache2.set(ctx.cwd, files);
1142
+ return files;
1143
+ }
1144
+ var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1145
+ function containsProtected(token, policy, category, ctx) {
1146
+ const dir = dirOf(token);
1147
+ if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1148
+ const files = trackedFiles(ctx);
1149
+ if (files) {
1150
+ const prefix = dir + "/";
1151
+ return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1152
+ }
1153
+ if (isProtected(dir + "/probe", policy, category)) return true;
1154
+ const base = dir.split("/").pop() ?? "";
1155
+ return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1156
+ }
1157
+
1158
+ // src/detectors/coverage-lowering.ts
1079
1159
  var RULE4 = "coverage-lowering";
1080
1160
  var METRICS = ["branches", "functions", "lines", "statements"];
1081
1161
  var isMetric = (k) => METRICS.includes(k);
@@ -1086,29 +1166,36 @@ function keyName(name) {
1086
1166
  }
1087
1167
  function numericOf(e) {
1088
1168
  if (ts2.isNumericLiteral(e)) return Number(e.text);
1169
+ if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
1089
1170
  if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
1090
1171
  return -Number(e.operand.text);
1091
1172
  }
1092
1173
  return void 0;
1093
1174
  }
1094
- function metricsOf(obj) {
1175
+ function metricsOf(expr, resolve5) {
1176
+ const obj = resolve5(expr);
1095
1177
  if (!ts2.isObjectLiteralExpression(obj)) return void 0;
1096
- const out2 = {};
1178
+ const out2 = { values: {}, opaque: false };
1097
1179
  let any = false;
1098
1180
  for (const p of obj.properties) {
1181
+ if (ts2.isSpreadAssignment(p)) {
1182
+ out2.opaque = true;
1183
+ any = true;
1184
+ continue;
1185
+ }
1099
1186
  if (!ts2.isPropertyAssignment(p)) continue;
1100
1187
  const k = keyName(p.name);
1101
1188
  if (k === null || !isMetric(k)) continue;
1102
- const n = numericOf(p.initializer);
1103
- if (n === void 0) continue;
1104
- out2[k] = n;
1105
1189
  any = true;
1190
+ const n = numericOf(resolve5(p.initializer));
1191
+ if (n === void 0) out2.opaque = true;
1192
+ else out2.values[k] = n;
1106
1193
  }
1107
1194
  return any ? out2 : void 0;
1108
1195
  }
1109
- function underCoverageKey(node) {
1196
+ function underKey(node, key2) {
1110
1197
  for (let p = node.parent; p; p = p.parent) {
1111
- if (ts2.isPropertyAssignment(p) && keyName(p.name) === "coverage") return true;
1198
+ if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
1112
1199
  }
1113
1200
  return false;
1114
1201
  }
@@ -1116,25 +1203,54 @@ function asExpression(src) {
1116
1203
  const t = src.trim();
1117
1204
  return t.startsWith("{") ? `(${t})` : src;
1118
1205
  }
1206
+ function sourceOf(src) {
1207
+ return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1208
+ }
1209
+ function resolverFor(sf) {
1210
+ const consts = /* @__PURE__ */ new Map();
1211
+ for (const st of sf.statements) {
1212
+ if (!ts2.isVariableStatement(st)) continue;
1213
+ for (const d of st.declarationList.declarations) {
1214
+ if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
1215
+ }
1216
+ }
1217
+ return (e) => {
1218
+ if (ts2.isIdentifier(e)) {
1219
+ const bound = consts.get(e.text);
1220
+ return bound ?? e;
1221
+ }
1222
+ return e;
1223
+ };
1224
+ }
1119
1225
  function parseThresholds(src) {
1120
- const res = { paths: /* @__PURE__ */ new Map(), present: false };
1226
+ const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
1121
1227
  try {
1122
- const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1228
+ const sf = sourceOf(src);
1229
+ const resolve5 = resolverFor(sf);
1123
1230
  const visit = (node) => {
1124
- if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
1231
+ if (ts2.isPropertyAssignment(node)) {
1125
1232
  const key2 = keyName(node.name);
1126
- if (key2 === "coverageThreshold" || key2 === "thresholds" && underCoverageKey(node)) {
1233
+ if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
1127
1234
  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);
1235
+ const init = resolve5(node.initializer);
1236
+ if (!ts2.isObjectLiteralExpression(init)) {
1237
+ res.opaque = true;
1238
+ } else {
1239
+ const flat = metricsOf(init, resolve5);
1240
+ if (flat) res.global = merge(res.global, flat);
1241
+ for (const p of init.properties) {
1242
+ if (ts2.isSpreadAssignment(p)) {
1243
+ res.opaque = true;
1244
+ continue;
1245
+ }
1246
+ if (!ts2.isPropertyAssignment(p)) continue;
1247
+ const k = keyName(p.name);
1248
+ if (k === null || isMetric(k)) continue;
1249
+ const m = metricsOf(p.initializer, resolve5);
1250
+ if (!m) continue;
1251
+ if (k === "global") res.global = merge(res.global, m);
1252
+ else res.paths.set(norm(k), m);
1253
+ }
1138
1254
  }
1139
1255
  }
1140
1256
  }
@@ -1145,41 +1261,242 @@ function parseThresholds(src) {
1145
1261
  }
1146
1262
  return res;
1147
1263
  }
1148
- var eff = (t, path) => t.paths.get(norm(path)) ?? t.global;
1264
+ function merge(a, b) {
1265
+ return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
1266
+ }
1267
+ function literals(e) {
1268
+ if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
1269
+ if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
1270
+ const items = [];
1271
+ let opaque = false;
1272
+ for (const el of e.elements) {
1273
+ if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
1274
+ else opaque = true;
1275
+ }
1276
+ return { items, opaque };
1277
+ }
1278
+ function parseLists(src) {
1279
+ const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1280
+ try {
1281
+ const sf = sourceOf(src);
1282
+ const resolve5 = resolverFor(sf);
1283
+ const visit = (node) => {
1284
+ if (ts2.isPropertyAssignment(node)) {
1285
+ const k = keyName(node.name);
1286
+ if (k === "collectCoverageFrom") {
1287
+ const { items, opaque } = literals(resolve5(node.initializer));
1288
+ res.collectFrom = items;
1289
+ if (opaque) res.opaqueCollect = true;
1290
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1291
+ const { items, opaque } = literals(resolve5(node.initializer));
1292
+ res.ignore.push(...items);
1293
+ if (opaque) res.opaqueIgnore = true;
1294
+ }
1295
+ }
1296
+ ts2.forEachChild(node, visit);
1297
+ };
1298
+ visit(sf);
1299
+ } catch {
1300
+ }
1301
+ return res;
1302
+ }
1303
+ 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
1304
  function compareMetrics(scope, before, after, out2) {
1150
1305
  if (!before) return;
1151
1306
  for (const m of METRICS) {
1152
- const bv = before[m];
1307
+ const bv = before.values[m];
1153
1308
  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}`);
1309
+ const av = after?.values[m];
1310
+ if (av === void 0) {
1311
+ if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
1312
+ } else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
1313
+ }
1314
+ }
1315
+ function overrideTargetExists(key2, configPath, ctx) {
1316
+ const files = trackedFiles(ctx);
1317
+ if (!files) return null;
1318
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1319
+ const rel = norm(key2);
1320
+ let m;
1321
+ try {
1322
+ m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
1323
+ } catch {
1324
+ return null;
1157
1325
  }
1326
+ return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1158
1327
  }
1159
- function weakenings(before, after) {
1328
+ function weakenings(before, after, configPath, ctx) {
1160
1329
  const out2 = [];
1161
1330
  if (before.present && !after.present) {
1162
1331
  out2.push("the coverage threshold gate was removed");
1163
1332
  return out2;
1164
1333
  }
1334
+ if (after.opaque && !after.global && after.paths.size === 0) return out2;
1165
1335
  compareMetrics("global", before.global, after.global, out2);
1166
1336
  const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
1167
1337
  for (const p of paths) {
1168
- compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p), out2);
1338
+ const scope = `required coverage for ${p}:`;
1339
+ const b = before.paths.get(p);
1340
+ const a = after.paths.get(p);
1341
+ if (b && a) {
1342
+ compareMetrics(scope, b, a, out2);
1343
+ } else if (a) {
1344
+ const g = before.global;
1345
+ if (!g) continue;
1346
+ for (const m of METRICS) {
1347
+ const gv = g.values[m];
1348
+ const av = a.values[m];
1349
+ if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
1350
+ }
1351
+ } else if (b) {
1352
+ if (overrideTargetExists(p, configPath, ctx) === false) continue;
1353
+ compareMetrics(scope, b, after.global, out2);
1354
+ }
1355
+ }
1356
+ return out2;
1357
+ }
1358
+ function narrowings(before, after) {
1359
+ const out2 = [];
1360
+ if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
1361
+ const kept = new Set(after.collectFrom);
1362
+ for (const g of before.collectFrom) {
1363
+ if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
1364
+ }
1365
+ const had2 = new Set(before.collectFrom);
1366
+ for (const g of after.collectFrom) {
1367
+ if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
1368
+ }
1369
+ }
1370
+ const had = new Set(before.ignore);
1371
+ for (const g of after.ignore) {
1372
+ if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
1373
+ }
1374
+ return out2;
1375
+ }
1376
+ var SIMPLE_FILES = [
1377
+ {
1378
+ // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1379
+ // [tool.coverage.report] (pyproject.toml)
1380
+ file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1381
+ keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1382
+ removable: true
1383
+ },
1384
+ {
1385
+ // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1386
+ file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
1387
+ keys: METRICS.map((m) => ({
1388
+ key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1389
+ label: m,
1390
+ weakens: "lower"
1391
+ })),
1392
+ removable: false
1393
+ },
1394
+ {
1395
+ // codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
1396
+ // permitted drop (raising weakens)
1397
+ file: /(?:^|\/)\.?codecov\.yml$/,
1398
+ keys: [
1399
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1400
+ { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1401
+ ],
1402
+ removable: false
1403
+ }
1404
+ ];
1405
+ function numbersOf(src, key2) {
1406
+ const out2 = [];
1407
+ for (const line of src.split("\n")) {
1408
+ if (key2.global) {
1409
+ for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
1410
+ } else {
1411
+ const m = line.match(key2);
1412
+ if (m) out2.push(Number(m[1]));
1413
+ }
1169
1414
  }
1170
1415
  return out2;
1171
1416
  }
1417
+ function simpleWeakenings(path, before, after) {
1418
+ const out2 = [];
1419
+ const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1420
+ if (!spec) return out2;
1421
+ for (const k of spec.keys) {
1422
+ const b = numbersOf(before, k.key).sort((x, y) => y - x);
1423
+ const a = numbersOf(after, k.key).sort((x, y) => y - x);
1424
+ if (b.length === 0) continue;
1425
+ if (a.length === 0) {
1426
+ if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
1427
+ continue;
1428
+ }
1429
+ if (k.weakens === "lower") {
1430
+ for (let i = 0; i < b.length; i++) {
1431
+ const av = a[Math.min(i, a.length - 1)];
1432
+ if (av < b[i]) {
1433
+ out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
1434
+ break;
1435
+ }
1436
+ }
1437
+ } else {
1438
+ const bMax = b[0];
1439
+ const aMax = a[0];
1440
+ if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1441
+ }
1442
+ }
1443
+ return out2;
1444
+ }
1445
+ var SWITCHES = [
1446
+ {
1447
+ re: /--passWithNoTests\b/,
1448
+ message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
1449
+ remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1450
+ },
1451
+ {
1452
+ re: /\bpassWithNoTests"?\s*:\s*true\b/,
1453
+ message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
1454
+ remediation: "Remove passWithNoTests; an empty suite must not be a green check."
1455
+ },
1456
+ {
1457
+ re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
1458
+ message: "coverage switched off on the command line (--coverage=false).",
1459
+ remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
1460
+ },
1461
+ {
1462
+ re: /--coverageThreshold[= ]/,
1463
+ message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
1464
+ remediation: "Remove the override; the configured threshold is the gate."
1465
+ },
1466
+ {
1467
+ re: /\bautoUpdate"?\s*:\s*true\b/,
1468
+ message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
1469
+ remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
1470
+ },
1471
+ {
1472
+ re: /"?check-coverage"?\s*[:=]\s*false\b/,
1473
+ message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1474
+ remediation: "Keep check-coverage on; raise real coverage instead."
1475
+ },
1476
+ {
1477
+ re: /^\s*informational:\s*true\b/,
1478
+ message: "informational: true added \u2014 the codecov status can no longer fail.",
1479
+ remediation: "Remove informational; a status that cannot fail is not a gate."
1480
+ }
1481
+ ];
1482
+ var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1172
1483
  var coverageLowering = {
1173
1484
  id: RULE4,
1174
1485
  surface: ["file"],
1175
1486
  certainty: "mechanical",
1176
- run(changes, policy) {
1487
+ run(changes, policy, _view, ctx) {
1177
1488
  const out2 = [];
1178
1489
  for (const c of changes) {
1179
1490
  if (c.kind !== "file") continue;
1180
1491
  if (!isProtected(c.path, policy, "config")) continue;
1181
1492
  if (c.after != null && c.op !== "delete") {
1182
- for (const reason of weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after))) {
1493
+ const reasons = [];
1494
+ if (isJsConfig(c.path)) {
1495
+ reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
1496
+ reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
1497
+ }
1498
+ reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1499
+ for (const reason of reasons) {
1183
1500
  out2.push(
1184
1501
  makeFinding(RULE4, policy, {
1185
1502
  file: c.path,
@@ -1190,7 +1507,9 @@ var coverageLowering = {
1190
1507
  );
1191
1508
  }
1192
1509
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1193
- if (parseThresholds(c.before ?? "").present) {
1510
+ const before = c.before ?? "";
1511
+ 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));
1512
+ if (gated) {
1194
1513
  out2.push(
1195
1514
  makeFinding(RULE4, policy, {
1196
1515
  file: c.path,
@@ -1215,20 +1534,22 @@ var coverageLowering = {
1215
1534
  );
1216
1535
  }
1217
1536
  for (const l of added) {
1218
- if (/--passWithNoTests\b/.test(l.content) || /\bpassWithNoTests\s*:\s*true\b/.test(l.content)) {
1537
+ for (const s of SWITCHES) {
1538
+ if (!s.re.test(l.content)) continue;
1539
+ if (removed.some((r) => s.re.test(r.content))) continue;
1219
1540
  out2.push(
1220
1541
  makeFinding(RULE4, policy, {
1221
1542
  file: c.path,
1222
1543
  line: l.newLine ?? void 0,
1223
- message: "passWithNoTests added \u2014 the suite can now pass with zero tests.",
1544
+ message: s.message,
1224
1545
  evidence: l.content.trim(),
1225
- remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1546
+ remediation: s.remediation
1226
1547
  })
1227
1548
  );
1228
1549
  }
1229
1550
  }
1230
1551
  if (c.after == null) {
1231
- const KEY = /\b(branches|functions|lines|statements)\b\s*:\s*(\d+(?:\.\d+)?)/;
1552
+ const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
1232
1553
  const oldNums = /* @__PURE__ */ new Map();
1233
1554
  for (const l of removed) {
1234
1555
  const m = l.content.match(KEY);
@@ -1254,7 +1575,7 @@ var coverageLowering = {
1254
1575
  }
1255
1576
  const seen = /* @__PURE__ */ new Set();
1256
1577
  return out2.filter((f) => {
1257
- const k = `${f.rule}|${f.message}`;
1578
+ const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
1258
1579
  if (seen.has(k)) return false;
1259
1580
  seen.add(k);
1260
1581
  return true;
@@ -1276,23 +1597,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1276
1597
  function commandCore(line) {
1277
1598
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1278
1599
  }
1600
+ function canonical(core) {
1601
+ let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
1602
+ s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1603
+ s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1604
+ s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
1605
+ s = s.replace(/^exec\s+/, "");
1606
+ return s.replace(/\s+--\s*$/, "").trim();
1607
+ }
1608
+ var usesRef = (core) => core.replace(/@.*$/, "");
1609
+ 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/;
1610
+ function survives(removedCore, isUses, afterCores) {
1611
+ if (isUses) {
1612
+ const ref = usesRef(removedCore);
1613
+ return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1614
+ }
1615
+ const r = canonical(removedCore);
1616
+ let neutralised;
1617
+ for (const raw of afterCores) {
1618
+ const a = canonical(raw);
1619
+ if (a === r) return { state: "kept" };
1620
+ if (a.startsWith(r + " ")) {
1621
+ const rest = a.slice(r.length);
1622
+ if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1623
+ else return { state: "kept" };
1624
+ } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1625
+ return { state: "kept" };
1626
+ }
1627
+ }
1628
+ return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1629
+ }
1279
1630
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1280
- function isAlwaysFalse(raw) {
1281
- const v = uncommented(raw);
1282
- if (/^false$/i.test(v)) return true;
1631
+ function lex(src) {
1632
+ const out2 = [];
1633
+ let i = 0;
1634
+ while (i < src.length) {
1635
+ const ch = src[i];
1636
+ if (/\s/.test(ch)) {
1637
+ i++;
1638
+ continue;
1639
+ }
1640
+ if (ch === "'") {
1641
+ let j = i + 1;
1642
+ let s = "";
1643
+ while (j < src.length) {
1644
+ if (src[j] === "'" && src[j + 1] === "'") {
1645
+ s += "'";
1646
+ j += 2;
1647
+ } else if (src[j] === "'") break;
1648
+ else s += src[j++];
1649
+ }
1650
+ if (j >= src.length) return null;
1651
+ out2.push({ t: "str", v: s });
1652
+ i = j + 1;
1653
+ continue;
1654
+ }
1655
+ const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
1656
+ if (num) {
1657
+ out2.push({ t: "num", v: num[0] });
1658
+ i += num[0].length;
1659
+ continue;
1660
+ }
1661
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1662
+ if (op) {
1663
+ out2.push({ t: "op", v: op[0] });
1664
+ i += op[0].length;
1665
+ continue;
1666
+ }
1667
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1668
+ if (id) {
1669
+ out2.push({ t: "id", v: id[0] });
1670
+ i += id[0].length;
1671
+ continue;
1672
+ }
1673
+ return null;
1674
+ }
1675
+ return out2;
1676
+ }
1677
+ function foldConst(src) {
1678
+ const toks = lex(src);
1679
+ if (!toks) return void 0;
1680
+ let p = 0;
1681
+ const peek = () => toks[p];
1682
+ const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
1683
+ const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
1684
+ const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1685
+ const eq = (a, b) => {
1686
+ if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1687
+ if (a === null && b === null) return true;
1688
+ const x = num(a);
1689
+ const y = num(b);
1690
+ return !Number.isNaN(x) && x === y;
1691
+ };
1692
+ const or = () => {
1693
+ let l = and();
1694
+ while (eat("||")) {
1695
+ const r = and();
1696
+ if (l === void 0 || r === void 0) return void 0;
1697
+ l = truthy(l) ? l : r;
1698
+ }
1699
+ return l;
1700
+ };
1701
+ const and = () => {
1702
+ let l = cmp();
1703
+ while (eat("&&")) {
1704
+ const r = cmp();
1705
+ if (l === void 0 || r === void 0) return void 0;
1706
+ l = truthy(l) ? r : l;
1707
+ }
1708
+ return l;
1709
+ };
1710
+ const cmp = () => {
1711
+ let l = unary();
1712
+ for (; ; ) {
1713
+ const t = peek();
1714
+ if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1715
+ p++;
1716
+ const r = unary();
1717
+ if (l === void 0 || r === void 0) return void 0;
1718
+ if (t.v === "==") l = eq(l, r);
1719
+ else if (t.v === "!=") l = !eq(l, r);
1720
+ else {
1721
+ const x = num(l);
1722
+ const y = num(r);
1723
+ if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
1724
+ l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
1725
+ }
1726
+ }
1727
+ };
1728
+ const unary = () => {
1729
+ if (eat("!")) {
1730
+ const v2 = unary();
1731
+ return v2 === void 0 ? void 0 : !truthy(v2);
1732
+ }
1733
+ return primary();
1734
+ };
1735
+ const primary = () => {
1736
+ const t = peek();
1737
+ if (!t) return void 0;
1738
+ if (eat("(")) {
1739
+ const v2 = or();
1740
+ return eat(")") ? v2 : void 0;
1741
+ }
1742
+ p++;
1743
+ if (t.t === "str") return t.v;
1744
+ if (t.t === "num") return Number(t.v);
1745
+ if (t.t === "id") {
1746
+ if (/^true$/i.test(t.v)) return true;
1747
+ if (/^false$/i.test(t.v)) return false;
1748
+ if (/^null$/i.test(t.v)) return null;
1749
+ return void 0;
1750
+ }
1751
+ return void 0;
1752
+ };
1753
+ const v = or();
1754
+ return p === toks.length ? v : void 0;
1755
+ }
1756
+ function expressionOf(raw) {
1757
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1283
1758
  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;
1759
+ return m ? m[1] : v;
1760
+ }
1761
+ function isAlwaysFalse(raw) {
1762
+ const e = expressionOf(raw);
1763
+ if (e === "") return false;
1764
+ const v = foldConst(e);
1765
+ return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
1290
1766
  }
1291
1767
  function isTruthy(raw) {
1292
- const v = uncommented(raw);
1293
- if (/^true$/i.test(v)) return true;
1768
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1769
+ if (/^(?:true|yes|on|1)$/i.test(v)) return true;
1294
1770
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1295
- return !!m && /^(true|1)$/i.test(m[1]);
1771
+ if (!m) return false;
1772
+ const f = foldConst(m[1]);
1773
+ return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
1774
+ }
1775
+ var indentOf = (l) => l.match(/^\s*/)[0].length;
1776
+ var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
1777
+ function stepOf(lines, i) {
1778
+ const ind = indentOf(lines[i]);
1779
+ let s = i - 1;
1780
+ while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
1781
+ if (s < 0 || !STEP_START.test(lines[s])) return null;
1782
+ const stepInd = indentOf(lines[s]);
1783
+ let e = i + 1;
1784
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1785
+ return [s, e];
1786
+ }
1787
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
1788
+ function runBlocks(lines) {
1789
+ const out2 = [];
1790
+ for (let i = 0; i < lines.length; i++) {
1791
+ if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
1792
+ const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
1793
+ let e = i + 1;
1794
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
1795
+ out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
1796
+ }
1797
+ return out2;
1798
+ }
1799
+ var NEUTRALISERS = [
1800
+ { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
1801
+ { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
1802
+ { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1803
+ { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1804
+ ];
1805
+ function flowList(v) {
1806
+ const m = v.trim().match(/^\[(.*)\]$/);
1807
+ if (!m) return null;
1808
+ return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1809
+ }
1810
+ function parseTriggers(src) {
1811
+ const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
1812
+ const lines = src.split("\n");
1813
+ const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1814
+ if (i < 0) return t;
1815
+ t.present = true;
1816
+ const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
1817
+ const inline = uncommented(head);
1818
+ if (inline) {
1819
+ const list = flowList(inline);
1820
+ for (const e of list ?? [inline]) t.events.add(e);
1821
+ return t;
1822
+ }
1823
+ let event = null;
1824
+ let listKey = null;
1825
+ let listInd = -1;
1826
+ for (let j = i + 1; j < lines.length; j++) {
1827
+ const l = lines[j];
1828
+ if (l.trim() === "" || /^\s*#/.test(l)) continue;
1829
+ const ind = indentOf(l);
1830
+ if (ind === 0) break;
1831
+ const body = uncommented(l);
1832
+ const item = body.match(/^\s*-\s+(.+)$/);
1833
+ if (item && listKey && event && ind > listInd) {
1834
+ const target = t[listKey].get(event) ?? [];
1835
+ target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
1836
+ t[listKey].set(event, target);
1837
+ continue;
1838
+ }
1839
+ const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
1840
+ if (!kv) continue;
1841
+ if (ind === 2 || event === null && ind < 4) {
1842
+ event = kv[1];
1843
+ t.events.add(event);
1844
+ listKey = null;
1845
+ continue;
1846
+ }
1847
+ if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1848
+ listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
1849
+ listInd = ind;
1850
+ const list = flowList(kv[2]);
1851
+ t[listKey].set(event, list ?? []);
1852
+ } else listKey = null;
1853
+ }
1854
+ return t;
1855
+ }
1856
+ var DEFAULT_BRANCH = /^(?:main|master)$/;
1857
+ var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1858
+ function triggerNarrowings(before, after) {
1859
+ const out2 = [];
1860
+ if (!before.present || !after.present) return out2;
1861
+ for (const e of ["push", "pull_request", "pull_request_target"]) {
1862
+ if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
1863
+ }
1864
+ for (const [e, list] of after.pathsIgnore) {
1865
+ const had = before.pathsIgnore.get(e) ?? [];
1866
+ 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}`);
1867
+ }
1868
+ for (const [e, list] of after.branchesIgnore) {
1869
+ const had = before.branchesIgnore.get(e) ?? [];
1870
+ if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1871
+ }
1872
+ for (const [e, was] of before.branches) {
1873
+ const now = after.branches.get(e);
1874
+ if (!now || !after.events.has(e)) continue;
1875
+ if (now.some(ALL)) continue;
1876
+ const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
1877
+ const replaced = was.length > 0 && !was.some((b) => now.includes(b));
1878
+ if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1879
+ }
1880
+ return out2;
1296
1881
  }
1297
1882
  var ciTampering = {
1298
1883
  id: RULE5,
@@ -1314,9 +1899,14 @@ var ciTampering = {
1314
1899
  continue;
1315
1900
  }
1316
1901
  if (!isProtected(c.path, policy, "ci")) continue;
1902
+ const afterLines = c.after != null ? c.after.split("\n") : null;
1903
+ const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
1317
1904
  for (const l of addedLines(c)) {
1318
1905
  const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
1319
1906
  const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
1907
+ if (!coe && !cond) continue;
1908
+ const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1909
+ if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
1320
1910
  if (coe && isTruthy(coe[1])) {
1321
1911
  out2.push(
1322
1912
  makeFinding(RULE5, policy, {
@@ -1339,15 +1929,72 @@ var ciTampering = {
1339
1929
  );
1340
1930
  }
1341
1931
  }
1342
- const afterCores = new Set(
1343
- (c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
1344
- );
1932
+ if (afterLines) {
1933
+ for (const b of runBlocks(afterLines)) {
1934
+ if (!b.body.some(invokesCheck)) continue;
1935
+ const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
1936
+ for (let k = 0; k < b.body.length; k++) {
1937
+ const idx = b.start + 1 + k;
1938
+ if (!addedAt.has(idx + 1)) continue;
1939
+ const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
1940
+ if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
1941
+ out2.push(
1942
+ makeFinding(RULE5, policy, {
1943
+ file: c.path,
1944
+ line: idx + 1,
1945
+ message: `A check's run block was neutralised: ${n.what}.`,
1946
+ evidence: b.body[k].trim(),
1947
+ remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
1948
+ })
1949
+ );
1950
+ }
1951
+ }
1952
+ for (const l of addedLines(c)) {
1953
+ if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
1954
+ const step = stepOf(afterLines, l.newLine - 1);
1955
+ if (step && !stepHasCheck(afterLines, step)) continue;
1956
+ out2.push(
1957
+ makeFinding(RULE5, policy, {
1958
+ file: c.path,
1959
+ line: l.newLine,
1960
+ message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
1961
+ evidence: l.content.trim(),
1962
+ remediation: "Keep the default shell (fail-fast) on steps that run checks."
1963
+ })
1964
+ );
1965
+ }
1966
+ if (c.before != null) {
1967
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
1968
+ out2.push(
1969
+ makeFinding(RULE5, policy, {
1970
+ file: c.path,
1971
+ message: `The workflow's triggers were narrowed: ${reason}.`,
1972
+ evidence: reason,
1973
+ remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
1974
+ })
1975
+ );
1976
+ }
1977
+ }
1978
+ }
1979
+ const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
1345
1980
  for (const l of removedLines(c)) {
1346
1981
  if (/^\s*#/.test(l.content)) continue;
1347
1982
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1348
1983
  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
1984
  if (!isUsesLine && !isCheckCommand) continue;
1350
- if (afterCores.has(commandCore(l.content))) continue;
1985
+ const s = survives(commandCore(l.content), isUsesLine, afterCores);
1986
+ if (s.state === "kept") continue;
1987
+ if (s.state === "neutralised") {
1988
+ out2.push(
1989
+ makeFinding(RULE5, policy, {
1990
+ file: c.path,
1991
+ message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1992
+ evidence: `${l.content.trim()} \u2192 ${s.by}`,
1993
+ remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
1994
+ })
1995
+ );
1996
+ continue;
1997
+ }
1351
1998
  out2.push(
1352
1999
  makeFinding(RULE5, policy, {
1353
2000
  file: c.path,
@@ -1364,7 +2011,7 @@ var ciTampering = {
1364
2011
 
1365
2012
  // src/detectors/hook-tampering.ts
1366
2013
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1367
- import { join as join2 } from "node:path";
2014
+ import { isAbsolute, join as join2, relative } from "node:path";
1368
2015
 
1369
2016
  // src/detectors/policy-diff.ts
1370
2017
  import { parse } from "yaml";
@@ -1874,9 +2521,22 @@ function chmodDropsExec(toks) {
1874
2521
  return false;
1875
2522
  }
1876
2523
  var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2524
+ var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
2525
+ var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
2526
+ function shellHookTarget(seg, toks, policy) {
2527
+ for (const m of seg.matchAll(REDIRECT_TARGET)) {
2528
+ const t = hookPath(m[1]);
2529
+ if (isProtected(t, policy, "hooks")) return t;
2530
+ }
2531
+ for (const tok of toks) {
2532
+ const t = hookPath(tok.replace(/^of=/, ""));
2533
+ if (isProtected(t, policy, "hooks")) return t;
2534
+ }
2535
+ return null;
2536
+ }
1877
2537
  function shellWritesHook(seg, toks, policy) {
1878
2538
  const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
1879
- for (const m of seg.matchAll(/(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g)) {
2539
+ for (const m of seg.matchAll(REDIRECT_TARGET)) {
1880
2540
  if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
1881
2541
  }
1882
2542
  let at = 0;
@@ -2087,14 +2747,24 @@ function configFindings(c, policy) {
2087
2747
  function updateIndexHiding(toks, policy) {
2088
2748
  if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
2089
2749
  const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
2090
- if (hide && toks.some((t) => isProtected(t, policy))) {
2091
- 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`;
2750
+ const hidden = hide ? toks.find((t) => isProtected(t, policy)) : void 0;
2751
+ if (hide && hidden) {
2752
+ return {
2753
+ reason: `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`,
2754
+ file: hidden
2755
+ };
2092
2756
  }
2093
- if (toks.some((t) => /^--chmod=-x$/.test(t)) && toks.some((t) => isProtected(t, policy, "hooks"))) {
2094
- return "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it";
2757
+ const hook = toks.some((t) => /^--chmod=-x$/.test(t)) ? toks.find((t) => isProtected(t, policy, "hooks")) : void 0;
2758
+ if (hook) {
2759
+ return { reason: "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it", file: hook };
2095
2760
  }
2096
2761
  return null;
2097
2762
  }
2763
+ function repoRelative(path, ctx) {
2764
+ if (!ctx?.cwd || !isAbsolute(path)) return path;
2765
+ const rel = relative(ctx.cwd, path);
2766
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2767
+ }
2098
2768
  var hookTampering = {
2099
2769
  id: RULE6,
2100
2770
  surface: ["file", "command"],
@@ -2244,7 +2914,8 @@ var hookTampering = {
2244
2914
  if (hiding) {
2245
2915
  out2.push(
2246
2916
  makeFinding(RULE6, policy, {
2247
- message: `Hook tampering via shell: ${hiding}.`,
2917
+ file: repoRelative(hiding.file, ctx),
2918
+ message: `Hook tampering via shell: ${hiding.reason}.`,
2248
2919
  evidence: seg,
2249
2920
  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."
2250
2921
  })
@@ -2253,8 +2924,10 @@ var hookTampering = {
2253
2924
  }
2254
2925
  const why = shellWritesHook(seg, toks, policy);
2255
2926
  if (why) {
2927
+ const target = shellHookTarget(seg, toks, policy);
2256
2928
  out2.push(
2257
2929
  makeFinding(RULE6, policy, {
2930
+ ...target ? { file: repoRelative(target, ctx) } : {},
2258
2931
  message: `Hook tampering via shell: ${why}.`,
2259
2932
  evidence: seg,
2260
2933
  remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
@@ -2269,44 +2942,215 @@ var hookTampering = {
2269
2942
  };
2270
2943
 
2271
2944
  // src/detectors/test-deletion.ts
2945
+ import ts4 from "typescript";
2946
+
2947
+ // src/detectors/suite-config.ts
2948
+ import picomatch3 from "picomatch";
2272
2949
  import ts3 from "typescript";
2950
+ var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
2951
+ var JEST_DEFAULT_IGNORE = ["/node_modules/"];
2952
+ var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
2953
+ var VITEST_DEFAULT_EXCLUDE = [
2954
+ "**/node_modules/**",
2955
+ "**/dist/**",
2956
+ "**/cypress/**",
2957
+ "**/.{idea,git,cache,output,temp}/**",
2958
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
2959
+ ];
2960
+ var CANONICAL_SAMPLES = [
2961
+ "src/a.test.ts",
2962
+ "src/a.spec.ts",
2963
+ "test/a.test.ts",
2964
+ "tests/a.test.js",
2965
+ "src/__tests__/a.ts",
2966
+ "a.test.tsx",
2967
+ "a.spec.js",
2968
+ "test/a.test.mjs"
2969
+ ];
2970
+ function keyName2(name) {
2971
+ if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2972
+ return null;
2973
+ }
2974
+ function literals2(e) {
2975
+ if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
2976
+ if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2977
+ const items = [];
2978
+ let opaque = false;
2979
+ for (const el of e.elements) {
2980
+ if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
2981
+ else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2982
+ items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2983
+ } else opaque = true;
2984
+ }
2985
+ return { items, opaque };
2986
+ }
2987
+ function underKey2(node, key2) {
2988
+ for (let p = node.parent; p; p = p.parent) {
2989
+ if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
2990
+ }
2991
+ return false;
2992
+ }
2993
+ function asExpression2(src) {
2994
+ const t = src.trim();
2995
+ return t.startsWith("{") ? `(${t})` : src;
2996
+ }
2997
+ function runnerOf(path) {
2998
+ const base = path.split("/").pop() ?? path;
2999
+ if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
3000
+ if (/^vitest\.config\./.test(base)) return "vitest";
3001
+ return null;
3002
+ }
3003
+ function parseSelection(src, runner) {
3004
+ const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
3005
+ try {
3006
+ const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
3007
+ const seen = /* @__PURE__ */ new Set();
3008
+ const take = (key2, e) => {
3009
+ const { items, opaque } = literals2(e);
3010
+ if (opaque || seen.has(key2)) sel.opaque = true;
3011
+ seen.add(key2);
3012
+ sel.present = true;
3013
+ return items.map((g) => g.replace(/^<rootDir>\//, ""));
3014
+ };
3015
+ const visit = (node) => {
3016
+ if (ts3.isPropertyAssignment(node)) {
3017
+ const k = keyName2(node.name);
3018
+ if (runner === "jest") {
3019
+ if (k === "testMatch") sel.include = take(k, node.initializer);
3020
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
3021
+ else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
3022
+ } else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
3023
+ if (k === "include") sel.include = take(k, node.initializer);
3024
+ else if (k === "exclude") sel.ignore = take(k, node.initializer);
3025
+ }
3026
+ }
3027
+ ts3.forEachChild(node, visit);
3028
+ };
3029
+ visit(sf);
3030
+ } catch {
3031
+ }
3032
+ return sel;
3033
+ }
3034
+ var globs2 = (patterns) => {
3035
+ const ms = patterns.map((g) => {
3036
+ try {
3037
+ return picomatch3(g, { dot: true });
3038
+ } catch {
3039
+ return () => false;
3040
+ }
3041
+ });
3042
+ return (path) => ms.some((m) => m(path));
3043
+ };
3044
+ var regexes = (patterns) => {
3045
+ const rs = patterns.flatMap((p) => {
3046
+ try {
3047
+ return [new RegExp(p.replace(/<rootDir>/g, ""))];
3048
+ } catch {
3049
+ return [];
3050
+ }
3051
+ });
3052
+ return (path) => rs.some((r) => r.test("/" + path));
3053
+ };
3054
+ function predicate(sel, runner) {
3055
+ const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
3056
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
3057
+ const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
3058
+ const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
3059
+ return {
3060
+ selects: (p) => included(p) && !ignored(p),
3061
+ why: (p) => {
3062
+ if (ignored(p)) {
3063
+ const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
3064
+ const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
3065
+ return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
3066
+ }
3067
+ const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3068
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3069
+ }
3070
+ };
3071
+ }
3072
+ function suiteNarrowings(before, after, path, samples) {
3073
+ const runner = runnerOf(path);
3074
+ if (!runner) return [];
3075
+ const b = parseSelection(before ?? "", runner);
3076
+ const a = parseSelection(after, runner);
3077
+ if (!a.present || a.opaque) return [];
3078
+ const pb = predicate(b, runner);
3079
+ const pa = predicate(a, runner);
3080
+ const out2 = [];
3081
+ for (const s of samples) {
3082
+ if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
3083
+ }
3084
+ return out2;
3085
+ }
3086
+
3087
+ // src/detectors/test-deletion.ts
2273
3088
  var RULE7 = "test-deletion";
3089
+ var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
2274
3090
  var TEST_DEFS = {
2275
3091
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
2276
3092
  go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
2277
3093
  rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
2278
3094
  rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
2279
- java: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
2280
- kt: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
3095
+ java: JVM_TEST,
3096
+ kt: JVM_TEST,
2281
3097
  php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
2282
3098
  cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
2283
3099
  };
2284
3100
  function calleeName(expr) {
2285
- if (ts3.isIdentifier(expr)) return expr.text;
2286
- if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
3101
+ if (ts4.isIdentifier(expr)) return expr.text;
3102
+ if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
2287
3103
  return expr.expression.text;
2288
3104
  }
2289
3105
  return null;
2290
3106
  }
2291
- function countTestBlocks(src, path = "spec.ts") {
3107
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
3108
+ function eachRows(call) {
3109
+ const arg = call.arguments[0];
3110
+ if (!arg) return { n: 1, open: true };
3111
+ if (ts4.isArrayLiteralExpression(arg)) {
3112
+ let n = 0;
3113
+ let open = false;
3114
+ for (const el of arg.elements) {
3115
+ if (ts4.isSpreadElement(el)) open = true;
3116
+ else n++;
3117
+ }
3118
+ return { n: Math.max(n, open ? 0 : 1), open };
3119
+ }
3120
+ return { n: 1, open: true };
3121
+ }
3122
+ function countTests(src, path = "spec.ts") {
2292
3123
  const lang = langOf(path);
2293
3124
  if (lang && lang !== "js") {
2294
3125
  const re = TEST_DEFS[lang];
2295
3126
  let n2 = 0;
2296
3127
  for (const line of src.split("\n")) if (re.test(line)) n2++;
2297
- return n2;
3128
+ return { min: n2, open: false };
2298
3129
  }
2299
- const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
3130
+ const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
2300
3131
  let n = 0;
3132
+ let open = false;
2301
3133
  const visit = (node) => {
2302
- if (ts3.isCallExpression(node)) {
2303
- const name = calleeName(node.expression);
2304
- if (name === "it" || name === "test") n++;
3134
+ if (ts4.isCallExpression(node)) {
3135
+ if (isEachOf(node.expression)) {
3136
+ const r = eachRows(node);
3137
+ n += r.n;
3138
+ if (r.open) open = true;
3139
+ } else {
3140
+ const name = calleeName(node.expression);
3141
+ if (name === "it" || name === "test") n++;
3142
+ }
3143
+ } else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
3144
+ const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3145
+ n += Math.max(rows, 1);
2305
3146
  }
2306
- ts3.forEachChild(node, visit);
3147
+ ts4.forEachChild(node, visit);
2307
3148
  };
2308
3149
  visit(sf);
2309
- return n;
3150
+ return { min: n, open };
3151
+ }
3152
+ function countTestBlocks(src, path = "spec.ts") {
3153
+ return countTests(src, path).min;
2310
3154
  }
2311
3155
  function significantLines(src, path) {
2312
3156
  const out2 = /* @__PURE__ */ new Set();
@@ -2317,32 +3161,63 @@ function significantLines(src, path) {
2317
3161
  }
2318
3162
  return out2;
2319
3163
  }
3164
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
3165
+ function runnerSamples(configPath, policy, ctx) {
3166
+ const files = trackedFiles(ctx);
3167
+ if (!files) return CANONICAL_SAMPLES;
3168
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
3169
+ 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));
3170
+ return own.length ? own : CANONICAL_SAMPLES;
3171
+ }
3172
+ function joinRoot(root, to) {
3173
+ const parts = root ? root.split("/") : [];
3174
+ for (const seg of to.replace(/\/+$/, "").split("/")) {
3175
+ if (seg === "" || seg === ".") continue;
3176
+ if (seg === "..") {
3177
+ if (parts.length === 0) return null;
3178
+ parts.pop();
3179
+ } else parts.push(seg);
3180
+ }
3181
+ return parts.join("/");
3182
+ }
3183
+ function leavesGlob(dir, dest, policy, ctx) {
3184
+ const files = trackedFiles(ctx);
3185
+ if (!files || !dir) return false;
3186
+ const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3187
+ const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3188
+ return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
3189
+ }
3190
+ var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
2320
3191
  var testDeletion = {
2321
3192
  id: RULE7,
2322
3193
  surface: ["file", "command"],
2323
3194
  certainty: "mechanical",
2324
- run(changes, policy) {
3195
+ run(changes, policy, _view, ctx) {
2325
3196
  const out2 = [];
2326
3197
  const addedTestLines = /* @__PURE__ */ new Set();
2327
3198
  let addedTestBlocks = 0;
2328
3199
  for (const c of changes) {
2329
- if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
3200
+ if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3201
+ if (c.op === "add") {
2330
3202
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
2331
3203
  addedTestBlocks += countTestBlocks(c.after, c.path);
3204
+ } else if (c.before != null) {
3205
+ const before = significantLines(c.before, c.path);
3206
+ for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
3207
+ addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
2332
3208
  }
2333
3209
  }
2334
- const isRelocation = (before, path) => {
2335
- const sig = significantLines(before, path);
2336
- if (sig.size === 0 || addedTestLines.size === 0) return false;
3210
+ const isRelocation = (removed, blocksRemoved) => {
3211
+ if (removed.size === 0 || addedTestLines.size === 0) return false;
2337
3212
  let hit = 0;
2338
- for (const l of sig) if (addedTestLines.has(l)) hit++;
2339
- return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
3213
+ for (const l of removed) if (addedTestLines.has(l)) hit++;
3214
+ return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
2340
3215
  };
2341
3216
  for (const c of changes) {
2342
3217
  if (c.kind === "file") {
2343
3218
  const isTest = isProtected(c.path, policy, "tests");
2344
3219
  if (c.op === "delete" && isTest) {
2345
- if (c.before != null && isRelocation(c.before, c.path)) continue;
3220
+ if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
2346
3221
  out2.push(
2347
3222
  makeFinding(RULE7, policy, {
2348
3223
  file: c.path,
@@ -2361,41 +3236,95 @@ var testDeletion = {
2361
3236
  })
2362
3237
  );
2363
3238
  } else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
2364
- const before = countTestBlocks(c.before, c.path);
2365
- const after = countTestBlocks(c.after, c.path);
2366
- if (after < before) {
3239
+ const before = countTests(c.before, c.path);
3240
+ const after = countTests(c.after, c.path);
3241
+ if (after.min < before.min && !after.open) {
3242
+ const afterLines = significantLines(c.after, c.path);
3243
+ const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
3244
+ if (isRelocation(removed, before.min - after.min)) continue;
2367
3245
  out2.push(
2368
3246
  makeFinding(RULE7, policy, {
2369
3247
  file: c.path,
2370
- message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
2371
- evidence: `${before - after} test block(s) removed from ${c.path}`,
3248
+ message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
3249
+ evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
2372
3250
  remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
2373
3251
  })
2374
3252
  );
2375
3253
  }
3254
+ } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
3255
+ for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3256
+ out2.push(
3257
+ makeFinding(RULE7, policy, {
3258
+ file: c.path,
3259
+ message: `The runner no longer selects ${n.path}: ${n.reason}.`,
3260
+ evidence: `${n.path} dropped from the suite by ${c.path}`,
3261
+ remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
3262
+ })
3263
+ );
3264
+ }
2376
3265
  }
2377
3266
  } else {
3267
+ let root = "";
2378
3268
  for (const seg of segments(c.raw)) {
2379
3269
  const toks = tokens(seg).map(unquote);
2380
- const testToks = toks.filter((t) => isProtected(t, policy, "tests"));
2381
- if (testToks.length === 0) continue;
3270
+ const cdTo = toks[0] === "cd" ? toks[1] : null;
3271
+ if (cdTo !== null) {
3272
+ root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
3273
+ continue;
3274
+ }
3275
+ const inRepo = (t) => root ? `${root}/${t}` : t;
3276
+ const listing = trackedFiles(ctx);
3277
+ const testToks = toks.filter((t) => {
3278
+ if (!isProtected(t, policy, "tests")) return false;
3279
+ if (!listing || root === null) return true;
3280
+ const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
3281
+ if (looksLikeFile || listing.includes(inRepo(t))) return true;
3282
+ return containsProtected(inRepo(t), policy, "tests", ctx);
3283
+ });
3284
+ const dirToks = toks.filter(
3285
+ (t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
3286
+ );
3287
+ if (testToks.length === 0 && dirToks.length === 0) continue;
3288
+ const named = [...testToks, ...dirToks];
3289
+ const cmd = toks[0] ?? "";
2382
3290
  let why = null;
2383
3291
  let evidence = seg;
2384
3292
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
2385
3293
  const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
2386
- if (/\brm\b/.test(seg)) {
3294
+ const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3295
+ const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3296
+ const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
3297
+ const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
3298
+ return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
3299
+ })();
3300
+ if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
2387
3301
  why = "rm deletes a test file";
2388
- } else if (/\bsed\b/.test(seg) && /(?:^|\s)-i\b/.test(seg)) {
3302
+ } else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
3303
+ why = "git rm deletes a test file";
3304
+ } else if (cmd === "sed" && SED_INPLACE.test(seg)) {
2389
3305
  why = "sed -i rewrites a test file in place";
3306
+ } else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
3307
+ why = "perl -i rewrites a test file in place";
2390
3308
  } else if (/\btruncate\b/.test(seg)) {
2391
3309
  why = "truncate empties a test file";
2392
3310
  } else if (redirectsOntoTest) {
2393
3311
  why = "a redirect overwrites/empties a test file";
3312
+ } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
3313
+ why = "cp overwrites a test file with something else";
3314
+ } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
3315
+ why = "tee overwrites a test file";
3316
+ } else if (findDeletes) {
3317
+ why = "find -delete removes test files";
3318
+ } else if (gitRestoresOld) {
3319
+ why = "git checkout <rev> replaces a test file with an older version";
2394
3320
  } else if (/\bmv\b/.test(seg)) {
2395
3321
  const dest = toks[toks.length - 1];
2396
- if (dest && !isProtected(dest, policy, "tests")) {
3322
+ if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
2397
3323
  why = "mv renames a test file out of the test glob";
2398
3324
  evidence = `${testToks[0]} \u2192 ${dest}`;
3325
+ } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
3326
+ why = "mv renames a test directory so its specs leave the test glob";
3327
+ evidence = `${dirToks[0]} \u2192 ${dest}`;
2399
3328
  }
2400
3329
  }
2401
3330
  if (why) {
@@ -2415,9 +3344,40 @@ var testDeletion = {
2415
3344
  };
2416
3345
 
2417
3346
  // src/detectors/test-content-removal.ts
3347
+ import ts5 from "typescript";
2418
3348
  var RULE8 = "test-content-removal";
2419
3349
  var MIN_REMOVED_LINES = 3;
3350
+ var MIN_REMOVED_ROWS = 2;
2420
3351
  var ws = (s) => s.replace(/\s+/g, "");
3352
+ function eachTables(src, path) {
3353
+ const rows = /* @__PURE__ */ new Set();
3354
+ let open = false;
3355
+ if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3356
+ try {
3357
+ const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
3358
+ const visit = (node) => {
3359
+ 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)) {
3360
+ const arg = node.arguments[0];
3361
+ if (arg && ts5.isArrayLiteralExpression(arg)) {
3362
+ for (const el of arg.elements) {
3363
+ if (ts5.isSpreadElement(el)) {
3364
+ open = true;
3365
+ continue;
3366
+ }
3367
+ for (const raw of el.getText(sf).split("\n")) {
3368
+ const l = raw.trim().replace(/,$/, "");
3369
+ if (isSignificantLine(l, "js")) rows.add(l);
3370
+ }
3371
+ }
3372
+ } else open = true;
3373
+ }
3374
+ ts5.forEachChild(node, visit);
3375
+ };
3376
+ visit(sf);
3377
+ } catch {
3378
+ }
3379
+ return { rows, open };
3380
+ }
2421
3381
  function significantLinesOrdered(src, path) {
2422
3382
  const out2 = [];
2423
3383
  const lang = langOf(path);
@@ -2434,16 +3394,23 @@ var testContentRemoval = {
2434
3394
  run(changes, policy) {
2435
3395
  const out2 = [];
2436
3396
  let keptPool = "";
3397
+ let addedPool = "";
2437
3398
  for (const c of changes) {
2438
- if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
2439
- keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3399
+ if (c.kind !== "file" || c.after == null) continue;
3400
+ if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3401
+ if (c.op === "add") addedPool += ws(c.after);
3402
+ else if (c.before != null) {
3403
+ const had = new Set(c.before.split("\n").map((l) => l.trim()));
3404
+ addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
2440
3405
  }
2441
3406
  }
2442
3407
  for (const c of changes) {
2443
3408
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
2444
3409
  if (!isProtected(c.path, policy, "tests")) continue;
2445
3410
  if (isProtected(c.path, policy, "snapshots")) continue;
2446
- if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
3411
+ const blocksBefore = countTests(c.before, c.path);
3412
+ const blocksAfter = countTests(c.after, c.path);
3413
+ if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
2447
3414
  const beforeSig = significantLinesOrdered(c.before, c.path);
2448
3415
  const afterSig = significantLinesOrdered(c.after, c.path);
2449
3416
  const netDrop = beforeSig.length - afterSig.length;
@@ -2454,6 +3421,19 @@ var testContentRemoval = {
2454
3421
  if (keptPool.includes(ws(line))) continue;
2455
3422
  gone.push(line);
2456
3423
  }
3424
+ const table = eachTables(c.before, c.path);
3425
+ const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
3426
+ if (goneRows.length >= MIN_REMOVED_ROWS) {
3427
+ out2.push(
3428
+ makeFinding(RULE8, policy, {
3429
+ file: c.path,
3430
+ 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.`,
3431
+ evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3432
+ 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."
3433
+ })
3434
+ );
3435
+ continue;
3436
+ }
2457
3437
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
2458
3438
  out2.push(
2459
3439
  makeFinding(RULE8, policy, {
@@ -2644,7 +3624,7 @@ function hasBlocking(findings) {
2644
3624
 
2645
3625
  // src/policy-load.ts
2646
3626
  import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
2647
- import { isAbsolute, join as join3, posix } from "node:path";
3627
+ import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
2648
3628
  import { parse as parse2 } from "yaml";
2649
3629
  var PolicyError = class extends Error {
2650
3630
  };
@@ -2661,7 +3641,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
2661
3641
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2662
3642
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
2663
3643
  function ledgerInsideRepo(ledger) {
2664
- if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
3644
+ if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
2665
3645
  const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
2666
3646
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
2667
3647
  }
@@ -2692,8 +3672,8 @@ function validate(r, where) {
2692
3672
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
2693
3673
  if (r.protected !== void 0) {
2694
3674
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
2695
- for (const [cat, globs2] of Object.entries(r.protected)) {
2696
- if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
3675
+ for (const [cat, globs3] of Object.entries(r.protected)) {
3676
+ if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
2697
3677
  }
2698
3678
  }
2699
3679
  if (r.signoff !== void 0) {
@@ -2725,10 +3705,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
2725
3705
  const version = normalizeVersion(r.version, where);
2726
3706
  validate(r, where);
2727
3707
  const base = defaultPolicy(version);
2728
- const globs2 = (list) => list?.map(normalizeGlob);
2729
- const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
3708
+ const globs3 = (list) => list?.map(normalizeGlob);
3709
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
2730
3710
  const userRules = r.rules ? Object.fromEntries(
2731
- Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
3711
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
2732
3712
  ) : void 0;
2733
3713
  return {
2734
3714
  version,
@@ -2738,7 +3718,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
2738
3718
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
2739
3719
  protected: mergeProtected(base.protected, userProtected),
2740
3720
  rules: mergeRules(base.rules, userRules),
2741
- ignore: globs2(r.ignore) ?? base.ignore,
3721
+ ignore: globs3(r.ignore) ?? base.ignore,
2742
3722
  signoff: {
2743
3723
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
2744
3724
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -3115,7 +4095,8 @@ function check(opts) {
3115
4095
  layer = "local";
3116
4096
  view = "staged";
3117
4097
  } else if (opts.worktree) {
3118
- changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }) : diffWorktree({ cwd: opts.cwd });
4098
+ const p = policy;
4099
+ changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
3119
4100
  layer = opts.ciLayer ? "ci" : "local";
3120
4101
  view = "worktree";
3121
4102
  } else if (opts.diff) {
@@ -3171,10 +4152,10 @@ import { createHash as createHash4 } from "node:crypto";
3171
4152
  import { join as join9 } from "node:path";
3172
4153
 
3173
4154
  // src/adapters/claude/changes.ts
3174
- import { execFileSync as execFileSync2 } from "node:child_process";
4155
+ import { execFileSync as execFileSync3 } from "node:child_process";
3175
4156
  import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3176
4157
  import { tmpdir } from "node:os";
3177
- import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
4158
+ import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
3178
4159
  function asStr(v) {
3179
4160
  return typeof v === "string" ? v : "";
3180
4161
  }
@@ -3189,8 +4170,8 @@ function abs(path, cwd) {
3189
4170
  return resolve(cwd, path);
3190
4171
  }
3191
4172
  function relForDisplay(path, cwd) {
3192
- const rel = relative(cwd, path);
3193
- return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
4173
+ const rel = relative2(cwd, path);
4174
+ return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
3194
4175
  }
3195
4176
  function applyEdit(content, oldStr, newStr) {
3196
4177
  if (content === null) return newStr;
@@ -3211,7 +4192,7 @@ function synthFileChange(displayPath, before, after) {
3211
4192
  writeFileSync(a, before ?? "");
3212
4193
  writeFileSync(b, after ?? "");
3213
4194
  try {
3214
- raw = execFileSync2("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
4195
+ raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
3215
4196
  encoding: "utf8",
3216
4197
  maxBuffer: 256 * 1024 * 1024
3217
4198
  });
@@ -3333,20 +4314,20 @@ function advanceTurnBaseline(cwd, sessionId) {
3333
4314
 
3334
4315
  // src/effect.ts
3335
4316
  import { createHash as createHash2 } from "node:crypto";
3336
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
3337
- import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
3338
- import { execFileSync as execFileSync3 } from "node:child_process";
4317
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4318
+ import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
4319
+ import { execFileSync as execFileSync4 } from "node:child_process";
3339
4320
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
3340
- var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
4321
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
3341
4322
  function gitDir2(cwd) {
3342
4323
  try {
3343
- const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
4324
+ const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
3344
4325
  cwd,
3345
4326
  encoding: "utf8",
3346
4327
  stdio: ["ignore", "pipe", "pipe"]
3347
4328
  }).trim();
3348
4329
  if (!d) return null;
3349
- return isAbsolute3(d) ? d : join7(cwd, d);
4330
+ return isAbsolute4(d) ? d : join7(cwd, d);
3350
4331
  } catch {
3351
4332
  return null;
3352
4333
  }
@@ -3379,7 +4360,7 @@ function snapshotProtected(cwd, policy, prev) {
3379
4360
  return;
3380
4361
  }
3381
4362
  for (const name of names) {
3382
- if (SKIP_DIRS.has(name)) continue;
4363
+ if (SKIP_DIRS2.has(name)) continue;
3383
4364
  const r = rel ? `${rel}/${name}` : name;
3384
4365
  let st;
3385
4366
  try {
@@ -3405,12 +4386,16 @@ function snapshotProtected(cwd, policy, prev) {
3405
4386
  function driftBetween(expected, current) {
3406
4387
  const changed = [];
3407
4388
  const deleted = [];
4389
+ const added = [];
3408
4390
  for (const [path, e] of Object.entries(expected)) {
3409
4391
  const c = current[path];
3410
4392
  if (!c) deleted.push(path);
3411
4393
  else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
3412
4394
  }
3413
- return { changed, deleted };
4395
+ for (const path of Object.keys(current)) {
4396
+ if (!expected[path]) added.push(path);
4397
+ }
4398
+ return { changed, deleted, added };
3414
4399
  }
3415
4400
  function loadTree(p) {
3416
4401
  if (!p || !existsSync6(p)) return null;
@@ -3424,7 +4409,16 @@ function saveTree(p, tree) {
3424
4409
  if (!p) return;
3425
4410
  try {
3426
4411
  mkdirSync3(join7(p, ".."), { recursive: true });
3427
- writeFileSync3(p, JSON.stringify(tree));
4412
+ const tmp = `${p}.${process.pid}.tmp`;
4413
+ writeFileSync3(tmp, JSON.stringify(tree));
4414
+ renameSync(tmp, p);
4415
+ } catch {
4416
+ }
4417
+ }
4418
+ function dropTree(p) {
4419
+ if (!p) return;
4420
+ try {
4421
+ rmSync2(p, { force: true });
3428
4422
  } catch {
3429
4423
  }
3430
4424
  }
@@ -3440,6 +4434,9 @@ function loadTurnTree(cwd, sessionId) {
3440
4434
  function saveTurnTree(cwd, sessionId, tree) {
3441
4435
  saveTree(turnTreePath(cwd, sessionId), tree);
3442
4436
  }
4437
+ function dropTurnTree(cwd, sessionId) {
4438
+ dropTree(turnTreePath(cwd, sessionId));
4439
+ }
3443
4440
 
3444
4441
  // src/cli/watch.ts
3445
4442
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
@@ -3684,17 +4681,26 @@ function turnView(cwd, sessionId, policy, base) {
3684
4681
  if (c.oldPath) seen.add(c.oldPath);
3685
4682
  }
3686
4683
  const turnTree = loadTurnTree(cwd, sessionId);
3687
- const untracked = untrackedAdds({ cwd }, (rel) => isProtected(rel, policy), seen).filter(
3688
- (c) => c.kind !== "file" || !turnTree || turnTree[c.path]?.hash !== hashOnDisk(cwd, c.path)
3689
- );
4684
+ const ptree = loadPtree(cwd, sessionId);
4685
+ const keep = (rel) => isProtected(rel, policy);
4686
+ const untouched = (rel, disk) => disk != null && turnTree?.[rel]?.hash === disk && ptree?.[rel]?.hash === disk;
4687
+ const untracked = untrackedAdds({ cwd }, keep, seen).filter((c) => c.kind !== "file" || !untouched(c.path, hashOnDisk(cwd, c.path)));
3690
4688
  for (const c of fileChanges(untracked)) seen.add(c.path);
4689
+ const ignored = ignoredAdds({ cwd }, keep, seen).filter((c) => {
4690
+ if (c.kind !== "file") return false;
4691
+ const disk = hashOnDisk(cwd, c.path);
4692
+ if (untouched(c.path, disk)) return false;
4693
+ const sanctioned = ptree?.[c.path];
4694
+ return !(sanctioned && disk != null && sanctioned.hash !== disk);
4695
+ });
4696
+ for (const c of fileChanges(ignored)) seen.add(c.path);
3691
4697
  const trusted = base ?? "HEAD";
3692
4698
  const hidden = [];
3693
4699
  for (const rel of hiddenTrackedPaths({ cwd })) {
3694
4700
  if (seen.has(rel) || !isProtected(rel, policy)) continue;
3695
4701
  hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
3696
4702
  }
3697
- return [...tracked, ...untracked, ...hidden];
4703
+ return [...tracked, ...untracked, ...ignored, ...hidden];
3698
4704
  }
3699
4705
  function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
3700
4706
  const d = driftBetween(expected, current);
@@ -3712,7 +4718,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
3712
4718
  const after = fileOnDisk(rel, { cwd });
3713
4719
  const trustedBlob = fileAt(trusted, rel, { cwd });
3714
4720
  const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
3715
- const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
4721
+ const atTurnStart = current[rel] != null && expected[rel]?.mtimeMs === -1 && turnTree?.[rel]?.hash === current[rel].hash;
3716
4722
  if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
3717
4723
  continue;
3718
4724
  }
@@ -3755,6 +4761,23 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
3755
4761
  });
3756
4762
  }
3757
4763
  }
4764
+ for (const rel of d.added) {
4765
+ if (seen.has(rel)) continue;
4766
+ const after = fileOnDisk(rel, { cwd });
4767
+ if (after == null) {
4768
+ blocks.push({
4769
+ rule: "hidden-drift",
4770
+ severity: "block",
4771
+ file: rel,
4772
+ message: `Protected file ${rel} appeared outside git's view and cannot be read to judge it.`,
4773
+ evidence: rel,
4774
+ remediation: "Remove the file, or make it readable and bring it into git's view; create protected files through a tool call the gate can see.",
4775
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
4776
+ });
4777
+ continue;
4778
+ }
4779
+ changes.push(...synthFileChange(rel, null, after));
4780
+ }
3758
4781
  return { changes, blocks };
3759
4782
  }
3760
4783
  function effectDriftBlocks(cwd, sessionId, policy) {
@@ -3763,17 +4786,18 @@ function effectDriftBlocks(cwd, sessionId, policy) {
3763
4786
  const current = snapshotProtected(cwd, policy, expected ?? void 0);
3764
4787
  if (!expected) {
3765
4788
  savePtree(cwd, sessionId, current);
3766
- if (!loadTurnTree(cwd, sessionId)) saveTurnTree(cwd, sessionId, current);
4789
+ if (loadTurnTree(cwd, sessionId)) dropTurnTree(cwd, sessionId);
4790
+ else saveTurnTree(cwd, sessionId, current);
3767
4791
  return null;
3768
4792
  }
3769
4793
  const d = driftBetween(expected, current);
3770
- if (d.changed.length === 0 && d.deleted.length === 0) {
4794
+ if (d.changed.length === 0 && d.deleted.length === 0 && d.added.length === 0) {
3771
4795
  savePtree(cwd, sessionId, current);
3772
4796
  return null;
3773
4797
  }
3774
4798
  const base = turnBaseline(cwd, sessionId);
3775
4799
  const all = turnView(cwd, sessionId, policy, base);
3776
- const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
4800
+ const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted, ...d.added]);
3777
4801
  const scoped = all.filter(
3778
4802
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
3779
4803
  );
@@ -3985,16 +5009,16 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3985
5009
  }
3986
5010
 
3987
5011
  // src/cli/init.ts
3988
- import { execFileSync as execFileSync4 } from "node:child_process";
5012
+ import { execFileSync as execFileSync5 } from "node:child_process";
3989
5013
  import { createHash as createHash5 } from "node:crypto";
3990
5014
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3991
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
5015
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
3992
5016
  import { fileURLToPath } from "node:url";
3993
5017
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3994
5018
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3995
5019
  function gitConfig(cwd, key2) {
3996
5020
  try {
3997
- return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
5021
+ return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
3998
5022
  } catch {
3999
5023
  return null;
4000
5024
  }
@@ -4299,15 +5323,15 @@ var MANAGED_HOOKS = [
4299
5323
  ];
4300
5324
  function hooksDir(cwd) {
4301
5325
  try {
4302
- const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5326
+ const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4303
5327
  if (out2) return resolve2(cwd, out2);
4304
5328
  } catch {
4305
5329
  }
4306
5330
  return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
4307
5331
  }
4308
5332
  function display(cwd, path) {
4309
- const rel = relative2(cwd, path);
4310
- return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
5333
+ const rel = relative3(cwd, path);
5334
+ return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
4311
5335
  }
4312
5336
  function unconditionalExitAt(lines) {
4313
5337
  let depth = 0;
@@ -4591,30 +5615,30 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
4591
5615
  }
4592
5616
 
4593
5617
  // src/cli/verify.ts
4594
- import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
5618
+ import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
4595
5619
  import { createHash as createHash8 } from "node:crypto";
4596
- 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";
5620
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
4597
5621
  import { tmpdir as tmpdir2 } from "node:os";
4598
5622
  import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
4599
5623
 
4600
5624
  // src/git/trusted.ts
4601
- import { execFileSync as execFileSync5 } from "node:child_process";
5625
+ import { execFileSync as execFileSync6 } from "node:child_process";
4602
5626
  import { createHash as createHash6 } from "node:crypto";
4603
5627
  import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
4604
- import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
5628
+ import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
4605
5629
  function trustedGitEnv() {
4606
5630
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
4607
5631
  }
4608
5632
  function gitDirOf(cwd) {
4609
5633
  try {
4610
- const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
5634
+ const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
4611
5635
  cwd,
4612
5636
  encoding: "utf8",
4613
5637
  stdio: ["ignore", "pipe", "pipe"],
4614
5638
  env: trustedGitEnv()
4615
5639
  }).trim();
4616
5640
  if (!d) return null;
4617
- return isAbsolute5(d) ? d : join11(cwd, d);
5641
+ return isAbsolute6(d) ? d : join11(cwd, d);
4618
5642
  } catch {
4619
5643
  return null;
4620
5644
  }
@@ -4623,7 +5647,7 @@ function objectRewriteState(cwd) {
4623
5647
  const parts = [];
4624
5648
  try {
4625
5649
  parts.push(
4626
- "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
5650
+ "replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
4627
5651
  cwd,
4628
5652
  encoding: "utf8",
4629
5653
  stdio: ["ignore", "pipe", "pipe"],
@@ -4650,12 +5674,12 @@ function objectRewriteState(cwd) {
4650
5674
  }
4651
5675
 
4652
5676
  // src/fingerprint.ts
4653
- import { execFileSync as execFileSync6 } from "node:child_process";
5677
+ import { execFileSync as execFileSync7 } from "node:child_process";
4654
5678
  import { createHash as createHash7 } from "node:crypto";
4655
5679
  import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
4656
5680
  import { join as join12 } from "node:path";
4657
5681
  function treeFingerprint(cwd) {
4658
- const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
5682
+ const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
4659
5683
  cwd,
4660
5684
  encoding: "utf8",
4661
5685
  maxBuffer: 1 << 28,
@@ -4787,7 +5811,7 @@ var VERIFICATION_SURFACE = [
4787
5811
  "**/*.runsettings"
4788
5812
  ];
4789
5813
  function git2(args, cwd) {
4790
- return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
5814
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
4791
5815
  }
4792
5816
  function resolveBase(base, cwd) {
4793
5817
  assertRev(base);
@@ -4828,7 +5852,7 @@ function materialize(cwd, dest) {
4828
5852
  }
4829
5853
  function dropSymlink(p) {
4830
5854
  try {
4831
- if (lstatSync(p).isSymbolicLink()) rmSync2(p, { force: true });
5855
+ if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
4832
5856
  } catch {
4833
5857
  }
4834
5858
  }
@@ -4839,9 +5863,9 @@ function verifierInputs(cmd, atBase, policy) {
4839
5863
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
4840
5864
  if (tok && baseFiles.has(tok)) picked.add(tok);
4841
5865
  }
4842
- const globs2 = policy.verify?.inputs;
4843
- if (globs2 && globs2.length) {
4844
- for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
5866
+ const globs3 = policy.verify?.inputs;
5867
+ if (globs3 && globs3.length) {
5868
+ for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
4845
5869
  }
4846
5870
  return picked;
4847
5871
  }
@@ -4883,10 +5907,10 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4883
5907
  if (e.type === "commit") {
4884
5908
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4885
5909
  }
4886
- const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
5910
+ const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4887
5911
  const out2 = join13(dest, e.path);
4888
5912
  mkdirSync6(dirname4(out2), { recursive: true });
4889
- rmSync2(out2, { force: true });
5913
+ rmSync3(out2, { force: true });
4890
5914
  if (e.mode === "120000") {
4891
5915
  symlinkSync(content.toString("utf8"), out2);
4892
5916
  } else {
@@ -4899,7 +5923,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4899
5923
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
4900
5924
  for (const rel of inCopy) {
4901
5925
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
4902
- rmSync2(join13(dest, rel), { force: true });
5926
+ rmSync3(join13(dest, rel), { force: true });
4903
5927
  removed++;
4904
5928
  }
4905
5929
  return { restored, removed };
@@ -4974,7 +5998,7 @@ function runSuite(dir, cmd, budgetSecs) {
4974
5998
  if (r.exit === null || r.exit === void 0) return { exit: null, secs };
4975
5999
  return { exit: r.exit, secs };
4976
6000
  } finally {
4977
- rmSync2(outDir, { recursive: true, force: true });
6001
+ rmSync3(outDir, { recursive: true, force: true });
4978
6002
  }
4979
6003
  }
4980
6004
  function runVerify(opts) {
@@ -5021,7 +6045,7 @@ function runVerify(opts) {
5021
6045
  const visDir = join13(visRoot, "t");
5022
6046
  const cleanup = (dirs) => {
5023
6047
  if (opts.keep) return;
5024
- for (const d of dirs) rmSync2(d, { recursive: true, force: true });
6048
+ for (const d of dirs) rmSync3(d, { recursive: true, force: true });
5025
6049
  };
5026
6050
  const treeBefore = treeFingerprint(cwd);
5027
6051
  const depsBefore = depsFingerprint(cwd);
@@ -5158,13 +6182,13 @@ function parseVerify(args) {
5158
6182
  }
5159
6183
 
5160
6184
  // src/cli/run.ts
5161
- import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
6185
+ import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
5162
6186
  import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
5163
6187
  import { resolve as resolve4 } from "node:path";
5164
6188
  var out = (s) => process.stdout.write(s + "\n");
5165
6189
  var err = (s) => process.stderr.write(s + "\n");
5166
6190
  function git3(args, cwd) {
5167
- return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
6191
+ return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
5168
6192
  }
5169
6193
  function nowTicks() {
5170
6194
  try {
@@ -5289,7 +6313,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
5289
6313
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
5290
6314
  if (opts.settle && opts.settle > 0) {
5291
6315
  try {
5292
- execFileSync8("sleep", [String(opts.settle)]);
6316
+ execFileSync9("sleep", [String(opts.settle)]);
5293
6317
  } catch {
5294
6318
  }
5295
6319
  }