tamperward 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +244 -70
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -1186,6 +1186,50 @@ function trackedFiles(ctx) {
1186
1186
  return files;
1187
1187
  }
1188
1188
  var branchCache = /* @__PURE__ */ new Map();
1189
+ var refsCache = /* @__PURE__ */ new Map();
1190
+ function branchRefs(ctx) {
1191
+ if (!ctx?.cwd) return null;
1192
+ const hit = refsCache.get(ctx.cwd);
1193
+ if (hit !== void 0) return hit;
1194
+ let refs = null;
1195
+ try {
1196
+ const out2 = execFileSync2("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], {
1197
+ cwd: ctx.cwd,
1198
+ encoding: "utf8",
1199
+ stdio: ["ignore", "pipe", "pipe"]
1200
+ });
1201
+ refs = new Set(out2.split("\n").map((l) => l.trim()).filter(Boolean));
1202
+ } catch {
1203
+ refs = null;
1204
+ }
1205
+ refsCache.set(ctx.cwd, refs);
1206
+ return refs;
1207
+ }
1208
+ function branchExists(name, ctx) {
1209
+ const refs = branchRefs(ctx);
1210
+ if (!refs) return null;
1211
+ if (refs.has(name)) return true;
1212
+ for (const r of refs) if (r.endsWith("/" + name)) return true;
1213
+ return false;
1214
+ }
1215
+ function revIsHead(rev, ctx) {
1216
+ if (!ctx?.cwd || rev.startsWith("-")) return null;
1217
+ const parse3 = (r) => {
1218
+ try {
1219
+ return execFileSync2("git", ["rev-parse", "--verify", "--quiet", "--end-of-options", `${r}^{commit}`], {
1220
+ cwd: ctx.cwd,
1221
+ encoding: "utf8",
1222
+ stdio: ["ignore", "pipe", "pipe"]
1223
+ }).trim();
1224
+ } catch {
1225
+ return null;
1226
+ }
1227
+ };
1228
+ const head = parse3("HEAD");
1229
+ const target = parse3(rev);
1230
+ if (!head || !target) return null;
1231
+ return head === target;
1232
+ }
1189
1233
  function defaultBranch(ctx) {
1190
1234
  if (!ctx?.cwd) return null;
1191
1235
  const hit = branchCache.get(ctx.cwd);
@@ -1205,15 +1249,16 @@ function defaultBranch(ctx) {
1205
1249
  return name;
1206
1250
  }
1207
1251
  var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
1208
- function containsProtected(token, policy, category, ctx) {
1252
+ function containsProtected(token, policy, category, ctx, except) {
1209
1253
  const dir = dirOf(token);
1210
1254
  if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
1255
+ const counts = (p) => isProtected(p, policy, category) && !(except && isProtected(p, policy, except));
1211
1256
  const files = trackedFiles(ctx);
1212
1257
  if (files) {
1213
1258
  const prefix = dir + "/";
1214
- return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
1259
+ return files.some((f) => f.startsWith(prefix) && counts(f));
1215
1260
  }
1216
- if (isProtected(dir + "/probe", policy, category)) return true;
1261
+ if (counts(dir + "/probe")) return true;
1217
1262
  const base = dir.split("/").pop() ?? "";
1218
1263
  return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
1219
1264
  }
@@ -1316,6 +1361,10 @@ function parseThresholds(src) {
1316
1361
  if (!ts2.isPropertyAssignment(p)) continue;
1317
1362
  const k = keyName(p.name);
1318
1363
  if (k === null || isMetric(k)) continue;
1364
+ if (key2 === "thresholds" && k === "100" && p.initializer.kind === ts2.SyntaxKind.TrueKeyword) {
1365
+ res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
1366
+ continue;
1367
+ }
1319
1368
  const m = metricsOf(p.initializer, resolve5);
1320
1369
  if (!m) continue;
1321
1370
  if (k === "global") res.global = merge(res.global, m);
@@ -1409,7 +1458,7 @@ function sectionText(src, key2) {
1409
1458
  return "";
1410
1459
  }
1411
1460
  }
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;
1461
+ 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|generated|__generated__|codegen|migrations?)(?:\/|$|\*)|\.(?:test|spec|stories|d|gen|generated|pb|e2e-spec)\.|\.(?:config|setup)\.|(?:^|[/!*])(?:manage|wsgi|asgi|__init__)\.py$|(?:^|[/!*])main\.[jt]sx?$|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
1413
1462
  function compareMetrics(scope, before, after, out2) {
1414
1463
  if (!before) return;
1415
1464
  for (const m of METRICS) {
@@ -1553,20 +1602,28 @@ var SIMPLE_FILES = [
1553
1602
  ];
1554
1603
  var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
1555
1604
  var numOf = (s) => s === "auto" ? "auto" : Number(s);
1556
- function numbersOf(src, key2, scoped = false) {
1557
- const out2 = [];
1605
+ function* scopedLines(src) {
1558
1606
  const stack = [];
1559
1607
  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 ?? "";
1608
+ const m = line.match(/^(\s*)([\w-]+):/);
1609
+ if (m) {
1610
+ const ind = m[1].length;
1611
+ while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
1612
+ stack.push({ ind, key: m[2] });
1569
1613
  }
1614
+ const scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
1615
+ yield [line, scope];
1616
+ }
1617
+ }
1618
+ function scopedKeys(src, key2) {
1619
+ const out2 = [];
1620
+ for (const [line, scope] of scopedLines(src)) if (key2.test(line)) out2.push(scope);
1621
+ return out2;
1622
+ }
1623
+ function numbersOf(src, key2, scoped = false) {
1624
+ const out2 = [];
1625
+ for (const [line, inScope] of scopedLines(src)) {
1626
+ const scope = scoped ? inScope : "";
1570
1627
  if (key2.global) {
1571
1628
  for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
1572
1629
  } else {
@@ -1645,6 +1702,9 @@ function simpleNarrowings(path, before, after) {
1645
1702
  const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
1646
1703
  const had = off2(before);
1647
1704
  for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
1705
+ const informational = (s) => new Set(scopedKeys(s, /^\s*informational:\s*true\b/).filter((scope) => scope !== "patch" && scope !== "changes"));
1706
+ const was = informational(before);
1707
+ for (const scope of informational(after)) if (!was.has(scope)) out2.push(`codecov ${scope || "project"} status informational: true added \u2014 the status can no longer fail`);
1648
1708
  }
1649
1709
  return out2;
1650
1710
  }
@@ -1688,13 +1748,14 @@ var SWITCHES = [
1688
1748
  re: /"?check-coverage"?\s*[:=]\s*false\b/,
1689
1749
  message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
1690
1750
  remediation: "Keep check-coverage on; raise real coverage instead."
1691
- },
1692
- {
1693
- re: /^\s*informational:\s*true\b/,
1694
- message: "informational: true added \u2014 the codecov status can no longer fail.",
1695
- remediation: "Remove informational; a status that cannot fail is not a gate."
1696
1751
  }
1697
1752
  ];
1753
+ function dirHasSpec(configPath, policy, ctx) {
1754
+ const files = trackedFiles(ctx);
1755
+ if (!files) return null;
1756
+ const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
1757
+ return files.some((f) => f.startsWith(root) && isProtected(f, policy, "tests"));
1758
+ }
1698
1759
  var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
1699
1760
  function gateMovedElsewhere(c, changes, policy) {
1700
1761
  const before = c.before ?? "";
@@ -1787,6 +1848,7 @@ var coverageLowering = {
1787
1848
  for (const s of SWITCHES) {
1788
1849
  if (!s.re.test(l.content)) continue;
1789
1850
  if (removed.some((r) => s.re.test(r.content))) continue;
1851
+ if (/passWithNoTests/.test(s.re.source) && dirHasSpec(c.path, policy, ctx) === false) continue;
1790
1852
  out2.push(
1791
1853
  makeFinding(RULE4, policy, {
1792
1854
  file: c.path,
@@ -1838,7 +1900,7 @@ var RULE5 = "ci-tampering";
1838
1900
  var USES = /^\s*-\s*uses:/;
1839
1901
  var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
1840
1902
  var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
1841
- var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward)\b/;
1903
+ var INVOKES_TOOL = /(?:^\s*|[;&|`]\s*|\$\(\s*|\b(?:npx|yarn|pnpm|bunx?)\s+)(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward|mocha|ava|oxlint|tsgo|mypy|golangci-lint|node\s+--test|biome\s+(?:ci|check|lint)|deno\s+(?:test|lint|check)|ruff\s+check)\b/;
1842
1904
  var INVOKES_SCRIPT = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|tests|lint|typecheck|type-check|coverage)\b|\b(?:make|cargo|go)\s+test\b|\bgradle\w*\s+(?:test|check)\b/;
1843
1905
  function isActiveWorkflow(path) {
1844
1906
  return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
@@ -1847,8 +1909,19 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
1847
1909
  function commandCore(line) {
1848
1910
  return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
1849
1911
  }
1912
+ function foldExpressions(s) {
1913
+ return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
1914
+ const v = foldConst(e.trim());
1915
+ if (v !== void 0 && v !== TRUTHY) return String(v);
1916
+ return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
1917
+ });
1918
+ }
1919
+ var EXPR_TOKEN = "__expr__";
1920
+ var MATRIX_TOKEN = "__matrix__";
1921
+ var MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
1850
1922
  function canonical(core) {
1851
- let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
1923
+ let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
1924
+ s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
1852
1925
  s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
1853
1926
  s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
1854
1927
  s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
@@ -1878,8 +1951,8 @@ function checkKind(core) {
1878
1951
  if (!invokesCheck(core)) return null;
1879
1952
  const s = canonical(core);
1880
1953
  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";
1954
+ if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
1955
+ if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
1883
1956
  return "test";
1884
1957
  }
1885
1958
  function survives(removedCore, isUses, afterCores, addedCores) {
@@ -1888,13 +1961,15 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1888
1961
  return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
1889
1962
  }
1890
1963
  const r = canonical(removedCore);
1964
+ const kind = checkKind(removedCore);
1965
+ const narrowed = (args) => kind === "test" && narrowedByPositional(args);
1891
1966
  let neutralised;
1892
1967
  for (const raw of afterCores) {
1893
1968
  const a = canonical(raw);
1894
1969
  if (a === r) return { state: "kept" };
1895
1970
  if (a.startsWith(r + " ")) {
1896
1971
  const rest = a.slice(r.length);
1897
- if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
1972
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest)) neutralised ??= raw;
1898
1973
  else return { state: "kept" };
1899
1974
  } else if (r.startsWith(a + " ") && invokesCheck(a)) {
1900
1975
  return { state: "kept" };
@@ -1903,7 +1978,6 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1903
1978
  if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
1904
1979
  }
1905
1980
  }
1906
- const kind = checkKind(removedCore);
1907
1981
  let respelled = false;
1908
1982
  for (const raw of addedCores) {
1909
1983
  if (!kind || checkKind(raw) !== kind) continue;
@@ -1913,7 +1987,7 @@ function survives(removedCore, isUses, afterCores, addedCores) {
1913
1987
  continue;
1914
1988
  }
1915
1989
  const args = a.slice(a.indexOf(" ") + 1 || a.length);
1916
- if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
1990
+ if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowed(args))) neutralised ??= raw;
1917
1991
  else respelled = true;
1918
1992
  }
1919
1993
  if (respelled) return { state: "kept" };
@@ -2085,6 +2159,7 @@ function isTruthy(raw) {
2085
2159
  const f = foldConst(m[1]);
2086
2160
  return f !== void 0 && truthy(f);
2087
2161
  }
2162
+ var SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
2088
2163
  var indentOf = (l) => l.match(/^\s*/)[0].length;
2089
2164
  var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
2090
2165
  function stepOf(lines, i) {
@@ -2110,8 +2185,10 @@ function jobOf(lines, i) {
2110
2185
  while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
2111
2186
  return [h, e];
2112
2187
  }
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));
2188
+ var REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
2189
+ var usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
2190
+ var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l));
2191
+ var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
2115
2192
  function runBlocks(lines) {
2116
2193
  const out2 = [];
2117
2194
  for (let i = 0; i < lines.length; i++) {
@@ -2256,7 +2333,8 @@ function triggerNarrowings(before, after, opts) {
2256
2333
  const now = after.branches.get(e);
2257
2334
  if (!now || !after.events.has(e)) continue;
2258
2335
  if (now.some(ALL)) continue;
2259
- const lostDefault = namesDefault(was) && !namesDefault(now);
2336
+ const renamed = (b) => DEFAULT_BRANCH.test(b) && opts.hasBranch(b) !== false;
2337
+ const lostDefault = namesDefault(was) && !namesDefault(now) && !now.some(renamed);
2260
2338
  const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
2261
2339
  if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
2262
2340
  }
@@ -2342,6 +2420,7 @@ var ciTampering = {
2342
2420
  }
2343
2421
  for (const l of addedLines(c)) {
2344
2422
  if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
2423
+ if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
2345
2424
  const step = stepOf(afterLines, l.newLine - 1);
2346
2425
  if (step && !stepHasCheck(afterLines, step)) continue;
2347
2426
  out2.push(
@@ -2355,7 +2434,7 @@ var ciTampering = {
2355
2434
  );
2356
2435
  }
2357
2436
  if (c.before != null) {
2358
- triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
2437
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
2359
2438
  for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
2360
2439
  out2.push(
2361
2440
  makeFinding(RULE5, policy, {
@@ -2380,7 +2459,7 @@ var ciTampering = {
2380
2459
  }
2381
2460
  for (const l of removedLines(c)) {
2382
2461
  if (/^\s*#/.test(l.content)) continue;
2383
- const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
2462
+ const isUsesLine = usesCheck(l.content);
2384
2463
  const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
2385
2464
  if (!isUsesLine && !isCheckCommand) continue;
2386
2465
  const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
@@ -4442,21 +4521,20 @@ function collect(root, runner) {
4442
4521
  const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
4443
4522
  sel.ignore = [...sel.ignore ?? [], ...entries];
4444
4523
  };
4524
+ let projectList = null;
4445
4525
  const projects = (e) => {
4446
4526
  sel.present = true;
4447
- if (!ts3.isArrayLiteralExpression(e)) {
4527
+ projectList = e;
4528
+ };
4529
+ const visit = (node) => {
4530
+ if (ts3.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
4531
+ sel.present = true;
4448
4532
  sel.opaque = true;
4449
- return;
4450
4533
  }
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;
4534
+ if (ts3.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts3.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
4535
+ sel.present = true;
4536
+ sel.opaque = true;
4455
4537
  }
4456
- sel.projects = list2;
4457
- if (list2.some((p) => p.opaque)) sel.opaque = true;
4458
- };
4459
- const visit = (node) => {
4460
4538
  if (ts3.isPropertyAssignment(node)) {
4461
4539
  const k = keyName2(node.name);
4462
4540
  if (runner === "jest") {
@@ -4476,7 +4554,7 @@ function collect(root, runner) {
4476
4554
  } else if (ownerKey2(node) === "test") {
4477
4555
  if (k === "include") sel.include = take(k, node.initializer);
4478
4556
  else if (k === "exclude") ignores(k, node.initializer);
4479
- else if (k === "dir") {
4557
+ else if (k === "dir" || k === "root") {
4480
4558
  const { dir, opaque } = dirValue(node.initializer);
4481
4559
  sel.present = true;
4482
4560
  if (opaque) sel.opaque = true;
@@ -4490,8 +4568,68 @@ function collect(root, runner) {
4490
4568
  ts3.forEachChild(node, visit);
4491
4569
  };
4492
4570
  visit(root);
4571
+ if (projectList) sel.projects = readProjects(projectList, sel, runner);
4572
+ if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
4493
4573
  return sel;
4494
4574
  }
4575
+ function unwrapConfigCall(e) {
4576
+ let x = e;
4577
+ while (ts3.isParenthesizedExpression(x) || ts3.isAsExpression(x) || x.kind === ts3.SyntaxKind.SatisfiesExpression) {
4578
+ x = x.expression;
4579
+ }
4580
+ if (ts3.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
4581
+ return x;
4582
+ }
4583
+ var JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
4584
+ function spreadsIntoConfig(node, runner) {
4585
+ const obj = node.parent;
4586
+ if (!ts3.isObjectLiteralExpression(obj)) return false;
4587
+ const keys = obj.properties.map((p) => ts3.isPropertyAssignment(p) ? keyName2(p.name) : null);
4588
+ if (runner === "vitest") {
4589
+ if (keys.includes("test")) return true;
4590
+ let q = obj.parent;
4591
+ while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
4592
+ return !!q && ts3.isPropertyAssignment(q) && keyName2(q.name) === "test";
4593
+ }
4594
+ return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
4595
+ }
4596
+ function extendsOf(el) {
4597
+ for (const p of el.properties) {
4598
+ if (!ts3.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
4599
+ if (p.initializer.kind === ts3.SyntaxKind.TrueKeyword) return true;
4600
+ if (p.initializer.kind === ts3.SyntaxKind.FalseKeyword) return null;
4601
+ return "path";
4602
+ }
4603
+ return null;
4604
+ }
4605
+ function readProject(el, root, runner) {
4606
+ const p = collect(el, runner);
4607
+ const ext = extendsOf(el);
4608
+ if (ext === "path") p.opaque = true;
4609
+ else if (ext === true && root) {
4610
+ p.include ??= root.include;
4611
+ p.regex ??= root.regex;
4612
+ p.ignore ??= root.ignore;
4613
+ p.roots ??= root.roots;
4614
+ p.base ??= root.base;
4615
+ if (root.opaque) p.opaque = true;
4616
+ }
4617
+ p.present = true;
4618
+ return p;
4619
+ }
4620
+ function readProjects(e, root, runner) {
4621
+ if (!ts3.isArrayLiteralExpression(e)) {
4622
+ root.opaque = true;
4623
+ return [];
4624
+ }
4625
+ const list2 = [];
4626
+ for (const el of e.elements) {
4627
+ const lit = unwrapConfigCall(el);
4628
+ if (ts3.isObjectLiteralExpression(lit)) list2.push(readProject(lit, root, runner));
4629
+ else root.opaque = true;
4630
+ }
4631
+ return list2;
4632
+ }
4495
4633
  function workspaceArray(sf) {
4496
4634
  for (const st of sf.statements) {
4497
4635
  let e;
@@ -4513,14 +4651,9 @@ function parseSelection(src, runner, path = "") {
4513
4651
  if (runner === "vitest" && isWorkspaceFile(path)) {
4514
4652
  const arr = workspaceArray(sf);
4515
4653
  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;
4520
- }
4521
- sel.projects = list2;
4654
+ sel.projects = readProjects(arr, sel, runner);
4522
4655
  sel.present = true;
4523
- if (list2.some((p) => p.opaque)) sel.opaque = true;
4656
+ if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
4524
4657
  } else if (src.trim()) sel.opaque = true;
4525
4658
  } else sel = collect(sf, runner);
4526
4659
  } catch {
@@ -4580,8 +4713,8 @@ function predicate(sel, runner) {
4580
4713
  if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
4581
4714
  const hit = ignoredBy(r);
4582
4715
  if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
4583
- const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
4584
- return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
4716
+ const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
4717
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
4585
4718
  }
4586
4719
  };
4587
4720
  }
@@ -4620,8 +4753,21 @@ function calleeName(expr) {
4620
4753
  }
4621
4754
  return null;
4622
4755
  }
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";
4756
+ var TABLE_METHOD = /^(?:each|for)$/;
4757
+ var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && TABLE_METHOD.test(expr.name.text);
4758
+ var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && TABLE_METHOD.test(expr.name.text);
4759
+ function loopRows(iterable) {
4760
+ if (ts4.isArrayLiteralExpression(iterable)) {
4761
+ let n = 0;
4762
+ let open = false;
4763
+ for (const el of iterable.elements) {
4764
+ if (ts4.isSpreadElement(el)) open = true;
4765
+ else n++;
4766
+ }
4767
+ return { n: Math.max(n, open ? 0 : 1), open };
4768
+ }
4769
+ return { n: 1, open: true };
4770
+ }
4625
4771
  function templateRows(node) {
4626
4772
  const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
4627
4773
  return Math.max(rows, 1);
@@ -4659,7 +4805,23 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
4659
4805
  let n = 0;
4660
4806
  let open = false;
4661
4807
  const visit = (node, mult) => {
4808
+ if (ts4.isForOfStatement(node)) {
4809
+ const r = loopRows(node.expression);
4810
+ if (r.open) open = true;
4811
+ visit(node.statement, mult * r.n);
4812
+ return;
4813
+ }
4814
+ if (ts4.isForStatement(node) || ts4.isForInStatement(node) || ts4.isWhileStatement(node) || ts4.isDoStatement(node)) {
4815
+ open = true;
4816
+ }
4662
4817
  if (ts4.isCallExpression(node)) {
4818
+ const callee = node.expression;
4819
+ if (ts4.isPropertyAccessExpression(callee) && callee.name.text === "forEach") {
4820
+ const r = loopRows(callee.expression);
4821
+ if (r.open) open = true;
4822
+ for (const a of node.arguments) visit(a, mult * r.n);
4823
+ return;
4824
+ }
4663
4825
  const head = node.expression;
4664
4826
  if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
4665
4827
  const r = eachRows(head);
@@ -4707,6 +4869,12 @@ function runnerSamples(configPath, policy, ctx) {
4707
4869
  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));
4708
4870
  return own.length ? own : CANONICAL_SAMPLES;
4709
4871
  }
4872
+ var WHOLE_SUITE_SCRIPT = /^test(?::ci)?$/;
4873
+ function excludesOtherRunner(flag, cmd) {
4874
+ if (flag !== "--exclude") return false;
4875
+ const values = [...cmd.matchAll(/--exclude(?:=|\s+)(['"]?)(\S+?)\1(?=\s|$)/g)].map((m) => m[2]);
4876
+ return values.length > 0 && values.every((v) => OTHER_RUNNER_DIR.test(v.replace(/^\.\//, "").replace(/(?:\/\*+)*\/?$/, "") + "/"));
4877
+ }
4710
4878
  function scriptNarrowings(before, after, path) {
4711
4879
  if ((path.split("/").pop() ?? path) !== "package.json") return [];
4712
4880
  const scriptsOf = (src) => {
@@ -4721,7 +4889,7 @@ function scriptNarrowings(before, after, path) {
4721
4889
  const now = scriptsOf(after);
4722
4890
  const out2 = [];
4723
4891
  for (const [name, cmd] of Object.entries(now)) {
4724
- if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
4892
+ if (!WHOLE_SUITE_SCRIPT.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
4725
4893
  const prev = was[name] ?? "";
4726
4894
  const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
4727
4895
  for (const m of cmd.matchAll(flags)) {
@@ -4729,6 +4897,7 @@ function scriptNarrowings(before, after, path) {
4729
4897
  if (!flag) continue;
4730
4898
  const one = new RegExp(SUITE_NARROWING_FLAGS.source);
4731
4899
  if (one.test(prev) && prev.includes(flag)) continue;
4900
+ if (excludesOtherRunner(flag, cmd)) continue;
4732
4901
  out2.push({ script: name, flag, before: prev, after: cmd });
4733
4902
  break;
4734
4903
  }
@@ -4760,10 +4929,11 @@ var testDeletion = {
4760
4929
  certainty: "mechanical",
4761
4930
  run(changes, policy, _view, ctx) {
4762
4931
  const out2 = [];
4932
+ const isSpec = (p) => isProtected(p, policy, "tests") && !isProtected(p, policy, "snapshots");
4763
4933
  const addedTestLines = /* @__PURE__ */ new Set();
4764
4934
  let addedTestBlocks = 0;
4765
4935
  for (const c of changes) {
4766
- if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
4936
+ if (c.kind !== "file" || c.after == null || !isSpec(c.path)) continue;
4767
4937
  if (c.op === "add") {
4768
4938
  for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
4769
4939
  addedTestBlocks += countTests(c.after, c.path, true).min;
@@ -4781,7 +4951,7 @@ var testDeletion = {
4781
4951
  };
4782
4952
  for (const c of changes) {
4783
4953
  if (c.kind === "file") {
4784
- const isTest = isProtected(c.path, policy, "tests");
4954
+ const isTest = isSpec(c.path);
4785
4955
  if (c.op === "delete" && isTest) {
4786
4956
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
4787
4957
  out2.push(
@@ -4792,7 +4962,7 @@ var testDeletion = {
4792
4962
  remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
4793
4963
  })
4794
4964
  );
4795
- } else if (c.op === "rename" && c.oldPath && isProtected(c.oldPath, policy, "tests") && !isTest) {
4965
+ } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
4796
4966
  out2.push(
4797
4967
  makeFinding(RULE7, policy, {
4798
4968
  file: c.path,
@@ -4853,22 +5023,22 @@ var testDeletion = {
4853
5023
  const testToks = toks.filter((t) => {
4854
5024
  const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
4855
5025
  const probe = resolved ?? t;
4856
- if (!isProtected(probe, policy, "tests")) return false;
5026
+ if (!isSpec(probe)) return false;
4857
5027
  if (!listing || root === null) return true;
4858
5028
  const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
4859
5029
  if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
4860
- return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
5030
+ return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx, "snapshots");
4861
5031
  });
4862
- const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
5032
+ const rootHasProtected = () => listing ? listing.some(isSpec) : true;
4863
5033
  const dirToks = toks.filter((t) => {
4864
5034
  if (t.startsWith("-") || testToks.includes(t)) return false;
4865
5035
  const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
4866
5036
  if (!whole && /[*?{}[\]]/.test(t)) return false;
4867
- if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
5037
+ if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx, "snapshots");
4868
5038
  const dir = whole ? root : joinRoot(root, t);
4869
5039
  if (dir === null) return false;
4870
5040
  if (dir === "") return rootHasProtected();
4871
- return containsProtected(dir, policy, "tests", ctx);
5041
+ return containsProtected(dir, policy, "tests", ctx, "snapshots");
4872
5042
  });
4873
5043
  if (testToks.length === 0 && dirToks.length === 0) continue;
4874
5044
  const named = [...testToks, ...dirToks];
@@ -4876,13 +5046,13 @@ var testDeletion = {
4876
5046
  let why = null;
4877
5047
  let evidence = seg;
4878
5048
  const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
4879
- const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
5049
+ const redirectsOntoTest = redirectTarget ? isSpec(unquote(redirectTarget)) : false;
4880
5050
  const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
4881
5051
  const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
4882
5052
  const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
4883
5053
  const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
4884
5054
  const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
4885
- if (!rev || rev === "HEAD") return false;
5055
+ if (!rev || rev === "HEAD" || rev === "@" || revIsHead(rev, ctx) === true) return false;
4886
5056
  if (src || seg.includes(" -- ")) return true;
4887
5057
  return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
4888
5058
  })();
@@ -4898,7 +5068,7 @@ var testDeletion = {
4898
5068
  why = "truncate empties a test file";
4899
5069
  } else if (redirectsOntoTest) {
4900
5070
  why = "a redirect overwrites/empties a test file";
4901
- } else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
5071
+ } else if (cmd === "cp" && testToks.length > 0 && isSpec(toks[toks.length - 1] ?? "") && !isSpec(toks[toks.length - 2] ?? "")) {
4902
5072
  why = "cp overwrites a test file with something else";
4903
5073
  } else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
4904
5074
  why = "tee overwrites a test file";
@@ -4908,7 +5078,7 @@ var testDeletion = {
4908
5078
  why = "git checkout <rev> replaces a test file with an older version";
4909
5079
  } else if (/\bmv\b/.test(seg)) {
4910
5080
  const dest = toks[toks.length - 1];
4911
- if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
5081
+ if (dest && testToks.length > 0 && !isSpec(dest)) {
4912
5082
  why = "mv renames a test file out of the test glob";
4913
5083
  evidence = `${testToks[0]} \u2192 ${dest}`;
4914
5084
  } else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
@@ -4945,7 +5115,7 @@ function eachTables(src, path) {
4945
5115
  try {
4946
5116
  const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
4947
5117
  const visit = (node) => {
4948
- if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
5118
+ if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && /^(?:each|for)$/.test(node.expression.name.text) && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
4949
5119
  const arg = node.arguments[0];
4950
5120
  if (arg && ts5.isArrayLiteralExpression(arg)) {
4951
5121
  for (const el of arg.elements) {
@@ -4966,6 +5136,7 @@ function eachTables(src, path) {
4966
5136
  }
4967
5137
  return { rows, open };
4968
5138
  }
5139
+ var TEST_DIR_FILE = /(?:^|\/)(?:test|tests|__tests__|spec|specs)\//;
4969
5140
  function significantLinesOrdered(src, path) {
4970
5141
  const out2 = [];
4971
5142
  const lang = langOf(path);
@@ -4986,11 +5157,14 @@ var testContentRemoval = {
4986
5157
  for (const c of changes) {
4987
5158
  if (c.kind !== "file" || c.after == null) continue;
4988
5159
  if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
4989
- if (c.op === "add") addedPool += ws(c.after);
5160
+ let addedHere = "";
5161
+ if (c.op === "add") addedHere = ws(c.after);
4990
5162
  else if (c.before != null) {
4991
5163
  const had = new Set(c.before.split("\n").map((l) => l.trim()));
4992
- addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
5164
+ addedHere = ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
4993
5165
  }
5166
+ addedPool += addedHere;
5167
+ if (!isProtected(c.path, policy, "tests") && TEST_DIR_FILE.test(c.path) && langOf(c.path) === "js") keptPool += addedHere;
4994
5168
  }
4995
5169
  for (const c of changes) {
4996
5170
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",