tamperward 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +1047 -119
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -560,12 +560,12 @@ function matcher(glob) {
560
560
  }
561
561
  return m;
562
562
  }
563
- function matchesAny(path, globs2) {
564
- return !!globs2 && globs2.some((g) => matcher(g)(path));
563
+ function matchesAny(path, globs3) {
564
+ return !!globs3 && globs3.some((g) => matcher(g)(path));
565
565
  }
566
566
  function protectedCategory(path, policy) {
567
- for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
568
- if (matchesAny(path, globs2)) return cat;
567
+ for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
568
+ if (matchesAny(path, globs3)) return cat;
569
569
  }
570
570
  return null;
571
571
  }
@@ -581,9 +581,9 @@ function isIgnored(path, policy) {
581
581
  }
582
582
  function mergeProtected(base, user) {
583
583
  const out2 = { ...base };
584
- for (const [cat, globs2] of Object.entries(user ?? {})) {
584
+ for (const [cat, globs3] of Object.entries(user ?? {})) {
585
585
  const have = new Set(out2[cat] ?? []);
586
- out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
586
+ out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
587
587
  }
588
588
  return out2;
589
589
  }
@@ -631,7 +631,19 @@ function defaultPolicy(version = 1) {
631
631
  "**/tsconfig*.json",
632
632
  "**/.eslintrc*",
633
633
  "**/eslint.config.*",
634
- "**/package.json"
634
+ "**/package.json",
635
+ // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
636
+ // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
637
+ // codecov `target` lived in files no glob protected, so lowering them fired
638
+ // nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
639
+ // as a strengthening). pyproject.toml carries [tool.coverage.report].
640
+ "**/.coveragerc",
641
+ "**/pyproject.toml",
642
+ "**/.nycrc",
643
+ "**/.nycrc.*",
644
+ "**/nyc.config.*",
645
+ "**/codecov.yml",
646
+ "**/.codecov.yml"
635
647
  ],
636
648
  ci: [".github/workflows/**"],
637
649
  // Recorded expected outputs. An assertion stored as data is still an assertion;
@@ -735,9 +747,11 @@ var IMPORTS = {
735
747
  cs: /^(?:using\b|namespace\b)/
736
748
  };
737
749
  var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
750
+ var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
738
751
  function isSignificantLine(trimmed, lang) {
739
752
  if (trimmed.length < 10) return false;
740
753
  if (COMMON.test(trimmed)) return false;
754
+ if (DEBUG_ONLY.test(trimmed)) return false;
741
755
  const l = lang ?? "js";
742
756
  if (IMPORTS[l].test(trimmed)) return false;
743
757
  if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
@@ -1075,7 +1089,47 @@ var testSkip = {
1075
1089
  };
1076
1090
 
1077
1091
  // src/detectors/coverage-lowering.ts
1092
+ import picomatch2 from "picomatch";
1078
1093
  import ts2 from "typescript";
1094
+
1095
+ // src/detectors/repo.ts
1096
+ import { execFileSync as execFileSync2 } from "node:child_process";
1097
+ var cache2 = /* @__PURE__ */ new Map();
1098
+ function trackedFiles(ctx) {
1099
+ if (!ctx) return null;
1100
+ if (ctx.trackedFiles) return ctx.trackedFiles;
1101
+ if (!ctx.cwd) return null;
1102
+ const hit = cache2.get(ctx.cwd);
1103
+ if (hit !== void 0) return hit;
1104
+ let files = null;
1105
+ try {
1106
+ files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
1107
+ cwd: ctx.cwd,
1108
+ encoding: "utf8",
1109
+ maxBuffer: 64 * 1024 * 1024,
1110
+ stdio: ["ignore", "pipe", "pipe"]
1111
+ }).split("\0").filter(Boolean);
1112
+ } catch {
1113
+ files = null;
1114
+ }
1115
+ cache2.set(ctx.cwd, files);
1116
+ return files;
1117
+ }
1118
+ var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1119
+ function containsProtected(token, policy, category, ctx) {
1120
+ const dir = dirOf(token);
1121
+ if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1122
+ const files = trackedFiles(ctx);
1123
+ if (files) {
1124
+ const prefix = dir + "/";
1125
+ return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1126
+ }
1127
+ if (isProtected(dir + "/probe", policy, category)) return true;
1128
+ const base = dir.split("/").pop() ?? "";
1129
+ return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1130
+ }
1131
+
1132
+ // src/detectors/coverage-lowering.ts
1079
1133
  var RULE4 = "coverage-lowering";
1080
1134
  var METRICS = ["branches", "functions", "lines", "statements"];
1081
1135
  var isMetric = (k) => METRICS.includes(k);
@@ -1086,29 +1140,36 @@ function keyName(name) {
1086
1140
  }
1087
1141
  function numericOf(e) {
1088
1142
  if (ts2.isNumericLiteral(e)) return Number(e.text);
1143
+ if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
1089
1144
  if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
1090
1145
  return -Number(e.operand.text);
1091
1146
  }
1092
1147
  return void 0;
1093
1148
  }
1094
- function metricsOf(obj) {
1149
+ function metricsOf(expr, resolve5) {
1150
+ const obj = resolve5(expr);
1095
1151
  if (!ts2.isObjectLiteralExpression(obj)) return void 0;
1096
- const out2 = {};
1152
+ const out2 = { values: {}, opaque: false };
1097
1153
  let any = false;
1098
1154
  for (const p of obj.properties) {
1155
+ if (ts2.isSpreadAssignment(p)) {
1156
+ out2.opaque = true;
1157
+ any = true;
1158
+ continue;
1159
+ }
1099
1160
  if (!ts2.isPropertyAssignment(p)) continue;
1100
1161
  const k = keyName(p.name);
1101
1162
  if (k === null || !isMetric(k)) continue;
1102
- const n = numericOf(p.initializer);
1103
- if (n === void 0) continue;
1104
- out2[k] = n;
1105
1163
  any = true;
1164
+ const n = numericOf(resolve5(p.initializer));
1165
+ if (n === void 0) out2.opaque = true;
1166
+ else out2.values[k] = n;
1106
1167
  }
1107
1168
  return any ? out2 : void 0;
1108
1169
  }
1109
- function underCoverageKey(node) {
1170
+ function underKey(node, key2) {
1110
1171
  for (let p = node.parent; p; p = p.parent) {
1111
- if (ts2.isPropertyAssignment(p) && keyName(p.name) === "coverage") return true;
1172
+ if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
1112
1173
  }
1113
1174
  return false;
1114
1175
  }
@@ -1116,25 +1177,54 @@ function asExpression(src) {
1116
1177
  const t = src.trim();
1117
1178
  return t.startsWith("{") ? `(${t})` : src;
1118
1179
  }
1180
+ function sourceOf(src) {
1181
+ return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1182
+ }
1183
+ function resolverFor(sf) {
1184
+ const consts = /* @__PURE__ */ new Map();
1185
+ for (const st of sf.statements) {
1186
+ if (!ts2.isVariableStatement(st)) continue;
1187
+ for (const d of st.declarationList.declarations) {
1188
+ if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
1189
+ }
1190
+ }
1191
+ return (e) => {
1192
+ if (ts2.isIdentifier(e)) {
1193
+ const bound = consts.get(e.text);
1194
+ return bound ?? e;
1195
+ }
1196
+ return e;
1197
+ };
1198
+ }
1119
1199
  function parseThresholds(src) {
1120
- const res = { paths: /* @__PURE__ */ new Map(), present: false };
1200
+ const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
1121
1201
  try {
1122
- const sf = ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1202
+ const sf = sourceOf(src);
1203
+ const resolve5 = resolverFor(sf);
1123
1204
  const visit = (node) => {
1124
- if (ts2.isPropertyAssignment(node) && ts2.isObjectLiteralExpression(node.initializer)) {
1205
+ if (ts2.isPropertyAssignment(node)) {
1125
1206
  const key2 = keyName(node.name);
1126
- if (key2 === "coverageThreshold" || key2 === "thresholds" && underCoverageKey(node)) {
1207
+ if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
1127
1208
  res.present = true;
1128
- const flat = metricsOf(node.initializer);
1129
- if (flat) res.global = { ...res.global ?? {}, ...flat };
1130
- for (const p of node.initializer.properties) {
1131
- if (!ts2.isPropertyAssignment(p)) continue;
1132
- const k = keyName(p.name);
1133
- if (k === null || isMetric(k)) continue;
1134
- const m = metricsOf(p.initializer);
1135
- if (!m) continue;
1136
- if (k === "global") res.global = { ...res.global ?? {}, ...m };
1137
- else res.paths.set(norm(k), m);
1209
+ const init = resolve5(node.initializer);
1210
+ if (!ts2.isObjectLiteralExpression(init)) {
1211
+ res.opaque = true;
1212
+ } else {
1213
+ const flat = metricsOf(init, resolve5);
1214
+ if (flat) res.global = merge(res.global, flat);
1215
+ for (const p of init.properties) {
1216
+ if (ts2.isSpreadAssignment(p)) {
1217
+ res.opaque = true;
1218
+ continue;
1219
+ }
1220
+ if (!ts2.isPropertyAssignment(p)) continue;
1221
+ const k = keyName(p.name);
1222
+ if (k === null || isMetric(k)) continue;
1223
+ const m = metricsOf(p.initializer, resolve5);
1224
+ if (!m) continue;
1225
+ if (k === "global") res.global = merge(res.global, m);
1226
+ else res.paths.set(norm(k), m);
1227
+ }
1138
1228
  }
1139
1229
  }
1140
1230
  }
@@ -1145,41 +1235,242 @@ function parseThresholds(src) {
1145
1235
  }
1146
1236
  return res;
1147
1237
  }
1148
- var eff = (t, path) => t.paths.get(norm(path)) ?? t.global;
1238
+ function merge(a, b) {
1239
+ return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
1240
+ }
1241
+ function literals(e) {
1242
+ if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
1243
+ if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
1244
+ const items = [];
1245
+ let opaque = false;
1246
+ for (const el of e.elements) {
1247
+ if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
1248
+ else opaque = true;
1249
+ }
1250
+ return { items, opaque };
1251
+ }
1252
+ function parseLists(src) {
1253
+ const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1254
+ try {
1255
+ const sf = sourceOf(src);
1256
+ const resolve5 = resolverFor(sf);
1257
+ const visit = (node) => {
1258
+ if (ts2.isPropertyAssignment(node)) {
1259
+ const k = keyName(node.name);
1260
+ if (k === "collectCoverageFrom") {
1261
+ const { items, opaque } = literals(resolve5(node.initializer));
1262
+ res.collectFrom = items;
1263
+ if (opaque) res.opaqueCollect = true;
1264
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1265
+ const { items, opaque } = literals(resolve5(node.initializer));
1266
+ res.ignore.push(...items);
1267
+ if (opaque) res.opaqueIgnore = true;
1268
+ }
1269
+ }
1270
+ ts2.forEachChild(node, visit);
1271
+ };
1272
+ visit(sf);
1273
+ } catch {
1274
+ }
1275
+ return res;
1276
+ }
1277
+ var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1149
1278
  function compareMetrics(scope, before, after, out2) {
1150
1279
  if (!before) return;
1151
1280
  for (const m of METRICS) {
1152
- const bv = before[m];
1281
+ const bv = before.values[m];
1153
1282
  if (bv === void 0) continue;
1154
- const av = after?.[m];
1155
- if (av === void 0) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
1156
- else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
1283
+ const av = after?.values[m];
1284
+ if (av === void 0) {
1285
+ if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
1286
+ } else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
1287
+ }
1288
+ }
1289
+ function overrideTargetExists(key2, configPath, ctx) {
1290
+ const files = trackedFiles(ctx);
1291
+ if (!files) return null;
1292
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1293
+ const rel = norm(key2);
1294
+ let m;
1295
+ try {
1296
+ m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
1297
+ } catch {
1298
+ return null;
1157
1299
  }
1300
+ return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1158
1301
  }
1159
- function weakenings(before, after) {
1302
+ function weakenings(before, after, configPath, ctx) {
1160
1303
  const out2 = [];
1161
1304
  if (before.present && !after.present) {
1162
1305
  out2.push("the coverage threshold gate was removed");
1163
1306
  return out2;
1164
1307
  }
1308
+ if (after.opaque && !after.global && after.paths.size === 0) return out2;
1165
1309
  compareMetrics("global", before.global, after.global, out2);
1166
1310
  const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
1167
1311
  for (const p of paths) {
1168
- compareMetrics(`required coverage for ${p}:`, eff(before, p), eff(after, p), out2);
1312
+ const scope = `required coverage for ${p}:`;
1313
+ const b = before.paths.get(p);
1314
+ const a = after.paths.get(p);
1315
+ if (b && a) {
1316
+ compareMetrics(scope, b, a, out2);
1317
+ } else if (a) {
1318
+ const g = before.global;
1319
+ if (!g) continue;
1320
+ for (const m of METRICS) {
1321
+ const gv = g.values[m];
1322
+ const av = a.values[m];
1323
+ if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
1324
+ }
1325
+ } else if (b) {
1326
+ if (overrideTargetExists(p, configPath, ctx) === false) continue;
1327
+ compareMetrics(scope, b, after.global, out2);
1328
+ }
1329
+ }
1330
+ return out2;
1331
+ }
1332
+ function narrowings(before, after) {
1333
+ const out2 = [];
1334
+ if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
1335
+ const kept = new Set(after.collectFrom);
1336
+ for (const g of before.collectFrom) {
1337
+ if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
1338
+ }
1339
+ const had2 = new Set(before.collectFrom);
1340
+ for (const g of after.collectFrom) {
1341
+ if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
1342
+ }
1343
+ }
1344
+ const had = new Set(before.ignore);
1345
+ for (const g of after.ignore) {
1346
+ if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
1347
+ }
1348
+ return out2;
1349
+ }
1350
+ var SIMPLE_FILES = [
1351
+ {
1352
+ // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1353
+ // [tool.coverage.report] (pyproject.toml)
1354
+ file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1355
+ keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1356
+ removable: true
1357
+ },
1358
+ {
1359
+ // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1360
+ file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
1361
+ keys: METRICS.map((m) => ({
1362
+ key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1363
+ label: m,
1364
+ weakens: "lower"
1365
+ })),
1366
+ removable: false
1367
+ },
1368
+ {
1369
+ // codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
1370
+ // permitted drop (raising weakens)
1371
+ file: /(?:^|\/)\.?codecov\.yml$/,
1372
+ keys: [
1373
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1374
+ { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1375
+ ],
1376
+ removable: false
1377
+ }
1378
+ ];
1379
+ function numbersOf(src, key2) {
1380
+ const out2 = [];
1381
+ for (const line of src.split("\n")) {
1382
+ if (key2.global) {
1383
+ for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
1384
+ } else {
1385
+ const m = line.match(key2);
1386
+ if (m) out2.push(Number(m[1]));
1387
+ }
1388
+ }
1389
+ return out2;
1390
+ }
1391
+ function simpleWeakenings(path, before, after) {
1392
+ const out2 = [];
1393
+ const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1394
+ if (!spec) return out2;
1395
+ for (const k of spec.keys) {
1396
+ const b = numbersOf(before, k.key).sort((x, y) => y - x);
1397
+ const a = numbersOf(after, k.key).sort((x, y) => y - x);
1398
+ if (b.length === 0) continue;
1399
+ if (a.length === 0) {
1400
+ if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
1401
+ continue;
1402
+ }
1403
+ if (k.weakens === "lower") {
1404
+ for (let i = 0; i < b.length; i++) {
1405
+ const av = a[Math.min(i, a.length - 1)];
1406
+ if (av < b[i]) {
1407
+ out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
1408
+ break;
1409
+ }
1410
+ }
1411
+ } else {
1412
+ const bMax = b[0];
1413
+ const aMax = a[0];
1414
+ if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1415
+ }
1169
1416
  }
1170
1417
  return out2;
1171
1418
  }
1419
+ var SWITCHES = [
1420
+ {
1421
+ re: /--passWithNoTests\b/,
1422
+ message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
1423
+ remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1424
+ },
1425
+ {
1426
+ re: /\bpassWithNoTests"?\s*:\s*true\b/,
1427
+ message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
1428
+ remediation: "Remove passWithNoTests; an empty suite must not be a green check."
1429
+ },
1430
+ {
1431
+ re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
1432
+ message: "coverage switched off on the command line (--coverage=false).",
1433
+ remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
1434
+ },
1435
+ {
1436
+ re: /--coverageThreshold[= ]/,
1437
+ message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
1438
+ remediation: "Remove the override; the configured threshold is the gate."
1439
+ },
1440
+ {
1441
+ re: /\bautoUpdate"?\s*:\s*true\b/,
1442
+ message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
1443
+ remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
1444
+ },
1445
+ {
1446
+ re: /"?check-coverage"?\s*[:=]\s*false\b/,
1447
+ message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1448
+ remediation: "Keep check-coverage on; raise real coverage instead."
1449
+ },
1450
+ {
1451
+ re: /^\s*informational:\s*true\b/,
1452
+ message: "informational: true added \u2014 the codecov status can no longer fail.",
1453
+ remediation: "Remove informational; a status that cannot fail is not a gate."
1454
+ }
1455
+ ];
1456
+ var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1172
1457
  var coverageLowering = {
1173
1458
  id: RULE4,
1174
1459
  surface: ["file"],
1175
1460
  certainty: "mechanical",
1176
- run(changes, policy) {
1461
+ run(changes, policy, _view, ctx) {
1177
1462
  const out2 = [];
1178
1463
  for (const c of changes) {
1179
1464
  if (c.kind !== "file") continue;
1180
1465
  if (!isProtected(c.path, policy, "config")) continue;
1181
1466
  if (c.after != null && c.op !== "delete") {
1182
- for (const reason of weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after))) {
1467
+ const reasons = [];
1468
+ if (isJsConfig(c.path)) {
1469
+ reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
1470
+ reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
1471
+ }
1472
+ reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1473
+ for (const reason of reasons) {
1183
1474
  out2.push(
1184
1475
  makeFinding(RULE4, policy, {
1185
1476
  file: c.path,
@@ -1190,7 +1481,9 @@ var coverageLowering = {
1190
1481
  );
1191
1482
  }
1192
1483
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1193
- if (parseThresholds(c.before ?? "").present) {
1484
+ const before = c.before ?? "";
1485
+ const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
1486
+ if (gated) {
1194
1487
  out2.push(
1195
1488
  makeFinding(RULE4, policy, {
1196
1489
  file: c.path,
@@ -1215,20 +1508,22 @@ var coverageLowering = {
1215
1508
  );
1216
1509
  }
1217
1510
  for (const l of added) {
1218
- if (/--passWithNoTests\b/.test(l.content) || /\bpassWithNoTests\s*:\s*true\b/.test(l.content)) {
1511
+ for (const s of SWITCHES) {
1512
+ if (!s.re.test(l.content)) continue;
1513
+ if (removed.some((r) => s.re.test(r.content))) continue;
1219
1514
  out2.push(
1220
1515
  makeFinding(RULE4, policy, {
1221
1516
  file: c.path,
1222
1517
  line: l.newLine ?? void 0,
1223
- message: "passWithNoTests added \u2014 the suite can now pass with zero tests.",
1518
+ message: s.message,
1224
1519
  evidence: l.content.trim(),
1225
- remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
1520
+ remediation: s.remediation
1226
1521
  })
1227
1522
  );
1228
1523
  }
1229
1524
  }
1230
1525
  if (c.after == null) {
1231
- const KEY = /\b(branches|functions|lines|statements)\b\s*:\s*(\d+(?:\.\d+)?)/;
1526
+ const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
1232
1527
  const oldNums = /* @__PURE__ */ new Map();
1233
1528
  for (const l of removed) {
1234
1529
  const m = l.content.match(KEY);
@@ -1254,7 +1549,7 @@ var coverageLowering = {
1254
1549
  }
1255
1550
  const seen = /* @__PURE__ */ new Set();
1256
1551
  return out2.filter((f) => {
1257
- const k = `${f.rule}|${f.message}`;
1552
+ const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
1258
1553
  if (seen.has(k)) return false;
1259
1554
  seen.add(k);
1260
1555
  return true;
@@ -1276,23 +1571,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1276
1571
  function commandCore(line) {
1277
1572
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1278
1573
  }
1574
+ function canonical(core) {
1575
+ let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
1576
+ s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1577
+ s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1578
+ s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
1579
+ s = s.replace(/^exec\s+/, "");
1580
+ return s.replace(/\s+--\s*$/, "").trim();
1581
+ }
1582
+ var usesRef = (core) => core.replace(/@.*$/, "");
1583
+ var NEUTRALISING_SUFFIX = /\|\||;|(?<!&)&(?!&)|\|(?!\|)|--passWithNoTests\b|--coverage=false\b|--coverageThreshold\b|--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b/;
1584
+ function survives(removedCore, isUses, afterCores) {
1585
+ if (isUses) {
1586
+ const ref = usesRef(removedCore);
1587
+ return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1588
+ }
1589
+ const r = canonical(removedCore);
1590
+ let neutralised;
1591
+ for (const raw of afterCores) {
1592
+ const a = canonical(raw);
1593
+ if (a === r) return { state: "kept" };
1594
+ if (a.startsWith(r + " ")) {
1595
+ const rest = a.slice(r.length);
1596
+ if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1597
+ else return { state: "kept" };
1598
+ } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1599
+ return { state: "kept" };
1600
+ }
1601
+ }
1602
+ return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1603
+ }
1279
1604
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1280
- function isAlwaysFalse(raw) {
1281
- const v = uncommented(raw);
1282
- if (/^false$/i.test(v)) return true;
1605
+ function lex(src) {
1606
+ const out2 = [];
1607
+ let i = 0;
1608
+ while (i < src.length) {
1609
+ const ch = src[i];
1610
+ if (/\s/.test(ch)) {
1611
+ i++;
1612
+ continue;
1613
+ }
1614
+ if (ch === "'") {
1615
+ let j = i + 1;
1616
+ let s = "";
1617
+ while (j < src.length) {
1618
+ if (src[j] === "'" && src[j + 1] === "'") {
1619
+ s += "'";
1620
+ j += 2;
1621
+ } else if (src[j] === "'") break;
1622
+ else s += src[j++];
1623
+ }
1624
+ if (j >= src.length) return null;
1625
+ out2.push({ t: "str", v: s });
1626
+ i = j + 1;
1627
+ continue;
1628
+ }
1629
+ const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
1630
+ if (num) {
1631
+ out2.push({ t: "num", v: num[0] });
1632
+ i += num[0].length;
1633
+ continue;
1634
+ }
1635
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1636
+ if (op) {
1637
+ out2.push({ t: "op", v: op[0] });
1638
+ i += op[0].length;
1639
+ continue;
1640
+ }
1641
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1642
+ if (id) {
1643
+ out2.push({ t: "id", v: id[0] });
1644
+ i += id[0].length;
1645
+ continue;
1646
+ }
1647
+ return null;
1648
+ }
1649
+ return out2;
1650
+ }
1651
+ function foldConst(src) {
1652
+ const toks = lex(src);
1653
+ if (!toks) return void 0;
1654
+ let p = 0;
1655
+ const peek = () => toks[p];
1656
+ const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
1657
+ const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
1658
+ const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1659
+ const eq = (a, b) => {
1660
+ if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1661
+ if (a === null && b === null) return true;
1662
+ const x = num(a);
1663
+ const y = num(b);
1664
+ return !Number.isNaN(x) && x === y;
1665
+ };
1666
+ const or = () => {
1667
+ let l = and();
1668
+ while (eat("||")) {
1669
+ const r = and();
1670
+ if (l === void 0 || r === void 0) return void 0;
1671
+ l = truthy(l) ? l : r;
1672
+ }
1673
+ return l;
1674
+ };
1675
+ const and = () => {
1676
+ let l = cmp();
1677
+ while (eat("&&")) {
1678
+ const r = cmp();
1679
+ if (l === void 0 || r === void 0) return void 0;
1680
+ l = truthy(l) ? r : l;
1681
+ }
1682
+ return l;
1683
+ };
1684
+ const cmp = () => {
1685
+ let l = unary();
1686
+ for (; ; ) {
1687
+ const t = peek();
1688
+ if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1689
+ p++;
1690
+ const r = unary();
1691
+ if (l === void 0 || r === void 0) return void 0;
1692
+ if (t.v === "==") l = eq(l, r);
1693
+ else if (t.v === "!=") l = !eq(l, r);
1694
+ else {
1695
+ const x = num(l);
1696
+ const y = num(r);
1697
+ if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
1698
+ l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
1699
+ }
1700
+ }
1701
+ };
1702
+ const unary = () => {
1703
+ if (eat("!")) {
1704
+ const v2 = unary();
1705
+ return v2 === void 0 ? void 0 : !truthy(v2);
1706
+ }
1707
+ return primary();
1708
+ };
1709
+ const primary = () => {
1710
+ const t = peek();
1711
+ if (!t) return void 0;
1712
+ if (eat("(")) {
1713
+ const v2 = or();
1714
+ return eat(")") ? v2 : void 0;
1715
+ }
1716
+ p++;
1717
+ if (t.t === "str") return t.v;
1718
+ if (t.t === "num") return Number(t.v);
1719
+ if (t.t === "id") {
1720
+ if (/^true$/i.test(t.v)) return true;
1721
+ if (/^false$/i.test(t.v)) return false;
1722
+ if (/^null$/i.test(t.v)) return null;
1723
+ return void 0;
1724
+ }
1725
+ return void 0;
1726
+ };
1727
+ const v = or();
1728
+ return p === toks.length ? v : void 0;
1729
+ }
1730
+ function expressionOf(raw) {
1731
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1283
1732
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1284
- if (!m) return false;
1285
- const e = m[1];
1286
- if (/^(false|0|''|"")$/i.test(e)) return true;
1287
- const cmp = e.match(/^(-?\d+)\s*(==|!=)\s*(-?\d+)$/);
1288
- if (cmp) return cmp[2] === "==" ? cmp[1] !== cmp[3] : cmp[1] === cmp[3];
1289
- return false;
1733
+ return m ? m[1] : v;
1734
+ }
1735
+ function isAlwaysFalse(raw) {
1736
+ const e = expressionOf(raw);
1737
+ if (e === "") return false;
1738
+ const v = foldConst(e);
1739
+ return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
1290
1740
  }
1291
1741
  function isTruthy(raw) {
1292
- const v = uncommented(raw);
1293
- if (/^true$/i.test(v)) return true;
1742
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
1743
+ if (/^(?:true|yes|on|1)$/i.test(v)) return true;
1294
1744
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1295
- return !!m && /^(true|1)$/i.test(m[1]);
1745
+ if (!m) return false;
1746
+ const f = foldConst(m[1]);
1747
+ return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
1748
+ }
1749
+ var indentOf = (l) => l.match(/^\s*/)[0].length;
1750
+ var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
1751
+ function stepOf(lines, i) {
1752
+ const ind = indentOf(lines[i]);
1753
+ let s = i - 1;
1754
+ while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
1755
+ if (s < 0 || !STEP_START.test(lines[s])) return null;
1756
+ const stepInd = indentOf(lines[s]);
1757
+ let e = i + 1;
1758
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1759
+ return [s, e];
1760
+ }
1761
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
1762
+ function runBlocks(lines) {
1763
+ const out2 = [];
1764
+ for (let i = 0; i < lines.length; i++) {
1765
+ if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
1766
+ const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
1767
+ let e = i + 1;
1768
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
1769
+ out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
1770
+ }
1771
+ return out2;
1772
+ }
1773
+ var NEUTRALISERS = [
1774
+ { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
1775
+ { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
1776
+ { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1777
+ { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1778
+ ];
1779
+ function flowList(v) {
1780
+ const m = v.trim().match(/^\[(.*)\]$/);
1781
+ if (!m) return null;
1782
+ return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1783
+ }
1784
+ function parseTriggers(src) {
1785
+ const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
1786
+ const lines = src.split("\n");
1787
+ const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1788
+ if (i < 0) return t;
1789
+ t.present = true;
1790
+ const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
1791
+ const inline = uncommented(head);
1792
+ if (inline) {
1793
+ const list = flowList(inline);
1794
+ for (const e of list ?? [inline]) t.events.add(e);
1795
+ return t;
1796
+ }
1797
+ let event = null;
1798
+ let listKey = null;
1799
+ let listInd = -1;
1800
+ for (let j = i + 1; j < lines.length; j++) {
1801
+ const l = lines[j];
1802
+ if (l.trim() === "" || /^\s*#/.test(l)) continue;
1803
+ const ind = indentOf(l);
1804
+ if (ind === 0) break;
1805
+ const body = uncommented(l);
1806
+ const item = body.match(/^\s*-\s+(.+)$/);
1807
+ if (item && listKey && event && ind > listInd) {
1808
+ const target = t[listKey].get(event) ?? [];
1809
+ target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
1810
+ t[listKey].set(event, target);
1811
+ continue;
1812
+ }
1813
+ const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
1814
+ if (!kv) continue;
1815
+ if (ind === 2 || event === null && ind < 4) {
1816
+ event = kv[1];
1817
+ t.events.add(event);
1818
+ listKey = null;
1819
+ continue;
1820
+ }
1821
+ if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1822
+ listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
1823
+ listInd = ind;
1824
+ const list = flowList(kv[2]);
1825
+ t[listKey].set(event, list ?? []);
1826
+ } else listKey = null;
1827
+ }
1828
+ return t;
1829
+ }
1830
+ var DEFAULT_BRANCH = /^(?:main|master)$/;
1831
+ var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1832
+ function triggerNarrowings(before, after) {
1833
+ const out2 = [];
1834
+ if (!before.present || !after.present) return out2;
1835
+ for (const e of ["push", "pull_request", "pull_request_target"]) {
1836
+ if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
1837
+ }
1838
+ for (const [e, list] of after.pathsIgnore) {
1839
+ const had = before.pathsIgnore.get(e) ?? [];
1840
+ if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
1841
+ }
1842
+ for (const [e, list] of after.branchesIgnore) {
1843
+ const had = before.branchesIgnore.get(e) ?? [];
1844
+ if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1845
+ }
1846
+ for (const [e, was] of before.branches) {
1847
+ const now = after.branches.get(e);
1848
+ if (!now || !after.events.has(e)) continue;
1849
+ if (now.some(ALL)) continue;
1850
+ const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
1851
+ const replaced = was.length > 0 && !was.some((b) => now.includes(b));
1852
+ if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1853
+ }
1854
+ return out2;
1296
1855
  }
1297
1856
  var ciTampering = {
1298
1857
  id: RULE5,
@@ -1314,9 +1873,14 @@ var ciTampering = {
1314
1873
  continue;
1315
1874
  }
1316
1875
  if (!isProtected(c.path, policy, "ci")) continue;
1876
+ const afterLines = c.after != null ? c.after.split("\n") : null;
1877
+ const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
1317
1878
  for (const l of addedLines(c)) {
1318
1879
  const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
1319
1880
  const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
1881
+ if (!coe && !cond) continue;
1882
+ const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1883
+ if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
1320
1884
  if (coe && isTruthy(coe[1])) {
1321
1885
  out2.push(
1322
1886
  makeFinding(RULE5, policy, {
@@ -1339,15 +1903,72 @@ var ciTampering = {
1339
1903
  );
1340
1904
  }
1341
1905
  }
1342
- const afterCores = new Set(
1343
- (c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
1344
- );
1906
+ if (afterLines) {
1907
+ for (const b of runBlocks(afterLines)) {
1908
+ if (!b.body.some(invokesCheck)) continue;
1909
+ const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
1910
+ for (let k = 0; k < b.body.length; k++) {
1911
+ const idx = b.start + 1 + k;
1912
+ if (!addedAt.has(idx + 1)) continue;
1913
+ const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
1914
+ if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
1915
+ out2.push(
1916
+ makeFinding(RULE5, policy, {
1917
+ file: c.path,
1918
+ line: idx + 1,
1919
+ message: `A check's run block was neutralised: ${n.what}.`,
1920
+ evidence: b.body[k].trim(),
1921
+ remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
1922
+ })
1923
+ );
1924
+ }
1925
+ }
1926
+ for (const l of addedLines(c)) {
1927
+ if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
1928
+ const step = stepOf(afterLines, l.newLine - 1);
1929
+ if (step && !stepHasCheck(afterLines, step)) continue;
1930
+ out2.push(
1931
+ makeFinding(RULE5, policy, {
1932
+ file: c.path,
1933
+ line: l.newLine,
1934
+ message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
1935
+ evidence: l.content.trim(),
1936
+ remediation: "Keep the default shell (fail-fast) on steps that run checks."
1937
+ })
1938
+ );
1939
+ }
1940
+ if (c.before != null) {
1941
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
1942
+ out2.push(
1943
+ makeFinding(RULE5, policy, {
1944
+ file: c.path,
1945
+ message: `The workflow's triggers were narrowed: ${reason}.`,
1946
+ evidence: reason,
1947
+ remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
1948
+ })
1949
+ );
1950
+ }
1951
+ }
1952
+ }
1953
+ const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
1345
1954
  for (const l of removedLines(c)) {
1346
1955
  if (/^\s*#/.test(l.content)) continue;
1347
1956
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1348
1957
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
1349
1958
  if (!isUsesLine && !isCheckCommand) continue;
1350
- if (afterCores.has(commandCore(l.content))) continue;
1959
+ const s = survives(commandCore(l.content), isUsesLine, afterCores);
1960
+ if (s.state === "kept") continue;
1961
+ if (s.state === "neutralised") {
1962
+ out2.push(
1963
+ makeFinding(RULE5, policy, {
1964
+ file: c.path,
1965
+ message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1966
+ evidence: `${l.content.trim()} \u2192 ${s.by}`,
1967
+ remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
1968
+ })
1969
+ );
1970
+ continue;
1971
+ }
1351
1972
  out2.push(
1352
1973
  makeFinding(RULE5, policy, {
1353
1974
  file: c.path,
@@ -2269,44 +2890,215 @@ var hookTampering = {
2269
2890
  };
2270
2891
 
2271
2892
  // src/detectors/test-deletion.ts
2893
+ import ts4 from "typescript";
2894
+
2895
+ // src/detectors/suite-config.ts
2896
+ import picomatch3 from "picomatch";
2272
2897
  import ts3 from "typescript";
2898
+ var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
2899
+ var JEST_DEFAULT_IGNORE = ["/node_modules/"];
2900
+ var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
2901
+ var VITEST_DEFAULT_EXCLUDE = [
2902
+ "**/node_modules/**",
2903
+ "**/dist/**",
2904
+ "**/cypress/**",
2905
+ "**/.{idea,git,cache,output,temp}/**",
2906
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
2907
+ ];
2908
+ var CANONICAL_SAMPLES = [
2909
+ "src/a.test.ts",
2910
+ "src/a.spec.ts",
2911
+ "test/a.test.ts",
2912
+ "tests/a.test.js",
2913
+ "src/__tests__/a.ts",
2914
+ "a.test.tsx",
2915
+ "a.spec.js",
2916
+ "test/a.test.mjs"
2917
+ ];
2918
+ function keyName2(name) {
2919
+ if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2920
+ return null;
2921
+ }
2922
+ function literals2(e) {
2923
+ if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
2924
+ if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2925
+ const items = [];
2926
+ let opaque = false;
2927
+ for (const el of e.elements) {
2928
+ if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
2929
+ else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2930
+ items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2931
+ } else opaque = true;
2932
+ }
2933
+ return { items, opaque };
2934
+ }
2935
+ function underKey2(node, key2) {
2936
+ for (let p = node.parent; p; p = p.parent) {
2937
+ if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
2938
+ }
2939
+ return false;
2940
+ }
2941
+ function asExpression2(src) {
2942
+ const t = src.trim();
2943
+ return t.startsWith("{") ? `(${t})` : src;
2944
+ }
2945
+ function runnerOf(path) {
2946
+ const base = path.split("/").pop() ?? path;
2947
+ if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
2948
+ if (/^vitest\.config\./.test(base)) return "vitest";
2949
+ return null;
2950
+ }
2951
+ function parseSelection(src, runner) {
2952
+ const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
2953
+ try {
2954
+ const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
2955
+ const seen = /* @__PURE__ */ new Set();
2956
+ const take = (key2, e) => {
2957
+ const { items, opaque } = literals2(e);
2958
+ if (opaque || seen.has(key2)) sel.opaque = true;
2959
+ seen.add(key2);
2960
+ sel.present = true;
2961
+ return items.map((g) => g.replace(/^<rootDir>\//, ""));
2962
+ };
2963
+ const visit = (node) => {
2964
+ if (ts3.isPropertyAssignment(node)) {
2965
+ const k = keyName2(node.name);
2966
+ if (runner === "jest") {
2967
+ if (k === "testMatch") sel.include = take(k, node.initializer);
2968
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
2969
+ else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
2970
+ } else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
2971
+ if (k === "include") sel.include = take(k, node.initializer);
2972
+ else if (k === "exclude") sel.ignore = take(k, node.initializer);
2973
+ }
2974
+ }
2975
+ ts3.forEachChild(node, visit);
2976
+ };
2977
+ visit(sf);
2978
+ } catch {
2979
+ }
2980
+ return sel;
2981
+ }
2982
+ var globs2 = (patterns) => {
2983
+ const ms = patterns.map((g) => {
2984
+ try {
2985
+ return picomatch3(g, { dot: true });
2986
+ } catch {
2987
+ return () => false;
2988
+ }
2989
+ });
2990
+ return (path) => ms.some((m) => m(path));
2991
+ };
2992
+ var regexes = (patterns) => {
2993
+ const rs = patterns.flatMap((p) => {
2994
+ try {
2995
+ return [new RegExp(p.replace(/<rootDir>/g, ""))];
2996
+ } catch {
2997
+ return [];
2998
+ }
2999
+ });
3000
+ return (path) => rs.some((r) => r.test("/" + path));
3001
+ };
3002
+ function predicate(sel, runner) {
3003
+ const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
3004
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
3005
+ const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
3006
+ const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
3007
+ return {
3008
+ selects: (p) => included(p) && !ignored(p),
3009
+ why: (p) => {
3010
+ if (ignored(p)) {
3011
+ const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
3012
+ const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
3013
+ return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
3014
+ }
3015
+ const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3016
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3017
+ }
3018
+ };
3019
+ }
3020
+ function suiteNarrowings(before, after, path, samples) {
3021
+ const runner = runnerOf(path);
3022
+ if (!runner) return [];
3023
+ const b = parseSelection(before ?? "", runner);
3024
+ const a = parseSelection(after, runner);
3025
+ if (!a.present || a.opaque) return [];
3026
+ const pb = predicate(b, runner);
3027
+ const pa = predicate(a, runner);
3028
+ const out2 = [];
3029
+ for (const s of samples) {
3030
+ if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
3031
+ }
3032
+ return out2;
3033
+ }
3034
+
3035
+ // src/detectors/test-deletion.ts
2273
3036
  var RULE7 = "test-deletion";
3037
+ var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
2274
3038
  var TEST_DEFS = {
2275
3039
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
2276
3040
  go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
2277
3041
  rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
2278
3042
  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/,
3043
+ java: JVM_TEST,
3044
+ kt: JVM_TEST,
2281
3045
  php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
2282
3046
  cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
2283
3047
  };
2284
3048
  function calleeName(expr) {
2285
- if (ts3.isIdentifier(expr)) return expr.text;
2286
- if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
3049
+ if (ts4.isIdentifier(expr)) return expr.text;
3050
+ if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
2287
3051
  return expr.expression.text;
2288
3052
  }
2289
3053
  return null;
2290
3054
  }
2291
- function countTestBlocks(src, path = "spec.ts") {
3055
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
3056
+ function eachRows(call) {
3057
+ const arg = call.arguments[0];
3058
+ if (!arg) return { n: 1, open: true };
3059
+ if (ts4.isArrayLiteralExpression(arg)) {
3060
+ let n = 0;
3061
+ let open = false;
3062
+ for (const el of arg.elements) {
3063
+ if (ts4.isSpreadElement(el)) open = true;
3064
+ else n++;
3065
+ }
3066
+ return { n: Math.max(n, open ? 0 : 1), open };
3067
+ }
3068
+ return { n: 1, open: true };
3069
+ }
3070
+ function countTests(src, path = "spec.ts") {
2292
3071
  const lang = langOf(path);
2293
3072
  if (lang && lang !== "js") {
2294
3073
  const re = TEST_DEFS[lang];
2295
3074
  let n2 = 0;
2296
3075
  for (const line of src.split("\n")) if (re.test(line)) n2++;
2297
- return n2;
3076
+ return { min: n2, open: false };
2298
3077
  }
2299
- const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
3078
+ const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
2300
3079
  let n = 0;
3080
+ let open = false;
2301
3081
  const visit = (node) => {
2302
- if (ts3.isCallExpression(node)) {
2303
- const name = calleeName(node.expression);
2304
- if (name === "it" || name === "test") n++;
3082
+ if (ts4.isCallExpression(node)) {
3083
+ if (isEachOf(node.expression)) {
3084
+ const r = eachRows(node);
3085
+ n += r.n;
3086
+ if (r.open) open = true;
3087
+ } else {
3088
+ const name = calleeName(node.expression);
3089
+ if (name === "it" || name === "test") n++;
3090
+ }
3091
+ } else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
3092
+ const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3093
+ n += Math.max(rows, 1);
2305
3094
  }
2306
- ts3.forEachChild(node, visit);
3095
+ ts4.forEachChild(node, visit);
2307
3096
  };
2308
3097
  visit(sf);
2309
- return n;
3098
+ return { min: n, open };
3099
+ }
3100
+ function countTestBlocks(src, path = "spec.ts") {
3101
+ return countTests(src, path).min;
2310
3102
  }
2311
3103
  function significantLines(src, path) {
2312
3104
  const out2 = /* @__PURE__ */ new Set();
@@ -2317,32 +3109,63 @@ function significantLines(src, path) {
2317
3109
  }
2318
3110
  return out2;
2319
3111
  }
3112
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
3113
+ function runnerSamples(configPath, policy, ctx) {
3114
+ const files = trackedFiles(ctx);
3115
+ if (!files) return CANONICAL_SAMPLES;
3116
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
3117
+ const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
3118
+ return own.length ? own : CANONICAL_SAMPLES;
3119
+ }
3120
+ function joinRoot(root, to) {
3121
+ const parts = root ? root.split("/") : [];
3122
+ for (const seg of to.replace(/\/+$/, "").split("/")) {
3123
+ if (seg === "" || seg === ".") continue;
3124
+ if (seg === "..") {
3125
+ if (parts.length === 0) return null;
3126
+ parts.pop();
3127
+ } else parts.push(seg);
3128
+ }
3129
+ return parts.join("/");
3130
+ }
3131
+ function leavesGlob(dir, dest, policy, ctx) {
3132
+ const files = trackedFiles(ctx);
3133
+ if (!files || !dir) return false;
3134
+ const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3135
+ const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3136
+ return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
3137
+ }
3138
+ var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
2320
3139
  var testDeletion = {
2321
3140
  id: RULE7,
2322
3141
  surface: ["file", "command"],
2323
3142
  certainty: "mechanical",
2324
- run(changes, policy) {
3143
+ run(changes, policy, _view, ctx) {
2325
3144
  const out2 = [];
2326
3145
  const addedTestLines = /* @__PURE__ */ new Set();
2327
3146
  let addedTestBlocks = 0;
2328
3147
  for (const c of changes) {
2329
- if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
3148
+ if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3149
+ if (c.op === "add") {
2330
3150
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
2331
3151
  addedTestBlocks += countTestBlocks(c.after, c.path);
3152
+ } else if (c.before != null) {
3153
+ const before = significantLines(c.before, c.path);
3154
+ for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
3155
+ addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
2332
3156
  }
2333
3157
  }
2334
- const isRelocation = (before, path) => {
2335
- const sig = significantLines(before, path);
2336
- if (sig.size === 0 || addedTestLines.size === 0) return false;
3158
+ const isRelocation = (removed, blocksRemoved) => {
3159
+ if (removed.size === 0 || addedTestLines.size === 0) return false;
2337
3160
  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);
3161
+ for (const l of removed) if (addedTestLines.has(l)) hit++;
3162
+ return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
2340
3163
  };
2341
3164
  for (const c of changes) {
2342
3165
  if (c.kind === "file") {
2343
3166
  const isTest = isProtected(c.path, policy, "tests");
2344
3167
  if (c.op === "delete" && isTest) {
2345
- if (c.before != null && isRelocation(c.before, c.path)) continue;
3168
+ if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
2346
3169
  out2.push(
2347
3170
  makeFinding(RULE7, policy, {
2348
3171
  file: c.path,
@@ -2361,41 +3184,95 @@ var testDeletion = {
2361
3184
  })
2362
3185
  );
2363
3186
  } 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) {
3187
+ const before = countTests(c.before, c.path);
3188
+ const after = countTests(c.after, c.path);
3189
+ if (after.min < before.min && !after.open) {
3190
+ const afterLines = significantLines(c.after, c.path);
3191
+ const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
3192
+ if (isRelocation(removed, before.min - after.min)) continue;
2367
3193
  out2.push(
2368
3194
  makeFinding(RULE7, policy, {
2369
3195
  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}`,
3196
+ message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
3197
+ evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
2372
3198
  remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
2373
3199
  })
2374
3200
  );
2375
3201
  }
3202
+ } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
3203
+ for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3204
+ out2.push(
3205
+ makeFinding(RULE7, policy, {
3206
+ file: c.path,
3207
+ message: `The runner no longer selects ${n.path}: ${n.reason}.`,
3208
+ evidence: `${n.path} dropped from the suite by ${c.path}`,
3209
+ remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
3210
+ })
3211
+ );
3212
+ }
2376
3213
  }
2377
3214
  } else {
3215
+ let root = "";
2378
3216
  for (const seg of segments(c.raw)) {
2379
3217
  const toks = tokens(seg).map(unquote);
2380
- const testToks = toks.filter((t) => isProtected(t, policy, "tests"));
2381
- if (testToks.length === 0) continue;
3218
+ const cdTo = toks[0] === "cd" ? toks[1] : null;
3219
+ if (cdTo !== null) {
3220
+ root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
3221
+ continue;
3222
+ }
3223
+ const inRepo = (t) => root ? `${root}/${t}` : t;
3224
+ const listing = trackedFiles(ctx);
3225
+ const testToks = toks.filter((t) => {
3226
+ if (!isProtected(t, policy, "tests")) return false;
3227
+ if (!listing || root === null) return true;
3228
+ const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
3229
+ if (looksLikeFile || listing.includes(inRepo(t))) return true;
3230
+ return containsProtected(inRepo(t), policy, "tests", ctx);
3231
+ });
3232
+ const dirToks = toks.filter(
3233
+ (t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
3234
+ );
3235
+ if (testToks.length === 0 && dirToks.length === 0) continue;
3236
+ const named = [...testToks, ...dirToks];
3237
+ const cmd = toks[0] ?? "";
2382
3238
  let why = null;
2383
3239
  let evidence = seg;
2384
3240
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
2385
3241
  const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
2386
- if (/\brm\b/.test(seg)) {
3242
+ const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3243
+ const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3244
+ const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
3245
+ const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
3246
+ return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
3247
+ })();
3248
+ if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
2387
3249
  why = "rm deletes a test file";
2388
- } else if (/\bsed\b/.test(seg) && /(?:^|\s)-i\b/.test(seg)) {
3250
+ } else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
3251
+ why = "git rm deletes a test file";
3252
+ } else if (cmd === "sed" && SED_INPLACE.test(seg)) {
2389
3253
  why = "sed -i rewrites a test file in place";
3254
+ } else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
3255
+ why = "perl -i rewrites a test file in place";
2390
3256
  } else if (/\btruncate\b/.test(seg)) {
2391
3257
  why = "truncate empties a test file";
2392
3258
  } else if (redirectsOntoTest) {
2393
3259
  why = "a redirect overwrites/empties a test file";
3260
+ } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
3261
+ why = "cp overwrites a test file with something else";
3262
+ } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
3263
+ why = "tee overwrites a test file";
3264
+ } else if (findDeletes) {
3265
+ why = "find -delete removes test files";
3266
+ } else if (gitRestoresOld) {
3267
+ why = "git checkout <rev> replaces a test file with an older version";
2394
3268
  } else if (/\bmv\b/.test(seg)) {
2395
3269
  const dest = toks[toks.length - 1];
2396
- if (dest && !isProtected(dest, policy, "tests")) {
3270
+ if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
2397
3271
  why = "mv renames a test file out of the test glob";
2398
3272
  evidence = `${testToks[0]} \u2192 ${dest}`;
3273
+ } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
3274
+ why = "mv renames a test directory so its specs leave the test glob";
3275
+ evidence = `${dirToks[0]} \u2192 ${dest}`;
2399
3276
  }
2400
3277
  }
2401
3278
  if (why) {
@@ -2415,9 +3292,40 @@ var testDeletion = {
2415
3292
  };
2416
3293
 
2417
3294
  // src/detectors/test-content-removal.ts
3295
+ import ts5 from "typescript";
2418
3296
  var RULE8 = "test-content-removal";
2419
3297
  var MIN_REMOVED_LINES = 3;
3298
+ var MIN_REMOVED_ROWS = 2;
2420
3299
  var ws = (s) => s.replace(/\s+/g, "");
3300
+ function eachTables(src, path) {
3301
+ const rows = /* @__PURE__ */ new Set();
3302
+ let open = false;
3303
+ if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3304
+ try {
3305
+ const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
3306
+ const visit = (node) => {
3307
+ if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
3308
+ const arg = node.arguments[0];
3309
+ if (arg && ts5.isArrayLiteralExpression(arg)) {
3310
+ for (const el of arg.elements) {
3311
+ if (ts5.isSpreadElement(el)) {
3312
+ open = true;
3313
+ continue;
3314
+ }
3315
+ for (const raw of el.getText(sf).split("\n")) {
3316
+ const l = raw.trim().replace(/,$/, "");
3317
+ if (isSignificantLine(l, "js")) rows.add(l);
3318
+ }
3319
+ }
3320
+ } else open = true;
3321
+ }
3322
+ ts5.forEachChild(node, visit);
3323
+ };
3324
+ visit(sf);
3325
+ } catch {
3326
+ }
3327
+ return { rows, open };
3328
+ }
2421
3329
  function significantLinesOrdered(src, path) {
2422
3330
  const out2 = [];
2423
3331
  const lang = langOf(path);
@@ -2434,16 +3342,23 @@ var testContentRemoval = {
2434
3342
  run(changes, policy) {
2435
3343
  const out2 = [];
2436
3344
  let keptPool = "";
3345
+ let addedPool = "";
2437
3346
  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"));
3347
+ if (c.kind !== "file" || c.after == null) continue;
3348
+ if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
3349
+ if (c.op === "add") addedPool += ws(c.after);
3350
+ else if (c.before != null) {
3351
+ const had = new Set(c.before.split("\n").map((l) => l.trim()));
3352
+ addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
2440
3353
  }
2441
3354
  }
2442
3355
  for (const c of changes) {
2443
3356
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
2444
3357
  if (!isProtected(c.path, policy, "tests")) continue;
2445
3358
  if (isProtected(c.path, policy, "snapshots")) continue;
2446
- if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
3359
+ const blocksBefore = countTests(c.before, c.path);
3360
+ const blocksAfter = countTests(c.after, c.path);
3361
+ if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
2447
3362
  const beforeSig = significantLinesOrdered(c.before, c.path);
2448
3363
  const afterSig = significantLinesOrdered(c.after, c.path);
2449
3364
  const netDrop = beforeSig.length - afterSig.length;
@@ -2454,6 +3369,19 @@ var testContentRemoval = {
2454
3369
  if (keptPool.includes(ws(line))) continue;
2455
3370
  gone.push(line);
2456
3371
  }
3372
+ const table = eachTables(c.before, c.path);
3373
+ const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
3374
+ if (goneRows.length >= MIN_REMOVED_ROWS) {
3375
+ out2.push(
3376
+ makeFinding(RULE8, policy, {
3377
+ file: c.path,
3378
+ message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that now spreads from elsewhere, and they reappear nowhere in the change.`,
3379
+ evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3380
+ remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
3381
+ })
3382
+ );
3383
+ continue;
3384
+ }
2457
3385
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
2458
3386
  out2.push(
2459
3387
  makeFinding(RULE8, policy, {
@@ -2692,8 +3620,8 @@ function validate(r, where) {
2692
3620
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
2693
3621
  if (r.protected !== void 0) {
2694
3622
  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)}`);
3623
+ for (const [cat, globs3] of Object.entries(r.protected)) {
3624
+ if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
2697
3625
  }
2698
3626
  }
2699
3627
  if (r.signoff !== void 0) {
@@ -2725,10 +3653,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
2725
3653
  const version = normalizeVersion(r.version, where);
2726
3654
  validate(r, where);
2727
3655
  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;
3656
+ const globs3 = (list) => list?.map(normalizeGlob);
3657
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
2730
3658
  const userRules = r.rules ? Object.fromEntries(
2731
- Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
3659
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
2732
3660
  ) : void 0;
2733
3661
  return {
2734
3662
  version,
@@ -2738,7 +3666,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
2738
3666
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
2739
3667
  protected: mergeProtected(base.protected, userProtected),
2740
3668
  rules: mergeRules(base.rules, userRules),
2741
- ignore: globs2(r.ignore) ?? base.ignore,
3669
+ ignore: globs3(r.ignore) ?? base.ignore,
2742
3670
  signoff: {
2743
3671
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
2744
3672
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -3171,7 +4099,7 @@ import { createHash as createHash4 } from "node:crypto";
3171
4099
  import { join as join9 } from "node:path";
3172
4100
 
3173
4101
  // src/adapters/claude/changes.ts
3174
- import { execFileSync as execFileSync2 } from "node:child_process";
4102
+ import { execFileSync as execFileSync3 } from "node:child_process";
3175
4103
  import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3176
4104
  import { tmpdir } from "node:os";
3177
4105
  import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
@@ -3211,7 +4139,7 @@ function synthFileChange(displayPath, before, after) {
3211
4139
  writeFileSync(a, before ?? "");
3212
4140
  writeFileSync(b, after ?? "");
3213
4141
  try {
3214
- raw = execFileSync2("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
4142
+ raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
3215
4143
  encoding: "utf8",
3216
4144
  maxBuffer: 256 * 1024 * 1024
3217
4145
  });
@@ -3335,12 +4263,12 @@ function advanceTurnBaseline(cwd, sessionId) {
3335
4263
  import { createHash as createHash2 } from "node:crypto";
3336
4264
  import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
3337
4265
  import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
3338
- import { execFileSync as execFileSync3 } from "node:child_process";
4266
+ import { execFileSync as execFileSync4 } from "node:child_process";
3339
4267
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
3340
4268
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
3341
4269
  function gitDir2(cwd) {
3342
4270
  try {
3343
- const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
4271
+ const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
3344
4272
  cwd,
3345
4273
  encoding: "utf8",
3346
4274
  stdio: ["ignore", "pipe", "pipe"]
@@ -3985,7 +4913,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
3985
4913
  }
3986
4914
 
3987
4915
  // src/cli/init.ts
3988
- import { execFileSync as execFileSync4 } from "node:child_process";
4916
+ import { execFileSync as execFileSync5 } from "node:child_process";
3989
4917
  import { createHash as createHash5 } from "node:crypto";
3990
4918
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3991
4919
  import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
@@ -3994,7 +4922,7 @@ var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/
3994
4922
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3995
4923
  function gitConfig(cwd, key2) {
3996
4924
  try {
3997
- return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
4925
+ return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
3998
4926
  } catch {
3999
4927
  return null;
4000
4928
  }
@@ -4299,7 +5227,7 @@ var MANAGED_HOOKS = [
4299
5227
  ];
4300
5228
  function hooksDir(cwd) {
4301
5229
  try {
4302
- const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5230
+ const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4303
5231
  if (out2) return resolve2(cwd, out2);
4304
5232
  } catch {
4305
5233
  }
@@ -4591,14 +5519,14 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
4591
5519
  }
4592
5520
 
4593
5521
  // src/cli/verify.ts
4594
- import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
5522
+ import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
4595
5523
  import { createHash as createHash8 } from "node:crypto";
4596
5524
  import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
4597
5525
  import { tmpdir as tmpdir2 } from "node:os";
4598
5526
  import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
4599
5527
 
4600
5528
  // src/git/trusted.ts
4601
- import { execFileSync as execFileSync5 } from "node:child_process";
5529
+ import { execFileSync as execFileSync6 } from "node:child_process";
4602
5530
  import { createHash as createHash6 } from "node:crypto";
4603
5531
  import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
4604
5532
  import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
@@ -4607,7 +5535,7 @@ function trustedGitEnv() {
4607
5535
  }
4608
5536
  function gitDirOf(cwd) {
4609
5537
  try {
4610
- const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
5538
+ const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
4611
5539
  cwd,
4612
5540
  encoding: "utf8",
4613
5541
  stdio: ["ignore", "pipe", "pipe"],
@@ -4623,7 +5551,7 @@ function objectRewriteState(cwd) {
4623
5551
  const parts = [];
4624
5552
  try {
4625
5553
  parts.push(
4626
- "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
5554
+ "replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
4627
5555
  cwd,
4628
5556
  encoding: "utf8",
4629
5557
  stdio: ["ignore", "pipe", "pipe"],
@@ -4650,12 +5578,12 @@ function objectRewriteState(cwd) {
4650
5578
  }
4651
5579
 
4652
5580
  // src/fingerprint.ts
4653
- import { execFileSync as execFileSync6 } from "node:child_process";
5581
+ import { execFileSync as execFileSync7 } from "node:child_process";
4654
5582
  import { createHash as createHash7 } from "node:crypto";
4655
5583
  import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
4656
5584
  import { join as join12 } from "node:path";
4657
5585
  function treeFingerprint(cwd) {
4658
- const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
5586
+ const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
4659
5587
  cwd,
4660
5588
  encoding: "utf8",
4661
5589
  maxBuffer: 1 << 28,
@@ -4787,7 +5715,7 @@ var VERIFICATION_SURFACE = [
4787
5715
  "**/*.runsettings"
4788
5716
  ];
4789
5717
  function git2(args, cwd) {
4790
- return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
5718
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
4791
5719
  }
4792
5720
  function resolveBase(base, cwd) {
4793
5721
  assertRev(base);
@@ -4839,9 +5767,9 @@ function verifierInputs(cmd, atBase, policy) {
4839
5767
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
4840
5768
  if (tok && baseFiles.has(tok)) picked.add(tok);
4841
5769
  }
4842
- const globs2 = policy.verify?.inputs;
4843
- if (globs2 && globs2.length) {
4844
- for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
5770
+ const globs3 = policy.verify?.inputs;
5771
+ if (globs3 && globs3.length) {
5772
+ for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
4845
5773
  }
4846
5774
  return picked;
4847
5775
  }
@@ -4883,7 +5811,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
4883
5811
  if (e.type === "commit") {
4884
5812
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
4885
5813
  }
4886
- const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
5814
+ const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
4887
5815
  const out2 = join13(dest, e.path);
4888
5816
  mkdirSync6(dirname4(out2), { recursive: true });
4889
5817
  rmSync2(out2, { force: true });
@@ -5158,13 +6086,13 @@ function parseVerify(args) {
5158
6086
  }
5159
6087
 
5160
6088
  // src/cli/run.ts
5161
- import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
6089
+ import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
5162
6090
  import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
5163
6091
  import { resolve as resolve4 } from "node:path";
5164
6092
  var out = (s) => process.stdout.write(s + "\n");
5165
6093
  var err = (s) => process.stderr.write(s + "\n");
5166
6094
  function git3(args, cwd) {
5167
- return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
6095
+ return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
5168
6096
  }
5169
6097
  function nowTicks() {
5170
6098
  try {
@@ -5289,7 +6217,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
5289
6217
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
5290
6218
  if (opts.settle && opts.settle > 0) {
5291
6219
  try {
5292
- execFileSync8("sleep", [String(opts.settle)]);
6220
+ execFileSync9("sleep", [String(opts.settle)]);
5293
6221
  } catch {
5294
6222
  }
5295
6223
  }