tamperward 2.25.2 → 2.26.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 (3) hide show
  1. package/README.md +16 -3
  2. package/dist/cli/index.js +2085 -1516
  3. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -500,6 +500,12 @@ function defaultPolicy(version = 1) {
500
500
  "**/tsconfig*.json",
501
501
  "**/.eslintrc*",
502
502
  "**/eslint.config.*",
503
+ // The lint's own blind-spot list and biome's config: read by
504
+ // config-weakening (#447) alongside tsconfig and the eslint configs, so a
505
+ // path added to `.eslintignore` is as visible as one added to `ignores`.
506
+ "**/.eslintignore",
507
+ "**/biome.json",
508
+ "**/biome.jsonc",
503
509
  "**/package.json",
504
510
  // Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
505
511
  // see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
@@ -594,6 +600,11 @@ function defaultPolicy(version = 1) {
594
600
  // harness/fp-study/CAST-GROWTH-CORPUS.md), so it is a review prompt, not a gate.
595
601
  "ts-cast-growth": { severity: "warn" },
596
602
  "lint-suppression": { severity: "block" },
603
+ // tsconfig strictness lowered, eslint/biome rules turned off or ignores grown,
604
+ // a runner setup module loaded from an unprotected file (#447). Ships warn
605
+ // against a committed corpus (harness/fp-study/CONFIG-WEAKENING-CORPUS.md);
606
+ // block is a separate decision with its own measurement.
607
+ "config-weakening": { severity: "warn" },
597
608
  "coverage-lowering": { severity: "block" },
598
609
  // the per-function form of coverage-lowering: an inline `istanbul ignore` /
599
610
  // `c8 ignore` / `v8 ignore` / `node:coverage ignore` / `# pragma: no cover` /
@@ -4200,24 +4211,24 @@ function topLevelStaticStrings(ctx) {
4200
4211
  }
4201
4212
  return resolved;
4202
4213
  }
4203
- function staticPropertyName(expr, ctx, strings) {
4214
+ function staticPropertyName(expr, ctx, strings2) {
4204
4215
  if (ts.isStringLiteralLike(expr)) return { name: expr.text, causes: [] };
4205
- if (ts.isParenthesizedExpression(expr)) return staticPropertyName(expr.expression, ctx, strings);
4216
+ if (ts.isParenthesizedExpression(expr)) return staticPropertyName(expr.expression, ctx, strings2);
4206
4217
  if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
4207
- const left = staticPropertyName(expr.left, ctx, strings);
4208
- const right = staticPropertyName(expr.right, ctx, strings);
4218
+ const left = staticPropertyName(expr.left, ctx, strings2);
4219
+ const right = staticPropertyName(expr.right, ctx, strings2);
4209
4220
  if (!left || !right) return null;
4210
4221
  return { name: left.name + right.name, causes: [...left.causes, ...right.causes] };
4211
4222
  }
4212
4223
  if (ts.isIdentifier(expr)) {
4213
4224
  const sym = symbolAt(ctx, expr);
4214
4225
  if (!sym) return null;
4215
- const value = strings.get(sym);
4226
+ const value = strings2.get(sym);
4216
4227
  return value ? { name: value.value, causes: value.causes } : null;
4217
4228
  }
4218
4229
  return null;
4219
4230
  }
4220
- function runnerChain(expr, ctx, binder, strings) {
4231
+ function runnerChain(expr, ctx, binder, strings2) {
4221
4232
  const props = [];
4222
4233
  const propNodes = [];
4223
4234
  const causes = [];
@@ -4232,7 +4243,7 @@ function runnerChain(expr, ctx, binder, strings) {
4232
4243
  continue;
4233
4244
  }
4234
4245
  if (!cur.argumentExpression) return null;
4235
- const property = staticPropertyName(cur.argumentExpression, ctx, strings);
4246
+ const property = staticPropertyName(cur.argumentExpression, ctx, strings2);
4236
4247
  if (!property) return null;
4237
4248
  props.unshift(property.name);
4238
4249
  propNodes.unshift(cur.argumentExpression);
@@ -4286,7 +4297,7 @@ function structuralNodeKey(node, sf) {
4286
4297
  return `${node.kind}(${children.map((child) => structuralNodeKey(child, sf)).join(",")})`;
4287
4298
  }
4288
4299
  function semanticSkipHits(ctx, binder) {
4289
- const strings = topLevelStaticStrings(ctx);
4300
+ const strings2 = topLevelStaticStrings(ctx);
4290
4301
  const hits = [];
4291
4302
  const unowned = /* @__PURE__ */ new Set();
4292
4303
  const callOrdinals = /* @__PURE__ */ new Map();
@@ -4298,7 +4309,7 @@ function semanticSkipHits(ctx, binder) {
4298
4309
  const ordinal = (callOrdinals.get(callIdentity) ?? 0) + 1;
4299
4310
  callOrdinals.set(callIdentity, ordinal);
4300
4311
  const callKey = `${callIdentity}\0${ordinal}`;
4301
- const chain = runnerChain(node.expression, ctx, binder, strings);
4312
+ const chain = runnerChain(node.expression, ctx, binder, strings2);
4302
4313
  if (chain === "unknown") {
4303
4314
  const callee = node.expression;
4304
4315
  const first = lineOf(callee.getStart(ctx.sf));
@@ -4336,7 +4347,7 @@ function semanticSkipHits(ctx, binder) {
4336
4347
  if (ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)) {
4337
4348
  name = prop.name.text;
4338
4349
  } else if (ts.isComputedPropertyName(prop.name)) {
4339
- const resolved = staticPropertyName(prop.name.expression, ctx, strings);
4350
+ const resolved = staticPropertyName(prop.name.expression, ctx, strings2);
4340
4351
  name = resolved?.name ?? null;
4341
4352
  propertyCauses = resolved?.causes ?? [];
4342
4353
  }
@@ -5599,1123 +5610,980 @@ var init_coverage_lowering = __esm({
5599
5610
  }
5600
5611
  });
5601
5612
 
5602
- // src/detectors/coverage-exclusion.ts
5603
- function isText(line, idx, lang) {
5604
- const opener = LINE_COMMENT[lang];
5605
- let quote = null;
5606
- for (let j = 0; j < idx; j++) {
5607
- const ch = line[j];
5608
- if (quote) {
5609
- if (ch === "\\") j++;
5610
- else if (ch === quote) quote = null;
5611
- continue;
5612
- }
5613
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
5614
- else if (opener && opener.test(line.slice(j, j + 2))) return true;
5613
+ // src/detectors/suite-config.ts
5614
+ function runnerSkips(path) {
5615
+ const parts = path.split("/");
5616
+ if (/\.rs$/.test(path)) {
5617
+ const i = parts.indexOf("tests");
5618
+ if (i < 0 || parts.slice(0, i).includes("src")) return null;
5619
+ const rest = parts.slice(i + 1);
5620
+ if (rest.length === 1 || rest.length === 2 && rest[1] === "main.rs") return null;
5621
+ return "cargo builds only tests/*.rs and tests/*/main.rs as test targets";
5615
5622
  }
5616
- return quote !== null;
5623
+ if (/\.go$/.test(path)) {
5624
+ const hit = parts.find((p) => p === "testdata" || p.startsWith("_") || p.startsWith("."));
5625
+ if (hit === void 0) return null;
5626
+ return `go test skips ${hit === "testdata" ? "testdata/" : hit.startsWith("_") ? "_*" : ".*"} paths (${JSON.stringify(hit)})`;
5627
+ }
5628
+ if (/\.py$/.test(path)) {
5629
+ const hit = PYTEST_DEFAULT_IGNORE.find((g) => globs2([g])(path));
5630
+ return hit === void 0 ? null : `pytest's default norecursedirs matches it (${JSON.stringify(hit)})`;
5631
+ }
5632
+ if (/\.[cm]?[jt]sx?$/.test(path)) {
5633
+ const hit = JS_UNWALKED.find((g) => globs2([g])(path));
5634
+ return hit === void 0 ? null : `the runner's default exclude matches it (${JSON.stringify(hit)})`;
5635
+ }
5636
+ return null;
5617
5637
  }
5618
- var RULE6, PATTERNS3, OUT_OF_SCOPE_PATH, LINE_COMMENT, coverageExclusion;
5619
- var init_coverage_exclusion = __esm({
5620
- "src/detectors/coverage-exclusion.ts"() {
5621
- "use strict";
5622
- init_select();
5623
- init_files();
5624
- init_policy();
5625
- init_finding();
5626
- RULE6 = "coverage-exclusion";
5627
- PATTERNS3 = {
5628
- js: [
5629
- { re: /\/\*\s*istanbul\s+ignore\b/, why: "`istanbul ignore` takes the next statement, branch or the whole file out of the coverage measurement" },
5630
- { re: /\/\*\s*c8\s+ignore\b(?!\s*stop\b)/, why: "`c8 ignore` takes the next statement or the marked range out of the coverage measurement" },
5631
- { re: /\/\*\s*v8\s+ignore\b(?!\s*stop\b)/, why: "`v8 ignore` takes the next statement or the marked range out of the coverage measurement" },
5632
- { re: /\/\*\s*node:coverage\s+(?:ignore|disable)\b/, why: "`node:coverage ignore/disable` takes the following code out of the coverage measurement" }
5633
- ],
5634
- py: [{ re: /#\s*pragma[:\s]\s*no\s*cover\b/i, why: "`# pragma: no cover` takes the line or block out of the coverage measurement" }],
5635
- rs: [{ re: /#\s*!?\[[^\]]*\bcoverage\s*\(\s*off\s*\)/, why: "`#[coverage(off)]` takes the item out of the coverage measurement" }],
5636
- go: [
5637
- {
5638
- re: /^\s*\/\/\s*(?:go:build\b|\+build\b)/,
5639
- why: "a build constraint added to an existing file takes it out of the default build, and out of `go test -cover`",
5640
- existingOnly: true
5641
- }
5642
- ]
5643
- };
5644
- OUT_OF_SCOPE_PATH = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build|out|coverage|generated|__generated__|codegen|examples?|docs?|scripts?|fixtures?|__fixtures__|__mocks__)\/|\.d\.[cm]?ts$|\.(?:generated|gen|pb)\.[^/]*$/;
5645
- LINE_COMMENT = { js: /^\/\//, go: /^\/\//, rs: /^\/\//, py: /^#/ };
5646
- coverageExclusion = {
5647
- id: RULE6,
5648
- surface: ["file"],
5649
- certainty: "mechanical",
5650
- run(changes, policy) {
5651
- const out3 = [];
5652
- for (const c of changes) {
5653
- if (c.kind !== "file" || c.op === "delete") continue;
5654
- const lang = langOf(c.path);
5655
- if (!lang) continue;
5656
- const patterns = PATTERNS3[lang];
5657
- if (!patterns) continue;
5658
- const category = protectedCategory(c.path, policy);
5659
- if (OUT_OF_SCOPE_PATH.test(c.path) || category === "tests" || category === "config") continue;
5660
- const removed = new Set(removedLines(c).map((l) => l.content.trim()));
5661
- const before = c.before ?? "";
5662
- for (const l of addedLines(c)) {
5663
- const trimmed = l.content.trim();
5664
- if (removed.has(trimmed)) continue;
5665
- for (const p of patterns) {
5666
- const m = p.re.exec(l.content);
5667
- if (!m || isText(l.content, m.index, lang)) continue;
5668
- if (p.existingOnly && (c.op === "add" || c.before == null || before.split("\n").some((b) => p.re.test(b)))) continue;
5669
- out3.push(
5670
- makeFinding(RULE6, policy, {
5671
- file: c.path,
5672
- line: l.newLine ?? void 0,
5673
- message: `Coverage exclusion added: ${p.why}.`,
5674
- evidence: trimmed,
5675
- remediation: "Cover the code with a test instead of excluding it from the measurement; if it is genuinely unreachable, say so in review.",
5676
- defaultSeverity: "warn"
5677
- })
5678
- );
5679
- break;
5680
- }
5681
- }
5682
- }
5683
- return out3;
5684
- }
5685
- };
5638
+ function keyName2(name) {
5639
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
5640
+ return null;
5641
+ }
5642
+ function literals2(e) {
5643
+ if (isStr(e)) return { items: [e.text], opaque: false };
5644
+ if (!ts.isArrayLiteralExpression(e)) return { items: [], opaque: true };
5645
+ const items = [];
5646
+ let opaque = false;
5647
+ for (const el of e.elements) {
5648
+ if (isStr(el)) items.push(el.text);
5649
+ else if (ts.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
5650
+ items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
5651
+ } else opaque = true;
5686
5652
  }
5687
- });
5688
-
5689
- // src/detectors/gh-expression.ts
5690
- function foldExpressions(s) {
5691
- return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
5692
- const v = foldConst(e.trim());
5693
- if (v !== void 0 && v !== TRUTHY) return String(v);
5694
- return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
5695
- });
5653
+ return { items, opaque };
5696
5654
  }
5697
- function lex(src) {
5698
- const out3 = [];
5699
- let i = 0;
5700
- while (i < src.length) {
5701
- const ch = src[i];
5702
- if (/\s/.test(ch)) {
5703
- i++;
5704
- continue;
5705
- }
5706
- if (ch === "'") {
5707
- let j = i + 1;
5708
- let s = "";
5709
- while (j < src.length) {
5710
- if (src[j] === "'" && src[j + 1] === "'") {
5711
- s += "'";
5712
- j += 2;
5713
- } else if (src[j] === "'") break;
5714
- else s += src[j++];
5715
- }
5716
- if (j >= src.length) return null;
5717
- out3.push({ t: "str", v: s });
5718
- i = j + 1;
5719
- continue;
5720
- }
5721
- const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
5722
- if (num) {
5723
- out3.push({ t: "num", v: num[0] });
5724
- i += num[0].length;
5725
- continue;
5726
- }
5727
- const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
5728
- if (op) {
5729
- out3.push({ t: "op", v: op[0] });
5730
- i += op[0].length;
5655
+ function ownerKey2(node) {
5656
+ const obj = node.parent;
5657
+ if (!obj || !ts.isObjectLiteralExpression(obj)) return null;
5658
+ let q = obj.parent;
5659
+ while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
5660
+ return q && ts.isPropertyAssignment(q) ? keyName2(q.name) : null;
5661
+ }
5662
+ function asExpression2(src) {
5663
+ const t = src.trim();
5664
+ return t.startsWith("{") ? `(${t})` : src;
5665
+ }
5666
+ function runnerOf(path, content) {
5667
+ const base = path.split("/").pop() ?? path;
5668
+ if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
5669
+ if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
5670
+ if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
5671
+ if (base === "pytest.ini" || base === ".pytest.ini") return "pytest";
5672
+ if (content == null) return null;
5673
+ if (base === "tox.ini" && /^\s*\[pytest\]/m.test(content)) return "pytest";
5674
+ if (base === "setup.cfg" && /^\s*\[tool:pytest\]/m.test(content)) return "pytest";
5675
+ if (base === "pyproject.toml" && /^\s*\[tool\.pytest\.ini_options\]/m.test(content)) return "pytest";
5676
+ return null;
5677
+ }
5678
+ function claimsPytest(name, src) {
5679
+ if (name === "pytest.ini" || name === ".pytest.ini") return true;
5680
+ const section = name === "pyproject.toml" ? /^\s*\[tool\.pytest\.ini_options\]\s*$/m : name === "tox.ini" ? /^\s*\[pytest\]\s*$/m : name === "setup.cfg" ? /^\s*\[tool:pytest\]\s*$/m : null;
5681
+ return section !== null && section.test(src);
5682
+ }
5683
+ function effectivePytestFile(files) {
5684
+ for (const name of PYTEST_INI_ORDER) {
5685
+ const content = files.get(name);
5686
+ if (content == null) continue;
5687
+ if (claimsPytest(name, content)) return { path: name, content };
5688
+ }
5689
+ return null;
5690
+ }
5691
+ function effectivePytestConfig(path, ctx) {
5692
+ if (path.includes("/")) return false;
5693
+ const base = path;
5694
+ const rank = PYTEST_INI_ORDER.findIndex((name) => name === base);
5695
+ if (rank < 0) return false;
5696
+ const files = ctx?.trackedFiles;
5697
+ if (!files) return true;
5698
+ const rootNames = new Set(files.filter((f) => !f.includes("/")));
5699
+ return !PYTEST_INI_ORDER.slice(0, rank).some((n) => rootNames.has(n));
5700
+ }
5701
+ function pytestBlock(src) {
5702
+ const lines = src.split(/\r?\n/);
5703
+ const head = /^\s*\[(pytest|tool:pytest|tool\.pytest\.ini_options)\]\s*$/;
5704
+ let i = lines.findIndex((l) => head.test(l));
5705
+ if (i < 0) return null;
5706
+ const out3 = {};
5707
+ let key2 = null;
5708
+ for (i += 1; i < lines.length; i++) {
5709
+ const line = lines[i];
5710
+ if (/^\s*\[/.test(line)) break;
5711
+ if (/^\s*[#;]/.test(line) || !line.trim()) {
5731
5712
  continue;
5732
5713
  }
5733
- const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
5734
- if (id) {
5735
- out3.push({ t: "id", v: id[0] });
5736
- i += id[0].length;
5714
+ const m = line.match(/^(\w[\w.-]*)\s*[=:]\s*(.*)$/);
5715
+ if (m) {
5716
+ key2 = m[1];
5717
+ out3[key2] = m[2].trim();
5737
5718
  continue;
5738
5719
  }
5739
- return null;
5720
+ if (key2 && /^\s+\S/.test(line)) out3[key2] += " " + line.trim();
5740
5721
  }
5741
5722
  return out3;
5742
5723
  }
5743
- function foldConst(src) {
5744
- const toks = lex(src);
5745
- if (!toks) return void 0;
5746
- let p = 0;
5747
- const peek = () => toks[p];
5748
- const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
5749
- const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
5750
- const eq = (a, b) => {
5751
- if (a === TRUTHY || b === TRUTHY) return void 0;
5752
- if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
5753
- if (a === null && b === null) return true;
5754
- const x = num(a);
5755
- const y2 = num(b);
5756
- return !Number.isNaN(x) && x === y2;
5757
- };
5758
- const or = () => {
5759
- let l = and();
5760
- while (eat("||")) {
5761
- const r = and();
5762
- if (l !== void 0 && truthy(l)) continue;
5763
- if (l !== void 0) l = r;
5764
- else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
5765
- }
5766
- return l;
5767
- };
5768
- const and = () => {
5769
- let l = cmp();
5770
- while (eat("&&")) {
5771
- const r = cmp();
5772
- if (l !== void 0 && !truthy(l)) continue;
5773
- if (l !== void 0) l = r;
5774
- else l = r !== void 0 && !truthy(r) ? false : void 0;
5775
- }
5776
- return l;
5724
+ function pytestTokens(raw) {
5725
+ const v = raw.trim();
5726
+ if (v.startsWith("[")) {
5727
+ return [...v.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? "");
5728
+ }
5729
+ const unq2 = v.replace(/^"([\s\S]*)"$/, "$1").replace(/^'([\s\S]*)'$/, "$1");
5730
+ return [...unq2.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "").filter(Boolean);
5731
+ }
5732
+ function pytestSelection(src) {
5733
+ const sel = empty();
5734
+ const blk = pytestBlock(src);
5735
+ if (!blk) return sel;
5736
+ sel.present = true;
5737
+ const ignore = blk.norecursedirs !== void 0 ? pytestTokens(blk.norecursedirs).flatMap((d) => pathGlobs(d).map((pattern) => ({ pattern, key: "norecursedirs" }))) : PYTEST_DEFAULT_IGNORE.map((pattern) => ({ pattern, key: "norecursedirs" }));
5738
+ if (blk.testpaths !== void 0) sel.roots = pytestTokens(blk.testpaths).map((t) => t.replace(/^\.\//, ""));
5739
+ if (blk.python_files !== void 0) sel.include = pytestTokens(blk.python_files).map((g) => g.includes("/") ? g : "**/" + g);
5740
+ for (const key2 of ["python_classes", "python_functions"]) {
5741
+ if (blk[key2] !== void 0) ignore.push({ pattern: "\0" + key2, key: key2 });
5742
+ }
5743
+ const opts = blk.addopts !== void 0 ? pytestTokens(blk.addopts) : [];
5744
+ for (let j = 0; j < opts.length; j++) {
5745
+ const o = opts[j];
5746
+ const val = (inline) => o.includes("=") ? o.slice(o.indexOf("=") + 1) : opts[++j] ?? inline;
5747
+ if (o === "--ignore" || o.startsWith("--ignore=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--ignore" });
5748
+ else if (o === "--ignore-glob" || o.startsWith("--ignore-glob=")) {
5749
+ const g = val("");
5750
+ ignore.push({ pattern: g, key: "--ignore-glob" });
5751
+ if (!g.startsWith("**/") && !g.startsWith("/")) ignore.push({ pattern: "**/" + g, key: "--ignore-glob" });
5752
+ } else if (o === "--deselect" || o.startsWith("--deselect=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--deselect" });
5753
+ else if (o === "-k" || o.startsWith("-k=")) {
5754
+ for (const w of (val("").match(/\bnot\s+([A-Za-z_][\w]*)/g) ?? []).map((t) => t.replace(/^not\s+/, "")))
5755
+ ignore.push({ pattern: `**/*${w}*`, key: "-k" });
5756
+ } else if (o === "-m" || o.startsWith("-m=")) {
5757
+ ignore.push({ pattern: "\0-m " + val(""), key: "-m" });
5758
+ } else if (o === "-p" || o.startsWith("-p=") || o.length > 2 && o.startsWith("-p")) {
5759
+ const v = o === "-p" ? opts[++j] ?? "" : o.startsWith("-p=") ? o.slice(3) : o.slice(2);
5760
+ if (v.startsWith("no:")) ignore.push({ pattern: "\0-p " + v, key: "-p" });
5761
+ } else if (o === "--collect-only" || o === "--co") ignore.push({ pattern: "**", key: o });
5762
+ }
5763
+ sel.ignore = ignore;
5764
+ return sel;
5765
+ }
5766
+ function dirValue(e) {
5767
+ if (!isStr(e)) return { dir: null, opaque: true };
5768
+ const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
5769
+ if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
5770
+ return { dir: d, opaque: false };
5771
+ }
5772
+ function collect(root, runner) {
5773
+ const sel = empty();
5774
+ const seen = /* @__PURE__ */ new Set();
5775
+ const take = (key2, e) => {
5776
+ const { items, opaque } = literals2(e);
5777
+ if (opaque || seen.has(key2)) sel.opaque = true;
5778
+ seen.add(key2);
5779
+ sel.present = true;
5780
+ return items.map(stripRootDir);
5777
5781
  };
5778
- const cmp = () => {
5779
- let l = unary();
5780
- for (; ; ) {
5781
- const t = peek();
5782
- if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
5783
- p++;
5784
- const r = unary();
5785
- if (l === void 0 || r === void 0) {
5786
- l = void 0;
5787
- continue;
5788
- }
5789
- if (t.v === "==" || t.v === "!=") {
5790
- const e = eq(l, r);
5791
- l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
5792
- } else {
5793
- const x = num(l);
5794
- const y2 = num(r);
5795
- if (Number.isNaN(x) || Number.isNaN(y2)) l = void 0;
5796
- else l = t.v === "<" ? x < y2 : t.v === ">" ? x > y2 : t.v === "<=" ? x <= y2 : x >= y2;
5797
- }
5798
- }
5782
+ const ignores = (key2, e) => {
5783
+ const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
5784
+ sel.ignore = [...sel.ignore ?? [], ...entries];
5799
5785
  };
5800
- const unary = () => {
5801
- if (eat("!")) {
5802
- const v2 = unary();
5803
- return v2 === void 0 ? void 0 : !truthy(v2);
5804
- }
5805
- return primary();
5786
+ const namePattern = (e) => {
5787
+ sel.present = true;
5788
+ sel.wide.push({ pattern: "\0testNamePattern " + (isStr(e) ? e.text : e.getText()), key: "testNamePattern" });
5806
5789
  };
5807
- const primary = () => {
5808
- const t = peek();
5809
- if (!t) return void 0;
5810
- if (eat("(")) {
5811
- const v2 = or();
5812
- return eat(")") ? v2 : void 0;
5790
+ let projectList = null;
5791
+ const projects = (e) => {
5792
+ sel.present = true;
5793
+ projectList = e;
5794
+ };
5795
+ const visit = (node) => {
5796
+ if (ts.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
5797
+ sel.present = true;
5798
+ sel.opaque = true;
5813
5799
  }
5814
- p++;
5815
- if (t.t === "str") return t.v;
5816
- if (t.t === "num") return Number(t.v);
5817
- if (t.t === "id") {
5818
- if (/^true$/i.test(t.v)) return true;
5819
- if (/^false$/i.test(t.v)) return false;
5820
- if (/^null$/i.test(t.v)) return null;
5821
- if (eat("(")) {
5822
- const args = [];
5823
- if (!eat(")")) {
5824
- for (; ; ) {
5825
- args.push(or());
5826
- if (eat(")")) break;
5827
- if (!eat(",")) return void 0;
5828
- }
5829
- }
5830
- let indexed = false;
5831
- while (eat("[")) {
5832
- or();
5833
- indexed = true;
5834
- if (!eat("]")) return void 0;
5835
- }
5836
- if (indexed) return void 0;
5837
- return callConst(t.v, args);
5838
- }
5839
- while (eat("[")) {
5840
- or();
5841
- if (!eat("]")) return void 0;
5800
+ if (ts.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
5801
+ sel.present = true;
5802
+ sel.opaque = true;
5803
+ }
5804
+ if (ts.isPropertyAssignment(node)) {
5805
+ const k = keyName2(node.name);
5806
+ if (runner === "jest") {
5807
+ if (k === "testMatch") sel.include = take(k, node.initializer);
5808
+ else if (k === "testRegex") sel.regex = take(k, node.initializer);
5809
+ else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
5810
+ else if (k === "roots") sel.roots = take(k, node.initializer);
5811
+ else if (k === "rootDir") {
5812
+ const { dir, opaque } = dirValue(node.initializer);
5813
+ sel.present = true;
5814
+ if (opaque) sel.opaque = true;
5815
+ else sel.base = dir;
5816
+ } else if (k === "projects") {
5817
+ projects(node.initializer);
5818
+ return;
5819
+ } else if (k === "testNamePattern") namePattern(node.initializer);
5820
+ } else if (ownerKey2(node) === "test") {
5821
+ if (k === "include") sel.include = take(k, node.initializer);
5822
+ else if (k === "exclude") ignores(k, node.initializer);
5823
+ else if (k === "dir" || k === "root") {
5824
+ const { dir, opaque } = dirValue(node.initializer);
5825
+ sel.present = true;
5826
+ if (opaque) sel.opaque = true;
5827
+ else sel.base = dir;
5828
+ } else if (k === "projects") {
5829
+ projects(node.initializer);
5830
+ return;
5831
+ } else if (k === "testNamePattern") namePattern(node.initializer);
5842
5832
  }
5843
- return void 0;
5844
5833
  }
5845
- return void 0;
5834
+ ts.forEachChild(node, visit);
5846
5835
  };
5847
- const v = or();
5848
- return p === toks.length ? v : void 0;
5836
+ visit(root);
5837
+ if (projectList) sel.projects = readProjects(projectList, sel, runner);
5838
+ if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
5839
+ return sel;
5849
5840
  }
5850
- function callConst(name, args) {
5851
- const vals = [];
5852
- for (const a of args) {
5853
- if (a === void 0 || a === TRUTHY) return void 0;
5854
- vals.push(a);
5855
- }
5856
- const s = (i) => vals[i] == null ? "" : String(vals[i]);
5857
- switch (name.toLowerCase()) {
5858
- case "contains":
5859
- return vals.length === 2 ? s(0).toLowerCase().includes(s(1).toLowerCase()) : void 0;
5860
- case "startswith":
5861
- return vals.length === 2 ? s(0).toLowerCase().startsWith(s(1).toLowerCase()) : void 0;
5862
- case "endswith":
5863
- return vals.length === 2 ? s(0).toLowerCase().endsWith(s(1).toLowerCase()) : void 0;
5864
- case "format": {
5865
- if (vals.length < 1) return void 0;
5866
- return s(0).replace(/\{\{|\}\}|\{(\d+)\}/g, (m, i) => m === "{{" ? "{" : m === "}}" ? "}" : s(Number(i) + 1));
5867
- }
5868
- case "join":
5869
- return vals.length >= 1 && vals.length <= 2 ? s(0) : void 0;
5870
- case "tojson":
5871
- return vals.length === 1 ? JSON.stringify(vals[0]) : void 0;
5872
- case "fromjson": {
5873
- if (vals.length !== 1) return void 0;
5874
- try {
5875
- const v = JSON.parse(s(0));
5876
- if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
5877
- return TRUTHY;
5878
- } catch {
5879
- return void 0;
5880
- }
5881
- }
5882
- default:
5883
- return void 0;
5841
+ function unwrapConfigCall(e) {
5842
+ let x = e;
5843
+ while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isSatisfiesExpression(x)) {
5844
+ x = x.expression;
5884
5845
  }
5846
+ if (ts.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
5847
+ return x;
5885
5848
  }
5886
- var EXPR_TOKEN, MATRIX_TOKEN, TRUTHY, truthy;
5887
- var init_gh_expression = __esm({
5888
- "src/detectors/gh-expression.ts"() {
5889
- "use strict";
5890
- EXPR_TOKEN = "__expr__";
5891
- MATRIX_TOKEN = "__matrix__";
5892
- TRUTHY = /* @__PURE__ */ Symbol("truthy");
5893
- truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
5849
+ function spreadsIntoConfig(node, runner) {
5850
+ const obj = node.parent;
5851
+ if (!ts.isObjectLiteralExpression(obj)) return false;
5852
+ const keys = obj.properties.map((p) => ts.isPropertyAssignment(p) ? keyName2(p.name) : null);
5853
+ if (runner === "vitest") {
5854
+ if (keys.includes("test")) return true;
5855
+ let q = obj.parent;
5856
+ while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
5857
+ return !!q && ts.isPropertyAssignment(q) && keyName2(q.name) === "test";
5894
5858
  }
5895
- });
5896
-
5897
- // src/detectors/invocation.ts
5898
- function canonical(core) {
5899
- let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
5900
- s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
5901
- s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
5902
- s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
5903
- s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
5904
- s = s.replace(/^exec\s+/, "");
5905
- return s.replace(/\s+--\s*$/, "").trim();
5859
+ return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
5906
5860
  }
5907
- function runnerNarrowed(cmd, args) {
5908
- return runnerNarrowing(cmd, args) !== null;
5861
+ function extendsOf(el) {
5862
+ for (const p of el.properties) {
5863
+ if (!ts.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
5864
+ if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) return true;
5865
+ if (p.initializer.kind === ts.SyntaxKind.FalseKeyword) return null;
5866
+ return "path";
5867
+ }
5868
+ return null;
5909
5869
  }
5910
- function runnerNarrowing(cmd, args) {
5911
- const t = RUNNER_NARROWINGS.find((x) => x.runner.test(cmd));
5912
- const m = t ? (" " + args.trim()).match(t.flags) : null;
5913
- return m ? m[0].trim().replace(/[=\s].*$/, "") : null;
5914
- }
5915
- function coverageFloorLowered(removed, added) {
5916
- const was = removed.match(COVERAGE_FLOOR);
5917
- const now = added.match(COVERAGE_FLOOR);
5918
- return was !== null && now !== null && Number(now[1]) < Number(was[1]);
5919
- }
5920
- function inCommandPosition(core) {
5921
- return canonical(core).split(/\s*(?:&&|\|\||;|\|)\s*/).some((seg) => CHECK_HEAD.test(canonical(seg.replace(COMMAND_PREFIX, ""))));
5870
+ function readProject(el, root, runner) {
5871
+ const p = collect(el, runner);
5872
+ const ext = extendsOf(el);
5873
+ if (ext === "path") p.opaque = true;
5874
+ else if (ext === true && root) {
5875
+ p.include ??= root.include;
5876
+ p.regex ??= root.regex;
5877
+ p.ignore ??= root.ignore;
5878
+ p.roots ??= root.roots;
5879
+ p.base ??= root.base;
5880
+ if (root.opaque) p.opaque = true;
5881
+ }
5882
+ p.present = true;
5883
+ return p;
5922
5884
  }
5923
- function narrowedByPositional(args) {
5924
- return positionalOf(args) !== null;
5885
+ function readProjects(e, root, runner) {
5886
+ if (!ts.isArrayLiteralExpression(e)) {
5887
+ root.opaque = true;
5888
+ return [];
5889
+ }
5890
+ const list3 = [];
5891
+ for (const el of e.elements) {
5892
+ const lit = unwrapConfigCall(el);
5893
+ if (ts.isObjectLiteralExpression(lit)) list3.push(readProject(lit, root, runner));
5894
+ else root.opaque = true;
5895
+ }
5896
+ return list3;
5925
5897
  }
5926
- function positionalOf(args) {
5927
- const toks = args.trim().split(/\s+/).filter(Boolean);
5928
- for (let i = 0; i < toks.length; i++) {
5929
- const t = toks[i];
5930
- if (t === "--" || t.startsWith("-")) continue;
5931
- const prev = toks[i - 1];
5932
- if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
5933
- if (/[<>|;&]/.test(t)) continue;
5934
- if (PATH_SHAPED.test(t)) return t;
5898
+ function workspaceArray(sf) {
5899
+ for (const st of sf.statements) {
5900
+ let e;
5901
+ if (ts.isExportAssignment(st)) e = st.expression;
5902
+ else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
5903
+ if (!e) continue;
5904
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
5905
+ e = e.expression;
5906
+ }
5907
+ if (ts.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
5908
+ return ts.isArrayLiteralExpression(e) ? e : null;
5935
5909
  }
5936
5910
  return null;
5937
5911
  }
5938
- function checkKind(core) {
5939
- if (!invokesCheck(core)) return null;
5940
- const s = canonical(core);
5941
- if (/\btamperward\b/.test(s)) return "gate";
5942
- if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
5943
- if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
5944
- return "test";
5945
- }
5946
- function survives(removedCore, isUses, afterCores, addedCores) {
5947
- if (isUses) {
5948
- const ref = usesRef(removedCore);
5949
- return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
5912
+ function parseSelection(src, runner, path = "") {
5913
+ let sel = empty();
5914
+ if (runner === "pytest") return pytestSelection(src);
5915
+ try {
5916
+ const sf = parseSource("cfg.ts", asExpression2(src));
5917
+ if (!sf) return sel;
5918
+ if (runner === "vitest" && isWorkspaceFile(path)) {
5919
+ const arr = workspaceArray(sf);
5920
+ if (arr) {
5921
+ sel.projects = readProjects(arr, sel, runner);
5922
+ sel.present = true;
5923
+ if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
5924
+ } else if (src.trim()) sel.opaque = true;
5925
+ } else sel = collect(sf, runner);
5926
+ } catch {
5950
5927
  }
5951
- const r = canonical(removedCore);
5952
- const kind = checkKind(removedCore);
5953
- const narrowed = (args) => kind === "test" && narrowedByPositional(args);
5954
- let neutralised;
5955
- for (const raw of afterCores) {
5956
- const a = canonical(raw);
5957
- if (a === r) return { state: "kept" };
5958
- if (a.startsWith(r + " ")) {
5959
- const rest = a.slice(r.length);
5960
- if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest) || runnerNarrowed(a, rest)) neutralised ??= raw;
5961
- else return { state: "kept" };
5962
- } else if (r.startsWith(a + " ") && invokesCheck(a)) {
5963
- return { state: "kept" };
5964
- } else {
5965
- const w = a.match(TIMEOUT_WRAP);
5966
- if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
5967
- }
5928
+ return sel;
5929
+ }
5930
+ function predicate(sel, runner) {
5931
+ if (sel.projects) {
5932
+ const ps = sel.projects.map((p) => predicate(p, runner));
5933
+ const wide = sel.wide[0];
5934
+ return {
5935
+ selects: (p) => wide === void 0 && ps.some((x) => x.selects(p)),
5936
+ why: (p) => wide !== void 0 ? wideWhy(wide) : ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
5937
+ };
5968
5938
  }
5969
- const maskOf = (args) => (" " + args).match(NEUTRALISING_SUFFIX)?.[0]?.trim() ?? null;
5970
- const priorMask = r.includes(" ") ? maskOf(r.slice(r.indexOf(" ") + 1)) : null;
5971
- let respelled = false;
5972
- for (const raw of addedCores) {
5973
- if (!kind || checkKind(raw) !== kind) continue;
5974
- const a = canonical(raw);
5975
- if (a.match(TIMEOUT_WRAP)) {
5976
- neutralised ??= raw;
5977
- continue;
5978
- }
5979
- if (!inCommandPosition(raw)) continue;
5980
- if (coverageFloorLowered(r, a)) {
5981
- neutralised ??= raw;
5982
- continue;
5939
+ const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : runner === "pytest" ? PYTEST_DEFAULT_INCLUDE : VITEST_DEFAULT_INCLUDE);
5940
+ const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : runner === "pytest" ? PYTEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({
5941
+ pattern,
5942
+ key: runner === "jest" ? "testPathIgnorePatterns" : runner === "pytest" ? "norecursedirs" : "test.exclude"
5943
+ }));
5944
+ const blockWide = [...ignoreList.filter((e) => e.pattern.startsWith("\0")), ...sel.wide];
5945
+ const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
5946
+ const ignoredBy = (p) => blockWide[0] ?? ignoreList.filter((e) => !e.pattern.startsWith("\0")).find((e) => (runner === "jest" ? regexes([e.pattern]) : globs2([e.pattern]))(p));
5947
+ const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
5948
+ const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
5949
+ const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
5950
+ return {
5951
+ selects: (p) => {
5952
+ const r = rebase(p);
5953
+ return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
5954
+ },
5955
+ why: (p) => {
5956
+ const r = rebase(p);
5957
+ const baseKey = runner === "jest" ? "rootDir" : "test.dir";
5958
+ if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
5959
+ if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
5960
+ const hit = ignoredBy(r);
5961
+ if (hit) {
5962
+ if (hit.pattern.startsWith("\0")) return wideWhy(hit);
5963
+ return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
5964
+ }
5965
+ const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
5966
+ return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
5983
5967
  }
5984
- const args = a.slice(a.indexOf(" ") + 1 || a.length);
5985
- const mask = maskOf(args);
5986
- const newMask = mask !== null && mask !== priorMask;
5987
- if (a.includes(" ") && (newMask || narrowed(args) || runnerNarrowed(a, args))) neutralised ??= raw;
5988
- else respelled = true;
5989
- }
5990
- if (respelled) return { state: "kept" };
5991
- return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
5968
+ };
5992
5969
  }
5993
- function unwrap(seg) {
5994
- const s = seg.trim().replace(PYTHON_M, "").replace(NODE_BIN, "$1").replace(BIN_OF, "$1$2");
5995
- if (invokesCheck(s) || PRINTER.test(s) || /^["']/.test(s)) return s;
5996
- const toks = s.split(/\s+/);
5997
- for (let i = 1; i < toks.length && i <= 16; i++) {
5998
- const rest = toks.slice(i).join(" ");
5999
- if (invokesCheck(rest)) return rest;
5970
+ function suiteNarrowings(before, after, path, samples) {
5971
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
5972
+ if (!runner) return [];
5973
+ const b = parseSelection(before ?? "", runner, path);
5974
+ const a = parseSelection(after, runner, path);
5975
+ if (!a.present || a.opaque) return [];
5976
+ const pb = predicate(b, runner);
5977
+ const pa = predicate(a, runner);
5978
+ const out3 = [];
5979
+ for (const s of samples) {
5980
+ if (pb.selects(s) && !pa.selects(s)) out3.push({ path: s, reason: pa.why(s) });
6000
5981
  }
6001
- return s;
6002
- }
6003
- function scriptKind3(inner) {
6004
- const kind = checkKind(inner);
6005
- if (!kind) return null;
6006
- const head = canonical(inner).match(/^(tests?|lint|type-?check|coverage)(?=[\s:]|$)/i)?.[1];
6007
- if (!head) return kind;
6008
- return /^type/i.test(head) ? "types" : /^lint/i.test(head) ? "lint" : "test";
5982
+ return out3;
6009
5983
  }
6010
- function runnerOfSegment(canon) {
6011
- const m = canon.match(RUNNER_NAME);
6012
- if (!m) return null;
6013
- const n = m[1];
6014
- return n === "jest" || n === "vitest" || n === "mocha" || n === "pytest" || n === "go" || n === "cargo" ? n : "node";
5984
+ function requiredPath(e) {
5985
+ let x = e;
5986
+ while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isSatisfiesExpression(x) || ts.isAwaitExpression(x)) x = x.expression;
5987
+ if (ts.isCallExpression(x) && x.arguments.length > 0 && isStr(x.arguments[0])) {
5988
+ const callee = x.expression;
5989
+ if (ts.isIdentifier(callee) && callee.text === "require" || callee.kind === ts.SyntaxKind.ImportKeyword) return x.arguments[0].text;
5990
+ }
5991
+ return null;
6015
5992
  }
6016
- function statements(raw) {
5993
+ function delegationsIn(src, runner) {
6017
5994
  const out3 = [];
6018
- let single = false;
6019
- let double = false;
6020
- let last = 0;
6021
- for (let i = 0; i < raw.length; i++) {
6022
- const ch = raw[i];
6023
- if (ch === "\\" && !single) {
6024
- i++;
6025
- continue;
6026
- }
6027
- if (ch === "'" && !double) {
6028
- single = !single;
6029
- continue;
5995
+ const seen = /* @__PURE__ */ new Set();
5996
+ const add = (target, how, local = true) => {
5997
+ if (local && !isLocalPath(target) || hasGlob(target) || seen.has(target)) return;
5998
+ seen.add(target);
5999
+ out3.push({ target, how });
6000
+ };
6001
+ let sf = null;
6002
+ try {
6003
+ sf = parseSource("cfg.ts", asExpression2(src));
6004
+ } catch {
6005
+ return out3;
6006
+ }
6007
+ if (!sf) return out3;
6008
+ const bound = /* @__PURE__ */ new Map();
6009
+ const bindNames = (name, spec2) => {
6010
+ if (ts.isIdentifier(name)) bound.set(name.text, spec2);
6011
+ else for (const el of name.elements) if (ts.isBindingElement(el)) bindNames(el.name, spec2);
6012
+ };
6013
+ for (const st of sf.statements) {
6014
+ if (ts.isImportDeclaration(st) && isStr(st.moduleSpecifier) && isLocalPath(st.moduleSpecifier.text) && st.importClause) {
6015
+ const spec2 = st.moduleSpecifier.text;
6016
+ if (st.importClause.name) bound.set(st.importClause.name.text, spec2);
6017
+ const nb = st.importClause.namedBindings;
6018
+ if (nb && ts.isNamespaceImport(nb)) bound.set(nb.name.text, spec2);
6019
+ else if (nb) for (const el of nb.elements) bound.set(el.name.text, spec2);
6020
+ } else if (ts.isVariableStatement(st)) {
6021
+ for (const d of st.declarationList.declarations) {
6022
+ const spec2 = d.initializer ? requiredPath(d.initializer) : null;
6023
+ if (spec2 !== null && isLocalPath(spec2)) bindNames(d.name, spec2);
6024
+ }
6030
6025
  }
6031
- if (ch === '"' && !single) {
6032
- double = !double;
6033
- continue;
6026
+ }
6027
+ const moduleOf = (e) => {
6028
+ let x = e;
6029
+ while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isSatisfiesExpression(x) || ts.isAwaitExpression(x) || ts.isPropertyAccessExpression(x) || ts.isNonNullExpression(x))
6030
+ x = x.expression;
6031
+ if (ts.isIdentifier(x)) return bound.get(x.text) ?? null;
6032
+ return requiredPath(x);
6033
+ };
6034
+ const exported = (e, how) => {
6035
+ const m = moduleOf(unwrapConfigCall(e));
6036
+ if (m !== null) add(m, how);
6037
+ };
6038
+ const visit = (node) => {
6039
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && isStr(node.moduleSpecifier)) {
6040
+ const clause = node.exportClause;
6041
+ const whole = !clause || ts.isNamedExports(clause) && clause.elements.some((el) => el.name.text === "default");
6042
+ if (whole) add(node.moduleSpecifier.text, oneLine(node));
6043
+ } else if (ts.isExportAssignment(node)) {
6044
+ exported(node.expression, oneLine(node));
6045
+ } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && /^(?:module\.)?exports(?:\.default)?$/.test(node.left.getText())) {
6046
+ exported(node.right, oneLine(node));
6047
+ } else if (ts.isSpreadAssignment(node)) {
6048
+ const m = moduleOf(node.expression);
6049
+ if (m !== null) add(m, `...${oneLine(node.expression)}`);
6050
+ } else if (ts.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0]) {
6051
+ const m = moduleOf(node.arguments[0]);
6052
+ if (m !== null) add(m, `${oneLine(node.expression)}(${oneLine(node.arguments[0])}, \u2026)`);
6053
+ } else if (ts.isPropertyAssignment(node)) {
6054
+ const k = keyName2(node.name);
6055
+ const v = node.initializer;
6056
+ if (runner === "vitest" && k === "extends" && isStr(v)) add(v.text, `extends: ${JSON.stringify(v.text)}`);
6057
+ else if (runner === "jest" && k === "preset" && isStr(v)) add(v.text, `preset: ${JSON.stringify(v.text)}`);
6058
+ else if (k === "projects" && ts.isArrayLiteralExpression(v) && (runner === "jest" || ownerKey2(node) === "test")) {
6059
+ for (const el of v.elements) if (isStr(el)) add(el.text, `projects: [${JSON.stringify(el.text)}]`, false);
6060
+ }
6034
6061
  }
6035
- if (single || double) continue;
6036
- const prev = raw[i - 1];
6037
- const next = raw[i + 1];
6038
- let op = null;
6039
- if (ch === "&" && next === "&") op = "&&";
6040
- else if (ch === "|" && next === "|") op = "||";
6041
- else if (ch === "|" && next === "&") op = "|&";
6042
- else if (ch === ";" || ch === "\n") op = ";";
6043
- else if (ch === "|" && prev !== ">") op = "|";
6044
- else if (ch === "&" && prev !== ">" && next !== ">") op = "&";
6045
- if (!op) continue;
6046
- out3.push({ seg: raw.slice(last, i).trim(), op });
6047
- i += op.length - 1;
6048
- last = i + 1;
6062
+ ts.forEachChild(node, visit);
6063
+ };
6064
+ visit(sf);
6065
+ return out3;
6066
+ }
6067
+ function suiteDelegations(before, after, path) {
6068
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
6069
+ if (!runner || runner === "pytest") return [];
6070
+ const was = new Set(delegationsIn(before ?? "", runner).map((d) => d.target));
6071
+ return delegationsIn(after, runner).filter((d) => !was.has(d.target));
6072
+ }
6073
+ function targetCandidates(configPath, target) {
6074
+ const dir = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : "";
6075
+ const rel = target.replace(/^<rootDir>\/?/, "./");
6076
+ const parts = rel.startsWith("/") || !dir ? [] : dir.split("/");
6077
+ for (const seg of rel.split("/")) {
6078
+ if (seg === "" || seg === ".") continue;
6079
+ if (seg === "..") parts.pop();
6080
+ else parts.push(seg);
6049
6081
  }
6050
- out3.push({ seg: raw.slice(last).trim(), op: null });
6051
- return out3.filter((s) => s.seg !== "");
6082
+ const base = parts.join("/");
6083
+ if (!base) return [];
6084
+ const exts = [".js", ".cjs", ".mjs", ".ts", ".cts", ".mts", ".json"];
6085
+ return [base, ...exts.map((e) => base + e), ...exts.map((e) => `${base}/index${e}`)];
6052
6086
  }
6053
- function descriptors(cmd, runner, opts) {
6087
+ function setupEntriesIn(src, runner, path) {
6054
6088
  const out3 = [];
6055
- const stmts = statements(cmd);
6056
- for (let i = 0; i < stmts.length; i++) {
6057
- const { seg, op } = stmts[i];
6058
- const wrapped = seg.match(TIMEOUT_WRAP);
6059
- const inner = unwrap(wrapped ? wrapped[1] : seg);
6060
- if (!invokesCheck(inner)) continue;
6061
- const kind = scriptKind3(inner);
6062
- const canon = canonical(inner);
6063
- const rn = runner ?? runnerOfSegment(canon);
6064
- const args = canon.includes(" ") ? " " + canon.slice(canon.indexOf(" ") + 1) : " ";
6065
- if (wrapped) out3.push({ key: "timeout", what: "a timeout wrapper", direction: "added" });
6066
- const following = stmts[i + 1]?.seg ?? "";
6067
- if (op === "||" || op === "|" || op === "&" || op === ";" && !invokesCheck(unwrap(following))) {
6068
- out3.push({ key: `op:${op}`, what: `${op} ${following.split(/\s+/).slice(0, 2).join(" ")}`.trim(), direction: "added" });
6069
- }
6070
- for (const re of MASKING_FLAGS) {
6071
- const f = spelled(re, args);
6072
- if (f) out3.push({ key: `flag:${f}`, what: f, direction: "added" });
6073
- }
6074
- for (const re of GATE_FLAGS) {
6075
- const f = spelled(re, " " + seg);
6076
- if (f) out3.push({ key: `gate:${f}`, what: f, direction: "removed" });
6089
+ let sf = null;
6090
+ try {
6091
+ sf = parseSource("cfg.ts", asExpression2(src));
6092
+ } catch {
6093
+ return out3;
6094
+ }
6095
+ if (!sf) return out3;
6096
+ const inPackageJson = (path.split("/").pop() ?? path) === "package.json";
6097
+ const underJestKey = (node) => {
6098
+ for (let q = node.parent; q; q = q.parent) {
6099
+ if (ts.isPropertyAssignment(q) && keyName2(q.name) === "jest") return true;
6077
6100
  }
6078
- if (kind !== "test" || opts.sliceByDesign || opts.extended?.has(kind)) continue;
6079
- const flags = [SUITE_NARROWING_FLAGS, ...NARROWING_BY_RUNNER["*"], ...rn ? NARROWING_BY_RUNNER[rn] : []];
6080
- for (const re of flags) {
6081
- const f = spelled(re, args);
6082
- if (f && !opts.excuseFlag?.(f, canon)) out3.push({ key: `flag:${f}`, what: f, direction: "added" });
6101
+ return false;
6102
+ };
6103
+ const values = (e) => {
6104
+ if (isStr(e)) return [e.text];
6105
+ if (!ts.isArrayLiteralExpression(e)) return [];
6106
+ const vs = [];
6107
+ for (const el of e.elements) {
6108
+ if (isStr(el)) vs.push(el.text);
6109
+ else if (ts.isArrayLiteralExpression(el) && el.elements[0] && isStr(el.elements[0])) vs.push(el.elements[0].text);
6110
+ }
6111
+ return vs;
6112
+ };
6113
+ const visit = (node) => {
6114
+ if (ts.isPropertyAssignment(node)) {
6115
+ const k = keyName2(node.name);
6116
+ const applies = k !== null && (runner === "jest" ? JEST_SETUP_KEYS.test(k) && (!inPackageJson || underJestKey(node)) : VITEST_SETUP_KEYS.test(k) && ownerKey2(node) === "test");
6117
+ if (applies && k !== null) {
6118
+ for (const v of values(node.initializer)) if (namesFile(v)) out3.push({ key: k, target: v });
6119
+ }
6083
6120
  }
6084
- if (rn === "cargo" && CARGO_SKIP.test(args)) out3.push({ key: "flag:-- --skip", what: "-- --skip", direction: "added" });
6085
- const shared = runnerNarrowing(canon, args);
6086
- if (shared && !opts.excuseFlag?.(shared, canon)) out3.push({ key: `flag:${shared}`, what: shared, direction: "added" });
6087
- if (opts.fresh) continue;
6088
- const cfg = args.match(CONFIG_FLAG);
6089
- if (cfg && !opts.configReviewed?.(cfg[3])) out3.push({ key: `config:${cfg[3]}`, what: `${cfg[1]} ${cfg[3]}`, direction: "added" });
6090
- const pos = positionalOf(args);
6091
- if (pos) out3.push({ key: "positional", what: `a path positional (${pos})`, direction: "added" });
6092
- }
6093
- return out3;
6094
- }
6095
- function checkStatements(cmd) {
6096
- const out3 = [];
6097
- for (const seg of segments(cmd)) {
6098
- const w = seg.match(TIMEOUT_WRAP);
6099
- const inner = unwrap(w ? w[1] : seg);
6100
- const kind = scriptKind3(inner);
6101
- if (kind) out3.push({ kind, canon: canonical(inner) });
6102
- }
6121
+ ts.forEachChild(node, visit);
6122
+ };
6123
+ visit(sf);
6103
6124
  return out3;
6104
6125
  }
6105
- function checkKinds(cmd) {
6106
- return new Set(checkStatements(cmd).map((s) => s.kind));
6107
- }
6108
- function failsLoudly(cmd) {
6109
- const last = segments(cmd).pop();
6110
- return last !== void 0 && FAILS_LOUDLY.test(last.trim());
6111
- }
6112
- function invocationWeakening(before, after, runner, opts = {}) {
6113
- const was = checkKinds(before);
6114
- const now = checkKinds(after);
6115
- if (now.size === 0 && failsLoudly(after)) return { state: "kept" };
6116
- for (const k of was) {
6117
- if (now.has(k) || opts.relocated?.(k)) continue;
6118
- const instead = [...now].filter((x) => !was.has(x));
6119
- return { state: "removed", kind: k, what: instead.length ? `runs a ${instead.join("/")} check instead` : now.size ? "the check is gone" : "no check is run" };
6120
- }
6121
- const survived = new Set(checkStatements(before).map((s) => s.canon));
6122
- const extended = new Set(checkStatements(after).filter((s) => survived.has(s.canon)).map((s) => s.kind));
6123
- const read = { ...opts, fresh: was.size === 0, extended };
6124
- const beforeDs = descriptors(before, runner, read);
6125
- const afterDs = descriptors(after, runner, read);
6126
- const seenBefore = new Set(beforeDs.map((d) => d.key));
6127
- const added = afterDs.find((d) => d.direction === "added" && !seenBefore.has(d.key));
6128
- if (added) return { state: "neutralised", what: added.what, direction: "added" };
6129
- if (coverageFloorLowered(before, after)) return { state: "neutralised", what: "a lowered --cov-fail-under", direction: "added" };
6130
- const seenAfter = new Set(afterDs.map((d) => d.key));
6131
- const dropped = beforeDs.find((d) => d.direction === "removed" && !seenAfter.has(d.key));
6132
- if (dropped) return { state: "neutralised", what: dropped.what, direction: "removed" };
6133
- return { state: "kept" };
6126
+ function newSetupEntries(before, after, path) {
6127
+ const runner = runnerOf(path, after) ?? runnerOf(path, before);
6128
+ if (!runner || runner === "pytest") return [];
6129
+ const was = new Set(setupEntriesIn(before ?? "", runner, path).map((e) => `${e.key} ${e.target}`));
6130
+ return setupEntriesIn(after, runner, path).filter((e) => !was.has(`${e.key} ${e.target}`));
6134
6131
  }
6135
- var INVOKES_TOOL, INVOKES_SCRIPT, invokesCheck, MATRIX_FLAG, usesRef, SUITE_NARROWING_FLAGS, NEUTRALISING_SUFFIX, TIMEOUT_WRAP, RUNNER_NARROWINGS, COVERAGE_FLOOR, COMMAND_PREFIX, CHECK_HEAD, PATH_SHAPED, NARROWING_BY_RUNNER, CARGO_SKIP, MASKING_FLAGS, GATE_FLAGS, CONFIG_FLAG, PRINTER, PYTHON_M, NODE_BIN, BIN_OF, RUNNER_NAME, spelled, FAILS_LOUDLY;
6136
- var init_invocation = __esm({
6137
- "src/detectors/invocation.ts"() {
6132
+ var JEST_DEFAULT_MATCH, JEST_DEFAULT_IGNORE, VITEST_DEFAULT_INCLUDE, PYTEST_DEFAULT_INCLUDE, PYTEST_DEFAULT_IGNORE, VITEST_DEFAULT_EXCLUDE, JS_UNWALKED, CANONICAL_SAMPLES, PYTEST_CANONICAL_SAMPLES, isStr, PYTEST_INI_ORDER, pathGlobs, isWorkspaceFile, empty, stripRootDir, JEST_SELECTION_KEYS, globs2, globList2, regexes, wideWhy, underDir, isLocalPath, hasGlob, oneLine, JEST_SETUP_KEYS, VITEST_SETUP_KEYS, namesFile;
6133
+ var init_suite_config = __esm({
6134
+ "src/detectors/suite-config.ts"() {
6138
6135
  "use strict";
6139
- init_gh_expression();
6140
- init_command();
6141
- 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/;
6142
- 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/;
6143
- invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line);
6144
- MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
6145
- usesRef = (core) => core.replace(/@.*$/, "");
6146
- SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
6147
- NEUTRALISING_SUFFIX = new RegExp(
6148
- `\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
6149
- );
6150
- TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
6151
- RUNNER_NARROWINGS = [
6152
- {
6153
- // a package-manager script: `test --prefix dir`, `-w pkg`, `--workspace=pkg`,
6154
- // `--if-present` (no script, no failure), pnpm's `--filter` / `-F`, npm's `-C`
6155
- runner: /^(?:test|tests|coverage)(?::\S+)?(?=\s|$)/,
6156
- flags: /\s--if-present(?=\s|$)|\s--prefix(?:=|\s)|\s-C(?:=|\s)|\s-w(?:=|\s)|\s--workspace(?:=|\s)|\s--filter(?:=|\s)|\s-F(?:=|\s)/
6157
- },
6158
- {
6159
- runner: /^(?:jest|vitest)\b/,
6160
- flags: /\s--shard(?:=|\s)|\s--root(?:Dir)?(?:=|\s)|\s--testMatch(?:=|\s)|\s--testRegex(?:=|\s)|\s--modulePathIgnorePatterns(?:=|\s)|\s--selectProjects(?:=|\s)|\s--grep(?:=|\s)|\s-g(?:=|\s)/
6161
- },
6162
- { runner: /^playwright\b/, flags: /\s--shard(?:=|\s)|\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--grep-invert(?:=|\s)/ },
6163
- { runner: /^mocha\b/, flags: /\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--fgrep(?:=|\s)|\s-f(?:=|\s)/ },
6164
- { runner: /^ava\b/, flags: /\s--match(?:=|\s)|\s-m(?:=|\s)/ },
6165
- { runner: /^node --test\b/, flags: /\s--test-name-pattern(?:=|\s)|\s--test-skip-pattern(?:=|\s)|\s--test-only(?=\s|$)/ },
6166
- {
6167
- // `-p no:<plugin>` disables collection when the plugin is the collector
6168
- // (`python`, `doctest`, `cov`); the cache, warning and output plugins select nothing
6169
- runner: /^pytest\b/,
6170
- flags: /\s-k(?:=|\s)|\s-m(?:=|\s)|\s--deselect(?:=|\s)|\s--ignore(?:-glob)?(?:=|\s)|\s-p\s*no:(?!(?:cacheprovider|warnings|faulthandler|randomly|logging|legacypath|pastebin|stepwise|tmpdir|junitxml|terminal|terminalreporter)(?=\s|$))|\s--collect-only(?=\s|$)|\s--co(?=\s|$)/
6171
- },
6172
- { runner: /^cargo test\b/, flags: /\s--\s(?:\S+\s+)*--skip(?:=|\s)|\s--exclude(?:=|\s)/ },
6173
- { runner: /^go test\b/, flags: /\s-run(?:=|\s)|\s-skip(?:=|\s)/ }
6136
+ init_lazy_deps();
6137
+ init_ts_lazy();
6138
+ JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
6139
+ JEST_DEFAULT_IGNORE = ["/node_modules/"];
6140
+ VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
6141
+ PYTEST_DEFAULT_INCLUDE = ["**/test_*.py", "**/*_test.py"];
6142
+ PYTEST_DEFAULT_IGNORE = ["**/build/**", "**/dist/**", "**/node_modules/**", "**/venv/**", "**/.*/**"];
6143
+ VITEST_DEFAULT_EXCLUDE = [
6144
+ "**/node_modules/**",
6145
+ "**/dist/**",
6146
+ "**/cypress/**",
6147
+ "**/.{idea,git,cache,output,temp}/**",
6148
+ "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
6174
6149
  ];
6175
- COVERAGE_FLOOR = /--cov-fail-under(?:=|\s+)(\d+(?:\.\d+)?)/;
6176
- COMMAND_PREFIX = /^(?:(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)|sudo|env|nice|time|exec|command|nohup|xvfb-run|dbus-launch|cross-env|dotenv|poetry\s+run|pipenv\s+run|uv\s+run|hatch\s+run|pdm\s+run|bundle\s+exec)\s+(?:-{1,2}\S+\s+)*)*/;
6177
- CHECK_HEAD = /^(?:(?:test|tests|lint|typecheck|type-check|coverage)(?::\S+)?(?=\s|$)|(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward|mocha|ava|oxlint|tsgo|mypy|golangci-lint)\b|node --test\b|biome (?:ci|check|lint)\b|deno (?:test|lint|check)\b|ruff check\b|(?:make|cargo|go) test\b|gradle\w* (?:test|check)\b)/;
6178
- PATH_SHAPED = /\/|\.(?:test|spec)\./;
6179
- NARROWING_BY_RUNNER = {
6180
- "*": [/--shard\b/],
6181
- jest: [/--root\b/, /--rootDir\b/, /--testMatch\b/, /--testRegex\b/, /--modulePathIgnorePatterns\b/, /--selectProjects\b/, /--ignoreProjects\b/, /--filter\b/],
6182
- vitest: [/--grep\b/],
6183
- mocha: [/--grep\b/, /(?:^|\s)-g(?:\s|=)/, /--ignore\b/, /(?:^|\s)-f(?:\s|=)/, /--fgrep\b/],
6184
- pytest: [/(?:^|\s)-k(?:\s|=)/, /(?:^|\s)-m(?:\s|=)/, /--deselect\b/, /--ignore\b/, /--ignore-glob\b/, /--last-failed\b/, /(?:^|\s)--lf(?:\s|$)/, /--collect-only\b/, /(?:^|\s)--co(?:\s|$)/],
6185
- go: [/(?:^|\s)-run(?:\s|=)/, /(?:^|\s)-skip(?:\s|=)/],
6186
- cargo: [],
6187
- node: [/--test-name-pattern\b/, /--test-skip-pattern\b/]
6150
+ JS_UNWALKED = [...VITEST_DEFAULT_EXCLUDE, "**/.*/**"];
6151
+ CANONICAL_SAMPLES = [
6152
+ "src/a.test.ts",
6153
+ "src/a.spec.ts",
6154
+ "test/a.test.ts",
6155
+ "tests/a.test.js",
6156
+ "src/__tests__/a.ts",
6157
+ "a.test.tsx",
6158
+ "a.spec.js",
6159
+ "test/a.test.mjs"
6160
+ ];
6161
+ PYTEST_CANONICAL_SAMPLES = [
6162
+ "tests/test_a.py",
6163
+ "tests/a_test.py",
6164
+ "test_a.py",
6165
+ "src/tests/test_a.py",
6166
+ "tests/conftest.py"
6167
+ ];
6168
+ isStr = (e) => ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e);
6169
+ PYTEST_INI_ORDER = ["pytest.ini", ".pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg"];
6170
+ pathGlobs = (p) => {
6171
+ const clean = p.replace(/^\.\//, "").replace(/::.*$/, "").replace(/\/+$/, "");
6172
+ return clean ? [clean, clean + "/**"] : [];
6188
6173
  };
6189
- CARGO_SKIP = /\s--\s+(?:\S+\s+)*--skip\b/;
6190
- MASKING_FLAGS = [/--passWithNoTests\b/, /--coverage=false\b/, /--coverageThreshold\b/, /--if-present\b/];
6191
- GATE_FLAGS = [/--check-coverage\b/];
6192
- CONFIG_FLAG = /(?:^|\s)(--config|-c)(?:=|\s+)(['"]?)([^\s'"]+)\2(?=\s|$)/;
6193
- PRINTER = /^(?:echo|printf|true|false|:|cat|exit|return|sleep|test|\[|node|git|curl|wget)(?=\s|$)/;
6194
- PYTHON_M = /^python\d?(?:\.\d+)?\s+-m\s+(?=pytest\b)/;
6195
- NODE_BIN = /^node(?:\s+-\S+)*\s+\S*node_modules\/(?:\.bin\/)?(jest|vitest|mocha|ava)\b\S*/;
6196
- BIN_OF = /\$\((?:yarn|npm|pnpm)\s+bin\s+(\w+)\)|\$\((?:yarn|npm|pnpm)\s+bin\)\/(\w+)/g;
6197
- RUNNER_NAME = /^(jest|vitest|mocha|pytest|go|cargo|node)\b/;
6198
- spelled = (re, s) => {
6199
- const m = s.match(re);
6200
- return m ? m[0].trim().replace(/[=\s].*$/, "") : null;
6174
+ isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
6175
+ empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, wide: [], opaque: false, present: false });
6176
+ stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
6177
+ JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
6178
+ globs2 = (patterns) => {
6179
+ const ms = patterns.map((g) => {
6180
+ try {
6181
+ return picomatch(g, { dot: true });
6182
+ } catch {
6183
+ return () => false;
6184
+ }
6185
+ });
6186
+ return (path) => ms.some((m) => m(path));
6201
6187
  };
6202
- FAILS_LOUDLY = /^(?:exit\s+(?:[1-9]\d*)|false)\s*$/;
6188
+ globList2 = (patterns) => {
6189
+ const pos = globs2(patterns.filter((g) => !g.startsWith("!")));
6190
+ const neg = globs2(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
6191
+ return (path) => pos(path) && !neg(path);
6192
+ };
6193
+ regexes = (patterns) => {
6194
+ const rs = patterns.flatMap((p) => {
6195
+ try {
6196
+ return [new RegExp(p.replace(/<rootDir>/g, ""))];
6197
+ } catch {
6198
+ return [];
6199
+ }
6200
+ });
6201
+ return (path) => rs.some((r) => r.test("/" + path));
6202
+ };
6203
+ wideWhy = (hit) => `${hit.key} narrows the whole run (${JSON.stringify(hit.pattern.slice(1).replace(/^testNamePattern /, ""))})`;
6204
+ underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
6205
+ isLocalPath = (s) => /^(?:\.\.?\/|\/|<rootDir>)/.test(s);
6206
+ hasGlob = (s) => /[*?[\]{}]/.test(s);
6207
+ oneLine = (node) => node.getText().replace(/\s+/g, " ").slice(0, 120);
6208
+ JEST_SETUP_KEYS = /^(?:setupFiles|setupFilesAfterEnv|setupFilesAfterEach|globalSetup|globalTeardown|runner|testRunner|testEnvironment|reporters|testResultsProcessor|testSequencer|snapshotResolver|resolver)$/;
6209
+ VITEST_SETUP_KEYS = /^(?:setupFiles|setupFilesAfterEach|globalSetup|environment|runner|reporters)$/;
6210
+ namesFile = (s) => isLocalPath(s) || /\.[cm]?[jt]sx?$/.test(s) && !s.startsWith("@");
6203
6211
  }
6204
6212
  });
6205
6213
 
6206
- // src/detectors/ci-tampering.ts
6207
- function isActiveWorkflow(path) {
6208
- return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
6214
+ // src/detectors/config-weakening.ts
6215
+ function parseJsonc(path, text) {
6216
+ try {
6217
+ const r = ts.parseConfigFileTextToJson(path, text);
6218
+ if (r.error) return null;
6219
+ const cfg = r.config;
6220
+ return cfg;
6221
+ } catch {
6222
+ return null;
6223
+ }
6209
6224
  }
6210
- function commandCore(line) {
6211
- return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses|script|command|before_script|after_script|sh|bash|pwsh|powershell):\s*/, "").replace(/\s+/g, " ").trim();
6225
+ function parseYaml(text) {
6226
+ try {
6227
+ const v = yaml.parse(text);
6228
+ return v;
6229
+ } catch {
6230
+ return null;
6231
+ }
6212
6232
  }
6213
- function expressionOf(raw) {
6214
- const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
6215
- const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
6216
- return m ? m[1] : v;
6233
+ function corePattern(p) {
6234
+ return p.trim().replace(/^!/, "").replace(/^(?:\.\/|\/)+/, "").replace(/^(?:\*\*\/)+/, "").replace(/(?:\/\*\*?)+\/?$/, "").replace(/\/+$/, "");
6217
6235
  }
6218
- function isAlwaysFalse(raw) {
6219
- const e = expressionOf(raw);
6220
- if (e === "") return false;
6221
- const v = foldConst(e);
6222
- return v !== void 0 && !truthy(v);
6236
+ function housekeepingByName(p) {
6237
+ const core = corePattern(p);
6238
+ if (core === "" || core === "**" || core === "*") return false;
6239
+ const first = core.split("/")[0];
6240
+ return HOUSEKEEPING_DIR.test(first) || HOUSEKEEPING_FILE.test(core);
6223
6241
  }
6224
- function isTruthy(raw) {
6225
- const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
6226
- if (/^(?:true|yes|on|1)$/i.test(v)) return true;
6227
- const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
6228
- if (!m) return false;
6229
- const f = foldConst(m[1]);
6230
- return f !== void 0 && truthy(f);
6242
+ function ignoreGlobs(p) {
6243
+ const core = corePattern(p);
6244
+ if (!core) return [];
6245
+ const anchored = p.trim().replace(/^!/, "").startsWith("/") || core.includes("/");
6246
+ const heads = anchored ? [core] : [core, `**/${core}`];
6247
+ return heads.flatMap((h) => [h, `${h}/**`]);
6231
6248
  }
6232
- function stepOf(lines, i) {
6233
- const ind = indentOf(lines[i]);
6234
- let s = i - 1;
6235
- while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
6236
- if (s < 0 || !STEP_START.test(lines[s])) return null;
6237
- const stepInd = indentOf(lines[s]);
6238
- let e = i + 1;
6239
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
6240
- return [s, e];
6249
+ function hidesSource(pattern, dir, ctx) {
6250
+ const files = trackedFiles(ctx);
6251
+ if (!files) return housekeepingByName(pattern) ? null : pattern;
6252
+ const m = matcher(ignoreGlobs(pattern));
6253
+ const hit = files.find((f) => {
6254
+ if (dir && !f.startsWith(dir)) return false;
6255
+ const rel = f.slice(dir.length);
6256
+ if (!CODE_FILE.test(rel) || HOUSEKEEPING_FILE.test(rel)) return false;
6257
+ if (rel.split("/").slice(0, -1).some((seg) => HOUSEKEEPING_DIR.test(seg))) return false;
6258
+ return m(rel);
6259
+ });
6260
+ return hit === void 0 ? null : hit;
6241
6261
  }
6242
- function jobOf(lines, i) {
6243
- const ind = indentOf(lines[i]);
6244
- let h = i - 1;
6245
- while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
6246
- if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
6247
- let t = h - 1;
6248
- while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
6249
- if (t < 0 || !/^jobs:/.test(lines[t])) return null;
6250
- const hi = indentOf(lines[h]);
6251
- let e = i + 1;
6252
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
6253
- return [h, e];
6262
+ function grown(before, after) {
6263
+ const was = new Set(before.map(corePattern));
6264
+ return after.filter((e) => !was.has(corePattern(e)));
6254
6265
  }
6255
- function runBlocks(lines) {
6256
- const out3 = [];
6257
- for (let i = 0; i < lines.length; i++) {
6258
- if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
6259
- const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
6260
- let e = i + 1;
6261
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
6262
- out3.push({ start: i, end: e, body: lines.slice(i + 1, e) });
6263
- }
6264
- return out3;
6266
+ function tsconfigFacts(path, text) {
6267
+ if (text === null) return null;
6268
+ const cfg = parseJsonc(path, text);
6269
+ if (!isRecord(cfg)) return null;
6270
+ return {
6271
+ options: isRecord(cfg.compilerOptions) ? cfg.compilerOptions : {},
6272
+ exclude: strings(cfg.exclude),
6273
+ extends: strings(cfg.extends)
6274
+ };
6265
6275
  }
6266
- function commandLines(lines) {
6267
- const out3 = lines.slice();
6268
- for (const b of runBlocks(lines)) {
6269
- if (/^\s*-?\s*run:\s*>/.test(lines[b.start])) {
6270
- out3[b.start + 1] = b.body.map((l) => l.trim()).filter(Boolean).join(" ");
6271
- for (let k = b.start + 2; k < b.end; k++) out3[k] = "";
6272
- continue;
6273
- }
6274
- const indents = b.body.filter((l) => l.trim() !== "").map(indentOf);
6275
- const ind = indents.length ? Math.min(...indents) : 0;
6276
- const dedented = b.body.map((l) => l.slice(Math.min(ind, indentOf(l))));
6277
- const kept = stripHeredocs(dedented);
6278
- let j = 0;
6279
- for (let k = 0; k < dedented.length; k++) {
6280
- if (j < kept.length && kept[j] === dedented[k]) j++;
6281
- else out3[b.start + 1 + k] = "";
6282
- }
6276
+ function effective(f, flag) {
6277
+ if (!f) return false;
6278
+ const v = f.options[flag];
6279
+ if (typeof v === "boolean") return v;
6280
+ return STRICT_FAMILY.has(flag) && f.options.strict === true;
6281
+ }
6282
+ function tsconfigWeakenings(c, all, policy, addedPaths, ctx, out3) {
6283
+ if (c.before === null || c.after === null) return;
6284
+ const b = tsconfigFacts(c.path, c.before);
6285
+ const a = tsconfigFacts(c.path, c.after);
6286
+ if (!b || !a) return;
6287
+ const others = all.filter((o) => o !== c && o.after !== null && isTsconfig(o.path)).map((o) => ({ b: tsconfigFacts(o.path, o.before), a: tsconfigFacts(o.path, o.after) }));
6288
+ const movedTo = (flag) => others.find((o) => effective(o.a, flag) && !effective(o.b, flag));
6289
+ const push = (message, evidence, remediation) => out3.push(makeFinding(RULE6, policy, { file: c.path, message, evidence, remediation, defaultSeverity: "warn" }));
6290
+ const strictLowered = effective(b, "strict") && !effective(a, "strict");
6291
+ for (const flag of TRUE_IS_STRICT) {
6292
+ if (!effective(b, flag) || effective(a, flag)) continue;
6293
+ if (flag !== "strict" && STRICT_FAMILY.has(flag) && strictLowered && a.options[flag] === void 0) continue;
6294
+ if (movedTo(flag)) continue;
6295
+ const was = typeof b.options[flag] === "boolean" ? String(b.options[flag]) : "implied by strict";
6296
+ const now = a.options[flag] === void 0 ? "removed" : String(a.options[flag]);
6297
+ push(
6298
+ `tsconfig strictness lowered: compilerOptions.${flag} ${was} \u2192 ${now}.`,
6299
+ `${c.path}: ${flag} ${was} \u2192 ${now}`,
6300
+ `Keep ${flag} on and fix the code it reports. Lowering the compiler's strictness hides the errors it was finding, in every file at once.`
6301
+ );
6283
6302
  }
6284
- return out3;
6285
- }
6286
- function flowList(v) {
6287
- const m = v.trim().match(/^\[(.*)\]$/);
6288
- if (!m) return null;
6289
- return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
6290
- }
6291
- function parseTriggers(src) {
6292
- const t = {
6293
- events: /* @__PURE__ */ new Set(),
6294
- branches: /* @__PURE__ */ new Map(),
6295
- branchesIgnore: /* @__PURE__ */ new Map(),
6296
- pathsIgnore: /* @__PURE__ */ new Map(),
6297
- paths: /* @__PURE__ */ new Map(),
6298
- types: /* @__PURE__ */ new Map(),
6299
- tags: /* @__PURE__ */ new Map(),
6300
- present: false
6301
- };
6302
- const lines = src.split("\n");
6303
- const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
6304
- if (i < 0) return t;
6305
- t.present = true;
6306
- const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
6307
- const inline = uncommented(head);
6308
- if (inline) {
6309
- const list3 = flowList(inline);
6310
- for (const e of list3 ?? [inline]) t.events.add(e);
6311
- return t;
6303
+ for (const flag of TRUE_IS_WEAK) {
6304
+ if (b.options[flag] === true || a.options[flag] !== true) continue;
6305
+ push(
6306
+ `tsconfig check loosened: compilerOptions.${flag}: true added${flag === "skipLibCheck" ? " (declaration files are no longer type-checked)" : ""}.`,
6307
+ `${c.path}: ${flag}: true`,
6308
+ `Leave ${flag} off and fix what the compiler reports; a check switched off here is off for every build.`
6309
+ );
6312
6310
  }
6313
- let event = null;
6314
- let listKey = null;
6315
- let listInd = -1;
6316
- for (let j = i + 1; j < lines.length; j++) {
6317
- const l = lines[j];
6318
- if (l.trim() === "" || /^\s*#/.test(l)) continue;
6319
- const ind = indentOf(l);
6320
- if (ind === 0) break;
6321
- const body = uncommented(l);
6322
- const item = body.match(/^\s*-\s+(.+)$/);
6323
- if (item && listKey && event && ind > listInd) {
6324
- const target = t[listKey].get(event) ?? [];
6325
- target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
6326
- t[listKey].set(event, target);
6327
- continue;
6328
- }
6329
- const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
6330
- if (!kv) continue;
6331
- if (ind === 2 || event === null && ind < 4) {
6332
- event = kv[1];
6333
- t.events.add(event);
6334
- listKey = null;
6335
- continue;
6336
- }
6337
- const key2 = LIST_KEYS[kv[1]];
6338
- if (event && key2) {
6339
- listKey = key2;
6340
- listInd = ind;
6341
- const list3 = flowList(kv[2]);
6342
- t[listKey].set(event, list3 ?? []);
6343
- } else listKey = null;
6311
+ const dir = dirOf2(c.path);
6312
+ for (const e of grown(b.exclude, a.exclude)) {
6313
+ const hit = hidesSource(e, dir, ctx);
6314
+ if (hit === null) continue;
6315
+ push(
6316
+ `tsconfig exclude grown: ${JSON.stringify(e)} now hides ${hit === e ? "source" : hit} from the compiler.`,
6317
+ `${c.path}: exclude += ${JSON.stringify(e)}`,
6318
+ "Keep the compiler pointed at the whole source. A file excluded from tsconfig is not type-checked anywhere."
6319
+ );
6344
6320
  }
6345
- return t;
6346
- }
6347
- function ghGlob(g) {
6348
- const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
6349
- try {
6350
- const r = new RegExp(`^${re}$`);
6351
- return (p) => r.test(p);
6352
- } catch {
6353
- return () => false;
6321
+ if (b.extends.length && a.extends.length === 0) {
6322
+ push(
6323
+ `tsconfig no longer extends ${b.extends.map((x) => JSON.stringify(x)).join(", ")}: the strictness the base carried is gone with it.`,
6324
+ `${c.path}: extends removed`,
6325
+ "Keep the base config, or carry its compiler options over explicitly so the check stays as strict as it was."
6326
+ );
6354
6327
  }
6355
- }
6356
- function admits(globs4, name) {
6357
- let on = false;
6358
- for (const g of globs4) {
6359
- const neg = g.startsWith("!");
6360
- if (ghGlob(neg ? g.slice(1) : g)(name)) on = !neg;
6328
+ for (const target of a.extends.filter((t) => !b.extends.includes(t) && isLocalTarget(t))) {
6329
+ const cands = targetCandidates(c.path, target);
6330
+ const added = cands.find((f) => addedPaths.has(f));
6331
+ if (added === void 0 && cands.some((f) => isProtected(f, policy, "config"))) continue;
6332
+ push(
6333
+ `tsconfig now extends ${JSON.stringify(target)}, ${added !== void 0 ? `a file this change adds (${added})` : "a file no protected glob covers"}.`,
6334
+ `${c.path}: extends ${JSON.stringify(target)}`,
6335
+ "Extend a protected config, or protect the base (protected.config in .tamperward.yml); the strictness now lives where the gate cannot read it."
6336
+ );
6361
6337
  }
6362
- return on;
6363
6338
  }
6364
- function sourceProbes(ctx) {
6365
- const files = trackedFiles(ctx);
6366
- if (!files) return CODE_PROBES;
6367
- const code2 = files.filter((f) => langOf(f) !== null);
6368
- return code2.length ? code2 : CODE_PROBES;
6339
+ function levelOf(v) {
6340
+ const head = Array.isArray(v) ? v[0] : v;
6341
+ if (head === "off" || head === 0) return "off";
6342
+ if (head === "warn" || head === "error" || head === 1 || head === 2) return "on";
6343
+ return null;
6369
6344
  }
6370
- function triggerNarrowings(before, after, opts) {
6371
- const out3 = [];
6372
- if (!before.present || !after.present) return out3;
6373
- const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
6374
- const candidates = opts.defaultBranch ? [opts.defaultBranch] : ["main", "master"];
6375
- const namesDefault = (l) => candidates.some((b) => admits(l, b));
6376
- for (const e of CODE_EVENTS) {
6377
- if (before.events.has(e) && !after.events.has(e)) out3.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
6345
+ function eslintObjectFacts(v, f, key2 = null) {
6346
+ if (Array.isArray(v)) {
6347
+ for (const el of v) eslintObjectFacts(el, f, null);
6348
+ return;
6378
6349
  }
6379
- for (const [e, list3] of after.pathsIgnore) {
6380
- const had = before.pathsIgnore.get(e) ?? [];
6381
- if (list3.some((g) => ALL(g) && !had.includes(g))) {
6382
- out3.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
6383
- continue;
6350
+ if (!isRecord(v)) return;
6351
+ if (key2 === "rules") {
6352
+ for (const [rule, level] of Object.entries(v)) {
6353
+ const l = levelOf(level);
6354
+ if (l === "off") f.off.set(rule, `${rule}: ${JSON.stringify(level)}`);
6355
+ else if (l === "on") f.on.add(rule);
6384
6356
  }
6385
- if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
6386
- if (had.length && ignoresEverySource(had, opts.sources)) continue;
6387
- if (ignoresEverySource(list3, opts.sources)) out3.push(`on.${e}.paths-ignore now ignores every source file (now [${list3.join(", ")}]) \u2014 the workflow never runs on code`);
6388
- }
6389
- for (const [e, list3] of after.paths) {
6390
- if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
6391
- const had = before.paths.get(e);
6392
- if (had && !pathsMatchSource(had, opts.sources)) continue;
6393
- if (!pathsMatchSource(list3, opts.sources)) out3.push(`on.${e}.paths matches no source file (now [${list3.join(", ")}]) \u2014 the workflow never runs on code`);
6357
+ return;
6394
6358
  }
6395
- for (const [e, list3] of after.types) {
6396
- if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
6397
- const had = before.types.get(e);
6398
- if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
6399
- if (!list3.some((t) => PR_CODE_TYPES.test(t))) out3.push(`on.${e}.types no longer includes opened/synchronize (now [${list3.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
6359
+ for (const [k, val] of Object.entries(v)) {
6360
+ if (k === "ignores" || k === "ignorePatterns") f.ignores.push(...strings(val));
6361
+ else eslintObjectFacts(val, f, k);
6400
6362
  }
6401
- for (const [e, list3] of after.branchesIgnore) {
6402
- const had = before.branchesIgnore.get(e) ?? [];
6403
- if (list3.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out3.push(`on.${e}.branches-ignore now covers the default branch`);
6363
+ }
6364
+ function eslintAstFacts(text, f) {
6365
+ let sf = null;
6366
+ try {
6367
+ sf = parseSource("eslint.config.ts", text);
6368
+ } catch {
6369
+ return;
6404
6370
  }
6405
- const events = /* @__PURE__ */ new Set([...before.branches.keys(), ...CODE_EVENTS.filter((e) => before.events.has(e))]);
6406
- for (const e of events) {
6407
- const was = before.branches.get(e) ?? (before.tags.has(e) ? null : ["**"]);
6408
- if (!was || !after.events.has(e)) continue;
6409
- const now = after.branches.get(e);
6410
- if (!now) {
6411
- const tags = after.tags.get(e);
6412
- if (tags && namesDefault(was)) out3.push(`on.${e} now filters on tags only (tags: [${tags.join(", ")}]) \u2014 no push to the default branch runs the workflow`);
6413
- continue;
6371
+ if (!sf) return;
6372
+ const isStr2 = (e) => ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e);
6373
+ const keyText = (n) => ts.isIdentifier(n) || isStr2(n) || ts.isNumericLiteral(n) ? n.text : null;
6374
+ const literal2 = (e) => {
6375
+ if (isStr2(e)) return e.text;
6376
+ if (ts.isNumericLiteral(e)) return Number(e.text);
6377
+ if (ts.isArrayLiteralExpression(e)) return e.elements.map(literal2);
6378
+ return void 0;
6379
+ };
6380
+ const arrayStrings = (e) => ts.isArrayLiteralExpression(e) ? e.elements.filter(isStr2).map((s) => s.text) : isStr2(e) ? [e.text] : [];
6381
+ const visit = (node) => {
6382
+ if (ts.isPropertyAssignment(node)) {
6383
+ const k = keyText(node.name);
6384
+ if (k === "rules" && ts.isObjectLiteralExpression(node.initializer)) {
6385
+ for (const p of node.initializer.properties) {
6386
+ if (!ts.isPropertyAssignment(p)) continue;
6387
+ const rule = keyText(p.name);
6388
+ if (rule === null) continue;
6389
+ const l = levelOf(literal2(p.initializer));
6390
+ if (l === "off") f.off.set(rule, `${rule}: ${p.initializer.getText().replace(/\s+/g, " ").slice(0, 60)}`);
6391
+ else if (l === "on") f.on.add(rule);
6392
+ }
6393
+ } else if (k === "ignores" || k === "ignorePatterns") f.ignores.push(...arrayStrings(node.initializer));
6394
+ } else if (ts.isCallExpression(node) && /^(?:\w+\.)?globalIgnores$/.test(node.expression.getText()) && node.arguments[0]) {
6395
+ f.ignores.push(...arrayStrings(node.arguments[0]));
6414
6396
  }
6415
- const renamed = (b) => DEFAULT_BRANCH.test(b) && now.includes(b) && admits(now, b) && opts.hasBranch(b) !== false;
6416
- const lost = candidates.filter((b) => admits(was, b) && !admits(now, b));
6417
- const lostDefault = lost.length > 0 && !["main", "master"].some(renamed);
6418
- const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
6419
- if (!lostDefault && !replaced) continue;
6420
- const dropped = was.filter((b) => !now.includes(b) && !ALL(b));
6421
- out3.push(
6422
- dropped.length ? `on.${e}.branches no longer names ${dropped.join(", ")} (now [${now.join(", ")}])` : `on.${e}.branches now filters to [${now.join(", ")}], which does not admit the default branch`
6423
- );
6424
- }
6425
- return out3;
6426
- }
6427
- function localUses(line) {
6428
- const wf = line.match(REUSED_WORKFLOW)?.[1];
6429
- if (wf) return [wf];
6430
- const action = line.match(LOCAL_ACTION)?.[1];
6431
- return action ? [`${action}/action.yml`, `${action}/action.yaml`] : [];
6432
- }
6433
- function literalValue(raw) {
6434
- const v = foldExpressions(uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim());
6435
- return v.includes(EXPR_TOKEN) || v.includes(MATRIX_TOKEN) ? null : v;
6397
+ ts.forEachChild(node, visit);
6398
+ };
6399
+ visit(sf);
6436
6400
  }
6437
- function dirRunsNothing(dir, ctx) {
6438
- const files = trackedFiles(ctx);
6439
- if (!files) return true;
6440
- const d = dir.replace(/^\.\//, "").replace(/\/+$/, "");
6441
- if (d === "" || d === ".") return false;
6442
- return !files.some((f) => f.startsWith(d + "/") && (langOf(f) !== null || MANIFEST.test(f)));
6401
+ function eslintFacts(path, text) {
6402
+ const f = emptyFacts();
6403
+ if (text === null) return f;
6404
+ const base = basename2(path);
6405
+ if (isEslintIgnore(path)) {
6406
+ f.ignores.push(...text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("!")));
6407
+ return f;
6408
+ }
6409
+ if (/\.(?:[cm]?[jt]s)$/.test(base)) eslintAstFacts(text, f);
6410
+ else if (/\.ya?ml$/.test(base)) eslintObjectFacts(parseYaml(text), f);
6411
+ else if (base === "package.json") {
6412
+ const pkg = parseJsonc(path, text);
6413
+ if (isRecord(pkg)) eslintObjectFacts(pkg.eslintConfig, f);
6414
+ } else {
6415
+ const j = parseJsonc(path, text);
6416
+ eslintObjectFacts(j !== null ? j : base === ".eslintrc" ? parseYaml(text) : null, f);
6417
+ }
6418
+ return f;
6419
+ }
6420
+ function biomeFacts(path, text) {
6421
+ const f = emptyFacts();
6422
+ if (text === null) return f;
6423
+ const cfg = parseJsonc(path, text);
6424
+ if (!isRecord(cfg)) return f;
6425
+ const readLinter = (linter) => {
6426
+ if (!isRecord(linter)) return;
6427
+ if (linter.enabled === false) f.linterOff = true;
6428
+ f.ignores.push(...strings(linter.ignore));
6429
+ const rules = linter.rules;
6430
+ if (!isRecord(rules)) return;
6431
+ if (rules.recommended === false) f.off.set("recommended", "rules.recommended: false");
6432
+ for (const [group, body] of Object.entries(rules)) {
6433
+ if (!isRecord(body)) continue;
6434
+ if (body.recommended === false) f.off.set(`${group}/recommended`, `${group}.recommended: false`);
6435
+ for (const [rule, level] of Object.entries(body)) {
6436
+ const l = level === "off" || isRecord(level) && level.level === "off" ? "off" : typeof level === "string" || isRecord(level) ? "on" : null;
6437
+ if (l === "off") f.off.set(`${group}/${rule}`, `${group}.${rule}: off`);
6438
+ else if (l === "on") f.on.add(`${group}/${rule}`);
6439
+ }
6440
+ }
6441
+ };
6442
+ readLinter(cfg.linter);
6443
+ if (Array.isArray(cfg.overrides)) {
6444
+ for (const o of cfg.overrides) if (isRecord(o)) readLinter(o.linter);
6445
+ }
6446
+ if (isRecord(cfg.files)) {
6447
+ f.ignores.push(...strings(cfg.files.ignore));
6448
+ f.ignores.push(...strings(cfg.files.includes).filter((p) => p.startsWith("!")));
6449
+ }
6450
+ return f;
6443
6451
  }
6444
- function defaultsOf(lines, i) {
6445
- const run = ancestorOf(lines, i);
6446
- if (run === null || !/^\s*run:\s*$/.test(lines[run])) return null;
6447
- const d = ancestorOf(lines, run);
6448
- return d !== null && /^\s*defaults:\s*$/.test(lines[d]) ? d : null;
6452
+ function replacedByVariant(rule, on) {
6453
+ if (rule.includes("/")) return false;
6454
+ for (const r of on) if (r.endsWith(`/${rule}`)) return true;
6455
+ return false;
6449
6456
  }
6450
- function enclosingStep(lines, i) {
6451
- let h = i;
6452
- while ((h = ancestorOf(lines, h)) !== null) {
6453
- if (!STEP_START.test(lines[h])) continue;
6454
- const ind = indentOf(lines[h]);
6455
- let e = i + 1;
6456
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
6457
- return [h, e];
6457
+ function lintWeakenings(c, facts, priorAll, policy, ctx, out3) {
6458
+ if (c.after === null) return;
6459
+ const a = facts(c.path, c.after);
6460
+ const tool = isBiome(c.path) ? "biome" : "eslint";
6461
+ const push = (message, evidence, remediation) => out3.push(makeFinding(RULE6, policy, { file: c.path, message, evidence, remediation, defaultSeverity: "warn" }));
6462
+ if (a.linterOff && !priorAll.linterOff) {
6463
+ push(`${tool} linter switched off: linter.enabled: false.`, `${c.path}: linter.enabled: false`, "Keep the linter on and fix what it reports.");
6464
+ }
6465
+ for (const [rule, how] of a.off) {
6466
+ if (priorAll.off.has(rule) || replacedByVariant(rule, a.on)) continue;
6467
+ push(
6468
+ `${tool} rule turned off: ${rule}.`,
6469
+ `${c.path}: ${how}`,
6470
+ `Keep ${rule} on and fix what it reports, or scope a justified exception with a reason. A rule turned off in the config is off for every file at once.`
6471
+ );
6472
+ }
6473
+ const dir = dirOf2(c.path);
6474
+ for (const e of grown(priorAll.ignores, a.ignores)) {
6475
+ const hit = hidesSource(e, dir, ctx);
6476
+ if (hit === null) continue;
6477
+ const key2 = isEslintIgnore(c.path) ? ".eslintignore" : tool === "biome" ? "files.ignore" : /\.eslintrc/.test(basename2(c.path)) ? "ignorePatterns" : "ignores";
6478
+ push(
6479
+ `${tool} ${key2} grown: ${JSON.stringify(e)} now hides ${hit === e ? "source" : hit} from the lint.`,
6480
+ `${c.path}: ${key2} += ${JSON.stringify(e)}`,
6481
+ "Keep the lint pointed at the whole source. A path ignored here is never linted, whatever the rules say."
6482
+ );
6458
6483
  }
6459
- return null;
6460
- }
6461
- function ancestorOf(lines, i) {
6462
- const ind = indentOf(lines[i]);
6463
- let h = i - 1;
6464
- while (h >= 0 && (lines[h].trim() === "" || /^\s*#/.test(lines[h]) || indentOf(lines[h]) >= ind)) h--;
6465
- return h >= 0 ? h : null;
6466
6484
  }
6467
- var RULE7, USES, CHECK, YAML_KEY, isCompositeAction, isGithubFile, GH_RUN_KEY, GENERIC_RUN_KEY, invokesCheck2, uncommented, SHELL_ERREXIT, indentOf, STEP_START, REPORTER_ACTION, usesCheck, stepHasCheck, jobHasCheck, NEUTRALISERS, LIST_KEYS, DEFAULT_BRANCH, ALL, CODE_EVENTS, PR_CODE_TYPES, pathsMatchSource, ignoresEverySource, CODE_PROBES, REUSED_WORKFLOW, LOCAL_ACTION, MANIFEST, WORKDIR_LINE, REF_LINE, CHECKOUT, ciTampering;
6468
- var init_ci_tampering = __esm({
6469
- "src/detectors/ci-tampering.ts"() {
6485
+ var RULE6, basename2, isTsconfig, isEslintConfig, isEslintIgnore, isBiome, CODE_FILE, HOUSEKEEPING_DIR, HOUSEKEEPING_FILE, matcher, dirOf2, strings, STRICT_FAMILY, TRUE_IS_STRICT, TRUE_IS_WEAK, isLocalTarget, emptyFacts, configWeakening;
6486
+ var init_config_weakening = __esm({
6487
+ "src/detectors/config-weakening.ts"() {
6470
6488
  "use strict";
6471
- init_select();
6489
+ init_lazy_deps();
6490
+ init_ts_lazy();
6472
6491
  init_policy();
6492
+ init_narrow();
6473
6493
  init_finding();
6474
- init_files();
6475
- init_hook_wiring();
6476
6494
  init_repo();
6477
- init_gh_expression();
6478
- init_invocation();
6479
- init_invocation();
6480
- RULE7 = "ci-tampering";
6481
- USES = /^\s*-\s*uses:/;
6482
- CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
6483
- YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
6484
- isCompositeAction = (path) => /(^|\/)\.github\/actions\/.+\/action\.ya?ml$/.test(path);
6485
- isGithubFile = (path) => /(^|\/)\.github\//.test(path);
6486
- GH_RUN_KEY = /^\s*-?\s*run:/;
6487
- GENERIC_RUN_KEY = /^\s*-?\s*(?:run|script|command|before_script|after_script|sh|bash|pwsh|powershell):/;
6488
- invokesCheck2 = (line) => invokesCheck(line) || INVOKES_TOOL.test(commandCore(line));
6489
- uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
6490
- SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
6491
- indentOf = (l) => l.length - l.trimStart().length;
6492
- STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
6493
- REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
6494
- usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
6495
- stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l));
6496
- jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
6497
- NEUTRALISERS = [
6498
- { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
6499
- { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
6500
- { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
6501
- { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
6502
- ];
6503
- LIST_KEYS = {
6504
- branches: "branches",
6505
- "branches-ignore": "branchesIgnore",
6506
- "paths-ignore": "pathsIgnore",
6507
- paths: "paths",
6508
- types: "types",
6509
- tags: "tags"
6495
+ init_suite_config();
6496
+ RULE6 = "config-weakening";
6497
+ basename2 = (path) => path.split("/").pop() ?? path;
6498
+ isTsconfig = (path) => /^tsconfig.*\.json$/.test(basename2(path));
6499
+ isEslintConfig = (path) => /^(?:\.eslintrc(?:\..*)?|eslint\.config\..*)$/.test(basename2(path));
6500
+ isEslintIgnore = (path) => basename2(path) === ".eslintignore";
6501
+ isBiome = (path) => /^biome\.jsonc?$/.test(basename2(path));
6502
+ CODE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte)$/;
6503
+ HOUSEKEEPING_DIR = /^(?:node_modules|dist|build|out|output|coverage|lib|esm|cjs|umd|target|tmp|temp|vendor|generated|__generated__|public|static|storybook-static|\..+)$/;
6504
+ HOUSEKEEPING_FILE = /(?:\.(?:min\.[cm]?js|d\.[cm]?ts|snap|map|json|jsonc|md|mdx|lock|ya?ml|toml|html?|css|scss|svg|png|jpe?g|gif|txt)|(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|CHANGELOG\.md))$/i;
6505
+ matcher = (patterns) => {
6506
+ const ms = patterns.map((g) => {
6507
+ try {
6508
+ return picomatch(g, { dot: true });
6509
+ } catch {
6510
+ return () => false;
6511
+ }
6512
+ });
6513
+ return (path) => ms.some((m) => m(path));
6510
6514
  };
6511
- DEFAULT_BRANCH = /^(?:main|master)$/;
6512
- ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
6513
- CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
6514
- PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
6515
- pathsMatchSource = (globs4, sources) => sources.some((s) => admits(globs4, s));
6516
- ignoresEverySource = (globs4, sources) => sources.length > 0 && sources.every((s) => admits(globs4, s));
6517
- 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"];
6518
- REUSED_WORKFLOW = /^\s*-?\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
6519
- LOCAL_ACTION = /^\s*-?\s*uses:\s*\.\/(\.github\/actions\/[^\s@]+?)\/?\s*(?:#.*)?$/;
6520
- MANIFEST = /(?:^|\/)(?:package\.json|pyproject\.toml|setup\.py|setup\.cfg|Cargo\.toml|go\.mod|Makefile|build\.gradle(?:\.kts)?|pom\.xml|Gemfile|deno\.jsonc?)$/;
6521
- WORKDIR_LINE = /^\s*-?\s*working-directory:\s*(.+?)\s*$/;
6522
- REF_LINE = /^\s*ref:\s*(.+?)\s*$/;
6523
- CHECKOUT = /^\s*-?\s*uses:\s*actions\/checkout(?:@|\s|$)/;
6524
- ciTampering = {
6525
- id: RULE7,
6515
+ dirOf2 = (path) => path.includes("/") ? path.slice(0, path.lastIndexOf("/") + 1) : "";
6516
+ strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : typeof v === "string" ? [v] : [];
6517
+ STRICT_FAMILY = /* @__PURE__ */ new Set([
6518
+ "noImplicitAny",
6519
+ "strictNullChecks",
6520
+ "strictFunctionTypes",
6521
+ "strictBindCallApply",
6522
+ "strictPropertyInitialization",
6523
+ "noImplicitThis",
6524
+ "alwaysStrict",
6525
+ "useUnknownInCatchVariables",
6526
+ "strictBuiltinIteratorReturn"
6527
+ ]);
6528
+ TRUE_IS_STRICT = [
6529
+ "strict",
6530
+ ...STRICT_FAMILY,
6531
+ "noUnusedLocals",
6532
+ "noUnusedParameters",
6533
+ "noImplicitReturns",
6534
+ "noFallthroughCasesInSwitch",
6535
+ "noUncheckedIndexedAccess",
6536
+ "noImplicitOverride",
6537
+ "noPropertyAccessFromIndexSignature",
6538
+ "exactOptionalPropertyTypes",
6539
+ "checkJs",
6540
+ "forceConsistentCasingInFileNames"
6541
+ ];
6542
+ TRUE_IS_WEAK = ["skipLibCheck", "allowUnreachableCode", "allowUnusedLabels", "suppressImplicitAnyIndexErrors", "suppressExcessPropertyErrors", "noStrictGenericChecks"];
6543
+ isLocalTarget = (s) => /^(?:\.\.?\/|\/)/.test(s);
6544
+ emptyFacts = () => ({ off: /* @__PURE__ */ new Map(), on: /* @__PURE__ */ new Set(), ignores: [], linterOff: false });
6545
+ configWeakening = {
6546
+ id: RULE6,
6526
6547
  surface: ["file"],
6527
6548
  certainty: "mechanical",
6528
6549
  run(changes, policy, _view, ctx) {
6529
6550
  const out3 = [];
6530
- const afterByPath = /* @__PURE__ */ new Map();
6531
- for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
6532
- let triggerOpts = null;
6533
- for (const c of changes) {
6534
- if (c.kind !== "file") continue;
6535
- if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
6536
- out3.push(
6537
- makeFinding(RULE7, policy, {
6538
- file: c.path,
6539
- message: `A CI workflow was renamed so it no longer runs (${c.oldPath} \u2192 ${c.path}).`,
6540
- evidence: `${c.oldPath} \u2192 ${c.path}`,
6541
- remediation: "Restore the path and extension. GitHub runs only *.yml/*.yaml directly under .github/workflows \u2014 renaming outside that disables the checks as surely as deleting them."
6542
- })
6543
- );
6544
- continue;
6545
- }
6546
- if (!isProtected(c.path, policy, "ci")) continue;
6547
- const afterLines = c.after != null ? c.after.split("\n") : null;
6548
- const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
6549
- const github = isGithubFile(c.path);
6550
- for (const l of github ? addedLines(c) : []) {
6551
- const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
6552
- const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
6553
- if (!coe && !cond) continue;
6554
- const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
6555
- if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
6556
- if (afterLines && !step && l.newLine != null) {
6557
- const job = jobOf(afterLines, l.newLine - 1);
6558
- if (job && !jobHasCheck(afterLines, job)) continue;
6559
- }
6560
- if (coe && isTruthy(coe[1])) {
6561
- out3.push(
6562
- makeFinding(RULE7, policy, {
6563
- file: c.path,
6564
- line: l.newLine ?? void 0,
6565
- message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
6566
- evidence: l.content.trim(),
6567
- remediation: "Remove it. A check that cannot fail is not a check."
6568
- })
6569
- );
6570
- } else if (cond && isAlwaysFalse(cond[1])) {
6571
- out3.push(
6572
- makeFinding(RULE7, policy, {
6573
- file: c.path,
6574
- line: l.newLine ?? void 0,
6575
- message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
6576
- evidence: l.content.trim(),
6577
- remediation: "Re-enable the step instead of conditioning it off."
6578
- })
6579
- );
6580
- }
6581
- }
6582
- if (afterLines && github) {
6583
- for (const b of runBlocks(afterLines)) {
6584
- if (!b.body.some(invokesCheck2)) continue;
6585
- const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
6586
- for (let k = 0; k < b.body.length; k++) {
6587
- const idx = b.start + 1 + k;
6588
- if (!addedAt.has(idx + 1)) continue;
6589
- const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
6590
- if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
6591
- out3.push(
6592
- makeFinding(RULE7, policy, {
6593
- file: c.path,
6594
- line: idx + 1,
6595
- message: `A check's run block was neutralised: ${n.what}.`,
6596
- evidence: b.body[k].trim(),
6597
- remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
6598
- })
6599
- );
6600
- }
6601
- }
6602
- for (const l of addedLines(c)) {
6603
- if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
6604
- if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
6605
- const step = stepOf(afterLines, l.newLine - 1);
6606
- if (step && !stepHasCheck(afterLines, step)) continue;
6607
- out3.push(
6608
- makeFinding(RULE7, policy, {
6609
- file: c.path,
6610
- line: l.newLine,
6611
- message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
6612
- evidence: l.content.trim(),
6613
- remediation: "Keep the default shell (fail-fast) on steps that run checks."
6614
- })
6615
- );
6616
- }
6617
- for (const l of addedLines(c)) {
6618
- if (l.newLine == null) continue;
6619
- const i = l.newLine - 1;
6620
- const wd = l.content.match(WORKDIR_LINE);
6621
- if (wd) {
6622
- const dir = literalValue(wd[1]);
6623
- if (dir === null || !dirRunsNothing(dir, ctx)) continue;
6624
- const step2 = stepOf(afterLines, i);
6625
- if (step2) {
6626
- if (!stepHasCheck(afterLines, step2)) continue;
6627
- } else {
6628
- const d = defaultsOf(afterLines, i);
6629
- if (d === null) continue;
6630
- const job = jobOf(afterLines, d);
6631
- if (job ? !jobHasCheck(afterLines, job) : !afterLines.some((x) => invokesCheck2(x) || usesCheck(x))) continue;
6632
- }
6633
- out3.push(
6634
- makeFinding(RULE7, policy, {
6635
- file: c.path,
6636
- line: l.newLine,
6637
- message: `A check was pointed at ${dir} by working-directory \u2014 a directory holding no code or package the check could run.`,
6638
- evidence: l.content.trim(),
6639
- remediation: "Run the check where the code is. A suite pointed at an empty directory passes on nothing."
6640
- })
6641
- );
6642
- continue;
6643
- }
6644
- const ref = l.content.match(REF_LINE);
6645
- if (!ref) continue;
6646
- const step = enclosingStep(afterLines, i);
6647
- if (!step || !afterLines.slice(step[0], step[1]).some((x) => CHECKOUT.test(x))) continue;
6648
- const target = literalValue(ref[1]);
6649
- if (target === null) continue;
6650
- out3.push(
6651
- makeFinding(RULE7, policy, {
6652
- file: c.path,
6653
- line: l.newLine,
6654
- message: `actions/checkout was pinned to ref: ${target} \u2014 the checks run against that ref, not against the change under review.`,
6655
- evidence: l.content.trim(),
6656
- remediation: "Let checkout take the event's ref (its default). A suite that checks out main proves nothing about the pull request."
6657
- })
6658
- );
6659
- }
6660
- if (c.before != null && c.after != null && !isCompositeAction(c.path)) {
6661
- triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
6662
- for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
6663
- out3.push(
6664
- makeFinding(RULE7, policy, {
6665
- file: c.path,
6666
- message: `The workflow's triggers were narrowed: ${reason}.`,
6667
- evidence: reason,
6668
- 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."
6669
- })
6670
- );
6671
- }
6672
- }
6673
- }
6674
- const afterCommands = afterLines ? commandLines(afterLines) : null;
6675
- const afterCores = (afterCommands ?? addedLines(c).map((l) => l.content)).map(commandCore);
6676
- const addedCores = addedLines(c).map((l) => commandCore(afterCommands && l.newLine != null ? afterCommands[l.newLine - 1] : l.content));
6677
- for (const l of afterLines ?? []) {
6678
- for (const reused of localUses(l)) {
6679
- const content = afterByPath.get(reused);
6680
- if (content == null || reused === c.path) continue;
6681
- const cores = commandLines(content.split("\n")).map(commandCore);
6682
- afterCores.push(...cores);
6683
- addedCores.push(...cores);
6684
- }
6685
- }
6686
- if (!github && c.after == null) {
6687
- for (const o of changes) {
6688
- if (o.kind !== "file" || o.op !== "add" || o.after == null || !isProtected(o.path, policy, "ci")) continue;
6689
- const cores = commandLines(o.after.split("\n")).map(commandCore);
6690
- afterCores.push(...cores);
6691
- addedCores.push(...cores);
6692
- }
6551
+ const files = changes.filter((c) => c.kind === "file");
6552
+ const addedPaths = new Set(files.filter((c) => c.op === "add").map((c) => c.path));
6553
+ const tsconfigs = files.filter((c) => isTsconfig(c.path) && isProtected(c.path, policy, "config"));
6554
+ const eslints = files.filter((c) => (isEslintConfig(c.path) || isEslintIgnore(c.path)) && isProtected(c.path, policy, "config"));
6555
+ const biomes = files.filter((c) => isBiome(c.path) && isProtected(c.path, policy, "config"));
6556
+ const priorOf = (list3, facts) => {
6557
+ const all = emptyFacts();
6558
+ for (const c of list3) {
6559
+ const f = facts(c.path, c.before);
6560
+ for (const [k, v] of f.off) all.off.set(k, v);
6561
+ for (const k of f.on) all.on.add(k);
6562
+ all.ignores.push(...f.ignores);
6563
+ if (f.linterOff) all.linterOff = true;
6693
6564
  }
6694
- const runKey = github ? GH_RUN_KEY : GENERIC_RUN_KEY;
6695
- for (const l of removedLines(c)) {
6696
- if (/^\s*#/.test(l.content)) continue;
6697
- const isUsesLine = usesCheck(l.content);
6698
- const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? runKey.test(l.content) : true) && invokesCheck2(l.content);
6699
- if (!isUsesLine && !isCheckCommand) continue;
6700
- const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
6701
- if (s.state === "kept") continue;
6702
- if (s.state === "neutralised") {
6703
- out3.push(
6704
- makeFinding(RULE7, policy, {
6705
- file: c.path,
6706
- message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
6707
- evidence: `${l.content.trim()} \u2192 ${s.by}`,
6708
- remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
6709
- })
6710
- );
6711
- continue;
6712
- }
6565
+ return all;
6566
+ };
6567
+ const eslintPrior = priorOf(eslints, eslintFacts);
6568
+ const biomePrior = priorOf(biomes, biomeFacts);
6569
+ for (const c of tsconfigs) tsconfigWeakenings(c, tsconfigs, policy, addedPaths, ctx, out3);
6570
+ for (const c of eslints) lintWeakenings(c, eslintFacts, eslintPrior, policy, ctx, out3);
6571
+ for (const c of biomes) lintWeakenings(c, biomeFacts, biomePrior, policy, ctx, out3);
6572
+ for (const c of files) {
6573
+ if (c.after === null || !isProtected(c.path, policy, "config")) continue;
6574
+ const rn = runnerOf(c.path, c.after) ?? runnerOf(c.path, c.before);
6575
+ if (rn !== "jest" && rn !== "vitest") continue;
6576
+ for (const e of newSetupEntries(c.before, c.after, c.path)) {
6577
+ const cands = targetCandidates(c.path, e.target);
6578
+ const added = cands.find((f) => addedPaths.has(f));
6579
+ if (added === void 0 && cands.some((f) => isProtected(f, policy))) continue;
6713
6580
  out3.push(
6714
- makeFinding(RULE7, policy, {
6581
+ makeFinding(RULE6, policy, {
6715
6582
  file: c.path,
6716
- message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
6717
- evidence: l.content.trim(),
6718
- remediation: "Restore the step. Removing the check that protects main is itself the tamper."
6583
+ message: `The runner now loads ${e.target} through ${e.key}, ${added !== void 0 ? `a file this change adds (${added})` : "a file no protected glob covers"}: it runs with the suite's globals in reach.`,
6584
+ evidence: `${c.path}: ${e.key} += ${JSON.stringify(e.target)}`,
6585
+ remediation: "Review the module the runner will execute around every test, and protect it (protected.config in .tamperward.yml) so later edits to it are gated too.",
6586
+ defaultSeverity: "warn"
6719
6587
  })
6720
6588
  );
6721
6589
  }
@@ -6726,444 +6594,1130 @@ var init_ci_tampering = __esm({
6726
6594
  }
6727
6595
  });
6728
6596
 
6729
- // src/detectors/suite-config.ts
6730
- function runnerSkips(path) {
6731
- const parts = path.split("/");
6732
- if (/\.rs$/.test(path)) {
6733
- const i = parts.indexOf("tests");
6734
- if (i < 0 || parts.slice(0, i).includes("src")) return null;
6735
- const rest = parts.slice(i + 1);
6736
- if (rest.length === 1 || rest.length === 2 && rest[1] === "main.rs") return null;
6737
- return "cargo builds only tests/*.rs and tests/*/main.rs as test targets";
6597
+ // src/detectors/coverage-exclusion.ts
6598
+ function isText(line, idx, lang) {
6599
+ const opener = LINE_COMMENT[lang];
6600
+ let quote = null;
6601
+ for (let j = 0; j < idx; j++) {
6602
+ const ch = line[j];
6603
+ if (quote) {
6604
+ if (ch === "\\") j++;
6605
+ else if (ch === quote) quote = null;
6606
+ continue;
6607
+ }
6608
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
6609
+ else if (opener && opener.test(line.slice(j, j + 2))) return true;
6610
+ }
6611
+ return quote !== null;
6612
+ }
6613
+ var RULE7, PATTERNS3, OUT_OF_SCOPE_PATH, LINE_COMMENT, coverageExclusion;
6614
+ var init_coverage_exclusion = __esm({
6615
+ "src/detectors/coverage-exclusion.ts"() {
6616
+ "use strict";
6617
+ init_select();
6618
+ init_files();
6619
+ init_policy();
6620
+ init_finding();
6621
+ RULE7 = "coverage-exclusion";
6622
+ PATTERNS3 = {
6623
+ js: [
6624
+ { re: /\/\*\s*istanbul\s+ignore\b/, why: "`istanbul ignore` takes the next statement, branch or the whole file out of the coverage measurement" },
6625
+ { re: /\/\*\s*c8\s+ignore\b(?!\s*stop\b)/, why: "`c8 ignore` takes the next statement or the marked range out of the coverage measurement" },
6626
+ { re: /\/\*\s*v8\s+ignore\b(?!\s*stop\b)/, why: "`v8 ignore` takes the next statement or the marked range out of the coverage measurement" },
6627
+ { re: /\/\*\s*node:coverage\s+(?:ignore|disable)\b/, why: "`node:coverage ignore/disable` takes the following code out of the coverage measurement" }
6628
+ ],
6629
+ py: [{ re: /#\s*pragma[:\s]\s*no\s*cover\b/i, why: "`# pragma: no cover` takes the line or block out of the coverage measurement" }],
6630
+ rs: [{ re: /#\s*!?\[[^\]]*\bcoverage\s*\(\s*off\s*\)/, why: "`#[coverage(off)]` takes the item out of the coverage measurement" }],
6631
+ go: [
6632
+ {
6633
+ re: /^\s*\/\/\s*(?:go:build\b|\+build\b)/,
6634
+ why: "a build constraint added to an existing file takes it out of the default build, and out of `go test -cover`",
6635
+ existingOnly: true
6636
+ }
6637
+ ]
6638
+ };
6639
+ OUT_OF_SCOPE_PATH = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build|out|coverage|generated|__generated__|codegen|examples?|docs?|scripts?|fixtures?|__fixtures__|__mocks__)\/|\.d\.[cm]?ts$|\.(?:generated|gen|pb)\.[^/]*$/;
6640
+ LINE_COMMENT = { js: /^\/\//, go: /^\/\//, rs: /^\/\//, py: /^#/ };
6641
+ coverageExclusion = {
6642
+ id: RULE7,
6643
+ surface: ["file"],
6644
+ certainty: "mechanical",
6645
+ run(changes, policy) {
6646
+ const out3 = [];
6647
+ for (const c of changes) {
6648
+ if (c.kind !== "file" || c.op === "delete") continue;
6649
+ const lang = langOf(c.path);
6650
+ if (!lang) continue;
6651
+ const patterns = PATTERNS3[lang];
6652
+ if (!patterns) continue;
6653
+ const category = protectedCategory(c.path, policy);
6654
+ if (OUT_OF_SCOPE_PATH.test(c.path) || category === "tests" || category === "config") continue;
6655
+ const removed = new Set(removedLines(c).map((l) => l.content.trim()));
6656
+ const before = c.before ?? "";
6657
+ for (const l of addedLines(c)) {
6658
+ const trimmed = l.content.trim();
6659
+ if (removed.has(trimmed)) continue;
6660
+ for (const p of patterns) {
6661
+ const m = p.re.exec(l.content);
6662
+ if (!m || isText(l.content, m.index, lang)) continue;
6663
+ if (p.existingOnly && (c.op === "add" || c.before == null || before.split("\n").some((b) => p.re.test(b)))) continue;
6664
+ out3.push(
6665
+ makeFinding(RULE7, policy, {
6666
+ file: c.path,
6667
+ line: l.newLine ?? void 0,
6668
+ message: `Coverage exclusion added: ${p.why}.`,
6669
+ evidence: trimmed,
6670
+ remediation: "Cover the code with a test instead of excluding it from the measurement; if it is genuinely unreachable, say so in review.",
6671
+ defaultSeverity: "warn"
6672
+ })
6673
+ );
6674
+ break;
6675
+ }
6676
+ }
6677
+ }
6678
+ return out3;
6679
+ }
6680
+ };
6681
+ }
6682
+ });
6683
+
6684
+ // src/detectors/gh-expression.ts
6685
+ function foldExpressions(s) {
6686
+ return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
6687
+ const v = foldConst(e.trim());
6688
+ if (v !== void 0 && v !== TRUTHY) return String(v);
6689
+ return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
6690
+ });
6691
+ }
6692
+ function lex(src) {
6693
+ const out3 = [];
6694
+ let i = 0;
6695
+ while (i < src.length) {
6696
+ const ch = src[i];
6697
+ if (/\s/.test(ch)) {
6698
+ i++;
6699
+ continue;
6700
+ }
6701
+ if (ch === "'") {
6702
+ let j = i + 1;
6703
+ let s = "";
6704
+ while (j < src.length) {
6705
+ if (src[j] === "'" && src[j + 1] === "'") {
6706
+ s += "'";
6707
+ j += 2;
6708
+ } else if (src[j] === "'") break;
6709
+ else s += src[j++];
6710
+ }
6711
+ if (j >= src.length) return null;
6712
+ out3.push({ t: "str", v: s });
6713
+ i = j + 1;
6714
+ continue;
6715
+ }
6716
+ const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
6717
+ if (num) {
6718
+ out3.push({ t: "num", v: num[0] });
6719
+ i += num[0].length;
6720
+ continue;
6721
+ }
6722
+ const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
6723
+ if (op) {
6724
+ out3.push({ t: "op", v: op[0] });
6725
+ i += op[0].length;
6726
+ continue;
6727
+ }
6728
+ const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
6729
+ if (id) {
6730
+ out3.push({ t: "id", v: id[0] });
6731
+ i += id[0].length;
6732
+ continue;
6733
+ }
6734
+ return null;
6735
+ }
6736
+ return out3;
6737
+ }
6738
+ function foldConst(src) {
6739
+ const toks = lex(src);
6740
+ if (!toks) return void 0;
6741
+ let p = 0;
6742
+ const peek = () => toks[p];
6743
+ const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
6744
+ const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
6745
+ const eq = (a, b) => {
6746
+ if (a === TRUTHY || b === TRUTHY) return void 0;
6747
+ if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
6748
+ if (a === null && b === null) return true;
6749
+ const x = num(a);
6750
+ const y2 = num(b);
6751
+ return !Number.isNaN(x) && x === y2;
6752
+ };
6753
+ const or = () => {
6754
+ let l = and();
6755
+ while (eat("||")) {
6756
+ const r = and();
6757
+ if (l !== void 0 && truthy(l)) continue;
6758
+ if (l !== void 0) l = r;
6759
+ else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
6760
+ }
6761
+ return l;
6762
+ };
6763
+ const and = () => {
6764
+ let l = cmp();
6765
+ while (eat("&&")) {
6766
+ const r = cmp();
6767
+ if (l !== void 0 && !truthy(l)) continue;
6768
+ if (l !== void 0) l = r;
6769
+ else l = r !== void 0 && !truthy(r) ? false : void 0;
6770
+ }
6771
+ return l;
6772
+ };
6773
+ const cmp = () => {
6774
+ let l = unary();
6775
+ for (; ; ) {
6776
+ const t = peek();
6777
+ if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
6778
+ p++;
6779
+ const r = unary();
6780
+ if (l === void 0 || r === void 0) {
6781
+ l = void 0;
6782
+ continue;
6783
+ }
6784
+ if (t.v === "==" || t.v === "!=") {
6785
+ const e = eq(l, r);
6786
+ l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
6787
+ } else {
6788
+ const x = num(l);
6789
+ const y2 = num(r);
6790
+ if (Number.isNaN(x) || Number.isNaN(y2)) l = void 0;
6791
+ else l = t.v === "<" ? x < y2 : t.v === ">" ? x > y2 : t.v === "<=" ? x <= y2 : x >= y2;
6792
+ }
6793
+ }
6794
+ };
6795
+ const unary = () => {
6796
+ if (eat("!")) {
6797
+ const v2 = unary();
6798
+ return v2 === void 0 ? void 0 : !truthy(v2);
6799
+ }
6800
+ return primary();
6801
+ };
6802
+ const primary = () => {
6803
+ const t = peek();
6804
+ if (!t) return void 0;
6805
+ if (eat("(")) {
6806
+ const v2 = or();
6807
+ return eat(")") ? v2 : void 0;
6808
+ }
6809
+ p++;
6810
+ if (t.t === "str") return t.v;
6811
+ if (t.t === "num") return Number(t.v);
6812
+ if (t.t === "id") {
6813
+ if (/^true$/i.test(t.v)) return true;
6814
+ if (/^false$/i.test(t.v)) return false;
6815
+ if (/^null$/i.test(t.v)) return null;
6816
+ if (eat("(")) {
6817
+ const args = [];
6818
+ if (!eat(")")) {
6819
+ for (; ; ) {
6820
+ args.push(or());
6821
+ if (eat(")")) break;
6822
+ if (!eat(",")) return void 0;
6823
+ }
6824
+ }
6825
+ let indexed = false;
6826
+ while (eat("[")) {
6827
+ or();
6828
+ indexed = true;
6829
+ if (!eat("]")) return void 0;
6830
+ }
6831
+ if (indexed) return void 0;
6832
+ return callConst(t.v, args);
6833
+ }
6834
+ while (eat("[")) {
6835
+ or();
6836
+ if (!eat("]")) return void 0;
6837
+ }
6838
+ return void 0;
6839
+ }
6840
+ return void 0;
6841
+ };
6842
+ const v = or();
6843
+ return p === toks.length ? v : void 0;
6844
+ }
6845
+ function callConst(name, args) {
6846
+ const vals = [];
6847
+ for (const a of args) {
6848
+ if (a === void 0 || a === TRUTHY) return void 0;
6849
+ vals.push(a);
6850
+ }
6851
+ const s = (i) => vals[i] == null ? "" : String(vals[i]);
6852
+ switch (name.toLowerCase()) {
6853
+ case "contains":
6854
+ return vals.length === 2 ? s(0).toLowerCase().includes(s(1).toLowerCase()) : void 0;
6855
+ case "startswith":
6856
+ return vals.length === 2 ? s(0).toLowerCase().startsWith(s(1).toLowerCase()) : void 0;
6857
+ case "endswith":
6858
+ return vals.length === 2 ? s(0).toLowerCase().endsWith(s(1).toLowerCase()) : void 0;
6859
+ case "format": {
6860
+ if (vals.length < 1) return void 0;
6861
+ return s(0).replace(/\{\{|\}\}|\{(\d+)\}/g, (m, i) => m === "{{" ? "{" : m === "}}" ? "}" : s(Number(i) + 1));
6862
+ }
6863
+ case "join":
6864
+ return vals.length >= 1 && vals.length <= 2 ? s(0) : void 0;
6865
+ case "tojson":
6866
+ return vals.length === 1 ? JSON.stringify(vals[0]) : void 0;
6867
+ case "fromjson": {
6868
+ if (vals.length !== 1) return void 0;
6869
+ try {
6870
+ const v = JSON.parse(s(0));
6871
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
6872
+ return TRUTHY;
6873
+ } catch {
6874
+ return void 0;
6875
+ }
6876
+ }
6877
+ default:
6878
+ return void 0;
6879
+ }
6880
+ }
6881
+ var EXPR_TOKEN, MATRIX_TOKEN, TRUTHY, truthy;
6882
+ var init_gh_expression = __esm({
6883
+ "src/detectors/gh-expression.ts"() {
6884
+ "use strict";
6885
+ EXPR_TOKEN = "__expr__";
6886
+ MATRIX_TOKEN = "__matrix__";
6887
+ TRUTHY = /* @__PURE__ */ Symbol("truthy");
6888
+ truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
6889
+ }
6890
+ });
6891
+
6892
+ // src/detectors/invocation.ts
6893
+ function canonical(core) {
6894
+ let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
6895
+ s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
6896
+ s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
6897
+ s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
6898
+ s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
6899
+ s = s.replace(/^exec\s+/, "");
6900
+ return s.replace(/\s+--\s*$/, "").trim();
6901
+ }
6902
+ function runnerNarrowed(cmd, args) {
6903
+ return runnerNarrowing(cmd, args) !== null;
6904
+ }
6905
+ function runnerNarrowing(cmd, args) {
6906
+ const t = RUNNER_NARROWINGS.find((x) => x.runner.test(cmd));
6907
+ const m = t ? (" " + args.trim()).match(t.flags) : null;
6908
+ return m ? m[0].trim().replace(/[=\s].*$/, "") : null;
6909
+ }
6910
+ function coverageFloorLowered(removed, added) {
6911
+ const was = removed.match(COVERAGE_FLOOR);
6912
+ const now = added.match(COVERAGE_FLOOR);
6913
+ return was !== null && now !== null && Number(now[1]) < Number(was[1]);
6914
+ }
6915
+ function inCommandPosition(core) {
6916
+ return canonical(core).split(/\s*(?:&&|\|\||;|\|)\s*/).some((seg) => CHECK_HEAD.test(canonical(seg.replace(COMMAND_PREFIX, ""))));
6917
+ }
6918
+ function narrowedByPositional(args) {
6919
+ return positionalOf(args) !== null;
6920
+ }
6921
+ function positionalOf(args) {
6922
+ const toks = args.trim().split(/\s+/).filter(Boolean);
6923
+ for (let i = 0; i < toks.length; i++) {
6924
+ const t = toks[i];
6925
+ if (t === "--" || t.startsWith("-")) continue;
6926
+ const prev = toks[i - 1];
6927
+ if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
6928
+ if (/[<>|;&]/.test(t)) continue;
6929
+ if (PATH_SHAPED.test(t)) return t;
6930
+ }
6931
+ return null;
6932
+ }
6933
+ function checkKind(core) {
6934
+ if (!invokesCheck(core)) return null;
6935
+ const s = canonical(core);
6936
+ if (/\btamperward\b/.test(s)) return "gate";
6937
+ if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
6938
+ if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
6939
+ return "test";
6940
+ }
6941
+ function survives(removedCore, isUses, afterCores, addedCores) {
6942
+ if (isUses) {
6943
+ const ref = usesRef(removedCore);
6944
+ return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
6945
+ }
6946
+ const r = canonical(removedCore);
6947
+ const kind = checkKind(removedCore);
6948
+ const narrowed = (args) => kind === "test" && narrowedByPositional(args);
6949
+ let neutralised;
6950
+ for (const raw of afterCores) {
6951
+ const a = canonical(raw);
6952
+ if (a === r) return { state: "kept" };
6953
+ if (a.startsWith(r + " ")) {
6954
+ const rest = a.slice(r.length);
6955
+ if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest) || runnerNarrowed(a, rest)) neutralised ??= raw;
6956
+ else return { state: "kept" };
6957
+ } else if (r.startsWith(a + " ") && invokesCheck(a)) {
6958
+ return { state: "kept" };
6959
+ } else {
6960
+ const w = a.match(TIMEOUT_WRAP);
6961
+ if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
6962
+ }
6963
+ }
6964
+ const maskOf = (args) => (" " + args).match(NEUTRALISING_SUFFIX)?.[0]?.trim() ?? null;
6965
+ const priorMask = r.includes(" ") ? maskOf(r.slice(r.indexOf(" ") + 1)) : null;
6966
+ let respelled = false;
6967
+ for (const raw of addedCores) {
6968
+ if (!kind || checkKind(raw) !== kind) continue;
6969
+ const a = canonical(raw);
6970
+ if (a.match(TIMEOUT_WRAP)) {
6971
+ neutralised ??= raw;
6972
+ continue;
6973
+ }
6974
+ if (!inCommandPosition(raw)) continue;
6975
+ if (coverageFloorLowered(r, a)) {
6976
+ neutralised ??= raw;
6977
+ continue;
6978
+ }
6979
+ const args = a.slice(a.indexOf(" ") + 1 || a.length);
6980
+ const mask = maskOf(args);
6981
+ const newMask = mask !== null && mask !== priorMask;
6982
+ if (a.includes(" ") && (newMask || narrowed(args) || runnerNarrowed(a, args))) neutralised ??= raw;
6983
+ else respelled = true;
6984
+ }
6985
+ if (respelled) return { state: "kept" };
6986
+ return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
6987
+ }
6988
+ function unwrap(seg) {
6989
+ const s = seg.trim().replace(PYTHON_M, "").replace(NODE_BIN, "$1").replace(BIN_OF, "$1$2");
6990
+ if (invokesCheck(s) || PRINTER.test(s) || /^["']/.test(s)) return s;
6991
+ const toks = s.split(/\s+/);
6992
+ for (let i = 1; i < toks.length && i <= 16; i++) {
6993
+ const rest = toks.slice(i).join(" ");
6994
+ if (invokesCheck(rest)) return rest;
6995
+ }
6996
+ return s;
6997
+ }
6998
+ function scriptKind3(inner) {
6999
+ const kind = checkKind(inner);
7000
+ if (!kind) return null;
7001
+ const head = canonical(inner).match(/^(tests?|lint|type-?check|coverage)(?=[\s:]|$)/i)?.[1];
7002
+ if (!head) return kind;
7003
+ return /^type/i.test(head) ? "types" : /^lint/i.test(head) ? "lint" : "test";
7004
+ }
7005
+ function runnerOfSegment(canon) {
7006
+ const m = canon.match(RUNNER_NAME);
7007
+ if (!m) return null;
7008
+ const n = m[1];
7009
+ return n === "jest" || n === "vitest" || n === "mocha" || n === "pytest" || n === "go" || n === "cargo" ? n : "node";
7010
+ }
7011
+ function statements(raw) {
7012
+ const out3 = [];
7013
+ let single = false;
7014
+ let double = false;
7015
+ let last = 0;
7016
+ for (let i = 0; i < raw.length; i++) {
7017
+ const ch = raw[i];
7018
+ if (ch === "\\" && !single) {
7019
+ i++;
7020
+ continue;
7021
+ }
7022
+ if (ch === "'" && !double) {
7023
+ single = !single;
7024
+ continue;
7025
+ }
7026
+ if (ch === '"' && !single) {
7027
+ double = !double;
7028
+ continue;
7029
+ }
7030
+ if (single || double) continue;
7031
+ const prev = raw[i - 1];
7032
+ const next = raw[i + 1];
7033
+ let op = null;
7034
+ if (ch === "&" && next === "&") op = "&&";
7035
+ else if (ch === "|" && next === "|") op = "||";
7036
+ else if (ch === "|" && next === "&") op = "|&";
7037
+ else if (ch === ";" || ch === "\n") op = ";";
7038
+ else if (ch === "|" && prev !== ">") op = "|";
7039
+ else if (ch === "&" && prev !== ">" && next !== ">") op = "&";
7040
+ if (!op) continue;
7041
+ out3.push({ seg: raw.slice(last, i).trim(), op });
7042
+ i += op.length - 1;
7043
+ last = i + 1;
7044
+ }
7045
+ out3.push({ seg: raw.slice(last).trim(), op: null });
7046
+ return out3.filter((s) => s.seg !== "");
7047
+ }
7048
+ function descriptors(cmd, runner, opts) {
7049
+ const out3 = [];
7050
+ const stmts = statements(cmd);
7051
+ for (let i = 0; i < stmts.length; i++) {
7052
+ const { seg, op } = stmts[i];
7053
+ const wrapped = seg.match(TIMEOUT_WRAP);
7054
+ const inner = unwrap(wrapped ? wrapped[1] : seg);
7055
+ if (!invokesCheck(inner)) continue;
7056
+ const kind = scriptKind3(inner);
7057
+ const canon = canonical(inner);
7058
+ const rn = runner ?? runnerOfSegment(canon);
7059
+ const args = canon.includes(" ") ? " " + canon.slice(canon.indexOf(" ") + 1) : " ";
7060
+ if (wrapped) out3.push({ key: "timeout", what: "a timeout wrapper", direction: "added" });
7061
+ const following = stmts[i + 1]?.seg ?? "";
7062
+ if (op === "||" || op === "|" || op === "&" || op === ";" && !invokesCheck(unwrap(following))) {
7063
+ out3.push({ key: `op:${op}`, what: `${op} ${following.split(/\s+/).slice(0, 2).join(" ")}`.trim(), direction: "added" });
7064
+ }
7065
+ for (const re of MASKING_FLAGS) {
7066
+ const f = spelled(re, args);
7067
+ if (f) out3.push({ key: `flag:${f}`, what: f, direction: "added" });
7068
+ }
7069
+ for (const re of GATE_FLAGS) {
7070
+ const f = spelled(re, " " + seg);
7071
+ if (f) out3.push({ key: `gate:${f}`, what: f, direction: "removed" });
7072
+ }
7073
+ if (kind !== "test" || opts.sliceByDesign || opts.extended?.has(kind)) continue;
7074
+ const flags = [SUITE_NARROWING_FLAGS, ...NARROWING_BY_RUNNER["*"], ...rn ? NARROWING_BY_RUNNER[rn] : []];
7075
+ for (const re of flags) {
7076
+ const f = spelled(re, args);
7077
+ if (f && !opts.excuseFlag?.(f, canon)) out3.push({ key: `flag:${f}`, what: f, direction: "added" });
7078
+ }
7079
+ if (rn === "cargo" && CARGO_SKIP.test(args)) out3.push({ key: "flag:-- --skip", what: "-- --skip", direction: "added" });
7080
+ const shared = runnerNarrowing(canon, args);
7081
+ if (shared && !opts.excuseFlag?.(shared, canon)) out3.push({ key: `flag:${shared}`, what: shared, direction: "added" });
7082
+ if (opts.fresh) continue;
7083
+ const cfg = args.match(CONFIG_FLAG);
7084
+ if (cfg && !opts.configReviewed?.(cfg[3])) out3.push({ key: `config:${cfg[3]}`, what: `${cfg[1]} ${cfg[3]}`, direction: "added" });
7085
+ const pos = positionalOf(args);
7086
+ if (pos) out3.push({ key: "positional", what: `a path positional (${pos})`, direction: "added" });
6738
7087
  }
6739
- if (/\.go$/.test(path)) {
6740
- const hit = parts.find((p) => p === "testdata" || p.startsWith("_") || p.startsWith("."));
6741
- if (hit === void 0) return null;
6742
- return `go test skips ${hit === "testdata" ? "testdata/" : hit.startsWith("_") ? "_*" : ".*"} paths (${JSON.stringify(hit)})`;
7088
+ return out3;
7089
+ }
7090
+ function checkStatements(cmd) {
7091
+ const out3 = [];
7092
+ for (const seg of segments(cmd)) {
7093
+ const w = seg.match(TIMEOUT_WRAP);
7094
+ const inner = unwrap(w ? w[1] : seg);
7095
+ const kind = scriptKind3(inner);
7096
+ if (kind) out3.push({ kind, canon: canonical(inner) });
6743
7097
  }
6744
- if (/\.py$/.test(path)) {
6745
- const hit = PYTEST_DEFAULT_IGNORE.find((g) => globs2([g])(path));
6746
- return hit === void 0 ? null : `pytest's default norecursedirs matches it (${JSON.stringify(hit)})`;
7098
+ return out3;
7099
+ }
7100
+ function checkKinds(cmd) {
7101
+ return new Set(checkStatements(cmd).map((s) => s.kind));
7102
+ }
7103
+ function failsLoudly(cmd) {
7104
+ const last = segments(cmd).pop();
7105
+ return last !== void 0 && FAILS_LOUDLY.test(last.trim());
7106
+ }
7107
+ function invocationWeakening(before, after, runner, opts = {}) {
7108
+ const was = checkKinds(before);
7109
+ const now = checkKinds(after);
7110
+ if (now.size === 0 && failsLoudly(after)) return { state: "kept" };
7111
+ for (const k of was) {
7112
+ if (now.has(k) || opts.relocated?.(k)) continue;
7113
+ const instead = [...now].filter((x) => !was.has(x));
7114
+ return { state: "removed", kind: k, what: instead.length ? `runs a ${instead.join("/")} check instead` : now.size ? "the check is gone" : "no check is run" };
6747
7115
  }
6748
- if (/\.[cm]?[jt]sx?$/.test(path)) {
6749
- const hit = JS_UNWALKED.find((g) => globs2([g])(path));
6750
- return hit === void 0 ? null : `the runner's default exclude matches it (${JSON.stringify(hit)})`;
7116
+ const survived = new Set(checkStatements(before).map((s) => s.canon));
7117
+ const extended = new Set(checkStatements(after).filter((s) => survived.has(s.canon)).map((s) => s.kind));
7118
+ const read = { ...opts, fresh: was.size === 0, extended };
7119
+ const beforeDs = descriptors(before, runner, read);
7120
+ const afterDs = descriptors(after, runner, read);
7121
+ const seenBefore = new Set(beforeDs.map((d) => d.key));
7122
+ const added = afterDs.find((d) => d.direction === "added" && !seenBefore.has(d.key));
7123
+ if (added) return { state: "neutralised", what: added.what, direction: "added" };
7124
+ if (coverageFloorLowered(before, after)) return { state: "neutralised", what: "a lowered --cov-fail-under", direction: "added" };
7125
+ const seenAfter = new Set(afterDs.map((d) => d.key));
7126
+ const dropped = beforeDs.find((d) => d.direction === "removed" && !seenAfter.has(d.key));
7127
+ if (dropped) return { state: "neutralised", what: dropped.what, direction: "removed" };
7128
+ return { state: "kept" };
7129
+ }
7130
+ var INVOKES_TOOL, INVOKES_SCRIPT, invokesCheck, MATRIX_FLAG, usesRef, SUITE_NARROWING_FLAGS, NEUTRALISING_SUFFIX, TIMEOUT_WRAP, RUNNER_NARROWINGS, COVERAGE_FLOOR, COMMAND_PREFIX, CHECK_HEAD, PATH_SHAPED, NARROWING_BY_RUNNER, CARGO_SKIP, MASKING_FLAGS, GATE_FLAGS, CONFIG_FLAG, PRINTER, PYTHON_M, NODE_BIN, BIN_OF, RUNNER_NAME, spelled, FAILS_LOUDLY;
7131
+ var init_invocation = __esm({
7132
+ "src/detectors/invocation.ts"() {
7133
+ "use strict";
7134
+ init_gh_expression();
7135
+ init_command();
7136
+ 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/;
7137
+ 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/;
7138
+ invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line);
7139
+ MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
7140
+ usesRef = (core) => core.replace(/@.*$/, "");
7141
+ SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
7142
+ NEUTRALISING_SUFFIX = new RegExp(
7143
+ `\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
7144
+ );
7145
+ TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
7146
+ RUNNER_NARROWINGS = [
7147
+ {
7148
+ // a package-manager script: `test --prefix dir`, `-w pkg`, `--workspace=pkg`,
7149
+ // `--if-present` (no script, no failure), pnpm's `--filter` / `-F`, npm's `-C`
7150
+ runner: /^(?:test|tests|coverage)(?::\S+)?(?=\s|$)/,
7151
+ flags: /\s--if-present(?=\s|$)|\s--prefix(?:=|\s)|\s-C(?:=|\s)|\s-w(?:=|\s)|\s--workspace(?:=|\s)|\s--filter(?:=|\s)|\s-F(?:=|\s)/
7152
+ },
7153
+ {
7154
+ runner: /^(?:jest|vitest)\b/,
7155
+ flags: /\s--shard(?:=|\s)|\s--root(?:Dir)?(?:=|\s)|\s--testMatch(?:=|\s)|\s--testRegex(?:=|\s)|\s--modulePathIgnorePatterns(?:=|\s)|\s--selectProjects(?:=|\s)|\s--grep(?:=|\s)|\s-g(?:=|\s)/
7156
+ },
7157
+ { runner: /^playwright\b/, flags: /\s--shard(?:=|\s)|\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--grep-invert(?:=|\s)/ },
7158
+ { runner: /^mocha\b/, flags: /\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--fgrep(?:=|\s)|\s-f(?:=|\s)/ },
7159
+ { runner: /^ava\b/, flags: /\s--match(?:=|\s)|\s-m(?:=|\s)/ },
7160
+ { runner: /^node --test\b/, flags: /\s--test-name-pattern(?:=|\s)|\s--test-skip-pattern(?:=|\s)|\s--test-only(?=\s|$)/ },
7161
+ {
7162
+ // `-p no:<plugin>` disables collection when the plugin is the collector
7163
+ // (`python`, `doctest`, `cov`); the cache, warning and output plugins select nothing
7164
+ runner: /^pytest\b/,
7165
+ flags: /\s-k(?:=|\s)|\s-m(?:=|\s)|\s--deselect(?:=|\s)|\s--ignore(?:-glob)?(?:=|\s)|\s-p\s*no:(?!(?:cacheprovider|warnings|faulthandler|randomly|logging|legacypath|pastebin|stepwise|tmpdir|junitxml|terminal|terminalreporter)(?=\s|$))|\s--collect-only(?=\s|$)|\s--co(?=\s|$)/
7166
+ },
7167
+ { runner: /^cargo test\b/, flags: /\s--\s(?:\S+\s+)*--skip(?:=|\s)|\s--exclude(?:=|\s)/ },
7168
+ { runner: /^go test\b/, flags: /\s-run(?:=|\s)|\s-skip(?:=|\s)/ }
7169
+ ];
7170
+ COVERAGE_FLOOR = /--cov-fail-under(?:=|\s+)(\d+(?:\.\d+)?)/;
7171
+ COMMAND_PREFIX = /^(?:(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)|sudo|env|nice|time|exec|command|nohup|xvfb-run|dbus-launch|cross-env|dotenv|poetry\s+run|pipenv\s+run|uv\s+run|hatch\s+run|pdm\s+run|bundle\s+exec)\s+(?:-{1,2}\S+\s+)*)*/;
7172
+ CHECK_HEAD = /^(?:(?:test|tests|lint|typecheck|type-check|coverage)(?::\S+)?(?=\s|$)|(?:jest|vitest|eslint|tsc|playwright|pytest|tamperward|mocha|ava|oxlint|tsgo|mypy|golangci-lint)\b|node --test\b|biome (?:ci|check|lint)\b|deno (?:test|lint|check)\b|ruff check\b|(?:make|cargo|go) test\b|gradle\w* (?:test|check)\b)/;
7173
+ PATH_SHAPED = /\/|\.(?:test|spec)\./;
7174
+ NARROWING_BY_RUNNER = {
7175
+ "*": [/--shard\b/],
7176
+ jest: [/--root\b/, /--rootDir\b/, /--testMatch\b/, /--testRegex\b/, /--modulePathIgnorePatterns\b/, /--selectProjects\b/, /--ignoreProjects\b/, /--filter\b/],
7177
+ vitest: [/--grep\b/],
7178
+ mocha: [/--grep\b/, /(?:^|\s)-g(?:\s|=)/, /--ignore\b/, /(?:^|\s)-f(?:\s|=)/, /--fgrep\b/],
7179
+ pytest: [/(?:^|\s)-k(?:\s|=)/, /(?:^|\s)-m(?:\s|=)/, /--deselect\b/, /--ignore\b/, /--ignore-glob\b/, /--last-failed\b/, /(?:^|\s)--lf(?:\s|$)/, /--collect-only\b/, /(?:^|\s)--co(?:\s|$)/],
7180
+ go: [/(?:^|\s)-run(?:\s|=)/, /(?:^|\s)-skip(?:\s|=)/],
7181
+ cargo: [],
7182
+ node: [/--test-name-pattern\b/, /--test-skip-pattern\b/]
7183
+ };
7184
+ CARGO_SKIP = /\s--\s+(?:\S+\s+)*--skip\b/;
7185
+ MASKING_FLAGS = [/--passWithNoTests\b/, /--coverage=false\b/, /--coverageThreshold\b/, /--if-present\b/];
7186
+ GATE_FLAGS = [/--check-coverage\b/];
7187
+ CONFIG_FLAG = /(?:^|\s)(--config|-c)(?:=|\s+)(['"]?)([^\s'"]+)\2(?=\s|$)/;
7188
+ PRINTER = /^(?:echo|printf|true|false|:|cat|exit|return|sleep|test|\[|node|git|curl|wget)(?=\s|$)/;
7189
+ PYTHON_M = /^python\d?(?:\.\d+)?\s+-m\s+(?=pytest\b)/;
7190
+ NODE_BIN = /^node(?:\s+-\S+)*\s+\S*node_modules\/(?:\.bin\/)?(jest|vitest|mocha|ava)\b\S*/;
7191
+ BIN_OF = /\$\((?:yarn|npm|pnpm)\s+bin\s+(\w+)\)|\$\((?:yarn|npm|pnpm)\s+bin\)\/(\w+)/g;
7192
+ RUNNER_NAME = /^(jest|vitest|mocha|pytest|go|cargo|node)\b/;
7193
+ spelled = (re, s) => {
7194
+ const m = s.match(re);
7195
+ return m ? m[0].trim().replace(/[=\s].*$/, "") : null;
7196
+ };
7197
+ FAILS_LOUDLY = /^(?:exit\s+(?:[1-9]\d*)|false)\s*$/;
6751
7198
  }
6752
- return null;
7199
+ });
7200
+
7201
+ // src/detectors/ci-tampering.ts
7202
+ function isActiveWorkflow(path) {
7203
+ return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
6753
7204
  }
6754
- function keyName2(name) {
6755
- if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
6756
- return null;
7205
+ function commandCore(line) {
7206
+ return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses|script|command|before_script|after_script|sh|bash|pwsh|powershell):\s*/, "").replace(/\s+/g, " ").trim();
6757
7207
  }
6758
- function literals2(e) {
6759
- if (isStr(e)) return { items: [e.text], opaque: false };
6760
- if (!ts.isArrayLiteralExpression(e)) return { items: [], opaque: true };
6761
- const items = [];
6762
- let opaque = false;
6763
- for (const el of e.elements) {
6764
- if (isStr(el)) items.push(el.text);
6765
- else if (ts.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
6766
- items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
6767
- } else opaque = true;
6768
- }
6769
- return { items, opaque };
7208
+ function expressionOf(raw) {
7209
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
7210
+ const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
7211
+ return m ? m[1] : v;
6770
7212
  }
6771
- function ownerKey2(node) {
6772
- const obj = node.parent;
6773
- if (!obj || !ts.isObjectLiteralExpression(obj)) return null;
6774
- let q = obj.parent;
6775
- while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
6776
- return q && ts.isPropertyAssignment(q) ? keyName2(q.name) : null;
7213
+ function isAlwaysFalse(raw) {
7214
+ const e = expressionOf(raw);
7215
+ if (e === "") return false;
7216
+ const v = foldConst(e);
7217
+ return v !== void 0 && !truthy(v);
6777
7218
  }
6778
- function asExpression2(src) {
6779
- const t = src.trim();
6780
- return t.startsWith("{") ? `(${t})` : src;
7219
+ function isTruthy(raw) {
7220
+ const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
7221
+ if (/^(?:true|yes|on|1)$/i.test(v)) return true;
7222
+ const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
7223
+ if (!m) return false;
7224
+ const f = foldConst(m[1]);
7225
+ return f !== void 0 && truthy(f);
6781
7226
  }
6782
- function runnerOf(path, content) {
6783
- const base = path.split("/").pop() ?? path;
6784
- if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
6785
- if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
6786
- if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
6787
- if (base === "pytest.ini" || base === ".pytest.ini") return "pytest";
6788
- if (content == null) return null;
6789
- if (base === "tox.ini" && /^\s*\[pytest\]/m.test(content)) return "pytest";
6790
- if (base === "setup.cfg" && /^\s*\[tool:pytest\]/m.test(content)) return "pytest";
6791
- if (base === "pyproject.toml" && /^\s*\[tool\.pytest\.ini_options\]/m.test(content)) return "pytest";
6792
- return null;
7227
+ function stepOf(lines, i) {
7228
+ const ind = indentOf(lines[i]);
7229
+ let s = i - 1;
7230
+ while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
7231
+ if (s < 0 || !STEP_START.test(lines[s])) return null;
7232
+ const stepInd = indentOf(lines[s]);
7233
+ let e = i + 1;
7234
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
7235
+ return [s, e];
6793
7236
  }
6794
- function claimsPytest(name, src) {
6795
- if (name === "pytest.ini" || name === ".pytest.ini") return true;
6796
- const section = name === "pyproject.toml" ? /^\s*\[tool\.pytest\.ini_options\]\s*$/m : name === "tox.ini" ? /^\s*\[pytest\]\s*$/m : name === "setup.cfg" ? /^\s*\[tool:pytest\]\s*$/m : null;
6797
- return section !== null && section.test(src);
7237
+ function jobOf(lines, i) {
7238
+ const ind = indentOf(lines[i]);
7239
+ let h = i - 1;
7240
+ while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
7241
+ if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
7242
+ let t = h - 1;
7243
+ while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
7244
+ if (t < 0 || !/^jobs:/.test(lines[t])) return null;
7245
+ const hi = indentOf(lines[h]);
7246
+ let e = i + 1;
7247
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
7248
+ return [h, e];
6798
7249
  }
6799
- function effectivePytestFile(files) {
6800
- for (const name of PYTEST_INI_ORDER) {
6801
- const content = files.get(name);
6802
- if (content == null) continue;
6803
- if (claimsPytest(name, content)) return { path: name, content };
7250
+ function runBlocks(lines) {
7251
+ const out3 = [];
7252
+ for (let i = 0; i < lines.length; i++) {
7253
+ if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
7254
+ const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
7255
+ let e = i + 1;
7256
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
7257
+ out3.push({ start: i, end: e, body: lines.slice(i + 1, e) });
6804
7258
  }
6805
- return null;
6806
- }
6807
- function effectivePytestConfig(path, ctx) {
6808
- if (path.includes("/")) return false;
6809
- const base = path;
6810
- const rank = PYTEST_INI_ORDER.findIndex((name) => name === base);
6811
- if (rank < 0) return false;
6812
- const files = ctx?.trackedFiles;
6813
- if (!files) return true;
6814
- const rootNames = new Set(files.filter((f) => !f.includes("/")));
6815
- return !PYTEST_INI_ORDER.slice(0, rank).some((n) => rootNames.has(n));
7259
+ return out3;
6816
7260
  }
6817
- function pytestBlock(src) {
6818
- const lines = src.split(/\r?\n/);
6819
- const head = /^\s*\[(pytest|tool:pytest|tool\.pytest\.ini_options)\]\s*$/;
6820
- let i = lines.findIndex((l) => head.test(l));
6821
- if (i < 0) return null;
6822
- const out3 = {};
6823
- let key2 = null;
6824
- for (i += 1; i < lines.length; i++) {
6825
- const line = lines[i];
6826
- if (/^\s*\[/.test(line)) break;
6827
- if (/^\s*[#;]/.test(line) || !line.trim()) {
7261
+ function commandLines(lines) {
7262
+ const out3 = lines.slice();
7263
+ for (const b of runBlocks(lines)) {
7264
+ if (/^\s*-?\s*run:\s*>/.test(lines[b.start])) {
7265
+ out3[b.start + 1] = b.body.map((l) => l.trim()).filter(Boolean).join(" ");
7266
+ for (let k = b.start + 2; k < b.end; k++) out3[k] = "";
6828
7267
  continue;
6829
7268
  }
6830
- const m = line.match(/^(\w[\w.-]*)\s*[=:]\s*(.*)$/);
6831
- if (m) {
6832
- key2 = m[1];
6833
- out3[key2] = m[2].trim();
6834
- continue;
7269
+ const indents = b.body.filter((l) => l.trim() !== "").map(indentOf);
7270
+ const ind = indents.length ? Math.min(...indents) : 0;
7271
+ const dedented = b.body.map((l) => l.slice(Math.min(ind, indentOf(l))));
7272
+ const kept = stripHeredocs(dedented);
7273
+ let j = 0;
7274
+ for (let k = 0; k < dedented.length; k++) {
7275
+ if (j < kept.length && kept[j] === dedented[k]) j++;
7276
+ else out3[b.start + 1 + k] = "";
6835
7277
  }
6836
- if (key2 && /^\s+\S/.test(line)) out3[key2] += " " + line.trim();
6837
7278
  }
6838
7279
  return out3;
6839
7280
  }
6840
- function pytestTokens(raw) {
6841
- const v = raw.trim();
6842
- if (v.startsWith("[")) {
6843
- return [...v.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? "");
6844
- }
6845
- const unq2 = v.replace(/^"([\s\S]*)"$/, "$1").replace(/^'([\s\S]*)'$/, "$1");
6846
- return [...unq2.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "").filter(Boolean);
6847
- }
6848
- function pytestSelection(src) {
6849
- const sel = empty();
6850
- const blk = pytestBlock(src);
6851
- if (!blk) return sel;
6852
- sel.present = true;
6853
- const ignore = blk.norecursedirs !== void 0 ? pytestTokens(blk.norecursedirs).flatMap((d) => pathGlobs(d).map((pattern) => ({ pattern, key: "norecursedirs" }))) : PYTEST_DEFAULT_IGNORE.map((pattern) => ({ pattern, key: "norecursedirs" }));
6854
- if (blk.testpaths !== void 0) sel.roots = pytestTokens(blk.testpaths).map((t) => t.replace(/^\.\//, ""));
6855
- if (blk.python_files !== void 0) sel.include = pytestTokens(blk.python_files).map((g) => g.includes("/") ? g : "**/" + g);
6856
- for (const key2 of ["python_classes", "python_functions"]) {
6857
- if (blk[key2] !== void 0) ignore.push({ pattern: "\0" + key2, key: key2 });
6858
- }
6859
- const opts = blk.addopts !== void 0 ? pytestTokens(blk.addopts) : [];
6860
- for (let j = 0; j < opts.length; j++) {
6861
- const o = opts[j];
6862
- const val = (inline) => o.includes("=") ? o.slice(o.indexOf("=") + 1) : opts[++j] ?? inline;
6863
- if (o === "--ignore" || o.startsWith("--ignore=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--ignore" });
6864
- else if (o === "--ignore-glob" || o.startsWith("--ignore-glob=")) {
6865
- const g = val("");
6866
- ignore.push({ pattern: g, key: "--ignore-glob" });
6867
- if (!g.startsWith("**/") && !g.startsWith("/")) ignore.push({ pattern: "**/" + g, key: "--ignore-glob" });
6868
- } else if (o === "--deselect" || o.startsWith("--deselect=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--deselect" });
6869
- else if (o === "-k" || o.startsWith("-k=")) {
6870
- for (const w of (val("").match(/\bnot\s+([A-Za-z_][\w]*)/g) ?? []).map((t) => t.replace(/^not\s+/, "")))
6871
- ignore.push({ pattern: `**/*${w}*`, key: "-k" });
6872
- } else if (o === "-m" || o.startsWith("-m=")) {
6873
- ignore.push({ pattern: "\0-m " + val(""), key: "-m" });
6874
- } else if (o === "-p" || o.startsWith("-p=") || o.length > 2 && o.startsWith("-p")) {
6875
- const v = o === "-p" ? opts[++j] ?? "" : o.startsWith("-p=") ? o.slice(3) : o.slice(2);
6876
- if (v.startsWith("no:")) ignore.push({ pattern: "\0-p " + v, key: "-p" });
6877
- } else if (o === "--collect-only" || o === "--co") ignore.push({ pattern: "**", key: o });
6878
- }
6879
- sel.ignore = ignore;
6880
- return sel;
6881
- }
6882
- function dirValue(e) {
6883
- if (!isStr(e)) return { dir: null, opaque: true };
6884
- const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
6885
- if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
6886
- return { dir: d, opaque: false };
7281
+ function flowList(v) {
7282
+ const m = v.trim().match(/^\[(.*)\]$/);
7283
+ if (!m) return null;
7284
+ return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
6887
7285
  }
6888
- function collect(root, runner) {
6889
- const sel = empty();
6890
- const seen = /* @__PURE__ */ new Set();
6891
- const take = (key2, e) => {
6892
- const { items, opaque } = literals2(e);
6893
- if (opaque || seen.has(key2)) sel.opaque = true;
6894
- seen.add(key2);
6895
- sel.present = true;
6896
- return items.map(stripRootDir);
6897
- };
6898
- const ignores = (key2, e) => {
6899
- const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
6900
- sel.ignore = [...sel.ignore ?? [], ...entries];
6901
- };
6902
- let projectList = null;
6903
- const projects = (e) => {
6904
- sel.present = true;
6905
- projectList = e;
7286
+ function parseTriggers(src) {
7287
+ const t = {
7288
+ events: /* @__PURE__ */ new Set(),
7289
+ branches: /* @__PURE__ */ new Map(),
7290
+ branchesIgnore: /* @__PURE__ */ new Map(),
7291
+ pathsIgnore: /* @__PURE__ */ new Map(),
7292
+ paths: /* @__PURE__ */ new Map(),
7293
+ types: /* @__PURE__ */ new Map(),
7294
+ tags: /* @__PURE__ */ new Map(),
7295
+ present: false
6906
7296
  };
6907
- const visit = (node) => {
6908
- if (ts.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
6909
- sel.present = true;
6910
- sel.opaque = true;
6911
- }
6912
- if (ts.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
6913
- sel.present = true;
6914
- sel.opaque = true;
7297
+ const lines = src.split("\n");
7298
+ const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
7299
+ if (i < 0) return t;
7300
+ t.present = true;
7301
+ const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
7302
+ const inline = uncommented(head);
7303
+ if (inline) {
7304
+ const list3 = flowList(inline);
7305
+ for (const e of list3 ?? [inline]) t.events.add(e);
7306
+ return t;
7307
+ }
7308
+ let event = null;
7309
+ let listKey = null;
7310
+ let listInd = -1;
7311
+ for (let j = i + 1; j < lines.length; j++) {
7312
+ const l = lines[j];
7313
+ if (l.trim() === "" || /^\s*#/.test(l)) continue;
7314
+ const ind = indentOf(l);
7315
+ if (ind === 0) break;
7316
+ const body = uncommented(l);
7317
+ const item = body.match(/^\s*-\s+(.+)$/);
7318
+ if (item && listKey && event && ind > listInd) {
7319
+ const target = t[listKey].get(event) ?? [];
7320
+ target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
7321
+ t[listKey].set(event, target);
7322
+ continue;
6915
7323
  }
6916
- if (ts.isPropertyAssignment(node)) {
6917
- const k = keyName2(node.name);
6918
- if (runner === "jest") {
6919
- if (k === "testMatch") sel.include = take(k, node.initializer);
6920
- else if (k === "testRegex") sel.regex = take(k, node.initializer);
6921
- else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
6922
- else if (k === "roots") sel.roots = take(k, node.initializer);
6923
- else if (k === "rootDir") {
6924
- const { dir, opaque } = dirValue(node.initializer);
6925
- sel.present = true;
6926
- if (opaque) sel.opaque = true;
6927
- else sel.base = dir;
6928
- } else if (k === "projects") {
6929
- projects(node.initializer);
6930
- return;
6931
- }
6932
- } else if (ownerKey2(node) === "test") {
6933
- if (k === "include") sel.include = take(k, node.initializer);
6934
- else if (k === "exclude") ignores(k, node.initializer);
6935
- else if (k === "dir" || k === "root") {
6936
- const { dir, opaque } = dirValue(node.initializer);
6937
- sel.present = true;
6938
- if (opaque) sel.opaque = true;
6939
- else sel.base = dir;
6940
- } else if (k === "projects") {
6941
- projects(node.initializer);
6942
- return;
6943
- }
6944
- }
7324
+ const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
7325
+ if (!kv) continue;
7326
+ if (ind === 2 || event === null && ind < 4) {
7327
+ event = kv[1];
7328
+ t.events.add(event);
7329
+ listKey = null;
7330
+ continue;
6945
7331
  }
6946
- ts.forEachChild(node, visit);
6947
- };
6948
- visit(root);
6949
- if (projectList) sel.projects = readProjects(projectList, sel, runner);
6950
- if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
6951
- return sel;
6952
- }
6953
- function unwrapConfigCall(e) {
6954
- let x = e;
6955
- while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isSatisfiesExpression(x)) {
6956
- x = x.expression;
7332
+ const key2 = LIST_KEYS[kv[1]];
7333
+ if (event && key2) {
7334
+ listKey = key2;
7335
+ listInd = ind;
7336
+ const list3 = flowList(kv[2]);
7337
+ t[listKey].set(event, list3 ?? []);
7338
+ } else listKey = null;
6957
7339
  }
6958
- if (ts.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
6959
- return x;
7340
+ return t;
6960
7341
  }
6961
- function spreadsIntoConfig(node, runner) {
6962
- const obj = node.parent;
6963
- if (!ts.isObjectLiteralExpression(obj)) return false;
6964
- const keys = obj.properties.map((p) => ts.isPropertyAssignment(p) ? keyName2(p.name) : null);
6965
- if (runner === "vitest") {
6966
- if (keys.includes("test")) return true;
6967
- let q = obj.parent;
6968
- while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
6969
- return !!q && ts.isPropertyAssignment(q) && keyName2(q.name) === "test";
7342
+ function ghGlob(g) {
7343
+ const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
7344
+ try {
7345
+ const r = new RegExp(`^${re}$`);
7346
+ return (p) => r.test(p);
7347
+ } catch {
7348
+ return () => false;
6970
7349
  }
6971
- return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
6972
7350
  }
6973
- function extendsOf(el) {
6974
- for (const p of el.properties) {
6975
- if (!ts.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
6976
- if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) return true;
6977
- if (p.initializer.kind === ts.SyntaxKind.FalseKeyword) return null;
6978
- return "path";
7351
+ function admits(globs4, name) {
7352
+ let on = false;
7353
+ for (const g of globs4) {
7354
+ const neg = g.startsWith("!");
7355
+ if (ghGlob(neg ? g.slice(1) : g)(name)) on = !neg;
6979
7356
  }
6980
- return null;
7357
+ return on;
6981
7358
  }
6982
- function readProject(el, root, runner) {
6983
- const p = collect(el, runner);
6984
- const ext = extendsOf(el);
6985
- if (ext === "path") p.opaque = true;
6986
- else if (ext === true && root) {
6987
- p.include ??= root.include;
6988
- p.regex ??= root.regex;
6989
- p.ignore ??= root.ignore;
6990
- p.roots ??= root.roots;
6991
- p.base ??= root.base;
6992
- if (root.opaque) p.opaque = true;
7359
+ function sourceProbes(ctx) {
7360
+ const files = trackedFiles(ctx);
7361
+ if (!files) return CODE_PROBES;
7362
+ const code2 = files.filter((f) => langOf(f) !== null);
7363
+ return code2.length ? code2 : CODE_PROBES;
7364
+ }
7365
+ function triggerNarrowings(before, after, opts) {
7366
+ const out3 = [];
7367
+ if (!before.present || !after.present) return out3;
7368
+ const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
7369
+ const candidates = opts.defaultBranch ? [opts.defaultBranch] : ["main", "master"];
7370
+ const namesDefault = (l) => candidates.some((b) => admits(l, b));
7371
+ for (const e of CODE_EVENTS) {
7372
+ if (before.events.has(e) && !after.events.has(e)) out3.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
7373
+ }
7374
+ for (const [e, list3] of after.pathsIgnore) {
7375
+ const had = before.pathsIgnore.get(e) ?? [];
7376
+ if (list3.some((g) => ALL(g) && !had.includes(g))) {
7377
+ out3.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
7378
+ continue;
7379
+ }
7380
+ if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
7381
+ if (had.length && ignoresEverySource(had, opts.sources)) continue;
7382
+ if (ignoresEverySource(list3, opts.sources)) out3.push(`on.${e}.paths-ignore now ignores every source file (now [${list3.join(", ")}]) \u2014 the workflow never runs on code`);
7383
+ }
7384
+ for (const [e, list3] of after.paths) {
7385
+ if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
7386
+ const had = before.paths.get(e);
7387
+ if (had && !pathsMatchSource(had, opts.sources)) continue;
7388
+ if (!pathsMatchSource(list3, opts.sources)) out3.push(`on.${e}.paths matches no source file (now [${list3.join(", ")}]) \u2014 the workflow never runs on code`);
6993
7389
  }
6994
- p.present = true;
6995
- return p;
6996
- }
6997
- function readProjects(e, root, runner) {
6998
- if (!ts.isArrayLiteralExpression(e)) {
6999
- root.opaque = true;
7000
- return [];
7390
+ for (const [e, list3] of after.types) {
7391
+ if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
7392
+ const had = before.types.get(e);
7393
+ if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
7394
+ if (!list3.some((t) => PR_CODE_TYPES.test(t))) out3.push(`on.${e}.types no longer includes opened/synchronize (now [${list3.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
7001
7395
  }
7002
- const list3 = [];
7003
- for (const el of e.elements) {
7004
- const lit = unwrapConfigCall(el);
7005
- if (ts.isObjectLiteralExpression(lit)) list3.push(readProject(lit, root, runner));
7006
- else root.opaque = true;
7396
+ for (const [e, list3] of after.branchesIgnore) {
7397
+ const had = before.branchesIgnore.get(e) ?? [];
7398
+ if (list3.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out3.push(`on.${e}.branches-ignore now covers the default branch`);
7007
7399
  }
7008
- return list3;
7009
- }
7010
- function workspaceArray(sf) {
7011
- for (const st of sf.statements) {
7012
- let e;
7013
- if (ts.isExportAssignment(st)) e = st.expression;
7014
- else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
7015
- if (!e) continue;
7016
- while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
7017
- e = e.expression;
7400
+ const events = /* @__PURE__ */ new Set([...before.branches.keys(), ...CODE_EVENTS.filter((e) => before.events.has(e))]);
7401
+ for (const e of events) {
7402
+ const was = before.branches.get(e) ?? (before.tags.has(e) ? null : ["**"]);
7403
+ if (!was || !after.events.has(e)) continue;
7404
+ const now = after.branches.get(e);
7405
+ if (!now) {
7406
+ const tags = after.tags.get(e);
7407
+ if (tags && namesDefault(was)) out3.push(`on.${e} now filters on tags only (tags: [${tags.join(", ")}]) \u2014 no push to the default branch runs the workflow`);
7408
+ continue;
7018
7409
  }
7019
- if (ts.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
7020
- return ts.isArrayLiteralExpression(e) ? e : null;
7410
+ const renamed = (b) => DEFAULT_BRANCH.test(b) && now.includes(b) && admits(now, b) && opts.hasBranch(b) !== false;
7411
+ const lost = candidates.filter((b) => admits(was, b) && !admits(now, b));
7412
+ const lostDefault = lost.length > 0 && !["main", "master"].some(renamed);
7413
+ const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
7414
+ if (!lostDefault && !replaced) continue;
7415
+ const dropped = was.filter((b) => !now.includes(b) && !ALL(b));
7416
+ out3.push(
7417
+ dropped.length ? `on.${e}.branches no longer names ${dropped.join(", ")} (now [${now.join(", ")}])` : `on.${e}.branches now filters to [${now.join(", ")}], which does not admit the default branch`
7418
+ );
7021
7419
  }
7022
- return null;
7420
+ return out3;
7023
7421
  }
7024
- function parseSelection(src, runner, path = "") {
7025
- let sel = empty();
7026
- if (runner === "pytest") return pytestSelection(src);
7027
- try {
7028
- const sf = parseSource("cfg.ts", asExpression2(src));
7029
- if (!sf) return sel;
7030
- if (runner === "vitest" && isWorkspaceFile(path)) {
7031
- const arr = workspaceArray(sf);
7032
- if (arr) {
7033
- sel.projects = readProjects(arr, sel, runner);
7034
- sel.present = true;
7035
- if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
7036
- } else if (src.trim()) sel.opaque = true;
7037
- } else sel = collect(sf, runner);
7038
- } catch {
7039
- }
7040
- return sel;
7422
+ function localUses(line) {
7423
+ const wf = line.match(REUSED_WORKFLOW)?.[1];
7424
+ if (wf) return [wf];
7425
+ const action = line.match(LOCAL_ACTION)?.[1];
7426
+ return action ? [`${action}/action.yml`, `${action}/action.yaml`] : [];
7041
7427
  }
7042
- function predicate(sel, runner) {
7043
- if (sel.projects) {
7044
- const ps = sel.projects.map((p) => predicate(p, runner));
7045
- return {
7046
- selects: (p) => ps.some((x) => x.selects(p)),
7047
- why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
7048
- };
7049
- }
7050
- const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : runner === "pytest" ? PYTEST_DEFAULT_INCLUDE : VITEST_DEFAULT_INCLUDE);
7051
- const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : runner === "pytest" ? PYTEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({
7052
- pattern,
7053
- key: runner === "jest" ? "testPathIgnorePatterns" : runner === "pytest" ? "norecursedirs" : "test.exclude"
7054
- }));
7055
- const blockWide = ignoreList.filter((e) => e.pattern.startsWith("\0"));
7056
- const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
7057
- const ignoredBy = (p) => blockWide[0] ?? ignoreList.filter((e) => !e.pattern.startsWith("\0")).find((e) => (runner === "jest" ? regexes([e.pattern]) : globs2([e.pattern]))(p));
7058
- const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
7059
- const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
7060
- const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
7061
- return {
7062
- selects: (p) => {
7063
- const r = rebase(p);
7064
- return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
7065
- },
7066
- why: (p) => {
7067
- const r = rebase(p);
7068
- const baseKey = runner === "jest" ? "rootDir" : "test.dir";
7069
- if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
7070
- if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
7071
- const hit = ignoredBy(r);
7072
- if (hit) {
7073
- if (hit.pattern.startsWith("\0")) return `${hit.key} narrows the whole run (${JSON.stringify(hit.pattern.slice(1))})`;
7074
- return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
7075
- }
7076
- const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
7077
- return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
7078
- }
7079
- };
7428
+ function literalValue(raw) {
7429
+ const v = foldExpressions(uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim());
7430
+ return v.includes(EXPR_TOKEN) || v.includes(MATRIX_TOKEN) ? null : v;
7080
7431
  }
7081
- function suiteNarrowings(before, after, path, samples) {
7082
- const runner = runnerOf(path, after) ?? runnerOf(path, before);
7083
- if (!runner) return [];
7084
- const b = parseSelection(before ?? "", runner, path);
7085
- const a = parseSelection(after, runner, path);
7086
- if (!a.present || a.opaque) return [];
7087
- const pb = predicate(b, runner);
7088
- const pa = predicate(a, runner);
7089
- const out3 = [];
7090
- for (const s of samples) {
7091
- if (pb.selects(s) && !pa.selects(s)) out3.push({ path: s, reason: pa.why(s) });
7432
+ function dirRunsNothing(dir, ctx) {
7433
+ const files = trackedFiles(ctx);
7434
+ if (!files) return true;
7435
+ const d = dir.replace(/^\.\//, "").replace(/\/+$/, "");
7436
+ if (d === "" || d === ".") return false;
7437
+ return !files.some((f) => f.startsWith(d + "/") && (langOf(f) !== null || MANIFEST.test(f)));
7438
+ }
7439
+ function defaultsOf(lines, i) {
7440
+ const run = ancestorOf(lines, i);
7441
+ if (run === null || !/^\s*run:\s*$/.test(lines[run])) return null;
7442
+ const d = ancestorOf(lines, run);
7443
+ return d !== null && /^\s*defaults:\s*$/.test(lines[d]) ? d : null;
7444
+ }
7445
+ function enclosingStep(lines, i) {
7446
+ let h = i;
7447
+ while ((h = ancestorOf(lines, h)) !== null) {
7448
+ if (!STEP_START.test(lines[h])) continue;
7449
+ const ind = indentOf(lines[h]);
7450
+ let e = i + 1;
7451
+ while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
7452
+ return [h, e];
7092
7453
  }
7093
- return out3;
7454
+ return null;
7094
7455
  }
7095
- var JEST_DEFAULT_MATCH, JEST_DEFAULT_IGNORE, VITEST_DEFAULT_INCLUDE, PYTEST_DEFAULT_INCLUDE, PYTEST_DEFAULT_IGNORE, VITEST_DEFAULT_EXCLUDE, JS_UNWALKED, CANONICAL_SAMPLES, PYTEST_CANONICAL_SAMPLES, isStr, PYTEST_INI_ORDER, pathGlobs, isWorkspaceFile, empty, stripRootDir, JEST_SELECTION_KEYS, globs2, globList2, regexes, underDir;
7096
- var init_suite_config = __esm({
7097
- "src/detectors/suite-config.ts"() {
7456
+ function ancestorOf(lines, i) {
7457
+ const ind = indentOf(lines[i]);
7458
+ let h = i - 1;
7459
+ while (h >= 0 && (lines[h].trim() === "" || /^\s*#/.test(lines[h]) || indentOf(lines[h]) >= ind)) h--;
7460
+ return h >= 0 ? h : null;
7461
+ }
7462
+ var RULE8, USES, CHECK, YAML_KEY, isCompositeAction, isGithubFile, GH_RUN_KEY, GENERIC_RUN_KEY, invokesCheck2, uncommented, SHELL_ERREXIT, indentOf, STEP_START, REPORTER_ACTION, usesCheck, stepHasCheck, jobHasCheck, NEUTRALISERS, LIST_KEYS, DEFAULT_BRANCH, ALL, CODE_EVENTS, PR_CODE_TYPES, pathsMatchSource, ignoresEverySource, CODE_PROBES, REUSED_WORKFLOW, LOCAL_ACTION, MANIFEST, WORKDIR_LINE, REF_LINE, CHECKOUT, ciTampering;
7463
+ var init_ci_tampering = __esm({
7464
+ "src/detectors/ci-tampering.ts"() {
7098
7465
  "use strict";
7099
- init_lazy_deps();
7100
- init_ts_lazy();
7101
- JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
7102
- JEST_DEFAULT_IGNORE = ["/node_modules/"];
7103
- VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
7104
- PYTEST_DEFAULT_INCLUDE = ["**/test_*.py", "**/*_test.py"];
7105
- PYTEST_DEFAULT_IGNORE = ["**/build/**", "**/dist/**", "**/node_modules/**", "**/venv/**", "**/.*/**"];
7106
- VITEST_DEFAULT_EXCLUDE = [
7107
- "**/node_modules/**",
7108
- "**/dist/**",
7109
- "**/cypress/**",
7110
- "**/.{idea,git,cache,output,temp}/**",
7111
- "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
7112
- ];
7113
- JS_UNWALKED = [...VITEST_DEFAULT_EXCLUDE, "**/.*/**"];
7114
- CANONICAL_SAMPLES = [
7115
- "src/a.test.ts",
7116
- "src/a.spec.ts",
7117
- "test/a.test.ts",
7118
- "tests/a.test.js",
7119
- "src/__tests__/a.ts",
7120
- "a.test.tsx",
7121
- "a.spec.js",
7122
- "test/a.test.mjs"
7123
- ];
7124
- PYTEST_CANONICAL_SAMPLES = [
7125
- "tests/test_a.py",
7126
- "tests/a_test.py",
7127
- "test_a.py",
7128
- "src/tests/test_a.py",
7129
- "tests/conftest.py"
7466
+ init_select();
7467
+ init_policy();
7468
+ init_finding();
7469
+ init_files();
7470
+ init_hook_wiring();
7471
+ init_repo();
7472
+ init_gh_expression();
7473
+ init_invocation();
7474
+ init_invocation();
7475
+ RULE8 = "ci-tampering";
7476
+ USES = /^\s*-\s*uses:/;
7477
+ CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
7478
+ YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
7479
+ isCompositeAction = (path) => /(^|\/)\.github\/actions\/.+\/action\.ya?ml$/.test(path);
7480
+ isGithubFile = (path) => /(^|\/)\.github\//.test(path);
7481
+ GH_RUN_KEY = /^\s*-?\s*run:/;
7482
+ GENERIC_RUN_KEY = /^\s*-?\s*(?:run|script|command|before_script|after_script|sh|bash|pwsh|powershell):/;
7483
+ invokesCheck2 = (line) => invokesCheck(line) || INVOKES_TOOL.test(commandCore(line));
7484
+ uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
7485
+ SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
7486
+ indentOf = (l) => l.length - l.trimStart().length;
7487
+ STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
7488
+ REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
7489
+ usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
7490
+ stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l));
7491
+ jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
7492
+ NEUTRALISERS = [
7493
+ { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
7494
+ { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
7495
+ { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
7496
+ { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
7130
7497
  ];
7131
- isStr = (e) => ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e);
7132
- PYTEST_INI_ORDER = ["pytest.ini", ".pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg"];
7133
- pathGlobs = (p) => {
7134
- const clean = p.replace(/^\.\//, "").replace(/::.*$/, "").replace(/\/+$/, "");
7135
- return clean ? [clean, clean + "/**"] : [];
7136
- };
7137
- isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
7138
- empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
7139
- stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
7140
- JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
7141
- globs2 = (patterns) => {
7142
- const ms = patterns.map((g) => {
7143
- try {
7144
- return picomatch(g, { dot: true });
7145
- } catch {
7146
- return () => false;
7147
- }
7148
- });
7149
- return (path) => ms.some((m) => m(path));
7150
- };
7151
- globList2 = (patterns) => {
7152
- const pos = globs2(patterns.filter((g) => !g.startsWith("!")));
7153
- const neg = globs2(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
7154
- return (path) => pos(path) && !neg(path);
7498
+ LIST_KEYS = {
7499
+ branches: "branches",
7500
+ "branches-ignore": "branchesIgnore",
7501
+ "paths-ignore": "pathsIgnore",
7502
+ paths: "paths",
7503
+ types: "types",
7504
+ tags: "tags"
7155
7505
  };
7156
- regexes = (patterns) => {
7157
- const rs = patterns.flatMap((p) => {
7158
- try {
7159
- return [new RegExp(p.replace(/<rootDir>/g, ""))];
7160
- } catch {
7161
- return [];
7506
+ DEFAULT_BRANCH = /^(?:main|master)$/;
7507
+ ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
7508
+ CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
7509
+ PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
7510
+ pathsMatchSource = (globs4, sources) => sources.some((s) => admits(globs4, s));
7511
+ ignoresEverySource = (globs4, sources) => sources.length > 0 && sources.every((s) => admits(globs4, s));
7512
+ 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"];
7513
+ REUSED_WORKFLOW = /^\s*-?\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
7514
+ LOCAL_ACTION = /^\s*-?\s*uses:\s*\.\/(\.github\/actions\/[^\s@]+?)\/?\s*(?:#.*)?$/;
7515
+ MANIFEST = /(?:^|\/)(?:package\.json|pyproject\.toml|setup\.py|setup\.cfg|Cargo\.toml|go\.mod|Makefile|build\.gradle(?:\.kts)?|pom\.xml|Gemfile|deno\.jsonc?)$/;
7516
+ WORKDIR_LINE = /^\s*-?\s*working-directory:\s*(.+?)\s*$/;
7517
+ REF_LINE = /^\s*ref:\s*(.+?)\s*$/;
7518
+ CHECKOUT = /^\s*-?\s*uses:\s*actions\/checkout(?:@|\s|$)/;
7519
+ ciTampering = {
7520
+ id: RULE8,
7521
+ surface: ["file"],
7522
+ certainty: "mechanical",
7523
+ run(changes, policy, _view, ctx) {
7524
+ const out3 = [];
7525
+ const afterByPath = /* @__PURE__ */ new Map();
7526
+ for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
7527
+ let triggerOpts = null;
7528
+ for (const c of changes) {
7529
+ if (c.kind !== "file") continue;
7530
+ if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
7531
+ out3.push(
7532
+ makeFinding(RULE8, policy, {
7533
+ file: c.path,
7534
+ message: `A CI workflow was renamed so it no longer runs (${c.oldPath} \u2192 ${c.path}).`,
7535
+ evidence: `${c.oldPath} \u2192 ${c.path}`,
7536
+ remediation: "Restore the path and extension. GitHub runs only *.yml/*.yaml directly under .github/workflows \u2014 renaming outside that disables the checks as surely as deleting them."
7537
+ })
7538
+ );
7539
+ continue;
7540
+ }
7541
+ if (!isProtected(c.path, policy, "ci")) continue;
7542
+ const afterLines = c.after != null ? c.after.split("\n") : null;
7543
+ const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
7544
+ const github = isGithubFile(c.path);
7545
+ for (const l of github ? addedLines(c) : []) {
7546
+ const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
7547
+ const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
7548
+ if (!coe && !cond) continue;
7549
+ const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
7550
+ if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
7551
+ if (afterLines && !step && l.newLine != null) {
7552
+ const job = jobOf(afterLines, l.newLine - 1);
7553
+ if (job && !jobHasCheck(afterLines, job)) continue;
7554
+ }
7555
+ if (coe && isTruthy(coe[1])) {
7556
+ out3.push(
7557
+ makeFinding(RULE8, policy, {
7558
+ file: c.path,
7559
+ line: l.newLine ?? void 0,
7560
+ message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
7561
+ evidence: l.content.trim(),
7562
+ remediation: "Remove it. A check that cannot fail is not a check."
7563
+ })
7564
+ );
7565
+ } else if (cond && isAlwaysFalse(cond[1])) {
7566
+ out3.push(
7567
+ makeFinding(RULE8, policy, {
7568
+ file: c.path,
7569
+ line: l.newLine ?? void 0,
7570
+ message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
7571
+ evidence: l.content.trim(),
7572
+ remediation: "Re-enable the step instead of conditioning it off."
7573
+ })
7574
+ );
7575
+ }
7576
+ }
7577
+ if (afterLines && github) {
7578
+ for (const b of runBlocks(afterLines)) {
7579
+ if (!b.body.some(invokesCheck2)) continue;
7580
+ const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
7581
+ for (let k = 0; k < b.body.length; k++) {
7582
+ const idx = b.start + 1 + k;
7583
+ if (!addedAt.has(idx + 1)) continue;
7584
+ const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
7585
+ if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
7586
+ out3.push(
7587
+ makeFinding(RULE8, policy, {
7588
+ file: c.path,
7589
+ line: idx + 1,
7590
+ message: `A check's run block was neutralised: ${n.what}.`,
7591
+ evidence: b.body[k].trim(),
7592
+ remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
7593
+ })
7594
+ );
7595
+ }
7596
+ }
7597
+ for (const l of addedLines(c)) {
7598
+ if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
7599
+ if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
7600
+ const step = stepOf(afterLines, l.newLine - 1);
7601
+ if (step && !stepHasCheck(afterLines, step)) continue;
7602
+ out3.push(
7603
+ makeFinding(RULE8, policy, {
7604
+ file: c.path,
7605
+ line: l.newLine,
7606
+ message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
7607
+ evidence: l.content.trim(),
7608
+ remediation: "Keep the default shell (fail-fast) on steps that run checks."
7609
+ })
7610
+ );
7611
+ }
7612
+ for (const l of addedLines(c)) {
7613
+ if (l.newLine == null) continue;
7614
+ const i = l.newLine - 1;
7615
+ const wd = l.content.match(WORKDIR_LINE);
7616
+ if (wd) {
7617
+ const dir = literalValue(wd[1]);
7618
+ if (dir === null || !dirRunsNothing(dir, ctx)) continue;
7619
+ const step2 = stepOf(afterLines, i);
7620
+ if (step2) {
7621
+ if (!stepHasCheck(afterLines, step2)) continue;
7622
+ } else {
7623
+ const d = defaultsOf(afterLines, i);
7624
+ if (d === null) continue;
7625
+ const job = jobOf(afterLines, d);
7626
+ if (job ? !jobHasCheck(afterLines, job) : !afterLines.some((x) => invokesCheck2(x) || usesCheck(x))) continue;
7627
+ }
7628
+ out3.push(
7629
+ makeFinding(RULE8, policy, {
7630
+ file: c.path,
7631
+ line: l.newLine,
7632
+ message: `A check was pointed at ${dir} by working-directory \u2014 a directory holding no code or package the check could run.`,
7633
+ evidence: l.content.trim(),
7634
+ remediation: "Run the check where the code is. A suite pointed at an empty directory passes on nothing."
7635
+ })
7636
+ );
7637
+ continue;
7638
+ }
7639
+ const ref = l.content.match(REF_LINE);
7640
+ if (!ref) continue;
7641
+ const step = enclosingStep(afterLines, i);
7642
+ if (!step || !afterLines.slice(step[0], step[1]).some((x) => CHECKOUT.test(x))) continue;
7643
+ const target = literalValue(ref[1]);
7644
+ if (target === null) continue;
7645
+ out3.push(
7646
+ makeFinding(RULE8, policy, {
7647
+ file: c.path,
7648
+ line: l.newLine,
7649
+ message: `actions/checkout was pinned to ref: ${target} \u2014 the checks run against that ref, not against the change under review.`,
7650
+ evidence: l.content.trim(),
7651
+ remediation: "Let checkout take the event's ref (its default). A suite that checks out main proves nothing about the pull request."
7652
+ })
7653
+ );
7654
+ }
7655
+ if (c.before != null && c.after != null && !isCompositeAction(c.path)) {
7656
+ triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
7657
+ for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
7658
+ out3.push(
7659
+ makeFinding(RULE8, policy, {
7660
+ file: c.path,
7661
+ message: `The workflow's triggers were narrowed: ${reason}.`,
7662
+ evidence: reason,
7663
+ 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."
7664
+ })
7665
+ );
7666
+ }
7667
+ }
7668
+ }
7669
+ const afterCommands = afterLines ? commandLines(afterLines) : null;
7670
+ const afterCores = (afterCommands ?? addedLines(c).map((l) => l.content)).map(commandCore);
7671
+ const addedCores = addedLines(c).map((l) => commandCore(afterCommands && l.newLine != null ? afterCommands[l.newLine - 1] : l.content));
7672
+ for (const l of afterLines ?? []) {
7673
+ for (const reused of localUses(l)) {
7674
+ const content = afterByPath.get(reused);
7675
+ if (content == null || reused === c.path) continue;
7676
+ const cores = commandLines(content.split("\n")).map(commandCore);
7677
+ afterCores.push(...cores);
7678
+ addedCores.push(...cores);
7679
+ }
7680
+ }
7681
+ if (!github && c.after == null) {
7682
+ for (const o of changes) {
7683
+ if (o.kind !== "file" || o.op !== "add" || o.after == null || !isProtected(o.path, policy, "ci")) continue;
7684
+ const cores = commandLines(o.after.split("\n")).map(commandCore);
7685
+ afterCores.push(...cores);
7686
+ addedCores.push(...cores);
7687
+ }
7688
+ }
7689
+ const runKey = github ? GH_RUN_KEY : GENERIC_RUN_KEY;
7690
+ for (const l of removedLines(c)) {
7691
+ if (/^\s*#/.test(l.content)) continue;
7692
+ const isUsesLine = usesCheck(l.content);
7693
+ const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? runKey.test(l.content) : true) && invokesCheck2(l.content);
7694
+ if (!isUsesLine && !isCheckCommand) continue;
7695
+ const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
7696
+ if (s.state === "kept") continue;
7697
+ if (s.state === "neutralised") {
7698
+ out3.push(
7699
+ makeFinding(RULE8, policy, {
7700
+ file: c.path,
7701
+ message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
7702
+ evidence: `${l.content.trim()} \u2192 ${s.by}`,
7703
+ remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
7704
+ })
7705
+ );
7706
+ continue;
7707
+ }
7708
+ out3.push(
7709
+ makeFinding(RULE8, policy, {
7710
+ file: c.path,
7711
+ message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
7712
+ evidence: l.content.trim(),
7713
+ remediation: "Restore the step. Removing the check that protects main is itself the tamper."
7714
+ })
7715
+ );
7716
+ }
7162
7717
  }
7163
- });
7164
- return (path) => rs.some((r) => r.test("/" + path));
7718
+ return out3;
7719
+ }
7165
7720
  };
7166
- underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
7167
7721
  }
7168
7722
  });
7169
7723
 
@@ -7191,7 +7745,7 @@ function safeParse(src) {
7191
7745
  return null;
7192
7746
  }
7193
7747
  }
7194
- function effective(raw) {
7748
+ function effective2(raw) {
7195
7749
  const v = raw.version;
7196
7750
  const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
7197
7751
  const base = defaultPolicy(version);
@@ -7256,8 +7810,8 @@ function policyWeakening(before, after, probe) {
7256
7810
  if (b === null) return null;
7257
7811
  const a = safeParse(after);
7258
7812
  if (a === null) return ["the policy no longer parses as a valid policy (emptied or corrupted)"];
7259
- const be = effective(b);
7260
- const ae = effective(a);
7813
+ const be = effective2(b);
7814
+ const ae = effective2(a);
7261
7815
  const reasons = [];
7262
7816
  if (ae.version < be.version) {
7263
7817
  reasons.push(
@@ -7520,7 +8074,7 @@ function entryShape(h) {
7520
8074
  }
7521
8075
  return { problems, runs };
7522
8076
  }
7523
- function judgeGateEntry(event, matcher, h) {
8077
+ function judgeGateEntry(event, matcher2, h) {
7524
8078
  const cmd = typeof h.command === "string" ? h.command : "";
7525
8079
  const a = analyseGateCommand(cmd, event);
7526
8080
  const shape = entryShape(h);
@@ -7528,13 +8082,13 @@ function judgeGateEntry(event, matcher, h) {
7528
8082
  let runs = a.runs && shape.runs;
7529
8083
  let tools = null;
7530
8084
  if (event === "PreToolUse") {
7531
- const sel = matcherSelection(matcher);
8085
+ const sel = matcherSelection(matcher2);
7532
8086
  tools = sel.tools;
7533
8087
  if (sel.problem) {
7534
8088
  problems.push(sel.problem);
7535
8089
  }
7536
- } else if (matcher !== void 0 && !(typeof matcher === "string" && (matcher === "" || matcher === "*"))) {
7537
- problems.push(`a \`matcher\` (${JSON.stringify(matcher)}) on the Stop entry`);
8090
+ } else if (matcher2 !== void 0 && !(typeof matcher2 === "string" && (matcher2 === "" || matcher2 === "*"))) {
8091
+ problems.push(`a \`matcher\` (${JSON.stringify(matcher2)}) on the Stop entry`);
7538
8092
  runs = false;
7539
8093
  }
7540
8094
  return { runs, problems, pin: a.pin, tools };
@@ -7741,7 +8295,7 @@ function claudeSettingsFindings(c, policy) {
7741
8295
  if (c.after == null) return null;
7742
8296
  const after = parseSettings(c.after);
7743
8297
  const before = c.before != null ? parseSettings(c.before) : null;
7744
- const finding = (reason) => makeFinding(RULE8, policy, {
8298
+ const finding = (reason) => makeFinding(RULE9, policy, {
7745
8299
  file: c.path,
7746
8300
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
7747
8301
  evidence: reason,
@@ -7766,7 +8320,7 @@ function policyFindings(c, policy, ctx) {
7766
8320
  const reasons = c.before == null ? policyAddWeakening(c.after, probe) : policyWeakening(c.before, c.after, probe);
7767
8321
  if (reasons === null) return null;
7768
8322
  return reasons.map(
7769
- (reason) => makeFinding(RULE8, policy, {
8323
+ (reason) => makeFinding(RULE9, policy, {
7770
8324
  file: c.path,
7771
8325
  message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
7772
8326
  evidence: reason,
@@ -7876,7 +8430,7 @@ function configFindings(c, changes, policy, ctx) {
7876
8430
  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);
7877
8431
  }
7878
8432
  return reasons.map(
7879
- (reason) => makeFinding(RULE8, policy, {
8433
+ (reason) => makeFinding(RULE9, policy, {
7880
8434
  file: c.path,
7881
8435
  message: `The hook configuration was weakened: ${reason}.`,
7882
8436
  evidence: reason,
@@ -7927,7 +8481,7 @@ function huskyRuntimeWrite(path, content, ctx) {
7927
8481
  (i) => i.identity === "tamperward check --staged" && i.state === "live" && i.pin === TW_VERSION
7928
8482
  );
7929
8483
  }
7930
- var RULE8, ownerExec2, EVENTS, isObj, declares, GATE_KEYS, HOOK_TYPES, SIBLING_KEYS, MENTIONS_GATE, FORGERY, ENV_MECHANISM, envMechanism, mentionsGate, carriesGate, CANONICAL_GATE, COMMIT_STAGES, namesGate, liveOrCanonical, HUSKY_SHIM, HUSKY_SHIM_NAMES, HUSKY_H_KNOWN, HUSKY_DEPRECATION, stripTrailingNl, isHuskyScript, isHookNote, hookTampering;
8484
+ var RULE9, ownerExec2, EVENTS, isObj, declares, GATE_KEYS, HOOK_TYPES, SIBLING_KEYS, MENTIONS_GATE, FORGERY, ENV_MECHANISM, envMechanism, mentionsGate, carriesGate, CANONICAL_GATE, COMMIT_STAGES, namesGate, liveOrCanonical, HUSKY_SHIM, HUSKY_SHIM_NAMES, HUSKY_H_KNOWN, HUSKY_DEPRECATION, stripTrailingNl, isHuskyScript, isHookNote, hookTampering;
7931
8485
  var init_hook_tampering = __esm({
7932
8486
  "src/detectors/hook-tampering.ts"() {
7933
8487
  "use strict";
@@ -7940,7 +8494,7 @@ var init_hook_tampering = __esm({
7940
8494
  init_command();
7941
8495
  init_repo();
7942
8496
  init_hook_wiring();
7943
- RULE8 = "hook-tampering";
8497
+ RULE9 = "hook-tampering";
7944
8498
  ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
7945
8499
  EVENTS = ["PreToolUse", "Stop"];
7946
8500
  isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -8008,7 +8562,7 @@ They WILL FAIL in v10.0.0
8008
8562
  isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
8009
8563
  isHookNote = (path) => !/(?:^|\/)\.husky\/_\//.test(path) && /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
8010
8564
  hookTampering = {
8011
- id: RULE8,
8565
+ id: RULE9,
8012
8566
  surface: ["file", "command"],
8013
8567
  certainty: "mechanical",
8014
8568
  run(changes, policy, _view, ctx) {
@@ -8021,7 +8575,7 @@ They WILL FAIL in v10.0.0
8021
8575
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
8022
8576
  for (const reason of packageJsonWeakening(c.before, c.after)) {
8023
8577
  out3.push(
8024
- makeFinding(RULE8, policy, {
8578
+ makeFinding(RULE9, policy, {
8025
8579
  file: c.path,
8026
8580
  message: `The hook installation was weakened: ${reason}.`,
8027
8581
  evidence: reason,
@@ -8034,7 +8588,7 @@ They WILL FAIL in v10.0.0
8034
8588
  }
8035
8589
  if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
8036
8590
  out3.push(
8037
- makeFinding(RULE8, policy, {
8591
+ makeFinding(RULE9, policy, {
8038
8592
  file: c.path,
8039
8593
  message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
8040
8594
  evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
@@ -8044,7 +8598,7 @@ They WILL FAIL in v10.0.0
8044
8598
  }
8045
8599
  if (c.op === "delete") {
8046
8600
  out3.push(
8047
- makeFinding(RULE8, policy, {
8601
+ makeFinding(RULE9, policy, {
8048
8602
  file: c.path,
8049
8603
  message: "A protected hook/policy file was deleted.",
8050
8604
  evidence: c.path,
@@ -8053,7 +8607,7 @@ They WILL FAIL in v10.0.0
8053
8607
  );
8054
8608
  } else if (c.op === "rename" && !renameKept(c)) {
8055
8609
  out3.push(
8056
- makeFinding(RULE8, policy, {
8610
+ makeFinding(RULE9, policy, {
8057
8611
  file: c.path,
8058
8612
  message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
8059
8613
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8067,7 +8621,7 @@ They WILL FAIL in v10.0.0
8067
8621
  if (c.before != null && c.after != null) {
8068
8622
  for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
8069
8623
  out3.push(
8070
- makeFinding(RULE8, policy, {
8624
+ makeFinding(RULE9, policy, {
8071
8625
  file: c.path,
8072
8626
  message: `CODEOWNERS was weakened: ${reason}.`,
8073
8627
  evidence: reason,
@@ -8089,7 +8643,7 @@ They WILL FAIL in v10.0.0
8089
8643
  const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
8090
8644
  if (!kept) {
8091
8645
  out3.push(
8092
- makeFinding(RULE8, policy, {
8646
+ makeFinding(RULE9, policy, {
8093
8647
  file: c.path,
8094
8648
  message: "A protected hook script no longer matches the shape `tamperward init` writes.",
8095
8649
  evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
@@ -8112,7 +8666,7 @@ They WILL FAIL in v10.0.0
8112
8666
  if (shebang || !live) {
8113
8667
  const why = shebang ?? (gate.length ? `its \`${gate[0].identity}\` is ${gate[0].state}${gate[0].why ? ` (${gate[0].why})` : ""}` : "it runs no `tamperward check`");
8114
8668
  out3.push(
8115
- makeFinding(RULE8, policy, {
8669
+ makeFinding(RULE9, policy, {
8116
8670
  file: c.path,
8117
8671
  message: `A protected hook script was added that does not run the gate live: ${why}. A new gate script needs a sign-off unless the gate is live in it.`,
8118
8672
  evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or make the gate live in it`,
@@ -8121,7 +8675,7 @@ They WILL FAIL in v10.0.0
8121
8675
  );
8122
8676
  } else if (earlyExit) {
8123
8677
  out3.push(
8124
- makeFinding(RULE8, policy, {
8678
+ makeFinding(RULE9, policy, {
8125
8679
  file: c.path,
8126
8680
  line: earlyExit.newLine ?? void 0,
8127
8681
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -8143,7 +8697,7 @@ They WILL FAIL in v10.0.0
8143
8697
  deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
8144
8698
  ].filter((d) => d !== null);
8145
8699
  out3.push(
8146
- makeFinding(RULE8, policy, {
8700
+ makeFinding(RULE9, policy, {
8147
8701
  file: c.path,
8148
8702
  message: `A hand-written protected hook script was changed: ${detail.length ? detail.join("; ") : "an edit other than a pin raise"}. Every edit to a gate script other than raising its pin needs a sign-off.`,
8149
8703
  evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or restore it`,
@@ -8155,7 +8709,7 @@ They WILL FAIL in v10.0.0
8155
8709
  }
8156
8710
  if (shebangChanged && c.after != null) {
8157
8711
  out3.push(
8158
- makeFinding(RULE8, policy, {
8712
+ makeFinding(RULE9, policy, {
8159
8713
  file: c.path,
8160
8714
  line: 1,
8161
8715
  message: `A protected hook's interpreter was changed: ${shebang}.`,
@@ -8166,7 +8720,7 @@ They WILL FAIL in v10.0.0
8166
8720
  }
8167
8721
  if (earlyExit) {
8168
8722
  out3.push(
8169
- makeFinding(RULE8, policy, {
8723
+ makeFinding(RULE9, policy, {
8170
8724
  file: c.path,
8171
8725
  line: earlyExit.newLine ?? void 0,
8172
8726
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -8178,7 +8732,7 @@ They WILL FAIL in v10.0.0
8178
8732
  const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
8179
8733
  if (deadGuard) {
8180
8734
  out3.push(
8181
- makeFinding(RULE8, policy, {
8735
+ makeFinding(RULE9, policy, {
8182
8736
  file: c.path,
8183
8737
  line: deadGuard.newLine ?? void 0,
8184
8738
  message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
@@ -8190,7 +8744,7 @@ They WILL FAIL in v10.0.0
8190
8744
  const lost = scriptWeakening(beforeLines, afterLines, opts);
8191
8745
  if (lost.length) {
8192
8746
  out3.push(
8193
- makeFinding(RULE8, policy, {
8747
+ makeFinding(RULE9, policy, {
8194
8748
  file: c.path,
8195
8749
  message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
8196
8750
  evidence: lost[0].evidence,
@@ -8209,7 +8763,7 @@ They WILL FAIL in v10.0.0
8209
8763
  const m = l.content.match(INLINE);
8210
8764
  if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
8211
8765
  out3.push(
8212
- makeFinding(RULE8, policy, {
8766
+ makeFinding(RULE9, policy, {
8213
8767
  file: c.path,
8214
8768
  line: l.newLine ?? void 0,
8215
8769
  message: `Rule "${m[1]}" was lowered from block to warn in the policy.`,
@@ -8219,7 +8773,7 @@ They WILL FAIL in v10.0.0
8219
8773
  );
8220
8774
  } else if (/enabled:\s*false\b/.test(l.content)) {
8221
8775
  out3.push(
8222
- makeFinding(RULE8, policy, {
8776
+ makeFinding(RULE9, policy, {
8223
8777
  file: c.path,
8224
8778
  line: l.newLine ?? void 0,
8225
8779
  message: "A rule was disabled (enabled: false) in the policy.",
@@ -8237,7 +8791,7 @@ They WILL FAIL in v10.0.0
8237
8791
  const hiding = updateIndexHiding(toks, policy);
8238
8792
  if (hiding) {
8239
8793
  out3.push(
8240
- makeFinding(RULE8, policy, {
8794
+ makeFinding(RULE9, policy, {
8241
8795
  file: repoRelative(hiding.file, ctx),
8242
8796
  message: `Hook tampering via shell: ${hiding.reason}.`,
8243
8797
  evidence: seg,
@@ -8253,7 +8807,7 @@ They WILL FAIL in v10.0.0
8253
8807
  const file = target ? repoRelative(target, ctx) : void 0;
8254
8808
  const outside = file !== void 0 && (isAbsolute6(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
8255
8809
  out3.push(
8256
- makeFinding(RULE8, policy, {
8810
+ makeFinding(RULE9, policy, {
8257
8811
  ...file !== void 0 ? { file } : {},
8258
8812
  message: `Hook tampering via shell: ${why}${outside}.`,
8259
8813
  evidence: seg,
@@ -8801,7 +9355,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
8801
9355
  if (!was || !now || was.path === now.path) return [];
8802
9356
  const samples = runnerSamples(now.path, policy, ctx, "pytest");
8803
9357
  return suiteNarrowings(was.content, now.content, now.path, samples).map(
8804
- (n) => makeFinding(RULE9, policy, {
9358
+ (n) => makeFinding(RULE10, policy, {
8805
9359
  file: now.path,
8806
9360
  message: `The effective pytest config moved from ${was.path} to ${now.path}, and ${n.path} is no longer selected: ${n.reason}.`,
8807
9361
  evidence: `inifile precedence ${was.path} \u2192 ${now.path} drops ${n.path} from the suite`,
@@ -8809,7 +9363,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
8809
9363
  })
8810
9364
  );
8811
9365
  }
8812
- var RULE9, JVM_TEST, TEST_DEFS, isTableOf, isEachOf, isDescribeEach, OTHER_RUNNER_DIR, WHOLE_SUITE_SCRIPT, SLICE_SCRIPT, SED_INPLACE, testDeletion;
9366
+ var RULE10, JVM_TEST, TEST_DEFS, isTableOf, isEachOf, isDescribeEach, OTHER_RUNNER_DIR, WHOLE_SUITE_SCRIPT, SLICE_SCRIPT, SED_INPLACE, testDeletion;
8813
9367
  var init_test_deletion = __esm({
8814
9368
  "src/detectors/test-deletion.ts"() {
8815
9369
  "use strict";
@@ -8826,7 +9380,7 @@ var init_test_deletion = __esm({
8826
9380
  init_narrow();
8827
9381
  init_reachability();
8828
9382
  init_runner_chain();
8829
- RULE9 = "test-deletion";
9383
+ RULE10 = "test-deletion";
8830
9384
  JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
8831
9385
  TEST_DEFS = {
8832
9386
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
@@ -8849,7 +9403,7 @@ var init_test_deletion = __esm({
8849
9403
  SLICE_SCRIPT = /^test:(?:unit|integration)$/;
8850
9404
  SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
8851
9405
  testDeletion = {
8852
- id: RULE9,
9406
+ id: RULE10,
8853
9407
  surface: ["file", "command"],
8854
9408
  certainty: "mechanical",
8855
9409
  run(changes, policy, _view, ctx) {
@@ -8881,7 +9435,7 @@ var init_test_deletion = __esm({
8881
9435
  if (c.op === "delete" && isTest) {
8882
9436
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
8883
9437
  out3.push(
8884
- makeFinding(RULE9, policy, {
9438
+ makeFinding(RULE10, policy, {
8885
9439
  file: c.path,
8886
9440
  message: "A test file was deleted.",
8887
9441
  evidence: c.path,
@@ -8890,7 +9444,7 @@ var init_test_deletion = __esm({
8890
9444
  );
8891
9445
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isSpecShaped(c.oldPath, c.before, c.after) && !isSpec(c.path)) {
8892
9446
  out3.push(
8893
- makeFinding(RULE9, policy, {
9447
+ makeFinding(RULE10, policy, {
8894
9448
  file: c.path,
8895
9449
  message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
8896
9450
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8899,7 +9453,7 @@ var init_test_deletion = __esm({
8899
9453
  );
8900
9454
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isTest && leavesWalk(c.oldPath, c.path)) {
8901
9455
  out3.push(
8902
- makeFinding(RULE9, policy, {
9456
+ makeFinding(RULE10, policy, {
8903
9457
  file: c.path,
8904
9458
  message: `A test file was renamed into a path the runner does not walk (${c.oldPath} \u2192 ${c.path}): ${leavesWalk(c.oldPath, c.path)}.`,
8905
9459
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8914,7 +9468,7 @@ var init_test_deletion = __esm({
8914
9468
  const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
8915
9469
  if (isRelocation(removed, before.min - after.min)) continue;
8916
9470
  out3.push(
8917
- makeFinding(RULE9, policy, {
9471
+ makeFinding(RULE10, policy, {
8918
9472
  file: c.path,
8919
9473
  message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
8920
9474
  evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
@@ -8925,9 +9479,22 @@ var init_test_deletion = __esm({
8925
9479
  } else if (c.op !== "delete" && c.after != null && !isSpec(c.path) && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
8926
9480
  const rn = runnerOf(c.path, c.after);
8927
9481
  const readable = rn !== "pytest" || effectivePytestConfig(c.path, ctx);
9482
+ for (const d of suiteDelegations(c.before, c.after, c.path)) {
9483
+ const cands = targetCandidates(c.path, d.target);
9484
+ const added = cands.find((f) => addedPaths.has(f));
9485
+ if (added === void 0 && cands.some((f) => isProtected(f, policy, "config"))) continue;
9486
+ out3.push(
9487
+ makeFinding(RULE10, policy, {
9488
+ file: c.path,
9489
+ message: `The runner config now delegates its selection to ${d.target}, ${added !== void 0 ? `a file this change adds (${added})` : "a file no protected glob covers"}: ${d.how}.`,
9490
+ evidence: `${c.path}: ${d.how}`,
9491
+ remediation: "Keep the selection in the protected config, or protect the file it delegates to (protected.config in .tamperward.yml). A config the gate cannot read can select nothing."
9492
+ })
9493
+ );
9494
+ }
8928
9495
  for (const n of readable ? suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx, rn)) : []) {
8929
9496
  out3.push(
8930
- makeFinding(RULE9, policy, {
9497
+ makeFinding(RULE10, policy, {
8931
9498
  file: c.path,
8932
9499
  message: `The runner no longer selects ${n.path}: ${n.reason}.`,
8933
9500
  evidence: `${n.path} dropped from the suite by ${c.path}`,
@@ -8939,12 +9506,12 @@ var init_test_deletion = __esm({
8939
9506
  const w = n.weakening;
8940
9507
  const evidence = `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${n.after === "" ? "deleted" : JSON.stringify(n.after)}`;
8941
9508
  out3.push(
8942
- w.state === "removed" ? makeFinding(RULE9, policy, {
9509
+ w.state === "removed" ? makeFinding(RULE10, policy, {
8943
9510
  file: c.path,
8944
9511
  message: `The test script no longer runs the ${w.kind} check: scripts.${n.script} ${w.what}.`,
8945
9512
  evidence,
8946
9513
  remediation: "Keep the script running its check. Replacing the runner in package.json removes every test from every `npm test` while the workflow line stays untouched."
8947
- }) : makeFinding(RULE9, policy, {
9514
+ }) : makeFinding(RULE10, policy, {
8948
9515
  file: c.path,
8949
9516
  message: `The test script now narrows the suite: ${w.what} ${w.direction} ${w.direction === "added" ? "to" : "from"} scripts.${n.script}.`,
8950
9517
  evidence,
@@ -9079,7 +9646,7 @@ var init_test_deletion = __esm({
9079
9646
  }
9080
9647
  if (why) {
9081
9648
  out3.push(
9082
- makeFinding(RULE9, policy, {
9649
+ makeFinding(RULE10, policy, {
9083
9650
  message: `Test removed via shell: ${why}.`,
9084
9651
  evidence,
9085
9652
  remediation: "Fix the code under test. Mutating a spec from the shell is still deleting the test \u2014 and is blocked the same way."
@@ -9138,7 +9705,7 @@ function significantLinesOrdered(src, path) {
9138
9705
  function liveSignificantLines(src, path) {
9139
9706
  return significantLinesOrdered(partition(src, path).live, path);
9140
9707
  }
9141
- var RULE10, MIN_REMOVED_LINES, MIN_REMOVED_ROWS, ws, TEST_DIR_FILE, KeptPool, testContentRemoval;
9708
+ var RULE11, MIN_REMOVED_LINES, MIN_REMOVED_ROWS, ws, TEST_DIR_FILE, KeptPool, testContentRemoval;
9142
9709
  var init_test_content_removal = __esm({
9143
9710
  "src/detectors/test-content-removal.ts"() {
9144
9711
  "use strict";
@@ -9149,7 +9716,7 @@ var init_test_content_removal = __esm({
9149
9716
  init_files();
9150
9717
  init_spec_shape();
9151
9718
  init_reachability();
9152
- RULE10 = "test-content-removal";
9719
+ RULE11 = "test-content-removal";
9153
9720
  MIN_REMOVED_LINES = 3;
9154
9721
  MIN_REMOVED_ROWS = 2;
9155
9722
  ws = (s) => s.replace(/\s+/g, "");
@@ -9190,7 +9757,7 @@ var init_test_content_removal = __esm({
9190
9757
  }
9191
9758
  };
9192
9759
  testContentRemoval = {
9193
- id: RULE10,
9760
+ id: RULE11,
9194
9761
  surface: ["file"],
9195
9762
  certainty: "mechanical",
9196
9763
  run(changes, policy) {
@@ -9234,7 +9801,7 @@ var init_test_content_removal = __esm({
9234
9801
  const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
9235
9802
  if (goneRows.length >= MIN_REMOVED_ROWS) {
9236
9803
  out3.push(
9237
- makeFinding(RULE10, policy, {
9804
+ makeFinding(RULE11, policy, {
9238
9805
  file: c.path,
9239
9806
  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.`,
9240
9807
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9245,7 +9812,7 @@ var init_test_content_removal = __esm({
9245
9812
  }
9246
9813
  if (hidden.length >= MIN_REMOVED_LINES) {
9247
9814
  out3.push(
9248
- makeFinding(RULE10, policy, {
9815
+ makeFinding(RULE11, policy, {
9249
9816
  file: c.path,
9250
9817
  message: `Test content made unreachable in a surviving spec: ${hidden.length} significant line(s) kept only where they cannot run (a string, a template literal, a dead branch, after a return).`,
9251
9818
  evidence: hidden.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9256,7 +9823,7 @@ var init_test_content_removal = __esm({
9256
9823
  }
9257
9824
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
9258
9825
  out3.push(
9259
- makeFinding(RULE10, policy, {
9826
+ makeFinding(RULE11, policy, {
9260
9827
  file: c.path,
9261
9828
  message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
9262
9829
  evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9279,14 +9846,14 @@ function namesProtectedSnapshot(seg, policy) {
9279
9846
  }
9280
9847
  return null;
9281
9848
  }
9282
- var RULE11, RUNNER3, PM_TEST_SCRIPT, UPDATE_ANY, UPDATE_WITH_RUNNER, INSTA_ACCEPT, UPDATE_EXPECT, REGEN_SCRIPT, MUTATE, snapshotRewrite;
9849
+ var RULE12, RUNNER3, PM_TEST_SCRIPT, UPDATE_ANY, UPDATE_WITH_RUNNER, INSTA_ACCEPT, UPDATE_EXPECT, REGEN_SCRIPT, MUTATE, snapshotRewrite;
9283
9850
  var init_snapshot_rewrite = __esm({
9284
9851
  "src/detectors/snapshot-rewrite.ts"() {
9285
9852
  "use strict";
9286
9853
  init_policy();
9287
9854
  init_finding();
9288
9855
  init_command();
9289
- RULE11 = "snapshot-rewrite";
9856
+ RULE12 = "snapshot-rewrite";
9290
9857
  RUNNER3 = /\b(?:jest|vitest|playwright|ava)\b/;
9291
9858
  PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
9292
9859
  UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
@@ -9296,12 +9863,12 @@ var init_snapshot_rewrite = __esm({
9296
9863
  REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
9297
9864
  MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}|\bfind\b.*\s-(?:delete\b|exec\s+rm\b)|\bgit\s+(?:checkout|restore)\b/;
9298
9865
  snapshotRewrite = {
9299
- id: RULE11,
9866
+ id: RULE12,
9300
9867
  surface: ["command", "file"],
9301
9868
  certainty: "mechanical",
9302
9869
  run(changes, policy) {
9303
9870
  const out3 = [];
9304
- const warn = (input) => out3.push(makeFinding(RULE11, policy, { ...input, defaultSeverity: "warn" }));
9871
+ const warn = (input) => out3.push(makeFinding(RULE12, policy, { ...input, defaultSeverity: "warn" }));
9305
9872
  for (const c of changes) {
9306
9873
  if (c.kind === "command") {
9307
9874
  for (const seg of segments(c.raw)) {
@@ -9349,16 +9916,16 @@ function isSnapshotChange(c, policy) {
9349
9916
  if (isProtected(c.path, policy, "snapshots")) return true;
9350
9917
  return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
9351
9918
  }
9352
- var RULE12, COMMIT_VIEWS, snapshotOnlyRewrite;
9919
+ var RULE13, COMMIT_VIEWS, snapshotOnlyRewrite;
9353
9920
  var init_snapshot_only = __esm({
9354
9921
  "src/detectors/snapshot-only.ts"() {
9355
9922
  "use strict";
9356
9923
  init_policy();
9357
9924
  init_finding();
9358
- RULE12 = "snapshot-only-rewrite";
9925
+ RULE13 = "snapshot-only-rewrite";
9359
9926
  COMMIT_VIEWS = ["staged", "range"];
9360
9927
  snapshotOnlyRewrite = {
9361
- id: RULE12,
9928
+ id: RULE13,
9362
9929
  surface: ["file"],
9363
9930
  certainty: "mechanical",
9364
9931
  run(changes, policy, view) {
@@ -9371,7 +9938,7 @@ var init_snapshot_only = __esm({
9371
9938
  if (!first) return [];
9372
9939
  const names = snaps.map((c) => c.path).slice(0, 3).join(", ");
9373
9940
  return [
9374
- makeFinding(RULE12, policy, {
9941
+ makeFinding(RULE13, policy, {
9375
9942
  file: first.path,
9376
9943
  message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
9377
9944
  evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
@@ -9428,7 +9995,7 @@ function staticFacts(node) {
9428
9995
  }
9429
9996
  function expectAssertion(node, sf) {
9430
9997
  if (!ts.isPropertyAccessExpression(node.expression)) return null;
9431
- const matcher = node.expression.name.text;
9998
+ const matcher2 = node.expression.name.text;
9432
9999
  let receiver = node.expression.expression;
9433
10000
  let polarity = "";
9434
10001
  if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "not") {
@@ -9443,10 +10010,10 @@ function expectAssertion(node, sf) {
9443
10010
  return {
9444
10011
  subject,
9445
10012
  polarity,
9446
- matcher,
10013
+ matcher: matcher2,
9447
10014
  args,
9448
10015
  firstArgFacts: staticFacts(node.arguments[0]),
9449
- canonical: [subject, polarity, matcher, ...args].join("|"),
10016
+ canonical: [subject, polarity, matcher2, ...args].join("|"),
9450
10017
  text: node.getText(sf).replace(/\s+/g, " ")
9451
10018
  };
9452
10019
  }
@@ -9549,7 +10116,7 @@ function findingForBlock(path, before, after, policy) {
9549
10116
  const weakened2 = directionalWeakening(before.assertions, after.assertions);
9550
10117
  if (weakened2) {
9551
10118
  const message = weakened2.kind === "throw-specificity" ? `Assertion weakened in test "${before.identity}": exception-message specificity was removed from toThrow().` : `Assertion weakened in test "${before.identity}": ${weakened2.before.matcher} was replaced by weaker ${weakened2.after.matcher} on the same subject.`;
9552
- return makeFinding(RULE13, policy, {
10119
+ return makeFinding(RULE14, policy, {
9553
10120
  file: path,
9554
10121
  message,
9555
10122
  evidence: `${weakened2.before.text} -> ${weakened2.after.text}`,
@@ -9558,7 +10125,7 @@ function findingForBlock(path, before, after, policy) {
9558
10125
  }
9559
10126
  const removed = pureRemoval(before.assertions, after.assertions);
9560
10127
  if (removed) {
9561
- return makeFinding(RULE13, policy, {
10128
+ return makeFinding(RULE14, policy, {
9562
10129
  file: path,
9563
10130
  message: `Assertion removed from kept test "${before.identity}" without a replacement assertion.`,
9564
10131
  evidence: removed.text,
@@ -9567,7 +10134,7 @@ function findingForBlock(path, before, after, policy) {
9567
10134
  }
9568
10135
  return null;
9569
10136
  }
9570
- var RULE13, TEST_CALLS, SUITE_CALLS, EXACT_MATCHERS, WEAK_MATCHERS, compact, assertionWeakening;
10137
+ var RULE14, TEST_CALLS, SUITE_CALLS, EXACT_MATCHERS, WEAK_MATCHERS, compact, assertionWeakening;
9571
10138
  var init_assertion_weakening = __esm({
9572
10139
  "src/detectors/assertion-weakening.ts"() {
9573
10140
  "use strict";
@@ -9576,14 +10143,14 @@ var init_assertion_weakening = __esm({
9576
10143
  init_finding();
9577
10144
  init_files();
9578
10145
  init_runner_chain();
9579
- RULE13 = "assertion-weakening";
10146
+ RULE14 = "assertion-weakening";
9580
10147
  TEST_CALLS = /* @__PURE__ */ new Set(["it", "test"]);
9581
10148
  SUITE_CALLS = /* @__PURE__ */ new Set(["describe", "suite", "context"]);
9582
10149
  EXACT_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual", "toMatchObject"]);
9583
10150
  WEAK_MATCHERS = /* @__PURE__ */ new Set(["toBeTruthy", "toBeDefined"]);
9584
10151
  compact = (s) => s.replace(/\s+/g, "");
9585
10152
  assertionWeakening = {
9586
- id: RULE13,
10153
+ id: RULE14,
9587
10154
  surface: ["file"],
9588
10155
  certainty: "heuristic",
9589
10156
  run(changes, policy) {
@@ -9617,7 +10184,7 @@ function significant(src, path) {
9617
10184
  }
9618
10185
  return out3;
9619
10186
  }
9620
- var RULE14, MIN_REMOVED_LINES2, ws2, testSupport;
10187
+ var RULE15, MIN_REMOVED_LINES2, ws2, testSupport;
9621
10188
  var init_test_support = __esm({
9622
10189
  "src/detectors/test-support.ts"() {
9623
10190
  "use strict";
@@ -9625,11 +10192,11 @@ var init_test_support = __esm({
9625
10192
  init_finding();
9626
10193
  init_spec_shape();
9627
10194
  init_files();
9628
- RULE14 = "test-support";
10195
+ RULE15 = "test-support";
9629
10196
  MIN_REMOVED_LINES2 = 3;
9630
10197
  ws2 = (s) => s.replace(/\s+/g, "");
9631
10198
  testSupport = {
9632
- id: RULE14,
10199
+ id: RULE15,
9633
10200
  surface: ["file"],
9634
10201
  certainty: "mechanical",
9635
10202
  run(changes, policy) {
@@ -9644,7 +10211,7 @@ var init_test_support = __esm({
9644
10211
  if (c.op === "delete") {
9645
10212
  if (!isSupport(c.path, c.before, null)) continue;
9646
10213
  out3.push(
9647
- makeFinding(RULE14, policy, {
10214
+ makeFinding(RULE15, policy, {
9648
10215
  file: c.path,
9649
10216
  message: "A test-support file was deleted (a helper, setup module or fixture under the test globs; it defines no test).",
9650
10217
  evidence: c.path,
@@ -9654,7 +10221,7 @@ var init_test_support = __esm({
9654
10221
  );
9655
10222
  } else if (c.op === "rename" && c.oldPath && isSupport(c.oldPath, c.before, c.after) && !isProtected(c.path, policy, "tests")) {
9656
10223
  out3.push(
9657
- makeFinding(RULE14, policy, {
10224
+ makeFinding(RULE15, policy, {
9658
10225
  file: c.path,
9659
10226
  message: `A test-support file was renamed out of the test globs (${c.oldPath} \u2192 ${c.path}).`,
9660
10227
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -9672,7 +10239,7 @@ var init_test_support = __esm({
9672
10239
  const gone = [...new Set(beforeSig)].filter((l) => !afterSet.has(l) && !kept.includes(ws2(l)));
9673
10240
  if (gone.length < MIN_REMOVED_LINES2) continue;
9674
10241
  out3.push(
9675
- makeFinding(RULE14, policy, {
10242
+ makeFinding(RULE15, policy, {
9676
10243
  file: c.path,
9677
10244
  message: `Test-support content removed: ${gone.length} significant line(s) gone from a helper, setup module or fixture under the test globs.`,
9678
10245
  evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9699,6 +10266,7 @@ var init_detectors = __esm({
9699
10266
  init_lint_suppression();
9700
10267
  init_test_skip();
9701
10268
  init_coverage_lowering();
10269
+ init_config_weakening();
9702
10270
  init_coverage_exclusion();
9703
10271
  init_ci_tampering();
9704
10272
  init_hook_tampering();
@@ -9715,6 +10283,7 @@ var init_detectors = __esm({
9715
10283
  lintSuppression,
9716
10284
  testSkip,
9717
10285
  coverageLowering,
10286
+ configWeakening,
9718
10287
  coverageExclusion,
9719
10288
  ciTampering,
9720
10289
  hookTampering,
@@ -11188,7 +11757,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
11188
11757
  };
11189
11758
  }
11190
11759
  function transientFindings(events, persistentPaths, policy, finalHash) {
11191
- if (!isEnabled(RULE15, policy)) return [];
11760
+ if (!isEnabled(RULE16, policy)) return [];
11192
11761
  const byPath = /* @__PURE__ */ new Map();
11193
11762
  for (const e of events) {
11194
11763
  if (!isProtected(e.path, policy)) continue;
@@ -11208,7 +11777,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
11208
11777
  const modeChurn = modes.size > 1;
11209
11778
  if (!contentChurn && !modeChurn) continue;
11210
11779
  const what = contentChurn ? `content was modified and restored within the turn (${hashes.size} distinct states observed)` : `file mode changed during the turn (${[...modes].map((m) => (m & 4095).toString(8)).join(" \u2192 ")})`;
11211
- const f = makeFinding(RULE15, policy, {
11780
+ const f = makeFinding(RULE16, policy, {
11212
11781
  file: path,
11213
11782
  message: `Transient protected mutation: ${what}, leaving no net diff.`,
11214
11783
  evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
@@ -11223,14 +11792,14 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
11223
11792
  }
11224
11793
  return out3;
11225
11794
  }
11226
- var RULE15, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
11795
+ var RULE16, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
11227
11796
  var init_fs_events = __esm({
11228
11797
  "src/detectors/fs-events.ts"() {
11229
11798
  "use strict";
11230
11799
  init_policy();
11231
11800
  init_finding();
11232
11801
  init_narrow();
11233
- RULE15 = "transient-protected-mutation";
11802
+ RULE16 = "transient-protected-mutation";
11234
11803
  MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
11235
11804
  MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
11236
11805
  DEFAULT_EVENT_READ_IO = {
@@ -12263,7 +12832,7 @@ var init_safe_write = __esm({
12263
12832
  import { execFileSync as execFileSync6 } from "node:child_process";
12264
12833
  import { createHash as createHash6 } from "node:crypto";
12265
12834
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync4 } from "node:fs";
12266
- import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join14, relative as relative3, resolve as resolve4 } from "node:path";
12835
+ import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute8, join as join14, relative as relative3, resolve as resolve4 } from "node:path";
12267
12836
  function gitConfig(cwd, key2) {
12268
12837
  try {
12269
12838
  return execFileSync6("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -12322,13 +12891,13 @@ function readWorkflowMark(src) {
12322
12891
  if (!m) return null;
12323
12892
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
12324
12893
  }
12325
- function toolSet(matcher) {
12326
- const m = String(matcher ?? "").trim();
12894
+ function toolSet(matcher2) {
12895
+ const m = String(matcher2 ?? "").trim();
12327
12896
  if (m === "" || m === "*") return null;
12328
12897
  return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
12329
12898
  }
12330
- function missingTools(matcher) {
12331
- const have = toolSet(matcher);
12899
+ function missingTools(matcher2) {
12900
+ const have = toolSet(matcher2);
12332
12901
  if (have === null) return [];
12333
12902
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
12334
12903
  }
@@ -12643,7 +13212,7 @@ function planPreCommit(cwd) {
12643
13212
  }
12644
13213
  const dir = hooksDir(cwd);
12645
13214
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
12646
- if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
13215
+ if (basename3(dir) === "_" && basename3(dirname4(dir)) === ".husky") {
12647
13216
  path = join14(dirname4(dir), "pre-commit");
12648
13217
  note = "husky";
12649
13218
  } else {
@@ -13100,7 +13669,7 @@ import {
13100
13669
  statSync as statSync3
13101
13670
  } from "node:fs";
13102
13671
  import {
13103
- basename as basename3,
13672
+ basename as basename4,
13104
13673
  delimiter,
13105
13674
  dirname as dirname5,
13106
13675
  isAbsolute as isAbsolute9,
@@ -13322,23 +13891,23 @@ function pythonName(name) {
13322
13891
  return /^(?:python(?:\d+(?:\.\d+)*)?|pytest|py\.test|pip(?:\d+(?:\.\d+)*)?)$/.test(name.toLowerCase());
13323
13892
  }
13324
13893
  function looksPython(command, executable, token) {
13325
- const exe = executable ? basename3(executable) : "";
13326
- const raw = token ? basename3(token) : "";
13894
+ const exe = executable ? basename4(executable) : "";
13895
+ const raw = token ? basename4(token) : "";
13327
13896
  if (pythonName(exe) || pythonName(raw)) return true;
13328
13897
  return /(?:^|[\s;&|()])(?:python(?:\d+(?:\.\d+)*)?|pytest|py\.test|pip(?:\d+(?:\.\d+)*)?)(?=$|[\s;&|()])/.test(command);
13329
13898
  }
13330
13899
  function inferredVenvFromToken(token, cwd) {
13331
13900
  if (!token || !token.includes("/") && !token.includes("\\")) return null;
13332
13901
  const path = isAbsolute9(token) ? token : resolve5(cwd, token);
13333
- if (!pythonName(basename3(path))) return null;
13902
+ if (!pythonName(basename4(path))) return null;
13334
13903
  const parent = dirname5(path);
13335
- const parentName = basename3(parent).toLowerCase();
13904
+ const parentName = basename4(parent).toLowerCase();
13336
13905
  if (parentName !== "bin" && parentName !== "scripts") return null;
13337
13906
  const root = dirname5(parent);
13338
13907
  return existsSync12(join15(root, "pyvenv.cfg")) ? root : null;
13339
13908
  }
13340
13909
  function unsupportedEcosystem(command, token) {
13341
- const name = token ? basename3(token).toLowerCase() : "";
13910
+ const name = token ? basename4(token).toLowerCase() : "";
13342
13911
  const raw = command.toLowerCase();
13343
13912
  if (["ruby", "bundle", "bundler", "rspec", "rake"].includes(name) || /(?:^|[\s;&|()])(?:bundle\s+exec|ruby|rspec|rake)(?=$|[\s;&|()])/.test(raw)) return "Ruby dependency environment discovery is not yet attestable";
13344
13913
  if (["mvn", "mvnw", "gradle", "gradlew", "java"].includes(name) || /(?:^|[\s;&|()])(?:mvn|mvnw|gradle|gradlew|java)(?=$|[\s;&|()])/.test(raw)) return "JVM dependency environment discovery is not yet attestable";
@@ -13348,7 +13917,7 @@ function unsupportedEcosystem(command, token) {
13348
13917
  function inferredVenv(executable) {
13349
13918
  if (!executable) return null;
13350
13919
  const parent = dirname5(executable);
13351
- const parentName = basename3(parent).toLowerCase();
13920
+ const parentName = basename4(parent).toLowerCase();
13352
13921
  if (parentName !== "bin" && parentName !== "scripts") return null;
13353
13922
  const root = dirname5(parent);
13354
13923
  return existsSync12(join15(root, "pyvenv.cfg")) ? root : null;