tamperward 2.5.1 → 2.6.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 +712 -175
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -586,12 +586,12 @@ function matcher(glob) {
586
586
  }
587
587
  return m;
588
588
  }
589
- function matchesAny(path, globs3) {
590
- return !!globs3 && globs3.some((g) => matcher(g)(path));
589
+ function matchesAny(path, globs4) {
590
+ return !!globs4 && globs4.some((g) => matcher(g)(path));
591
591
  }
592
592
  function protectedCategory(path, policy) {
593
- for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
594
- if (matchesAny(path, globs3)) return cat;
593
+ for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
594
+ if (matchesAny(path, globs4)) return cat;
595
595
  }
596
596
  return null;
597
597
  }
@@ -607,9 +607,9 @@ function isIgnored(path, policy) {
607
607
  }
608
608
  function mergeProtected(base, user) {
609
609
  const out2 = { ...base };
610
- for (const [cat, globs3] of Object.entries(user ?? {})) {
610
+ for (const [cat, globs4] of Object.entries(user ?? {})) {
611
611
  const have = new Set(out2[cat] ?? []);
612
- out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
612
+ out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
613
613
  }
614
614
  return out2;
615
615
  }
@@ -654,6 +654,13 @@ function defaultPolicy(version = 1) {
654
654
  config: [
655
655
  "**/jest.config.*",
656
656
  "**/vitest.config.*",
657
+ // A `vitest.workspace.*` file REPLACES the root config's selection with the
658
+ // union of its projects, and a `vite.config.*` carrying a `test:` key is the
659
+ // vitest config when no vitest.config exists — both are where a spec can be
660
+ // dropped from the suite without a line of it changing. ADDITIVE, like every
661
+ // baseline widening (the policy-diff reads it as a strengthening).
662
+ "**/vitest.workspace.*",
663
+ "**/vite.config.*",
657
664
  "**/tsconfig*.json",
658
665
  "**/.eslintrc*",
659
666
  "**/eslint.config.*",
@@ -1141,6 +1148,25 @@ function trackedFiles(ctx) {
1141
1148
  cache2.set(ctx.cwd, files);
1142
1149
  return files;
1143
1150
  }
1151
+ var branchCache = /* @__PURE__ */ new Map();
1152
+ function defaultBranch(ctx) {
1153
+ if (!ctx?.cwd) return null;
1154
+ const hit = branchCache.get(ctx.cwd);
1155
+ if (hit !== void 0) return hit;
1156
+ let name = null;
1157
+ try {
1158
+ const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
1159
+ cwd: ctx.cwd,
1160
+ encoding: "utf8",
1161
+ stdio: ["ignore", "pipe", "pipe"]
1162
+ }).trim();
1163
+ name = ref.replace(/^origin\//, "") || null;
1164
+ } catch {
1165
+ name = null;
1166
+ }
1167
+ branchCache.set(ctx.cwd, name);
1168
+ return name;
1169
+ }
1144
1170
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1145
1171
  function containsProtected(token, policy, category, ctx) {
1146
1172
  const dir = dirOf(token);
@@ -1199,6 +1225,13 @@ function underKey(node, key2) {
1199
1225
  }
1200
1226
  return false;
1201
1227
  }
1228
+ function ownerKey(node) {
1229
+ const obj = node.parent;
1230
+ if (!obj || !ts2.isObjectLiteralExpression(obj)) return null;
1231
+ let q = obj.parent;
1232
+ while (q && (ts2.isParenthesizedExpression(q) || ts2.isAsExpression(q) || q.kind === ts2.SyntaxKind.SatisfiesExpression)) q = q.parent;
1233
+ return q && ts2.isPropertyAssignment(q) ? keyName(q.name) : null;
1234
+ }
1202
1235
  function asExpression(src) {
1203
1236
  const t = src.trim();
1204
1237
  return t.startsWith("{") ? `(${t})` : src;
@@ -1276,18 +1309,20 @@ function literals(e) {
1276
1309
  return { items, opaque };
1277
1310
  }
1278
1311
  function parseLists(src) {
1279
- const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1312
+ const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
1280
1313
  try {
1281
1314
  const sf = sourceOf(src);
1282
1315
  const resolve5 = resolverFor(sf);
1283
1316
  const visit = (node) => {
1284
1317
  if (ts2.isPropertyAssignment(node)) {
1285
1318
  const k = keyName(node.name);
1286
- if (k === "collectCoverageFrom") {
1319
+ const vitestCoverage = ownerKey(node) === "coverage";
1320
+ if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
1287
1321
  const { items, opaque } = literals(resolve5(node.initializer));
1288
1322
  res.collectFrom = items;
1323
+ res.collectKey = k === "include" ? "coverage.include" : k;
1289
1324
  if (opaque) res.opaqueCollect = true;
1290
- } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1325
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
1291
1326
  const { items, opaque } = literals(resolve5(node.initializer));
1292
1327
  res.ignore.push(...items);
1293
1328
  if (opaque) res.opaqueIgnore = true;
@@ -1300,6 +1335,43 @@ function parseLists(src) {
1300
1335
  }
1301
1336
  return res;
1302
1337
  }
1338
+ function parseSwitches(src) {
1339
+ const res = {};
1340
+ try {
1341
+ const sf = sourceOf(src);
1342
+ const bool = (e) => e.kind === ts2.SyntaxKind.TrueKeyword ? true : e.kind === ts2.SyntaxKind.FalseKeyword ? false : void 0;
1343
+ const visit = (node) => {
1344
+ if (ts2.isPropertyAssignment(node)) {
1345
+ const k = keyName(node.name);
1346
+ const owner = ownerKey(node);
1347
+ if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
1348
+ else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
1349
+ }
1350
+ ts2.forEachChild(node, visit);
1351
+ };
1352
+ visit(sf);
1353
+ } catch {
1354
+ }
1355
+ return res;
1356
+ }
1357
+ function sectionText(src, key2) {
1358
+ try {
1359
+ const sf = sourceOf(src);
1360
+ let out2 = "";
1361
+ const visit = (node) => {
1362
+ if (out2) return;
1363
+ if (ts2.isPropertyAssignment(node) && keyName(node.name) === key2 && ts2.isObjectLiteralExpression(node.initializer)) {
1364
+ out2 = node.initializer.getText(sf);
1365
+ return;
1366
+ }
1367
+ ts2.forEachChild(node, visit);
1368
+ };
1369
+ visit(sf);
1370
+ return out2;
1371
+ } catch {
1372
+ return "";
1373
+ }
1374
+ }
1303
1375
  var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1304
1376
  function compareMetrics(scope, before, after, out2) {
1305
1377
  if (!before) return;
@@ -1325,10 +1397,10 @@ function overrideTargetExists(key2, configPath, ctx) {
1325
1397
  }
1326
1398
  return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1327
1399
  }
1328
- function weakenings(before, after, configPath, ctx) {
1400
+ function weakenings(before, after, configPath, movedElsewhere, ctx) {
1329
1401
  const out2 = [];
1330
1402
  if (before.present && !after.present) {
1331
- out2.push("the coverage threshold gate was removed");
1403
+ if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
1332
1404
  return out2;
1333
1405
  }
1334
1406
  if (after.opaque && !after.global && after.paths.size === 0) return out2;
@@ -1355,16 +1427,45 @@ function weakenings(before, after, configPath, ctx) {
1355
1427
  }
1356
1428
  return out2;
1357
1429
  }
1358
- function narrowings(before, after) {
1430
+ var globs = (patterns) => {
1431
+ const ms = patterns.map((g) => {
1432
+ try {
1433
+ return picomatch2(norm(g), { dot: true });
1434
+ } catch {
1435
+ return () => false;
1436
+ }
1437
+ });
1438
+ return (p) => ms.some((m) => m(p));
1439
+ };
1440
+ var globList = (patterns) => {
1441
+ const pos = globs(patterns.filter((g) => !g.startsWith("!")));
1442
+ const neg = globs(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
1443
+ return (p) => pos(p) && !neg(p);
1444
+ };
1445
+ var CANONICAL_SOURCES = ["src/index.ts", "src/a.ts", "src/a.tsx", "src/lib/b.ts", "src/index.js", "src/a.jsx", "lib/a.js"];
1446
+ var NON_SOURCE = /(?:^|\/)(?:dist|build|out|coverage|node_modules|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)\.|\.d\.ts$/;
1447
+ function sourceSamples(configPath, policy, ctx) {
1448
+ const files = trackedFiles(ctx);
1449
+ if (!files) return CANONICAL_SOURCES;
1450
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1451
+ const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && !NON_SOURCE.test(f) && !isProtected(f, policy, "tests") && !isProtected(f, policy, "config")).map((f) => f.slice(root.length));
1452
+ return own.length ? own : CANONICAL_SOURCES;
1453
+ }
1454
+ function narrowings(before, after, sources) {
1359
1455
  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)}`);
1456
+ if (before.collectFrom && after.collectFrom && !after.opaqueCollect && !before.opaqueCollect) {
1457
+ const pb = globList(before.collectFrom);
1458
+ const pa = globList(after.collectFrom);
1459
+ const dropped = sources.filter((s) => pb(s) && !pa(s));
1460
+ if (dropped.length) {
1461
+ const shown = dropped.slice(0, 3).map((d) => JSON.stringify(d)).join(", ");
1462
+ out2.push(`${after.collectKey} no longer measures ${shown}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
1364
1463
  }
1365
1464
  const had2 = new Set(before.collectFrom);
1366
1465
  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)}`);
1466
+ if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g) && !dropped.some((d) => globs([g.slice(1)])(d))) {
1467
+ out2.push(`${after.collectKey} now exempts ${JSON.stringify(g)}`);
1468
+ }
1368
1469
  }
1369
1470
  }
1370
1471
  const had = new Set(before.ignore);
@@ -1373,75 +1474,153 @@ function narrowings(before, after) {
1373
1474
  }
1374
1475
  return out2;
1375
1476
  }
1477
+ function switchWeakenings(before, after) {
1478
+ const out2 = [];
1479
+ if (before.enabled === true && after.enabled === false) out2.push("coverage.enabled switched off \u2014 the thresholds are never checked");
1480
+ if (before.perFile === true && after.perFile !== true) out2.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
1481
+ return out2;
1482
+ }
1483
+ var NYC_KEYS = METRICS.map((m) => ({
1484
+ key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1485
+ label: m,
1486
+ weakens: "lower"
1487
+ }));
1488
+ var COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
1489
+ var CODECOV = /(?:^|\/)\.?codecov\.yml$/;
1376
1490
  var SIMPLE_FILES = [
1377
1491
  {
1378
1492
  // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1379
1493
  // [tool.coverage.report] (pyproject.toml)
1380
- file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1494
+ file: COVERAGE_PY,
1381
1495
  keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1382
1496
  removable: true
1383
1497
  },
1384
1498
  {
1385
1499
  // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1386
1500
  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
- })),
1501
+ keys: NYC_KEYS,
1392
1502
  removable: false
1393
1503
  },
1394
1504
  {
1395
- // codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
1505
+ // codecov: `target: 90%` is the floor (lowering weakens; `auto` is a floor that
1506
+ // follows the base commit, lower than any fixed number); `threshold: 2%` is the
1396
1507
  // permitted drop (raising weakens)
1397
- file: /(?:^|\/)\.?codecov\.yml$/,
1508
+ file: CODECOV,
1398
1509
  keys: [
1399
- { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1510
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
1400
1511
  { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1401
1512
  ],
1402
- removable: false
1513
+ removable: false,
1514
+ scoped: true
1403
1515
  }
1404
1516
  ];
1405
- function numbersOf(src, key2) {
1517
+ var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
1518
+ var numOf = (s) => s === "auto" ? "auto" : Number(s);
1519
+ function numbersOf(src, key2, scoped = false) {
1406
1520
  const out2 = [];
1521
+ const stack = [];
1407
1522
  for (const line of src.split("\n")) {
1523
+ let scope = "";
1524
+ if (scoped) {
1525
+ const m = line.match(/^(\s*)([\w-]+):/);
1526
+ if (m) {
1527
+ const ind = m[1].length;
1528
+ while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1529
+ stack.push({ ind, key: m[2] });
1530
+ }
1531
+ scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1532
+ }
1408
1533
  if (key2.global) {
1409
- for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
1534
+ for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
1410
1535
  } else {
1411
1536
  const m = line.match(key2);
1412
- if (m) out2.push(Number(m[1]));
1537
+ if (m) out2.push({ v: numOf(m[1]), scope });
1413
1538
  }
1414
1539
  }
1415
1540
  return out2;
1416
1541
  }
1417
- function simpleWeakenings(path, before, after) {
1542
+ function simpleWeakeningsOf(spec, before, after) {
1418
1543
  const out2 = [];
1419
- const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1420
- if (!spec) return out2;
1421
1544
  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;
1545
+ const bAll = numbersOf(before, k.key, spec.scoped);
1546
+ const aAll = numbersOf(after, k.key, spec.scoped);
1547
+ const scopes = [...new Set(bAll.map((n) => n.scope))];
1548
+ for (const scope of scopes) {
1549
+ const label = scope ? `${scope} ${k.label}` : k.label;
1550
+ const desc = (x, y) => x === "auto" ? -1 : y === "auto" ? 1 : lessThan(x, y) ? 1 : lessThan(y, x) ? -1 : 0;
1551
+ const b = bAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
1552
+ const a = aAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
1553
+ if (b.length === 0) continue;
1554
+ if (a.length === 0) {
1555
+ if (spec.removable) out2.push(`${label} removed (was ${b.join(", ")})`);
1556
+ continue;
1557
+ }
1558
+ if (k.weakens === "lower") {
1559
+ for (let i = 0; i < b.length; i++) {
1560
+ const av = a[Math.min(i, a.length - 1)];
1561
+ if (lessThan(av, b[i])) {
1562
+ out2.push(`${label} lowered ${b[i]} \u2192 ${av}`);
1563
+ break;
1564
+ }
1435
1565
  }
1566
+ } else {
1567
+ const bMax = b[0];
1568
+ const aMax = a[0];
1569
+ if (lessThan(bMax, aMax)) out2.push(`${label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1436
1570
  }
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
1571
  }
1442
1572
  }
1443
1573
  return out2;
1444
1574
  }
1575
+ function listEntries(src, key2) {
1576
+ const out2 = [];
1577
+ const lines = src.split("\n");
1578
+ for (let i = 0; i < lines.length; i++) {
1579
+ const m = lines[i].match(key2);
1580
+ if (!m) continue;
1581
+ let rest = m[1] ?? "";
1582
+ let j = i + 1;
1583
+ if (rest.trim().startsWith("[") && !rest.includes("]")) {
1584
+ while (j < lines.length && !lines[j].includes("]")) rest += "," + lines[j++];
1585
+ if (j < lines.length) rest += "," + lines[j];
1586
+ } else if (rest.trim() === "") {
1587
+ while (j < lines.length && /^\s+(?:-\s+)?\S/.test(lines[j])) rest += "," + lines[j++].replace(/^\s*-\s+/, "");
1588
+ }
1589
+ for (const e of rest.replace(/[[\]]/g, "").split(",")) {
1590
+ const v = e.trim().replace(/^(['"])(.*)\1$/, "$2");
1591
+ if (v) out2.push(v);
1592
+ }
1593
+ }
1594
+ return out2;
1595
+ }
1596
+ function simpleNarrowings(path, before, after) {
1597
+ const out2 = [];
1598
+ const spec = [
1599
+ [COVERAGE_PY, /^\s*omit\s*=\s*(.*)$/, "omit now exempts"],
1600
+ [CODECOV, /^ignore:\s*(.*)$/, "codecov now ignores"]
1601
+ ];
1602
+ for (const [file, key2, what] of spec) {
1603
+ if (!file.test(path)) continue;
1604
+ const had = new Set(listEntries(before, key2));
1605
+ for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
1606
+ }
1607
+ if (CODECOV.test(path)) {
1608
+ const off = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1609
+ const had = off(before);
1610
+ for (const s of off(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1611
+ }
1612
+ return out2;
1613
+ }
1614
+ function simpleWeakenings(path, before, after) {
1615
+ const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1616
+ const out2 = spec ? simpleWeakeningsOf(spec, before, after) : [];
1617
+ if ((path.split("/").pop() ?? path) === "package.json") {
1618
+ const b = sectionText(before, "nyc");
1619
+ const a = sectionText(after, "nyc");
1620
+ if (b && a) out2.push(...simpleWeakeningsOf({ file: /./, keys: NYC_KEYS, removable: false }, b, a).map((r) => `nyc ${r}`));
1621
+ }
1622
+ return out2;
1623
+ }
1445
1624
  var SWITCHES = [
1446
1625
  {
1447
1626
  re: /--passWithNoTests\b/,
@@ -1480,6 +1659,35 @@ var SWITCHES = [
1480
1659
  }
1481
1660
  ];
1482
1661
  var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1662
+ function gateMovedElsewhere(c, changes, policy) {
1663
+ const before = c.before ?? "";
1664
+ const others = changes.filter((o) => o.kind === "file" && o !== c && o.after != null && isProtected(o.path, policy, "config"));
1665
+ if (isJsConfig(c.path)) {
1666
+ const was = parseThresholds(before);
1667
+ if (!was.present) return false;
1668
+ return others.some((o) => {
1669
+ if (!isJsConfig(o.path)) return false;
1670
+ const now = parseThresholds(o.after);
1671
+ if (!now.present) return false;
1672
+ if (now.opaque) return true;
1673
+ return METRICS.every((m) => {
1674
+ const bv = was.global?.values[m];
1675
+ const av = now.global?.values[m];
1676
+ return bv === void 0 || av !== void 0 && av >= bv;
1677
+ });
1678
+ });
1679
+ }
1680
+ const spec = SIMPLE_FILES.find((s) => s.file.test(c.path));
1681
+ if (!spec) return false;
1682
+ return spec.keys.every((k) => {
1683
+ const b = numbersOf(before, k.key, spec.scoped).map((n) => n.v);
1684
+ if (b.length === 0) return true;
1685
+ return others.some((o) => {
1686
+ const a = numbersOf(o.after, k.key, spec.scoped).map((n) => n.v);
1687
+ return a.length > 0 && b.every((bv) => a.some((av) => k.weakens === "lower" ? !lessThan(av, bv) : !lessThan(bv, av)));
1688
+ });
1689
+ });
1690
+ }
1483
1691
  var coverageLowering = {
1484
1692
  id: RULE4,
1485
1693
  surface: ["file"],
@@ -1492,10 +1700,15 @@ var coverageLowering = {
1492
1700
  if (c.after != null && c.op !== "delete") {
1493
1701
  const reasons = [];
1494
1702
  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)));
1703
+ const moved = () => gateMovedElsewhere(c, changes, policy);
1704
+ const b = parseThresholds(c.before ?? "");
1705
+ const a = parseThresholds(c.after);
1706
+ reasons.push(...weakenings(b, a, c.path, b.present && !a.present && moved(), ctx));
1707
+ reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after), sourceSamples(c.path, policy, ctx)));
1708
+ reasons.push(...switchWeakenings(parseSwitches(c.before ?? ""), parseSwitches(c.after)));
1497
1709
  }
1498
1710
  reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1711
+ reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
1499
1712
  for (const reason of reasons) {
1500
1713
  out2.push(
1501
1714
  makeFinding(RULE4, policy, {
@@ -1509,7 +1722,7 @@ var coverageLowering = {
1509
1722
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1510
1723
  const before = c.before ?? "";
1511
1724
  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) {
1725
+ if (gated && !gateMovedElsewhere(c, changes, policy)) {
1513
1726
  out2.push(
1514
1727
  makeFinding(RULE4, policy, {
1515
1728
  file: c.path,
@@ -1606,8 +1819,33 @@ function canonical(core) {
1606
1819
  return s.replace(/\s+--\s*$/, "").trim();
1607
1820
  }
1608
1821
  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) {
1822
+ var SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
1823
+ var NEUTRALISING_SUFFIX = new RegExp(
1824
+ `\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
1825
+ );
1826
+ var TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
1827
+ var PATH_SHAPED = /\/|\.(?:test|spec)\./;
1828
+ function narrowedByPositional(args) {
1829
+ const toks = args.trim().split(/\s+/).filter(Boolean);
1830
+ for (let i = 0; i < toks.length; i++) {
1831
+ const t = toks[i];
1832
+ if (t === "--" || t.startsWith("-")) continue;
1833
+ const prev = toks[i - 1];
1834
+ if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
1835
+ if (/[<>|;&]/.test(t)) continue;
1836
+ if (PATH_SHAPED.test(t)) return true;
1837
+ }
1838
+ return false;
1839
+ }
1840
+ function checkKind(core) {
1841
+ if (!invokesCheck(core)) return null;
1842
+ const s = canonical(core);
1843
+ if (/\btamperward\b/.test(s)) return "gate";
1844
+ if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
1845
+ if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
1846
+ return "test";
1847
+ }
1848
+ function survives(removedCore, isUses, afterCores, addedCores) {
1611
1849
  if (isUses) {
1612
1850
  const ref = usesRef(removedCore);
1613
1851
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
@@ -1619,15 +1857,33 @@ function survives(removedCore, isUses, afterCores) {
1619
1857
  if (a === r) return { state: "kept" };
1620
1858
  if (a.startsWith(r + " ")) {
1621
1859
  const rest = a.slice(r.length);
1622
- if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1860
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
1623
1861
  else return { state: "kept" };
1624
1862
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1625
1863
  return { state: "kept" };
1864
+ } else {
1865
+ const w = a.match(TIMEOUT_WRAP);
1866
+ if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
1867
+ }
1868
+ }
1869
+ const kind = checkKind(removedCore);
1870
+ let respelled = false;
1871
+ for (const raw of addedCores) {
1872
+ if (!kind || checkKind(raw) !== kind) continue;
1873
+ const a = canonical(raw);
1874
+ if (a.match(TIMEOUT_WRAP)) {
1875
+ neutralised ??= raw;
1876
+ continue;
1626
1877
  }
1878
+ const args = a.slice(a.indexOf(" ") + 1 || a.length);
1879
+ if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
1880
+ else respelled = true;
1627
1881
  }
1882
+ if (respelled) return { state: "kept" };
1628
1883
  return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1629
1884
  }
1630
1885
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1886
+ var TRUTHY = Symbol("truthy");
1631
1887
  function lex(src) {
1632
1888
  const out2 = [];
1633
1889
  let i = 0;
@@ -1658,13 +1914,13 @@ function lex(src) {
1658
1914
  i += num[0].length;
1659
1915
  continue;
1660
1916
  }
1661
- const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1917
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
1662
1918
  if (op) {
1663
1919
  out2.push({ t: "op", v: op[0] });
1664
1920
  i += op[0].length;
1665
1921
  continue;
1666
1922
  }
1667
- const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1923
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
1668
1924
  if (id) {
1669
1925
  out2.push({ t: "id", v: id[0] });
1670
1926
  i += id[0].length;
@@ -1674,15 +1930,16 @@ function lex(src) {
1674
1930
  }
1675
1931
  return out2;
1676
1932
  }
1933
+ var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
1677
1934
  function foldConst(src) {
1678
1935
  const toks = lex(src);
1679
1936
  if (!toks) return void 0;
1680
1937
  let p = 0;
1681
1938
  const peek = () => toks[p];
1682
1939
  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);
1940
+ const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1685
1941
  const eq = (a, b) => {
1942
+ if (a === TRUTHY || b === TRUTHY) return void 0;
1686
1943
  if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1687
1944
  if (a === null && b === null) return true;
1688
1945
  const x = num(a);
@@ -1693,8 +1950,9 @@ function foldConst(src) {
1693
1950
  let l = and();
1694
1951
  while (eat("||")) {
1695
1952
  const r = and();
1696
- if (l === void 0 || r === void 0) return void 0;
1697
- l = truthy(l) ? l : r;
1953
+ if (l !== void 0 && truthy(l)) continue;
1954
+ if (l !== void 0) l = r;
1955
+ else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
1698
1956
  }
1699
1957
  return l;
1700
1958
  };
@@ -1702,8 +1960,9 @@ function foldConst(src) {
1702
1960
  let l = cmp();
1703
1961
  while (eat("&&")) {
1704
1962
  const r = cmp();
1705
- if (l === void 0 || r === void 0) return void 0;
1706
- l = truthy(l) ? r : l;
1963
+ if (l !== void 0 && !truthy(l)) continue;
1964
+ if (l !== void 0) l = r;
1965
+ else l = r !== void 0 && !truthy(r) ? false : void 0;
1707
1966
  }
1708
1967
  return l;
1709
1968
  };
@@ -1714,14 +1973,18 @@ function foldConst(src) {
1714
1973
  if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1715
1974
  p++;
1716
1975
  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 {
1976
+ if (l === void 0 || r === void 0) {
1977
+ l = void 0;
1978
+ continue;
1979
+ }
1980
+ if (t.v === "==" || t.v === "!=") {
1981
+ const e = eq(l, r);
1982
+ l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
1983
+ } else {
1721
1984
  const x = num(l);
1722
1985
  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;
1986
+ if (Number.isNaN(x) || Number.isNaN(y)) l = void 0;
1987
+ else l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
1725
1988
  }
1726
1989
  }
1727
1990
  };
@@ -1746,6 +2009,19 @@ function foldConst(src) {
1746
2009
  if (/^true$/i.test(t.v)) return true;
1747
2010
  if (/^false$/i.test(t.v)) return false;
1748
2011
  if (/^null$/i.test(t.v)) return null;
2012
+ if (eat("(")) {
2013
+ if (!eat(")")) {
2014
+ for (; ; ) {
2015
+ or();
2016
+ if (eat(")")) break;
2017
+ if (!eat(",")) return void 0;
2018
+ }
2019
+ }
2020
+ }
2021
+ while (eat("[")) {
2022
+ or();
2023
+ if (!eat("]")) return void 0;
2024
+ }
1749
2025
  return void 0;
1750
2026
  }
1751
2027
  return void 0;
@@ -1762,7 +2038,7 @@ function isAlwaysFalse(raw) {
1762
2038
  const e = expressionOf(raw);
1763
2039
  if (e === "") return false;
1764
2040
  const v = foldConst(e);
1765
- return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
2041
+ return v !== void 0 && !truthy(v);
1766
2042
  }
1767
2043
  function isTruthy(raw) {
1768
2044
  const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
@@ -1770,7 +2046,7 @@ function isTruthy(raw) {
1770
2046
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1771
2047
  if (!m) return false;
1772
2048
  const f = foldConst(m[1]);
1773
- return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
2049
+ return f !== void 0 && truthy(f);
1774
2050
  }
1775
2051
  var indentOf = (l) => l.match(/^\s*/)[0].length;
1776
2052
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
@@ -1784,7 +2060,21 @@ function stepOf(lines, i) {
1784
2060
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1785
2061
  return [s, e];
1786
2062
  }
2063
+ function jobOf(lines, i) {
2064
+ const ind = indentOf(lines[i]);
2065
+ let h = i - 1;
2066
+ while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
2067
+ if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
2068
+ let t = h - 1;
2069
+ while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
2070
+ if (t < 0 || !/^jobs:/.test(lines[t])) return null;
2071
+ const hi = indentOf(lines[h]);
2072
+ let e = i + 1;
2073
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
2074
+ return [h, e];
2075
+ }
1787
2076
  var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
2077
+ var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l) || /^\s*uses:\s*\S/.test(l));
1788
2078
  function runBlocks(lines) {
1789
2079
  const out2 = [];
1790
2080
  for (let i = 0; i < lines.length; i++) {
@@ -1802,13 +2092,28 @@ var NEUTRALISERS = [
1802
2092
  { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1803
2093
  { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1804
2094
  ];
2095
+ var LIST_KEYS = {
2096
+ branches: "branches",
2097
+ "branches-ignore": "branchesIgnore",
2098
+ "paths-ignore": "pathsIgnore",
2099
+ paths: "paths",
2100
+ types: "types"
2101
+ };
1805
2102
  function flowList(v) {
1806
2103
  const m = v.trim().match(/^\[(.*)\]$/);
1807
2104
  if (!m) return null;
1808
2105
  return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1809
2106
  }
1810
2107
  function parseTriggers(src) {
1811
- const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
2108
+ const t = {
2109
+ events: /* @__PURE__ */ new Set(),
2110
+ branches: /* @__PURE__ */ new Map(),
2111
+ branchesIgnore: /* @__PURE__ */ new Map(),
2112
+ pathsIgnore: /* @__PURE__ */ new Map(),
2113
+ paths: /* @__PURE__ */ new Map(),
2114
+ types: /* @__PURE__ */ new Map(),
2115
+ present: false
2116
+ };
1812
2117
  const lines = src.split("\n");
1813
2118
  const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1814
2119
  if (i < 0) return t;
@@ -1844,8 +2149,9 @@ function parseTriggers(src) {
1844
2149
  listKey = null;
1845
2150
  continue;
1846
2151
  }
1847
- if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1848
- listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
2152
+ const key2 = LIST_KEYS[kv[1]];
2153
+ if (event && key2) {
2154
+ listKey = key2;
1849
2155
  listInd = ind;
1850
2156
  const list = flowList(kv[2]);
1851
2157
  t[listKey].set(event, list ?? []);
@@ -1855,36 +2161,80 @@ function parseTriggers(src) {
1855
2161
  }
1856
2162
  var DEFAULT_BRANCH = /^(?:main|master)$/;
1857
2163
  var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1858
- function triggerNarrowings(before, after) {
2164
+ var CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
2165
+ var PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
2166
+ function ghGlob(g) {
2167
+ const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
2168
+ try {
2169
+ const r = new RegExp(`^${re}$`);
2170
+ return (p) => r.test(p);
2171
+ } catch {
2172
+ return () => false;
2173
+ }
2174
+ }
2175
+ function pathsMatchSource(globs4, sources) {
2176
+ const ms = globs4.map((g) => ({ neg: g.startsWith("!"), m: ghGlob(g.startsWith("!") ? g.slice(1) : g) }));
2177
+ return sources.some((s) => {
2178
+ let on = false;
2179
+ for (const { neg, m } of ms) if (m(s)) on = !neg;
2180
+ return on;
2181
+ });
2182
+ }
2183
+ var CODE_PROBES = ["src/index.ts", "src/index.js", "lib/index.js", "index.ts", "main.go", "src/main.py", "src/lib.rs", "packages/a/src/index.ts", "test/a.test.ts"];
2184
+ function sourceProbes(ctx) {
2185
+ const files = trackedFiles(ctx);
2186
+ if (!files) return CODE_PROBES;
2187
+ const code = files.filter((f) => langOf(f) !== null);
2188
+ return code.length ? code : CODE_PROBES;
2189
+ }
2190
+ function triggerNarrowings(before, after, opts) {
1859
2191
  const out2 = [];
1860
2192
  if (!before.present || !after.present) return out2;
1861
- for (const e of ["push", "pull_request", "pull_request_target"]) {
2193
+ const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
2194
+ const namesDefault = (l) => l.some(isDefault);
2195
+ for (const e of CODE_EVENTS) {
1862
2196
  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
2197
  }
1864
2198
  for (const [e, list] of after.pathsIgnore) {
1865
2199
  const had = before.pathsIgnore.get(e) ?? [];
1866
2200
  if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
1867
2201
  }
2202
+ for (const [e, list] of after.paths) {
2203
+ if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
2204
+ const had = before.paths.get(e);
2205
+ if (had && !pathsMatchSource(had, opts.sources)) continue;
2206
+ if (!pathsMatchSource(list, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list.join(", ")}]) \u2014 the workflow never runs on code`);
2207
+ }
2208
+ for (const [e, list] of after.types) {
2209
+ if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
2210
+ const had = before.types.get(e);
2211
+ if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
2212
+ if (!list.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
2213
+ }
1868
2214
  for (const [e, list] of after.branchesIgnore) {
1869
2215
  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`);
2216
+ if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1871
2217
  }
1872
2218
  for (const [e, was] of before.branches) {
1873
2219
  const now = after.branches.get(e);
1874
2220
  if (!now || !after.events.has(e)) continue;
1875
2221
  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));
2222
+ const lostDefault = namesDefault(was) && !namesDefault(now);
2223
+ const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
1878
2224
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1879
2225
  }
1880
2226
  return out2;
1881
2227
  }
2228
+ var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
1882
2229
  var ciTampering = {
1883
2230
  id: RULE5,
1884
2231
  surface: ["file"],
1885
2232
  certainty: "mechanical",
1886
- run(changes, policy) {
2233
+ run(changes, policy, _view, ctx) {
1887
2234
  const out2 = [];
2235
+ const afterByPath = /* @__PURE__ */ new Map();
2236
+ for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
2237
+ let triggerOpts = null;
1888
2238
  for (const c of changes) {
1889
2239
  if (c.kind !== "file") continue;
1890
2240
  if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
@@ -1907,6 +2257,10 @@ var ciTampering = {
1907
2257
  if (!coe && !cond) continue;
1908
2258
  const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1909
2259
  if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
2260
+ if (afterLines && !step && l.newLine != null) {
2261
+ const job = jobOf(afterLines, l.newLine - 1);
2262
+ if (job && !jobHasCheck(afterLines, job)) continue;
2263
+ }
1910
2264
  if (coe && isTruthy(coe[1])) {
1911
2265
  out2.push(
1912
2266
  makeFinding(RULE5, policy, {
@@ -1964,25 +2318,35 @@ var ciTampering = {
1964
2318
  );
1965
2319
  }
1966
2320
  if (c.before != null) {
1967
- for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
2321
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
2322
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
1968
2323
  out2.push(
1969
2324
  makeFinding(RULE5, policy, {
1970
2325
  file: c.path,
1971
2326
  message: `The workflow's triggers were narrowed: ${reason}.`,
1972
2327
  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."
2328
+ remediation: "Keep the workflow running on push and pull_request for the default branch and its source paths; a check that never runs is no check."
1974
2329
  })
1975
2330
  );
1976
2331
  }
1977
2332
  }
1978
2333
  }
1979
2334
  const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
2335
+ const addedCores = addedLines(c).map((l) => commandCore(l.content));
2336
+ for (const l of afterLines ?? []) {
2337
+ const reused = l.match(REUSED_WORKFLOW)?.[1];
2338
+ const content = reused ? afterByPath.get(reused) : void 0;
2339
+ if (content == null || reused === c.path) continue;
2340
+ const cores = content.split("\n").map(commandCore);
2341
+ afterCores.push(...cores);
2342
+ addedCores.push(...cores);
2343
+ }
1980
2344
  for (const l of removedLines(c)) {
1981
2345
  if (/^\s*#/.test(l.content)) continue;
1982
2346
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1983
2347
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
1984
2348
  if (!isUsesLine && !isCheckCommand) continue;
1985
- const s = survives(commandCore(l.content), isUsesLine, afterCores);
2349
+ const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
1986
2350
  if (s.state === "kept") continue;
1987
2351
  if (s.state === "neutralised") {
1988
2352
  out2.push(
@@ -1990,7 +2354,7 @@ var ciTampering = {
1990
2354
  file: c.path,
1991
2355
  message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1992
2356
  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."
2357
+ remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
1994
2358
  })
1995
2359
  );
1996
2360
  continue;
@@ -2015,7 +2379,7 @@ import { isAbsolute, join as join2, relative } from "node:path";
2015
2379
 
2016
2380
  // src/detectors/policy-diff.ts
2017
2381
  import { parse } from "yaml";
2018
- var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2382
+ var globs2 = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2019
2383
  function safeParse(src) {
2020
2384
  try {
2021
2385
  const v = parse(src);
@@ -2032,13 +2396,13 @@ function effective(raw) {
2032
2396
  const rules = { ...base.rules };
2033
2397
  for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
2034
2398
  const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
2035
- rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
2399
+ rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
2036
2400
  }
2037
- const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
2401
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs2(list)])) : void 0;
2038
2402
  return {
2039
2403
  version,
2040
2404
  rules,
2041
- ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
2405
+ ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
2042
2406
  protected: mergeProtected(base.protected, userProtected),
2043
2407
  requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
2044
2408
  ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
@@ -2670,11 +3034,11 @@ function settingsWeakening(before, after, sibling) {
2670
3034
  }
2671
3035
  if (now !== "none" || !declares(after, event)) continue;
2672
3036
  if (before !== null && declares(before, event)) continue;
2673
- const empty = (eventEntries(after, event) ?? []).length === 0;
3037
+ const empty2 = (eventEntries(after, event) ?? []).length === 0;
2674
3038
  const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
2675
- if (empty || shadowed) {
3039
+ if (empty2 || shadowed) {
2676
3040
  reasons.push(
2677
- `${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
3041
+ `${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
2678
3042
  );
2679
3043
  }
2680
3044
  }
@@ -2971,67 +3335,147 @@ function keyName2(name) {
2971
3335
  if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2972
3336
  return null;
2973
3337
  }
3338
+ var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
2974
3339
  function literals2(e) {
2975
- if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
3340
+ if (isStr(e)) return { items: [e.text], opaque: false };
2976
3341
  if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2977
3342
  const items = [];
2978
3343
  let opaque = false;
2979
3344
  for (const el of e.elements) {
2980
- if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
3345
+ if (isStr(el)) items.push(el.text);
2981
3346
  else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2982
3347
  items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2983
3348
  } else opaque = true;
2984
3349
  }
2985
3350
  return { items, opaque };
2986
3351
  }
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;
3352
+ function ownerKey2(node) {
3353
+ const obj = node.parent;
3354
+ if (!obj || !ts3.isObjectLiteralExpression(obj)) return null;
3355
+ let q = obj.parent;
3356
+ while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
3357
+ return q && ts3.isPropertyAssignment(q) ? keyName2(q.name) : null;
2992
3358
  }
2993
3359
  function asExpression2(src) {
2994
3360
  const t = src.trim();
2995
3361
  return t.startsWith("{") ? `(${t})` : src;
2996
3362
  }
2997
- function runnerOf(path) {
3363
+ function runnerOf(path, content) {
2998
3364
  const base = path.split("/").pop() ?? path;
2999
3365
  if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
3000
- if (/^vitest\.config\./.test(base)) return "vitest";
3366
+ if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
3367
+ if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
3368
+ return null;
3369
+ }
3370
+ var isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
3371
+ var empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
3372
+ var stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
3373
+ function dirValue(e) {
3374
+ if (!isStr(e)) return { dir: null, opaque: true };
3375
+ const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
3376
+ if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
3377
+ return { dir: d, opaque: false };
3378
+ }
3379
+ function collect(root, runner) {
3380
+ const sel = empty();
3381
+ const seen = /* @__PURE__ */ new Set();
3382
+ const take = (key2, e) => {
3383
+ const { items, opaque } = literals2(e);
3384
+ if (opaque || seen.has(key2)) sel.opaque = true;
3385
+ seen.add(key2);
3386
+ sel.present = true;
3387
+ return items.map(stripRootDir);
3388
+ };
3389
+ const ignores = (key2, e) => {
3390
+ const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
3391
+ sel.ignore = [...sel.ignore ?? [], ...entries];
3392
+ };
3393
+ const projects = (e) => {
3394
+ sel.present = true;
3395
+ if (!ts3.isArrayLiteralExpression(e)) {
3396
+ sel.opaque = true;
3397
+ return;
3398
+ }
3399
+ const list = [];
3400
+ for (const el of e.elements) {
3401
+ if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
3402
+ else sel.opaque = true;
3403
+ }
3404
+ sel.projects = list;
3405
+ if (list.some((p) => p.opaque)) sel.opaque = true;
3406
+ };
3407
+ const visit = (node) => {
3408
+ if (ts3.isPropertyAssignment(node)) {
3409
+ const k = keyName2(node.name);
3410
+ if (runner === "jest") {
3411
+ if (k === "testMatch") sel.include = take(k, node.initializer);
3412
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
3413
+ else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
3414
+ else if (k === "roots") sel.roots = take(k, node.initializer);
3415
+ else if (k === "rootDir") {
3416
+ const { dir, opaque } = dirValue(node.initializer);
3417
+ sel.present = true;
3418
+ if (opaque) sel.opaque = true;
3419
+ else sel.base = dir;
3420
+ } else if (k === "projects") {
3421
+ projects(node.initializer);
3422
+ return;
3423
+ }
3424
+ } else if (ownerKey2(node) === "test") {
3425
+ if (k === "include") sel.include = take(k, node.initializer);
3426
+ else if (k === "exclude") ignores(k, node.initializer);
3427
+ else if (k === "dir") {
3428
+ const { dir, opaque } = dirValue(node.initializer);
3429
+ sel.present = true;
3430
+ if (opaque) sel.opaque = true;
3431
+ else sel.base = dir;
3432
+ } else if (k === "projects") {
3433
+ projects(node.initializer);
3434
+ return;
3435
+ }
3436
+ }
3437
+ }
3438
+ ts3.forEachChild(node, visit);
3439
+ };
3440
+ visit(root);
3441
+ return sel;
3442
+ }
3443
+ function workspaceArray(sf) {
3444
+ for (const st of sf.statements) {
3445
+ let e;
3446
+ if (ts3.isExportAssignment(st)) e = st.expression;
3447
+ else if (ts3.isExpressionStatement(st) && ts3.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
3448
+ if (!e) continue;
3449
+ while (ts3.isParenthesizedExpression(e) || ts3.isAsExpression(e) || e.kind === ts3.SyntaxKind.SatisfiesExpression) {
3450
+ e = e.expression;
3451
+ }
3452
+ if (ts3.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
3453
+ return ts3.isArrayLiteralExpression(e) ? e : null;
3454
+ }
3001
3455
  return null;
3002
3456
  }
3003
- function parseSelection(src, runner) {
3004
- const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
3457
+ function parseSelection(src, runner, path = "") {
3458
+ let sel = empty();
3005
3459
  try {
3006
3460
  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);
3461
+ if (runner === "vitest" && isWorkspaceFile(path)) {
3462
+ const arr = workspaceArray(sf);
3463
+ if (arr) {
3464
+ const list = [];
3465
+ for (const el of arr.elements) {
3466
+ if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
3467
+ else sel.opaque = true;
3025
3468
  }
3026
- }
3027
- ts3.forEachChild(node, visit);
3028
- };
3029
- visit(sf);
3469
+ sel.projects = list;
3470
+ sel.present = true;
3471
+ if (list.some((p) => p.opaque)) sel.opaque = true;
3472
+ } else if (src.trim()) sel.opaque = true;
3473
+ } else sel = collect(sf, runner);
3030
3474
  } catch {
3031
3475
  }
3032
3476
  return sel;
3033
3477
  }
3034
- var globs2 = (patterns) => {
3478
+ var globs3 = (patterns) => {
3035
3479
  const ms = patterns.map((g) => {
3036
3480
  try {
3037
3481
  return picomatch3(g, { dot: true });
@@ -3041,6 +3485,11 @@ var globs2 = (patterns) => {
3041
3485
  });
3042
3486
  return (path) => ms.some((m) => m(path));
3043
3487
  };
3488
+ var globList2 = (patterns) => {
3489
+ const pos = globs3(patterns.filter((g) => !g.startsWith("!")));
3490
+ const neg = globs3(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
3491
+ return (path) => pos(path) && !neg(path);
3492
+ };
3044
3493
  var regexes = (patterns) => {
3045
3494
  const rs = patterns.flatMap((p) => {
3046
3495
  try {
@@ -3051,29 +3500,44 @@ var regexes = (patterns) => {
3051
3500
  });
3052
3501
  return (path) => rs.some((r) => r.test("/" + path));
3053
3502
  };
3503
+ var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
3054
3504
  function predicate(sel, runner) {
3505
+ if (sel.projects) {
3506
+ const ps = sel.projects.map((p) => predicate(p, runner));
3507
+ return {
3508
+ selects: (p) => ps.some((x) => x.selects(p)),
3509
+ why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
3510
+ };
3511
+ }
3055
3512
  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);
3513
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({ pattern, key: runner === "jest" ? "testPathIgnorePatterns" : "test.exclude" }));
3514
+ const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
3515
+ const ignoredBy = (p) => ignoreList.find((e) => (runner === "jest" ? regexes([e.pattern]) : globs3([e.pattern]))(p));
3516
+ const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
3517
+ const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
3518
+ const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
3059
3519
  return {
3060
- selects: (p) => included(p) && !ignored(p),
3520
+ selects: (p) => {
3521
+ const r = rebase(p);
3522
+ return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
3523
+ },
3061
3524
  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
- }
3525
+ const r = rebase(p);
3526
+ const baseKey = runner === "jest" ? "rootDir" : "test.dir";
3527
+ if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
3528
+ if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
3529
+ const hit = ignoredBy(r);
3530
+ if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
3067
3531
  const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3068
3532
  return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3069
3533
  }
3070
3534
  };
3071
3535
  }
3072
3536
  function suiteNarrowings(before, after, path, samples) {
3073
- const runner = runnerOf(path);
3537
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
3074
3538
  if (!runner) return [];
3075
- const b = parseSelection(before ?? "", runner);
3076
- const a = parseSelection(after, runner);
3539
+ const b = parseSelection(before ?? "", runner, path);
3540
+ const a = parseSelection(after, runner, path);
3077
3541
  if (!a.present || a.opaque) return [];
3078
3542
  const pb = predicate(b, runner);
3079
3543
  const pa = predicate(a, runner);
@@ -3105,6 +3569,18 @@ function calleeName(expr) {
3105
3569
  return null;
3106
3570
  }
3107
3571
  var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
3572
+ var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
3573
+ function templateRows(node) {
3574
+ const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3575
+ return Math.max(rows, 1);
3576
+ }
3577
+ function hasSubstantiveBody(call) {
3578
+ const fn = call.arguments.find((a) => ts4.isArrowFunction(a) || ts4.isFunctionExpression(a));
3579
+ if (!fn) return true;
3580
+ const body = fn.body;
3581
+ const text = ts4.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
3582
+ return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
3583
+ }
3108
3584
  function eachRows(call) {
3109
3585
  const arg = call.arguments[0];
3110
3586
  if (!arg) return { n: 1, open: true };
@@ -3119,7 +3595,7 @@ function eachRows(call) {
3119
3595
  }
3120
3596
  return { n: 1, open: true };
3121
3597
  }
3122
- function countTests(src, path = "spec.ts") {
3598
+ function countTests(src, path = "spec.ts", substantiveOnly = false) {
3123
3599
  const lang = langOf(path);
3124
3600
  if (lang && lang !== "js") {
3125
3601
  const re = TEST_DEFS[lang];
@@ -3130,23 +3606,33 @@ function countTests(src, path = "spec.ts") {
3130
3606
  const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
3131
3607
  let n = 0;
3132
3608
  let open = false;
3133
- const visit = (node) => {
3609
+ const visit = (node, mult) => {
3134
3610
  if (ts4.isCallExpression(node)) {
3611
+ const head = node.expression;
3612
+ if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
3613
+ const r = eachRows(head);
3614
+ if (r.open) open = true;
3615
+ for (const a of node.arguments) visit(a, mult * r.n);
3616
+ return;
3617
+ }
3618
+ if (ts4.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
3619
+ for (const a of node.arguments) visit(a, mult * templateRows(head));
3620
+ return;
3621
+ }
3135
3622
  if (isEachOf(node.expression)) {
3136
3623
  const r = eachRows(node);
3137
- n += r.n;
3624
+ n += r.n * mult;
3138
3625
  if (r.open) open = true;
3139
3626
  } else {
3140
3627
  const name = calleeName(node.expression);
3141
- if (name === "it" || name === "test") n++;
3628
+ if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
3142
3629
  }
3143
3630
  } 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);
3631
+ n += templateRows(node) * mult;
3146
3632
  }
3147
- ts4.forEachChild(node, visit);
3633
+ ts4.forEachChild(node, (c) => visit(c, mult));
3148
3634
  };
3149
- visit(sf);
3635
+ visit(sf, 1);
3150
3636
  return { min: n, open };
3151
3637
  }
3152
3638
  function countTestBlocks(src, path = "spec.ts") {
@@ -3161,7 +3647,7 @@ function significantLines(src, path) {
3161
3647
  }
3162
3648
  return out2;
3163
3649
  }
3164
- var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
3650
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
3165
3651
  function runnerSamples(configPath, policy, ctx) {
3166
3652
  const files = trackedFiles(ctx);
3167
3653
  if (!files) return CANONICAL_SAMPLES;
@@ -3169,6 +3655,34 @@ function runnerSamples(configPath, policy, ctx) {
3169
3655
  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
3656
  return own.length ? own : CANONICAL_SAMPLES;
3171
3657
  }
3658
+ function scriptNarrowings(before, after, path) {
3659
+ if ((path.split("/").pop() ?? path) !== "package.json") return [];
3660
+ const scriptsOf = (src) => {
3661
+ try {
3662
+ const s = JSON.parse(src ?? "{}").scripts ?? {};
3663
+ return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
3664
+ } catch {
3665
+ return {};
3666
+ }
3667
+ };
3668
+ const was = scriptsOf(before);
3669
+ const now = scriptsOf(after);
3670
+ const out2 = [];
3671
+ for (const [name, cmd] of Object.entries(now)) {
3672
+ if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
3673
+ const prev = was[name] ?? "";
3674
+ const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
3675
+ for (const m of cmd.matchAll(flags)) {
3676
+ const flag = m[0].trim();
3677
+ if (!flag) continue;
3678
+ const one = new RegExp(SUITE_NARROWING_FLAGS.source);
3679
+ if (one.test(prev) && prev.includes(flag)) continue;
3680
+ out2.push({ script: name, flag, before: prev, after: cmd });
3681
+ break;
3682
+ }
3683
+ }
3684
+ return out2;
3685
+ }
3172
3686
  function joinRoot(root, to) {
3173
3687
  const parts = root ? root.split("/") : [];
3174
3688
  for (const seg of to.replace(/\/+$/, "").split("/")) {
@@ -3200,11 +3714,11 @@ var testDeletion = {
3200
3714
  if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3201
3715
  if (c.op === "add") {
3202
3716
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
3203
- addedTestBlocks += countTestBlocks(c.after, c.path);
3717
+ addedTestBlocks += countTests(c.after, c.path, true).min;
3204
3718
  } else if (c.before != null) {
3205
3719
  const before = significantLines(c.before, c.path);
3206
3720
  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));
3721
+ addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
3208
3722
  }
3209
3723
  }
3210
3724
  const isRelocation = (removed, blocksRemoved) => {
@@ -3251,7 +3765,7 @@ var testDeletion = {
3251
3765
  })
3252
3766
  );
3253
3767
  }
3254
- } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
3768
+ } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
3255
3769
  for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3256
3770
  out2.push(
3257
3771
  makeFinding(RULE7, policy, {
@@ -3262,6 +3776,16 @@ var testDeletion = {
3262
3776
  })
3263
3777
  );
3264
3778
  }
3779
+ for (const n of scriptNarrowings(c.before, c.after, c.path)) {
3780
+ out2.push(
3781
+ makeFinding(RULE7, policy, {
3782
+ file: c.path,
3783
+ message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
3784
+ evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
3785
+ remediation: "Keep the test script running the whole suite; a runner flag that selects fewer specs removes the rest from every run."
3786
+ })
3787
+ );
3788
+ }
3265
3789
  }
3266
3790
  } else {
3267
3791
  let root = "";
@@ -3275,15 +3799,25 @@ var testDeletion = {
3275
3799
  const inRepo = (t) => root ? `${root}/${t}` : t;
3276
3800
  const listing = trackedFiles(ctx);
3277
3801
  const testToks = toks.filter((t) => {
3278
- if (!isProtected(t, policy, "tests")) return false;
3802
+ const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
3803
+ const probe = resolved ?? t;
3804
+ if (!isProtected(probe, policy, "tests")) return false;
3279
3805
  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);
3806
+ const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
3807
+ if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
3808
+ return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
3809
+ });
3810
+ const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
3811
+ const dirToks = toks.filter((t) => {
3812
+ if (t.startsWith("-") || testToks.includes(t)) return false;
3813
+ const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
3814
+ if (!whole && /[*?{}[\]]/.test(t)) return false;
3815
+ if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
3816
+ const dir = whole ? root : joinRoot(root, t);
3817
+ if (dir === null) return false;
3818
+ if (dir === "") return rootHasProtected();
3819
+ return containsProtected(dir, policy, "tests", ctx);
3283
3820
  });
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
3821
  if (testToks.length === 0 && dirToks.length === 0) continue;
3288
3822
  const named = [...testToks, ...dirToks];
3289
3823
  const cmd = toks[0] ?? "";
@@ -3294,8 +3828,11 @@ var testDeletion = {
3294
3828
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3295
3829
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3296
3830
  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(" -- "));
3831
+ const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
3832
+ const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
3833
+ if (!rev || rev === "HEAD") return false;
3834
+ if (src || seg.includes(" -- ")) return true;
3835
+ return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
3299
3836
  })();
3300
3837
  if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
3301
3838
  why = "rm deletes a test file";
@@ -3350,7 +3887,7 @@ var MIN_REMOVED_LINES = 3;
3350
3887
  var MIN_REMOVED_ROWS = 2;
3351
3888
  var ws = (s) => s.replace(/\s+/g, "");
3352
3889
  function eachTables(src, path) {
3353
- const rows = /* @__PURE__ */ new Set();
3890
+ const rows = /* @__PURE__ */ new Map();
3354
3891
  let open = false;
3355
3892
  if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3356
3893
  try {
@@ -3364,10 +3901,9 @@ function eachTables(src, path) {
3364
3901
  open = true;
3365
3902
  continue;
3366
3903
  }
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
- }
3904
+ const text = el.getText(sf).replace(/\s*\n\s*/g, " ");
3905
+ const key2 = ws(text);
3906
+ if (key2.length >= 3) rows.set(key2, text);
3371
3907
  }
3372
3908
  } else open = true;
3373
3909
  }
@@ -3422,12 +3958,13 @@ var testContentRemoval = {
3422
3958
  gone.push(line);
3423
3959
  }
3424
3960
  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))) : [];
3961
+ const tableAfter = eachTables(c.after, c.path);
3962
+ const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
3426
3963
  if (goneRows.length >= MIN_REMOVED_ROWS) {
3427
3964
  out2.push(
3428
3965
  makeFinding(RULE8, policy, {
3429
3966
  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.`,
3967
+ message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
3431
3968
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3432
3969
  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
3970
  })
@@ -3672,8 +4209,8 @@ function validate(r, where) {
3672
4209
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
3673
4210
  if (r.protected !== void 0) {
3674
4211
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
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)}`);
4212
+ for (const [cat, globs4] of Object.entries(r.protected)) {
4213
+ if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
3677
4214
  }
3678
4215
  }
3679
4216
  if (r.signoff !== void 0) {
@@ -3705,10 +4242,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
3705
4242
  const version = normalizeVersion(r.version, where);
3706
4243
  validate(r, where);
3707
4244
  const base = defaultPolicy(version);
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;
4245
+ const globs4 = (list) => list?.map(normalizeGlob);
4246
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs4(list) ?? []])) : void 0;
3710
4247
  const userRules = r.rules ? Object.fromEntries(
3711
- Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
4248
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
3712
4249
  ) : void 0;
3713
4250
  return {
3714
4251
  version,
@@ -3718,7 +4255,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
3718
4255
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
3719
4256
  protected: mergeProtected(base.protected, userProtected),
3720
4257
  rules: mergeRules(base.rules, userRules),
3721
- ignore: globs3(r.ignore) ?? base.ignore,
4258
+ ignore: globs4(r.ignore) ?? base.ignore,
3722
4259
  signoff: {
3723
4260
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
3724
4261
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -5863,9 +6400,9 @@ function verifierInputs(cmd, atBase, policy) {
5863
6400
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
5864
6401
  if (tok && baseFiles.has(tok)) picked.add(tok);
5865
6402
  }
5866
- const globs3 = policy.verify?.inputs;
5867
- if (globs3 && globs3.length) {
5868
- for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
6403
+ const globs4 = policy.verify?.inputs;
6404
+ if (globs4 && globs4.length) {
6405
+ for (const rel of atBase) if (matchesAny(rel, globs4)) picked.add(rel);
5869
6406
  }
5870
6407
  return picked;
5871
6408
  }