tamperward 2.5.1 → 2.7.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 +2057 -490
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -415,7 +415,41 @@ function makeFinding(rule, policy, input) {
415
415
 
416
416
  // src/detectors/command.ts
417
417
  function segments(raw) {
418
- return raw.split(/(?:&&|\|\||[;&|\n])+/).map((s) => s.trim()).filter(Boolean);
418
+ const out2 = [];
419
+ let single = false;
420
+ let double = false;
421
+ let last = 0;
422
+ for (let i = 0; i < raw.length; i++) {
423
+ const ch = raw[i];
424
+ if (ch === "\\" && !single) {
425
+ i++;
426
+ continue;
427
+ }
428
+ if (ch === "'" && !double) {
429
+ single = !single;
430
+ continue;
431
+ }
432
+ if (ch === '"' && !single) {
433
+ double = !double;
434
+ continue;
435
+ }
436
+ if (single || double) continue;
437
+ const prev = raw[i - 1];
438
+ const next = raw[i + 1];
439
+ let len = 0;
440
+ if (ch === "&" && next === "&") len = 2;
441
+ else if (ch === "|" && next === "|") len = 2;
442
+ else if (ch === "|" && next === "&") len = 2;
443
+ else if (ch === ";" || ch === "\n") len = 1;
444
+ else if (ch === "|" && prev !== ">") len = 1;
445
+ else if (ch === "&" && prev !== ">" && next !== ">") len = 1;
446
+ if (!len) continue;
447
+ out2.push(raw.slice(last, i));
448
+ i += len - 1;
449
+ last = i + 1;
450
+ }
451
+ out2.push(raw.slice(last));
452
+ return out2.map((s) => s.trim()).filter(Boolean);
419
453
  }
420
454
  function tokens(seg) {
421
455
  return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
@@ -586,12 +620,12 @@ function matcher(glob) {
586
620
  }
587
621
  return m;
588
622
  }
589
- function matchesAny(path, globs3) {
590
- return !!globs3 && globs3.some((g) => matcher(g)(path));
623
+ function matchesAny(path, globs4) {
624
+ return !!globs4 && globs4.some((g) => matcher(g)(path));
591
625
  }
592
626
  function protectedCategory(path, policy) {
593
- for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
594
- if (matchesAny(path, globs3)) return cat;
627
+ for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
628
+ if (matchesAny(path, globs4)) return cat;
595
629
  }
596
630
  return null;
597
631
  }
@@ -607,9 +641,9 @@ function isIgnored(path, policy) {
607
641
  }
608
642
  function mergeProtected(base, user) {
609
643
  const out2 = { ...base };
610
- for (const [cat, globs3] of Object.entries(user ?? {})) {
644
+ for (const [cat, globs4] of Object.entries(user ?? {})) {
611
645
  const have = new Set(out2[cat] ?? []);
612
- out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
646
+ out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
613
647
  }
614
648
  return out2;
615
649
  }
@@ -654,6 +688,13 @@ function defaultPolicy(version = 1) {
654
688
  config: [
655
689
  "**/jest.config.*",
656
690
  "**/vitest.config.*",
691
+ // A `vitest.workspace.*` file REPLACES the root config's selection with the
692
+ // union of its projects, and a `vite.config.*` carrying a `test:` key is the
693
+ // vitest config when no vitest.config exists — both are where a spec can be
694
+ // dropped from the suite without a line of it changing. ADDITIVE, like every
695
+ // baseline widening (the policy-diff reads it as a strengthening).
696
+ "**/vitest.workspace.*",
697
+ "**/vite.config.*",
657
698
  "**/tsconfig*.json",
658
699
  "**/.eslintrc*",
659
700
  "**/eslint.config.*",
@@ -686,6 +727,9 @@ function defaultPolicy(version = 1) {
686
727
  hooks: [
687
728
  ".husky/**",
688
729
  "**/lefthook.*",
730
+ // lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
731
+ // there switches the gate off without touching the file init pointed at.
732
+ "**/lefthook-local.*",
689
733
  ".tamperward.yml",
690
734
  "**/.tamperward.yml",
691
735
  ".claude/settings.json",
@@ -1141,6 +1185,25 @@ function trackedFiles(ctx) {
1141
1185
  cache2.set(ctx.cwd, files);
1142
1186
  return files;
1143
1187
  }
1188
+ var branchCache = /* @__PURE__ */ new Map();
1189
+ function defaultBranch(ctx) {
1190
+ if (!ctx?.cwd) return null;
1191
+ const hit = branchCache.get(ctx.cwd);
1192
+ if (hit !== void 0) return hit;
1193
+ let name = null;
1194
+ try {
1195
+ const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
1196
+ cwd: ctx.cwd,
1197
+ encoding: "utf8",
1198
+ stdio: ["ignore", "pipe", "pipe"]
1199
+ }).trim();
1200
+ name = ref.replace(/^origin\//, "") || null;
1201
+ } catch {
1202
+ name = null;
1203
+ }
1204
+ branchCache.set(ctx.cwd, name);
1205
+ return name;
1206
+ }
1144
1207
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1145
1208
  function containsProtected(token, policy, category, ctx) {
1146
1209
  const dir = dirOf(token);
@@ -1199,6 +1262,13 @@ function underKey(node, key2) {
1199
1262
  }
1200
1263
  return false;
1201
1264
  }
1265
+ function ownerKey(node) {
1266
+ const obj = node.parent;
1267
+ if (!obj || !ts2.isObjectLiteralExpression(obj)) return null;
1268
+ let q = obj.parent;
1269
+ while (q && (ts2.isParenthesizedExpression(q) || ts2.isAsExpression(q) || q.kind === ts2.SyntaxKind.SatisfiesExpression)) q = q.parent;
1270
+ return q && ts2.isPropertyAssignment(q) ? keyName(q.name) : null;
1271
+ }
1202
1272
  function asExpression(src) {
1203
1273
  const t = src.trim();
1204
1274
  return t.startsWith("{") ? `(${t})` : src;
@@ -1276,18 +1346,20 @@ function literals(e) {
1276
1346
  return { items, opaque };
1277
1347
  }
1278
1348
  function parseLists(src) {
1279
- const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
1349
+ const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
1280
1350
  try {
1281
1351
  const sf = sourceOf(src);
1282
1352
  const resolve5 = resolverFor(sf);
1283
1353
  const visit = (node) => {
1284
1354
  if (ts2.isPropertyAssignment(node)) {
1285
1355
  const k = keyName(node.name);
1286
- if (k === "collectCoverageFrom") {
1356
+ const vitestCoverage = ownerKey(node) === "coverage";
1357
+ if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
1287
1358
  const { items, opaque } = literals(resolve5(node.initializer));
1288
1359
  res.collectFrom = items;
1360
+ res.collectKey = k === "include" ? "coverage.include" : k;
1289
1361
  if (opaque) res.opaqueCollect = true;
1290
- } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
1362
+ } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
1291
1363
  const { items, opaque } = literals(resolve5(node.initializer));
1292
1364
  res.ignore.push(...items);
1293
1365
  if (opaque) res.opaqueIgnore = true;
@@ -1300,6 +1372,43 @@ function parseLists(src) {
1300
1372
  }
1301
1373
  return res;
1302
1374
  }
1375
+ function parseSwitches(src) {
1376
+ const res = {};
1377
+ try {
1378
+ const sf = sourceOf(src);
1379
+ const bool = (e) => e.kind === ts2.SyntaxKind.TrueKeyword ? true : e.kind === ts2.SyntaxKind.FalseKeyword ? false : void 0;
1380
+ const visit = (node) => {
1381
+ if (ts2.isPropertyAssignment(node)) {
1382
+ const k = keyName(node.name);
1383
+ const owner = ownerKey(node);
1384
+ if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
1385
+ else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
1386
+ }
1387
+ ts2.forEachChild(node, visit);
1388
+ };
1389
+ visit(sf);
1390
+ } catch {
1391
+ }
1392
+ return res;
1393
+ }
1394
+ function sectionText(src, key2) {
1395
+ try {
1396
+ const sf = sourceOf(src);
1397
+ let out2 = "";
1398
+ const visit = (node) => {
1399
+ if (out2) return;
1400
+ if (ts2.isPropertyAssignment(node) && keyName(node.name) === key2 && ts2.isObjectLiteralExpression(node.initializer)) {
1401
+ out2 = node.initializer.getText(sf);
1402
+ return;
1403
+ }
1404
+ ts2.forEachChild(node, visit);
1405
+ };
1406
+ visit(sf);
1407
+ return out2;
1408
+ } catch {
1409
+ return "";
1410
+ }
1411
+ }
1303
1412
  var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1304
1413
  function compareMetrics(scope, before, after, out2) {
1305
1414
  if (!before) return;
@@ -1325,10 +1434,10 @@ function overrideTargetExists(key2, configPath, ctx) {
1325
1434
  }
1326
1435
  return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
1327
1436
  }
1328
- function weakenings(before, after, configPath, ctx) {
1437
+ function weakenings(before, after, configPath, movedElsewhere, ctx) {
1329
1438
  const out2 = [];
1330
1439
  if (before.present && !after.present) {
1331
- out2.push("the coverage threshold gate was removed");
1440
+ if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
1332
1441
  return out2;
1333
1442
  }
1334
1443
  if (after.opaque && !after.global && after.paths.size === 0) return out2;
@@ -1355,16 +1464,45 @@ function weakenings(before, after, configPath, ctx) {
1355
1464
  }
1356
1465
  return out2;
1357
1466
  }
1358
- function narrowings(before, after) {
1467
+ var globs = (patterns) => {
1468
+ const ms = patterns.map((g) => {
1469
+ try {
1470
+ return picomatch2(norm(g), { dot: true });
1471
+ } catch {
1472
+ return () => false;
1473
+ }
1474
+ });
1475
+ return (p) => ms.some((m) => m(p));
1476
+ };
1477
+ var globList = (patterns) => {
1478
+ const pos = globs(patterns.filter((g) => !g.startsWith("!")));
1479
+ const neg = globs(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
1480
+ return (p) => pos(p) && !neg(p);
1481
+ };
1482
+ 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"];
1483
+ 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$/;
1484
+ function sourceSamples(configPath, policy, ctx) {
1485
+ const files = trackedFiles(ctx);
1486
+ if (!files) return CANONICAL_SOURCES;
1487
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1488
+ 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));
1489
+ return own.length ? own : CANONICAL_SOURCES;
1490
+ }
1491
+ function narrowings(before, after, sources) {
1359
1492
  const out2 = [];
1360
- if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
1361
- const kept = new Set(after.collectFrom);
1362
- for (const g of before.collectFrom) {
1363
- if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
1493
+ if (before.collectFrom && after.collectFrom && !after.opaqueCollect && !before.opaqueCollect) {
1494
+ const pb = globList(before.collectFrom);
1495
+ const pa = globList(after.collectFrom);
1496
+ const dropped = sources.filter((s) => pb(s) && !pa(s));
1497
+ if (dropped.length) {
1498
+ const shown = dropped.slice(0, 3).map((d) => JSON.stringify(d)).join(", ");
1499
+ out2.push(`${after.collectKey} no longer measures ${shown}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
1364
1500
  }
1365
1501
  const had2 = new Set(before.collectFrom);
1366
1502
  for (const g of after.collectFrom) {
1367
- if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
1503
+ if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g) && !dropped.some((d) => globs([g.slice(1)])(d))) {
1504
+ out2.push(`${after.collectKey} now exempts ${JSON.stringify(g)}`);
1505
+ }
1368
1506
  }
1369
1507
  }
1370
1508
  const had = new Set(before.ignore);
@@ -1373,75 +1511,153 @@ function narrowings(before, after) {
1373
1511
  }
1374
1512
  return out2;
1375
1513
  }
1514
+ function switchWeakenings(before, after) {
1515
+ const out2 = [];
1516
+ if (before.enabled === true && after.enabled === false) out2.push("coverage.enabled switched off \u2014 the thresholds are never checked");
1517
+ if (before.perFile === true && after.perFile !== true) out2.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
1518
+ return out2;
1519
+ }
1520
+ var NYC_KEYS = METRICS.map((m) => ({
1521
+ key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1522
+ label: m,
1523
+ weakens: "lower"
1524
+ }));
1525
+ var COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
1526
+ var CODECOV = /(?:^|\/)\.?codecov\.yml$/;
1376
1527
  var SIMPLE_FILES = [
1377
1528
  {
1378
1529
  // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
1379
1530
  // [tool.coverage.report] (pyproject.toml)
1380
- file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
1531
+ file: COVERAGE_PY,
1381
1532
  keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
1382
1533
  removable: true
1383
1534
  },
1384
1535
  {
1385
1536
  // nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
1386
1537
  file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
1387
- keys: METRICS.map((m) => ({
1388
- key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
1389
- label: m,
1390
- weakens: "lower"
1391
- })),
1538
+ keys: NYC_KEYS,
1392
1539
  removable: false
1393
1540
  },
1394
1541
  {
1395
- // codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
1542
+ // codecov: `target: 90%` is the floor (lowering weakens; `auto` is a floor that
1543
+ // follows the base commit, lower than any fixed number); `threshold: 2%` is the
1396
1544
  // permitted drop (raising weakens)
1397
- file: /(?:^|\/)\.?codecov\.yml$/,
1545
+ file: CODECOV,
1398
1546
  keys: [
1399
- { key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
1547
+ { key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
1400
1548
  { key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
1401
1549
  ],
1402
- removable: false
1550
+ removable: false,
1551
+ scoped: true
1403
1552
  }
1404
1553
  ];
1405
- function numbersOf(src, key2) {
1554
+ var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
1555
+ var numOf = (s) => s === "auto" ? "auto" : Number(s);
1556
+ function numbersOf(src, key2, scoped = false) {
1406
1557
  const out2 = [];
1558
+ const stack = [];
1407
1559
  for (const line of src.split("\n")) {
1560
+ let scope = "";
1561
+ if (scoped) {
1562
+ const m = line.match(/^(\s*)([\w-]+):/);
1563
+ if (m) {
1564
+ const ind = m[1].length;
1565
+ while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1566
+ stack.push({ ind, key: m[2] });
1567
+ }
1568
+ scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1569
+ }
1408
1570
  if (key2.global) {
1409
- for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
1571
+ for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
1410
1572
  } else {
1411
1573
  const m = line.match(key2);
1412
- if (m) out2.push(Number(m[1]));
1574
+ if (m) out2.push({ v: numOf(m[1]), scope });
1413
1575
  }
1414
1576
  }
1415
1577
  return out2;
1416
1578
  }
1417
- function simpleWeakenings(path, before, after) {
1579
+ function simpleWeakeningsOf(spec, before, after) {
1418
1580
  const out2 = [];
1419
- const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1420
- if (!spec) return out2;
1421
1581
  for (const k of spec.keys) {
1422
- const b = numbersOf(before, k.key).sort((x, y) => y - x);
1423
- const a = numbersOf(after, k.key).sort((x, y) => y - x);
1424
- if (b.length === 0) continue;
1425
- if (a.length === 0) {
1426
- if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
1427
- continue;
1428
- }
1429
- if (k.weakens === "lower") {
1430
- for (let i = 0; i < b.length; i++) {
1431
- const av = a[Math.min(i, a.length - 1)];
1432
- if (av < b[i]) {
1433
- out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
1434
- break;
1582
+ const bAll = numbersOf(before, k.key, spec.scoped);
1583
+ const aAll = numbersOf(after, k.key, spec.scoped);
1584
+ const scopes = [...new Set(bAll.map((n) => n.scope))];
1585
+ for (const scope of scopes) {
1586
+ const label = scope ? `${scope} ${k.label}` : k.label;
1587
+ const desc = (x, y) => x === "auto" ? -1 : y === "auto" ? 1 : lessThan(x, y) ? 1 : lessThan(y, x) ? -1 : 0;
1588
+ const b = bAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
1589
+ const a = aAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
1590
+ if (b.length === 0) continue;
1591
+ if (a.length === 0) {
1592
+ if (spec.removable) out2.push(`${label} removed (was ${b.join(", ")})`);
1593
+ continue;
1594
+ }
1595
+ if (k.weakens === "lower") {
1596
+ for (let i = 0; i < b.length; i++) {
1597
+ const av = a[Math.min(i, a.length - 1)];
1598
+ if (lessThan(av, b[i])) {
1599
+ out2.push(`${label} lowered ${b[i]} \u2192 ${av}`);
1600
+ break;
1601
+ }
1435
1602
  }
1603
+ } else {
1604
+ const bMax = b[0];
1605
+ const aMax = a[0];
1606
+ if (lessThan(bMax, aMax)) out2.push(`${label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1436
1607
  }
1437
- } else {
1438
- const bMax = b[0];
1439
- const aMax = a[0];
1440
- if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
1441
1608
  }
1442
1609
  }
1443
1610
  return out2;
1444
1611
  }
1612
+ function listEntries(src, key2) {
1613
+ const out2 = [];
1614
+ const lines = src.split("\n");
1615
+ for (let i = 0; i < lines.length; i++) {
1616
+ const m = lines[i].match(key2);
1617
+ if (!m) continue;
1618
+ let rest = m[1] ?? "";
1619
+ let j = i + 1;
1620
+ if (rest.trim().startsWith("[") && !rest.includes("]")) {
1621
+ while (j < lines.length && !lines[j].includes("]")) rest += "," + lines[j++];
1622
+ if (j < lines.length) rest += "," + lines[j];
1623
+ } else if (rest.trim() === "") {
1624
+ while (j < lines.length && /^\s+(?:-\s+)?\S/.test(lines[j])) rest += "," + lines[j++].replace(/^\s*-\s+/, "");
1625
+ }
1626
+ for (const e of rest.replace(/[[\]]/g, "").split(",")) {
1627
+ const v = e.trim().replace(/^(['"])(.*)\1$/, "$2");
1628
+ if (v) out2.push(v);
1629
+ }
1630
+ }
1631
+ return out2;
1632
+ }
1633
+ function simpleNarrowings(path, before, after) {
1634
+ const out2 = [];
1635
+ const spec = [
1636
+ [COVERAGE_PY, /^\s*omit\s*=\s*(.*)$/, "omit now exempts"],
1637
+ [CODECOV, /^ignore:\s*(.*)$/, "codecov now ignores"]
1638
+ ];
1639
+ for (const [file, key2, what] of spec) {
1640
+ if (!file.test(path)) continue;
1641
+ const had = new Set(listEntries(before, key2));
1642
+ for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
1643
+ }
1644
+ if (CODECOV.test(path)) {
1645
+ const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1646
+ const had = off2(before);
1647
+ for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1648
+ }
1649
+ return out2;
1650
+ }
1651
+ function simpleWeakenings(path, before, after) {
1652
+ const spec = SIMPLE_FILES.find((s) => s.file.test(path));
1653
+ const out2 = spec ? simpleWeakeningsOf(spec, before, after) : [];
1654
+ if ((path.split("/").pop() ?? path) === "package.json") {
1655
+ const b = sectionText(before, "nyc");
1656
+ const a = sectionText(after, "nyc");
1657
+ if (b && a) out2.push(...simpleWeakeningsOf({ file: /./, keys: NYC_KEYS, removable: false }, b, a).map((r) => `nyc ${r}`));
1658
+ }
1659
+ return out2;
1660
+ }
1445
1661
  var SWITCHES = [
1446
1662
  {
1447
1663
  re: /--passWithNoTests\b/,
@@ -1480,6 +1696,35 @@ var SWITCHES = [
1480
1696
  }
1481
1697
  ];
1482
1698
  var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1699
+ function gateMovedElsewhere(c, changes, policy) {
1700
+ const before = c.before ?? "";
1701
+ const others = changes.filter((o) => o.kind === "file" && o !== c && o.after != null && isProtected(o.path, policy, "config"));
1702
+ if (isJsConfig(c.path)) {
1703
+ const was = parseThresholds(before);
1704
+ if (!was.present) return false;
1705
+ return others.some((o) => {
1706
+ if (!isJsConfig(o.path)) return false;
1707
+ const now = parseThresholds(o.after);
1708
+ if (!now.present) return false;
1709
+ if (now.opaque) return true;
1710
+ return METRICS.every((m) => {
1711
+ const bv = was.global?.values[m];
1712
+ const av = now.global?.values[m];
1713
+ return bv === void 0 || av !== void 0 && av >= bv;
1714
+ });
1715
+ });
1716
+ }
1717
+ const spec = SIMPLE_FILES.find((s) => s.file.test(c.path));
1718
+ if (!spec) return false;
1719
+ return spec.keys.every((k) => {
1720
+ const b = numbersOf(before, k.key, spec.scoped).map((n) => n.v);
1721
+ if (b.length === 0) return true;
1722
+ return others.some((o) => {
1723
+ const a = numbersOf(o.after, k.key, spec.scoped).map((n) => n.v);
1724
+ return a.length > 0 && b.every((bv) => a.some((av) => k.weakens === "lower" ? !lessThan(av, bv) : !lessThan(bv, av)));
1725
+ });
1726
+ });
1727
+ }
1483
1728
  var coverageLowering = {
1484
1729
  id: RULE4,
1485
1730
  surface: ["file"],
@@ -1492,10 +1737,15 @@ var coverageLowering = {
1492
1737
  if (c.after != null && c.op !== "delete") {
1493
1738
  const reasons = [];
1494
1739
  if (isJsConfig(c.path)) {
1495
- reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
1496
- reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
1740
+ const moved = () => gateMovedElsewhere(c, changes, policy);
1741
+ const b = parseThresholds(c.before ?? "");
1742
+ const a = parseThresholds(c.after);
1743
+ reasons.push(...weakenings(b, a, c.path, b.present && !a.present && moved(), ctx));
1744
+ reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after), sourceSamples(c.path, policy, ctx)));
1745
+ reasons.push(...switchWeakenings(parseSwitches(c.before ?? ""), parseSwitches(c.after)));
1497
1746
  }
1498
1747
  reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
1748
+ reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
1499
1749
  for (const reason of reasons) {
1500
1750
  out2.push(
1501
1751
  makeFinding(RULE4, policy, {
@@ -1509,7 +1759,7 @@ var coverageLowering = {
1509
1759
  } else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
1510
1760
  const before = c.before ?? "";
1511
1761
  const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
1512
- if (gated) {
1762
+ if (gated && !gateMovedElsewhere(c, changes, policy)) {
1513
1763
  out2.push(
1514
1764
  makeFinding(RULE4, policy, {
1515
1765
  file: c.path,
@@ -1606,8 +1856,33 @@ function canonical(core) {
1606
1856
  return s.replace(/\s+--\s*$/, "").trim();
1607
1857
  }
1608
1858
  var usesRef = (core) => core.replace(/@.*$/, "");
1609
- var NEUTRALISING_SUFFIX = /\|\||;|(?<!&)&(?!&)|\|(?!\|)|--passWithNoTests\b|--coverage=false\b|--coverageThreshold\b|--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b/;
1610
- function survives(removedCore, isUses, afterCores) {
1859
+ 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/;
1860
+ var NEUTRALISING_SUFFIX = new RegExp(
1861
+ `\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
1862
+ );
1863
+ var TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
1864
+ var PATH_SHAPED = /\/|\.(?:test|spec)\./;
1865
+ function narrowedByPositional(args) {
1866
+ const toks = args.trim().split(/\s+/).filter(Boolean);
1867
+ for (let i = 0; i < toks.length; i++) {
1868
+ const t = toks[i];
1869
+ if (t === "--" || t.startsWith("-")) continue;
1870
+ const prev = toks[i - 1];
1871
+ if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
1872
+ if (/[<>|;&]/.test(t)) continue;
1873
+ if (PATH_SHAPED.test(t)) return true;
1874
+ }
1875
+ return false;
1876
+ }
1877
+ function checkKind(core) {
1878
+ if (!invokesCheck(core)) return null;
1879
+ const s = canonical(core);
1880
+ if (/\btamperward\b/.test(s)) return "gate";
1881
+ if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
1882
+ if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
1883
+ return "test";
1884
+ }
1885
+ function survives(removedCore, isUses, afterCores, addedCores) {
1611
1886
  if (isUses) {
1612
1887
  const ref = usesRef(removedCore);
1613
1888
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
@@ -1619,15 +1894,33 @@ function survives(removedCore, isUses, afterCores) {
1619
1894
  if (a === r) return { state: "kept" };
1620
1895
  if (a.startsWith(r + " ")) {
1621
1896
  const rest = a.slice(r.length);
1622
- if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
1897
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
1623
1898
  else return { state: "kept" };
1624
1899
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1625
1900
  return { state: "kept" };
1901
+ } else {
1902
+ const w = a.match(TIMEOUT_WRAP);
1903
+ if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
1904
+ }
1905
+ }
1906
+ const kind = checkKind(removedCore);
1907
+ let respelled = false;
1908
+ for (const raw of addedCores) {
1909
+ if (!kind || checkKind(raw) !== kind) continue;
1910
+ const a = canonical(raw);
1911
+ if (a.match(TIMEOUT_WRAP)) {
1912
+ neutralised ??= raw;
1913
+ continue;
1626
1914
  }
1915
+ const args = a.slice(a.indexOf(" ") + 1 || a.length);
1916
+ if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
1917
+ else respelled = true;
1627
1918
  }
1919
+ if (respelled) return { state: "kept" };
1628
1920
  return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
1629
1921
  }
1630
1922
  var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
1923
+ var TRUTHY = Symbol("truthy");
1631
1924
  function lex(src) {
1632
1925
  const out2 = [];
1633
1926
  let i = 0;
@@ -1658,13 +1951,13 @@ function lex(src) {
1658
1951
  i += num[0].length;
1659
1952
  continue;
1660
1953
  }
1661
- const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
1954
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
1662
1955
  if (op) {
1663
1956
  out2.push({ t: "op", v: op[0] });
1664
1957
  i += op[0].length;
1665
1958
  continue;
1666
1959
  }
1667
- const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
1960
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
1668
1961
  if (id) {
1669
1962
  out2.push({ t: "id", v: id[0] });
1670
1963
  i += id[0].length;
@@ -1674,15 +1967,16 @@ function lex(src) {
1674
1967
  }
1675
1968
  return out2;
1676
1969
  }
1970
+ var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
1677
1971
  function foldConst(src) {
1678
1972
  const toks = lex(src);
1679
1973
  if (!toks) return void 0;
1680
1974
  let p = 0;
1681
1975
  const peek = () => toks[p];
1682
1976
  const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
1683
- const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
1684
- const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1977
+ const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
1685
1978
  const eq = (a, b) => {
1979
+ if (a === TRUTHY || b === TRUTHY) return void 0;
1686
1980
  if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
1687
1981
  if (a === null && b === null) return true;
1688
1982
  const x = num(a);
@@ -1693,8 +1987,9 @@ function foldConst(src) {
1693
1987
  let l = and();
1694
1988
  while (eat("||")) {
1695
1989
  const r = and();
1696
- if (l === void 0 || r === void 0) return void 0;
1697
- l = truthy(l) ? l : r;
1990
+ if (l !== void 0 && truthy(l)) continue;
1991
+ if (l !== void 0) l = r;
1992
+ else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
1698
1993
  }
1699
1994
  return l;
1700
1995
  };
@@ -1702,8 +1997,9 @@ function foldConst(src) {
1702
1997
  let l = cmp();
1703
1998
  while (eat("&&")) {
1704
1999
  const r = cmp();
1705
- if (l === void 0 || r === void 0) return void 0;
1706
- l = truthy(l) ? r : l;
2000
+ if (l !== void 0 && !truthy(l)) continue;
2001
+ if (l !== void 0) l = r;
2002
+ else l = r !== void 0 && !truthy(r) ? false : void 0;
1707
2003
  }
1708
2004
  return l;
1709
2005
  };
@@ -1714,14 +2010,18 @@ function foldConst(src) {
1714
2010
  if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
1715
2011
  p++;
1716
2012
  const r = unary();
1717
- if (l === void 0 || r === void 0) return void 0;
1718
- if (t.v === "==") l = eq(l, r);
1719
- else if (t.v === "!=") l = !eq(l, r);
1720
- else {
2013
+ if (l === void 0 || r === void 0) {
2014
+ l = void 0;
2015
+ continue;
2016
+ }
2017
+ if (t.v === "==" || t.v === "!=") {
2018
+ const e = eq(l, r);
2019
+ l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
2020
+ } else {
1721
2021
  const x = num(l);
1722
2022
  const y = num(r);
1723
- if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
1724
- l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
2023
+ if (Number.isNaN(x) || Number.isNaN(y)) l = void 0;
2024
+ else l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
1725
2025
  }
1726
2026
  }
1727
2027
  };
@@ -1746,6 +2046,19 @@ function foldConst(src) {
1746
2046
  if (/^true$/i.test(t.v)) return true;
1747
2047
  if (/^false$/i.test(t.v)) return false;
1748
2048
  if (/^null$/i.test(t.v)) return null;
2049
+ if (eat("(")) {
2050
+ if (!eat(")")) {
2051
+ for (; ; ) {
2052
+ or();
2053
+ if (eat(")")) break;
2054
+ if (!eat(",")) return void 0;
2055
+ }
2056
+ }
2057
+ }
2058
+ while (eat("[")) {
2059
+ or();
2060
+ if (!eat("]")) return void 0;
2061
+ }
1749
2062
  return void 0;
1750
2063
  }
1751
2064
  return void 0;
@@ -1762,7 +2075,7 @@ function isAlwaysFalse(raw) {
1762
2075
  const e = expressionOf(raw);
1763
2076
  if (e === "") return false;
1764
2077
  const v = foldConst(e);
1765
- return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
2078
+ return v !== void 0 && !truthy(v);
1766
2079
  }
1767
2080
  function isTruthy(raw) {
1768
2081
  const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
@@ -1770,7 +2083,7 @@ function isTruthy(raw) {
1770
2083
  const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
1771
2084
  if (!m) return false;
1772
2085
  const f = foldConst(m[1]);
1773
- return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
2086
+ return f !== void 0 && truthy(f);
1774
2087
  }
1775
2088
  var indentOf = (l) => l.match(/^\s*/)[0].length;
1776
2089
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
@@ -1784,7 +2097,21 @@ function stepOf(lines, i) {
1784
2097
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
1785
2098
  return [s, e];
1786
2099
  }
2100
+ function jobOf(lines, i) {
2101
+ const ind = indentOf(lines[i]);
2102
+ let h = i - 1;
2103
+ while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
2104
+ if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
2105
+ let t = h - 1;
2106
+ while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
2107
+ if (t < 0 || !/^jobs:/.test(lines[t])) return null;
2108
+ const hi = indentOf(lines[h]);
2109
+ let e = i + 1;
2110
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
2111
+ return [h, e];
2112
+ }
1787
2113
  var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
2114
+ var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l) || /^\s*uses:\s*\S/.test(l));
1788
2115
  function runBlocks(lines) {
1789
2116
  const out2 = [];
1790
2117
  for (let i = 0; i < lines.length; i++) {
@@ -1802,13 +2129,28 @@ var NEUTRALISERS = [
1802
2129
  { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
1803
2130
  { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
1804
2131
  ];
2132
+ var LIST_KEYS = {
2133
+ branches: "branches",
2134
+ "branches-ignore": "branchesIgnore",
2135
+ "paths-ignore": "pathsIgnore",
2136
+ paths: "paths",
2137
+ types: "types"
2138
+ };
1805
2139
  function flowList(v) {
1806
2140
  const m = v.trim().match(/^\[(.*)\]$/);
1807
2141
  if (!m) return null;
1808
2142
  return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
1809
2143
  }
1810
2144
  function parseTriggers(src) {
1811
- const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
2145
+ const t = {
2146
+ events: /* @__PURE__ */ new Set(),
2147
+ branches: /* @__PURE__ */ new Map(),
2148
+ branchesIgnore: /* @__PURE__ */ new Map(),
2149
+ pathsIgnore: /* @__PURE__ */ new Map(),
2150
+ paths: /* @__PURE__ */ new Map(),
2151
+ types: /* @__PURE__ */ new Map(),
2152
+ present: false
2153
+ };
1812
2154
  const lines = src.split("\n");
1813
2155
  const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
1814
2156
  if (i < 0) return t;
@@ -1816,8 +2158,8 @@ function parseTriggers(src) {
1816
2158
  const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
1817
2159
  const inline = uncommented(head);
1818
2160
  if (inline) {
1819
- const list = flowList(inline);
1820
- for (const e of list ?? [inline]) t.events.add(e);
2161
+ const list2 = flowList(inline);
2162
+ for (const e of list2 ?? [inline]) t.events.add(e);
1821
2163
  return t;
1822
2164
  }
1823
2165
  let event = null;
@@ -1844,47 +2186,92 @@ function parseTriggers(src) {
1844
2186
  listKey = null;
1845
2187
  continue;
1846
2188
  }
1847
- if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
1848
- listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
2189
+ const key2 = LIST_KEYS[kv[1]];
2190
+ if (event && key2) {
2191
+ listKey = key2;
1849
2192
  listInd = ind;
1850
- const list = flowList(kv[2]);
1851
- t[listKey].set(event, list ?? []);
2193
+ const list2 = flowList(kv[2]);
2194
+ t[listKey].set(event, list2 ?? []);
1852
2195
  } else listKey = null;
1853
2196
  }
1854
2197
  return t;
1855
2198
  }
1856
2199
  var DEFAULT_BRANCH = /^(?:main|master)$/;
1857
2200
  var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
1858
- function triggerNarrowings(before, after) {
2201
+ var CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
2202
+ var PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
2203
+ function ghGlob(g) {
2204
+ const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
2205
+ try {
2206
+ const r = new RegExp(`^${re}$`);
2207
+ return (p) => r.test(p);
2208
+ } catch {
2209
+ return () => false;
2210
+ }
2211
+ }
2212
+ function pathsMatchSource(globs4, sources) {
2213
+ const ms = globs4.map((g) => ({ neg: g.startsWith("!"), m: ghGlob(g.startsWith("!") ? g.slice(1) : g) }));
2214
+ return sources.some((s) => {
2215
+ let on = false;
2216
+ for (const { neg, m } of ms) if (m(s)) on = !neg;
2217
+ return on;
2218
+ });
2219
+ }
2220
+ 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"];
2221
+ function sourceProbes(ctx) {
2222
+ const files = trackedFiles(ctx);
2223
+ if (!files) return CODE_PROBES;
2224
+ const code = files.filter((f) => langOf(f) !== null);
2225
+ return code.length ? code : CODE_PROBES;
2226
+ }
2227
+ function triggerNarrowings(before, after, opts) {
1859
2228
  const out2 = [];
1860
2229
  if (!before.present || !after.present) return out2;
1861
- for (const e of ["push", "pull_request", "pull_request_target"]) {
2230
+ const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
2231
+ const namesDefault = (l) => l.some(isDefault);
2232
+ for (const e of CODE_EVENTS) {
1862
2233
  if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
1863
2234
  }
1864
- for (const [e, list] of after.pathsIgnore) {
2235
+ for (const [e, list2] of after.pathsIgnore) {
1865
2236
  const had = before.pathsIgnore.get(e) ?? [];
1866
- if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2237
+ if (list2.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
2238
+ }
2239
+ for (const [e, list2] of after.paths) {
2240
+ if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
2241
+ const had = before.paths.get(e);
2242
+ if (had && !pathsMatchSource(had, opts.sources)) continue;
2243
+ if (!pathsMatchSource(list2, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list2.join(", ")}]) \u2014 the workflow never runs on code`);
2244
+ }
2245
+ for (const [e, list2] of after.types) {
2246
+ if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
2247
+ const had = before.types.get(e);
2248
+ if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
2249
+ if (!list2.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list2.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
1867
2250
  }
1868
- for (const [e, list] of after.branchesIgnore) {
2251
+ for (const [e, list2] of after.branchesIgnore) {
1869
2252
  const had = before.branchesIgnore.get(e) ?? [];
1870
- if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
2253
+ if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
1871
2254
  }
1872
2255
  for (const [e, was] of before.branches) {
1873
2256
  const now = after.branches.get(e);
1874
2257
  if (!now || !after.events.has(e)) continue;
1875
2258
  if (now.some(ALL)) continue;
1876
- const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
1877
- const replaced = was.length > 0 && !was.some((b) => now.includes(b));
2259
+ const lostDefault = namesDefault(was) && !namesDefault(now);
2260
+ const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
1878
2261
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
1879
2262
  }
1880
2263
  return out2;
1881
2264
  }
2265
+ var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
1882
2266
  var ciTampering = {
1883
2267
  id: RULE5,
1884
2268
  surface: ["file"],
1885
2269
  certainty: "mechanical",
1886
- run(changes, policy) {
2270
+ run(changes, policy, _view, ctx) {
1887
2271
  const out2 = [];
2272
+ const afterByPath = /* @__PURE__ */ new Map();
2273
+ for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
2274
+ let triggerOpts = null;
1888
2275
  for (const c of changes) {
1889
2276
  if (c.kind !== "file") continue;
1890
2277
  if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
@@ -1907,6 +2294,10 @@ var ciTampering = {
1907
2294
  if (!coe && !cond) continue;
1908
2295
  const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
1909
2296
  if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
2297
+ if (afterLines && !step && l.newLine != null) {
2298
+ const job = jobOf(afterLines, l.newLine - 1);
2299
+ if (job && !jobHasCheck(afterLines, job)) continue;
2300
+ }
1910
2301
  if (coe && isTruthy(coe[1])) {
1911
2302
  out2.push(
1912
2303
  makeFinding(RULE5, policy, {
@@ -1964,25 +2355,35 @@ var ciTampering = {
1964
2355
  );
1965
2356
  }
1966
2357
  if (c.before != null) {
1967
- for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
2358
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
2359
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
1968
2360
  out2.push(
1969
2361
  makeFinding(RULE5, policy, {
1970
2362
  file: c.path,
1971
2363
  message: `The workflow's triggers were narrowed: ${reason}.`,
1972
2364
  evidence: reason,
1973
- remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
2365
+ remediation: "Keep the workflow running on push and pull_request for the default branch and its source paths; a check that never runs is no check."
1974
2366
  })
1975
2367
  );
1976
2368
  }
1977
2369
  }
1978
2370
  }
1979
2371
  const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
2372
+ const addedCores = addedLines(c).map((l) => commandCore(l.content));
2373
+ for (const l of afterLines ?? []) {
2374
+ const reused = l.match(REUSED_WORKFLOW)?.[1];
2375
+ const content = reused ? afterByPath.get(reused) : void 0;
2376
+ if (content == null || reused === c.path) continue;
2377
+ const cores = content.split("\n").map(commandCore);
2378
+ afterCores.push(...cores);
2379
+ addedCores.push(...cores);
2380
+ }
1980
2381
  for (const l of removedLines(c)) {
1981
2382
  if (/^\s*#/.test(l.content)) continue;
1982
2383
  const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
1983
2384
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
1984
2385
  if (!isUsesLine && !isCheckCommand) continue;
1985
- const s = survives(commandCore(l.content), isUsesLine, afterCores);
2386
+ const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
1986
2387
  if (s.state === "kept") continue;
1987
2388
  if (s.state === "neutralised") {
1988
2389
  out2.push(
@@ -1990,7 +2391,7 @@ var ciTampering = {
1990
2391
  file: c.path,
1991
2392
  message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
1992
2393
  evidence: `${l.content.trim()} \u2192 ${s.by}`,
1993
- remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
2394
+ remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
1994
2395
  })
1995
2396
  );
1996
2397
  continue;
@@ -2010,12 +2411,59 @@ var ciTampering = {
2010
2411
  };
2011
2412
 
2012
2413
  // src/detectors/hook-tampering.ts
2013
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
2014
- import { isAbsolute, join as join2, relative } from "node:path";
2414
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
2415
+ import { isAbsolute, join as join3, relative } from "node:path";
2416
+
2417
+ // src/wiring.ts
2418
+ import { readFileSync as readFileSync2 } from "node:fs";
2419
+ import { dirname, join as join2 } from "node:path";
2420
+ import { fileURLToPath } from "node:url";
2421
+ function shippedVersion() {
2422
+ try {
2423
+ const here = dirname(fileURLToPath(import.meta.url));
2424
+ for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
2425
+ try {
2426
+ const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
2427
+ if (pkg.name === "tamperward" && pkg.version) return pkg.version;
2428
+ } catch {
2429
+ }
2430
+ }
2431
+ } catch {
2432
+ }
2433
+ return "latest";
2434
+ }
2435
+ var TW_VERSION = shippedVersion();
2436
+ var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
2437
+ var PRE_MATCHER = PRE_TOOLS.join("|");
2438
+ var MARKER = "# tamperward: block agent shortcuts before they land";
2439
+ var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
2440
+ var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
2441
+ var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
2442
+ var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
2443
+ var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
2444
+ function compareVersions(a, b) {
2445
+ if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
2446
+ const pa = a.split(".").map(Number);
2447
+ const pb = b.split(".").map(Number);
2448
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
2449
+ return 0;
2450
+ }
2451
+ function pinNotBelow(now, floor) {
2452
+ if (!now) return false;
2453
+ const c = compareVersions(now, floor);
2454
+ return c !== null && c >= 0;
2455
+ }
2456
+ var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
2457
+ function initScriptPin(src) {
2458
+ const m = src.match(INIT_SCRIPT);
2459
+ return m ? m[1] ?? "" : null;
2460
+ }
2461
+ var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
2462
+ var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
2015
2463
 
2016
2464
  // src/detectors/policy-diff.ts
2017
2465
  import { parse } from "yaml";
2018
- var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2466
+ var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
2019
2467
  function safeParse(src) {
2020
2468
  try {
2021
2469
  const v = parse(src);
@@ -2032,13 +2480,13 @@ function effective(raw) {
2032
2480
  const rules = { ...base.rules };
2033
2481
  for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
2034
2482
  const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
2035
- rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
2483
+ rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
2036
2484
  }
2037
- const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
2485
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
2038
2486
  return {
2039
2487
  version,
2040
2488
  rules,
2041
- ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
2489
+ ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
2042
2490
  protected: mergeProtected(base.protected, userProtected),
2043
2491
  requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
2044
2492
  ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
@@ -2155,7 +2603,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
2155
2603
  var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
2156
2604
  var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
2157
2605
  var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
2158
- var PREFIX = /^(?:(?:then|do|else|\{|\(|!|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2606
+ var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
2159
2607
  function checkIdentity(segment) {
2160
2608
  const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
2161
2609
  const viaRunner = RUNNER.test(core);
@@ -2166,20 +2614,49 @@ function checkIdentity(segment) {
2166
2614
  if (tool === "tamperward") {
2167
2615
  const sub = toks[1];
2168
2616
  if (!sub || sub.startsWith("-")) return null;
2169
- return `tamperward ${sub}`;
2617
+ const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
2618
+ return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
2170
2619
  }
2171
2620
  if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
2172
2621
  return tool;
2173
2622
  }
2623
+ function pinOfSegment(segment) {
2624
+ const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
2625
+ return m ? m[1] ?? "" : null;
2626
+ }
2174
2627
  function split(stmt) {
2175
2628
  const segs = [];
2176
2629
  const ops = [];
2177
- const re = /(&&|\|\||;|\|)/g;
2630
+ let depth = 0;
2631
+ let single = false;
2632
+ let double = false;
2178
2633
  let last = 0;
2179
- for (const m of stmt.matchAll(re)) {
2180
- segs.push(stmt.slice(last, m.index));
2181
- ops.push(m[1]);
2182
- last = (m.index ?? 0) + m[1].length;
2634
+ for (let i = 0; i < stmt.length; i++) {
2635
+ const ch = stmt[i];
2636
+ if (ch === "'" && !double) {
2637
+ single = !single;
2638
+ continue;
2639
+ }
2640
+ if (ch === '"' && !single) {
2641
+ double = !double;
2642
+ continue;
2643
+ }
2644
+ if (single || double) continue;
2645
+ if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
2646
+ depth++;
2647
+ continue;
2648
+ }
2649
+ if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
2650
+ depth = Math.max(0, depth - 1);
2651
+ continue;
2652
+ }
2653
+ if (depth > 0) continue;
2654
+ const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
2655
+ if (!op) continue;
2656
+ segs.push(stmt.slice(last, i));
2657
+ ops.push(op);
2658
+ i += op.length - 1;
2659
+ last = i + 1;
2183
2660
  }
2184
2661
  segs.push(stmt.slice(last));
2185
2662
  return { segs, ops };
@@ -2228,106 +2705,602 @@ function alwaysFalse(cond) {
2228
2705
  if (/^(?:""|'')$/.test(inner)) return true;
2229
2706
  return false;
2230
2707
  }
2231
- var alwaysTrue = (cond) => /^(?:true|:|\[\s+1\s+-eq\s+1\s+\]|\[\s+-n\s+"?\S"?\s+\])$/.test(cond.trim().replace(/;$/, "").trim());
2708
+ var alwaysTrue = (cond) => {
2709
+ const c = cond.trim().replace(/;$/, "").trim();
2710
+ if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
2711
+ const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
2712
+ if (!m) return false;
2713
+ const inner = (m[1] ?? m[2] ?? m[3]).trim();
2714
+ if (inner.includes("$")) return false;
2715
+ const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
2716
+ if (parts) {
2717
+ const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
2718
+ if (parts[2].startsWith("-") && !ints) return false;
2719
+ return !alwaysFalse(c);
2720
+ }
2721
+ if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
2722
+ if (/^\S+$/.test(inner)) return unq(inner) !== "";
2723
+ return false;
2724
+ };
2232
2725
  var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
2233
2726
  var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
2234
- var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
2235
2727
  function insertsPassingExit(line) {
2236
2728
  if (isComment(line)) return false;
2237
- return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
2729
+ return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
2238
2730
  }
2239
2731
  function insertsDeadGuard(line) {
2240
2732
  if (isComment(line)) return false;
2241
2733
  const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2242
2734
  return !!m && alwaysFalse(m[1]);
2243
2735
  }
2244
- function invocations(lines) {
2736
+ function joinContinued(lines) {
2245
2737
  const out2 = [];
2246
- const pipefail = lines.some((l) => /\bpipefail\b/.test(stripComment(l)));
2247
- const frames = [{ dead: false }];
2738
+ let cur = null;
2739
+ for (const l of lines) {
2740
+ cur = cur === null ? l : cur + " " + l.trimStart();
2741
+ const m = cur.match(/(\\+)$/);
2742
+ if (m && m[1].length % 2 === 1 && !isComment(cur)) {
2743
+ cur = cur.slice(0, -1);
2744
+ continue;
2745
+ }
2746
+ out2.push(cur);
2747
+ cur = null;
2748
+ }
2749
+ if (cur !== null) out2.push(cur);
2750
+ return out2;
2751
+ }
2752
+ function stripHeredocs(lines) {
2753
+ const out2 = [];
2754
+ for (let i = 0; i < lines.length; i++) {
2755
+ const l = lines[i];
2756
+ out2.push(l);
2757
+ if (isComment(l)) continue;
2758
+ const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
2759
+ if (!m) continue;
2760
+ const word = m[2] ?? m[3] ?? m[4];
2761
+ const dash = m[1] === "-";
2762
+ for (i++; i < lines.length; i++) {
2763
+ const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
2764
+ if (t === word) break;
2765
+ }
2766
+ }
2767
+ return out2;
2768
+ }
2769
+ function explodeKeywords(line) {
2770
+ if (isComment(line)) return [line];
2771
+ let s = stripComment(line);
2772
+ s = s.replace(/;;/g, "\n;;");
2773
+ s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
2774
+ s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
2775
+ s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
2776
+ s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
2777
+ return s.split("\n");
2778
+ }
2779
+ function closingBrace(line, depth) {
2780
+ let single = false;
2781
+ let double = false;
2782
+ let expansion = 0;
2783
+ for (let i = 0; i < line.length; i++) {
2784
+ const ch = line[i];
2785
+ if (ch === "'" && !double) single = !single;
2786
+ else if (ch === '"' && !single) double = !double;
2787
+ else if (single) continue;
2788
+ else if (ch === "$" && line[i + 1] === "{") {
2789
+ expansion++;
2790
+ i++;
2791
+ } else if (ch === "}" && expansion > 0) expansion--;
2792
+ else if (ch === "{" && !double) depth++;
2793
+ else if (ch === "}" && !double && --depth === 0) return i;
2794
+ }
2795
+ return -1;
2796
+ }
2797
+ function braceDelta(line) {
2798
+ let d = 0;
2799
+ let single = false;
2800
+ let double = false;
2801
+ let expansion = 0;
2802
+ for (let i = 0; i < line.length; i++) {
2803
+ const ch = line[i];
2804
+ if (ch === "'" && !double) single = !single;
2805
+ else if (ch === '"' && !single) double = !double;
2806
+ else if (single) continue;
2807
+ else if (ch === "$" && line[i + 1] === "{") {
2808
+ expansion++;
2809
+ i++;
2810
+ } else if (ch === "}" && expansion > 0) expansion--;
2811
+ else if (ch === "{" && !double) d++;
2812
+ else if (ch === "}" && !double) d--;
2813
+ }
2814
+ return d;
2815
+ }
2816
+ var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
2817
+ function prepare(lines, opts) {
2818
+ const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
2819
+ const top = [];
2820
+ const fns = /* @__PURE__ */ new Map();
2821
+ const shadowed = /* @__PURE__ */ new Set();
2822
+ let errexit = opts.errexit ?? false;
2823
+ let pipefail = false;
2824
+ if (flat[0]?.startsWith("#!")) {
2825
+ const sb = flat[0];
2826
+ if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
2827
+ if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
2828
+ }
2829
+ for (let i = 0; i < flat.length; i++) {
2830
+ const raw = flat[i];
2831
+ if (isComment(raw)) {
2832
+ top.push(raw);
2833
+ continue;
2834
+ }
2835
+ const stmt = stripComment(raw).trim();
2836
+ const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
2837
+ if (al) shadowed.add(al[1]);
2838
+ const def = stmt.match(FN_DEF);
2839
+ if (!def) {
2840
+ top.push(raw);
2841
+ continue;
2842
+ }
2843
+ const name = def[1] ?? def[3];
2844
+ shadowed.add(name);
2845
+ let rest = (def[2] ?? def[4] ?? "").trim();
2846
+ if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
2847
+ i++;
2848
+ rest = flat[i].trim();
2849
+ }
2850
+ const body = [];
2851
+ if (!rest.startsWith("{")) {
2852
+ fns.set(name, body);
2853
+ continue;
2854
+ }
2855
+ rest = rest.slice(1);
2856
+ const after = (line, close2) => {
2857
+ const t = line.slice(0, close2);
2858
+ if (t.trim()) body.push(t);
2859
+ const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
2860
+ if (remainder.trim()) top.push(remainder);
2861
+ };
2862
+ let close = closingBrace(rest, 1);
2863
+ if (close !== -1) {
2864
+ after(rest, close);
2865
+ } else {
2866
+ if (rest.trim()) body.push(rest);
2867
+ let depth = 1 + braceDelta(rest);
2868
+ for (i++; i < flat.length; i++) {
2869
+ const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
2870
+ close = closingBrace(line, depth);
2871
+ if (close !== -1) {
2872
+ after(line, close);
2873
+ break;
2874
+ }
2875
+ depth += braceDelta(line);
2876
+ body.push(flat[i]);
2877
+ }
2878
+ }
2879
+ fns.set(name, body);
2880
+ }
2881
+ return { top, fns, shadowed, pipefail, errexit };
2882
+ }
2883
+ var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
2884
+ function statusAfterFailure(seg, vars) {
2885
+ const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
2886
+ let status = "fail";
2887
+ for (const piece of inner.split(/;|&&|\|\||\|/)) {
2888
+ const w = piece.trim().replace(PREFIX, "").split(/\s+/);
2889
+ switch (w[0]) {
2890
+ case "":
2891
+ break;
2892
+ case "true":
2893
+ case ":":
2894
+ status = "ok";
2895
+ break;
2896
+ case "false":
2897
+ status = "fail";
2898
+ break;
2899
+ case "kill":
2900
+ status = w[1] === "-0" ? "ok" : "exit";
2901
+ break;
2902
+ case "exit":
2903
+ case "return": {
2904
+ const arg = unq(w[1] ?? "");
2905
+ if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
2906
+ if (/^0+$/.test(arg)) return "ok";
2907
+ if (/^[1-9]\d*$/.test(arg)) return "exit";
2908
+ const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
2909
+ const known = v ? vars.get(v[1]) : void 0;
2910
+ return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
2911
+ }
2912
+ default:
2913
+ status = "ok";
2914
+ }
2915
+ }
2916
+ return status;
2917
+ }
2918
+ function constStatus(seg) {
2919
+ const s = seg.trim().replace(/^!\s+/, "");
2920
+ if (alwaysTrue(s)) return "ok";
2921
+ if (alwaysFalse(s)) return "fail";
2922
+ return "unknown";
2923
+ }
2924
+ function reaches(segs, ops, i) {
2925
+ let status = "ok";
2926
+ let certain = true;
2927
+ for (let j = 0; j < i; j++) {
2928
+ const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
2929
+ if (!runs) continue;
2930
+ const s = constStatus(segs[j]);
2931
+ if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
2932
+ if (ops[j] === ";" || ops[j] === "|") {
2933
+ status = "ok";
2934
+ certain = certain && true;
2935
+ continue;
2936
+ }
2937
+ status = s;
2938
+ }
2939
+ const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
2940
+ if (!gate) return "no";
2941
+ if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
2942
+ if (!certain || status === "unknown") return "maybe";
2943
+ return "yes";
2944
+ }
2945
+ function literal(arg, vars) {
2946
+ const a = unq(arg.trim());
2947
+ if (/[`]|\$\(/.test(a)) return null;
2948
+ let unknown = false;
2949
+ const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
2950
+ const v = vars.get(n);
2951
+ if (v === void 0) unknown = true;
2952
+ return v ?? "";
2953
+ });
2954
+ return unknown ? null : r;
2955
+ }
2956
+ function cdStaysHome(arg, vars) {
2957
+ const a = arg.trim();
2958
+ if (/rev-parse\s+--show-toplevel/.test(a)) return true;
2959
+ const lit = literal(a, vars);
2960
+ if (lit === null) return null;
2961
+ return lit === "" || lit === "." || lit === "./";
2962
+ }
2963
+ function walk(lines, st, topIdx, depth) {
2964
+ const frames = [{ dead: false, exhausted: false, kind: "base" }];
2248
2965
  const dead = () => frames.some((f) => f.dead);
2249
- for (const raw of lines) {
2966
+ const conditional = () => frames.length > 1;
2967
+ let exits = false;
2968
+ for (let i = 0; i < lines.length; i++) {
2969
+ const raw = lines[i];
2970
+ const idx = topIdx(i);
2250
2971
  if (isComment(raw)) {
2251
2972
  const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
2252
- if (id) out2.push({ identity: id, line: raw, state: "comment" });
2973
+ if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
2253
2974
  continue;
2254
2975
  }
2255
- const stmt = stripComment(raw).trim();
2976
+ let stmt = stripComment(raw).trim();
2256
2977
  if (!stmt) continue;
2257
- const ifm = stmt.match(/^(if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
2978
+ const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
2258
2979
  if (ifm) {
2259
- if (ifm[1] === "if") frames.push({ dead: alwaysFalse(ifm[2]) });
2260
- else if (frames.length > 1) frames[frames.length - 1].dead = alwaysFalse(ifm[2]);
2980
+ const cond = ifm[2].replace(/;\s*$/, "");
2981
+ const cid = checkIdentity(cond.replace(/^!\s+/, ""));
2982
+ if (cid) st.out.push({ identity: cid, line: raw, state: dead() ? "unreachable" : "neutered", why: "used as an `if` condition \u2014 its failure selects a branch instead of failing the hook", idx, errexit: st.errexit, passthrough: false });
2983
+ if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
2984
+ else if (frames.length > 1) {
2985
+ const f = frames[frames.length - 1];
2986
+ f.dead = f.exhausted || alwaysFalse(cond);
2987
+ f.exhausted = f.exhausted || alwaysTrue(cond);
2988
+ }
2261
2989
  continue;
2262
2990
  }
2263
2991
  if (/^else\b/.test(stmt)) {
2264
- if (frames.length > 1) frames[frames.length - 1].dead = false;
2992
+ if (frames.length > 1) {
2993
+ const f = frames[frames.length - 1];
2994
+ f.dead = f.exhausted;
2995
+ }
2265
2996
  continue;
2266
2997
  }
2267
- if (/^fi\b/.test(stmt)) {
2998
+ if (/^(?:fi|done|esac)\b/.test(stmt)) {
2268
2999
  if (frames.length > 1) frames.pop();
2269
3000
  continue;
2270
3001
  }
3002
+ if (/^(?:then|do)\b/.test(stmt)) continue;
3003
+ const loop = stmt.match(/^(while|until)\s+(.+)$/);
3004
+ if (loop) {
3005
+ const cond = loop[2].replace(/;\s*$/, "");
3006
+ frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
3007
+ continue;
3008
+ }
3009
+ const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
3010
+ if (forl) {
3011
+ const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
3012
+ frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
3013
+ continue;
3014
+ }
3015
+ const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
3016
+ if (casem) {
3017
+ frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
3018
+ continue;
3019
+ }
3020
+ const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
3021
+ if (pat) {
3022
+ const f = frames[frames.length - 1];
3023
+ const word = f.word ?? "";
3024
+ const patterns = pat[1].split("|").map((p) => unq(p.trim()));
3025
+ const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
3026
+ f.dead = !may;
3027
+ stmt = pat[2].trim();
3028
+ if (!stmt) continue;
3029
+ }
3030
+ if (/^;;$/.test(stmt)) continue;
3031
+ if (/^[{}()]$/.test(stmt)) continue;
3032
+ const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
3033
+ if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
2271
3034
  const { segs, ops } = split(stmt);
2272
- for (let i = 0; i < segs.length; i++) {
2273
- const seg = segs[i].trim().replace(PREFIX, "");
3035
+ for (let s = 0; s < segs.length; s++) {
3036
+ let seg = segs[s].trim();
3037
+ const inverted = /^!\s+/.test(seg);
3038
+ seg = seg.replace(/^!\s+/, "");
3039
+ const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
3040
+ seg = seg.replace(PREFIX, "");
3041
+ const reach = dead() ? "no" : reaches(segs, ops, s);
3042
+ const first = seg.split(/\s+/)[0] ?? "";
3043
+ if (reach !== "no") {
3044
+ const certain = reach === "yes" && !conditional();
3045
+ const set = seg.match(/^set\s+(.+)$/);
3046
+ if (set) {
3047
+ const toks = set[1].split(/\s+/);
3048
+ for (let t = 0; t < toks.length; t++) {
3049
+ const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
3050
+ if (!m) continue;
3051
+ const on = m[1] === "-";
3052
+ const flip = (cur) => on ? certain ? true : cur : false;
3053
+ if (m[2].includes("e")) st.errexit = flip(st.errexit);
3054
+ if (m[2].includes("o")) {
3055
+ const name = toks[++t];
3056
+ if (name === "errexit") st.errexit = flip(st.errexit);
3057
+ if (name === "pipefail") st.pipefail = flip(st.pipefail);
3058
+ }
3059
+ }
3060
+ }
3061
+ const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
3062
+ if (trap) {
3063
+ const action = trap[1] ?? trap[2] ?? trap[3];
3064
+ const sigs = trap[4].split(/\s+/);
3065
+ if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
3066
+ }
3067
+ const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
3068
+ if (asg) {
3069
+ const v = certain ? literal(asg[2], st.vars) : null;
3070
+ if (v === null) st.vars.delete(asg[1]);
3071
+ else st.vars.set(asg[1], v);
3072
+ }
3073
+ if (first === "cd") {
3074
+ const home = cdStaysHome(seg.slice(2), st.vars);
3075
+ if (home === false) st.cdAway = true;
3076
+ else if (home === true && certain) st.cdAway = false;
3077
+ }
3078
+ }
2274
3079
  const id = checkIdentity(seg);
2275
3080
  if (!id) {
2276
- if (EXIT_ANY.test(seg) && !dead() && (i === 0 || ops[i - 1] === ";")) frames[frames.length - 1].dead = true;
3081
+ if (reach === "yes") {
3082
+ if (EXIT_ANY.test(seg)) {
3083
+ if (!conditional()) {
3084
+ frames[0].dead = true;
3085
+ if (/^exit\b/.test(seg)) exits = true;
3086
+ } else if (/^exit\b/.test(seg) && depth > 0) {
3087
+ }
3088
+ } else if (execs && !conditional()) {
3089
+ frames[0].dead = true;
3090
+ } else if (st.fns.has(first) && reach === "yes") {
3091
+ const start = st.out.length;
3092
+ const body = st.fns.get(first) ?? [];
3093
+ const sub = { ...st, out: st.out };
3094
+ const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
3095
+ st.errexit = sub.errexit;
3096
+ st.pipefail = sub.pipefail;
3097
+ st.cdAway = sub.cdAway;
3098
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3099
+ for (let k = start; k < st.out.length; k++) {
3100
+ const inv = st.out[k];
3101
+ if (inv.state === "live" && verdict2.state !== "live") {
3102
+ inv.state = verdict2.state;
3103
+ inv.why = verdict2.why;
3104
+ }
3105
+ if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
3106
+ }
3107
+ if (fnExits && !conditional()) {
3108
+ frames[0].dead = true;
3109
+ exits = true;
3110
+ }
3111
+ }
3112
+ } else if (reach === "maybe" && st.fns.has(first)) {
3113
+ const start = st.out.length;
3114
+ const sub = { ...st, out: st.out };
3115
+ if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
3116
+ const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
3117
+ for (let k = start; k < st.out.length; k++) {
3118
+ const inv = st.out[k];
3119
+ if (inv.state === "live" && verdict2.state !== "live") {
3120
+ inv.state = verdict2.state;
3121
+ inv.why = verdict2.why;
3122
+ }
3123
+ }
3124
+ }
2277
3125
  continue;
2278
3126
  }
2279
- let state = dead() ? "unreachable" : "live";
2280
- if (state === "live" && i > 0) {
2281
- if (ops[i - 1] === "&&" && alwaysFalse(segs[i - 1])) state = "unreachable";
2282
- if (ops[i - 1] === "||" && alwaysTrue(segs[i - 1])) state = "unreachable";
2283
- }
2284
- if (state === "live" && i < ops.length) {
2285
- const after = segs.slice(i + 1).join(" ");
2286
- if (ops[i] === "||" && !FAILS_LOUDLY.test(after)) state = "neutered";
2287
- else if (ops[i] === "|" && !pipefail) state = "neutered";
2288
- else if (ops[i] === ";" && segs.slice(i + 1).some((s) => /^(?:true|:)$/.test(s.trim()) || EXIT_ZERO.test(s.trim()))) state = "neutered";
3127
+ let state = "live";
3128
+ let why;
3129
+ let passthrough = false;
3130
+ if (reach === "no") {
3131
+ state = "unreachable";
3132
+ why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
3133
+ } else {
3134
+ const v = chainVerdict(segs, ops, s, st, inverted, bg);
3135
+ state = v.state;
3136
+ why = v.why;
3137
+ passthrough = v.passthrough;
3138
+ if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
3139
+ state = "neutered";
3140
+ why = "runs from another directory after a `cd` away from the repository root";
3141
+ }
3142
+ if (state === "live") {
3143
+ const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
3144
+ if (words2.length) {
3145
+ state = "neutered";
3146
+ why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
3147
+ }
3148
+ }
2289
3149
  }
2290
- out2.push({ identity: id, line: raw, state });
3150
+ const pin = pinOfSegment(seg);
3151
+ st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
2291
3152
  }
2292
3153
  }
2293
- return out2;
3154
+ return exits;
3155
+ }
3156
+ function chainVerdict(segs, ops, i, st, inverted, bg) {
3157
+ if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
3158
+ if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
3159
+ let cur = "fail";
3160
+ for (let j = i; j < ops.length; j++) {
3161
+ const op = ops[j];
3162
+ const next = segs[j + 1];
3163
+ if (op === "|") {
3164
+ if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
3165
+ continue;
3166
+ }
3167
+ if (cur === "exit") break;
3168
+ if (op === "||") {
3169
+ if (cur === "fail") cur = statusAfterFailure(next, st.vars);
3170
+ } else if (op === "&&") {
3171
+ if (cur === "ok") cur = statusAfterFailure(next, st.vars);
3172
+ } else if (op === ";") {
3173
+ const rest = segs.slice(j + 1).map((x) => x.trim());
3174
+ if (st.errexit && cur === "fail") {
3175
+ if (rest.some((x) => /^(?:true|:)$/.test(x) || EXIT_ZERO.test(x))) return { state: "neutered", why: "followed by a passing statement in the same line", passthrough: false };
3176
+ return { state: "live", passthrough: false };
3177
+ }
3178
+ if (rest.every((x) => TRANSPARENT.test(x))) return { state: cur === "fail" ? "live" : "neutered", why: cur === "fail" ? void 0 : "the `||` chain after it ends in success", passthrough: cur === "fail" };
3179
+ return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
3180
+ }
3181
+ }
3182
+ if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
3183
+ if (cur === "exit") return { state: "live", passthrough: false };
3184
+ return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
3185
+ }
3186
+ function invocations(lines, opts = {}) {
3187
+ const prep = prepare(lines, opts);
3188
+ const st = {
3189
+ errexit: prep.errexit,
3190
+ pipefail: prep.pipefail,
3191
+ cdAway: false,
3192
+ vars: /* @__PURE__ */ new Map(),
3193
+ fns: prep.fns,
3194
+ shadowed: prep.shadowed,
3195
+ traps: [],
3196
+ out: [],
3197
+ lastReal: -1
3198
+ };
3199
+ for (let i = 0; i < prep.top.length; i++) {
3200
+ const t = stripComment(prep.top[i]).trim();
3201
+ if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
3202
+ }
3203
+ walk(prep.top, st, (i) => i, 0);
3204
+ const seen = new Set(st.out.map((o) => o.line));
3205
+ for (const [, body] of prep.fns) {
3206
+ const sub = { ...st, out: [], traps: [] };
3207
+ walk(body, sub, () => -1, 1);
3208
+ for (const inv of sub.out) if (!seen.has(inv.line) && inv.state !== "comment") st.out.push({ ...inv, state: "unreachable", why: "defined in a function that is never called" });
3209
+ }
3210
+ return st.out.map((inv) => {
3211
+ if (inv.state !== "live") return strip(inv);
3212
+ if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
3213
+ return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
3214
+ }
3215
+ if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
3216
+ return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
3217
+ }
3218
+ return strip(inv);
3219
+ });
2294
3220
  }
2295
- function scriptWeakening(before, after) {
3221
+ var strip = (inv) => {
3222
+ const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
3223
+ return rest;
3224
+ };
3225
+ function scriptWeakening(before, after, opts = {}) {
3226
+ const beforeInv = invocations(before, opts);
3227
+ const afterInv = invocations(after, opts);
2296
3228
  const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
2297
- const bl = live(invocations(before));
2298
- const afterInv = invocations(after);
3229
+ const bl = live(beforeInv);
2299
3230
  const al = live(afterInv);
2300
3231
  const out2 = [];
2301
3232
  for (const id of bl) {
2302
3233
  if (al.has(id)) continue;
2303
- const trace = afterInv.find((i) => i.identity === id);
2304
- const how = trace?.state === "neutered" ? "neutralised in place (its failure can no longer fail the hook)" : trace?.state === "unreachable" ? "made unreachable (behind an always-false guard or an early exit)" : trace?.state === "comment" ? "commented out" : "removed";
2305
- const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
3234
+ const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
3235
+ const how = trace?.state === "neutered" ? `neutralised in place (${trace.why ?? "its failure can no longer fail the hook"})` : trace?.state === "unreachable" ? `made unreachable (${trace.why ?? "behind an always-false guard or an early exit"})` : trace?.state === "comment" ? "commented out" : trace && trace.identity !== id ? `replaced by \`${trace.identity}\`` : "removed";
3236
+ const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
2306
3237
  out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
2307
3238
  }
3239
+ const floor = beforeInv.filter((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin)).map((i) => i.pin).sort((a, b) => compareVersions(a, b) ?? 0)[0];
3240
+ if (floor) {
3241
+ for (const inv of afterInv) {
3242
+ if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
3243
+ if ((compareVersions(inv.pin, floor) ?? 0) < 0) out2.push({ reason: `the gate's pin was lowered from ${floor} to ${inv.pin}`, evidence: inv.line.trim() });
3244
+ }
3245
+ }
2308
3246
  return out2;
2309
3247
  }
3248
+ function shebangProblem(src) {
3249
+ const first = src.split("\n")[0] ?? "";
3250
+ if (!first.startsWith("#!")) return null;
3251
+ const body = first.slice(2).trim();
3252
+ const toks = body.split(/\s+/);
3253
+ let i = 0;
3254
+ if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
3255
+ i = 1;
3256
+ while (i < toks.length && toks[i].startsWith("-")) i++;
3257
+ }
3258
+ const interp = (toks[i] ?? "").replace(/^.*\//, "");
3259
+ if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
3260
+ if (toks.slice(i + 1).some((t) => /^-[a-zA-Z]*c/.test(t))) return `the shebang passes \`-c\` \u2014 the interpreter runs the argument, not the script`;
3261
+ return null;
3262
+ }
2310
3263
  var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
2311
3264
  var GATE_CRITICAL = [
2312
- "/.github/workflows/",
3265
+ "/.github/workflows/tamperward.yml",
2313
3266
  "/.tamperward.yml",
2314
3267
  "/.github/CODEOWNERS",
2315
3268
  "/CODEOWNERS",
2316
3269
  "/docs/CODEOWNERS",
2317
- "/.husky/",
3270
+ "/.husky/pre-commit",
2318
3271
  "/.claude/settings.json",
2319
3272
  "/.claude/settings.local.json",
2320
3273
  "/.pre-commit-config.yaml",
2321
3274
  "/lefthook.yml",
2322
3275
  "/lefthook.yaml"
2323
3276
  ];
2324
- function covers(pattern, critical) {
2325
- if (pattern === "*" || pattern === "**") return true;
2326
- let p = pattern.replace(/\/\*\*$/, "/").replace(/\/\*$/, "/");
2327
- if (!p.startsWith("/")) p = "/" + p;
2328
- if (p === critical) return true;
2329
- if (p.endsWith("/") && critical.startsWith(p)) return true;
2330
- return critical.startsWith(p + "/");
3277
+ function codeownersMatcher(pattern) {
3278
+ let p = pattern.trim();
3279
+ if (p === "*" || p === "**") return () => true;
3280
+ let anchored = p.startsWith("/");
3281
+ if (anchored) p = p.slice(1);
3282
+ const dir = p.endsWith("/");
3283
+ p = p.replace(/\/+$/, "");
3284
+ if (p.includes("/")) anchored = true;
3285
+ if (p.startsWith("**/")) {
3286
+ anchored = false;
3287
+ p = p.slice(3);
3288
+ }
3289
+ let re = "";
3290
+ for (let i = 0; i < p.length; i++) {
3291
+ const ch = p[i];
3292
+ if (ch === "*") {
3293
+ if (p[i + 1] === "*") {
3294
+ re += ".*";
3295
+ i++;
3296
+ } else re += "[^/]*";
3297
+ } else if (ch === "?") re += "[^/]";
3298
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3299
+ }
3300
+ const body = (anchored ? "^/" : "(?:^|/)") + re;
3301
+ const exact = new RegExp(body + "$");
3302
+ const under = new RegExp(body + "/");
3303
+ return (file) => !dir && exact.test(file) || under.test(file);
2331
3304
  }
2332
3305
  function ownersOf(content, critical) {
2333
3306
  let owners = null;
@@ -2335,13 +3308,20 @@ function ownersOf(content, critical) {
2335
3308
  const t = line.trim();
2336
3309
  if (!t || t.startsWith("#")) continue;
2337
3310
  const [pattern, ...rest] = t.split(/\s+/);
2338
- if (covers(pattern, critical)) owners = rest.filter((o) => !o.startsWith("#"));
3311
+ let matches;
3312
+ try {
3313
+ matches = codeownersMatcher(pattern)(critical);
3314
+ } catch {
3315
+ matches = false;
3316
+ }
3317
+ if (matches) owners = rest.filter((o) => !o.startsWith("#"));
2339
3318
  }
2340
3319
  return owners;
2341
3320
  }
2342
- function codeownersWeakening(before, after) {
3321
+ function codeownersWeakening(before, after, extra = []) {
2343
3322
  const out2 = [];
2344
- for (const critical of GATE_CRITICAL) {
3323
+ const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
3324
+ for (const critical of files) {
2345
3325
  const was = ownersOf(before, critical);
2346
3326
  if (!was || was.length === 0) continue;
2347
3327
  const now = ownersOf(after, critical);
@@ -2360,25 +3340,47 @@ function parseDoc(src) {
2360
3340
  }
2361
3341
  }
2362
3342
  var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
3343
+ var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
2363
3344
  var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
2364
3345
  var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
2365
3346
  var GATE = /\btamperward\b/;
2366
3347
  var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
3348
+ var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
3349
+ function runsGate(command) {
3350
+ return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
3351
+ }
3352
+ function mergeDocs(base, over) {
3353
+ const out2 = { ...base };
3354
+ for (const [k, v] of Object.entries(over)) {
3355
+ const b = out2[k];
3356
+ out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
3357
+ }
3358
+ return out2;
3359
+ }
3360
+ var off = (v) => v === void 0 || v === null || v === false;
2367
3361
  function lefthookEntries(doc) {
2368
3362
  const out2 = /* @__PURE__ */ new Map();
2369
3363
  for (const [section, v] of Object.entries(doc)) {
2370
3364
  if (!isDoc(v)) continue;
3365
+ const sectionExclude = list(v.exclude_tags);
2371
3366
  for (const group of ["commands", "scripts"]) {
2372
3367
  const g = v[group];
2373
3368
  if (!isDoc(g)) continue;
2374
3369
  for (const [name, cfg] of Object.entries(g)) {
2375
- if (!isDoc(cfg) || !GATE.test(str(cfg.run ?? cfg.runner))) continue;
3370
+ if (!isDoc(cfg)) continue;
3371
+ const run = cfg.run ?? cfg.runner;
3372
+ const named = GATE.test(name);
3373
+ if (!GATE.test(str(run)) && !named) continue;
3374
+ const tags = list(cfg.tags);
2376
3375
  out2.set(`${section}.${group}.${name}`, {
3376
+ live: run === void 0 ? named : runsGate(str(run)),
2377
3377
  skip: cfg.skip,
3378
+ only: cfg.only,
2378
3379
  sectionSkip: v.skip,
3380
+ sectionOnly: v.only,
2379
3381
  glob: str(cfg.glob),
2380
3382
  exclude: str(cfg.exclude),
2381
- only: str(cfg.only)
3383
+ excludedByTag: tags.filter((t) => sectionExclude.includes(t))
2382
3384
  });
2383
3385
  }
2384
3386
  }
@@ -2390,33 +3392,46 @@ function lefthookWeakening(before, after) {
2390
3392
  const b = lefthookEntries(before);
2391
3393
  const a = lefthookEntries(after);
2392
3394
  for (const [key2, was] of b) {
3395
+ if (!was.live) continue;
2393
3396
  const now = a.get(key2);
2394
3397
  if (!now) {
2395
3398
  out2.push(`lefthook entry ${key2} (the gate) was removed`);
2396
3399
  continue;
2397
3400
  }
2398
- if (now.skip === true && was.skip !== true) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: true\``);
2399
- if (now.sectionSkip === true && was.sectionSkip !== true) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: true\``);
3401
+ if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
3402
+ if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
3403
+ if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
3404
+ if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
3405
+ if (!off(now.sectionOnly) && str(now.sectionOnly) !== str(was.sectionOnly)) out2.push(`the lefthook hook carrying ${key2} (the gate) gained or changed \`only\` (${str(now.sectionOnly)})`);
2400
3406
  if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
2401
3407
  if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2402
- if (now.only !== was.only && now.only) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${now.only})`);
3408
+ if (now.excludedByTag.length && !was.excludedByTag.length) out2.push(`lefthook entry ${key2} (the gate) is tagged ${now.excludedByTag.join(", ")}, which the hook's \`exclude_tags\` switches off`);
2403
3409
  }
2404
3410
  return out2;
2405
3411
  }
2406
- var COMMIT_STAGES = /* @__PURE__ */ new Set(["commit", "pre-commit", "push", "pre-push", "commit-msg", "pre-merge-commit", "prepare-commit-msg"]);
3412
+ var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
3413
+ var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
3414
+ var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
3415
+ var normStage = (s) => STAGE_ALIAS[s] ?? s;
2407
3416
  function preCommitEntries(doc) {
2408
3417
  const out2 = /* @__PURE__ */ new Map();
2409
3418
  const repos = Array.isArray(doc.repos) ? doc.repos : [];
3419
+ const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
2410
3420
  for (const repo of repos) {
2411
3421
  if (!isDoc(repo)) continue;
2412
3422
  const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
2413
3423
  for (const h of hooks) {
2414
3424
  if (!isDoc(h)) continue;
2415
3425
  if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
3426
+ const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
2416
3427
  out2.set(`${str(repo.repo)}:${str(h.id)}`, {
2417
- stages: Array.isArray(h.stages) ? h.stages.map(String) : null,
3428
+ live,
3429
+ stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
3430
+ explicitStages: Array.isArray(h.stages),
2418
3431
  exclude: str(h.exclude),
2419
- files: str(h.files)
3432
+ files: str(h.files),
3433
+ types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
3434
+ alwaysRun: h.always_run === true
2420
3435
  });
2421
3436
  }
2422
3437
  }
@@ -2427,28 +3442,45 @@ function preCommitWeakening(before, after) {
2427
3442
  const b = preCommitEntries(before);
2428
3443
  const a = preCommitEntries(after);
2429
3444
  for (const [key2, was] of b) {
3445
+ if (!was.live) continue;
2430
3446
  const now = a.get(key2);
2431
3447
  if (!now) {
2432
3448
  out2.push(`pre-commit hook ${key2} (the gate) was removed`);
2433
3449
  continue;
2434
3450
  }
2435
- const wasStages = was.stages ?? [...COMMIT_STAGES];
2436
- if (now.stages && !now.stages.some((s) => COMMIT_STAGES.has(s)) && wasStages.some((s) => COMMIT_STAGES.has(s))) {
3451
+ if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
3452
+ const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
3453
+ if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
2437
3454
  out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
2438
- } else if (now.stages && was.stages) {
2439
- const lost = was.stages.filter((s) => COMMIT_STAGES.has(s) && !now.stages.includes(s));
2440
- if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
3455
+ } else if (lost.length) {
3456
+ out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
2441
3457
  }
2442
3458
  if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
2443
3459
  if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
3460
+ if (now.types !== was.types && now.types && !now.alwaysRun) out2.push(`pre-commit hook ${key2} (the gate) is scoped by file type (${now.types}) without \`always_run\``);
2444
3461
  }
2445
3462
  const topB = str(before.exclude);
2446
3463
  const topA = str(after.exclude);
2447
3464
  if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
2448
3465
  return out2;
2449
3466
  }
2450
- var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
2451
3467
  var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
3468
+ function installerOf(script) {
3469
+ for (const seg of script.split(/&&|\|\||;|\n/)) {
3470
+ const raw = seg.trim();
3471
+ if (!raw) continue;
3472
+ if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
3473
+ const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
3474
+ const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
3475
+ if (!m) continue;
3476
+ const [, tool, sub] = m;
3477
+ if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
3478
+ if (tool === "lefthook" && sub === "install") return "lefthook install";
3479
+ if (tool === "pre-commit" && sub === "install") return "pre-commit install";
3480
+ if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
3481
+ }
3482
+ return null;
3483
+ }
2452
3484
  function packageJsonWeakening(before, after) {
2453
3485
  let b;
2454
3486
  let a;
@@ -2459,13 +3491,14 @@ function packageJsonWeakening(before, after) {
2459
3491
  return [];
2460
3492
  }
2461
3493
  if (!isDoc(b) || !isDoc(a)) return [];
2462
- const scripts = (d) => {
3494
+ const installers = (d) => {
2463
3495
  const sc = d.scripts;
2464
- return isDoc(sc) ? INSTALL_SCRIPTS.map((k) => str(sc[k])).join(" ") : "";
3496
+ if (!isDoc(sc)) return [];
3497
+ return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
2465
3498
  };
2466
- const was = scripts(b).match(INSTALLER)?.[0];
3499
+ const was = installers(b)[0];
2467
3500
  if (!was) return [];
2468
- if (INSTALLER.test(scripts(a))) return [];
3501
+ if (installers(a).length) return [];
2469
3502
  return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
2470
3503
  }
2471
3504
  var GIT_HOOKS = /* @__PURE__ */ new Set([
@@ -2492,7 +3525,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
2492
3525
  ]);
2493
3526
  function hookIdentity(path) {
2494
3527
  const base = path.split("/").pop() ?? "";
2495
- if (isLefthook(path)) return "lefthook";
3528
+ if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
2496
3529
  if (isPreCommitConfig(path)) return "pre-commit-config";
2497
3530
  if (base === "CODEOWNERS") return "codeowners";
2498
3531
  const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
@@ -2500,7 +3533,7 @@ function hookIdentity(path) {
2500
3533
  return null;
2501
3534
  }
2502
3535
  var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
2503
- var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
3536
+ var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
2504
3537
  function chmodDropsExec(toks) {
2505
3538
  const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
2506
3539
  if (at === -1) return false;
@@ -2513,31 +3546,113 @@ function chmodDropsExec(toks) {
2513
3546
  for (const clause of tok.split(",")) {
2514
3547
  const m = clause.match(SYMBOLIC);
2515
3548
  if (!m) continue;
2516
- const [, op, perms] = m;
2517
- if (op === "-" && /[xX]/.test(perms)) return true;
2518
- if (op === "=" && !/[xX]/.test(perms)) return true;
3549
+ const [, who, opsText] = m;
3550
+ if (who && !/[ua]/.test(who)) continue;
3551
+ let exec = null;
3552
+ for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
3553
+ const op = g[0];
3554
+ const perms = g.slice(1);
3555
+ if (/[ugo]/.test(perms)) {
3556
+ exec = false;
3557
+ continue;
3558
+ }
3559
+ if (op === "-" && /[xX]/.test(perms)) exec = false;
3560
+ else if (op === "+" && /[xX]/.test(perms)) exec = true;
3561
+ else if (op === "=") exec = /[xX]/.test(perms);
3562
+ }
3563
+ if (exec === false) return true;
2519
3564
  }
2520
3565
  }
2521
3566
  return false;
2522
3567
  }
3568
+ function setfaclDropsExec(toks) {
3569
+ if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
3570
+ return toks.some((t) => t.split(",").some((e) => {
3571
+ const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
3572
+ return !!m && !/[xX]/.test(m[1]);
3573
+ }));
3574
+ }
2523
3575
  var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
2524
- var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
2525
- var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
2526
- function shellHookTarget(seg, toks, policy) {
3576
+ var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
3577
+ var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
3578
+ var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
3579
+ var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
3580
+ function nameGlob(pattern) {
3581
+ const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
3582
+ return (n) => re.test(n);
3583
+ }
3584
+ function destinations(cmd, args, isDir) {
3585
+ const positional = [];
3586
+ let target = null;
3587
+ for (let i = 0; i < args.length; i++) {
3588
+ const a = args[i];
3589
+ if (a === "--") {
3590
+ positional.push(...args.slice(i + 1));
3591
+ break;
3592
+ }
3593
+ if (a === "-t" || a === "--target-directory") {
3594
+ target = args[++i] ?? null;
3595
+ continue;
3596
+ }
3597
+ const eq = a.match(/^--target-directory=(.*)$/);
3598
+ if (eq) {
3599
+ target = eq[1];
3600
+ continue;
3601
+ }
3602
+ if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
3603
+ i++;
3604
+ continue;
3605
+ }
3606
+ if (a.startsWith("-")) continue;
3607
+ positional.push(a);
3608
+ }
3609
+ const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
3610
+ if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
3611
+ if (positional.length < 2) return [];
3612
+ const dest = positional[positional.length - 1];
3613
+ const srcs = positional.slice(0, -1);
3614
+ if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
3615
+ return [dest];
3616
+ }
3617
+ var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
3618
+ function hookTests(policy, ctx) {
3619
+ const rel = (t) => {
3620
+ let p = t.replace(/^["']|["']$/g, "");
3621
+ if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
3622
+ return p.replace(/^\.\/(?=.)/, "");
3623
+ };
3624
+ const hook = (t) => isProtected(rel(t), policy, "hooks");
3625
+ const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
3626
+ const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
3627
+ const hookish = (t) => hook(t) || holds(t);
3628
+ return { rel, hook, whole, holds, hookish };
3629
+ }
3630
+ function shellHookTarget(seg, toks, policy, ctx) {
3631
+ const { rel, hook, whole, holds } = hookTests(policy, ctx);
2527
3632
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2528
- const t = hookPath(m[1]);
2529
- if (isProtected(t, policy, "hooks")) return t;
3633
+ if (hook(m[1])) return rel(m[1]);
2530
3634
  }
2531
- for (const tok of toks) {
2532
- const t = hookPath(tok.replace(/^of=/, ""));
2533
- if (isProtected(t, policy, "hooks")) return t;
3635
+ const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
3636
+ const fileLike = (p) => {
3637
+ const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
3638
+ return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
3639
+ };
3640
+ for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
3641
+ for (const t of args) {
3642
+ if (t.startsWith("-") || !holds(t)) continue;
3643
+ const dir = rel(t).replace(/\/+$/, "");
3644
+ const under = whole(t) ? "" : dir + "/";
3645
+ const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
3646
+ if (listed) return listed;
3647
+ const guess = under + "pre-commit";
3648
+ return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
2534
3649
  }
2535
3650
  return null;
2536
3651
  }
2537
- function shellWritesHook(seg, toks, policy) {
2538
- const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
3652
+ function shellWritesHook(seg, toks, policy, ctx) {
3653
+ const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
2539
3654
  for (const m of seg.matchAll(REDIRECT_TARGET)) {
2540
- if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
3655
+ if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
2541
3656
  }
2542
3657
  let at = 0;
2543
3658
  while (at < toks.length && WRAPPERS.test(toks[at])) at++;
@@ -2547,21 +3662,28 @@ function shellWritesHook(seg, toks, policy) {
2547
3662
  const last = positional[positional.length - 1] ?? "";
2548
3663
  const anyHook = args.some(hook);
2549
3664
  const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
3665
+ const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
3666
+ const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
2550
3667
  switch (cmd) {
2551
3668
  case "rm":
2552
3669
  case "unlink":
2553
3670
  case "shred":
2554
- return anyHook ? "rm deletes a protected hook" : null;
3671
+ return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
2555
3672
  case "truncate":
2556
3673
  return anyHook ? "truncate empties a protected hook" : null;
2557
3674
  case "chmod":
2558
- return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3675
+ return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
3676
+ case "setfacl":
3677
+ return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
2559
3678
  case "mv":
2560
- return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
3679
+ return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
2561
3680
  case "cp":
2562
3681
  case "install":
2563
3682
  case "ln":
2564
- return hook(last) ? `${cmd} replaces a protected hook in place` : null;
3683
+ case "rsync": {
3684
+ const dests = destinations(cmd, args, holds);
3685
+ return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
3686
+ }
2565
3687
  case "tee":
2566
3688
  case "sponge":
2567
3689
  return anyHook ? `${cmd} rewrites a protected hook in place` : null;
@@ -2569,20 +3691,45 @@ function shellWritesHook(seg, toks, policy) {
2569
3691
  return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
2570
3692
  case "sed":
2571
3693
  case "perl":
2572
- return anyHook && inPlace ? `an in-place ${cmd} rewrites a protected hook` : null;
3694
+ if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
3695
+ if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
3696
+ return null;
2573
3697
  case "awk":
2574
3698
  case "gawk":
2575
3699
  return anyHook && args.some((a, i) => a === "-i" && args[i + 1] === "inplace" || a === "--in-place" || a === "-i=inplace") ? "an in-place awk rewrites a protected hook" : null;
3700
+ case "patch":
3701
+ return anyHook ? "patch rewrites a protected hook" : null;
3702
+ case "xargs":
3703
+ return null;
3704
+ // judged by the caller, which sees the segments feeding it
3705
+ case "find": {
3706
+ const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
3707
+ if (!mutates) return null;
3708
+ if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
3709
+ const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
3710
+ const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
3711
+ const nameHits = names.length === 0 || names.some((n) => {
3712
+ try {
3713
+ const g = nameGlob(n.replace(/^.*\//, ""));
3714
+ return HOOK_BASENAMES.some(g);
3715
+ } catch {
3716
+ return true;
3717
+ }
3718
+ });
3719
+ const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
3720
+ return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
3721
+ }
2576
3722
  case "git": {
2577
3723
  let i = 0;
2578
3724
  while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
2579
3725
  const sub = args[i];
2580
3726
  const rest = args.slice(i + 1);
2581
- if (!rest.some(hook)) return null;
3727
+ if (sub === "apply" || sub === "am") return rest.some((a) => hook(a.replace(/^--(?:include|exclude|directory)=/, ""))) ? `git ${sub} applies a patch that names a protected hook` : null;
3728
+ if (!rest.some(hookish)) return null;
2582
3729
  if (sub === "checkout") {
2583
3730
  const dd = rest.indexOf("--");
2584
3731
  const before = dd === -1 ? rest : rest.slice(0, dd);
2585
- const revs = before.filter((p) => !p.startsWith("-") && !hook(p));
3732
+ const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
2586
3733
  return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
2587
3734
  }
2588
3735
  if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
@@ -2591,108 +3738,290 @@ function shellWritesHook(seg, toks, policy) {
2591
3738
  return null;
2592
3739
  }
2593
3740
  default:
3741
+ if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
3742
+ if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
3743
+ if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
2594
3744
  return null;
2595
3745
  }
2596
3746
  }
3747
+ function xargsWritesHook(toks, fed, policy, ctx) {
3748
+ const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
3749
+ if (at === -1) return null;
3750
+ const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
3751
+ const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
3752
+ if (!fedHook) return null;
3753
+ let i = at + 1;
3754
+ while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
3755
+ const cmd = (toks[i] ?? "").replace(/^.*\//, "");
3756
+ const args = toks.slice(i + 1);
3757
+ if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
3758
+ if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
3759
+ if ((cmd === "sed" || cmd === "perl") && args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return `xargs ${cmd} -i rewrites a protected hook named earlier in the line`;
3760
+ return null;
3761
+ }
2597
3762
 
2598
3763
  // src/detectors/hook-tampering.ts
2599
3764
  var RULE6 = "hook-tampering";
2600
3765
  var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
2601
- var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
2602
- var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
2603
- function siblingSettings(p) {
2604
- return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
2605
- }
2606
- var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
2607
3766
  var EVENTS = ["PreToolUse", "Stop"];
3767
+ var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2608
3768
  function parseSettings(src) {
2609
3769
  try {
2610
3770
  const v = JSON.parse(src);
2611
- return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
3771
+ return isObj(v) ? v : null;
2612
3772
  } catch {
2613
3773
  return null;
2614
3774
  }
2615
3775
  }
2616
- function toolSet(matcher2) {
2617
- const m = typeof matcher2 === "string" ? matcher2.trim() : "";
2618
- if (m === "" || m === "*") return null;
2619
- return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
2620
- }
2621
3776
  function eventEntries(s, event) {
2622
3777
  const hooks = s.hooks;
2623
- if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) return null;
3778
+ if (!isObj(hooks)) return null;
2624
3779
  const arr = hooks[event];
2625
3780
  return Array.isArray(arr) ? arr : null;
2626
3781
  }
2627
3782
  var declares = (s, event) => eventEntries(s, event) !== null;
2628
- function gateWiring(s, event) {
3783
+ var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
3784
+ var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
3785
+ var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
3786
+ var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
3787
+ var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
3788
+ function analyseGateCommand(cmd, event) {
3789
+ const want = event === "PreToolUse" ? "hook" : "sweep";
3790
+ const problems = [];
3791
+ let runs = true;
3792
+ const canonical2 = cmd.match(CANONICAL_CMD);
3793
+ if (canonical2) {
3794
+ if (canonical2[2] !== want) {
3795
+ problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
3796
+ runs = false;
3797
+ }
3798
+ if (!PLAIN_SEMVER.test(canonical2[1])) {
3799
+ problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
3800
+ runs = false;
3801
+ }
3802
+ return { runs, problems, pin: canonical2[1] };
3803
+ }
3804
+ const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
3805
+ if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
3806
+ if (inv[2] !== want || inv[3] !== "claude") {
3807
+ problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
3808
+ runs = false;
3809
+ }
3810
+ if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
3811
+ problems.push(`the pin \`${inv[1]}\` is not a plain version`);
3812
+ runs = false;
3813
+ }
3814
+ if (/[|;&<>`]|\$\(/.test(cmd)) {
3815
+ problems.push("text around the invocation (a redirect, pipe or chain)");
3816
+ runs = false;
3817
+ }
3818
+ if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
3819
+ problems.push("an environment assignment in front of the gate");
3820
+ runs = false;
3821
+ }
3822
+ if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
3823
+ problems.push("wrapped in `sh -c`");
3824
+ runs = false;
3825
+ }
3826
+ if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
3827
+ problems.push("`-p`/`--package` points npx at another package");
3828
+ runs = false;
3829
+ }
3830
+ const at = cmd.indexOf(inv[0]);
3831
+ const trailing = cmd.slice(at + inv[0].length).trim();
3832
+ if (trailing) {
3833
+ problems.push(`extra text after the invocation (\`${trailing}\`)`);
3834
+ runs = false;
3835
+ }
3836
+ const head = cmd.slice(0, at).trim();
3837
+ if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
3838
+ return { runs, problems, pin: inv[1] ?? "" };
3839
+ }
3840
+ function matcherSelection(m) {
3841
+ if (m === void 0) return { tools: null, problem: null };
3842
+ if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
3843
+ if (m === "" || m === "*") return { tools: null, problem: null };
3844
+ let problem = null;
3845
+ if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
3846
+ if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
3847
+ let re;
3848
+ try {
3849
+ re = new RegExp(m);
3850
+ } catch {
3851
+ return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
3852
+ }
3853
+ return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
3854
+ }
3855
+ function gateCandidates(s, event) {
2629
3856
  const out2 = [];
2630
- for (const m of eventEntries(s, event) ?? []) {
2631
- if (m === null || typeof m !== "object") continue;
2632
- const matcher2 = m;
2633
- if (!Array.isArray(matcher2.hooks)) continue;
2634
- for (const h of matcher2.hooks) {
2635
- if (h === null || typeof h !== "object") continue;
2636
- const entry = h;
2637
- if (entry.type !== void 0 && entry.type !== "command") continue;
2638
- if (typeof entry.command !== "string" || !TW_INVOCATION.test(entry.command)) continue;
2639
- out2.push({ tools: toolSet(matcher2.matcher), command: entry.command });
3857
+ const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
3858
+ for (const group of eventEntries(s, event) ?? []) {
3859
+ if (!isObj(group)) {
3860
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
3861
+ continue;
3862
+ }
3863
+ if (!Array.isArray(group.hooks)) {
3864
+ if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
3865
+ continue;
3866
+ }
3867
+ for (const h of group.hooks) {
3868
+ if (!isObj(h)) {
3869
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
3870
+ continue;
3871
+ }
3872
+ const cmd = typeof h.command === "string" ? h.command : null;
3873
+ if (cmd === null) {
3874
+ if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
3875
+ continue;
3876
+ }
3877
+ if (!MENTIONS_GATE.test(cmd)) continue;
3878
+ const a = analyseGateCommand(cmd, event);
3879
+ const problems = [...a.problems];
3880
+ let runs = a.runs;
3881
+ if (h.type !== "command") {
3882
+ problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
3883
+ if (h.type !== void 0) runs = false;
3884
+ }
3885
+ for (const k of Object.keys(h)) {
3886
+ if (GATE_KEYS.has(k)) continue;
3887
+ problems.push(`carries \`${k}\``);
3888
+ runs = false;
3889
+ }
3890
+ if ("timeout" in h) {
3891
+ const t = h.timeout;
3892
+ if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
3893
+ problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
3894
+ runs = false;
3895
+ }
3896
+ }
3897
+ let tools = null;
3898
+ if (event === "PreToolUse") {
3899
+ const sel = matcherSelection(group.matcher);
3900
+ tools = sel.tools;
3901
+ if (sel.problem) {
3902
+ problems.push(sel.problem);
3903
+ }
3904
+ } else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
3905
+ problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
3906
+ runs = false;
3907
+ }
3908
+ out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
2640
3909
  }
2641
3910
  }
2642
3911
  return out2;
2643
3912
  }
2644
- function coverage(ws2) {
2645
- if (ws2.length === 0) return "none";
2646
- if (ws2.some((w) => w.tools === null)) return null;
2647
- return new Set(ws2.flatMap((w) => [...w.tools]));
3913
+ function otherCommands(s, event) {
3914
+ const out2 = [];
3915
+ for (const group of eventEntries(s, event) ?? []) {
3916
+ if (!isObj(group) || !Array.isArray(group.hooks)) continue;
3917
+ for (const h of group.hooks) {
3918
+ if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
3919
+ out2.push(h.command);
3920
+ }
3921
+ }
3922
+ return out2;
3923
+ }
3924
+ function coverage(cs) {
3925
+ const live = cs.filter((c) => c.runs);
3926
+ if (live.length === 0) return "none";
3927
+ if (live.some((c) => c.tools === null)) return null;
3928
+ return new Set(live.flatMap((c) => [...c.tools]));
3929
+ }
3930
+ function duplicateJsonKeys(src) {
3931
+ const dups = [];
3932
+ const stack = [];
3933
+ let expectKey = false;
3934
+ for (let i = 0; i < src.length; i++) {
3935
+ const ch = src[i];
3936
+ if (ch === '"') {
3937
+ let j = i + 1;
3938
+ let s = "";
3939
+ while (j < src.length && src[j] !== '"') {
3940
+ if (src[j] === "\\") {
3941
+ s += src[j + 1] ?? "";
3942
+ j += 2;
3943
+ } else {
3944
+ s += src[j];
3945
+ j++;
3946
+ }
3947
+ }
3948
+ if (expectKey) {
3949
+ const set = stack[stack.length - 1];
3950
+ if (set) {
3951
+ if (set.has(s)) dups.push(s);
3952
+ set.add(s);
3953
+ }
3954
+ expectKey = false;
3955
+ }
3956
+ i = j;
3957
+ continue;
3958
+ }
3959
+ if (ch === "{") {
3960
+ stack.push(/* @__PURE__ */ new Set());
3961
+ expectKey = true;
3962
+ } else if (ch === "[") {
3963
+ stack.push(null);
3964
+ expectKey = false;
3965
+ } else if (ch === "}" || ch === "]") {
3966
+ stack.pop();
3967
+ expectKey = false;
3968
+ } else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
3969
+ }
3970
+ return [...new Set(dups)];
2648
3971
  }
2649
- var hooksDisabled = (s) => s.disableAllHooks === true;
2650
- function settingsWeakening(before, after, sibling) {
3972
+ function settingsWeakening(before, after, local) {
2651
3973
  const reasons = [];
2652
- if (hooksDisabled(after) && !(before !== null && hooksDisabled(before))) {
2653
- reasons.push("`disableAllHooks: true` switches every hook off, the gate included");
3974
+ const dis = after.disableAllHooks;
3975
+ if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
3976
+ reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
3977
+ }
3978
+ if (isObj(after.env)) {
3979
+ const was = before !== null && isObj(before.env) ? before.env : {};
3980
+ for (const [k, v] of Object.entries(after.env)) {
3981
+ if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
3982
+ reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
3983
+ }
3984
+ }
2654
3985
  }
2655
3986
  for (const event of EVENTS) {
2656
- const was = before ? coverage(gateWiring(before, event)) : "none";
2657
- const now = coverage(gateWiring(after, event));
3987
+ const bc = before !== null ? gateCandidates(before, event) : [];
3988
+ const ac = gateCandidates(after, event);
3989
+ const was = coverage(bc);
3990
+ const now = coverage(ac);
2658
3991
  if (was !== "none") {
2659
3992
  if (now === "none") {
2660
3993
  reasons.push(`the tamperward ${event} hook entry was removed`);
2661
3994
  } else if (event === "PreToolUse") {
2662
3995
  if (was === null && now !== null) {
2663
- reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
3996
+ reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
2664
3997
  } else if (was !== null && now !== null) {
2665
3998
  const lost = [...was].filter((t) => !now.has(t));
2666
3999
  if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
2667
4000
  }
2668
4001
  }
2669
- continue;
4002
+ } else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
4003
+ reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
4004
+ }
4005
+ const had = new Set(bc.map((c) => c.key));
4006
+ const fresh = ac.filter((c) => !had.has(c.key));
4007
+ for (const c of fresh) {
4008
+ if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
4009
+ }
4010
+ const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
4011
+ const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
4012
+ if (floor) {
4013
+ for (const c of fresh) {
4014
+ if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
4015
+ }
2670
4016
  }
2671
- if (now !== "none" || !declares(after, event)) continue;
2672
- if (before !== null && declares(before, event)) continue;
2673
- const empty = (eventEntries(after, event) ?? []).length === 0;
2674
- const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
2675
- if (empty || shadowed) {
2676
- reasons.push(
2677
- `${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
2678
- );
4017
+ const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
4018
+ for (const cmd of otherCommands(after, event)) {
4019
+ if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
2679
4020
  }
2680
4021
  }
2681
4022
  return reasons;
2682
4023
  }
2683
- function siblingOf(c, changes, ctx) {
2684
- const path = siblingSettings(c.path);
2685
- const inSet = changes.find((o) => o.kind === "file" && o.path === path);
2686
- if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
2687
- if (!ctx?.cwd) return null;
2688
- try {
2689
- const p = join2(ctx.cwd, path);
2690
- return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
2691
- } catch {
2692
- return null;
2693
- }
2694
- }
2695
- function claudeSettingsFindings(c, changes, policy, ctx) {
4024
+ function claudeSettingsFindings(c, policy) {
2696
4025
  if (c.after == null) return null;
2697
4026
  const after = parseSettings(c.after);
2698
4027
  const before = c.before != null ? parseSettings(c.before) : null;
@@ -2700,14 +4029,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
2700
4029
  file: c.path,
2701
4030
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
2702
4031
  evidence: reason,
2703
- remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
4032
+ remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
2704
4033
  });
2705
4034
  if (c.before != null && before === null) return null;
2706
4035
  if (after === null) {
2707
4036
  if (c.before == null) return [];
2708
4037
  return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
2709
4038
  }
2710
- return settingsWeakening(before, after, siblingOf(c, changes, ctx)).map(finding);
4039
+ const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
4040
+ const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
4041
+ for (const k of duplicateJsonKeys(c.after)) {
4042
+ if (!had.has(k)) reasons.push(`the key "${k}" appears twice \u2014 which reading the runtime keeps is not something the gate can vouch for`);
4043
+ }
4044
+ return reasons.map(finding);
2711
4045
  }
2712
4046
  function policyFindings(c, policy) {
2713
4047
  if (c.after == null) return null;
@@ -2729,12 +4063,54 @@ function renameKept(c) {
2729
4063
  if (id === null || hookIdentity(c.path) !== id) return false;
2730
4064
  return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
2731
4065
  }
2732
- function configFindings(c, policy) {
2733
- if (c.before == null || c.after == null) return null;
2734
- const before = parseDoc(c.before);
2735
- const after = parseDoc(c.after);
2736
- if (before === null) return null;
2737
- const reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4066
+ function lefthookBase(c, changes, ctx) {
4067
+ const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
4068
+ const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
4069
+ const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
4070
+ if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
4071
+ if (!ctx?.cwd) return null;
4072
+ for (const n of names) {
4073
+ try {
4074
+ const p = join3(ctx.cwd, n);
4075
+ if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
4076
+ } catch {
4077
+ }
4078
+ }
4079
+ return null;
4080
+ }
4081
+ function syntheticBase(over) {
4082
+ const base = {};
4083
+ for (const [section, v] of Object.entries(over)) {
4084
+ if (!isObj(v)) continue;
4085
+ for (const group of ["commands", "scripts"]) {
4086
+ const g = v[group];
4087
+ if (!isObj(g)) continue;
4088
+ for (const name of Object.keys(g)) {
4089
+ if (!/tamperward/.test(name)) continue;
4090
+ const sec = base[section] ??= {};
4091
+ const grp = sec[group] ??= {};
4092
+ grp[name] = { run: "npx tamperward check --staged" };
4093
+ }
4094
+ }
4095
+ }
4096
+ return base;
4097
+ }
4098
+ function configFindings(c, changes, policy, ctx) {
4099
+ if (c.after == null) return null;
4100
+ let reasons;
4101
+ if (isLefthookLocal(c.path)) {
4102
+ const overBefore = c.before != null ? parseDoc(c.before) : {};
4103
+ const overAfter = parseDoc(c.after);
4104
+ if (overBefore === null) return null;
4105
+ const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
4106
+ reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
4107
+ } else {
4108
+ if (c.before == null) return null;
4109
+ const before = parseDoc(c.before);
4110
+ const after = parseDoc(c.after);
4111
+ if (before === null) return null;
4112
+ reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
4113
+ }
2738
4114
  return reasons.map(
2739
4115
  (reason) => makeFinding(RULE6, policy, {
2740
4116
  file: c.path,
@@ -2744,6 +4120,11 @@ function configFindings(c, policy) {
2744
4120
  })
2745
4121
  );
2746
4122
  }
4123
+ function repoGateFiles(ctx) {
4124
+ const files = trackedFiles(ctx);
4125
+ if (!files) return [];
4126
+ return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
4127
+ }
2747
4128
  function updateIndexHiding(toks, policy) {
2748
4129
  if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
2749
4130
  const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
@@ -2765,6 +4146,7 @@ function repoRelative(path, ctx) {
2765
4146
  const rel = relative(ctx.cwd, path);
2766
4147
  return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2767
4148
  }
4149
+ var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
2768
4150
  var hookTampering = {
2769
4151
  id: RULE6,
2770
4152
  surface: ["file", "command"],
@@ -2774,7 +4156,7 @@ var hookTampering = {
2774
4156
  for (const c of changes) {
2775
4157
  let semantic;
2776
4158
  if (c.kind === "file") {
2777
- const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
4159
+ const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
2778
4160
  if (!targetsHook) {
2779
4161
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
2780
4162
  for (const reason of packageJsonWeakening(c.before, c.after)) {
@@ -2818,12 +4200,12 @@ var hookTampering = {
2818
4200
  remediation: "Renaming a hook out of place disables it. Restore the original path."
2819
4201
  })
2820
4202
  );
2821
- } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, changes, policy, ctx)) !== null) {
4203
+ } else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
2822
4204
  out2.push(...semantic);
2823
4205
  } else if (c.op === "rename") {
2824
4206
  } else if (isCodeowners(c.path)) {
2825
4207
  if (c.before != null && c.after != null) {
2826
- for (const reason of codeownersWeakening(c.before, c.after)) {
4208
+ for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
2827
4209
  out2.push(
2828
4210
  makeFinding(RULE6, policy, {
2829
4211
  file: c.path,
@@ -2834,9 +4216,40 @@ var hookTampering = {
2834
4216
  );
2835
4217
  }
2836
4218
  }
2837
- } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
4219
+ } else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
2838
4220
  out2.push(...semantic);
2839
4221
  } else if (!c.path.endsWith(POLICY_FILE)) {
4222
+ const husky = isHuskyScript(c.path);
4223
+ const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
4224
+ if (beforePin !== null) {
4225
+ const afterPin = initScriptPin(c.after);
4226
+ const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
4227
+ if (!kept) {
4228
+ out2.push(
4229
+ makeFinding(RULE6, policy, {
4230
+ file: c.path,
4231
+ message: "A protected hook script no longer matches the shape `tamperward init` writes.",
4232
+ evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
4233
+ remediation: "The pre-commit script init writes is the marker line and the pinned staged check, nothing else. Re-run `tamperward init` to restore it, or sign off on the customised script."
4234
+ })
4235
+ );
4236
+ }
4237
+ continue;
4238
+ }
4239
+ if (!husky && c.after != null) {
4240
+ const problem = shebangProblem(c.after);
4241
+ if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
4242
+ out2.push(
4243
+ makeFinding(RULE6, policy, {
4244
+ file: c.path,
4245
+ line: 1,
4246
+ message: `A protected hook's interpreter was changed: ${problem}.`,
4247
+ evidence: c.after.split("\n")[0],
4248
+ remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
4249
+ })
4250
+ );
4251
+ }
4252
+ }
2840
4253
  const added = addedLines(c);
2841
4254
  const earlyExit = added.find((l) => insertsPassingExit(l.content));
2842
4255
  if (earlyExit) {
@@ -2864,7 +4277,7 @@ var hookTampering = {
2864
4277
  }
2865
4278
  const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
2866
4279
  const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
2867
- const lost = scriptWeakening(beforeLines, afterLines);
4280
+ const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
2868
4281
  if (lost.length) {
2869
4282
  out2.push(
2870
4283
  makeFinding(RULE6, policy, {
@@ -2908,6 +4321,7 @@ var hookTampering = {
2908
4321
  }
2909
4322
  }
2910
4323
  } else {
4324
+ const fed = [];
2911
4325
  for (const seg of segments(c.raw)) {
2912
4326
  const toks = tokens(seg).map(unquote);
2913
4327
  const hiding = updateIndexHiding(toks, policy);
@@ -2920,11 +4334,12 @@ var hookTampering = {
2920
4334
  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."
2921
4335
  })
2922
4336
  );
4337
+ fed.push(...toks);
2923
4338
  continue;
2924
4339
  }
2925
- const why = shellWritesHook(seg, toks, policy);
4340
+ const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
2926
4341
  if (why) {
2927
- const target = shellHookTarget(seg, toks, policy);
4342
+ const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
2928
4343
  out2.push(
2929
4344
  makeFinding(RULE6, policy, {
2930
4345
  ...target ? { file: repoRelative(target, ctx) } : {},
@@ -2934,6 +4349,7 @@ var hookTampering = {
2934
4349
  })
2935
4350
  );
2936
4351
  }
4352
+ fed.push(...toks);
2937
4353
  }
2938
4354
  }
2939
4355
  }
@@ -2971,67 +4387,147 @@ function keyName2(name) {
2971
4387
  if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
2972
4388
  return null;
2973
4389
  }
4390
+ var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
2974
4391
  function literals2(e) {
2975
- if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
4392
+ if (isStr(e)) return { items: [e.text], opaque: false };
2976
4393
  if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
2977
4394
  const items = [];
2978
4395
  let opaque = false;
2979
4396
  for (const el of e.elements) {
2980
- if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
4397
+ if (isStr(el)) items.push(el.text);
2981
4398
  else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
2982
4399
  items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
2983
4400
  } else opaque = true;
2984
4401
  }
2985
4402
  return { items, opaque };
2986
4403
  }
2987
- function underKey2(node, key2) {
2988
- for (let p = node.parent; p; p = p.parent) {
2989
- if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
2990
- }
2991
- return false;
4404
+ function ownerKey2(node) {
4405
+ const obj = node.parent;
4406
+ if (!obj || !ts3.isObjectLiteralExpression(obj)) return null;
4407
+ let q = obj.parent;
4408
+ while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
4409
+ return q && ts3.isPropertyAssignment(q) ? keyName2(q.name) : null;
2992
4410
  }
2993
4411
  function asExpression2(src) {
2994
4412
  const t = src.trim();
2995
4413
  return t.startsWith("{") ? `(${t})` : src;
2996
4414
  }
2997
- function runnerOf(path) {
4415
+ function runnerOf(path, content) {
2998
4416
  const base = path.split("/").pop() ?? path;
2999
4417
  if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
3000
- if (/^vitest\.config\./.test(base)) return "vitest";
4418
+ if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
4419
+ if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
3001
4420
  return null;
3002
4421
  }
3003
- function parseSelection(src, runner) {
3004
- const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
4422
+ var isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
4423
+ var empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
4424
+ var stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
4425
+ function dirValue(e) {
4426
+ if (!isStr(e)) return { dir: null, opaque: true };
4427
+ const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
4428
+ if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
4429
+ return { dir: d, opaque: false };
4430
+ }
4431
+ function collect(root, runner) {
4432
+ const sel = empty();
4433
+ const seen = /* @__PURE__ */ new Set();
4434
+ const take = (key2, e) => {
4435
+ const { items, opaque } = literals2(e);
4436
+ if (opaque || seen.has(key2)) sel.opaque = true;
4437
+ seen.add(key2);
4438
+ sel.present = true;
4439
+ return items.map(stripRootDir);
4440
+ };
4441
+ const ignores = (key2, e) => {
4442
+ const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
4443
+ sel.ignore = [...sel.ignore ?? [], ...entries];
4444
+ };
4445
+ const projects = (e) => {
4446
+ sel.present = true;
4447
+ if (!ts3.isArrayLiteralExpression(e)) {
4448
+ sel.opaque = true;
4449
+ return;
4450
+ }
4451
+ const list2 = [];
4452
+ for (const el of e.elements) {
4453
+ if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
4454
+ else sel.opaque = true;
4455
+ }
4456
+ sel.projects = list2;
4457
+ if (list2.some((p) => p.opaque)) sel.opaque = true;
4458
+ };
4459
+ const visit = (node) => {
4460
+ if (ts3.isPropertyAssignment(node)) {
4461
+ const k = keyName2(node.name);
4462
+ if (runner === "jest") {
4463
+ if (k === "testMatch") sel.include = take(k, node.initializer);
4464
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
4465
+ else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
4466
+ else if (k === "roots") sel.roots = take(k, node.initializer);
4467
+ else if (k === "rootDir") {
4468
+ const { dir, opaque } = dirValue(node.initializer);
4469
+ sel.present = true;
4470
+ if (opaque) sel.opaque = true;
4471
+ else sel.base = dir;
4472
+ } else if (k === "projects") {
4473
+ projects(node.initializer);
4474
+ return;
4475
+ }
4476
+ } else if (ownerKey2(node) === "test") {
4477
+ if (k === "include") sel.include = take(k, node.initializer);
4478
+ else if (k === "exclude") ignores(k, node.initializer);
4479
+ else if (k === "dir") {
4480
+ const { dir, opaque } = dirValue(node.initializer);
4481
+ sel.present = true;
4482
+ if (opaque) sel.opaque = true;
4483
+ else sel.base = dir;
4484
+ } else if (k === "projects") {
4485
+ projects(node.initializer);
4486
+ return;
4487
+ }
4488
+ }
4489
+ }
4490
+ ts3.forEachChild(node, visit);
4491
+ };
4492
+ visit(root);
4493
+ return sel;
4494
+ }
4495
+ function workspaceArray(sf) {
4496
+ for (const st of sf.statements) {
4497
+ let e;
4498
+ if (ts3.isExportAssignment(st)) e = st.expression;
4499
+ else if (ts3.isExpressionStatement(st) && ts3.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
4500
+ if (!e) continue;
4501
+ while (ts3.isParenthesizedExpression(e) || ts3.isAsExpression(e) || e.kind === ts3.SyntaxKind.SatisfiesExpression) {
4502
+ e = e.expression;
4503
+ }
4504
+ if (ts3.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
4505
+ return ts3.isArrayLiteralExpression(e) ? e : null;
4506
+ }
4507
+ return null;
4508
+ }
4509
+ function parseSelection(src, runner, path = "") {
4510
+ let sel = empty();
3005
4511
  try {
3006
4512
  const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
3007
- const seen = /* @__PURE__ */ new Set();
3008
- const take = (key2, e) => {
3009
- const { items, opaque } = literals2(e);
3010
- if (opaque || seen.has(key2)) sel.opaque = true;
3011
- seen.add(key2);
3012
- sel.present = true;
3013
- return items.map((g) => g.replace(/^<rootDir>\//, ""));
3014
- };
3015
- const visit = (node) => {
3016
- if (ts3.isPropertyAssignment(node)) {
3017
- const k = keyName2(node.name);
3018
- if (runner === "jest") {
3019
- if (k === "testMatch") sel.include = take(k, node.initializer);
3020
- else if (k === "testRegex") sel.regex = take(k, node.initializer);
3021
- else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
3022
- } else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
3023
- if (k === "include") sel.include = take(k, node.initializer);
3024
- else if (k === "exclude") sel.ignore = take(k, node.initializer);
4513
+ if (runner === "vitest" && isWorkspaceFile(path)) {
4514
+ const arr = workspaceArray(sf);
4515
+ if (arr) {
4516
+ const list2 = [];
4517
+ for (const el of arr.elements) {
4518
+ if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
4519
+ else sel.opaque = true;
3025
4520
  }
3026
- }
3027
- ts3.forEachChild(node, visit);
3028
- };
3029
- visit(sf);
4521
+ sel.projects = list2;
4522
+ sel.present = true;
4523
+ if (list2.some((p) => p.opaque)) sel.opaque = true;
4524
+ } else if (src.trim()) sel.opaque = true;
4525
+ } else sel = collect(sf, runner);
3030
4526
  } catch {
3031
4527
  }
3032
4528
  return sel;
3033
4529
  }
3034
- var globs2 = (patterns) => {
4530
+ var globs3 = (patterns) => {
3035
4531
  const ms = patterns.map((g) => {
3036
4532
  try {
3037
4533
  return picomatch3(g, { dot: true });
@@ -3041,6 +4537,11 @@ var globs2 = (patterns) => {
3041
4537
  });
3042
4538
  return (path) => ms.some((m) => m(path));
3043
4539
  };
4540
+ var globList2 = (patterns) => {
4541
+ const pos = globs3(patterns.filter((g) => !g.startsWith("!")));
4542
+ const neg = globs3(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
4543
+ return (path) => pos(path) && !neg(path);
4544
+ };
3044
4545
  var regexes = (patterns) => {
3045
4546
  const rs = patterns.flatMap((p) => {
3046
4547
  try {
@@ -3051,29 +4552,44 @@ var regexes = (patterns) => {
3051
4552
  });
3052
4553
  return (path) => rs.some((r) => r.test("/" + path));
3053
4554
  };
4555
+ var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
3054
4556
  function predicate(sel, runner) {
4557
+ if (sel.projects) {
4558
+ const ps = sel.projects.map((p) => predicate(p, runner));
4559
+ return {
4560
+ selects: (p) => ps.some((x) => x.selects(p)),
4561
+ why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
4562
+ };
4563
+ }
3055
4564
  const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
3056
- const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
3057
- const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
3058
- const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
4565
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({ pattern, key: runner === "jest" ? "testPathIgnorePatterns" : "test.exclude" }));
4566
+ const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
4567
+ const ignoredBy = (p) => ignoreList.find((e) => (runner === "jest" ? regexes([e.pattern]) : globs3([e.pattern]))(p));
4568
+ const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
4569
+ const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
4570
+ const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
3059
4571
  return {
3060
- selects: (p) => included(p) && !ignored(p),
4572
+ selects: (p) => {
4573
+ const r = rebase(p);
4574
+ return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
4575
+ },
3061
4576
  why: (p) => {
3062
- if (ignored(p)) {
3063
- const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
3064
- const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
3065
- return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
3066
- }
4577
+ const r = rebase(p);
4578
+ const baseKey = runner === "jest" ? "rootDir" : "test.dir";
4579
+ if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
4580
+ if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
4581
+ const hit = ignoredBy(r);
4582
+ if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
3067
4583
  const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
3068
4584
  return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
3069
4585
  }
3070
4586
  };
3071
4587
  }
3072
4588
  function suiteNarrowings(before, after, path, samples) {
3073
- const runner = runnerOf(path);
4589
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
3074
4590
  if (!runner) return [];
3075
- const b = parseSelection(before ?? "", runner);
3076
- const a = parseSelection(after, runner);
4591
+ const b = parseSelection(before ?? "", runner, path);
4592
+ const a = parseSelection(after, runner, path);
3077
4593
  if (!a.present || a.opaque) return [];
3078
4594
  const pb = predicate(b, runner);
3079
4595
  const pa = predicate(a, runner);
@@ -3105,6 +4621,18 @@ function calleeName(expr) {
3105
4621
  return null;
3106
4622
  }
3107
4623
  var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
4624
+ var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
4625
+ function templateRows(node) {
4626
+ const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
4627
+ return Math.max(rows, 1);
4628
+ }
4629
+ function hasSubstantiveBody(call) {
4630
+ const fn = call.arguments.find((a) => ts4.isArrowFunction(a) || ts4.isFunctionExpression(a));
4631
+ if (!fn) return true;
4632
+ const body = fn.body;
4633
+ const text = ts4.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
4634
+ return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
4635
+ }
3108
4636
  function eachRows(call) {
3109
4637
  const arg = call.arguments[0];
3110
4638
  if (!arg) return { n: 1, open: true };
@@ -3119,7 +4647,7 @@ function eachRows(call) {
3119
4647
  }
3120
4648
  return { n: 1, open: true };
3121
4649
  }
3122
- function countTests(src, path = "spec.ts") {
4650
+ function countTests(src, path = "spec.ts", substantiveOnly = false) {
3123
4651
  const lang = langOf(path);
3124
4652
  if (lang && lang !== "js") {
3125
4653
  const re = TEST_DEFS[lang];
@@ -3130,23 +4658,33 @@ function countTests(src, path = "spec.ts") {
3130
4658
  const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
3131
4659
  let n = 0;
3132
4660
  let open = false;
3133
- const visit = (node) => {
4661
+ const visit = (node, mult) => {
3134
4662
  if (ts4.isCallExpression(node)) {
4663
+ const head = node.expression;
4664
+ if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
4665
+ const r = eachRows(head);
4666
+ if (r.open) open = true;
4667
+ for (const a of node.arguments) visit(a, mult * r.n);
4668
+ return;
4669
+ }
4670
+ if (ts4.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
4671
+ for (const a of node.arguments) visit(a, mult * templateRows(head));
4672
+ return;
4673
+ }
3135
4674
  if (isEachOf(node.expression)) {
3136
4675
  const r = eachRows(node);
3137
- n += r.n;
4676
+ n += r.n * mult;
3138
4677
  if (r.open) open = true;
3139
4678
  } else {
3140
4679
  const name = calleeName(node.expression);
3141
- if (name === "it" || name === "test") n++;
4680
+ if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
3142
4681
  }
3143
4682
  } else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
3144
- const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
3145
- n += Math.max(rows, 1);
4683
+ n += templateRows(node) * mult;
3146
4684
  }
3147
- ts4.forEachChild(node, visit);
4685
+ ts4.forEachChild(node, (c) => visit(c, mult));
3148
4686
  };
3149
- visit(sf);
4687
+ visit(sf, 1);
3150
4688
  return { min: n, open };
3151
4689
  }
3152
4690
  function countTestBlocks(src, path = "spec.ts") {
@@ -3161,7 +4699,7 @@ function significantLines(src, path) {
3161
4699
  }
3162
4700
  return out2;
3163
4701
  }
3164
- var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
4702
+ var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
3165
4703
  function runnerSamples(configPath, policy, ctx) {
3166
4704
  const files = trackedFiles(ctx);
3167
4705
  if (!files) return CANONICAL_SAMPLES;
@@ -3169,6 +4707,34 @@ function runnerSamples(configPath, policy, ctx) {
3169
4707
  const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
3170
4708
  return own.length ? own : CANONICAL_SAMPLES;
3171
4709
  }
4710
+ function scriptNarrowings(before, after, path) {
4711
+ if ((path.split("/").pop() ?? path) !== "package.json") return [];
4712
+ const scriptsOf = (src) => {
4713
+ try {
4714
+ const s = JSON.parse(src ?? "{}").scripts ?? {};
4715
+ return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
4716
+ } catch {
4717
+ return {};
4718
+ }
4719
+ };
4720
+ const was = scriptsOf(before);
4721
+ const now = scriptsOf(after);
4722
+ const out2 = [];
4723
+ for (const [name, cmd] of Object.entries(now)) {
4724
+ if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
4725
+ const prev = was[name] ?? "";
4726
+ const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
4727
+ for (const m of cmd.matchAll(flags)) {
4728
+ const flag = m[0].trim();
4729
+ if (!flag) continue;
4730
+ const one = new RegExp(SUITE_NARROWING_FLAGS.source);
4731
+ if (one.test(prev) && prev.includes(flag)) continue;
4732
+ out2.push({ script: name, flag, before: prev, after: cmd });
4733
+ break;
4734
+ }
4735
+ }
4736
+ return out2;
4737
+ }
3172
4738
  function joinRoot(root, to) {
3173
4739
  const parts = root ? root.split("/") : [];
3174
4740
  for (const seg of to.replace(/\/+$/, "").split("/")) {
@@ -3200,11 +4766,11 @@ var testDeletion = {
3200
4766
  if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
3201
4767
  if (c.op === "add") {
3202
4768
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
3203
- addedTestBlocks += countTestBlocks(c.after, c.path);
4769
+ addedTestBlocks += countTests(c.after, c.path, true).min;
3204
4770
  } else if (c.before != null) {
3205
4771
  const before = significantLines(c.before, c.path);
3206
4772
  for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
3207
- addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
4773
+ addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
3208
4774
  }
3209
4775
  }
3210
4776
  const isRelocation = (removed, blocksRemoved) => {
@@ -3251,7 +4817,7 @@ var testDeletion = {
3251
4817
  })
3252
4818
  );
3253
4819
  }
3254
- } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
4820
+ } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
3255
4821
  for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
3256
4822
  out2.push(
3257
4823
  makeFinding(RULE7, policy, {
@@ -3262,6 +4828,16 @@ var testDeletion = {
3262
4828
  })
3263
4829
  );
3264
4830
  }
4831
+ for (const n of scriptNarrowings(c.before, c.after, c.path)) {
4832
+ out2.push(
4833
+ makeFinding(RULE7, policy, {
4834
+ file: c.path,
4835
+ message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
4836
+ evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
4837
+ remediation: "Keep the test script running the whole suite; a runner flag that selects fewer specs removes the rest from every run."
4838
+ })
4839
+ );
4840
+ }
3265
4841
  }
3266
4842
  } else {
3267
4843
  let root = "";
@@ -3275,15 +4851,25 @@ var testDeletion = {
3275
4851
  const inRepo = (t) => root ? `${root}/${t}` : t;
3276
4852
  const listing = trackedFiles(ctx);
3277
4853
  const testToks = toks.filter((t) => {
3278
- if (!isProtected(t, policy, "tests")) return false;
4854
+ const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
4855
+ const probe = resolved ?? t;
4856
+ if (!isProtected(probe, policy, "tests")) return false;
3279
4857
  if (!listing || root === null) return true;
3280
- const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
3281
- if (looksLikeFile || listing.includes(inRepo(t))) return true;
3282
- return containsProtected(inRepo(t), policy, "tests", ctx);
4858
+ const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
4859
+ if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
4860
+ return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
4861
+ });
4862
+ const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
4863
+ const dirToks = toks.filter((t) => {
4864
+ if (t.startsWith("-") || testToks.includes(t)) return false;
4865
+ const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
4866
+ if (!whole && /[*?{}[\]]/.test(t)) return false;
4867
+ if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
4868
+ const dir = whole ? root : joinRoot(root, t);
4869
+ if (dir === null) return false;
4870
+ if (dir === "") return rootHasProtected();
4871
+ return containsProtected(dir, policy, "tests", ctx);
3283
4872
  });
3284
- const dirToks = toks.filter(
3285
- (t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
3286
- );
3287
4873
  if (testToks.length === 0 && dirToks.length === 0) continue;
3288
4874
  const named = [...testToks, ...dirToks];
3289
4875
  const cmd = toks[0] ?? "";
@@ -3294,8 +4880,11 @@ var testDeletion = {
3294
4880
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
3295
4881
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
3296
4882
  const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
3297
- const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
3298
- return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
4883
+ const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
4884
+ const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
4885
+ if (!rev || rev === "HEAD") return false;
4886
+ if (src || seg.includes(" -- ")) return true;
4887
+ return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
3299
4888
  })();
3300
4889
  if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
3301
4890
  why = "rm deletes a test file";
@@ -3350,7 +4939,7 @@ var MIN_REMOVED_LINES = 3;
3350
4939
  var MIN_REMOVED_ROWS = 2;
3351
4940
  var ws = (s) => s.replace(/\s+/g, "");
3352
4941
  function eachTables(src, path) {
3353
- const rows = /* @__PURE__ */ new Set();
4942
+ const rows = /* @__PURE__ */ new Map();
3354
4943
  let open = false;
3355
4944
  if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
3356
4945
  try {
@@ -3364,10 +4953,9 @@ function eachTables(src, path) {
3364
4953
  open = true;
3365
4954
  continue;
3366
4955
  }
3367
- for (const raw of el.getText(sf).split("\n")) {
3368
- const l = raw.trim().replace(/,$/, "");
3369
- if (isSignificantLine(l, "js")) rows.add(l);
3370
- }
4956
+ const text = el.getText(sf).replace(/\s*\n\s*/g, " ");
4957
+ const key2 = ws(text);
4958
+ if (key2.length >= 3) rows.set(key2, text);
3371
4959
  }
3372
4960
  } else open = true;
3373
4961
  }
@@ -3422,12 +5010,13 @@ var testContentRemoval = {
3422
5010
  gone.push(line);
3423
5011
  }
3424
5012
  const table = eachTables(c.before, c.path);
3425
- const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
5013
+ const tableAfter = eachTables(c.after, c.path);
5014
+ const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
3426
5015
  if (goneRows.length >= MIN_REMOVED_ROWS) {
3427
5016
  out2.push(
3428
5017
  makeFinding(RULE8, policy, {
3429
5018
  file: c.path,
3430
- message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that now spreads from elsewhere, and they reappear nowhere in the change.`,
5019
+ message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
3431
5020
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
3432
5021
  remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
3433
5022
  })
@@ -3564,7 +5153,7 @@ var GUARDED_RULE = "hook-tampering";
3564
5153
  var BASELINE_HOOKS = defaultPolicy().protected.hooks;
3565
5154
  function isGuardedFinding(f) {
3566
5155
  if (f.rule !== GUARDED_RULE || !f.file) return false;
3567
- return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
5156
+ return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
3568
5157
  }
3569
5158
  function key(f) {
3570
5159
  return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
@@ -3623,8 +5212,8 @@ function hasBlocking(findings) {
3623
5212
  }
3624
5213
 
3625
5214
  // src/policy-load.ts
3626
- import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
3627
- import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
5215
+ import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
5216
+ import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
3628
5217
  import { parse as parse2 } from "yaml";
3629
5218
  var PolicyError = class extends Error {
3630
5219
  };
@@ -3672,8 +5261,8 @@ function validate(r, where) {
3672
5261
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
3673
5262
  if (r.protected !== void 0) {
3674
5263
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
3675
- for (const [cat, globs3] of Object.entries(r.protected)) {
3676
- if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
5264
+ for (const [cat, globs4] of Object.entries(r.protected)) {
5265
+ if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
3677
5266
  }
3678
5267
  }
3679
5268
  if (r.signoff !== void 0) {
@@ -3705,10 +5294,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
3705
5294
  const version = normalizeVersion(r.version, where);
3706
5295
  validate(r, where);
3707
5296
  const base = defaultPolicy(version);
3708
- const globs3 = (list) => list?.map(normalizeGlob);
3709
- const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
5297
+ const globs4 = (list2) => list2?.map(normalizeGlob);
5298
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
3710
5299
  const userRules = r.rules ? Object.fromEntries(
3711
- Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
5300
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
3712
5301
  ) : void 0;
3713
5302
  return {
3714
5303
  version,
@@ -3718,7 +5307,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
3718
5307
  // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
3719
5308
  protected: mergeProtected(base.protected, userProtected),
3720
5309
  rules: mergeRules(base.rules, userRules),
3721
- ignore: globs3(r.ignore) ?? base.ignore,
5310
+ ignore: globs4(r.ignore) ?? base.ignore,
3722
5311
  signoff: {
3723
5312
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
3724
5313
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -3748,9 +5337,9 @@ function parseOrThrow(src, where) {
3748
5337
  return parsePolicy(raw, where);
3749
5338
  }
3750
5339
  function loadPolicy(cwd = process.cwd()) {
3751
- const path = join3(cwd, POLICY_FILE);
5340
+ const path = join4(cwd, POLICY_FILE);
3752
5341
  if (!existsSync2(path)) return defaultPolicy();
3753
- return parseOrThrow(readFileSync3(path, "utf8"), POLICY_FILE);
5342
+ return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
3754
5343
  }
3755
5344
  function loadPolicyAt(rev, cwd) {
3756
5345
  const src = fileAt(rev, POLICY_FILE, { cwd });
@@ -3760,8 +5349,8 @@ function loadPolicyAt(rev, cwd) {
3760
5349
 
3761
5350
  // src/signoff.ts
3762
5351
  import { createHash } from "node:crypto";
3763
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, existsSync as existsSync3 } from "node:fs";
3764
- import { dirname, join as join4 } from "node:path";
5352
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
5353
+ import { dirname as dirname2, join as join5 } from "node:path";
3765
5354
  var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
3766
5355
  function fingerprint(rule, file, evidence) {
3767
5356
  return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
@@ -3772,13 +5361,13 @@ function ledgerPath(cwd, policy) {
3772
5361
  if (!ledgerInsideRepo(rel)) {
3773
5362
  throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
3774
5363
  }
3775
- return join4(cwd, rel);
5364
+ return join5(cwd, rel);
3776
5365
  }
3777
5366
  function readLedger(cwd, policy) {
3778
5367
  const p = ledgerPath(cwd, policy);
3779
5368
  if (!existsSync3(p)) return [];
3780
5369
  const out2 = [];
3781
- for (const line of readFileSync4(p, "utf8").split("\n")) {
5370
+ for (const line of readFileSync5(p, "utf8").split("\n")) {
3782
5371
  if (!line.trim()) continue;
3783
5372
  try {
3784
5373
  const e = JSON.parse(line);
@@ -3790,7 +5379,7 @@ function readLedger(cwd, policy) {
3790
5379
  }
3791
5380
  function appendEntry(cwd, policy, e) {
3792
5381
  const p = ledgerPath(cwd, policy);
3793
- mkdirSync(dirname(p), { recursive: true });
5382
+ mkdirSync(dirname2(p), { recursive: true });
3794
5383
  appendFileSync(p, JSON.stringify(e) + "\n");
3795
5384
  }
3796
5385
  function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
@@ -4147,21 +5736,21 @@ function runCheck(opts) {
4147
5736
  }
4148
5737
 
4149
5738
  // src/cli/hook.ts
4150
- import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
5739
+ import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
4151
5740
  import { createHash as createHash4 } from "node:crypto";
4152
- import { join as join9 } from "node:path";
5741
+ import { join as join10 } from "node:path";
4153
5742
 
4154
5743
  // src/adapters/claude/changes.ts
4155
5744
  import { execFileSync as execFileSync3 } from "node:child_process";
4156
- import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
5745
+ import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
4157
5746
  import { tmpdir } from "node:os";
4158
- import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
5747
+ import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
4159
5748
  function asStr(v) {
4160
5749
  return typeof v === "string" ? v : "";
4161
5750
  }
4162
5751
  function readDisk(path) {
4163
5752
  try {
4164
- return existsSync4(path) ? readFileSync5(path, "utf8") : null;
5753
+ return existsSync4(path) ? readFileSync6(path, "utf8") : null;
4165
5754
  } catch {
4166
5755
  return null;
4167
5756
  }
@@ -4184,11 +5773,11 @@ function synthFileChange(displayPath, before, after) {
4184
5773
  if (before === null) op = "add";
4185
5774
  else if (after === null) op = "delete";
4186
5775
  else op = "modify";
4187
- const dir = mkdtempSync(join5(tmpdir(), "hf-"));
5776
+ const dir = mkdtempSync(join6(tmpdir(), "hf-"));
4188
5777
  let raw = "";
4189
5778
  try {
4190
- const a = join5(dir, "a");
4191
- const b = join5(dir, "b");
5779
+ const a = join6(dir, "a");
5780
+ const b = join6(dir, "b");
4192
5781
  writeFileSync(a, before ?? "");
4193
5782
  writeFileSync(b, after ?? "");
4194
5783
  try {
@@ -4273,14 +5862,14 @@ function formatDenial(blocks) {
4273
5862
  }
4274
5863
 
4275
5864
  // src/session.ts
4276
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
4277
- import { dirname as dirname2, join as join6 } from "node:path";
5865
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
5866
+ import { dirname as dirname3, join as join7 } from "node:path";
4278
5867
  var UNSAFE = /[^A-Za-z0-9_-]/g;
4279
5868
  var SHA = /^[0-9a-f]{7,40}$/;
4280
5869
  function baselinePath(cwd, sessionId) {
4281
5870
  const gd = gitDir(cwd);
4282
5871
  if (!gd) return null;
4283
- return join6(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
5872
+ return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
4284
5873
  }
4285
5874
  function turnBaseline(cwd, sessionId) {
4286
5875
  if (!sessionId) return null;
@@ -4288,12 +5877,12 @@ function turnBaseline(cwd, sessionId) {
4288
5877
  const p = baselinePath(cwd, sessionId);
4289
5878
  if (!p) return null;
4290
5879
  if (existsSync5(p)) {
4291
- const v = readFileSync6(p, "utf8").trim();
5880
+ const v = readFileSync7(p, "utf8").trim();
4292
5881
  if (SHA.test(v)) return v;
4293
5882
  }
4294
5883
  const head = headSha(cwd);
4295
5884
  if (!head) return null;
4296
- mkdirSync2(dirname2(p), { recursive: true });
5885
+ mkdirSync2(dirname3(p), { recursive: true });
4297
5886
  writeFileSync2(p, head);
4298
5887
  return head;
4299
5888
  } catch {
@@ -4306,7 +5895,7 @@ function advanceTurnBaseline(cwd, sessionId) {
4306
5895
  const p = baselinePath(cwd, sessionId);
4307
5896
  const head = headSha(cwd);
4308
5897
  if (!p || !head) return;
4309
- mkdirSync2(dirname2(p), { recursive: true });
5898
+ mkdirSync2(dirname3(p), { recursive: true });
4310
5899
  writeFileSync2(p, head);
4311
5900
  } catch {
4312
5901
  }
@@ -4314,8 +5903,8 @@ function advanceTurnBaseline(cwd, sessionId) {
4314
5903
 
4315
5904
  // src/effect.ts
4316
5905
  import { createHash as createHash2 } from "node:crypto";
4317
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
4318
- import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
5906
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
5907
+ import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
4319
5908
  import { execFileSync as execFileSync4 } from "node:child_process";
4320
5909
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
4321
5910
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -4327,7 +5916,7 @@ function gitDir2(cwd) {
4327
5916
  stdio: ["ignore", "pipe", "pipe"]
4328
5917
  }).trim();
4329
5918
  if (!d) return null;
4330
- return isAbsolute4(d) ? d : join7(cwd, d);
5919
+ return isAbsolute4(d) ? d : join8(cwd, d);
4331
5920
  } catch {
4332
5921
  return null;
4333
5922
  }
@@ -4336,8 +5925,8 @@ function statePath(cwd, sessionId, kind) {
4336
5925
  if (!sessionId) return null;
4337
5926
  const gd = gitDir2(cwd);
4338
5927
  if (!gd) return null;
4339
- const dir = join7(gd, "tamperward");
4340
- return join7(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
5928
+ const dir = join8(gd, "tamperward");
5929
+ return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
4341
5930
  }
4342
5931
  function ptreePath(cwd, sessionId) {
4343
5932
  return statePath(cwd, sessionId, "ptree");
@@ -4351,8 +5940,8 @@ function contentHash(content) {
4351
5940
  }
4352
5941
  function snapshotProtected(cwd, policy, prev) {
4353
5942
  const out2 = {};
4354
- const walk = (rel) => {
4355
- const abs2 = rel ? join7(cwd, rel) : cwd;
5943
+ const walk2 = (rel) => {
5944
+ const abs2 = rel ? join8(cwd, rel) : cwd;
4356
5945
  let names;
4357
5946
  try {
4358
5947
  names = readdirSync(abs2);
@@ -4364,15 +5953,15 @@ function snapshotProtected(cwd, policy, prev) {
4364
5953
  const r = rel ? `${rel}/${name}` : name;
4365
5954
  let st;
4366
5955
  try {
4367
- st = statSync(join7(cwd, r));
5956
+ st = statSync(join8(cwd, r));
4368
5957
  } catch {
4369
5958
  continue;
4370
5959
  }
4371
- if (st.isDirectory()) walk(r);
5960
+ if (st.isDirectory()) walk2(r);
4372
5961
  else if (st.isFile() && isProtected(r, policy)) {
4373
5962
  let hash = "";
4374
5963
  try {
4375
- hash = sha(readFileSync7(join7(cwd, r)));
5964
+ hash = sha(readFileSync8(join8(cwd, r)));
4376
5965
  } catch {
4377
5966
  hash = "unreadable";
4378
5967
  }
@@ -4380,7 +5969,7 @@ function snapshotProtected(cwd, policy, prev) {
4380
5969
  }
4381
5970
  }
4382
5971
  };
4383
- walk("");
5972
+ walk2("");
4384
5973
  return out2;
4385
5974
  }
4386
5975
  function driftBetween(expected, current) {
@@ -4400,7 +5989,7 @@ function driftBetween(expected, current) {
4400
5989
  function loadTree(p) {
4401
5990
  if (!p || !existsSync6(p)) return null;
4402
5991
  try {
4403
- return JSON.parse(readFileSync7(p, "utf8"));
5992
+ return JSON.parse(readFileSync8(p, "utf8"));
4404
5993
  } catch {
4405
5994
  return null;
4406
5995
  }
@@ -4408,7 +5997,7 @@ function loadTree(p) {
4408
5997
  function saveTree(p, tree) {
4409
5998
  if (!p) return;
4410
5999
  try {
4411
- mkdirSync3(join7(p, ".."), { recursive: true });
6000
+ mkdirSync3(join8(p, ".."), { recursive: true });
4412
6001
  const tmp = `${p}.${process.pid}.tmp`;
4413
6002
  writeFileSync3(tmp, JSON.stringify(tree));
4414
6003
  renameSync(tmp, p);
@@ -4439,15 +6028,15 @@ function dropTurnTree(cwd, sessionId) {
4439
6028
  }
4440
6029
 
4441
6030
  // src/cli/watch.ts
4442
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
4443
- import { join as join8 } from "node:path";
6031
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
6032
+ import { join as join9 } from "node:path";
4444
6033
  import { createHash as createHash3 } from "node:crypto";
4445
6034
  var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
4446
6035
  function snap(abs2) {
4447
6036
  try {
4448
6037
  const st = statSync2(abs2);
4449
6038
  if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
4450
- const hash = createHash3("sha256").update(readFileSync8(abs2)).digest("hex").slice(0, 16);
6039
+ const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
4451
6040
  return { mode: st.mode, size: st.size, hash };
4452
6041
  } catch {
4453
6042
  return { mode: null, size: null, hash: null };
@@ -4455,7 +6044,7 @@ function snap(abs2) {
4455
6044
  }
4456
6045
  function defaultEventLog(cwd) {
4457
6046
  if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
4458
- return join8(gitDir(cwd) ?? join8(cwd, ".git"), "tamperward", "fsevents.jsonl");
6047
+ return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
4459
6048
  }
4460
6049
  function watchTree(dir, cb) {
4461
6050
  if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
@@ -4472,12 +6061,12 @@ function watchTree(dir, cb) {
4472
6061
  if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
4473
6062
  let w;
4474
6063
  try {
4475
- w = watch(rel ? join8(dir, rel) : dir, (kind, fname) => {
6064
+ w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
4476
6065
  if (!fname) return;
4477
6066
  const child = rel ? `${rel}/${String(fname)}` : String(fname);
4478
6067
  cb(kind, child);
4479
6068
  try {
4480
- if (statSync2(join8(dir, child)).isDirectory()) addDir(child);
6069
+ if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
4481
6070
  } catch {
4482
6071
  }
4483
6072
  });
@@ -4486,11 +6075,11 @@ function watchTree(dir, cb) {
4486
6075
  }
4487
6076
  watchers.set(rel, w);
4488
6077
  };
4489
- const walk = (rel) => {
6078
+ const walk2 = (rel) => {
4490
6079
  addDir(rel);
4491
6080
  let names = [];
4492
6081
  try {
4493
- names = readdirSync2(rel ? join8(dir, rel) : dir);
6082
+ names = readdirSync2(rel ? join9(dir, rel) : dir);
4494
6083
  } catch {
4495
6084
  return;
4496
6085
  }
@@ -4498,25 +6087,25 @@ function watchTree(dir, cb) {
4498
6087
  const child = rel ? `${rel}/${name}` : name;
4499
6088
  if (SKIP2.test(child + "/")) continue;
4500
6089
  try {
4501
- if (statSync2(join8(dir, child)).isDirectory()) walk(child);
6090
+ if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
4502
6091
  } catch {
4503
6092
  }
4504
6093
  }
4505
6094
  };
4506
- walk("");
6095
+ walk2("");
4507
6096
  return { close: () => {
4508
6097
  for (const w of watchers.values()) w.close();
4509
6098
  } };
4510
6099
  }
4511
6100
  function startWatcher(dir, log, policy) {
4512
6101
  try {
4513
- mkdirSync4(join8(log, ".."), { recursive: true });
6102
+ mkdirSync4(join9(log, ".."), { recursive: true });
4514
6103
  } catch {
4515
6104
  }
4516
6105
  const last = /* @__PURE__ */ new Map();
4517
6106
  return watchTree(dir, (kind, rel) => {
4518
6107
  if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
4519
- const s = snap(join8(dir, rel));
6108
+ const s = snap(join9(dir, rel));
4520
6109
  const key2 = `${s.hash}:${s.mode}`;
4521
6110
  if (last.get(rel) === key2) return;
4522
6111
  last.set(rel, key2);
@@ -4543,7 +6132,7 @@ function runWatch(args) {
4543
6132
  }
4544
6133
 
4545
6134
  // src/detectors/fs-events.ts
4546
- import { readFileSync as readFileSync9, statSync as statSync3 } from "node:fs";
6135
+ import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
4547
6136
  var RULE11 = "transient-protected-mutation";
4548
6137
  function readEvents(log, offset) {
4549
6138
  let size = 0;
@@ -4553,7 +6142,7 @@ function readEvents(log, offset) {
4553
6142
  return { events: [], newOffset: offset };
4554
6143
  }
4555
6144
  if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
4556
- const raw = readFileSync9(log, "utf8").slice(offset);
6145
+ const raw = readFileSync10(log, "utf8").slice(offset);
4557
6146
  const events = [];
4558
6147
  for (const line of raw.split("\n")) {
4559
6148
  if (!line.trim()) continue;
@@ -4606,7 +6195,7 @@ var HookInputError = class extends Error {
4606
6195
  };
4607
6196
  function readStdin() {
4608
6197
  try {
4609
- return readFileSync10(0, "utf8");
6198
+ return readFileSync11(0, "utf8");
4610
6199
  } catch (e) {
4611
6200
  throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
4612
6201
  }
@@ -4665,7 +6254,7 @@ function fileChanges(changes) {
4665
6254
  }
4666
6255
  function hashOnDisk(cwd, rel) {
4667
6256
  try {
4668
- return contentHash(readFileSync10(join9(cwd, rel)));
6257
+ return contentHash(readFileSync11(join10(cwd, rel)));
4669
6258
  } catch {
4670
6259
  return null;
4671
6260
  }
@@ -4854,7 +6443,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
4854
6443
  if (!existsSync7(log)) return none;
4855
6444
  let offset = 0;
4856
6445
  if (cp && existsSync7(cp)) {
4857
- const n = Number(readFileSync10(cp, "utf8"));
6446
+ const n = Number(readFileSync11(cp, "utf8"));
4858
6447
  offset = Number.isFinite(n) ? n : 0;
4859
6448
  }
4860
6449
  const { events, newOffset } = readEvents(log, offset);
@@ -4868,7 +6457,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
4868
6457
  const finalHash = (path) => {
4869
6458
  try {
4870
6459
  if (!statSync4(`${cwd}/${path}`).isFile()) return null;
4871
- return createHash4("sha256").update(readFileSync10(`${cwd}/${path}`)).digest("hex").slice(0, 16);
6460
+ return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
4872
6461
  } catch {
4873
6462
  return null;
4874
6463
  }
@@ -5011,9 +6600,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
5011
6600
  // src/cli/init.ts
5012
6601
  import { execFileSync as execFileSync5 } from "node:child_process";
5013
6602
  import { createHash as createHash5 } from "node:crypto";
5014
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
5015
- import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
5016
- import { fileURLToPath } from "node:url";
6603
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
6604
+ import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
5017
6605
  var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
5018
6606
  var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5019
6607
  function gitConfig(cwd, key2) {
@@ -5023,12 +6611,9 @@ function gitConfig(cwd, key2) {
5023
6611
  return null;
5024
6612
  }
5025
6613
  }
5026
- var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
5027
- var MARKER = "# tamperward: block agent shortcuts before they land";
5028
6614
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
5029
6615
  var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
5030
6616
  var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
5031
- var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
5032
6617
  var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
5033
6618
  var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
5034
6619
  function coveredBy(existing, critical) {
@@ -5072,24 +6657,6 @@ version: 1
5072
6657
  # snapshot-rewrite: { severity: block }
5073
6658
  # ignore: [] # visible blind spots \u2014 the count is always reported
5074
6659
  `;
5075
- function shippedVersion() {
5076
- try {
5077
- const here = dirname3(fileURLToPath(import.meta.url));
5078
- for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
5079
- try {
5080
- const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
5081
- if (pkg.name === "tamperward" && pkg.version) return pkg.version;
5082
- } catch {
5083
- }
5084
- }
5085
- } catch {
5086
- }
5087
- return "latest";
5088
- }
5089
- var TW_VERSION = shippedVersion();
5090
- var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
5091
- var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
5092
- var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
5093
6660
  function pinOf(command) {
5094
6661
  const m = command.match(OURS);
5095
6662
  return m ? m[1] ?? "" : null;
@@ -5184,13 +6751,13 @@ function readWorkflowMark(src) {
5184
6751
  if (!m) return null;
5185
6752
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
5186
6753
  }
5187
- function toolSet2(matcher2) {
6754
+ function toolSet(matcher2) {
5188
6755
  const m = String(matcher2 ?? "").trim();
5189
6756
  if (m === "" || m === "*") return null;
5190
6757
  return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
5191
6758
  }
5192
6759
  function missingTools(matcher2) {
5193
- const have = toolSet2(matcher2);
6760
+ const have = toolSet(matcher2);
5194
6761
  if (have === null) return [];
5195
6762
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
5196
6763
  }
@@ -5210,7 +6777,7 @@ function hooksShapeError(hooks) {
5210
6777
  return null;
5211
6778
  }
5212
6779
  function planPolicy(cwd) {
5213
- const path = join10(cwd, POLICY_FILE);
6780
+ const path = join11(cwd, POLICY_FILE);
5214
6781
  if (existsSync8(path)) {
5215
6782
  try {
5216
6783
  loadPolicy(cwd);
@@ -5229,12 +6796,12 @@ function planPolicy(cwd) {
5229
6796
  }
5230
6797
  function planClaudeHooks(cwd) {
5231
6798
  const rel = ".claude/settings.json";
5232
- const path = join10(cwd, rel);
6799
+ const path = join11(cwd, rel);
5233
6800
  let settings = {};
5234
6801
  if (existsSync8(path)) {
5235
6802
  let parsed;
5236
6803
  try {
5237
- parsed = JSON.parse(readFileSync11(path, "utf8"));
6804
+ parsed = JSON.parse(readFileSync12(path, "utf8"));
5238
6805
  } catch {
5239
6806
  return {
5240
6807
  item: "agent",
@@ -5287,12 +6854,12 @@ function planClaudeHooks(cwd) {
5287
6854
  });
5288
6855
  }
5289
6856
  for (const m of stale) {
5290
- m.matcher = [...toolSet2(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
6857
+ m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
5291
6858
  }
5292
6859
  if (needStop) {
5293
6860
  (hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
5294
6861
  }
5295
- mkdirSync5(dirname3(path), { recursive: true });
6862
+ mkdirSync5(dirname4(path), { recursive: true });
5296
6863
  writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
5297
6864
  }
5298
6865
  };
@@ -5327,7 +6894,7 @@ function hooksDir(cwd) {
5327
6894
  if (out2) return resolve2(cwd, out2);
5328
6895
  } catch {
5329
6896
  }
5330
- return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
6897
+ return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
5331
6898
  }
5332
6899
  function display(cwd, path) {
5333
6900
  const rel = relative3(cwd, path);
@@ -5354,30 +6921,30 @@ function planPreCommit(cwd) {
5354
6921
  const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
5355
6922
  let path;
5356
6923
  let note;
5357
- if (existsSync8(join10(cwd, ".husky"))) {
5358
- path = join10(cwd, ".husky", "pre-commit");
6924
+ if (existsSync8(join11(cwd, ".husky"))) {
6925
+ path = join11(cwd, ".husky", "pre-commit");
5359
6926
  note = "husky";
5360
6927
  } else {
5361
- const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join10(cwd, c))));
6928
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
5362
6929
  if (managed) {
5363
6930
  return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
5364
6931
  }
5365
6932
  const dir = hooksDir(cwd);
5366
6933
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
5367
- if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
5368
- path = join10(dirname3(dir), "pre-commit");
6934
+ if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
6935
+ path = join11(dirname4(dir), "pre-commit");
5369
6936
  note = "husky";
5370
6937
  } else {
5371
- path = join10(dir, "pre-commit");
6938
+ path = join11(dir, "pre-commit");
5372
6939
  const rel2 = display(cwd, dir);
5373
- note = rel2 === join10(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
6940
+ note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
5374
6941
  }
5375
6942
  }
5376
6943
  const rel = display(cwd, path);
5377
6944
  if (existsSync8(path) && !statSync5(path).isFile()) {
5378
6945
  return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
5379
6946
  }
5380
- const existing = existsSync8(path) ? readFileSync11(path, "utf8") : null;
6947
+ const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
5381
6948
  if (existing === null) {
5382
6949
  return {
5383
6950
  item: "pre-commit",
@@ -5385,7 +6952,7 @@ function planPreCommit(cwd) {
5385
6952
  status: "create",
5386
6953
  detail: `create via ${note}`,
5387
6954
  apply: () => {
5388
- mkdirSync5(dirname3(path), { recursive: true });
6955
+ mkdirSync5(dirname4(path), { recursive: true });
5389
6956
  writeFileSync5(path, `#!/bin/sh
5390
6957
  ${block.join("\n")}
5391
6958
  `);
@@ -5466,10 +7033,10 @@ ${block.join("\n")}
5466
7033
  function planCodeowners(cwd) {
5467
7034
  const rel = ".github/CODEOWNERS";
5468
7035
  const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
5469
- (r) => existsSync8(join10(cwd, r))
7036
+ (r) => existsSync8(join11(cwd, r))
5470
7037
  );
5471
- const path = join10(cwd, existingRel ?? rel);
5472
- const existing = existingRel ? readFileSync11(path, "utf8") : null;
7038
+ const path = join11(cwd, existingRel ?? rel);
7039
+ const existing = existingRel ? readFileSync12(path, "utf8") : null;
5473
7040
  const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
5474
7041
  if (missing.length === 0) {
5475
7042
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
@@ -5495,16 +7062,16 @@ ${CODEOWNERS_MARK}
5495
7062
  warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
5496
7063
  } : {},
5497
7064
  apply: () => {
5498
- mkdirSync5(dirname3(path), { recursive: true });
7065
+ mkdirSync5(dirname4(path), { recursive: true });
5499
7066
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
5500
7067
  }
5501
7068
  };
5502
7069
  }
5503
7070
  function planWorkflow(cwd, force) {
5504
7071
  const rel = ".github/workflows/tamperward.yml";
5505
- const path = join10(cwd, rel);
7072
+ const path = join11(cwd, rel);
5506
7073
  const write = () => {
5507
- mkdirSync5(dirname3(path), { recursive: true });
7074
+ mkdirSync5(dirname4(path), { recursive: true });
5508
7075
  writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
5509
7076
  };
5510
7077
  if (!existsSync8(path)) {
@@ -5518,7 +7085,7 @@ function planWorkflow(cwd, force) {
5518
7085
  }
5519
7086
  let src;
5520
7087
  try {
5521
- src = readFileSync11(path, "utf8");
7088
+ src = readFileSync12(path, "utf8");
5522
7089
  } catch {
5523
7090
  return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
5524
7091
  }
@@ -5617,15 +7184,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
5617
7184
  // src/cli/verify.ts
5618
7185
  import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
5619
7186
  import { createHash as createHash8 } from "node:crypto";
5620
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
7187
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
5621
7188
  import { tmpdir as tmpdir2 } from "node:os";
5622
- import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
7189
+ import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
5623
7190
 
5624
7191
  // src/git/trusted.ts
5625
7192
  import { execFileSync as execFileSync6 } from "node:child_process";
5626
7193
  import { createHash as createHash6 } from "node:crypto";
5627
- import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
5628
- import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
7194
+ import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
7195
+ import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
5629
7196
  function trustedGitEnv() {
5630
7197
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
5631
7198
  }
@@ -5638,7 +7205,7 @@ function gitDirOf(cwd) {
5638
7205
  env: trustedGitEnv()
5639
7206
  }).trim();
5640
7207
  if (!d) return null;
5641
- return isAbsolute6(d) ? d : join11(cwd, d);
7208
+ return isAbsolute6(d) ? d : join12(cwd, d);
5642
7209
  } catch {
5643
7210
  return null;
5644
7211
  }
@@ -5663,9 +7230,9 @@ function objectRewriteState(cwd) {
5663
7230
  parts.push(`${rel}:<no-git-dir>`);
5664
7231
  continue;
5665
7232
  }
5666
- const p = join11(dir, rel);
7233
+ const p = join12(dir, rel);
5667
7234
  try {
5668
- parts.push(`${rel}:` + (existsSync9(p) ? readFileSync12(p, "utf8").trim() : ""));
7235
+ parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
5669
7236
  } catch {
5670
7237
  parts.push(`${rel}:<unreadable>`);
5671
7238
  }
@@ -5676,8 +7243,8 @@ function objectRewriteState(cwd) {
5676
7243
  // src/fingerprint.ts
5677
7244
  import { execFileSync as execFileSync7 } from "node:child_process";
5678
7245
  import { createHash as createHash7 } from "node:crypto";
5679
- import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
5680
- import { join as join12 } from "node:path";
7246
+ import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
7247
+ import { join as join13 } from "node:path";
5681
7248
  function treeFingerprint(cwd) {
5682
7249
  const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
5683
7250
  cwd,
@@ -5690,7 +7257,7 @@ function treeFingerprint(cwd) {
5690
7257
  h.update(rel);
5691
7258
  h.update("\0");
5692
7259
  try {
5693
- h.update(readFileSync13(join12(cwd, rel)));
7260
+ h.update(readFileSync14(join13(cwd, rel)));
5694
7261
  } catch {
5695
7262
  h.update("<unreadable>");
5696
7263
  }
@@ -5699,9 +7266,9 @@ function treeFingerprint(cwd) {
5699
7266
  return h.digest("hex");
5700
7267
  }
5701
7268
  function depsFingerprint(cwd) {
5702
- const root = join12(cwd, "node_modules");
7269
+ const root = join13(cwd, "node_modules");
5703
7270
  const h = createHash7("sha256");
5704
- const walk = (dir) => {
7271
+ const walk2 = (dir) => {
5705
7272
  let entries;
5706
7273
  try {
5707
7274
  entries = readdirSync3(dir, { withFileTypes: true });
@@ -5709,12 +7276,12 @@ function depsFingerprint(cwd) {
5709
7276
  return;
5710
7277
  }
5711
7278
  for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
5712
- const p = join12(dir, e.name);
5713
- if (e.isDirectory()) walk(p);
7279
+ const p = join13(dir, e.name);
7280
+ if (e.isDirectory()) walk2(p);
5714
7281
  else if (e.isFile()) {
5715
7282
  h.update(p);
5716
7283
  try {
5717
- h.update(readFileSync13(p));
7284
+ h.update(readFileSync14(p));
5718
7285
  h.update(String(statSync6(p).mode));
5719
7286
  } catch {
5720
7287
  h.update("<unreadable>");
@@ -5734,7 +7301,7 @@ function depsFingerprint(cwd) {
5734
7301
  } catch {
5735
7302
  return "none";
5736
7303
  }
5737
- walk(root);
7304
+ walk2(root);
5738
7305
  return h.digest("hex");
5739
7306
  }
5740
7307
 
@@ -5829,7 +7396,7 @@ function baseIsAncestorOfHead(base, cwd) {
5829
7396
  function materialize(cwd, dest) {
5830
7397
  const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
5831
7398
  for (const rel of listed) {
5832
- const src = join13(cwd, rel);
7399
+ const src = join14(cwd, rel);
5833
7400
  let st;
5834
7401
  try {
5835
7402
  st = statSync7(src);
@@ -5837,18 +7404,18 @@ function materialize(cwd, dest) {
5837
7404
  continue;
5838
7405
  }
5839
7406
  if (!st.isFile()) continue;
5840
- const out2 = join13(dest, rel);
5841
- mkdirSync6(dirname4(out2), { recursive: true });
7407
+ const out2 = join14(dest, rel);
7408
+ mkdirSync6(dirname5(out2), { recursive: true });
5842
7409
  dropSymlink(out2);
5843
7410
  if (lstatSync(src).isSymbolicLink()) {
5844
- writeFileSync6(out2, readFileSync14(src));
7411
+ writeFileSync6(out2, readFileSync15(src));
5845
7412
  } else {
5846
7413
  cpSync(src, out2, { dereference: true });
5847
7414
  }
5848
7415
  chmodSync2(out2, st.mode);
5849
7416
  }
5850
7417
  const nm = resolve3(cwd, "node_modules");
5851
- if (existsSync10(nm)) symlinkSync(nm, join13(dest, "node_modules"), "dir");
7418
+ if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
5852
7419
  }
5853
7420
  function dropSymlink(p) {
5854
7421
  try {
@@ -5863,9 +7430,9 @@ function verifierInputs(cmd, atBase, policy) {
5863
7430
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
5864
7431
  if (tok && baseFiles.has(tok)) picked.add(tok);
5865
7432
  }
5866
- const globs3 = policy.verify?.inputs;
5867
- if (globs3 && globs3.length) {
5868
- for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
7433
+ const globs4 = policy.verify?.inputs;
7434
+ if (globs4 && globs4.length) {
7435
+ for (const rel of atBase) if (matchesAny(rel, globs4)) picked.add(rel);
5869
7436
  }
5870
7437
  return picked;
5871
7438
  }
@@ -5881,11 +7448,11 @@ function overlayDigest(dest, paths) {
5881
7448
  for (const rel of [...paths].sort()) {
5882
7449
  h.update(rel);
5883
7450
  h.update("\0");
5884
- const p = join13(dest, rel);
7451
+ const p = join14(dest, rel);
5885
7452
  try {
5886
7453
  const st = lstatSync(p);
5887
7454
  h.update(String(st.mode));
5888
- h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync14(p));
7455
+ h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
5889
7456
  } catch {
5890
7457
  h.update("<gone>");
5891
7458
  }
@@ -5908,8 +7475,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
5908
7475
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
5909
7476
  }
5910
7477
  const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
5911
- const out2 = join13(dest, e.path);
5912
- mkdirSync6(dirname4(out2), { recursive: true });
7478
+ const out2 = join14(dest, e.path);
7479
+ mkdirSync6(dirname5(out2), { recursive: true });
5913
7480
  rmSync3(out2, { force: true });
5914
7481
  if (e.mode === "120000") {
5915
7482
  symlinkSync(content.toString("utf8"), out2);
@@ -5923,7 +7490,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
5923
7490
  const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
5924
7491
  for (const rel of inCopy) {
5925
7492
  if (!isOverlay(rel) || baseProtected.has(rel)) continue;
5926
- rmSync3(join13(dest, rel), { force: true });
7493
+ rmSync3(join14(dest, rel), { force: true });
5927
7494
  removed++;
5928
7495
  }
5929
7496
  return { restored, removed };
@@ -5965,8 +7532,8 @@ function suiteEnv(scratch) {
5965
7532
  if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
5966
7533
  env[k] = v;
5967
7534
  }
5968
- const userRc = join13(scratch, "npmrc-user");
5969
- const globalRc = join13(scratch, "npmrc-global");
7535
+ const userRc = join14(scratch, "npmrc-user");
7536
+ const globalRc = join14(scratch, "npmrc-global");
5970
7537
  for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
5971
7538
  env.npm_config_userconfig = userRc;
5972
7539
  env.npm_config_globalconfig = globalRc;
@@ -5976,8 +7543,8 @@ function suiteEnv(scratch) {
5976
7543
  }
5977
7544
  function runSuite(dir, cmd, budgetSecs) {
5978
7545
  const t0 = Date.now();
5979
- const outDir = mkdtempSync2(join13(tmpdir2(), "tw-verify-run-"));
5980
- const outFile = join13(outDir, "result.json");
7546
+ const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
7547
+ const outFile = join14(outDir, "result.json");
5981
7548
  try {
5982
7549
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
5983
7550
  cwd: dir,
@@ -5990,7 +7557,7 @@ function runSuite(dir, cmd, budgetSecs) {
5990
7557
  const secs = Math.round((Date.now() - t0) / 1e3);
5991
7558
  let r;
5992
7559
  try {
5993
- r = JSON.parse(readFileSync14(outFile, "utf8"));
7560
+ r = JSON.parse(readFileSync15(outFile, "utf8"));
5994
7561
  } catch {
5995
7562
  return { exit: null, secs };
5996
7563
  }
@@ -6041,8 +7608,8 @@ function runVerify(opts) {
6041
7608
  out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
6042
7609
  return 2;
6043
7610
  }
6044
- const visRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-vis-"));
6045
- const visDir = join13(visRoot, "t");
7611
+ const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
7612
+ const visDir = join14(visRoot, "t");
6046
7613
  const cleanup = (dirs) => {
6047
7614
  if (opts.keep) return;
6048
7615
  for (const d of dirs) rmSync3(d, { recursive: true, force: true });
@@ -6070,8 +7637,8 @@ function runVerify(opts) {
6070
7637
  out2("pristine run executes through it too. Failing closed, not open.");
6071
7638
  return 2;
6072
7639
  }
6073
- const priRoot = mkdtempSync2(join13(tmpdir2(), "tw-verify-pri-"));
6074
- const priDir = join13(priRoot, "t");
7640
+ const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
7641
+ const priDir = join14(priRoot, "t");
6075
7642
  let restored = [];
6076
7643
  let removedAdded = 0;
6077
7644
  try {
@@ -6183,7 +7750,7 @@ function parseVerify(args) {
6183
7750
 
6184
7751
  // src/cli/run.ts
6185
7752
  import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
6186
- import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
7753
+ import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
6187
7754
  import { resolve as resolve4 } from "node:path";
6188
7755
  var out = (s) => process.stdout.write(s + "\n");
6189
7756
  var err = (s) => process.stderr.write(s + "\n");
@@ -6192,7 +7759,7 @@ function git3(args, cwd) {
6192
7759
  }
6193
7760
  function nowTicks() {
6194
7761
  try {
6195
- return Math.floor(parseFloat(readFileSync15("/proc/uptime", "utf8").split(" ")[0]) * 100);
7762
+ return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
6196
7763
  } catch {
6197
7764
  return Number.POSITIVE_INFINITY;
6198
7765
  }
@@ -6222,7 +7789,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
6222
7789
  }
6223
7790
  if (held !== real && !held.startsWith(real + "/")) continue;
6224
7791
  try {
6225
- const stat = readFileSync15(`/proc/${pid}/stat`, "utf8");
7792
+ const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
6226
7793
  const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
6227
7794
  if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
6228
7795
  } catch {