tamperward 2.25.1 → 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 +2098 -1488
  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
@@ -526,7 +532,22 @@ function defaultPolicy(version = 1) {
526
532
  "**/codecov.yml",
527
533
  "**/.codecov.yml"
528
534
  ],
529
- ci: [".github/workflows/**"],
535
+ // A workflow's checks can live in a composite action it `uses:` (the workflow
536
+ // keeps `uses: ./.github/actions/test` while the action's `run: npm test`
537
+ // becomes `echo ok`), and in the entry file of a CI system that is not GitHub
538
+ // Actions at all. Each is read by ci-tampering: the action's `runs.steps` get
539
+ // the workflow's removal/neutralisation pass, the other systems' entry files
540
+ // the generic check-line pass and no trigger logic (issue #437).
541
+ ci: [
542
+ ".github/workflows/**",
543
+ ".github/actions/**/action.y?(a)ml",
544
+ ".gitlab-ci.yml",
545
+ ".circleci/config.yml",
546
+ "Jenkinsfile",
547
+ "azure-pipelines.yml",
548
+ "bitbucket-pipelines.yml",
549
+ ".travis.yml"
550
+ ],
530
551
  // Recorded expected outputs. An assertion stored as data is still an assertion;
531
552
  // rewriting it from current output is the snapshot-update move the affordance
532
553
  // experiment measured at a 70% attempt / 100% through rate (snapshot-rewrite).
@@ -579,6 +600,11 @@ function defaultPolicy(version = 1) {
579
600
  // harness/fp-study/CAST-GROWTH-CORPUS.md), so it is a review prompt, not a gate.
580
601
  "ts-cast-growth": { severity: "warn" },
581
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" },
582
608
  "coverage-lowering": { severity: "block" },
583
609
  // the per-function form of coverage-lowering: an inline `istanbul ignore` /
584
610
  // `c8 ignore` / `v8 ignore` / `node:coverage ignore` / `# pragma: no cover` /
@@ -4185,24 +4211,24 @@ function topLevelStaticStrings(ctx) {
4185
4211
  }
4186
4212
  return resolved;
4187
4213
  }
4188
- function staticPropertyName(expr, ctx, strings) {
4214
+ function staticPropertyName(expr, ctx, strings2) {
4189
4215
  if (ts.isStringLiteralLike(expr)) return { name: expr.text, causes: [] };
4190
- if (ts.isParenthesizedExpression(expr)) return staticPropertyName(expr.expression, ctx, strings);
4216
+ if (ts.isParenthesizedExpression(expr)) return staticPropertyName(expr.expression, ctx, strings2);
4191
4217
  if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
4192
- const left = staticPropertyName(expr.left, ctx, strings);
4193
- const right = staticPropertyName(expr.right, ctx, strings);
4218
+ const left = staticPropertyName(expr.left, ctx, strings2);
4219
+ const right = staticPropertyName(expr.right, ctx, strings2);
4194
4220
  if (!left || !right) return null;
4195
4221
  return { name: left.name + right.name, causes: [...left.causes, ...right.causes] };
4196
4222
  }
4197
4223
  if (ts.isIdentifier(expr)) {
4198
4224
  const sym = symbolAt(ctx, expr);
4199
4225
  if (!sym) return null;
4200
- const value = strings.get(sym);
4226
+ const value = strings2.get(sym);
4201
4227
  return value ? { name: value.value, causes: value.causes } : null;
4202
4228
  }
4203
4229
  return null;
4204
4230
  }
4205
- function runnerChain(expr, ctx, binder, strings) {
4231
+ function runnerChain(expr, ctx, binder, strings2) {
4206
4232
  const props = [];
4207
4233
  const propNodes = [];
4208
4234
  const causes = [];
@@ -4217,7 +4243,7 @@ function runnerChain(expr, ctx, binder, strings) {
4217
4243
  continue;
4218
4244
  }
4219
4245
  if (!cur.argumentExpression) return null;
4220
- const property = staticPropertyName(cur.argumentExpression, ctx, strings);
4246
+ const property = staticPropertyName(cur.argumentExpression, ctx, strings2);
4221
4247
  if (!property) return null;
4222
4248
  props.unshift(property.name);
4223
4249
  propNodes.unshift(cur.argumentExpression);
@@ -4271,7 +4297,7 @@ function structuralNodeKey(node, sf) {
4271
4297
  return `${node.kind}(${children.map((child) => structuralNodeKey(child, sf)).join(",")})`;
4272
4298
  }
4273
4299
  function semanticSkipHits(ctx, binder) {
4274
- const strings = topLevelStaticStrings(ctx);
4300
+ const strings2 = topLevelStaticStrings(ctx);
4275
4301
  const hits = [];
4276
4302
  const unowned = /* @__PURE__ */ new Set();
4277
4303
  const callOrdinals = /* @__PURE__ */ new Map();
@@ -4283,7 +4309,7 @@ function semanticSkipHits(ctx, binder) {
4283
4309
  const ordinal = (callOrdinals.get(callIdentity) ?? 0) + 1;
4284
4310
  callOrdinals.set(callIdentity, ordinal);
4285
4311
  const callKey = `${callIdentity}\0${ordinal}`;
4286
- const chain = runnerChain(node.expression, ctx, binder, strings);
4312
+ const chain = runnerChain(node.expression, ctx, binder, strings2);
4287
4313
  if (chain === "unknown") {
4288
4314
  const callee = node.expression;
4289
4315
  const first = lineOf(callee.getStart(ctx.sf));
@@ -4321,7 +4347,7 @@ function semanticSkipHits(ctx, binder) {
4321
4347
  if (ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)) {
4322
4348
  name = prop.name.text;
4323
4349
  } else if (ts.isComputedPropertyName(prop.name)) {
4324
- const resolved = staticPropertyName(prop.name.expression, ctx, strings);
4350
+ const resolved = staticPropertyName(prop.name.expression, ctx, strings2);
4325
4351
  name = resolved?.name ?? null;
4326
4352
  propertyCauses = resolved?.causes ?? [];
4327
4353
  }
@@ -5584,1097 +5610,980 @@ var init_coverage_lowering = __esm({
5584
5610
  }
5585
5611
  });
5586
5612
 
5587
- // src/detectors/coverage-exclusion.ts
5588
- function isText(line, idx, lang) {
5589
- const opener = LINE_COMMENT[lang];
5590
- let quote = null;
5591
- for (let j = 0; j < idx; j++) {
5592
- const ch = line[j];
5593
- if (quote) {
5594
- if (ch === "\\") j++;
5595
- else if (ch === quote) quote = null;
5596
- continue;
5597
- }
5598
- if (ch === '"' || ch === "'" || ch === "`") quote = ch;
5599
- 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";
5600
5622
  }
5601
- 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;
5602
5637
  }
5603
- var RULE6, PATTERNS3, OUT_OF_SCOPE_PATH, LINE_COMMENT, coverageExclusion;
5604
- var init_coverage_exclusion = __esm({
5605
- "src/detectors/coverage-exclusion.ts"() {
5606
- "use strict";
5607
- init_select();
5608
- init_files();
5609
- init_policy();
5610
- init_finding();
5611
- RULE6 = "coverage-exclusion";
5612
- PATTERNS3 = {
5613
- js: [
5614
- { re: /\/\*\s*istanbul\s+ignore\b/, why: "`istanbul ignore` takes the next statement, branch or the whole file out of the coverage measurement" },
5615
- { 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" },
5616
- { 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" },
5617
- { re: /\/\*\s*node:coverage\s+(?:ignore|disable)\b/, why: "`node:coverage ignore/disable` takes the following code out of the coverage measurement" }
5618
- ],
5619
- 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" }],
5620
- rs: [{ re: /#\s*!?\[[^\]]*\bcoverage\s*\(\s*off\s*\)/, why: "`#[coverage(off)]` takes the item out of the coverage measurement" }],
5621
- go: [
5622
- {
5623
- re: /^\s*\/\/\s*(?:go:build\b|\+build\b)/,
5624
- why: "a build constraint added to an existing file takes it out of the default build, and out of `go test -cover`",
5625
- existingOnly: true
5626
- }
5627
- ]
5628
- };
5629
- 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)\.[^/]*$/;
5630
- LINE_COMMENT = { js: /^\/\//, go: /^\/\//, rs: /^\/\//, py: /^#/ };
5631
- coverageExclusion = {
5632
- id: RULE6,
5633
- surface: ["file"],
5634
- certainty: "mechanical",
5635
- run(changes, policy) {
5636
- const out3 = [];
5637
- for (const c of changes) {
5638
- if (c.kind !== "file" || c.op === "delete") continue;
5639
- const lang = langOf(c.path);
5640
- if (!lang) continue;
5641
- const patterns = PATTERNS3[lang];
5642
- if (!patterns) continue;
5643
- const category = protectedCategory(c.path, policy);
5644
- if (OUT_OF_SCOPE_PATH.test(c.path) || category === "tests" || category === "config") continue;
5645
- const removed = new Set(removedLines(c).map((l) => l.content.trim()));
5646
- const before = c.before ?? "";
5647
- for (const l of addedLines(c)) {
5648
- const trimmed = l.content.trim();
5649
- if (removed.has(trimmed)) continue;
5650
- for (const p of patterns) {
5651
- const m = p.re.exec(l.content);
5652
- if (!m || isText(l.content, m.index, lang)) continue;
5653
- if (p.existingOnly && (c.op === "add" || c.before == null || before.split("\n").some((b) => p.re.test(b)))) continue;
5654
- out3.push(
5655
- makeFinding(RULE6, policy, {
5656
- file: c.path,
5657
- line: l.newLine ?? void 0,
5658
- message: `Coverage exclusion added: ${p.why}.`,
5659
- evidence: trimmed,
5660
- remediation: "Cover the code with a test instead of excluding it from the measurement; if it is genuinely unreachable, say so in review.",
5661
- defaultSeverity: "warn"
5662
- })
5663
- );
5664
- break;
5665
- }
5666
- }
5667
- }
5668
- return out3;
5669
- }
5670
- };
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;
5671
5652
  }
5672
- });
5673
-
5674
- // src/detectors/gh-expression.ts
5675
- function foldExpressions(s) {
5676
- return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
5677
- const v = foldConst(e.trim());
5678
- if (v !== void 0 && v !== TRUTHY) return String(v);
5679
- return /\b(?:matrix|strategy|inputs)\./.test(e) ? MATRIX_TOKEN : EXPR_TOKEN;
5680
- });
5653
+ return { items, opaque };
5681
5654
  }
5682
- function lex(src) {
5683
- const out3 = [];
5684
- let i = 0;
5685
- while (i < src.length) {
5686
- const ch = src[i];
5687
- if (/\s/.test(ch)) {
5688
- i++;
5689
- continue;
5690
- }
5691
- if (ch === "'") {
5692
- let j = i + 1;
5693
- let s = "";
5694
- while (j < src.length) {
5695
- if (src[j] === "'" && src[j + 1] === "'") {
5696
- s += "'";
5697
- j += 2;
5698
- } else if (src[j] === "'") break;
5699
- else s += src[j++];
5700
- }
5701
- if (j >= src.length) return null;
5702
- out3.push({ t: "str", v: s });
5703
- i = j + 1;
5704
- continue;
5705
- }
5706
- const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
5707
- if (num) {
5708
- out3.push({ t: "num", v: num[0] });
5709
- i += num[0].length;
5710
- continue;
5711
- }
5712
- const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
5713
- if (op) {
5714
- out3.push({ t: "op", v: op[0] });
5715
- 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()) {
5716
5712
  continue;
5717
5713
  }
5718
- const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
5719
- if (id) {
5720
- out3.push({ t: "id", v: id[0] });
5721
- 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();
5722
5718
  continue;
5723
5719
  }
5724
- return null;
5720
+ if (key2 && /^\s+\S/.test(line)) out3[key2] += " " + line.trim();
5725
5721
  }
5726
5722
  return out3;
5727
5723
  }
5728
- function foldConst(src) {
5729
- const toks = lex(src);
5730
- if (!toks) return void 0;
5731
- let p = 0;
5732
- const peek = () => toks[p];
5733
- const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
5734
- const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
5735
- const eq = (a, b) => {
5736
- if (a === TRUTHY || b === TRUTHY) return void 0;
5737
- if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
5738
- if (a === null && b === null) return true;
5739
- const x = num(a);
5740
- const y2 = num(b);
5741
- return !Number.isNaN(x) && x === y2;
5742
- };
5743
- const or = () => {
5744
- let l = and();
5745
- while (eat("||")) {
5746
- const r = and();
5747
- if (l !== void 0 && truthy(l)) continue;
5748
- if (l !== void 0) l = r;
5749
- else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
5750
- }
5751
- return l;
5752
- };
5753
- const and = () => {
5754
- let l = cmp();
5755
- while (eat("&&")) {
5756
- const r = cmp();
5757
- if (l !== void 0 && !truthy(l)) continue;
5758
- if (l !== void 0) l = r;
5759
- else l = r !== void 0 && !truthy(r) ? false : void 0;
5760
- }
5761
- 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);
5762
5781
  };
5763
- const cmp = () => {
5764
- let l = unary();
5765
- for (; ; ) {
5766
- const t = peek();
5767
- if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
5768
- p++;
5769
- const r = unary();
5770
- if (l === void 0 || r === void 0) {
5771
- l = void 0;
5772
- continue;
5773
- }
5774
- if (t.v === "==" || t.v === "!=") {
5775
- const e = eq(l, r);
5776
- l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
5777
- } else {
5778
- const x = num(l);
5779
- const y2 = num(r);
5780
- if (Number.isNaN(x) || Number.isNaN(y2)) l = void 0;
5781
- else l = t.v === "<" ? x < y2 : t.v === ">" ? x > y2 : t.v === "<=" ? x <= y2 : x >= y2;
5782
- }
5783
- }
5782
+ const ignores = (key2, e) => {
5783
+ const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
5784
+ sel.ignore = [...sel.ignore ?? [], ...entries];
5784
5785
  };
5785
- const unary = () => {
5786
- if (eat("!")) {
5787
- const v2 = unary();
5788
- return v2 === void 0 ? void 0 : !truthy(v2);
5789
- }
5790
- return primary();
5786
+ const namePattern = (e) => {
5787
+ sel.present = true;
5788
+ sel.wide.push({ pattern: "\0testNamePattern " + (isStr(e) ? e.text : e.getText()), key: "testNamePattern" });
5791
5789
  };
5792
- const primary = () => {
5793
- const t = peek();
5794
- if (!t) return void 0;
5795
- if (eat("(")) {
5796
- const v2 = or();
5797
- 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;
5798
5799
  }
5799
- p++;
5800
- if (t.t === "str") return t.v;
5801
- if (t.t === "num") return Number(t.v);
5802
- if (t.t === "id") {
5803
- if (/^true$/i.test(t.v)) return true;
5804
- if (/^false$/i.test(t.v)) return false;
5805
- if (/^null$/i.test(t.v)) return null;
5806
- if (eat("(")) {
5807
- const args = [];
5808
- if (!eat(")")) {
5809
- for (; ; ) {
5810
- args.push(or());
5811
- if (eat(")")) break;
5812
- if (!eat(",")) return void 0;
5813
- }
5814
- }
5815
- let indexed = false;
5816
- while (eat("[")) {
5817
- or();
5818
- indexed = true;
5819
- if (!eat("]")) return void 0;
5820
- }
5821
- if (indexed) return void 0;
5822
- return callConst(t.v, args);
5823
- }
5824
- while (eat("[")) {
5825
- or();
5826
- 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);
5827
5832
  }
5828
- return void 0;
5829
5833
  }
5830
- return void 0;
5834
+ ts.forEachChild(node, visit);
5831
5835
  };
5832
- const v = or();
5833
- 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;
5834
5840
  }
5835
- function callConst(name, args) {
5836
- const vals = [];
5837
- for (const a of args) {
5838
- if (a === void 0 || a === TRUTHY) return void 0;
5839
- vals.push(a);
5840
- }
5841
- const s = (i) => vals[i] == null ? "" : String(vals[i]);
5842
- switch (name.toLowerCase()) {
5843
- case "contains":
5844
- return vals.length === 2 ? s(0).toLowerCase().includes(s(1).toLowerCase()) : void 0;
5845
- case "startswith":
5846
- return vals.length === 2 ? s(0).toLowerCase().startsWith(s(1).toLowerCase()) : void 0;
5847
- case "endswith":
5848
- return vals.length === 2 ? s(0).toLowerCase().endsWith(s(1).toLowerCase()) : void 0;
5849
- case "format": {
5850
- if (vals.length < 1) return void 0;
5851
- return s(0).replace(/\{\{|\}\}|\{(\d+)\}/g, (m, i) => m === "{{" ? "{" : m === "}}" ? "}" : s(Number(i) + 1));
5852
- }
5853
- case "join":
5854
- return vals.length >= 1 && vals.length <= 2 ? s(0) : void 0;
5855
- case "tojson":
5856
- return vals.length === 1 ? JSON.stringify(vals[0]) : void 0;
5857
- case "fromjson": {
5858
- if (vals.length !== 1) return void 0;
5859
- try {
5860
- const v = JSON.parse(s(0));
5861
- if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
5862
- return TRUTHY;
5863
- } catch {
5864
- return void 0;
5865
- }
5866
- }
5867
- default:
5868
- 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;
5869
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;
5870
5848
  }
5871
- var EXPR_TOKEN, MATRIX_TOKEN, TRUTHY, truthy;
5872
- var init_gh_expression = __esm({
5873
- "src/detectors/gh-expression.ts"() {
5874
- "use strict";
5875
- EXPR_TOKEN = "__expr__";
5876
- MATRIX_TOKEN = "__matrix__";
5877
- TRUTHY = /* @__PURE__ */ Symbol("truthy");
5878
- 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";
5879
5858
  }
5880
- });
5881
-
5882
- // src/detectors/invocation.ts
5883
- function canonical(core) {
5884
- let s = foldExpressions(core.replace(/^(['"])(.*)\1$/, "$2").trim()).replace(MATRIX_FLAG, " ").replace(/\s+/g, " ").trim();
5885
- s = s.replace(/^(?:cd|pushd)\s+\S+\s*&&\s*/, "");
5886
- s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
5887
- s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
5888
- s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
5889
- s = s.replace(/^exec\s+/, "");
5890
- return s.replace(/\s+--\s*$/, "").trim();
5859
+ return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
5891
5860
  }
5892
- function runnerNarrowed(cmd, args) {
5893
- 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;
5894
5869
  }
5895
- function runnerNarrowing(cmd, args) {
5896
- const t = RUNNER_NARROWINGS.find((x) => x.runner.test(cmd));
5897
- const m = t ? (" " + args.trim()).match(t.flags) : null;
5898
- return m ? m[0].trim().replace(/[=\s].*$/, "") : null;
5899
- }
5900
- function coverageFloorLowered(removed, added) {
5901
- const was = removed.match(COVERAGE_FLOOR);
5902
- const now = added.match(COVERAGE_FLOOR);
5903
- return was !== null && now !== null && Number(now[1]) < Number(was[1]);
5904
- }
5905
- function inCommandPosition(core) {
5906
- 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;
5907
5884
  }
5908
- function narrowedByPositional(args) {
5909
- 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;
5910
5897
  }
5911
- function positionalOf(args) {
5912
- const toks = args.trim().split(/\s+/).filter(Boolean);
5913
- for (let i = 0; i < toks.length; i++) {
5914
- const t = toks[i];
5915
- if (t === "--" || t.startsWith("-")) continue;
5916
- const prev = toks[i - 1];
5917
- if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
5918
- if (/[<>|;&]/.test(t)) continue;
5919
- 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;
5920
5909
  }
5921
5910
  return null;
5922
5911
  }
5923
- function checkKind(core) {
5924
- if (!invokesCheck(core)) return null;
5925
- const s = canonical(core);
5926
- if (/\btamperward\b/.test(s)) return "gate";
5927
- if (/\b(?:lint|eslint|biome|oxlint|golangci-lint|ruff)\b/i.test(s)) return "lint";
5928
- if (/\b(?:typecheck|type-check|tsc|tsgo|mypy)\b|\bdeno\s+check\b/i.test(s)) return "types";
5929
- return "test";
5930
- }
5931
- function survives(removedCore, isUses, afterCores, addedCores) {
5932
- if (isUses) {
5933
- const ref = usesRef(removedCore);
5934
- 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 {
5935
5927
  }
5936
- const r = canonical(removedCore);
5937
- const kind = checkKind(removedCore);
5938
- const narrowed = (args) => kind === "test" && narrowedByPositional(args);
5939
- let neutralised;
5940
- for (const raw of afterCores) {
5941
- const a = canonical(raw);
5942
- if (a === r) return { state: "kept" };
5943
- if (a.startsWith(r + " ")) {
5944
- const rest = a.slice(r.length);
5945
- if (NEUTRALISING_SUFFIX.test(rest) || narrowed(rest) || runnerNarrowed(a, rest)) neutralised ??= raw;
5946
- else return { state: "kept" };
5947
- } else if (r.startsWith(a + " ") && invokesCheck(a)) {
5948
- return { state: "kept" };
5949
- } else {
5950
- const w = a.match(TIMEOUT_WRAP);
5951
- if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
5952
- }
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
+ };
5953
5938
  }
5954
- let respelled = false;
5955
- for (const raw of addedCores) {
5956
- if (!kind || checkKind(raw) !== kind) continue;
5957
- const a = canonical(raw);
5958
- if (a.match(TIMEOUT_WRAP)) {
5959
- neutralised ??= raw;
5960
- continue;
5961
- }
5962
- if (!inCommandPosition(raw)) continue;
5963
- if (coverageFloorLowered(r, a)) {
5964
- neutralised ??= raw;
5965
- 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 ?? [])})`;
5966
5967
  }
5967
- const args = a.slice(a.indexOf(" ") + 1 || a.length);
5968
- if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowed(args) || runnerNarrowed(a, args))) neutralised ??= raw;
5969
- else respelled = true;
5970
- }
5971
- if (respelled) return { state: "kept" };
5972
- return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
5968
+ };
5973
5969
  }
5974
- function unwrap(seg) {
5975
- const s = seg.trim().replace(PYTHON_M, "").replace(NODE_BIN, "$1").replace(BIN_OF, "$1$2");
5976
- if (invokesCheck(s) || PRINTER.test(s) || /^["']/.test(s)) return s;
5977
- const toks = s.split(/\s+/);
5978
- for (let i = 1; i < toks.length && i <= 16; i++) {
5979
- const rest = toks.slice(i).join(" ");
5980
- 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) });
5981
5981
  }
5982
- return s;
5983
- }
5984
- function scriptKind3(inner) {
5985
- const kind = checkKind(inner);
5986
- if (!kind) return null;
5987
- const head = canonical(inner).match(/^(tests?|lint|type-?check|coverage)(?=[\s:]|$)/i)?.[1];
5988
- if (!head) return kind;
5989
- return /^type/i.test(head) ? "types" : /^lint/i.test(head) ? "lint" : "test";
5982
+ return out3;
5990
5983
  }
5991
- function runnerOfSegment(canon) {
5992
- const m = canon.match(RUNNER_NAME);
5993
- if (!m) return null;
5994
- const n = m[1];
5995
- 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;
5996
5992
  }
5997
- function statements(raw) {
5993
+ function delegationsIn(src, runner) {
5998
5994
  const out3 = [];
5999
- let single = false;
6000
- let double = false;
6001
- let last = 0;
6002
- for (let i = 0; i < raw.length; i++) {
6003
- const ch = raw[i];
6004
- if (ch === "\\" && !single) {
6005
- i++;
6006
- continue;
6007
- }
6008
- if (ch === "'" && !double) {
6009
- single = !single;
6010
- 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
+ }
6011
6025
  }
6012
- if (ch === '"' && !single) {
6013
- double = !double;
6014
- 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
+ }
6015
6061
  }
6016
- if (single || double) continue;
6017
- const prev = raw[i - 1];
6018
- const next = raw[i + 1];
6019
- let op = null;
6020
- if (ch === "&" && next === "&") op = "&&";
6021
- else if (ch === "|" && next === "|") op = "||";
6022
- else if (ch === "|" && next === "&") op = "|&";
6023
- else if (ch === ";" || ch === "\n") op = ";";
6024
- else if (ch === "|" && prev !== ">") op = "|";
6025
- else if (ch === "&" && prev !== ">" && next !== ">") op = "&";
6026
- if (!op) continue;
6027
- out3.push({ seg: raw.slice(last, i).trim(), op });
6028
- i += op.length - 1;
6029
- 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);
6030
6081
  }
6031
- out3.push({ seg: raw.slice(last).trim(), op: null });
6032
- 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}`)];
6033
6086
  }
6034
- function descriptors(cmd, runner, opts) {
6087
+ function setupEntriesIn(src, runner, path) {
6035
6088
  const out3 = [];
6036
- const stmts = statements(cmd);
6037
- for (let i = 0; i < stmts.length; i++) {
6038
- const { seg, op } = stmts[i];
6039
- const wrapped = seg.match(TIMEOUT_WRAP);
6040
- const inner = unwrap(wrapped ? wrapped[1] : seg);
6041
- if (!invokesCheck(inner)) continue;
6042
- const kind = scriptKind3(inner);
6043
- const canon = canonical(inner);
6044
- const rn = runner ?? runnerOfSegment(canon);
6045
- const args = canon.includes(" ") ? " " + canon.slice(canon.indexOf(" ") + 1) : " ";
6046
- if (wrapped) out3.push({ key: "timeout", what: "a timeout wrapper", direction: "added" });
6047
- const following = stmts[i + 1]?.seg ?? "";
6048
- if (op === "||" || op === "|" || op === "&" || op === ";" && !invokesCheck(unwrap(following))) {
6049
- out3.push({ key: `op:${op}`, what: `${op} ${following.split(/\s+/).slice(0, 2).join(" ")}`.trim(), direction: "added" });
6050
- }
6051
- for (const re of MASKING_FLAGS) {
6052
- const f = spelled(re, args);
6053
- if (f) out3.push({ key: `flag:${f}`, what: f, direction: "added" });
6054
- }
6055
- for (const re of GATE_FLAGS) {
6056
- const f = spelled(re, " " + seg);
6057
- 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;
6058
6100
  }
6059
- if (kind !== "test" || opts.sliceByDesign || opts.extended?.has(kind)) continue;
6060
- const flags = [SUITE_NARROWING_FLAGS, ...NARROWING_BY_RUNNER["*"], ...rn ? NARROWING_BY_RUNNER[rn] : []];
6061
- for (const re of flags) {
6062
- const f = spelled(re, args);
6063
- 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
+ }
6064
6120
  }
6065
- if (rn === "cargo" && CARGO_SKIP.test(args)) out3.push({ key: "flag:-- --skip", what: "-- --skip", direction: "added" });
6066
- const shared = runnerNarrowing(canon, args);
6067
- if (shared && !opts.excuseFlag?.(shared, canon)) out3.push({ key: `flag:${shared}`, what: shared, direction: "added" });
6068
- if (opts.fresh) continue;
6069
- const cfg = args.match(CONFIG_FLAG);
6070
- if (cfg && !opts.configReviewed?.(cfg[3])) out3.push({ key: `config:${cfg[3]}`, what: `${cfg[1]} ${cfg[3]}`, direction: "added" });
6071
- const pos = positionalOf(args);
6072
- if (pos) out3.push({ key: "positional", what: `a path positional (${pos})`, direction: "added" });
6073
- }
6074
- return out3;
6075
- }
6076
- function checkStatements(cmd) {
6077
- const out3 = [];
6078
- for (const seg of segments(cmd)) {
6079
- const w = seg.match(TIMEOUT_WRAP);
6080
- const inner = unwrap(w ? w[1] : seg);
6081
- const kind = scriptKind3(inner);
6082
- if (kind) out3.push({ kind, canon: canonical(inner) });
6083
- }
6121
+ ts.forEachChild(node, visit);
6122
+ };
6123
+ visit(sf);
6084
6124
  return out3;
6085
6125
  }
6086
- function checkKinds(cmd) {
6087
- return new Set(checkStatements(cmd).map((s) => s.kind));
6088
- }
6089
- function failsLoudly(cmd) {
6090
- const last = segments(cmd).pop();
6091
- return last !== void 0 && FAILS_LOUDLY.test(last.trim());
6092
- }
6093
- function invocationWeakening(before, after, runner, opts = {}) {
6094
- const was = checkKinds(before);
6095
- const now = checkKinds(after);
6096
- if (now.size === 0 && failsLoudly(after)) return { state: "kept" };
6097
- for (const k of was) {
6098
- if (now.has(k) || opts.relocated?.(k)) continue;
6099
- const instead = [...now].filter((x) => !was.has(x));
6100
- return { state: "removed", kind: k, what: instead.length ? `runs a ${instead.join("/")} check instead` : now.size ? "the check is gone" : "no check is run" };
6101
- }
6102
- const survived = new Set(checkStatements(before).map((s) => s.canon));
6103
- const extended = new Set(checkStatements(after).filter((s) => survived.has(s.canon)).map((s) => s.kind));
6104
- const read = { ...opts, fresh: was.size === 0, extended };
6105
- const beforeDs = descriptors(before, runner, read);
6106
- const afterDs = descriptors(after, runner, read);
6107
- const seenBefore = new Set(beforeDs.map((d) => d.key));
6108
- const added = afterDs.find((d) => d.direction === "added" && !seenBefore.has(d.key));
6109
- if (added) return { state: "neutralised", what: added.what, direction: "added" };
6110
- if (coverageFloorLowered(before, after)) return { state: "neutralised", what: "a lowered --cov-fail-under", direction: "added" };
6111
- const seenAfter = new Set(afterDs.map((d) => d.key));
6112
- const dropped = beforeDs.find((d) => d.direction === "removed" && !seenAfter.has(d.key));
6113
- if (dropped) return { state: "neutralised", what: dropped.what, direction: "removed" };
6114
- 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}`));
6115
6131
  }
6116
- 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;
6117
- var init_invocation = __esm({
6118
- "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"() {
6119
6135
  "use strict";
6120
- init_gh_expression();
6121
- init_command();
6122
- 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/;
6123
- 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/;
6124
- invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line);
6125
- MATRIX_FLAG = new RegExp(`(?:^|\\s)-{1,2}[\\w-]+(?:=|\\s+)${MATRIX_TOKEN}(?:/\\S*)?(?=\\s|$)`, "g");
6126
- usesRef = (core) => core.replace(/@.*$/, "");
6127
- SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
6128
- NEUTRALISING_SUFFIX = new RegExp(
6129
- `\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
6130
- );
6131
- TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
6132
- RUNNER_NARROWINGS = [
6133
- {
6134
- // a package-manager script: `test --prefix dir`, `-w pkg`, `--workspace=pkg`,
6135
- // `--if-present` (no script, no failure), pnpm's `--filter` / `-F`, npm's `-C`
6136
- runner: /^(?:test|tests|coverage)(?::\S+)?(?=\s|$)/,
6137
- flags: /\s--if-present(?=\s|$)|\s--prefix(?:=|\s)|\s-C(?:=|\s)|\s-w(?:=|\s)|\s--workspace(?:=|\s)|\s--filter(?:=|\s)|\s-F(?:=|\s)/
6138
- },
6139
- {
6140
- runner: /^(?:jest|vitest)\b/,
6141
- 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)/
6142
- },
6143
- { runner: /^playwright\b/, flags: /\s--shard(?:=|\s)|\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--grep-invert(?:=|\s)/ },
6144
- { runner: /^mocha\b/, flags: /\s--grep(?:=|\s)|\s-g(?:=|\s)|\s--fgrep(?:=|\s)|\s-f(?:=|\s)/ },
6145
- { runner: /^ava\b/, flags: /\s--match(?:=|\s)|\s-m(?:=|\s)/ },
6146
- { runner: /^node --test\b/, flags: /\s--test-name-pattern(?:=|\s)|\s--test-skip-pattern(?:=|\s)|\s--test-only(?=\s|$)/ },
6147
- {
6148
- // `-p no:<plugin>` disables collection when the plugin is the collector
6149
- // (`python`, `doctest`, `cov`); the cache, warning and output plugins select nothing
6150
- runner: /^pytest\b/,
6151
- 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|$)/
6152
- },
6153
- { runner: /^cargo test\b/, flags: /\s--\s(?:\S+\s+)*--skip(?:=|\s)|\s--exclude(?:=|\s)/ },
6154
- { 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.*"
6155
6149
  ];
6156
- COVERAGE_FLOOR = /--cov-fail-under(?:=|\s+)(\d+(?:\.\d+)?)/;
6157
- 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+)*)*/;
6158
- 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)/;
6159
- PATH_SHAPED = /\/|\.(?:test|spec)\./;
6160
- NARROWING_BY_RUNNER = {
6161
- "*": [/--shard\b/],
6162
- jest: [/--root\b/, /--rootDir\b/, /--testMatch\b/, /--testRegex\b/, /--modulePathIgnorePatterns\b/, /--selectProjects\b/, /--ignoreProjects\b/, /--filter\b/],
6163
- vitest: [/--grep\b/],
6164
- mocha: [/--grep\b/, /(?:^|\s)-g(?:\s|=)/, /--ignore\b/, /(?:^|\s)-f(?:\s|=)/, /--fgrep\b/],
6165
- 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|$)/],
6166
- go: [/(?:^|\s)-run(?:\s|=)/, /(?:^|\s)-skip(?:\s|=)/],
6167
- cargo: [],
6168
- 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 + "/**"] : [];
6169
6173
  };
6170
- CARGO_SKIP = /\s--\s+(?:\S+\s+)*--skip\b/;
6171
- MASKING_FLAGS = [/--passWithNoTests\b/, /--coverage=false\b/, /--coverageThreshold\b/, /--if-present\b/];
6172
- GATE_FLAGS = [/--check-coverage\b/];
6173
- CONFIG_FLAG = /(?:^|\s)(--config|-c)(?:=|\s+)(['"]?)([^\s'"]+)\2(?=\s|$)/;
6174
- PRINTER = /^(?:echo|printf|true|false|:|cat|exit|return|sleep|test|\[|node|git|curl|wget)(?=\s|$)/;
6175
- PYTHON_M = /^python\d?(?:\.\d+)?\s+-m\s+(?=pytest\b)/;
6176
- NODE_BIN = /^node(?:\s+-\S+)*\s+\S*node_modules\/(?:\.bin\/)?(jest|vitest|mocha|ava)\b\S*/;
6177
- BIN_OF = /\$\((?:yarn|npm|pnpm)\s+bin\s+(\w+)\)|\$\((?:yarn|npm|pnpm)\s+bin\)\/(\w+)/g;
6178
- RUNNER_NAME = /^(jest|vitest|mocha|pytest|go|cargo|node)\b/;
6179
- spelled = (re, s) => {
6180
- const m = s.match(re);
6181
- 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));
6182
6187
  };
6183
- 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("@");
6184
6211
  }
6185
6212
  });
6186
6213
 
6187
- // src/detectors/ci-tampering.ts
6188
- function isActiveWorkflow(path) {
6189
- 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
+ }
6190
6224
  }
6191
- function commandCore(line) {
6192
- return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\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
+ }
6193
6232
  }
6194
- function expressionOf(raw) {
6195
- const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
6196
- const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
6197
- return m ? m[1] : v;
6233
+ function corePattern(p) {
6234
+ return p.trim().replace(/^!/, "").replace(/^(?:\.\/|\/)+/, "").replace(/^(?:\*\*\/)+/, "").replace(/(?:\/\*\*?)+\/?$/, "").replace(/\/+$/, "");
6198
6235
  }
6199
- function isAlwaysFalse(raw) {
6200
- const e = expressionOf(raw);
6201
- if (e === "") return false;
6202
- const v = foldConst(e);
6203
- 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);
6204
6241
  }
6205
- function isTruthy(raw) {
6206
- const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
6207
- if (/^(?:true|yes|on|1)$/i.test(v)) return true;
6208
- const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
6209
- if (!m) return false;
6210
- const f = foldConst(m[1]);
6211
- 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}/**`]);
6212
6248
  }
6213
- function stepOf(lines, i) {
6214
- const ind = indentOf(lines[i]);
6215
- let s = i - 1;
6216
- while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
6217
- if (s < 0 || !STEP_START.test(lines[s])) return null;
6218
- const stepInd = indentOf(lines[s]);
6219
- let e = i + 1;
6220
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
6221
- 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;
6222
6261
  }
6223
- function jobOf(lines, i) {
6224
- const ind = indentOf(lines[i]);
6225
- let h = i - 1;
6226
- while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
6227
- if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
6228
- let t = h - 1;
6229
- while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
6230
- if (t < 0 || !/^jobs:/.test(lines[t])) return null;
6231
- const hi = indentOf(lines[h]);
6232
- let e = i + 1;
6233
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
6234
- 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)));
6235
6265
  }
6236
- function runBlocks(lines) {
6237
- const out3 = [];
6238
- for (let i = 0; i < lines.length; i++) {
6239
- if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
6240
- const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
6241
- let e = i + 1;
6242
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
6243
- out3.push({ start: i, end: e, body: lines.slice(i + 1, e) });
6244
- }
6245
- 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
+ };
6246
6275
  }
6247
- function commandLines(lines) {
6248
- const out3 = lines.slice();
6249
- for (const b of runBlocks(lines)) {
6250
- if (/^\s*-?\s*run:\s*>/.test(lines[b.start])) {
6251
- out3[b.start + 1] = b.body.map((l) => l.trim()).filter(Boolean).join(" ");
6252
- for (let k = b.start + 2; k < b.end; k++) out3[k] = "";
6253
- continue;
6254
- }
6255
- const indents = b.body.filter((l) => l.trim() !== "").map(indentOf);
6256
- const ind = indents.length ? Math.min(...indents) : 0;
6257
- const dedented = b.body.map((l) => l.slice(Math.min(ind, indentOf(l))));
6258
- const kept = stripHeredocs(dedented);
6259
- let j = 0;
6260
- for (let k = 0; k < dedented.length; k++) {
6261
- if (j < kept.length && kept[j] === dedented[k]) j++;
6262
- else out3[b.start + 1 + k] = "";
6263
- }
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
+ );
6264
6302
  }
6265
- return out3;
6266
- }
6267
- function flowList(v) {
6268
- const m = v.trim().match(/^\[(.*)\]$/);
6269
- if (!m) return null;
6270
- return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
6271
- }
6272
- function parseTriggers(src) {
6273
- const t = {
6274
- events: /* @__PURE__ */ new Set(),
6275
- branches: /* @__PURE__ */ new Map(),
6276
- branchesIgnore: /* @__PURE__ */ new Map(),
6277
- pathsIgnore: /* @__PURE__ */ new Map(),
6278
- paths: /* @__PURE__ */ new Map(),
6279
- types: /* @__PURE__ */ new Map(),
6280
- tags: /* @__PURE__ */ new Map(),
6281
- present: false
6282
- };
6283
- const lines = src.split("\n");
6284
- const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
6285
- if (i < 0) return t;
6286
- t.present = true;
6287
- const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
6288
- const inline = uncommented(head);
6289
- if (inline) {
6290
- const list3 = flowList(inline);
6291
- for (const e of list3 ?? [inline]) t.events.add(e);
6292
- 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
+ );
6293
6310
  }
6294
- let event = null;
6295
- let listKey = null;
6296
- let listInd = -1;
6297
- for (let j = i + 1; j < lines.length; j++) {
6298
- const l = lines[j];
6299
- if (l.trim() === "" || /^\s*#/.test(l)) continue;
6300
- const ind = indentOf(l);
6301
- if (ind === 0) break;
6302
- const body = uncommented(l);
6303
- const item = body.match(/^\s*-\s+(.+)$/);
6304
- if (item && listKey && event && ind > listInd) {
6305
- const target = t[listKey].get(event) ?? [];
6306
- target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
6307
- t[listKey].set(event, target);
6308
- continue;
6309
- }
6310
- const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
6311
- if (!kv) continue;
6312
- if (ind === 2 || event === null && ind < 4) {
6313
- event = kv[1];
6314
- t.events.add(event);
6315
- listKey = null;
6316
- continue;
6317
- }
6318
- const key2 = LIST_KEYS[kv[1]];
6319
- if (event && key2) {
6320
- listKey = key2;
6321
- listInd = ind;
6322
- const list3 = flowList(kv[2]);
6323
- t[listKey].set(event, list3 ?? []);
6324
- } 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
+ );
6325
6320
  }
6326
- return t;
6327
- }
6328
- function ghGlob(g) {
6329
- const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
6330
- try {
6331
- const r = new RegExp(`^${re}$`);
6332
- return (p) => r.test(p);
6333
- } catch {
6334
- 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
+ );
6335
6327
  }
6336
- }
6337
- function admits(globs4, name) {
6338
- let on = false;
6339
- for (const g of globs4) {
6340
- const neg = g.startsWith("!");
6341
- 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
+ );
6342
6337
  }
6343
- return on;
6344
6338
  }
6345
- function sourceProbes(ctx) {
6346
- const files = trackedFiles(ctx);
6347
- if (!files) return CODE_PROBES;
6348
- const code2 = files.filter((f) => langOf(f) !== null);
6349
- 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;
6350
6344
  }
6351
- function triggerNarrowings(before, after, opts) {
6352
- const out3 = [];
6353
- if (!before.present || !after.present) return out3;
6354
- const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
6355
- const candidates = opts.defaultBranch ? [opts.defaultBranch] : ["main", "master"];
6356
- const namesDefault = (l) => candidates.some((b) => admits(l, b));
6357
- for (const e of CODE_EVENTS) {
6358
- 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;
6359
6349
  }
6360
- for (const [e, list3] of after.pathsIgnore) {
6361
- const had = before.pathsIgnore.get(e) ?? [];
6362
- if (list3.some((g) => ALL(g) && !had.includes(g))) {
6363
- out3.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
6364
- 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);
6365
6356
  }
6366
- if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
6367
- if (had.length && ignoresEverySource(had, opts.sources)) continue;
6368
- 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`);
6369
- }
6370
- for (const [e, list3] of after.paths) {
6371
- if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
6372
- const had = before.paths.get(e);
6373
- if (had && !pathsMatchSource(had, opts.sources)) continue;
6374
- 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;
6375
6358
  }
6376
- for (const [e, list3] of after.types) {
6377
- if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
6378
- const had = before.types.get(e);
6379
- if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
6380
- 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);
6381
6362
  }
6382
- for (const [e, list3] of after.branchesIgnore) {
6383
- const had = before.branchesIgnore.get(e) ?? [];
6384
- 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;
6385
6370
  }
6386
- const events = /* @__PURE__ */ new Set([...before.branches.keys(), ...CODE_EVENTS.filter((e) => before.events.has(e))]);
6387
- for (const e of events) {
6388
- const was = before.branches.get(e) ?? (before.tags.has(e) ? null : ["**"]);
6389
- if (!was || !after.events.has(e)) continue;
6390
- const now = after.branches.get(e);
6391
- if (!now) {
6392
- const tags = after.tags.get(e);
6393
- 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`);
6394
- 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]));
6395
6396
  }
6396
- const renamed = (b) => DEFAULT_BRANCH.test(b) && now.includes(b) && admits(now, b) && opts.hasBranch(b) !== false;
6397
- const lost = candidates.filter((b) => admits(was, b) && !admits(now, b));
6398
- const lostDefault = lost.length > 0 && !["main", "master"].some(renamed);
6399
- const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
6400
- if (!lostDefault && !replaced) continue;
6401
- const dropped = was.filter((b) => !now.includes(b) && !ALL(b));
6402
- out3.push(
6403
- 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`
6404
- );
6405
- }
6406
- return out3;
6407
- }
6408
- function literalValue(raw) {
6409
- const v = foldExpressions(uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim());
6410
- return v.includes(EXPR_TOKEN) || v.includes(MATRIX_TOKEN) ? null : v;
6397
+ ts.forEachChild(node, visit);
6398
+ };
6399
+ visit(sf);
6411
6400
  }
6412
- function dirRunsNothing(dir, ctx) {
6413
- const files = trackedFiles(ctx);
6414
- if (!files) return true;
6415
- const d = dir.replace(/^\.\//, "").replace(/\/+$/, "");
6416
- if (d === "" || d === ".") return false;
6417
- 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;
6418
6451
  }
6419
- function defaultsOf(lines, i) {
6420
- const run = ancestorOf(lines, i);
6421
- if (run === null || !/^\s*run:\s*$/.test(lines[run])) return null;
6422
- const d = ancestorOf(lines, run);
6423
- 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;
6424
6456
  }
6425
- function enclosingStep(lines, i) {
6426
- let h = i;
6427
- while ((h = ancestorOf(lines, h)) !== null) {
6428
- if (!STEP_START.test(lines[h])) continue;
6429
- const ind = indentOf(lines[h]);
6430
- let e = i + 1;
6431
- while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
6432
- 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
+ );
6433
6483
  }
6434
- return null;
6435
- }
6436
- function ancestorOf(lines, i) {
6437
- const ind = indentOf(lines[i]);
6438
- let h = i - 1;
6439
- while (h >= 0 && (lines[h].trim() === "" || /^\s*#/.test(lines[h]) || indentOf(lines[h]) >= ind)) h--;
6440
- return h >= 0 ? h : null;
6441
6484
  }
6442
- var RULE7, USES, CHECK, YAML_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, MANIFEST, WORKDIR_LINE, REF_LINE, CHECKOUT, ciTampering;
6443
- var init_ci_tampering = __esm({
6444
- "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"() {
6445
6488
  "use strict";
6446
- init_select();
6489
+ init_lazy_deps();
6490
+ init_ts_lazy();
6447
6491
  init_policy();
6492
+ init_narrow();
6448
6493
  init_finding();
6449
- init_files();
6450
- init_hook_wiring();
6451
6494
  init_repo();
6452
- init_gh_expression();
6453
- init_invocation();
6454
- init_invocation();
6455
- RULE7 = "ci-tampering";
6456
- USES = /^\s*-\s*uses:/;
6457
- CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
6458
- YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
6459
- invokesCheck2 = (line) => invokesCheck(line) || INVOKES_TOOL.test(commandCore(line));
6460
- uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
6461
- SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
6462
- indentOf = (l) => l.length - l.trimStart().length;
6463
- STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
6464
- REPORTER_ACTION = /^\s*-?\s*uses:\s*\S*?(?:[\w-]*-reporter|[\w-]*-results?[\w-]*|upload-[\w-]*|codecov[\w-]*|coveralls[\w-]*)(?:@|\s|$)/i;
6465
- usesCheck = (l) => USES.test(l) && CHECK.test(l) && !REPORTER_ACTION.test(l);
6466
- stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l));
6467
- jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck2(l) || usesCheck(l) || /^\s*uses:\s*\S/.test(l));
6468
- NEUTRALISERS = [
6469
- { re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
6470
- { re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
6471
- { re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
6472
- { re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
6473
- ];
6474
- LIST_KEYS = {
6475
- branches: "branches",
6476
- "branches-ignore": "branchesIgnore",
6477
- "paths-ignore": "pathsIgnore",
6478
- paths: "paths",
6479
- types: "types",
6480
- 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));
6481
6514
  };
6482
- DEFAULT_BRANCH = /^(?:main|master)$/;
6483
- ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
6484
- CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
6485
- PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
6486
- pathsMatchSource = (globs4, sources) => sources.some((s) => admits(globs4, s));
6487
- ignoresEverySource = (globs4, sources) => sources.length > 0 && sources.every((s) => admits(globs4, s));
6488
- 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"];
6489
- REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
6490
- MANIFEST = /(?:^|\/)(?:package\.json|pyproject\.toml|setup\.py|setup\.cfg|Cargo\.toml|go\.mod|Makefile|build\.gradle(?:\.kts)?|pom\.xml|Gemfile|deno\.jsonc?)$/;
6491
- WORKDIR_LINE = /^\s*-?\s*working-directory:\s*(.+?)\s*$/;
6492
- REF_LINE = /^\s*ref:\s*(.+?)\s*$/;
6493
- CHECKOUT = /^\s*-?\s*uses:\s*actions\/checkout(?:@|\s|$)/;
6494
- ciTampering = {
6495
- 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,
6496
6547
  surface: ["file"],
6497
6548
  certainty: "mechanical",
6498
6549
  run(changes, policy, _view, ctx) {
6499
6550
  const out3 = [];
6500
- const afterByPath = /* @__PURE__ */ new Map();
6501
- for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
6502
- let triggerOpts = null;
6503
- for (const c of changes) {
6504
- if (c.kind !== "file") continue;
6505
- if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
6506
- out3.push(
6507
- makeFinding(RULE7, policy, {
6508
- file: c.path,
6509
- message: `A CI workflow was renamed so it no longer runs (${c.oldPath} \u2192 ${c.path}).`,
6510
- evidence: `${c.oldPath} \u2192 ${c.path}`,
6511
- 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."
6512
- })
6513
- );
6514
- continue;
6515
- }
6516
- if (!isProtected(c.path, policy, "ci")) continue;
6517
- const afterLines = c.after != null ? c.after.split("\n") : null;
6518
- const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
6519
- for (const l of addedLines(c)) {
6520
- const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
6521
- const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
6522
- if (!coe && !cond) continue;
6523
- const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
6524
- if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
6525
- if (afterLines && !step && l.newLine != null) {
6526
- const job = jobOf(afterLines, l.newLine - 1);
6527
- if (job && !jobHasCheck(afterLines, job)) continue;
6528
- }
6529
- if (coe && isTruthy(coe[1])) {
6530
- out3.push(
6531
- makeFinding(RULE7, policy, {
6532
- file: c.path,
6533
- line: l.newLine ?? void 0,
6534
- message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
6535
- evidence: l.content.trim(),
6536
- remediation: "Remove it. A check that cannot fail is not a check."
6537
- })
6538
- );
6539
- } else if (cond && isAlwaysFalse(cond[1])) {
6540
- out3.push(
6541
- makeFinding(RULE7, policy, {
6542
- file: c.path,
6543
- line: l.newLine ?? void 0,
6544
- message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
6545
- evidence: l.content.trim(),
6546
- remediation: "Re-enable the step instead of conditioning it off."
6547
- })
6548
- );
6549
- }
6550
- }
6551
- if (afterLines) {
6552
- for (const b of runBlocks(afterLines)) {
6553
- if (!b.body.some(invokesCheck2)) continue;
6554
- const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
6555
- for (let k = 0; k < b.body.length; k++) {
6556
- const idx = b.start + 1 + k;
6557
- if (!addedAt.has(idx + 1)) continue;
6558
- const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
6559
- if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
6560
- out3.push(
6561
- makeFinding(RULE7, policy, {
6562
- file: c.path,
6563
- line: idx + 1,
6564
- message: `A check's run block was neutralised: ${n.what}.`,
6565
- evidence: b.body[k].trim(),
6566
- remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
6567
- })
6568
- );
6569
- }
6570
- }
6571
- for (const l of addedLines(c)) {
6572
- if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
6573
- if (SHELL_ERREXIT.test(uncommented(l.content.replace(/^\s*-?\s*shell:\s*/, "")))) continue;
6574
- const step = stepOf(afterLines, l.newLine - 1);
6575
- if (step && !stepHasCheck(afterLines, step)) continue;
6576
- out3.push(
6577
- makeFinding(RULE7, policy, {
6578
- file: c.path,
6579
- line: l.newLine,
6580
- message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
6581
- evidence: l.content.trim(),
6582
- remediation: "Keep the default shell (fail-fast) on steps that run checks."
6583
- })
6584
- );
6585
- }
6586
- for (const l of addedLines(c)) {
6587
- if (l.newLine == null) continue;
6588
- const i = l.newLine - 1;
6589
- const wd = l.content.match(WORKDIR_LINE);
6590
- if (wd) {
6591
- const dir = literalValue(wd[1]);
6592
- if (dir === null || !dirRunsNothing(dir, ctx)) continue;
6593
- const step2 = stepOf(afterLines, i);
6594
- if (step2) {
6595
- if (!stepHasCheck(afterLines, step2)) continue;
6596
- } else {
6597
- const d = defaultsOf(afterLines, i);
6598
- if (d === null) continue;
6599
- const job = jobOf(afterLines, d);
6600
- if (job ? !jobHasCheck(afterLines, job) : !afterLines.some((x) => invokesCheck2(x) || usesCheck(x))) continue;
6601
- }
6602
- out3.push(
6603
- makeFinding(RULE7, policy, {
6604
- file: c.path,
6605
- line: l.newLine,
6606
- message: `A check was pointed at ${dir} by working-directory \u2014 a directory holding no code or package the check could run.`,
6607
- evidence: l.content.trim(),
6608
- remediation: "Run the check where the code is. A suite pointed at an empty directory passes on nothing."
6609
- })
6610
- );
6611
- continue;
6612
- }
6613
- const ref = l.content.match(REF_LINE);
6614
- if (!ref) continue;
6615
- const step = enclosingStep(afterLines, i);
6616
- if (!step || !afterLines.slice(step[0], step[1]).some((x) => CHECKOUT.test(x))) continue;
6617
- const target = literalValue(ref[1]);
6618
- if (target === null) continue;
6619
- out3.push(
6620
- makeFinding(RULE7, policy, {
6621
- file: c.path,
6622
- line: l.newLine,
6623
- message: `actions/checkout was pinned to ref: ${target} \u2014 the checks run against that ref, not against the change under review.`,
6624
- evidence: l.content.trim(),
6625
- remediation: "Let checkout take the event's ref (its default). A suite that checks out main proves nothing about the pull request."
6626
- })
6627
- );
6628
- }
6629
- if (c.before != null && c.after != null) {
6630
- triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
6631
- for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
6632
- out3.push(
6633
- makeFinding(RULE7, policy, {
6634
- file: c.path,
6635
- message: `The workflow's triggers were narrowed: ${reason}.`,
6636
- evidence: reason,
6637
- 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."
6638
- })
6639
- );
6640
- }
6641
- }
6642
- }
6643
- const afterCommands = afterLines ? commandLines(afterLines) : null;
6644
- const afterCores = (afterCommands ?? addedLines(c).map((l) => l.content)).map(commandCore);
6645
- const addedCores = addedLines(c).map((l) => commandCore(afterCommands && l.newLine != null ? afterCommands[l.newLine - 1] : l.content));
6646
- for (const l of afterLines ?? []) {
6647
- const reused = l.match(REUSED_WORKFLOW)?.[1];
6648
- const content = reused ? afterByPath.get(reused) : void 0;
6649
- if (content == null || reused === c.path) continue;
6650
- const cores = commandLines(content.split("\n")).map(commandCore);
6651
- afterCores.push(...cores);
6652
- addedCores.push(...cores);
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;
6653
6564
  }
6654
- for (const l of removedLines(c)) {
6655
- if (/^\s*#/.test(l.content)) continue;
6656
- const isUsesLine = usesCheck(l.content);
6657
- const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck2(l.content);
6658
- if (!isUsesLine && !isCheckCommand) continue;
6659
- const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
6660
- if (s.state === "kept") continue;
6661
- if (s.state === "neutralised") {
6662
- out3.push(
6663
- makeFinding(RULE7, policy, {
6664
- file: c.path,
6665
- message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
6666
- evidence: `${l.content.trim()} \u2192 ${s.by}`,
6667
- remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
6668
- })
6669
- );
6670
- continue;
6671
- }
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;
6672
6580
  out3.push(
6673
- makeFinding(RULE7, policy, {
6581
+ makeFinding(RULE6, policy, {
6674
6582
  file: c.path,
6675
- message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
6676
- evidence: l.content.trim(),
6677
- 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"
6678
6587
  })
6679
6588
  );
6680
6589
  }
@@ -6685,444 +6594,1130 @@ var init_ci_tampering = __esm({
6685
6594
  }
6686
6595
  });
6687
6596
 
6688
- // src/detectors/suite-config.ts
6689
- function runnerSkips(path) {
6690
- const parts = path.split("/");
6691
- if (/\.rs$/.test(path)) {
6692
- const i = parts.indexOf("tests");
6693
- if (i < 0 || parts.slice(0, i).includes("src")) return null;
6694
- const rest = parts.slice(i + 1);
6695
- if (rest.length === 1 || rest.length === 2 && rest[1] === "main.rs") return null;
6696
- return "cargo builds only tests/*.rs and tests/*/main.rs as test targets";
6697
- }
6698
- if (/\.go$/.test(path)) {
6699
- const hit = parts.find((p) => p === "testdata" || p.startsWith("_") || p.startsWith("."));
6700
- if (hit === void 0) return null;
6701
- return `go test skips ${hit === "testdata" ? "testdata/" : hit.startsWith("_") ? "_*" : ".*"} paths (${JSON.stringify(hit)})`;
6702
- }
6703
- if (/\.py$/.test(path)) {
6704
- const hit = PYTEST_DEFAULT_IGNORE.find((g) => globs2([g])(path));
6705
- return hit === void 0 ? null : `pytest's default norecursedirs matches it (${JSON.stringify(hit)})`;
6706
- }
6707
- if (/\.[cm]?[jt]sx?$/.test(path)) {
6708
- const hit = JS_UNWALKED.find((g) => globs2([g])(path));
6709
- return hit === void 0 ? null : `the runner's default exclude matches it (${JSON.stringify(hit)})`;
6710
- }
6711
- return null;
6712
- }
6713
- function keyName2(name) {
6714
- if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
6715
- return null;
6716
- }
6717
- function literals2(e) {
6718
- if (isStr(e)) return { items: [e.text], opaque: false };
6719
- if (!ts.isArrayLiteralExpression(e)) return { items: [], opaque: true };
6720
- const items = [];
6721
- let opaque = false;
6722
- for (const el of e.elements) {
6723
- if (isStr(el)) items.push(el.text);
6724
- else if (ts.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
6725
- items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
6726
- } else opaque = true;
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;
6727
6610
  }
6728
- return { items, opaque };
6729
- }
6730
- function ownerKey2(node) {
6731
- const obj = node.parent;
6732
- if (!obj || !ts.isObjectLiteralExpression(obj)) return null;
6733
- let q = obj.parent;
6734
- while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
6735
- return q && ts.isPropertyAssignment(q) ? keyName2(q.name) : null;
6611
+ return quote !== null;
6736
6612
  }
6737
- function asExpression2(src) {
6738
- const t = src.trim();
6739
- return t.startsWith("{") ? `(${t})` : src;
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
+ });
6740
6691
  }
6741
- function runnerOf(path, content) {
6742
- const base = path.split("/").pop() ?? path;
6743
- if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
6744
- if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
6745
- if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
6746
- if (base === "pytest.ini" || base === ".pytest.ini") return "pytest";
6747
- if (content == null) return null;
6748
- if (base === "tox.ini" && /^\s*\[pytest\]/m.test(content)) return "pytest";
6749
- if (base === "setup.cfg" && /^\s*\[tool:pytest\]/m.test(content)) return "pytest";
6750
- if (base === "pyproject.toml" && /^\s*\[tool\.pytest\.ini_options\]/m.test(content)) return "pytest";
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
+ }
6751
6931
  return null;
6752
6932
  }
6753
- function claimsPytest(name, src) {
6754
- if (name === "pytest.ini" || name === ".pytest.ini") return true;
6755
- 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;
6756
- return section !== null && section.test(src);
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" };
6757
6987
  }
6758
- function effectivePytestFile(files) {
6759
- for (const name of PYTEST_INI_ORDER) {
6760
- const content = files.get(name);
6761
- if (content == null) continue;
6762
- if (claimsPytest(name, content)) return { path: name, content };
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;
6763
6995
  }
6764
- return null;
6996
+ return s;
6765
6997
  }
6766
- function effectivePytestConfig(path, ctx) {
6767
- if (path.includes("/")) return false;
6768
- const base = path;
6769
- const rank = PYTEST_INI_ORDER.findIndex((name) => name === base);
6770
- if (rank < 0) return false;
6771
- const files = ctx?.trackedFiles;
6772
- if (!files) return true;
6773
- const rootNames = new Set(files.filter((f) => !f.includes("/")));
6774
- return !PYTEST_INI_ORDER.slice(0, rank).some((n) => rootNames.has(n));
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";
6775
7004
  }
6776
- function pytestBlock(src) {
6777
- const lines = src.split(/\r?\n/);
6778
- const head = /^\s*\[(pytest|tool:pytest|tool\.pytest\.ini_options)\]\s*$/;
6779
- let i = lines.findIndex((l) => head.test(l));
6780
- if (i < 0) return null;
6781
- const out3 = {};
6782
- let key2 = null;
6783
- for (i += 1; i < lines.length; i++) {
6784
- const line = lines[i];
6785
- if (/^\s*\[/.test(line)) break;
6786
- if (/^\s*[#;]/.test(line) || !line.trim()) {
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++;
6787
7020
  continue;
6788
7021
  }
6789
- const m = line.match(/^(\w[\w.-]*)\s*[=:]\s*(.*)$/);
6790
- if (m) {
6791
- key2 = m[1];
6792
- out3[key2] = m[2].trim();
7022
+ if (ch === "'" && !double) {
7023
+ single = !single;
6793
7024
  continue;
6794
7025
  }
6795
- if (key2 && /^\s+\S/.test(line)) out3[key2] += " " + line.trim();
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" });
6796
7087
  }
6797
7088
  return out3;
6798
7089
  }
6799
- function pytestTokens(raw) {
6800
- const v = raw.trim();
6801
- if (v.startsWith("[")) {
6802
- return [...v.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? "");
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) });
6803
7097
  }
6804
- const unq2 = v.replace(/^"([\s\S]*)"$/, "$1").replace(/^'([\s\S]*)'$/, "$1");
6805
- return [...unq2.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3] ?? "").filter(Boolean);
7098
+ return out3;
6806
7099
  }
6807
- function pytestSelection(src) {
6808
- const sel = empty();
6809
- const blk = pytestBlock(src);
6810
- if (!blk) return sel;
6811
- sel.present = true;
6812
- 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" }));
6813
- if (blk.testpaths !== void 0) sel.roots = pytestTokens(blk.testpaths).map((t) => t.replace(/^\.\//, ""));
6814
- if (blk.python_files !== void 0) sel.include = pytestTokens(blk.python_files).map((g) => g.includes("/") ? g : "**/" + g);
6815
- for (const key2 of ["python_classes", "python_functions"]) {
6816
- if (blk[key2] !== void 0) ignore.push({ pattern: "\0" + key2, key: key2 });
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" };
6817
7115
  }
6818
- const opts = blk.addopts !== void 0 ? pytestTokens(blk.addopts) : [];
6819
- for (let j = 0; j < opts.length; j++) {
6820
- const o = opts[j];
6821
- const val = (inline) => o.includes("=") ? o.slice(o.indexOf("=") + 1) : opts[++j] ?? inline;
6822
- if (o === "--ignore" || o.startsWith("--ignore=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--ignore" });
6823
- else if (o === "--ignore-glob" || o.startsWith("--ignore-glob=")) {
6824
- const g = val("");
6825
- ignore.push({ pattern: g, key: "--ignore-glob" });
6826
- if (!g.startsWith("**/") && !g.startsWith("/")) ignore.push({ pattern: "**/" + g, key: "--ignore-glob" });
6827
- } else if (o === "--deselect" || o.startsWith("--deselect=")) for (const g of pathGlobs(val(""))) ignore.push({ pattern: g, key: "--deselect" });
6828
- else if (o === "-k" || o.startsWith("-k=")) {
6829
- for (const w of (val("").match(/\bnot\s+([A-Za-z_][\w]*)/g) ?? []).map((t) => t.replace(/^not\s+/, "")))
6830
- ignore.push({ pattern: `**/*${w}*`, key: "-k" });
6831
- } else if (o === "-m" || o.startsWith("-m=")) {
6832
- ignore.push({ pattern: "\0-m " + val(""), key: "-m" });
6833
- } else if (o === "-p" || o.startsWith("-p=") || o.length > 2 && o.startsWith("-p")) {
6834
- const v = o === "-p" ? opts[++j] ?? "" : o.startsWith("-p=") ? o.slice(3) : o.slice(2);
6835
- if (v.startsWith("no:")) ignore.push({ pattern: "\0-p " + v, key: "-p" });
6836
- } else if (o === "--collect-only" || o === "--co") ignore.push({ pattern: "**", key: o });
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*$/;
6837
7198
  }
6838
- sel.ignore = ignore;
6839
- return sel;
7199
+ });
7200
+
7201
+ // src/detectors/ci-tampering.ts
7202
+ function isActiveWorkflow(path) {
7203
+ return /(^|\/)\.github\/workflows\/[^/]+\.ya?ml$/.test(path);
6840
7204
  }
6841
- function dirValue(e) {
6842
- if (!isStr(e)) return { dir: null, opaque: true };
6843
- const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
6844
- if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
6845
- return { dir: d, opaque: false };
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();
6846
7207
  }
6847
- function collect(root, runner) {
6848
- const sel = empty();
6849
- const seen = /* @__PURE__ */ new Set();
6850
- const take = (key2, e) => {
6851
- const { items, opaque } = literals2(e);
6852
- if (opaque || seen.has(key2)) sel.opaque = true;
6853
- seen.add(key2);
6854
- sel.present = true;
6855
- return items.map(stripRootDir);
6856
- };
6857
- const ignores = (key2, e) => {
6858
- const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
6859
- sel.ignore = [...sel.ignore ?? [], ...entries];
6860
- };
6861
- let projectList = null;
6862
- const projects = (e) => {
6863
- sel.present = true;
6864
- projectList = e;
6865
- };
6866
- const visit = (node) => {
6867
- if (ts.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
6868
- sel.present = true;
6869
- sel.opaque = true;
6870
- }
6871
- if (ts.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
6872
- sel.present = true;
6873
- sel.opaque = true;
6874
- }
6875
- if (ts.isPropertyAssignment(node)) {
6876
- const k = keyName2(node.name);
6877
- if (runner === "jest") {
6878
- if (k === "testMatch") sel.include = take(k, node.initializer);
6879
- else if (k === "testRegex") sel.regex = take(k, node.initializer);
6880
- else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
6881
- else if (k === "roots") sel.roots = take(k, node.initializer);
6882
- else if (k === "rootDir") {
6883
- const { dir, opaque } = dirValue(node.initializer);
6884
- sel.present = true;
6885
- if (opaque) sel.opaque = true;
6886
- else sel.base = dir;
6887
- } else if (k === "projects") {
6888
- projects(node.initializer);
6889
- return;
6890
- }
6891
- } else if (ownerKey2(node) === "test") {
6892
- if (k === "include") sel.include = take(k, node.initializer);
6893
- else if (k === "exclude") ignores(k, node.initializer);
6894
- else if (k === "dir" || k === "root") {
6895
- const { dir, opaque } = dirValue(node.initializer);
6896
- sel.present = true;
6897
- if (opaque) sel.opaque = true;
6898
- else sel.base = dir;
6899
- } else if (k === "projects") {
6900
- projects(node.initializer);
6901
- return;
6902
- }
6903
- }
6904
- }
6905
- ts.forEachChild(node, visit);
6906
- };
6907
- visit(root);
6908
- if (projectList) sel.projects = readProjects(projectList, sel, runner);
6909
- if (sel.projects?.some((p) => p.opaque)) sel.opaque = true;
6910
- return sel;
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;
6911
7212
  }
6912
- function unwrapConfigCall(e) {
6913
- let x = e;
6914
- while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isSatisfiesExpression(x)) {
6915
- x = x.expression;
6916
- }
6917
- if (ts.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
6918
- return x;
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);
6919
7218
  }
6920
- function spreadsIntoConfig(node, runner) {
6921
- const obj = node.parent;
6922
- if (!ts.isObjectLiteralExpression(obj)) return false;
6923
- const keys = obj.properties.map((p) => ts.isPropertyAssignment(p) ? keyName2(p.name) : null);
6924
- if (runner === "vitest") {
6925
- if (keys.includes("test")) return true;
6926
- let q = obj.parent;
6927
- while (q && (ts.isParenthesizedExpression(q) || ts.isAsExpression(q) || q.kind === ts.SyntaxKind.SatisfiesExpression)) q = q.parent;
6928
- return !!q && ts.isPropertyAssignment(q) && keyName2(q.name) === "test";
6929
- }
6930
- return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
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);
6931
7226
  }
6932
- function extendsOf(el) {
6933
- for (const p of el.properties) {
6934
- if (!ts.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
6935
- if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) return true;
6936
- if (p.initializer.kind === ts.SyntaxKind.FalseKeyword) return null;
6937
- return "path";
6938
- }
6939
- 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];
6940
7236
  }
6941
- function readProject(el, root, runner) {
6942
- const p = collect(el, runner);
6943
- const ext = extendsOf(el);
6944
- if (ext === "path") p.opaque = true;
6945
- else if (ext === true && root) {
6946
- p.include ??= root.include;
6947
- p.regex ??= root.regex;
6948
- p.ignore ??= root.ignore;
6949
- p.roots ??= root.roots;
6950
- p.base ??= root.base;
6951
- if (root.opaque) p.opaque = true;
6952
- }
6953
- p.present = true;
6954
- return p;
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];
6955
7249
  }
6956
- function readProjects(e, root, runner) {
6957
- if (!ts.isArrayLiteralExpression(e)) {
6958
- root.opaque = true;
6959
- return [];
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) });
6960
7258
  }
6961
- const list3 = [];
6962
- for (const el of e.elements) {
6963
- const lit = unwrapConfigCall(el);
6964
- if (ts.isObjectLiteralExpression(lit)) list3.push(readProject(lit, root, runner));
6965
- else root.opaque = true;
7259
+ return out3;
7260
+ }
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] = "";
7267
+ continue;
7268
+ }
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] = "";
7277
+ }
6966
7278
  }
6967
- return list3;
7279
+ return out3;
6968
7280
  }
6969
- function workspaceArray(sf) {
6970
- for (const st of sf.statements) {
6971
- let e;
6972
- if (ts.isExportAssignment(st)) e = st.expression;
6973
- else if (ts.isExpressionStatement(st) && ts.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
6974
- if (!e) continue;
6975
- while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
6976
- e = e.expression;
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);
7285
+ }
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
7296
+ };
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;
7323
+ }
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;
6977
7331
  }
6978
- if (ts.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
6979
- return ts.isArrayLiteralExpression(e) ? e : null;
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;
6980
7339
  }
6981
- return null;
7340
+ return t;
6982
7341
  }
6983
- function parseSelection(src, runner, path = "") {
6984
- let sel = empty();
6985
- if (runner === "pytest") return pytestSelection(src);
7342
+ function ghGlob(g) {
7343
+ const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
6986
7344
  try {
6987
- const sf = parseSource("cfg.ts", asExpression2(src));
6988
- if (!sf) return sel;
6989
- if (runner === "vitest" && isWorkspaceFile(path)) {
6990
- const arr = workspaceArray(sf);
6991
- if (arr) {
6992
- sel.projects = readProjects(arr, sel, runner);
6993
- sel.present = true;
6994
- if (sel.projects.some((p) => p.opaque)) sel.opaque = true;
6995
- } else if (src.trim()) sel.opaque = true;
6996
- } else sel = collect(sf, runner);
7345
+ const r = new RegExp(`^${re}$`);
7346
+ return (p) => r.test(p);
6997
7347
  } catch {
7348
+ return () => false;
6998
7349
  }
6999
- return sel;
7000
7350
  }
7001
- function predicate(sel, runner) {
7002
- if (sel.projects) {
7003
- const ps = sel.projects.map((p) => predicate(p, runner));
7004
- return {
7005
- selects: (p) => ps.some((x) => x.selects(p)),
7006
- why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
7007
- };
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;
7008
7356
  }
7009
- const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : runner === "pytest" ? PYTEST_DEFAULT_INCLUDE : VITEST_DEFAULT_INCLUDE);
7010
- const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : runner === "pytest" ? PYTEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({
7011
- pattern,
7012
- key: runner === "jest" ? "testPathIgnorePatterns" : runner === "pytest" ? "norecursedirs" : "test.exclude"
7013
- }));
7014
- const blockWide = ignoreList.filter((e) => e.pattern.startsWith("\0"));
7015
- const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
7016
- const ignoredBy = (p) => blockWide[0] ?? ignoreList.filter((e) => !e.pattern.startsWith("\0")).find((e) => (runner === "jest" ? regexes([e.pattern]) : globs2([e.pattern]))(p));
7017
- const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
7018
- const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
7019
- const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
7020
- return {
7021
- selects: (p) => {
7022
- const r = rebase(p);
7023
- return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
7024
- },
7025
- why: (p) => {
7026
- const r = rebase(p);
7027
- const baseKey = runner === "jest" ? "rootDir" : "test.dir";
7028
- if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
7029
- if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
7030
- const hit = ignoredBy(r);
7031
- if (hit) {
7032
- if (hit.pattern.startsWith("\0")) return `${hit.key} narrows the whole run (${JSON.stringify(hit.pattern.slice(1))})`;
7033
- return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
7034
- }
7035
- const key2 = runner === "jest" ? sel.include ? "testMatch" : sel.regex ? "testRegex" : "testMatch (default)" : sel.include ? "test.include" : "test.include (default)";
7036
- return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? includeList ?? [])})`;
7037
- }
7038
- };
7357
+ return on;
7039
7358
  }
7040
- function suiteNarrowings(before, after, path, samples) {
7041
- const runner = runnerOf(path, after) ?? runnerOf(path, before);
7042
- if (!runner) return [];
7043
- const b = parseSelection(before ?? "", runner, path);
7044
- const a = parseSelection(after, runner, path);
7045
- if (!a.present || a.opaque) return [];
7046
- const pb = predicate(b, runner);
7047
- const pa = predicate(a, runner);
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) {
7048
7366
  const out3 = [];
7049
- for (const s of samples) {
7050
- if (pb.selects(s) && !pa.selects(s)) out3.push({ path: s, reason: pa.why(s) });
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`);
7389
+ }
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`);
7395
+ }
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`);
7399
+ }
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;
7409
+ }
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
+ );
7051
7419
  }
7052
7420
  return out3;
7053
7421
  }
7054
- 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;
7055
- var init_suite_config = __esm({
7056
- "src/detectors/suite-config.ts"() {
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`] : [];
7427
+ }
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;
7431
+ }
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];
7453
+ }
7454
+ return null;
7455
+ }
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"() {
7057
7465
  "use strict";
7058
- init_lazy_deps();
7059
- init_ts_lazy();
7060
- JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
7061
- JEST_DEFAULT_IGNORE = ["/node_modules/"];
7062
- VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
7063
- PYTEST_DEFAULT_INCLUDE = ["**/test_*.py", "**/*_test.py"];
7064
- PYTEST_DEFAULT_IGNORE = ["**/build/**", "**/dist/**", "**/node_modules/**", "**/venv/**", "**/.*/**"];
7065
- VITEST_DEFAULT_EXCLUDE = [
7066
- "**/node_modules/**",
7067
- "**/dist/**",
7068
- "**/cypress/**",
7069
- "**/.{idea,git,cache,output,temp}/**",
7070
- "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
7071
- ];
7072
- JS_UNWALKED = [...VITEST_DEFAULT_EXCLUDE, "**/.*/**"];
7073
- CANONICAL_SAMPLES = [
7074
- "src/a.test.ts",
7075
- "src/a.spec.ts",
7076
- "test/a.test.ts",
7077
- "tests/a.test.js",
7078
- "src/__tests__/a.ts",
7079
- "a.test.tsx",
7080
- "a.spec.js",
7081
- "test/a.test.mjs"
7082
- ];
7083
- PYTEST_CANONICAL_SAMPLES = [
7084
- "tests/test_a.py",
7085
- "tests/a_test.py",
7086
- "test_a.py",
7087
- "src/tests/test_a.py",
7088
- "tests/conftest.py"
7089
- ];
7090
- isStr = (e) => ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e);
7091
- PYTEST_INI_ORDER = ["pytest.ini", ".pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg"];
7092
- pathGlobs = (p) => {
7093
- const clean = p.replace(/^\.\//, "").replace(/::.*$/, "").replace(/\/+$/, "");
7094
- return clean ? [clean, clean + "/**"] : [];
7095
- };
7096
- isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
7097
- empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
7098
- stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
7099
- JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
7100
- globs2 = (patterns) => {
7101
- const ms = patterns.map((g) => {
7102
- try {
7103
- return picomatch(g, { dot: true });
7104
- } catch {
7105
- return () => false;
7106
- }
7107
- });
7108
- return (path) => ms.some((m) => m(path));
7109
- };
7110
- globList2 = (patterns) => {
7111
- const pos = globs2(patterns.filter((g) => !g.startsWith("!")));
7112
- const neg = globs2(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
7113
- return (path) => pos(path) && !neg(path);
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" }
7497
+ ];
7498
+ LIST_KEYS = {
7499
+ branches: "branches",
7500
+ "branches-ignore": "branchesIgnore",
7501
+ "paths-ignore": "pathsIgnore",
7502
+ paths: "paths",
7503
+ types: "types",
7504
+ tags: "tags"
7114
7505
  };
7115
- regexes = (patterns) => {
7116
- const rs = patterns.flatMap((p) => {
7117
- try {
7118
- return [new RegExp(p.replace(/<rootDir>/g, ""))];
7119
- } catch {
7120
- 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
+ }
7121
7717
  }
7122
- });
7123
- return (path) => rs.some((r) => r.test("/" + path));
7718
+ return out3;
7719
+ }
7124
7720
  };
7125
- underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
7126
7721
  }
7127
7722
  });
7128
7723
 
@@ -7150,7 +7745,7 @@ function safeParse(src) {
7150
7745
  return null;
7151
7746
  }
7152
7747
  }
7153
- function effective(raw) {
7748
+ function effective2(raw) {
7154
7749
  const v = raw.version;
7155
7750
  const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
7156
7751
  const base = defaultPolicy(version);
@@ -7215,8 +7810,8 @@ function policyWeakening(before, after, probe) {
7215
7810
  if (b === null) return null;
7216
7811
  const a = safeParse(after);
7217
7812
  if (a === null) return ["the policy no longer parses as a valid policy (emptied or corrupted)"];
7218
- const be = effective(b);
7219
- const ae = effective(a);
7813
+ const be = effective2(b);
7814
+ const ae = effective2(a);
7220
7815
  const reasons = [];
7221
7816
  if (ae.version < be.version) {
7222
7817
  reasons.push(
@@ -7479,7 +8074,7 @@ function entryShape(h) {
7479
8074
  }
7480
8075
  return { problems, runs };
7481
8076
  }
7482
- function judgeGateEntry(event, matcher, h) {
8077
+ function judgeGateEntry(event, matcher2, h) {
7483
8078
  const cmd = typeof h.command === "string" ? h.command : "";
7484
8079
  const a = analyseGateCommand(cmd, event);
7485
8080
  const shape = entryShape(h);
@@ -7487,13 +8082,13 @@ function judgeGateEntry(event, matcher, h) {
7487
8082
  let runs = a.runs && shape.runs;
7488
8083
  let tools = null;
7489
8084
  if (event === "PreToolUse") {
7490
- const sel = matcherSelection(matcher);
8085
+ const sel = matcherSelection(matcher2);
7491
8086
  tools = sel.tools;
7492
8087
  if (sel.problem) {
7493
8088
  problems.push(sel.problem);
7494
8089
  }
7495
- } else if (matcher !== void 0 && !(typeof matcher === "string" && (matcher === "" || matcher === "*"))) {
7496
- 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`);
7497
8092
  runs = false;
7498
8093
  }
7499
8094
  return { runs, problems, pin: a.pin, tools };
@@ -7700,7 +8295,7 @@ function claudeSettingsFindings(c, policy) {
7700
8295
  if (c.after == null) return null;
7701
8296
  const after = parseSettings(c.after);
7702
8297
  const before = c.before != null ? parseSettings(c.before) : null;
7703
- const finding = (reason) => makeFinding(RULE8, policy, {
8298
+ const finding = (reason) => makeFinding(RULE9, policy, {
7704
8299
  file: c.path,
7705
8300
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
7706
8301
  evidence: reason,
@@ -7725,7 +8320,7 @@ function policyFindings(c, policy, ctx) {
7725
8320
  const reasons = c.before == null ? policyAddWeakening(c.after, probe) : policyWeakening(c.before, c.after, probe);
7726
8321
  if (reasons === null) return null;
7727
8322
  return reasons.map(
7728
- (reason) => makeFinding(RULE8, policy, {
8323
+ (reason) => makeFinding(RULE9, policy, {
7729
8324
  file: c.path,
7730
8325
  message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
7731
8326
  evidence: reason,
@@ -7835,7 +8430,7 @@ function configFindings(c, changes, policy, ctx) {
7835
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);
7836
8431
  }
7837
8432
  return reasons.map(
7838
- (reason) => makeFinding(RULE8, policy, {
8433
+ (reason) => makeFinding(RULE9, policy, {
7839
8434
  file: c.path,
7840
8435
  message: `The hook configuration was weakened: ${reason}.`,
7841
8436
  evidence: reason,
@@ -7886,7 +8481,7 @@ function huskyRuntimeWrite(path, content, ctx) {
7886
8481
  (i) => i.identity === "tamperward check --staged" && i.state === "live" && i.pin === TW_VERSION
7887
8482
  );
7888
8483
  }
7889
- 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;
7890
8485
  var init_hook_tampering = __esm({
7891
8486
  "src/detectors/hook-tampering.ts"() {
7892
8487
  "use strict";
@@ -7899,7 +8494,7 @@ var init_hook_tampering = __esm({
7899
8494
  init_command();
7900
8495
  init_repo();
7901
8496
  init_hook_wiring();
7902
- RULE8 = "hook-tampering";
8497
+ RULE9 = "hook-tampering";
7903
8498
  ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
7904
8499
  EVENTS = ["PreToolUse", "Stop"];
7905
8500
  isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -7967,7 +8562,7 @@ They WILL FAIL in v10.0.0
7967
8562
  isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
7968
8563
  isHookNote = (path) => !/(?:^|\/)\.husky\/_\//.test(path) && /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
7969
8564
  hookTampering = {
7970
- id: RULE8,
8565
+ id: RULE9,
7971
8566
  surface: ["file", "command"],
7972
8567
  certainty: "mechanical",
7973
8568
  run(changes, policy, _view, ctx) {
@@ -7980,7 +8575,7 @@ They WILL FAIL in v10.0.0
7980
8575
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
7981
8576
  for (const reason of packageJsonWeakening(c.before, c.after)) {
7982
8577
  out3.push(
7983
- makeFinding(RULE8, policy, {
8578
+ makeFinding(RULE9, policy, {
7984
8579
  file: c.path,
7985
8580
  message: `The hook installation was weakened: ${reason}.`,
7986
8581
  evidence: reason,
@@ -7993,7 +8588,7 @@ They WILL FAIL in v10.0.0
7993
8588
  }
7994
8589
  if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
7995
8590
  out3.push(
7996
- makeFinding(RULE8, policy, {
8591
+ makeFinding(RULE9, policy, {
7997
8592
  file: c.path,
7998
8593
  message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
7999
8594
  evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
@@ -8003,7 +8598,7 @@ They WILL FAIL in v10.0.0
8003
8598
  }
8004
8599
  if (c.op === "delete") {
8005
8600
  out3.push(
8006
- makeFinding(RULE8, policy, {
8601
+ makeFinding(RULE9, policy, {
8007
8602
  file: c.path,
8008
8603
  message: "A protected hook/policy file was deleted.",
8009
8604
  evidence: c.path,
@@ -8012,7 +8607,7 @@ They WILL FAIL in v10.0.0
8012
8607
  );
8013
8608
  } else if (c.op === "rename" && !renameKept(c)) {
8014
8609
  out3.push(
8015
- makeFinding(RULE8, policy, {
8610
+ makeFinding(RULE9, policy, {
8016
8611
  file: c.path,
8017
8612
  message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
8018
8613
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8026,7 +8621,7 @@ They WILL FAIL in v10.0.0
8026
8621
  if (c.before != null && c.after != null) {
8027
8622
  for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
8028
8623
  out3.push(
8029
- makeFinding(RULE8, policy, {
8624
+ makeFinding(RULE9, policy, {
8030
8625
  file: c.path,
8031
8626
  message: `CODEOWNERS was weakened: ${reason}.`,
8032
8627
  evidence: reason,
@@ -8048,7 +8643,7 @@ They WILL FAIL in v10.0.0
8048
8643
  const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
8049
8644
  if (!kept) {
8050
8645
  out3.push(
8051
- makeFinding(RULE8, policy, {
8646
+ makeFinding(RULE9, policy, {
8052
8647
  file: c.path,
8053
8648
  message: "A protected hook script no longer matches the shape `tamperward init` writes.",
8054
8649
  evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
@@ -8071,7 +8666,7 @@ They WILL FAIL in v10.0.0
8071
8666
  if (shebang || !live) {
8072
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`");
8073
8668
  out3.push(
8074
- makeFinding(RULE8, policy, {
8669
+ makeFinding(RULE9, policy, {
8075
8670
  file: c.path,
8076
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.`,
8077
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`,
@@ -8080,7 +8675,7 @@ They WILL FAIL in v10.0.0
8080
8675
  );
8081
8676
  } else if (earlyExit) {
8082
8677
  out3.push(
8083
- makeFinding(RULE8, policy, {
8678
+ makeFinding(RULE9, policy, {
8084
8679
  file: c.path,
8085
8680
  line: earlyExit.newLine ?? void 0,
8086
8681
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -8102,7 +8697,7 @@ They WILL FAIL in v10.0.0
8102
8697
  deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
8103
8698
  ].filter((d) => d !== null);
8104
8699
  out3.push(
8105
- makeFinding(RULE8, policy, {
8700
+ makeFinding(RULE9, policy, {
8106
8701
  file: c.path,
8107
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.`,
8108
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`,
@@ -8114,7 +8709,7 @@ They WILL FAIL in v10.0.0
8114
8709
  }
8115
8710
  if (shebangChanged && c.after != null) {
8116
8711
  out3.push(
8117
- makeFinding(RULE8, policy, {
8712
+ makeFinding(RULE9, policy, {
8118
8713
  file: c.path,
8119
8714
  line: 1,
8120
8715
  message: `A protected hook's interpreter was changed: ${shebang}.`,
@@ -8125,7 +8720,7 @@ They WILL FAIL in v10.0.0
8125
8720
  }
8126
8721
  if (earlyExit) {
8127
8722
  out3.push(
8128
- makeFinding(RULE8, policy, {
8723
+ makeFinding(RULE9, policy, {
8129
8724
  file: c.path,
8130
8725
  line: earlyExit.newLine ?? void 0,
8131
8726
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -8137,7 +8732,7 @@ They WILL FAIL in v10.0.0
8137
8732
  const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
8138
8733
  if (deadGuard) {
8139
8734
  out3.push(
8140
- makeFinding(RULE8, policy, {
8735
+ makeFinding(RULE9, policy, {
8141
8736
  file: c.path,
8142
8737
  line: deadGuard.newLine ?? void 0,
8143
8738
  message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
@@ -8149,7 +8744,7 @@ They WILL FAIL in v10.0.0
8149
8744
  const lost = scriptWeakening(beforeLines, afterLines, opts);
8150
8745
  if (lost.length) {
8151
8746
  out3.push(
8152
- makeFinding(RULE8, policy, {
8747
+ makeFinding(RULE9, policy, {
8153
8748
  file: c.path,
8154
8749
  message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
8155
8750
  evidence: lost[0].evidence,
@@ -8168,7 +8763,7 @@ They WILL FAIL in v10.0.0
8168
8763
  const m = l.content.match(INLINE);
8169
8764
  if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
8170
8765
  out3.push(
8171
- makeFinding(RULE8, policy, {
8766
+ makeFinding(RULE9, policy, {
8172
8767
  file: c.path,
8173
8768
  line: l.newLine ?? void 0,
8174
8769
  message: `Rule "${m[1]}" was lowered from block to warn in the policy.`,
@@ -8178,7 +8773,7 @@ They WILL FAIL in v10.0.0
8178
8773
  );
8179
8774
  } else if (/enabled:\s*false\b/.test(l.content)) {
8180
8775
  out3.push(
8181
- makeFinding(RULE8, policy, {
8776
+ makeFinding(RULE9, policy, {
8182
8777
  file: c.path,
8183
8778
  line: l.newLine ?? void 0,
8184
8779
  message: "A rule was disabled (enabled: false) in the policy.",
@@ -8196,7 +8791,7 @@ They WILL FAIL in v10.0.0
8196
8791
  const hiding = updateIndexHiding(toks, policy);
8197
8792
  if (hiding) {
8198
8793
  out3.push(
8199
- makeFinding(RULE8, policy, {
8794
+ makeFinding(RULE9, policy, {
8200
8795
  file: repoRelative(hiding.file, ctx),
8201
8796
  message: `Hook tampering via shell: ${hiding.reason}.`,
8202
8797
  evidence: seg,
@@ -8212,7 +8807,7 @@ They WILL FAIL in v10.0.0
8212
8807
  const file = target ? repoRelative(target, ctx) : void 0;
8213
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` : "";
8214
8809
  out3.push(
8215
- makeFinding(RULE8, policy, {
8810
+ makeFinding(RULE9, policy, {
8216
8811
  ...file !== void 0 ? { file } : {},
8217
8812
  message: `Hook tampering via shell: ${why}${outside}.`,
8218
8813
  evidence: seg,
@@ -8760,7 +9355,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
8760
9355
  if (!was || !now || was.path === now.path) return [];
8761
9356
  const samples = runnerSamples(now.path, policy, ctx, "pytest");
8762
9357
  return suiteNarrowings(was.content, now.content, now.path, samples).map(
8763
- (n) => makeFinding(RULE9, policy, {
9358
+ (n) => makeFinding(RULE10, policy, {
8764
9359
  file: now.path,
8765
9360
  message: `The effective pytest config moved from ${was.path} to ${now.path}, and ${n.path} is no longer selected: ${n.reason}.`,
8766
9361
  evidence: `inifile precedence ${was.path} \u2192 ${now.path} drops ${n.path} from the suite`,
@@ -8768,7 +9363,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
8768
9363
  })
8769
9364
  );
8770
9365
  }
8771
- 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;
8772
9367
  var init_test_deletion = __esm({
8773
9368
  "src/detectors/test-deletion.ts"() {
8774
9369
  "use strict";
@@ -8785,7 +9380,7 @@ var init_test_deletion = __esm({
8785
9380
  init_narrow();
8786
9381
  init_reachability();
8787
9382
  init_runner_chain();
8788
- RULE9 = "test-deletion";
9383
+ RULE10 = "test-deletion";
8789
9384
  JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
8790
9385
  TEST_DEFS = {
8791
9386
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
@@ -8808,7 +9403,7 @@ var init_test_deletion = __esm({
8808
9403
  SLICE_SCRIPT = /^test:(?:unit|integration)$/;
8809
9404
  SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
8810
9405
  testDeletion = {
8811
- id: RULE9,
9406
+ id: RULE10,
8812
9407
  surface: ["file", "command"],
8813
9408
  certainty: "mechanical",
8814
9409
  run(changes, policy, _view, ctx) {
@@ -8840,7 +9435,7 @@ var init_test_deletion = __esm({
8840
9435
  if (c.op === "delete" && isTest) {
8841
9436
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
8842
9437
  out3.push(
8843
- makeFinding(RULE9, policy, {
9438
+ makeFinding(RULE10, policy, {
8844
9439
  file: c.path,
8845
9440
  message: "A test file was deleted.",
8846
9441
  evidence: c.path,
@@ -8849,7 +9444,7 @@ var init_test_deletion = __esm({
8849
9444
  );
8850
9445
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isSpecShaped(c.oldPath, c.before, c.after) && !isSpec(c.path)) {
8851
9446
  out3.push(
8852
- makeFinding(RULE9, policy, {
9447
+ makeFinding(RULE10, policy, {
8853
9448
  file: c.path,
8854
9449
  message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
8855
9450
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8858,7 +9453,7 @@ var init_test_deletion = __esm({
8858
9453
  );
8859
9454
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isTest && leavesWalk(c.oldPath, c.path)) {
8860
9455
  out3.push(
8861
- makeFinding(RULE9, policy, {
9456
+ makeFinding(RULE10, policy, {
8862
9457
  file: c.path,
8863
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)}.`,
8864
9459
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -8873,7 +9468,7 @@ var init_test_deletion = __esm({
8873
9468
  const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
8874
9469
  if (isRelocation(removed, before.min - after.min)) continue;
8875
9470
  out3.push(
8876
- makeFinding(RULE9, policy, {
9471
+ makeFinding(RULE10, policy, {
8877
9472
  file: c.path,
8878
9473
  message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
8879
9474
  evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
@@ -8884,9 +9479,22 @@ var init_test_deletion = __esm({
8884
9479
  } else if (c.op !== "delete" && c.after != null && !isSpec(c.path) && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
8885
9480
  const rn = runnerOf(c.path, c.after);
8886
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
+ }
8887
9495
  for (const n of readable ? suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx, rn)) : []) {
8888
9496
  out3.push(
8889
- makeFinding(RULE9, policy, {
9497
+ makeFinding(RULE10, policy, {
8890
9498
  file: c.path,
8891
9499
  message: `The runner no longer selects ${n.path}: ${n.reason}.`,
8892
9500
  evidence: `${n.path} dropped from the suite by ${c.path}`,
@@ -8898,12 +9506,12 @@ var init_test_deletion = __esm({
8898
9506
  const w = n.weakening;
8899
9507
  const evidence = `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${n.after === "" ? "deleted" : JSON.stringify(n.after)}`;
8900
9508
  out3.push(
8901
- w.state === "removed" ? makeFinding(RULE9, policy, {
9509
+ w.state === "removed" ? makeFinding(RULE10, policy, {
8902
9510
  file: c.path,
8903
9511
  message: `The test script no longer runs the ${w.kind} check: scripts.${n.script} ${w.what}.`,
8904
9512
  evidence,
8905
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."
8906
- }) : makeFinding(RULE9, policy, {
9514
+ }) : makeFinding(RULE10, policy, {
8907
9515
  file: c.path,
8908
9516
  message: `The test script now narrows the suite: ${w.what} ${w.direction} ${w.direction === "added" ? "to" : "from"} scripts.${n.script}.`,
8909
9517
  evidence,
@@ -9038,7 +9646,7 @@ var init_test_deletion = __esm({
9038
9646
  }
9039
9647
  if (why) {
9040
9648
  out3.push(
9041
- makeFinding(RULE9, policy, {
9649
+ makeFinding(RULE10, policy, {
9042
9650
  message: `Test removed via shell: ${why}.`,
9043
9651
  evidence,
9044
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."
@@ -9097,7 +9705,7 @@ function significantLinesOrdered(src, path) {
9097
9705
  function liveSignificantLines(src, path) {
9098
9706
  return significantLinesOrdered(partition(src, path).live, path);
9099
9707
  }
9100
- 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;
9101
9709
  var init_test_content_removal = __esm({
9102
9710
  "src/detectors/test-content-removal.ts"() {
9103
9711
  "use strict";
@@ -9108,7 +9716,7 @@ var init_test_content_removal = __esm({
9108
9716
  init_files();
9109
9717
  init_spec_shape();
9110
9718
  init_reachability();
9111
- RULE10 = "test-content-removal";
9719
+ RULE11 = "test-content-removal";
9112
9720
  MIN_REMOVED_LINES = 3;
9113
9721
  MIN_REMOVED_ROWS = 2;
9114
9722
  ws = (s) => s.replace(/\s+/g, "");
@@ -9149,7 +9757,7 @@ var init_test_content_removal = __esm({
9149
9757
  }
9150
9758
  };
9151
9759
  testContentRemoval = {
9152
- id: RULE10,
9760
+ id: RULE11,
9153
9761
  surface: ["file"],
9154
9762
  certainty: "mechanical",
9155
9763
  run(changes, policy) {
@@ -9193,7 +9801,7 @@ var init_test_content_removal = __esm({
9193
9801
  const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
9194
9802
  if (goneRows.length >= MIN_REMOVED_ROWS) {
9195
9803
  out3.push(
9196
- makeFinding(RULE10, policy, {
9804
+ makeFinding(RULE11, policy, {
9197
9805
  file: c.path,
9198
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.`,
9199
9807
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9204,7 +9812,7 @@ var init_test_content_removal = __esm({
9204
9812
  }
9205
9813
  if (hidden.length >= MIN_REMOVED_LINES) {
9206
9814
  out3.push(
9207
- makeFinding(RULE10, policy, {
9815
+ makeFinding(RULE11, policy, {
9208
9816
  file: c.path,
9209
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).`,
9210
9818
  evidence: hidden.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9215,7 +9823,7 @@ var init_test_content_removal = __esm({
9215
9823
  }
9216
9824
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
9217
9825
  out3.push(
9218
- makeFinding(RULE10, policy, {
9826
+ makeFinding(RULE11, policy, {
9219
9827
  file: c.path,
9220
9828
  message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
9221
9829
  evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9238,14 +9846,14 @@ function namesProtectedSnapshot(seg, policy) {
9238
9846
  }
9239
9847
  return null;
9240
9848
  }
9241
- 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;
9242
9850
  var init_snapshot_rewrite = __esm({
9243
9851
  "src/detectors/snapshot-rewrite.ts"() {
9244
9852
  "use strict";
9245
9853
  init_policy();
9246
9854
  init_finding();
9247
9855
  init_command();
9248
- RULE11 = "snapshot-rewrite";
9856
+ RULE12 = "snapshot-rewrite";
9249
9857
  RUNNER3 = /\b(?:jest|vitest|playwright|ava)\b/;
9250
9858
  PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
9251
9859
  UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
@@ -9255,12 +9863,12 @@ var init_snapshot_rewrite = __esm({
9255
9863
  REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
9256
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/;
9257
9865
  snapshotRewrite = {
9258
- id: RULE11,
9866
+ id: RULE12,
9259
9867
  surface: ["command", "file"],
9260
9868
  certainty: "mechanical",
9261
9869
  run(changes, policy) {
9262
9870
  const out3 = [];
9263
- const warn = (input) => out3.push(makeFinding(RULE11, policy, { ...input, defaultSeverity: "warn" }));
9871
+ const warn = (input) => out3.push(makeFinding(RULE12, policy, { ...input, defaultSeverity: "warn" }));
9264
9872
  for (const c of changes) {
9265
9873
  if (c.kind === "command") {
9266
9874
  for (const seg of segments(c.raw)) {
@@ -9308,16 +9916,16 @@ function isSnapshotChange(c, policy) {
9308
9916
  if (isProtected(c.path, policy, "snapshots")) return true;
9309
9917
  return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
9310
9918
  }
9311
- var RULE12, COMMIT_VIEWS, snapshotOnlyRewrite;
9919
+ var RULE13, COMMIT_VIEWS, snapshotOnlyRewrite;
9312
9920
  var init_snapshot_only = __esm({
9313
9921
  "src/detectors/snapshot-only.ts"() {
9314
9922
  "use strict";
9315
9923
  init_policy();
9316
9924
  init_finding();
9317
- RULE12 = "snapshot-only-rewrite";
9925
+ RULE13 = "snapshot-only-rewrite";
9318
9926
  COMMIT_VIEWS = ["staged", "range"];
9319
9927
  snapshotOnlyRewrite = {
9320
- id: RULE12,
9928
+ id: RULE13,
9321
9929
  surface: ["file"],
9322
9930
  certainty: "mechanical",
9323
9931
  run(changes, policy, view) {
@@ -9330,7 +9938,7 @@ var init_snapshot_only = __esm({
9330
9938
  if (!first) return [];
9331
9939
  const names = snaps.map((c) => c.path).slice(0, 3).join(", ");
9332
9940
  return [
9333
- makeFinding(RULE12, policy, {
9941
+ makeFinding(RULE13, policy, {
9334
9942
  file: first.path,
9335
9943
  message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
9336
9944
  evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
@@ -9387,7 +9995,7 @@ function staticFacts(node) {
9387
9995
  }
9388
9996
  function expectAssertion(node, sf) {
9389
9997
  if (!ts.isPropertyAccessExpression(node.expression)) return null;
9390
- const matcher = node.expression.name.text;
9998
+ const matcher2 = node.expression.name.text;
9391
9999
  let receiver = node.expression.expression;
9392
10000
  let polarity = "";
9393
10001
  if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "not") {
@@ -9402,10 +10010,10 @@ function expectAssertion(node, sf) {
9402
10010
  return {
9403
10011
  subject,
9404
10012
  polarity,
9405
- matcher,
10013
+ matcher: matcher2,
9406
10014
  args,
9407
10015
  firstArgFacts: staticFacts(node.arguments[0]),
9408
- canonical: [subject, polarity, matcher, ...args].join("|"),
10016
+ canonical: [subject, polarity, matcher2, ...args].join("|"),
9409
10017
  text: node.getText(sf).replace(/\s+/g, " ")
9410
10018
  };
9411
10019
  }
@@ -9508,7 +10116,7 @@ function findingForBlock(path, before, after, policy) {
9508
10116
  const weakened2 = directionalWeakening(before.assertions, after.assertions);
9509
10117
  if (weakened2) {
9510
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.`;
9511
- return makeFinding(RULE13, policy, {
10119
+ return makeFinding(RULE14, policy, {
9512
10120
  file: path,
9513
10121
  message,
9514
10122
  evidence: `${weakened2.before.text} -> ${weakened2.after.text}`,
@@ -9517,7 +10125,7 @@ function findingForBlock(path, before, after, policy) {
9517
10125
  }
9518
10126
  const removed = pureRemoval(before.assertions, after.assertions);
9519
10127
  if (removed) {
9520
- return makeFinding(RULE13, policy, {
10128
+ return makeFinding(RULE14, policy, {
9521
10129
  file: path,
9522
10130
  message: `Assertion removed from kept test "${before.identity}" without a replacement assertion.`,
9523
10131
  evidence: removed.text,
@@ -9526,7 +10134,7 @@ function findingForBlock(path, before, after, policy) {
9526
10134
  }
9527
10135
  return null;
9528
10136
  }
9529
- 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;
9530
10138
  var init_assertion_weakening = __esm({
9531
10139
  "src/detectors/assertion-weakening.ts"() {
9532
10140
  "use strict";
@@ -9535,14 +10143,14 @@ var init_assertion_weakening = __esm({
9535
10143
  init_finding();
9536
10144
  init_files();
9537
10145
  init_runner_chain();
9538
- RULE13 = "assertion-weakening";
10146
+ RULE14 = "assertion-weakening";
9539
10147
  TEST_CALLS = /* @__PURE__ */ new Set(["it", "test"]);
9540
10148
  SUITE_CALLS = /* @__PURE__ */ new Set(["describe", "suite", "context"]);
9541
10149
  EXACT_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual", "toMatchObject"]);
9542
10150
  WEAK_MATCHERS = /* @__PURE__ */ new Set(["toBeTruthy", "toBeDefined"]);
9543
10151
  compact = (s) => s.replace(/\s+/g, "");
9544
10152
  assertionWeakening = {
9545
- id: RULE13,
10153
+ id: RULE14,
9546
10154
  surface: ["file"],
9547
10155
  certainty: "heuristic",
9548
10156
  run(changes, policy) {
@@ -9576,7 +10184,7 @@ function significant(src, path) {
9576
10184
  }
9577
10185
  return out3;
9578
10186
  }
9579
- var RULE14, MIN_REMOVED_LINES2, ws2, testSupport;
10187
+ var RULE15, MIN_REMOVED_LINES2, ws2, testSupport;
9580
10188
  var init_test_support = __esm({
9581
10189
  "src/detectors/test-support.ts"() {
9582
10190
  "use strict";
@@ -9584,11 +10192,11 @@ var init_test_support = __esm({
9584
10192
  init_finding();
9585
10193
  init_spec_shape();
9586
10194
  init_files();
9587
- RULE14 = "test-support";
10195
+ RULE15 = "test-support";
9588
10196
  MIN_REMOVED_LINES2 = 3;
9589
10197
  ws2 = (s) => s.replace(/\s+/g, "");
9590
10198
  testSupport = {
9591
- id: RULE14,
10199
+ id: RULE15,
9592
10200
  surface: ["file"],
9593
10201
  certainty: "mechanical",
9594
10202
  run(changes, policy) {
@@ -9603,7 +10211,7 @@ var init_test_support = __esm({
9603
10211
  if (c.op === "delete") {
9604
10212
  if (!isSupport(c.path, c.before, null)) continue;
9605
10213
  out3.push(
9606
- makeFinding(RULE14, policy, {
10214
+ makeFinding(RULE15, policy, {
9607
10215
  file: c.path,
9608
10216
  message: "A test-support file was deleted (a helper, setup module or fixture under the test globs; it defines no test).",
9609
10217
  evidence: c.path,
@@ -9613,7 +10221,7 @@ var init_test_support = __esm({
9613
10221
  );
9614
10222
  } else if (c.op === "rename" && c.oldPath && isSupport(c.oldPath, c.before, c.after) && !isProtected(c.path, policy, "tests")) {
9615
10223
  out3.push(
9616
- makeFinding(RULE14, policy, {
10224
+ makeFinding(RULE15, policy, {
9617
10225
  file: c.path,
9618
10226
  message: `A test-support file was renamed out of the test globs (${c.oldPath} \u2192 ${c.path}).`,
9619
10227
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -9631,7 +10239,7 @@ var init_test_support = __esm({
9631
10239
  const gone = [...new Set(beforeSig)].filter((l) => !afterSet.has(l) && !kept.includes(ws2(l)));
9632
10240
  if (gone.length < MIN_REMOVED_LINES2) continue;
9633
10241
  out3.push(
9634
- makeFinding(RULE14, policy, {
10242
+ makeFinding(RULE15, policy, {
9635
10243
  file: c.path,
9636
10244
  message: `Test-support content removed: ${gone.length} significant line(s) gone from a helper, setup module or fixture under the test globs.`,
9637
10245
  evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -9658,6 +10266,7 @@ var init_detectors = __esm({
9658
10266
  init_lint_suppression();
9659
10267
  init_test_skip();
9660
10268
  init_coverage_lowering();
10269
+ init_config_weakening();
9661
10270
  init_coverage_exclusion();
9662
10271
  init_ci_tampering();
9663
10272
  init_hook_tampering();
@@ -9674,6 +10283,7 @@ var init_detectors = __esm({
9674
10283
  lintSuppression,
9675
10284
  testSkip,
9676
10285
  coverageLowering,
10286
+ configWeakening,
9677
10287
  coverageExclusion,
9678
10288
  ciTampering,
9679
10289
  hookTampering,
@@ -11147,7 +11757,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
11147
11757
  };
11148
11758
  }
11149
11759
  function transientFindings(events, persistentPaths, policy, finalHash) {
11150
- if (!isEnabled(RULE15, policy)) return [];
11760
+ if (!isEnabled(RULE16, policy)) return [];
11151
11761
  const byPath = /* @__PURE__ */ new Map();
11152
11762
  for (const e of events) {
11153
11763
  if (!isProtected(e.path, policy)) continue;
@@ -11167,7 +11777,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
11167
11777
  const modeChurn = modes.size > 1;
11168
11778
  if (!contentChurn && !modeChurn) continue;
11169
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 ")})`;
11170
- const f = makeFinding(RULE15, policy, {
11780
+ const f = makeFinding(RULE16, policy, {
11171
11781
  file: path,
11172
11782
  message: `Transient protected mutation: ${what}, leaving no net diff.`,
11173
11783
  evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
@@ -11182,14 +11792,14 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
11182
11792
  }
11183
11793
  return out3;
11184
11794
  }
11185
- 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;
11186
11796
  var init_fs_events = __esm({
11187
11797
  "src/detectors/fs-events.ts"() {
11188
11798
  "use strict";
11189
11799
  init_policy();
11190
11800
  init_finding();
11191
11801
  init_narrow();
11192
- RULE15 = "transient-protected-mutation";
11802
+ RULE16 = "transient-protected-mutation";
11193
11803
  MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
11194
11804
  MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
11195
11805
  DEFAULT_EVENT_READ_IO = {
@@ -12222,7 +12832,7 @@ var init_safe_write = __esm({
12222
12832
  import { execFileSync as execFileSync6 } from "node:child_process";
12223
12833
  import { createHash as createHash6 } from "node:crypto";
12224
12834
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync4 } from "node:fs";
12225
- 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";
12226
12836
  function gitConfig(cwd, key2) {
12227
12837
  try {
12228
12838
  return execFileSync6("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -12281,13 +12891,13 @@ function readWorkflowMark(src) {
12281
12891
  if (!m) return null;
12282
12892
  return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
12283
12893
  }
12284
- function toolSet(matcher) {
12285
- const m = String(matcher ?? "").trim();
12894
+ function toolSet(matcher2) {
12895
+ const m = String(matcher2 ?? "").trim();
12286
12896
  if (m === "" || m === "*") return null;
12287
12897
  return new Set(m.split(/[|,]/).map((t) => t.trim()).filter(Boolean));
12288
12898
  }
12289
- function missingTools(matcher) {
12290
- const have = toolSet(matcher);
12899
+ function missingTools(matcher2) {
12900
+ const have = toolSet(matcher2);
12291
12901
  if (have === null) return [];
12292
12902
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
12293
12903
  }
@@ -12602,7 +13212,7 @@ function planPreCommit(cwd) {
12602
13212
  }
12603
13213
  const dir = hooksDir(cwd);
12604
13214
  if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
12605
- if (basename2(dir) === "_" && basename2(dirname4(dir)) === ".husky") {
13215
+ if (basename3(dir) === "_" && basename3(dirname4(dir)) === ".husky") {
12606
13216
  path = join14(dirname4(dir), "pre-commit");
12607
13217
  note = "husky";
12608
13218
  } else {
@@ -13059,7 +13669,7 @@ import {
13059
13669
  statSync as statSync3
13060
13670
  } from "node:fs";
13061
13671
  import {
13062
- basename as basename3,
13672
+ basename as basename4,
13063
13673
  delimiter,
13064
13674
  dirname as dirname5,
13065
13675
  isAbsolute as isAbsolute9,
@@ -13281,23 +13891,23 @@ function pythonName(name) {
13281
13891
  return /^(?:python(?:\d+(?:\.\d+)*)?|pytest|py\.test|pip(?:\d+(?:\.\d+)*)?)$/.test(name.toLowerCase());
13282
13892
  }
13283
13893
  function looksPython(command, executable, token) {
13284
- const exe = executable ? basename3(executable) : "";
13285
- const raw = token ? basename3(token) : "";
13894
+ const exe = executable ? basename4(executable) : "";
13895
+ const raw = token ? basename4(token) : "";
13286
13896
  if (pythonName(exe) || pythonName(raw)) return true;
13287
13897
  return /(?:^|[\s;&|()])(?:python(?:\d+(?:\.\d+)*)?|pytest|py\.test|pip(?:\d+(?:\.\d+)*)?)(?=$|[\s;&|()])/.test(command);
13288
13898
  }
13289
13899
  function inferredVenvFromToken(token, cwd) {
13290
13900
  if (!token || !token.includes("/") && !token.includes("\\")) return null;
13291
13901
  const path = isAbsolute9(token) ? token : resolve5(cwd, token);
13292
- if (!pythonName(basename3(path))) return null;
13902
+ if (!pythonName(basename4(path))) return null;
13293
13903
  const parent = dirname5(path);
13294
- const parentName = basename3(parent).toLowerCase();
13904
+ const parentName = basename4(parent).toLowerCase();
13295
13905
  if (parentName !== "bin" && parentName !== "scripts") return null;
13296
13906
  const root = dirname5(parent);
13297
13907
  return existsSync12(join15(root, "pyvenv.cfg")) ? root : null;
13298
13908
  }
13299
13909
  function unsupportedEcosystem(command, token) {
13300
- const name = token ? basename3(token).toLowerCase() : "";
13910
+ const name = token ? basename4(token).toLowerCase() : "";
13301
13911
  const raw = command.toLowerCase();
13302
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";
13303
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";
@@ -13307,7 +13917,7 @@ function unsupportedEcosystem(command, token) {
13307
13917
  function inferredVenv(executable) {
13308
13918
  if (!executable) return null;
13309
13919
  const parent = dirname5(executable);
13310
- const parentName = basename3(parent).toLowerCase();
13920
+ const parentName = basename4(parent).toLowerCase();
13311
13921
  if (parentName !== "bin" && parentName !== "scripts") return null;
13312
13922
  const root = dirname5(parent);
13313
13923
  return existsSync12(join15(root, "pyvenv.cfg")) ? root : null;