tamperward 2.5.0 → 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 +862 -229
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -330,26 +330,52 @@ function diffWorktree(opts = {}) {
330
330
  (c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
331
331
  );
332
332
  }
333
+ function addOf(rel, cwd) {
334
+ const after = fromDisk(rel, cwd);
335
+ return {
336
+ kind: "file",
337
+ path: rel,
338
+ oldPath: null,
339
+ op: "add",
340
+ before: null,
341
+ after,
342
+ binary: false,
343
+ hunks: after == null ? [] : addHunks(after)
344
+ };
345
+ }
333
346
  function untrackedAdds(opts = {}, keep, exclude) {
334
347
  const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
335
- return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => {
336
- const after = fromDisk(rel, opts.cwd);
337
- return {
338
- kind: "file",
339
- path: rel,
340
- oldPath: null,
341
- op: "add",
342
- before: null,
343
- after,
344
- binary: false,
345
- hunks: after == null ? [] : addHunks(after)
346
- };
347
- });
348
+ return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => addOf(rel, opts.cwd));
349
+ }
350
+ var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
351
+ var IGNORED_LS = ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"];
352
+ function underSkippedDir(rel) {
353
+ return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
354
+ }
355
+ function ignoredPaths(opts = {}, keep) {
356
+ const files = [];
357
+ const dirs = [];
358
+ for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
359
+ if (underSkippedDir(entry)) continue;
360
+ if (entry.endsWith("/")) dirs.push(entry);
361
+ else files.push(entry);
362
+ }
363
+ if (dirs.length > 0) {
364
+ const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
365
+ for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
366
+ }
367
+ return files.filter(keep);
348
368
  }
349
- function diffWorktreeWithUntracked(opts = {}) {
369
+ function ignoredAdds(opts = {}, keep, exclude) {
370
+ return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
371
+ }
372
+ function diffWorktreeWithUntracked(opts = {}, keepIgnored) {
350
373
  const tracked = diffWorktree(opts);
351
374
  const seen = new Set(tracked.filter((c) => c.kind === "file").map((c) => c.path));
352
- return [...tracked, ...untrackedAdds(opts, void 0, seen)];
375
+ const untracked = untrackedAdds(opts, void 0, seen);
376
+ for (const c of untracked) if (c.kind === "file") seen.add(c.path);
377
+ const ignored = keepIgnored ? ignoredAdds(opts, keepIgnored, seen) : [];
378
+ return [...tracked, ...untracked, ...ignored];
353
379
  }
354
380
  function hiddenTrackedPaths(opts = {}) {
355
381
  const out2 = [];
@@ -560,12 +586,12 @@ function matcher(glob) {
560
586
  }
561
587
  return m;
562
588
  }
563
- function matchesAny(path, globs3) {
564
- return !!globs3 && globs3.some((g) => matcher(g)(path));
589
+ function matchesAny(path, globs4) {
590
+ return !!globs4 && globs4.some((g) => matcher(g)(path));
565
591
  }
566
592
  function protectedCategory(path, policy) {
567
- for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
568
- if (matchesAny(path, globs3)) return cat;
593
+ for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
594
+ if (matchesAny(path, globs4)) return cat;
569
595
  }
570
596
  return null;
571
597
  }
@@ -581,9 +607,9 @@ function isIgnored(path, policy) {
581
607
  }
582
608
  function mergeProtected(base, user) {
583
609
  const out2 = { ...base };
584
- for (const [cat, globs3] of Object.entries(user ?? {})) {
610
+ for (const [cat, globs4] of Object.entries(user ?? {})) {
585
611
  const have = new Set(out2[cat] ?? []);
586
- out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
612
+ out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
587
613
  }
588
614
  return out2;
589
615
  }
@@ -628,6 +654,13 @@ function defaultPolicy(version = 1) {
628
654
  config: [
629
655
  "**/jest.config.*",
630
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.*",
631
664
  "**/tsconfig*.json",
632
665
  "**/.eslintrc*",
633
666
  "**/eslint.config.*",
@@ -1115,6 +1148,25 @@ function trackedFiles(ctx) {
1115
1148
  cache2.set(ctx.cwd, files);
1116
1149
  return files;
1117
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
+ }
1118
1170
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1119
1171
  function containsProtected(token, policy, category, ctx) {
1120
1172
  const dir = dirOf(token);
@@ -1173,6 +1225,13 @@ function underKey(node, key2) {
1173
1225
  }
1174
1226
  return false;
1175
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
+ }
1176
1235
  function asExpression(src) {
1177
1236
  const t = src.trim();
1178
1237
  return t.startsWith("{") ? `(${t})` : src;
@@ -1250,18 +1309,20 @@ function literals(e) {
1250
1309
  return { items, opaque };
1251
1310
  }
1252
1311
  function parseLists(src) {
1253
- const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1312
+ const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
1254
1313
  try {
1255
1314
  const sf = sourceOf(src);
1256
1315
  const resolve5 = resolverFor(sf);
1257
1316
  const visit = (node) => {
1258
1317
  if (ts2.isPropertyAssignment(node)) {
1259
1318
  const k = keyName(node.name);
1260
- if (k === "collectCoverageFrom") {
1319
+ const vitestCoverage = ownerKey(node) === "coverage";
1320
+ if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
1261
1321
  const { items, opaque } = literals(resolve5(node.initializer));
1262
1322
  res.collectFrom = items;
1323
+ res.collectKey = k === "include" ? "coverage.include" : k;
1263
1324
  if (opaque) res.opaqueCollect = true;
1264
- } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1325
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
1265
1326
  const { items, opaque } = literals(resolve5(node.initializer));
1266
1327
  res.ignore.push(...items);
1267
1328
  if (opaque) res.opaqueIgnore = true;
@@ -1274,6 +1335,43 @@ function parseLists(src) {
1274
1335
  }
1275
1336
  return res;
1276
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
+ }
1277
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;
1278
1376
  function compareMetrics(scope, before, after, out2) {
1279
1377
  if (!before) return;
@@ -1299,10 +1397,10 @@ function overrideTargetExists(key2, configPath, ctx) {
1299
1397
  }
1300
1398
  return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1301
1399
  }
1302
- function weakenings(before, after, configPath, ctx) {
1400
+ function weakenings(before, after, configPath, movedElsewhere, ctx) {
1303
1401
  const out2 = [];
1304
1402
  if (before.present && !after.present) {
1305
- out2.push("the coverage threshold gate was removed");
1403
+ if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
1306
1404
  return out2;
1307
1405
  }
1308
1406
  if (after.opaque && !after.global && after.paths.size === 0) return out2;
@@ -1329,16 +1427,45 @@ function weakenings(before, after, configPath, ctx) {
1329
1427
  }
1330
1428
  return out2;
1331
1429
  }
1332
- 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) {
1333
1455
  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)}`);
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)` : ""}`);
1338
1463
  }
1339
1464
  const had2 = new Set(before.collectFrom);
1340
1465
  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)}`);
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
+ }
1342
1469
  }
1343
1470
  }
1344
1471
  const had = new Set(before.ignore);
@@ -1347,75 +1474,153 @@ function narrowings(before, after) {
1347
1474
  }
1348
1475
  return out2;
1349
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$/;
1350
1490
  var SIMPLE_FILES = [
1351
1491
  {
1352
1492
  // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1353
1493
  // [tool.coverage.report] (pyproject.toml)
1354
- file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1494
+ file: COVERAGE_PY,
1355
1495
  keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1356
1496
  removable: true
1357
1497
  },
1358
1498
  {
1359
1499
  // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1360
1500
  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
- })),
1501
+ keys: NYC_KEYS,
1366
1502
  removable: false
1367
1503
  },
1368
1504
  {
1369
- // 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
1370
1507
  // permitted drop (raising weakens)
1371
- file: /(?:^|\/)\.?codecov\.yml$/,
1508
+ file: CODECOV,
1372
1509
  keys: [
1373
- { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1510
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
1374
1511
  { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1375
1512
  ],
1376
- removable: false
1513
+ removable: false,
1514
+ scoped: true
1377
1515
  }
1378
1516
  ];
1379
- 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) {
1380
1520
  const out2 = [];
1521
+ const stack = [];
1381
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
+ }
1382
1533
  if (key2.global) {
1383
- 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 });
1384
1535
  } else {
1385
1536
  const m = line.match(key2);
1386
- if (m) out2.push(Number(m[1]));
1537
+ if (m) out2.push({ v: numOf(m[1]), scope });
1387
1538
  }
1388
1539
  }
1389
1540
  return out2;
1390
1541
  }
1391
- function simpleWeakenings(path, before, after) {
1542
+ function simpleWeakeningsOf(spec, before, after) {
1392
1543
  const out2 = [];
1393
- const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1394
- if (!spec) return out2;
1395
1544
  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;
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
+ }
1409
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)`);
1410
1570
  }
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
1571
  }
1416
1572
  }
1417
1573
  return out2;
1418
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
+ }
1419
1624
  var SWITCHES = [
1420
1625
  {
1421
1626
  re: /--passWithNoTests\b/,
@@ -1454,6 +1659,35 @@ var SWITCHES = [
1454
1659
  }
1455
1660
  ];
1456
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
+ }
1457
1691
  var coverageLowering = {
1458
1692
  id: RULE4,
1459
1693
  surface: ["file"],
@@ -1466,10 +1700,15 @@ var coverageLowering = {
1466
1700
  if (c.after != null && c.op !== "delete") {
1467
1701
  const reasons = [];
1468
1702
  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)));
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)));
1471
1709
  }
1472
1710
  reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1711
+ reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
1473
1712
  for (const reason of reasons) {
1474
1713
  out2.push(
1475
1714
  makeFinding(RULE4, policy, {
@@ -1483,7 +1722,7 @@ var coverageLowering = {
1483
1722
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1484
1723
  const before = c.before ?? "";
1485
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));
1486
- if (gated) {
1725
+ if (gated && !gateMovedElsewhere(c, changes, policy)) {
1487
1726
  out2.push(
1488
1727
  makeFinding(RULE4, policy, {
1489
1728
  file: c.path,
@@ -1580,8 +1819,33 @@ function canonical(core) {
1580
1819
  return s.replace(/\s+--\s*$/, "").trim();
1581
1820
  }
1582
1821
  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) {
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) {
1585
1849
  if (isUses) {
1586
1850
  const ref = usesRef(removedCore);
1587
1851
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
@@ -1593,15 +1857,33 @@ function survives(removedCore, isUses, afterCores) {
1593
1857
  if (a === r) return { state: "kept" };
1594
1858
  if (a.startsWith(r + " ")) {
1595
1859
  const rest = a.slice(r.length);
1596
- if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1860
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
1597
1861
  else return { state: "kept" };
1598
1862
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1599
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;
1600
1867
  }
1601
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;
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;
1881
+ }
1882
+ if (respelled) return { state: "kept" };
1602
1883
  return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1603
1884
  }
1604
1885
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1886
+ var TRUTHY = Symbol("truthy");
1605
1887
  function lex(src) {
1606
1888
  const out2 = [];
1607
1889
  let i = 0;
@@ -1632,13 +1914,13 @@ function lex(src) {
1632
1914
  i += num[0].length;
1633
1915
  continue;
1634
1916
  }
1635
- const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1917
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
1636
1918
  if (op) {
1637
1919
  out2.push({ t: "op", v: op[0] });
1638
1920
  i += op[0].length;
1639
1921
  continue;
1640
1922
  }
1641
- const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1923
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
1642
1924
  if (id) {
1643
1925
  out2.push({ t: "id", v: id[0] });
1644
1926
  i += id[0].length;
@@ -1648,15 +1930,16 @@ function lex(src) {
1648
1930
  }
1649
1931
  return out2;
1650
1932
  }
1933
+ var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
1651
1934
  function foldConst(src) {
1652
1935
  const toks = lex(src);
1653
1936
  if (!toks) return void 0;
1654
1937
  let p = 0;
1655
1938
  const peek = () => toks[p];
1656
1939
  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);
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);
1659
1941
  const eq = (a, b) => {
1942
+ if (a === TRUTHY || b === TRUTHY) return void 0;
1660
1943
  if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1661
1944
  if (a === null && b === null) return true;
1662
1945
  const x = num(a);
@@ -1667,8 +1950,9 @@ function foldConst(src) {
1667
1950
  let l = and();
1668
1951
  while (eat("||")) {
1669
1952
  const r = and();
1670
- if (l === void 0 || r === void 0) return void 0;
1671
- 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;
1672
1956
  }
1673
1957
  return l;
1674
1958
  };
@@ -1676,8 +1960,9 @@ function foldConst(src) {
1676
1960
  let l = cmp();
1677
1961
  while (eat("&&")) {
1678
1962
  const r = cmp();
1679
- if (l === void 0 || r === void 0) return void 0;
1680
- 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;
1681
1966
  }
1682
1967
  return l;
1683
1968
  };
@@ -1688,14 +1973,18 @@ function foldConst(src) {
1688
1973
  if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1689
1974
  p++;
1690
1975
  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 {
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 {
1695
1984
  const x = num(l);
1696
1985
  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;
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;
1699
1988
  }
1700
1989
  }
1701
1990
  };
@@ -1720,6 +2009,19 @@ function foldConst(src) {
1720
2009
  if (/^true$/i.test(t.v)) return true;
1721
2010
  if (/^false$/i.test(t.v)) return false;
1722
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
+ }
1723
2025
  return void 0;
1724
2026
  }
1725
2027
  return void 0;
@@ -1736,7 +2038,7 @@ function isAlwaysFalse(raw) {
1736
2038
  const e = expressionOf(raw);
1737
2039
  if (e === "") return false;
1738
2040
  const v = foldConst(e);
1739
- return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
2041
+ return v !== void 0 && !truthy(v);
1740
2042
  }
1741
2043
  function isTruthy(raw) {
1742
2044
  const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
@@ -1744,7 +2046,7 @@ function isTruthy(raw) {
1744
2046
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1745
2047
  if (!m) return false;
1746
2048
  const f = foldConst(m[1]);
1747
- return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
2049
+ return f !== void 0 && truthy(f);
1748
2050
  }
1749
2051
  var indentOf = (l) => l.match(/^\s*/)[0].length;
1750
2052
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
@@ -1758,7 +2060,21 @@ function stepOf(lines, i) {
1758
2060
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1759
2061
  return [s, e];
1760
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
+ }
1761
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));
1762
2078
  function runBlocks(lines) {
1763
2079
  const out2 = [];
1764
2080
  for (let i = 0; i < lines.length; i++) {
@@ -1776,13 +2092,28 @@ var NEUTRALISERS = [
1776
2092
  { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1777
2093
  { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1778
2094
  ];
2095
+ var LIST_KEYS = {
2096
+ branches: "branches",
2097
+ "branches-ignore": "branchesIgnore",
2098
+ "paths-ignore": "pathsIgnore",
2099
+ paths: "paths",
2100
+ types: "types"
2101
+ };
1779
2102
  function flowList(v) {
1780
2103
  const m = v.trim().match(/^\[(.*)\]$/);
1781
2104
  if (!m) return null;
1782
2105
  return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1783
2106
  }
1784
2107
  function parseTriggers(src) {
1785
- 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
+ };
1786
2117
  const lines = src.split("\n");
1787
2118
  const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1788
2119
  if (i < 0) return t;
@@ -1818,8 +2149,9 @@ function parseTriggers(src) {
1818
2149
  listKey = null;
1819
2150
  continue;
1820
2151
  }
1821
- if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1822
- 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;
1823
2155
  listInd = ind;
1824
2156
  const list = flowList(kv[2]);
1825
2157
  t[listKey].set(event, list ?? []);
@@ -1829,36 +2161,80 @@ function parseTriggers(src) {
1829
2161
  }
1830
2162
  var DEFAULT_BRANCH = /^(?:main|master)$/;
1831
2163
  var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1832
- 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) {
1833
2191
  const out2 = [];
1834
2192
  if (!before.present || !after.present) return out2;
1835
- 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) {
1836
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`);
1837
2197
  }
1838
2198
  for (const [e, list] of after.pathsIgnore) {
1839
2199
  const had = before.pathsIgnore.get(e) ?? [];
1840
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}`);
1841
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
+ }
1842
2214
  for (const [e, list] of after.branchesIgnore) {
1843
2215
  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`);
2216
+ if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1845
2217
  }
1846
2218
  for (const [e, was] of before.branches) {
1847
2219
  const now = after.branches.get(e);
1848
2220
  if (!now || !after.events.has(e)) continue;
1849
2221
  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));
2222
+ const lostDefault = namesDefault(was) && !namesDefault(now);
2223
+ const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
1852
2224
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1853
2225
  }
1854
2226
  return out2;
1855
2227
  }
2228
+ var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
1856
2229
  var ciTampering = {
1857
2230
  id: RULE5,
1858
2231
  surface: ["file"],
1859
2232
  certainty: "mechanical",
1860
- run(changes, policy) {
2233
+ run(changes, policy, _view, ctx) {
1861
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;
1862
2238
  for (const c of changes) {
1863
2239
  if (c.kind !== "file") continue;
1864
2240
  if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
@@ -1881,6 +2257,10 @@ var ciTampering = {
1881
2257
  if (!coe && !cond) continue;
1882
2258
  const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1883
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
+ }
1884
2264
  if (coe && isTruthy(coe[1])) {
1885
2265
  out2.push(
1886
2266
  makeFinding(RULE5, policy, {
@@ -1938,25 +2318,35 @@ var ciTampering = {
1938
2318
  );
1939
2319
  }
1940
2320
  if (c.before != null) {
1941
- 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)) {
1942
2323
  out2.push(
1943
2324
  makeFinding(RULE5, policy, {
1944
2325
  file: c.path,
1945
2326
  message: `The workflow's triggers were narrowed: ${reason}.`,
1946
2327
  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."
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."
1948
2329
  })
1949
2330
  );
1950
2331
  }
1951
2332
  }
1952
2333
  }
1953
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
+ }
1954
2344
  for (const l of removedLines(c)) {
1955
2345
  if (/^\s*#/.test(l.content)) continue;
1956
2346
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1957
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);
1958
2348
  if (!isUsesLine && !isCheckCommand) continue;
1959
- const s = survives(commandCore(l.content), isUsesLine, afterCores);
2349
+ const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
1960
2350
  if (s.state === "kept") continue;
1961
2351
  if (s.state === "neutralised") {
1962
2352
  out2.push(
@@ -1964,7 +2354,7 @@ var ciTampering = {
1964
2354
  file: c.path,
1965
2355
  message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1966
2356
  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."
2357
+ remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
1968
2358
  })
1969
2359
  );
1970
2360
  continue;
@@ -1985,11 +2375,11 @@ var ciTampering = {
1985
2375
 
1986
2376
  // src/detectors/hook-tampering.ts
1987
2377
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1988
- import { join as join2 } from "node:path";
2378
+ import { isAbsolute, join as join2, relative } from "node:path";
1989
2379
 
1990
2380
  // src/detectors/policy-diff.ts
1991
2381
  import { parse } from "yaml";
1992
- 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) : [];
1993
2383
  function safeParse(src) {
1994
2384
  try {
1995
2385
  const v = parse(src);
@@ -2006,13 +2396,13 @@ function effective(raw) {
2006
2396
  const rules = { ...base.rules };
2007
2397
  for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
2008
2398
  const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
2009
- rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
2399
+ rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
2010
2400
  }
2011
- 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;
2012
2402
  return {
2013
2403
  version,
2014
2404
  rules,
2015
- ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
2405
+ ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
2016
2406
  protected: mergeProtected(base.protected, userProtected),
2017
2407
  requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
2018
2408
  ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
@@ -2495,9 +2885,22 @@ function chmodDropsExec(toks) {
2495
2885
  return false;
2496
2886
  }
2497
2887
  var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2888
+ var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
2889
+ var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
2890
+ function shellHookTarget(seg, toks, policy) {
2891
+ for (const m of seg.matchAll(REDIRECT_TARGET)) {
2892
+ const t = hookPath(m[1]);
2893
+ if (isProtected(t, policy, "hooks")) return t;
2894
+ }
2895
+ for (const tok of toks) {
2896
+ const t = hookPath(tok.replace(/^of=/, ""));
2897
+ if (isProtected(t, policy, "hooks")) return t;
2898
+ }
2899
+ return null;
2900
+ }
2498
2901
  function shellWritesHook(seg, toks, policy) {
2499
2902
  const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
2500
- for (const m of seg.matchAll(/(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g)) {
2903
+ for (const m of seg.matchAll(REDIRECT_TARGET)) {
2501
2904
  if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
2502
2905
  }
2503
2906
  let at = 0;
@@ -2631,11 +3034,11 @@ function settingsWeakening(before, after, sibling) {
2631
3034
  }
2632
3035
  if (now !== "none" || !declares(after, event)) continue;
2633
3036
  if (before !== null && declares(before, event)) continue;
2634
- const empty = (eventEntries(after, event) ?? []).length === 0;
3037
+ const empty2 = (eventEntries(after, event) ?? []).length === 0;
2635
3038
  const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
2636
- if (empty || shadowed) {
3039
+ if (empty2 || shadowed) {
2637
3040
  reasons.push(
2638
- `${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
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" : "")
2639
3042
  );
2640
3043
  }
2641
3044
  }
@@ -2708,14 +3111,24 @@ function configFindings(c, policy) {
2708
3111
  function updateIndexHiding(toks, policy) {
2709
3112
  if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
2710
3113
  const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
2711
- if (hide && toks.some((t) => isProtected(t, policy))) {
2712
- return `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`;
3114
+ const hidden = hide ? toks.find((t) => isProtected(t, policy)) : void 0;
3115
+ if (hide && hidden) {
3116
+ return {
3117
+ reason: `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`,
3118
+ file: hidden
3119
+ };
2713
3120
  }
2714
- if (toks.some((t) => /^--chmod=-x$/.test(t)) && toks.some((t) => isProtected(t, policy, "hooks"))) {
2715
- return "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it";
3121
+ const hook = toks.some((t) => /^--chmod=-x$/.test(t)) ? toks.find((t) => isProtected(t, policy, "hooks")) : void 0;
3122
+ if (hook) {
3123
+ return { reason: "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it", file: hook };
2716
3124
  }
2717
3125
  return null;
2718
3126
  }
3127
+ function repoRelative(path, ctx) {
3128
+ if (!ctx?.cwd || !isAbsolute(path)) return path;
3129
+ const rel = relative(ctx.cwd, path);
3130
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
3131
+ }
2719
3132
  var hookTampering = {
2720
3133
  id: RULE6,
2721
3134
  surface: ["file", "command"],
@@ -2865,7 +3278,8 @@ var hookTampering = {
2865
3278
  if (hiding) {
2866
3279
  out2.push(
2867
3280
  makeFinding(RULE6, policy, {
2868
- message: `Hook tampering via shell: ${hiding}.`,
3281
+ file: repoRelative(hiding.file, ctx),
3282
+ message: `Hook tampering via shell: ${hiding.reason}.`,
2869
3283
  evidence: seg,
2870
3284
  remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
2871
3285
  })
@@ -2874,8 +3288,10 @@ var hookTampering = {
2874
3288
  }
2875
3289
  const why = shellWritesHook(seg, toks, policy);
2876
3290
  if (why) {
3291
+ const target = shellHookTarget(seg, toks, policy);
2877
3292
  out2.push(
2878
3293
  makeFinding(RULE6, policy, {
3294
+ ...target ? { file: repoRelative(target, ctx) } : {},
2879
3295
  message: `Hook tampering via shell: ${why}.`,
2880
3296
  evidence: seg,
2881
3297
  remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
@@ -2919,67 +3335,147 @@ function keyName2(name) {
2919
3335
  if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2920
3336
  return null;
2921
3337
  }
3338
+ var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
2922
3339
  function literals2(e) {
2923
- if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
3340
+ if (isStr(e)) return { items: [e.text], opaque: false };
2924
3341
  if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2925
3342
  const items = [];
2926
3343
  let opaque = false;
2927
3344
  for (const el of e.elements) {
2928
- if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
3345
+ if (isStr(el)) items.push(el.text);
2929
3346
  else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2930
3347
  items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2931
3348
  } else opaque = true;
2932
3349
  }
2933
3350
  return { items, opaque };
2934
3351
  }
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;
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;
2940
3358
  }
2941
3359
  function asExpression2(src) {
2942
3360
  const t = src.trim();
2943
3361
  return t.startsWith("{") ? `(${t})` : src;
2944
3362
  }
2945
- function runnerOf(path) {
3363
+ function runnerOf(path, content) {
2946
3364
  const base = path.split("/").pop() ?? path;
2947
3365
  if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
2948
- 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";
2949
3368
  return null;
2950
3369
  }
2951
- function parseSelection(src, runner) {
2952
- const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
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
+ }
3455
+ return null;
3456
+ }
3457
+ function parseSelection(src, runner, path = "") {
3458
+ let sel = empty();
2953
3459
  try {
2954
3460
  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);
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;
2973
3468
  }
2974
- }
2975
- ts3.forEachChild(node, visit);
2976
- };
2977
- 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);
2978
3474
  } catch {
2979
3475
  }
2980
3476
  return sel;
2981
3477
  }
2982
- var globs2 = (patterns) => {
3478
+ var globs3 = (patterns) => {
2983
3479
  const ms = patterns.map((g) => {
2984
3480
  try {
2985
3481
  return picomatch3(g, { dot: true });
@@ -2989,6 +3485,11 @@ var globs2 = (patterns) => {
2989
3485
  });
2990
3486
  return (path) => ms.some((m) => m(path));
2991
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
+ };
2992
3493
  var regexes = (patterns) => {
2993
3494
  const rs = patterns.flatMap((p) => {
2994
3495
  try {
@@ -2999,29 +3500,44 @@ var regexes = (patterns) => {
2999
3500
  });
3000
3501
  return (path) => rs.some((r) => r.test("/" + path));
3001
3502
  };
3503
+ var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
3002
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
+ }
3003
3512
  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);
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));
3007
3519
  return {
3008
- 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
+ },
3009
3524
  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
- }
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)})`;
3015
3531
  const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3016
3532
  return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3017
3533
  }
3018
3534
  };
3019
3535
  }
3020
3536
  function suiteNarrowings(before, after, path, samples) {
3021
- const runner = runnerOf(path);
3537
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
3022
3538
  if (!runner) return [];
3023
- const b = parseSelection(before ?? "", runner);
3024
- const a = parseSelection(after, runner);
3539
+ const b = parseSelection(before ?? "", runner, path);
3540
+ const a = parseSelection(after, runner, path);
3025
3541
  if (!a.present || a.opaque) return [];
3026
3542
  const pb = predicate(b, runner);
3027
3543
  const pa = predicate(a, runner);
@@ -3053,6 +3569,18 @@ function calleeName(expr) {
3053
3569
  return null;
3054
3570
  }
3055
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
+ }
3056
3584
  function eachRows(call) {
3057
3585
  const arg = call.arguments[0];
3058
3586
  if (!arg) return { n: 1, open: true };
@@ -3067,7 +3595,7 @@ function eachRows(call) {
3067
3595
  }
3068
3596
  return { n: 1, open: true };
3069
3597
  }
3070
- function countTests(src, path = "spec.ts") {
3598
+ function countTests(src, path = "spec.ts", substantiveOnly = false) {
3071
3599
  const lang = langOf(path);
3072
3600
  if (lang && lang !== "js") {
3073
3601
  const re = TEST_DEFS[lang];
@@ -3078,23 +3606,33 @@ function countTests(src, path = "spec.ts") {
3078
3606
  const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
3079
3607
  let n = 0;
3080
3608
  let open = false;
3081
- const visit = (node) => {
3609
+ const visit = (node, mult) => {
3082
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
+ }
3083
3622
  if (isEachOf(node.expression)) {
3084
3623
  const r = eachRows(node);
3085
- n += r.n;
3624
+ n += r.n * mult;
3086
3625
  if (r.open) open = true;
3087
3626
  } else {
3088
3627
  const name = calleeName(node.expression);
3089
- if (name === "it" || name === "test") n++;
3628
+ if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
3090
3629
  }
3091
3630
  } 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);
3631
+ n += templateRows(node) * mult;
3094
3632
  }
3095
- ts4.forEachChild(node, visit);
3633
+ ts4.forEachChild(node, (c) => visit(c, mult));
3096
3634
  };
3097
- visit(sf);
3635
+ visit(sf, 1);
3098
3636
  return { min: n, open };
3099
3637
  }
3100
3638
  function countTestBlocks(src, path = "spec.ts") {
@@ -3109,7 +3647,7 @@ function significantLines(src, path) {
3109
3647
  }
3110
3648
  return out2;
3111
3649
  }
3112
- var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
3650
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
3113
3651
  function runnerSamples(configPath, policy, ctx) {
3114
3652
  const files = trackedFiles(ctx);
3115
3653
  if (!files) return CANONICAL_SAMPLES;
@@ -3117,6 +3655,34 @@ function runnerSamples(configPath, policy, ctx) {
3117
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));
3118
3656
  return own.length ? own : CANONICAL_SAMPLES;
3119
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
+ }
3120
3686
  function joinRoot(root, to) {
3121
3687
  const parts = root ? root.split("/") : [];
3122
3688
  for (const seg of to.replace(/\/+$/, "").split("/")) {
@@ -3148,11 +3714,11 @@ var testDeletion = {
3148
3714
  if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3149
3715
  if (c.op === "add") {
3150
3716
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
3151
- addedTestBlocks += countTestBlocks(c.after, c.path);
3717
+ addedTestBlocks += countTests(c.after, c.path, true).min;
3152
3718
  } else if (c.before != null) {
3153
3719
  const before = significantLines(c.before, c.path);
3154
3720
  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));
3721
+ addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
3156
3722
  }
3157
3723
  }
3158
3724
  const isRelocation = (removed, blocksRemoved) => {
@@ -3199,7 +3765,7 @@ var testDeletion = {
3199
3765
  })
3200
3766
  );
3201
3767
  }
3202
- } 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)) {
3203
3769
  for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3204
3770
  out2.push(
3205
3771
  makeFinding(RULE7, policy, {
@@ -3210,6 +3776,16 @@ var testDeletion = {
3210
3776
  })
3211
3777
  );
3212
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
+ }
3213
3789
  }
3214
3790
  } else {
3215
3791
  let root = "";
@@ -3223,15 +3799,25 @@ var testDeletion = {
3223
3799
  const inRepo = (t) => root ? `${root}/${t}` : t;
3224
3800
  const listing = trackedFiles(ctx);
3225
3801
  const testToks = toks.filter((t) => {
3226
- 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;
3227
3805
  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);
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);
3231
3820
  });
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
3821
  if (testToks.length === 0 && dirToks.length === 0) continue;
3236
3822
  const named = [...testToks, ...dirToks];
3237
3823
  const cmd = toks[0] ?? "";
@@ -3242,8 +3828,11 @@ var testDeletion = {
3242
3828
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3243
3829
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3244
3830
  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(" -- "));
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));
3247
3836
  })();
3248
3837
  if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
3249
3838
  why = "rm deletes a test file";
@@ -3298,7 +3887,7 @@ var MIN_REMOVED_LINES = 3;
3298
3887
  var MIN_REMOVED_ROWS = 2;
3299
3888
  var ws = (s) => s.replace(/\s+/g, "");
3300
3889
  function eachTables(src, path) {
3301
- const rows = /* @__PURE__ */ new Set();
3890
+ const rows = /* @__PURE__ */ new Map();
3302
3891
  let open = false;
3303
3892
  if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3304
3893
  try {
@@ -3312,10 +3901,9 @@ function eachTables(src, path) {
3312
3901
  open = true;
3313
3902
  continue;
3314
3903
  }
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
- }
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);
3319
3907
  }
3320
3908
  } else open = true;
3321
3909
  }
@@ -3370,12 +3958,13 @@ var testContentRemoval = {
3370
3958
  gone.push(line);
3371
3959
  }
3372
3960
  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))) : [];
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) : [];
3374
3963
  if (goneRows.length >= MIN_REMOVED_ROWS) {
3375
3964
  out2.push(
3376
3965
  makeFinding(RULE8, policy, {
3377
3966
  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.`,
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.`,
3379
3968
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3380
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."
3381
3970
  })
@@ -3572,7 +4161,7 @@ function hasBlocking(findings) {
3572
4161
 
3573
4162
  // src/policy-load.ts
3574
4163
  import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
3575
- import { isAbsolute, join as join3, posix } from "node:path";
4164
+ import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
3576
4165
  import { parse as parse2 } from "yaml";
3577
4166
  var PolicyError = class extends Error {
3578
4167
  };
@@ -3589,7 +4178,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
3589
4178
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3590
4179
  var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
3591
4180
  function ledgerInsideRepo(ledger) {
3592
- if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
4181
+ if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
3593
4182
  const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
3594
4183
  return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
3595
4184
  }
@@ -3620,8 +4209,8 @@ function validate(r, where) {
3620
4209
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
3621
4210
  if (r.protected !== void 0) {
3622
4211
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
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)}`);
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)}`);
3625
4214
  }
3626
4215
  }
3627
4216
  if (r.signoff !== void 0) {
@@ -3653,10 +4242,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
3653
4242
  const version = normalizeVersion(r.version, where);
3654
4243
  validate(r, where);
3655
4244
  const base = defaultPolicy(version);
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;
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;
3658
4247
  const userRules = r.rules ? Object.fromEntries(
3659
- 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])
3660
4249
  ) : void 0;
3661
4250
  return {
3662
4251
  version,
@@ -3666,7 +4255,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
3666
4255
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
3667
4256
  protected: mergeProtected(base.protected, userProtected),
3668
4257
  rules: mergeRules(base.rules, userRules),
3669
- ignore: globs3(r.ignore) ?? base.ignore,
4258
+ ignore: globs4(r.ignore) ?? base.ignore,
3670
4259
  signoff: {
3671
4260
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
3672
4261
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -4043,7 +4632,8 @@ function check(opts) {
4043
4632
  layer = "local";
4044
4633
  view = "staged";
4045
4634
  } else if (opts.worktree) {
4046
- changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }) : diffWorktree({ cwd: opts.cwd });
4635
+ const p = policy;
4636
+ changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
4047
4637
  layer = opts.ciLayer ? "ci" : "local";
4048
4638
  view = "worktree";
4049
4639
  } else if (opts.diff) {
@@ -4102,7 +4692,7 @@ import { join as join9 } from "node:path";
4102
4692
  import { execFileSync as execFileSync3 } from "node:child_process";
4103
4693
  import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
4104
4694
  import { tmpdir } from "node:os";
4105
- import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
4695
+ import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
4106
4696
  function asStr(v) {
4107
4697
  return typeof v === "string" ? v : "";
4108
4698
  }
@@ -4117,8 +4707,8 @@ function abs(path, cwd) {
4117
4707
  return resolve(cwd, path);
4118
4708
  }
4119
4709
  function relForDisplay(path, cwd) {
4120
- const rel = relative(cwd, path);
4121
- return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
4710
+ const rel = relative2(cwd, path);
4711
+ return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
4122
4712
  }
4123
4713
  function applyEdit(content, oldStr, newStr) {
4124
4714
  if (content === null) return newStr;
@@ -4261,11 +4851,11 @@ function advanceTurnBaseline(cwd, sessionId) {
4261
4851
 
4262
4852
  // src/effect.ts
4263
4853
  import { createHash as createHash2 } from "node:crypto";
4264
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4265
- import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
4854
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4855
+ import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
4266
4856
  import { execFileSync as execFileSync4 } from "node:child_process";
4267
4857
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
4268
- var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
4858
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
4269
4859
  function gitDir2(cwd) {
4270
4860
  try {
4271
4861
  const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
@@ -4274,7 +4864,7 @@ function gitDir2(cwd) {
4274
4864
  stdio: ["ignore", "pipe", "pipe"]
4275
4865
  }).trim();
4276
4866
  if (!d) return null;
4277
- return isAbsolute3(d) ? d : join7(cwd, d);
4867
+ return isAbsolute4(d) ? d : join7(cwd, d);
4278
4868
  } catch {
4279
4869
  return null;
4280
4870
  }
@@ -4307,7 +4897,7 @@ function snapshotProtected(cwd, policy, prev) {
4307
4897
  return;
4308
4898
  }
4309
4899
  for (const name of names) {
4310
- if (SKIP_DIRS.has(name)) continue;
4900
+ if (SKIP_DIRS2.has(name)) continue;
4311
4901
  const r = rel ? `${rel}/${name}` : name;
4312
4902
  let st;
4313
4903
  try {
@@ -4333,12 +4923,16 @@ function snapshotProtected(cwd, policy, prev) {
4333
4923
  function driftBetween(expected, current) {
4334
4924
  const changed = [];
4335
4925
  const deleted = [];
4926
+ const added = [];
4336
4927
  for (const [path, e] of Object.entries(expected)) {
4337
4928
  const c = current[path];
4338
4929
  if (!c) deleted.push(path);
4339
4930
  else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
4340
4931
  }
4341
- return { changed, deleted };
4932
+ for (const path of Object.keys(current)) {
4933
+ if (!expected[path]) added.push(path);
4934
+ }
4935
+ return { changed, deleted, added };
4342
4936
  }
4343
4937
  function loadTree(p) {
4344
4938
  if (!p || !existsSync6(p)) return null;
@@ -4352,7 +4946,16 @@ function saveTree(p, tree) {
4352
4946
  if (!p) return;
4353
4947
  try {
4354
4948
  mkdirSync3(join7(p, ".."), { recursive: true });
4355
- writeFileSync3(p, JSON.stringify(tree));
4949
+ const tmp = `${p}.${process.pid}.tmp`;
4950
+ writeFileSync3(tmp, JSON.stringify(tree));
4951
+ renameSync(tmp, p);
4952
+ } catch {
4953
+ }
4954
+ }
4955
+ function dropTree(p) {
4956
+ if (!p) return;
4957
+ try {
4958
+ rmSync2(p, { force: true });
4356
4959
  } catch {
4357
4960
  }
4358
4961
  }
@@ -4368,6 +4971,9 @@ function loadTurnTree(cwd, sessionId) {
4368
4971
  function saveTurnTree(cwd, sessionId, tree) {
4369
4972
  saveTree(turnTreePath(cwd, sessionId), tree);
4370
4973
  }
4974
+ function dropTurnTree(cwd, sessionId) {
4975
+ dropTree(turnTreePath(cwd, sessionId));
4976
+ }
4371
4977
 
4372
4978
  // src/cli/watch.ts
4373
4979
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
@@ -4612,17 +5218,26 @@ function turnView(cwd, sessionId, policy, base) {
4612
5218
  if (c.oldPath) seen.add(c.oldPath);
4613
5219
  }
4614
5220
  const turnTree = loadTurnTree(cwd, sessionId);
4615
- const untracked = untrackedAdds({ cwd }, (rel) => isProtected(rel, policy), seen).filter(
4616
- (c) => c.kind !== "file" || !turnTree || turnTree[c.path]?.hash !== hashOnDisk(cwd, c.path)
4617
- );
5221
+ const ptree = loadPtree(cwd, sessionId);
5222
+ const keep = (rel) => isProtected(rel, policy);
5223
+ const untouched = (rel, disk) => disk != null && turnTree?.[rel]?.hash === disk && ptree?.[rel]?.hash === disk;
5224
+ const untracked = untrackedAdds({ cwd }, keep, seen).filter((c) => c.kind !== "file" || !untouched(c.path, hashOnDisk(cwd, c.path)));
4618
5225
  for (const c of fileChanges(untracked)) seen.add(c.path);
5226
+ const ignored = ignoredAdds({ cwd }, keep, seen).filter((c) => {
5227
+ if (c.kind !== "file") return false;
5228
+ const disk = hashOnDisk(cwd, c.path);
5229
+ if (untouched(c.path, disk)) return false;
5230
+ const sanctioned = ptree?.[c.path];
5231
+ return !(sanctioned && disk != null && sanctioned.hash !== disk);
5232
+ });
5233
+ for (const c of fileChanges(ignored)) seen.add(c.path);
4619
5234
  const trusted = base ?? "HEAD";
4620
5235
  const hidden = [];
4621
5236
  for (const rel of hiddenTrackedPaths({ cwd })) {
4622
5237
  if (seen.has(rel) || !isProtected(rel, policy)) continue;
4623
5238
  hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
4624
5239
  }
4625
- return [...tracked, ...untracked, ...hidden];
5240
+ return [...tracked, ...untracked, ...ignored, ...hidden];
4626
5241
  }
4627
5242
  function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
4628
5243
  const d = driftBetween(expected, current);
@@ -4640,7 +5255,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
4640
5255
  const after = fileOnDisk(rel, { cwd });
4641
5256
  const trustedBlob = fileAt(trusted, rel, { cwd });
4642
5257
  const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
4643
- const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
5258
+ const atTurnStart = current[rel] != null && expected[rel]?.mtimeMs === -1 && turnTree?.[rel]?.hash === current[rel].hash;
4644
5259
  if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
4645
5260
  continue;
4646
5261
  }
@@ -4683,6 +5298,23 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
4683
5298
  });
4684
5299
  }
4685
5300
  }
5301
+ for (const rel of d.added) {
5302
+ if (seen.has(rel)) continue;
5303
+ const after = fileOnDisk(rel, { cwd });
5304
+ if (after == null) {
5305
+ blocks.push({
5306
+ rule: "hidden-drift",
5307
+ severity: "block",
5308
+ file: rel,
5309
+ message: `Protected file ${rel} appeared outside git's view and cannot be read to judge it.`,
5310
+ evidence: rel,
5311
+ remediation: "Remove the file, or make it readable and bring it into git's view; create protected files through a tool call the gate can see.",
5312
+ signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
5313
+ });
5314
+ continue;
5315
+ }
5316
+ changes.push(...synthFileChange(rel, null, after));
5317
+ }
4686
5318
  return { changes, blocks };
4687
5319
  }
4688
5320
  function effectDriftBlocks(cwd, sessionId, policy) {
@@ -4691,17 +5323,18 @@ function effectDriftBlocks(cwd, sessionId, policy) {
4691
5323
  const current = snapshotProtected(cwd, policy, expected ?? void 0);
4692
5324
  if (!expected) {
4693
5325
  savePtree(cwd, sessionId, current);
4694
- if (!loadTurnTree(cwd, sessionId)) saveTurnTree(cwd, sessionId, current);
5326
+ if (loadTurnTree(cwd, sessionId)) dropTurnTree(cwd, sessionId);
5327
+ else saveTurnTree(cwd, sessionId, current);
4695
5328
  return null;
4696
5329
  }
4697
5330
  const d = driftBetween(expected, current);
4698
- if (d.changed.length === 0 && d.deleted.length === 0) {
5331
+ if (d.changed.length === 0 && d.deleted.length === 0 && d.added.length === 0) {
4699
5332
  savePtree(cwd, sessionId, current);
4700
5333
  return null;
4701
5334
  }
4702
5335
  const base = turnBaseline(cwd, sessionId);
4703
5336
  const all = turnView(cwd, sessionId, policy, base);
4704
- const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
5337
+ const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted, ...d.added]);
4705
5338
  const scoped = all.filter(
4706
5339
  (c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
4707
5340
  );
@@ -4916,7 +5549,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
4916
5549
  import { execFileSync as execFileSync5 } from "node:child_process";
4917
5550
  import { createHash as createHash5 } from "node:crypto";
4918
5551
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
4919
- import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
5552
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
4920
5553
  import { fileURLToPath } from "node:url";
4921
5554
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
4922
5555
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -5234,8 +5867,8 @@ function hooksDir(cwd) {
5234
5867
  return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
5235
5868
  }
5236
5869
  function display(cwd, path) {
5237
- const rel = relative2(cwd, path);
5238
- return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
5870
+ const rel = relative3(cwd, path);
5871
+ return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
5239
5872
  }
5240
5873
  function unconditionalExitAt(lines) {
5241
5874
  let depth = 0;
@@ -5521,7 +6154,7 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
5521
6154
  // src/cli/verify.ts
5522
6155
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
5523
6156
  import { createHash as createHash8 } from "node:crypto";
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";
6157
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
5525
6158
  import { tmpdir as tmpdir2 } from "node:os";
5526
6159
  import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
5527
6160
 
@@ -5529,7 +6162,7 @@ import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:p
5529
6162
  import { execFileSync as execFileSync6 } from "node:child_process";
5530
6163
  import { createHash as createHash6 } from "node:crypto";
5531
6164
  import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
5532
- import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
6165
+ import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
5533
6166
  function trustedGitEnv() {
5534
6167
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
5535
6168
  }
@@ -5542,7 +6175,7 @@ function gitDirOf(cwd) {
5542
6175
  env: trustedGitEnv()
5543
6176
  }).trim();
5544
6177
  if (!d) return null;
5545
- return isAbsolute5(d) ? d : join11(cwd, d);
6178
+ return isAbsolute6(d) ? d : join11(cwd, d);
5546
6179
  } catch {
5547
6180
  return null;
5548
6181
  }
@@ -5756,7 +6389,7 @@ function materialize(cwd, dest) {
5756
6389
  }
5757
6390
  function dropSymlink(p) {
5758
6391
  try {
5759
- if (lstatSync(p).isSymbolicLink()) rmSync2(p, { force: true });
6392
+ if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
5760
6393
  } catch {
5761
6394
  }
5762
6395
  }
@@ -5767,9 +6400,9 @@ function verifierInputs(cmd, atBase, policy) {
5767
6400
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
5768
6401
  if (tok && baseFiles.has(tok)) picked.add(tok);
5769
6402
  }
5770
- const globs3 = policy.verify?.inputs;
5771
- if (globs3 && globs3.length) {
5772
- 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);
5773
6406
  }
5774
6407
  return picked;
5775
6408
  }
@@ -5814,7 +6447,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
5814
6447
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
5815
6448
  const out2 = join13(dest, e.path);
5816
6449
  mkdirSync6(dirname4(out2), { recursive: true });
5817
- rmSync2(out2, { force: true });
6450
+ rmSync3(out2, { force: true });
5818
6451
  if (e.mode === "120000") {
5819
6452
  symlinkSync(content.toString("utf8"), out2);
5820
6453
  } else {
@@ -5827,7 +6460,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
5827
6460
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
5828
6461
  for (const rel of inCopy) {
5829
6462
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
5830
- rmSync2(join13(dest, rel), { force: true });
6463
+ rmSync3(join13(dest, rel), { force: true });
5831
6464
  removed++;
5832
6465
  }
5833
6466
  return { restored, removed };
@@ -5902,7 +6535,7 @@ function runSuite(dir, cmd, budgetSecs) {
5902
6535
  if (r.exit === null || r.exit === void 0) return { exit: null, secs };
5903
6536
  return { exit: r.exit, secs };
5904
6537
  } finally {
5905
- rmSync2(outDir, { recursive: true, force: true });
6538
+ rmSync3(outDir, { recursive: true, force: true });
5906
6539
  }
5907
6540
  }
5908
6541
  function runVerify(opts) {
@@ -5949,7 +6582,7 @@ function runVerify(opts) {
5949
6582
  const visDir = join13(visRoot, "t");
5950
6583
  const cleanup = (dirs) => {
5951
6584
  if (opts.keep) return;
5952
- for (const d of dirs) rmSync2(d, { recursive: true, force: true });
6585
+ for (const d of dirs) rmSync3(d, { recursive: true, force: true });
5953
6586
  };
5954
6587
  const treeBefore = treeFingerprint(cwd);
5955
6588
  const depsBefore = depsFingerprint(cwd);