@t09tanaka/test-gates 0.3.4 → 0.4.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.
package/dist/cli.js CHANGED
@@ -4,7 +4,14 @@
4
4
  import { createRequire as createRequire2 } from "module";
5
5
 
6
6
  // src/core/args.ts
7
- var COMMANDS = ["check", "mutation", "mutation-result", "selfcheck", "lcov"];
7
+ var COMMANDS = [
8
+ "check",
9
+ "mutation",
10
+ "mutation-result",
11
+ "selfcheck",
12
+ "lcov",
13
+ "doctor"
14
+ ];
8
15
  var UsageError = class extends Error {
9
16
  };
10
17
  var VALUE_OPTIONS = ["--dir", "--report", "--file", "--gate", "--shard", "--jobs"];
@@ -21,10 +28,11 @@ function parseShard(value) {
21
28
  }
22
29
  var OPTIONS_BY_COMMAND = {
23
30
  check: ["--dir"],
24
- mutation: ["--dir", "--gate"],
31
+ mutation: ["--dir", "--gate", "--affected"],
25
32
  "mutation-result": ["--dir", "--report"],
26
33
  selfcheck: ["--dir", "--all", "--first", "--gate", "--shard", "--quick", "--jobs"],
27
- lcov: ["--dir", "--file"]
34
+ lcov: ["--dir", "--file"],
35
+ doctor: ["--dir"]
28
36
  };
29
37
  function consume(parsed, argv, index, allowed) {
30
38
  const arg = argv[index];
@@ -77,6 +85,10 @@ function consume(parsed, argv, index, allowed) {
77
85
  parsed.quick = true;
78
86
  return next;
79
87
  }
88
+ if (name === "--affected") {
89
+ parsed.affected = true;
90
+ return next;
91
+ }
80
92
  const mode = name === "--all" ? "all" : "first";
81
93
  if (parsed.mode !== null && parsed.mode !== mode) {
82
94
  throw new UsageError("--all and --first cannot be combined");
@@ -104,6 +116,7 @@ function parseArgs(argv) {
104
116
  gates: [],
105
117
  shard: null,
106
118
  quick: false,
119
+ affected: false,
107
120
  jobs: null,
108
121
  rest: []
109
122
  };
@@ -128,14 +141,22 @@ function parseArgs(argv) {
128
141
  if (selections.length > 1) {
129
142
  throw new UsageError(`${selections.join(" and ")} cannot be combined`);
130
143
  }
144
+ if (parsed.affected && parsed.gates.length > 0) {
145
+ throw new UsageError(
146
+ "--affected and --gate cannot be combined: --gate runs the gates it names, --affected decides which gates run"
147
+ );
148
+ }
131
149
  const misplaced = parsed.rest.find((arg) => /^--(gate|shard|quick|jobs)(=|$)/.test(arg));
132
150
  if (parsed.command === "selfcheck" && misplaced !== void 0) {
133
151
  throw new UsageError(
134
152
  `${misplaced.split("=")[0]} after "--" would go to the gate command. Write it before "--"`
135
153
  );
136
154
  }
137
- if (parsed.rest.some((arg) => arg === "--gate" || arg.startsWith("--gate="))) {
138
- throw new UsageError('--gate after "--" would go to Stryker. Write it before "--"');
155
+ const forStryker = parsed.rest.find((arg) => /^--(gate|affected)(=|$)/.test(arg));
156
+ if (forStryker !== void 0) {
157
+ throw new UsageError(
158
+ `${forStryker.split("=")[0]} after "--" would go to Stryker. Write it before "--"`
159
+ );
139
160
  }
140
161
  if (parsed.command === "selfcheck" && parsed.rest.length === 0 && argv.includes("--")) {
141
162
  throw new UsageError('"--" must be followed by the gate command');
@@ -1003,13 +1024,13 @@ function judgeSelfcheck(input) {
1003
1024
  function selectControls(input) {
1004
1025
  const { gates, mode, only, shard } = input;
1005
1026
  if (only.length > 0) {
1006
- const unknown = only.filter((path11) => !gates.includes(path11));
1027
+ const unknown = only.filter((path13) => !gates.includes(path13));
1007
1028
  if (unknown.length > 0) {
1008
1029
  return {
1009
1030
  error: `--gate ${unknown.join(", ")}: not a gate of test-gates.json (write the path as it is written there)`
1010
1031
  };
1011
1032
  }
1012
- return { selected: gates.filter((path11) => only.includes(path11)) };
1033
+ return { selected: gates.filter((path13) => only.includes(path13)) };
1013
1034
  }
1014
1035
  if (shard !== null) {
1015
1036
  if (shard.total > gates.length) {
@@ -1049,6 +1070,9 @@ function quickRefused(env) {
1049
1070
  function gateRunRefused(env) {
1050
1071
  return inCi(env) ? "--gate is refused in CI (the variable CI is set): a mutation run of a part of the gates is not the final check. Run mutation without it" : null;
1051
1072
  }
1073
+ function affectedRefused(env) {
1074
+ return inCi(env) ? "CI is set, and there every mutant is run (the final check)" : null;
1075
+ }
1052
1076
  function gateRunCaveat(judged, all) {
1053
1077
  return `. Only ${judged} of ${all} gate(s) were run and judged (--gate), and a change in one gate can make a mutant of another survive: run without --gate for the final check`;
1054
1078
  }
@@ -1384,179 +1408,34 @@ function runCheck(dir, io2) {
1384
1408
  return 0;
1385
1409
  }
1386
1410
 
1387
- // src/commands/lcov.ts
1388
- import fs4 from "fs";
1389
- import path4 from "path";
1411
+ // src/commands/doctor.ts
1412
+ import { spawnSync as spawnSync3 } from "child_process";
1413
+ import fs9 from "fs";
1414
+ import path9 from "path";
1390
1415
 
1391
- // src/core/lcov.ts
1392
- function normalizePath(file, rootDir) {
1393
- let normalized = file.replace(/\\/g, "/");
1394
- if (rootDir !== void 0) {
1395
- const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
1396
- if (normalized.startsWith(`${root}/`)) {
1397
- normalized = normalized.slice(root.length + 1);
1398
- }
1399
- }
1400
- return normalized.replace(/^\.\//, "");
1401
- }
1402
- function parseLcov(text, rootDir) {
1403
- const files = /* @__PURE__ */ new Map();
1404
- let record = null;
1405
- const closeRecord = () => {
1406
- if (record && !record.hasDa) {
1407
- record.file.found += record.found;
1408
- record.file.hit += record.hit;
1409
- }
1410
- record = null;
1411
- };
1412
- for (const rawLine of text.split("\n")) {
1413
- const line = rawLine.trim();
1414
- if (line.startsWith("SF:")) {
1415
- closeRecord();
1416
- const path11 = normalizePath(line.slice(3), rootDir);
1417
- const file = files.get(path11) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
1418
- files.set(path11, file);
1419
- record = { file, hasDa: false, found: 0, hit: 0 };
1420
- } else if (line === "end_of_record") {
1421
- closeRecord();
1422
- } else if (record) {
1423
- const open = record;
1424
- if (line.startsWith("DA:")) {
1425
- const [lineNumber, hits] = line.slice(3).split(",");
1426
- const number = Number(lineNumber);
1427
- const count = Number(hits);
1428
- if (Number.isInteger(number) && Number.isFinite(count)) {
1429
- open.hasDa = true;
1430
- open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
1431
- }
1432
- } else if (line.startsWith("LF:")) {
1433
- open.found = Number(line.slice(3)) || 0;
1434
- } else if (line.startsWith("LH:")) {
1435
- open.hit = Number(line.slice(3)) || 0;
1436
- }
1437
- }
1438
- }
1439
- closeRecord();
1440
- const result = /* @__PURE__ */ new Map();
1441
- for (const [file, accumulator] of files) {
1442
- const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
1443
- result.set(file, {
1444
- found: accumulator.lines.size + accumulator.found,
1445
- hit: accumulator.lines.size - uncovered.length + accumulator.hit,
1446
- uncovered
1447
- });
1448
- }
1449
- return result;
1450
- }
1451
- function judgeLcov(gatePaths, coverage) {
1452
- const violations = [];
1453
- const results = [];
1454
- for (const gatePath of gatePaths) {
1455
- const file = coverage.get(normalizePath(gatePath));
1456
- if (!file) {
1457
- violations.push({ file: gatePath, message: "no record in the lcov file" });
1458
- results.push({ path: gatePath, found: 0, hit: 0, ok: false });
1459
- continue;
1460
- }
1461
- if (file.found === 0) {
1462
- violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
1463
- results.push({ path: gatePath, found: 0, hit: 0, ok: false });
1464
- continue;
1465
- }
1466
- const ok = file.hit === file.found;
1467
- if (!ok) {
1468
- const shown = file.uncovered.slice(0, 20).join(", ");
1469
- const more = file.uncovered.length > 20 ? ", \u2026" : "";
1470
- violations.push({
1471
- file: gatePath,
1472
- ...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
1473
- message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
1474
- });
1475
- }
1476
- results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
1477
- }
1478
- return { violations, results };
1479
- }
1480
- function summarizeLcov(coverage, exclude) {
1481
- let found = 0;
1482
- let hit = 0;
1483
- for (const [file, lines] of coverage) {
1484
- if (exclude.some((regex) => regex.test(file))) {
1485
- continue;
1486
- }
1487
- found += lines.found;
1488
- hit += lines.hit;
1489
- }
1490
- return { found, hit };
1416
+ // src/core/reuse-guard.ts
1417
+ var RECORD_FORMAT = 2;
1418
+ function isStringRecord(value) {
1419
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
1491
1420
  }
1492
-
1493
- // src/commands/lcov.ts
1494
- function runLcov(dir, fileOption, io2) {
1495
- const manifest = readManifest(dir);
1496
- const file = fileOption ?? manifest.settings.lcovFile;
1497
- if (file === null) {
1498
- throw new ManifestError(
1499
- "no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
1500
- );
1501
- }
1502
- const lcovPath = path4.resolve(manifest.dir, file);
1503
- let text;
1504
- try {
1505
- text = fs4.readFileSync(lcovPath, "utf8");
1506
- } catch (error) {
1507
- io2.err(
1508
- `test-gates lcov: cannot read ${file} (${error.message}). Run the tests with coverage first`
1509
- );
1510
- return 1;
1421
+ var isStringList = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
1422
+ function parseAffected(value) {
1423
+ const { tests, gates } = value ?? {};
1424
+ if (!isStringList(tests) || typeof gates !== "object" || gates === null || Array.isArray(gates)) {
1425
+ return null;
1511
1426
  }
1512
- const violations = [...manifest.violations];
1513
- const listing = /* @__PURE__ */ new Map();
1514
- const existing = manifest.gates.filter((gate) => {
1515
- const exists = existsExact(manifest.dir, gate.path, "file", listing);
1516
- if (!exists) {
1517
- violations.push({
1518
- file: gate.path,
1519
- message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
1520
- });
1521
- }
1522
- return exists;
1523
- });
1524
- const coverage = parseLcov(text, manifest.dir);
1525
- const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
1526
- io2.out(
1527
- total.found === 0 ? "reference: no instrumented lines in the lcov file" : `reference: overall line coverage ${(total.hit / total.found * 100).toFixed(2)}% (${total.hit}/${total.found}, not gated)`
1528
- );
1529
- const verdict = judgeLcov(
1530
- existing.map((gate) => gate.path),
1531
- coverage
1532
- );
1533
- for (const result of verdict.results) {
1534
- if (result.ok) {
1535
- io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
1427
+ const parsed = /* @__PURE__ */ Object.create(null);
1428
+ for (const [gate, entry] of Object.entries(gates)) {
1429
+ const { specs, files } = entry ?? {};
1430
+ if (!isStringList(specs) || !isStringList(files)) {
1431
+ return null;
1536
1432
  }
1433
+ parsed[gate] = { specs, files };
1537
1434
  }
1538
- violations.push(...verdict.violations);
1539
- if (violations.length > 0) {
1540
- printViolations(io2, "lcov", violations);
1541
- return 1;
1542
- }
1543
- io2.out(`test-gates lcov: OK (${verdict.results.length} gate(s) at 100% line coverage)`);
1544
- return 0;
1545
- }
1546
-
1547
- // src/commands/mutation.ts
1548
- import { spawnSync as spawnSync2 } from "child_process";
1549
- import fs7 from "fs";
1550
- import path7 from "path";
1551
- import { pathToFileURL } from "url";
1552
-
1553
- // src/core/reuse-guard.ts
1554
- var RECORD_FORMAT = 1;
1555
- function isStringRecord(value) {
1556
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
1435
+ return { tests, gates: parsed };
1557
1436
  }
1558
1437
  function parseRecord(value) {
1559
- const { format, incremental, snapshot } = value ?? {};
1438
+ const { format, incremental, snapshot, affected } = value ?? {};
1560
1439
  if (format !== RECORD_FORMAT || typeof incremental !== "string") {
1561
1440
  return null;
1562
1441
  }
@@ -1567,13 +1446,18 @@ function parseRecord(value) {
1567
1446
  if (mode !== "git" && mode !== "walk") {
1568
1447
  return null;
1569
1448
  }
1570
- if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
1449
+ if (!isStringList(args) || !isStringRecord(environment) || !isStringRecord(files)) {
1571
1450
  return null;
1572
1451
  }
1573
- if (!isStringRecord(environment) || !isStringRecord(files)) {
1574
- return null;
1452
+ const record = { format, incremental, snapshot: { mode, environment, args, files } };
1453
+ if (affected !== void 0) {
1454
+ const parsed = parseAffected(affected);
1455
+ if (parsed === null) {
1456
+ return null;
1457
+ }
1458
+ record.affected = parsed;
1575
1459
  }
1576
- return { format, incremental, snapshot: { mode, environment, args, files } };
1460
+ return record;
1577
1461
  }
1578
1462
  function listed(names) {
1579
1463
  const shown = names.slice(0, 3).join(", ");
@@ -1582,33 +1466,42 @@ function listed(names) {
1582
1466
  function differingKeys(before, after) {
1583
1467
  return [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => before[key] !== after[key]).sort();
1584
1468
  }
1585
- function refuseReuse(input) {
1469
+ function readRecord(input) {
1586
1470
  if (input.record === void 0) {
1587
- return "there is no record of the run that wrote the incremental file";
1471
+ return { refused: "there is no record of the run that wrote the incremental file" };
1588
1472
  }
1589
1473
  const record = parseRecord(input.record);
1590
1474
  if (record === null) {
1591
- return "the record of the last run is not one this version can read";
1475
+ return { refused: "the record of the last run is not one this version can read" };
1592
1476
  }
1593
1477
  if (record.incremental !== input.incremental) {
1594
- return "the incremental file is not the one the last recorded run wrote";
1478
+ return { refused: "the incremental file is not the one the last recorded run wrote" };
1595
1479
  }
1596
1480
  const before = record.snapshot;
1597
1481
  const now = input.snapshot;
1598
1482
  if (before.mode !== now.mode) {
1599
- return `the files are listed in another way than in the last run (${before.mode}, now ${now.mode})`;
1483
+ return {
1484
+ refused: `the files are listed in another way than in the last run (${before.mode}, now ${now.mode})`
1485
+ };
1600
1486
  }
1601
1487
  const environment = differingKeys(before.environment, now.environment);
1602
1488
  if (environment.length > 0) {
1603
1489
  const changes = environment.map(
1604
1490
  (key) => `${key} ${before.environment[key] ?? "none"} \u2192 ${now.environment[key] ?? "none"}`
1605
1491
  );
1606
- return `the tools changed since the last run: ${listed(changes)}`;
1492
+ return { refused: `the tools changed since the last run: ${listed(changes)}` };
1607
1493
  }
1608
1494
  if (JSON.stringify(before.args) !== JSON.stringify(now.args)) {
1609
- return "the arguments for Stryker are not those of the last run";
1495
+ return { refused: "the arguments for Stryker are not those of the last run" };
1496
+ }
1497
+ return { record };
1498
+ }
1499
+ function refuseReuse(input) {
1500
+ const read2 = readRecord(input);
1501
+ if ("refused" in read2) {
1502
+ return read2.refused;
1610
1503
  }
1611
- const files = differingKeys(before.files, now.files);
1504
+ const files = differingKeys(read2.record.snapshot.files, input.snapshot.files);
1612
1505
  if (files.length > 0) {
1613
1506
  return `changed since the last run: ${listed(files)}`;
1614
1507
  }
@@ -1639,6 +1532,232 @@ function comparableArgs(args) {
1639
1532
  return args.filter((arg) => arg !== "--force");
1640
1533
  }
1641
1534
 
1535
+ // src/core/affected.ts
1536
+ var CONFIG_NAMES = [
1537
+ /^package\.json$/,
1538
+ /^(?:package-lock|npm-shrinkwrap)\.json$/,
1539
+ /^test-gates\.json$/,
1540
+ /^[jt]sconfig(?:\..*)?\.json$/,
1541
+ /\.config\.[^.]+$/,
1542
+ /\.config\.[^.]+\.[^.]+$/,
1543
+ /^(?:jest|vitest|vite|stryker|babel)[.-]/,
1544
+ /^\./,
1545
+ // Declarations need no import to take effect.
1546
+ /\.d\.[cm]?ts$/
1547
+ ];
1548
+ var CONFIG_DIRECTORIES = ["__mocks__", "node_modules"];
1549
+ var DOCUMENT = /\.(?:md|markdown|txt|rst|adoc)$/i;
1550
+ var PASSIVE = /\.(?:[cm]?[jt]sx?|json|snap|css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|ico)$/i;
1551
+ function kindOf(key, subproject) {
1552
+ const inside = subproject === "" || key.startsWith(`${subproject}/`);
1553
+ const local = inside && subproject !== "" ? key.slice(subproject.length + 1) : key;
1554
+ const segments = local.split("/");
1555
+ const name = segments[segments.length - 1];
1556
+ if (segments.some((segment) => CONFIG_DIRECTORIES.includes(segment)) || CONFIG_NAMES.some((pattern) => pattern.test(name))) {
1557
+ return "config";
1558
+ }
1559
+ if (DOCUMENT.test(name)) {
1560
+ return "document";
1561
+ }
1562
+ if (!inside || segments.length === 1 || !PASSIVE.test(name)) {
1563
+ return "other";
1564
+ }
1565
+ return "passive";
1566
+ }
1567
+ function gateDependencies(input) {
1568
+ const closureOf = (key) => Object.hasOwn(input.closures, key) ? input.closures[key] : [key];
1569
+ const result = /* @__PURE__ */ Object.create(null);
1570
+ for (const gate of input.gates) {
1571
+ const covering = Object.hasOwn(input.covering, gate.key) ? input.covering[gate.key] : [];
1572
+ const specs = /* @__PURE__ */ new Set([...gate.specs, ...covering]);
1573
+ for (const test of input.tests) {
1574
+ if (closureOf(test).includes(gate.key)) {
1575
+ specs.add(test);
1576
+ }
1577
+ }
1578
+ const files = new Set(closureOf(gate.key));
1579
+ for (const spec of specs) {
1580
+ closureOf(spec).forEach((file) => files.add(file));
1581
+ }
1582
+ result[gate.key] = { specs: [...specs].sort(), files: [...files].sort() };
1583
+ }
1584
+ return result;
1585
+ }
1586
+ function planAffected(input) {
1587
+ const recorded = input.before.affected;
1588
+ if (recorded === void 0) {
1589
+ return {
1590
+ full: "the last run did not write down what each gate depends on (it ran without --affected)"
1591
+ };
1592
+ }
1593
+ const changed = differingKeys(input.before.files, input.now);
1594
+ const reached = new Set(input.gates.flatMap((gate) => gate.files));
1595
+ Object.values(recorded.gates).forEach((gate) => gate.files.forEach((file) => reached.add(file)));
1596
+ const config = new Set(input.config);
1597
+ const everywhere = changed.filter((key) => {
1598
+ const kind = kindOf(key, input.subproject);
1599
+ return config.has(key) || kind === "config" || kind === "other" && !reached.has(key);
1600
+ });
1601
+ if (everywhere.length > 0) {
1602
+ return { full: `changed since the last run, and may affect every gate: ${listed(everywhere)}` };
1603
+ }
1604
+ const rerun = [];
1605
+ const reused = [];
1606
+ for (const gate of input.gates) {
1607
+ const last = Object.hasOwn(recorded.gates, gate.path) ? recorded.gates[gate.path] : void 0;
1608
+ if (last === void 0) {
1609
+ rerun.push({ gate: gate.path, changed: [] });
1610
+ continue;
1611
+ }
1612
+ const files = [.../* @__PURE__ */ new Set([...last.files, ...gate.files])];
1613
+ const differing = files.filter((file) => input.before.files[file] !== input.now[file]).sort();
1614
+ if (differing.length > 0) {
1615
+ rerun.push({ gate: gate.path, changed: differing });
1616
+ } else {
1617
+ reused.push(gate.path);
1618
+ }
1619
+ }
1620
+ return { rerun, reused };
1621
+ }
1622
+ function withoutDots(file) {
1623
+ return file.split("/").filter((part) => part !== ".").join("/");
1624
+ }
1625
+ function timeoutRanges(last, gates) {
1626
+ const ranges = [];
1627
+ const files = last.files ?? {};
1628
+ for (const named of gates) {
1629
+ const gate = withoutDots(named);
1630
+ const mutants = Object.hasOwn(files, gate) ? files[gate]?.mutants : void 0;
1631
+ if (mutants === void 0) {
1632
+ continue;
1633
+ }
1634
+ for (const mutant of mutants) {
1635
+ if (mutant.status === "Timeout") {
1636
+ const { start, end } = mutant.location;
1637
+ ranges.push(`${gate}:${start.line}:${start.column - 1}-${end.line}:${end.column}`);
1638
+ }
1639
+ }
1640
+ }
1641
+ return [...new Set(ranges)];
1642
+ }
1643
+ function describePlan(plan, shown = 5, timeouts = 0) {
1644
+ if ("full" in plan) {
1645
+ return [
1646
+ `test-gates mutation --affected: running every mutant again (--force), the results of the last run are not reused: ${plan.full}`
1647
+ ];
1648
+ }
1649
+ const total = plan.rerun.length + plan.reused.length;
1650
+ const lines = [
1651
+ `test-gates mutation --affected: ${plan.reused.length} of ${total} gate(s) reused, ${plan.rerun.length} run again`
1652
+ ];
1653
+ for (const { gate, changed } of plan.rerun.slice(0, shown)) {
1654
+ lines.push(
1655
+ changed.length === 0 ? ` ${gate}: the last run left no result of it that can be reused` : ` ${gate}: changed ${listed(changed)}`
1656
+ );
1657
+ }
1658
+ if (plan.rerun.length > shown) {
1659
+ lines.push(` and ${plan.rerun.length - shown} more`);
1660
+ }
1661
+ if (timeouts > 0) {
1662
+ lines.push(` and ${timeouts} mutant(s) of reused gates that timed out in the last run`);
1663
+ }
1664
+ return lines;
1665
+ }
1666
+
1667
+ // src/core/doctor.ts
1668
+ var NOT_THE_HELPER = "the runner config is not made with";
1669
+ function diagnose(facts) {
1670
+ const findings = [];
1671
+ if (!facts.incremental) {
1672
+ findings.push({
1673
+ level: "problem",
1674
+ text: "Stryker does not run in incremental mode: every run is one of every mutant",
1675
+ fix: "make the Stryker config with createStrykerGatesConfig, or set incremental: true"
1676
+ });
1677
+ return findings;
1678
+ }
1679
+ if (facts.forced) {
1680
+ findings.push({
1681
+ level: "problem",
1682
+ text: "the Stryker config sets force: every run is one of every mutant",
1683
+ fix: "remove force from the Stryker config; pass -- --force where a full run is meant (CI)"
1684
+ });
1685
+ }
1686
+ if (facts.failure !== null) {
1687
+ findings.push({
1688
+ level: "problem",
1689
+ text: `the results of a run are never reused: ${facts.failure}`,
1690
+ fix: facts.failure.includes("points outside") ? "remove the path, or let it lead into the repository. A path that leads outside of it to nothing is compared as missing" : null
1691
+ });
1692
+ }
1693
+ if (facts.widened.length > 0) {
1694
+ findings.push({
1695
+ level: "problem",
1696
+ text: `every file of the repository is compared, so a change anywhere in it makes the next run a full one. Because of: ${facts.widened.join("; ")}`,
1697
+ fix: "write such a path from the directory of its file (path.resolve(__dirname, \u2026)) so that it can be followed, or keep it out of the files the run loads"
1698
+ });
1699
+ }
1700
+ if (facts.scope !== null && "every" in facts.scope) {
1701
+ findings.push({
1702
+ level: "note",
1703
+ text: `the paths in every file of the subproject are followed, also in files the run does not load: ${facts.scope.every}`,
1704
+ fix: facts.scope.every.startsWith(NOT_THE_HELPER) ? "make the runner config with createJestGatesConfig or createVitestGatesConfig" : null
1705
+ });
1706
+ }
1707
+ if (facts.hooks.length === 0) {
1708
+ findings.push({
1709
+ level: "note",
1710
+ text: "no git hook runs test-gates mutation for this subproject",
1711
+ fix: null
1712
+ });
1713
+ }
1714
+ for (const hook of facts.hooks.filter((one) => !one.affected)) {
1715
+ findings.push({
1716
+ level: "problem",
1717
+ text: `${hook.file} runs test-gates mutation without --affected: a change of any file of the subproject runs every mutant`,
1718
+ fix: "pass --affected in the hook (npm run test:gates:mutation -- --affected); keep the run in CI without it"
1719
+ });
1720
+ }
1721
+ return findings;
1722
+ }
1723
+ function formatFindings(findings) {
1724
+ const lines = [];
1725
+ for (const finding of findings) {
1726
+ lines.push(`${finding.level === "problem" ? "problem" : "note"}: ${finding.text}`);
1727
+ if (finding.fix !== null) {
1728
+ lines.push(` fix: ${finding.fix}`);
1729
+ }
1730
+ }
1731
+ const problems = findings.filter((finding) => finding.level === "problem").length;
1732
+ lines.push(
1733
+ problems === 0 ? "test-gates doctor: OK" : `test-gates doctor: ${problems} problem(s) that make runs full ones`
1734
+ );
1735
+ return { lines, status: problems === 0 ? 0 : 1 };
1736
+ }
1737
+ var RUNS_MUTATION = /(?<![\w-])test-gates(?:\.js)?\s+mutation(?![\w-])|(?<![\w:-])test:gates:mutation(?![\w:-])/;
1738
+ var TARGET = /(?:--dir|--prefix|--workspace|-w)(?:=|\s+)(['"]?)([^\s'";&|)]+)\1/;
1739
+ var CD = /\bcd\s+(['"]?)([^\s'";&|)]+)\1/;
1740
+ function hookUses(input) {
1741
+ const lines = input.text.split("\n").map((line) => line.replace(/(?:^|\s)#.*/, ""));
1742
+ const uses = [];
1743
+ lines.forEach((line, index) => {
1744
+ if (!RUNS_MUTATION.test(line)) {
1745
+ return;
1746
+ }
1747
+ const named = TARGET.exec(line) ?? CD.exec(line);
1748
+ const target = named?.[2] ?? lines.slice(0, index).reverse().map((before) => CD.exec(before)?.[2]).find((found) => found !== void 0);
1749
+ const normalized = target?.split("/").filter((part) => part !== "." && part !== "").join("/");
1750
+ const ours = normalized === void 0 || normalized.includes("$") || normalized.includes("..") || normalized === input.subproject;
1751
+ if (ours) {
1752
+ uses.push({
1753
+ file: input.file,
1754
+ affected: /--affected\b/.test(line) || /test:gates:mutation\b/.test(line) && input.script.includes("--affected")
1755
+ });
1756
+ }
1757
+ });
1758
+ return uses;
1759
+ }
1760
+
1642
1761
  // src/snapshot.ts
1643
1762
  import { spawnSync } from "child_process";
1644
1763
  import { createHash } from "crypto";
@@ -1646,19 +1765,169 @@ import fs5 from "fs";
1646
1765
  import { createRequire } from "module";
1647
1766
  import os from "os";
1648
1767
  import path5 from "path";
1649
- var SnapshotError = class extends Error {
1650
- };
1651
- var TOOLS = [
1652
- "@stryker-mutator/core",
1653
- "@stryker-mutator/jest-runner",
1654
- "@stryker-mutator/vitest-runner",
1655
- "@stryker-mutator/typescript-checker",
1656
- "jest",
1657
- "babel-jest",
1658
- "ts-jest",
1659
- "@swc/jest",
1660
- "vitest",
1661
- "vite",
1768
+
1769
+ // src/commands/loaded-files.ts
1770
+ import fs4 from "fs";
1771
+ import { builtinModules } from "module";
1772
+ import path4 from "path";
1773
+ var SCRIPT = /\.[cm]?[jt]sx?$/;
1774
+ var COMPONENT = /\.(?:vue|svelte|astro|mdx)$/;
1775
+ var EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
1776
+ var QUOTED = /(['"`])([^'"`\n]+)\1/g;
1777
+ var RUNNER_LOAD = /\b(?:requireActual|requireMock|importActual|importMock|mock|doMock|unstable_mockModule)\(\s*(['"`])([^'"`\n]+)\1/g;
1778
+ function isFile(file) {
1779
+ try {
1780
+ return fs4.statSync(file).isFile();
1781
+ } catch {
1782
+ return false;
1783
+ }
1784
+ }
1785
+ function resolveFile(base, directory = true) {
1786
+ const candidates = [
1787
+ base,
1788
+ ...EXTENSIONS.map((extension) => base + extension),
1789
+ // `./fee.js` written in a TypeScript file is `./fee.ts`
1790
+ ...EXTENSIONS.map((extension) => base.replace(/\.[cm]?jsx?$/, extension)),
1791
+ ...directory ? EXTENSIONS.map((extension) => path4.join(base, `index${extension}`)) : []
1792
+ ];
1793
+ return candidates.find(isFile) ?? null;
1794
+ }
1795
+ function installed(specifier, from) {
1796
+ const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
1797
+ for (let current = path4.dirname(from); ; current = path4.dirname(current)) {
1798
+ if (fs4.existsSync(path4.join(current, "node_modules", name))) {
1799
+ return true;
1800
+ }
1801
+ if (current === path4.dirname(current)) {
1802
+ return false;
1803
+ }
1804
+ }
1805
+ }
1806
+ function loadedFiles(manifest, configFile) {
1807
+ const aliases = manifest.settings.importAliases;
1808
+ const relative = (file) => path4.relative(manifest.dir, file).split(path4.sep).join("/");
1809
+ const target = (specifier, from) => {
1810
+ if (specifier.startsWith(".")) {
1811
+ return resolveFile(path4.resolve(path4.dirname(from), specifier));
1812
+ }
1813
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
1814
+ return alias === void 0 ? null : resolveFile(
1815
+ path4.resolve(manifest.dir, alias.target + specifier.slice(alias.prefix.length))
1816
+ );
1817
+ };
1818
+ const read2 = (file) => {
1819
+ const source = fs4.readFileSync(file, "utf8");
1820
+ const imports = parseImports(source).filter((ref) => !ref.typeOnly);
1821
+ const specifiers = [
1822
+ .../* @__PURE__ */ new Set([
1823
+ ...imports.map((ref) => ref.module),
1824
+ ...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
1825
+ ])
1826
+ ];
1827
+ const strings = [...source.matchAll(QUOTED)].map((match) => match[2]).filter((text) => !specifiers.includes(text));
1828
+ return { source, specifiers, strings };
1829
+ };
1830
+ const named = /* @__PURE__ */ new Set();
1831
+ const configs = configFile === null ? [] : [path4.resolve(manifest.dir, configFile)];
1832
+ const seenConfigs = new Set(configs);
1833
+ const entry = configs[0];
1834
+ for (let file = configs.shift(); file !== void 0; file = configs.shift()) {
1835
+ const { specifiers, strings } = read2(file);
1836
+ const paths = strings.filter((text) => file === entry || /^(?:\.|<rootDir>\/)/.test(text));
1837
+ for (const text of paths) {
1838
+ const stripped = text.replace(/^<rootDir>\//, "");
1839
+ const found = resolveFile(path4.resolve(path4.dirname(file), stripped), false) ?? resolveFile(path4.resolve(manifest.dir, stripped), false);
1840
+ if (found !== null && SCRIPT.test(found)) {
1841
+ named.add(found);
1842
+ }
1843
+ }
1844
+ for (const specifier of specifiers.filter((one) => isProjectImport(one, aliases))) {
1845
+ const found = target(specifier, file);
1846
+ if (found !== null && SCRIPT.test(found) && !seenConfigs.has(found)) {
1847
+ seenConfigs.add(found);
1848
+ configs.push(found);
1849
+ }
1850
+ }
1851
+ }
1852
+ const known = /* @__PURE__ */ new Map();
1853
+ const visit = (file) => {
1854
+ const done = known.get(file);
1855
+ if (done !== void 0) {
1856
+ return done;
1857
+ }
1858
+ const { source, specifiers, strings } = read2(file);
1859
+ const imports = [];
1860
+ const next = [];
1861
+ let lost = COMPONENT.test(file) ? `${relative(file)} is a component, which can use others that it does not import` : null;
1862
+ for (const specifier of specifiers) {
1863
+ if (isProjectImport(specifier, aliases)) {
1864
+ const found = target(specifier, file);
1865
+ if (found === null) {
1866
+ lost ??= `${specifier} (imported by ${relative(file)})`;
1867
+ } else {
1868
+ next.push(found);
1869
+ }
1870
+ } else if (specifier.startsWith("node:") || builtinModules.includes(specifier)) {
1871
+ imports.push({ specifier, builtin: true });
1872
+ } else if (installed(specifier, file)) {
1873
+ imports.push({ specifier, builtin: false });
1874
+ } else {
1875
+ lost ??= `${specifier} (imported by ${relative(file)})`;
1876
+ }
1877
+ }
1878
+ for (const text of strings.filter((one) => one.startsWith("."))) {
1879
+ const found = resolveFile(path4.resolve(path4.dirname(file), text));
1880
+ if (found !== null) {
1881
+ next.push(found);
1882
+ }
1883
+ }
1884
+ const visited = {
1885
+ file: { path: relative(file), source, imports },
1886
+ // A component imports like a script (see COMPONENT); data imports nothing.
1887
+ next: next.filter((found) => SCRIPT.test(found) || COMPONENT.test(found)),
1888
+ lost
1889
+ };
1890
+ known.set(file, visited);
1891
+ return visited;
1892
+ };
1893
+ const closure = (spec, starts) => {
1894
+ const queue = [...starts];
1895
+ const seen = new Set(queue);
1896
+ const files = [];
1897
+ let lost = null;
1898
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
1899
+ const visited = visit(file);
1900
+ files.push(visited.file.path);
1901
+ lost ??= visited.lost;
1902
+ for (const found of visited.next.filter((one) => !seen.has(one))) {
1903
+ seen.add(found);
1904
+ queue.push(found);
1905
+ }
1906
+ }
1907
+ return { spec, files, lost };
1908
+ };
1909
+ const specs = [...new Set(manifest.gates.flatMap((gate) => gate.existingSpecs))];
1910
+ const roots = [
1911
+ closure(null, [...named]),
1912
+ ...specs.map((spec) => closure(spec, [path4.join(manifest.dir, spec)]))
1913
+ ];
1914
+ return { files: [...known.values()].map((visited) => visited.file), roots };
1915
+ }
1916
+
1917
+ // src/snapshot.ts
1918
+ var SnapshotError = class extends Error {
1919
+ };
1920
+ var TOOLS = [
1921
+ "@stryker-mutator/core",
1922
+ "@stryker-mutator/jest-runner",
1923
+ "@stryker-mutator/vitest-runner",
1924
+ "@stryker-mutator/typescript-checker",
1925
+ "jest",
1926
+ "babel-jest",
1927
+ "ts-jest",
1928
+ "@swc/jest",
1929
+ "vitest",
1930
+ "vite",
1662
1931
  "@vitest/coverage-istanbul",
1663
1932
  "@vitest/coverage-v8",
1664
1933
  "typescript"
@@ -1678,8 +1947,14 @@ var LOCK_FILES = [
1678
1947
  "bun.lockb"
1679
1948
  ];
1680
1949
  var NEVER = ["node_modules", ".git"];
1950
+ var WIDENED_SHOWN = 5;
1681
1951
  var SCAN_LIMIT = 8 * 1024 * 1024;
1682
1952
  var UPWARD = /(['"`])((?:[^'"`\n]*\/)?\.\.(?:\/[^'"`\n]*)?)\1/g;
1953
+ var DOCUMENTS = [".md", ".markdown", ".txt", ".rst", ".adoc"];
1954
+ var HERE = /^\$\{(?:__dirname|import\.meta\.dirname)\}(?=\/)/;
1955
+ var FROM_HERE = /\b(?:__dirname|import\.meta\.dirname)((?:\s*,\s*(['"])[^'"`\n]*\2)+)(\s*\))?/g;
1956
+ var LITERAL = /(['"])([^'"`\n]*)\1/g;
1957
+ var isUpward = (segment) => segment.split("/").includes("..");
1683
1958
  function hashOf(content) {
1684
1959
  return createHash("sha256").update(content).digest("hex");
1685
1960
  }
@@ -1697,8 +1972,21 @@ function isInside(parent, child) {
1697
1972
  function toPosix(file) {
1698
1973
  return file.split(path5.sep).join("/");
1699
1974
  }
1975
+ function gitEnvironment(env) {
1976
+ if (env.GIT_DIR === void 0 || env.GIT_WORK_TREE !== void 0) {
1977
+ return env;
1978
+ }
1979
+ const rest = { ...env };
1980
+ delete rest.GIT_DIR;
1981
+ return rest;
1982
+ }
1700
1983
  function git(cwd, args) {
1701
- const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 });
1984
+ const result = spawnSync("git", args, {
1985
+ cwd,
1986
+ env: gitEnvironment(process.env),
1987
+ encoding: "utf8",
1988
+ maxBuffer: 1024 * 1024 * 1024
1989
+ });
1702
1990
  return result.error || result.status !== 0 ? null : result.stdout;
1703
1991
  }
1704
1992
  function gitLister(root) {
@@ -1831,7 +2119,7 @@ function ownVersion() {
1831
2119
  const { version } = createRequire(import.meta.url)("../package.json");
1832
2120
  return version;
1833
2121
  }
1834
- function takeSnapshot(input) {
2122
+ function snapshotOf(input) {
1835
2123
  const dir = fs5.realpathSync(input.dir);
1836
2124
  const top = git(dir, ["rev-parse", "--show-toplevel"]);
1837
2125
  let root = top === null ? dir : fs5.realpathSync(top.trim());
@@ -1845,6 +2133,7 @@ function takeSnapshot(input) {
1845
2133
  }
1846
2134
  const listRoot = root;
1847
2135
  const list = lister;
2136
+ const followed = input.scope === void 0 ? null : input.scope(list(dir, true).map((file) => path5.join(listRoot, file)));
1848
2137
  const walk = walkLister(listRoot);
1849
2138
  const written = input.written.map((file) => {
1850
2139
  const target = path5.resolve(dir, file);
@@ -1860,34 +2149,75 @@ function takeSnapshot(input) {
1860
2149
  }
1861
2150
  const files = /* @__PURE__ */ Object.create(null);
1862
2151
  const regions = [dir];
2152
+ const widened = [];
1863
2153
  const queued = new Set(regions);
1864
- const enqueue = (target, from) => {
1865
- const directory = isInside(target, dir) && isInside(listRoot, target) ? listRoot : target;
2154
+ const enqueue = (target, from, reference, anchored = false) => {
2155
+ const above2 = target !== dir && isInside(target, dir) && isInside(listRoot, target);
2156
+ if (above2 && anchored) {
2157
+ return;
2158
+ }
2159
+ const directory = above2 ? listRoot : target;
1866
2160
  if (!isInside(listRoot, directory)) {
2161
+ const written2 = reference === void 0 ? "" : ` with ${JSON.stringify(reference)}`;
1867
2162
  throw new SnapshotError(
1868
- `${toPosix(path5.relative(dir, from))} points outside of what can be listed (${directory})`
2163
+ `${toPosix(path5.relative(dir, from))} points outside of what can be listed${written2} (${directory})`
1869
2164
  );
1870
2165
  }
2166
+ if (directory === listRoot && listRoot !== dir && widened.length < WIDENED_SHOWN) {
2167
+ const written2 = reference === void 0 ? "" : ` ${JSON.stringify(reference)}`;
2168
+ widened.push(`${toPosix(path5.relative(listRoot, from))}${written2}`);
2169
+ }
1871
2170
  if (![...queued].some((region) => isInside(region, directory))) {
1872
2171
  queued.add(directory);
1873
2172
  regions.push(directory);
1874
2173
  }
1875
2174
  };
1876
- const follow = (literal, file) => {
1877
- const fixed = literal.includes("${") ? literal.slice(0, literal.lastIndexOf("/", literal.indexOf("${")) + 1) : literal;
1878
- if (!fixed.startsWith("../")) {
1879
- enqueue(listRoot, file);
1880
- return;
1881
- }
2175
+ const follow = (fixed, open, anchored, file, reference) => {
1882
2176
  const target = path5.resolve(path5.dirname(file), fixed);
1883
2177
  let isDirectory = false;
1884
2178
  try {
1885
2179
  isDirectory = fs5.statSync(target).isDirectory();
1886
2180
  } catch {
1887
2181
  }
2182
+ if (!isDirectory && !isInside(listRoot, target) && resolveFile(target) === null) {
2183
+ files[toPosix(path5.relative(listRoot, target))] = "missing";
2184
+ return;
2185
+ }
1888
2186
  const directory = isDirectory ? target : path5.dirname(target);
1889
2187
  if (fs5.existsSync(directory)) {
1890
- enqueue(directory, file);
2188
+ enqueue(directory, file, reference, anchored && !open);
2189
+ }
2190
+ };
2191
+ const scan = (text, file) => {
2192
+ const taken = [];
2193
+ for (const match of text.matchAll(FROM_HERE)) {
2194
+ const literals = match[1];
2195
+ const segments = [...literals.matchAll(LITERAL)].map((literal) => literal[2]);
2196
+ if (!segments.some(isUpward)) {
2197
+ continue;
2198
+ }
2199
+ const start = (match.index ?? 0) + match[0].indexOf(literals);
2200
+ taken.push([start, start + literals.length]);
2201
+ const end = (match.index ?? 0) + match[0].length;
2202
+ const open = match[3] === void 0 || /^\s*[,+.}[]/.test(text.slice(end, end + 200));
2203
+ const reference = match[0].slice(0, match[0].length - (match[3]?.length ?? 0));
2204
+ follow(path5.posix.join(...segments), open, true, file, reference);
2205
+ }
2206
+ for (const match of text.matchAll(UPWARD)) {
2207
+ const at = match.index ?? 0;
2208
+ if (taken.some(([start, end]) => at >= start && at < end)) {
2209
+ continue;
2210
+ }
2211
+ const literal = match[2];
2212
+ const here = HERE.exec(literal);
2213
+ const relative = here === null ? literal : `.${literal.slice(here[0].length)}`;
2214
+ const open = relative.includes("${");
2215
+ const fixed = open ? relative.slice(0, relative.lastIndexOf("/", relative.indexOf("${")) + 1) : relative;
2216
+ if (here === null && !fixed.startsWith("../") || open && isUpward(relative.slice(relative.indexOf("${")))) {
2217
+ enqueue(listRoot, file, literal);
2218
+ } else {
2219
+ follow(fixed, open, here !== null, file, literal);
2220
+ }
1891
2221
  }
1892
2222
  };
1893
2223
  const add = (relative) => {
@@ -1919,11 +2249,10 @@ function takeSnapshot(input) {
1919
2249
  } else {
1920
2250
  const content = fs5.readFileSync(file);
1921
2251
  files[relative] = hashOf(content);
1922
- if (content.length <= SCAN_LIMIT && !content.subarray(0, 8e3).includes(0)) {
1923
- const text = content.toString("utf8").replace(/\\[\\/]/g, "/");
1924
- for (const match of text.matchAll(UPWARD)) {
1925
- follow(match[2], file);
1926
- }
2252
+ const document = DOCUMENTS.includes(path5.extname(file).toLowerCase());
2253
+ const loaded = followed === null || !isInside(dir, file) || followed.has(file);
2254
+ if (loaded && !document && content.length <= SCAN_LIMIT && !content.subarray(0, 8e3).includes(0)) {
2255
+ scan(content.toString("utf8").replace(/\\[\\/]/g, "/"), file);
1927
2256
  }
1928
2257
  }
1929
2258
  };
@@ -1965,17 +2294,477 @@ function takeSnapshot(input) {
1965
2294
  environment[name] = version;
1966
2295
  }
1967
2296
  }
1968
- return {
1969
- mode,
1970
- environment,
1971
- args: input.args,
1972
- files
1973
- };
2297
+ return { snapshot: { mode, environment, args: input.args, files }, root: listRoot, widened };
1974
2298
  }
1975
2299
 
1976
- // src/commands/mutation-result.ts
2300
+ // src/commands/mutation.ts
2301
+ import { spawnSync as spawnSync2 } from "child_process";
2302
+ import fs8 from "fs";
2303
+ import path8 from "path";
2304
+ import { pathToFileURL } from "url";
2305
+
2306
+ // src/affected-files.ts
1977
2307
  import fs6 from "fs";
2308
+ import { builtinModules as builtinModules2 } from "module";
1978
2309
  import path6 from "path";
2310
+ var AffectedError = class extends Error {
2311
+ };
2312
+ var RUNTIME_PATHS = /\b(?:__dirname|__filename|require\.(?:context|resolve)|process\.cwd|createRequire|toMatchFileSnapshot|readFileSync|readdirSync)\b|\bimport\.meta\.(?:url|dirname|filename|glob|resolve)\b/;
2313
+ var FILE_MODULES = /* @__PURE__ */ new Set([
2314
+ "fs",
2315
+ "fs/promises",
2316
+ "child_process",
2317
+ "module",
2318
+ "worker_threads",
2319
+ "fs-extra",
2320
+ "graceful-fs",
2321
+ "glob",
2322
+ "fast-glob",
2323
+ "globby"
2324
+ ]);
2325
+ var bare = (specifier) => specifier.replace(/^node:/, "");
2326
+ function linkedPackage(specifier, from) {
2327
+ const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
2328
+ for (let current = path6.dirname(from); ; current = path6.dirname(current)) {
2329
+ const candidate = path6.join(current, "node_modules", name);
2330
+ if (fs6.existsSync(candidate)) {
2331
+ const target = realpath(candidate);
2332
+ return target.split(path6.sep).includes("node_modules") ? null : target;
2333
+ }
2334
+ if (current === path6.dirname(current)) {
2335
+ return null;
2336
+ }
2337
+ }
2338
+ }
2339
+ var toPosix2 = (file) => file.split(path6.sep).join("/");
2340
+ function realpath(file) {
2341
+ try {
2342
+ return fs6.realpathSync.native(file);
2343
+ } catch {
2344
+ return file;
2345
+ }
2346
+ }
2347
+ function runnerConfigs(dir, stryker, options) {
2348
+ const files = [path6.resolve(dir, stryker)];
2349
+ const named = options.testRunner === "vitest" ? options.vitestConfig : options.jestConfig;
2350
+ if (options.testRunner !== "vitest" && options.testRunner !== "jest") {
2351
+ throw new AffectedError(
2352
+ `--affected knows the test runners jest and vitest, the Stryker config uses ${String(options.testRunner)}`
2353
+ );
2354
+ }
2355
+ if (typeof named === "string") {
2356
+ files.push(path6.resolve(dir, named));
2357
+ } else {
2358
+ const names = options.testRunner === "vitest" ? ["vitest.config", "vite.config"] : ["jest.config"];
2359
+ for (const name of names) {
2360
+ for (const extension of [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"]) {
2361
+ const file = path6.join(dir, name + extension);
2362
+ if (fs6.existsSync(file)) {
2363
+ files.push(file);
2364
+ }
2365
+ }
2366
+ }
2367
+ }
2368
+ return { files: files.filter((file) => fs6.existsSync(file)).map(realpath) };
2369
+ }
2370
+ function fileEdges(file, context) {
2371
+ const { dir, aliases, cache } = context;
2372
+ const known = cache.get(file);
2373
+ if (known !== void 0) {
2374
+ return known;
2375
+ }
2376
+ const shown = (one) => toPosix2(path6.relative(dir, one));
2377
+ const target = (specifier, from) => {
2378
+ if (specifier.startsWith(".")) {
2379
+ return resolveFile(path6.resolve(path6.dirname(from), specifier));
2380
+ }
2381
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
2382
+ return alias === void 0 ? null : resolveFile(path6.resolve(dir, alias.target + specifier.slice(alias.prefix.length)));
2383
+ };
2384
+ const found = { loads: [], unknown: null, computed: false, unnamed: [] };
2385
+ cache.set(file, found);
2386
+ const snapshotFile = path6.join(
2387
+ path6.dirname(file),
2388
+ "__snapshots__",
2389
+ `${path6.basename(file)}.snap`
2390
+ );
2391
+ if (fs6.existsSync(snapshotFile)) {
2392
+ found.loads.push(realpath(snapshotFile));
2393
+ }
2394
+ if (!SCRIPT.test(file) && !COMPONENT.test(file)) {
2395
+ return found;
2396
+ }
2397
+ const source = fs6.readFileSync(file, "utf8");
2398
+ if (COMPONENT.test(file)) {
2399
+ found.unknown = `${shown(file)} is a component, which can use others that it does not import`;
2400
+ }
2401
+ const uncheckable = findUncheckableImports(source)[0];
2402
+ const runtime = RUNTIME_PATHS.exec(source);
2403
+ if (uncheckable !== void 0) {
2404
+ found.unknown = `${shown(file)}:${uncheckable} imports a module whose path is not written out`;
2405
+ found.computed = true;
2406
+ } else if (runtime !== null) {
2407
+ found.unknown = `${shown(file)} uses ${runtime[0]}, which can reach files that no import names`;
2408
+ }
2409
+ const specifiers = /* @__PURE__ */ new Set([
2410
+ ...parseImports(source).map((ref) => ref.module),
2411
+ ...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
2412
+ ]);
2413
+ for (const specifier of specifiers) {
2414
+ if (path6.isAbsolute(specifier) || specifier.startsWith("file:")) {
2415
+ const resolved = resolveFile(
2416
+ specifier.startsWith("file:") ? new URL(specifier).pathname : specifier
2417
+ );
2418
+ if (resolved === null) {
2419
+ found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
2420
+ } else {
2421
+ found.loads.push(realpath(resolved));
2422
+ }
2423
+ } else if (isProjectImport(specifier, aliases)) {
2424
+ const resolved = target(specifier, file);
2425
+ if (resolved === null) {
2426
+ found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
2427
+ } else {
2428
+ found.loads.push(realpath(resolved));
2429
+ }
2430
+ } else if (FILE_MODULES.has(bare(specifier))) {
2431
+ found.unknown ??= `${shown(file)} imports ${specifier}, which can reach files that no import names`;
2432
+ } else if (!specifier.startsWith("node:") && !builtinModules2.includes(specifier)) {
2433
+ if (!installed(specifier, file)) {
2434
+ found.unknown ??= `${shown(file)} imports ${specifier}, which is neither a file nor an installed package (name an alias in settings.importAliases)`;
2435
+ found.unnamed.push(specifier);
2436
+ } else if (linkedPackage(specifier, file) !== null) {
2437
+ found.unknown ??= `${shown(file)} imports ${specifier}, a package of the repository linked into node_modules`;
2438
+ found.computed = true;
2439
+ }
2440
+ }
2441
+ }
2442
+ for (const match of source.matchAll(QUOTED)) {
2443
+ const text = match[2];
2444
+ if (text.startsWith(".") && !specifiers.has(text)) {
2445
+ const resolved = resolveFile(path6.resolve(path6.dirname(file), text));
2446
+ if (resolved !== null) {
2447
+ found.loads.push(realpath(resolved));
2448
+ }
2449
+ }
2450
+ }
2451
+ return found;
2452
+ }
2453
+ function affectedInputs(manifest, input) {
2454
+ const dir = realpath(manifest.dir);
2455
+ const aliases = manifest.settings.importAliases;
2456
+ const placed = [...input.tests, ...Object.values(input.covering).flat()];
2457
+ const absolute = placed.find((file) => path6.isAbsolute(file) || file.startsWith(".."));
2458
+ if (absolute !== void 0) {
2459
+ throw new AffectedError(`the report names a spec file outside of the subproject: ${absolute}`);
2460
+ }
2461
+ const keyOf2 = (file) => {
2462
+ const relative = path6.relative(input.root, realpath(file));
2463
+ if (relative.startsWith("..") || path6.isAbsolute(relative)) {
2464
+ throw new AffectedError(`${file} is outside of the files that are compared`);
2465
+ }
2466
+ return toPosix2(relative);
2467
+ };
2468
+ const shown = (file) => toPosix2(path6.relative(dir, file));
2469
+ const edges = /* @__PURE__ */ new Map();
2470
+ const edgesOf = (file) => fileEdges(file, { dir, aliases, cache: edges });
2471
+ const closure = (start, strict) => {
2472
+ const seen = /* @__PURE__ */ new Set([start]);
2473
+ const queue = [start];
2474
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
2475
+ const found = edgesOf(file);
2476
+ if (strict && found.unknown !== null) {
2477
+ throw new AffectedError(found.unknown);
2478
+ }
2479
+ for (const next of found.loads) {
2480
+ if (!seen.has(next)) {
2481
+ seen.add(next);
2482
+ queue.push(next);
2483
+ }
2484
+ }
2485
+ }
2486
+ return [...seen];
2487
+ };
2488
+ const isCompared = (file) => {
2489
+ const relative = path6.relative(input.root, realpath(file));
2490
+ return !relative.startsWith("..") && !path6.isAbsolute(relative) && toPosix2(relative) in input.files;
2491
+ };
2492
+ const keysOf = (files) => files.map((file) => {
2493
+ const key = keyOf2(file);
2494
+ if (!(key in input.files)) {
2495
+ throw new AffectedError(
2496
+ `${shown(file)} is loaded by a gate or a spec, but is not among the compared files (does git ignore it?)`
2497
+ );
2498
+ }
2499
+ return key;
2500
+ });
2501
+ const config = /* @__PURE__ */ new Set();
2502
+ const named = [];
2503
+ for (const file of input.configs.files) {
2504
+ for (const loaded of closure(file, false).filter(isCompared)) {
2505
+ config.add(loaded);
2506
+ if (!SCRIPT.test(loaded)) {
2507
+ continue;
2508
+ }
2509
+ for (const match of fs6.readFileSync(loaded, "utf8").matchAll(QUOTED)) {
2510
+ const text = match[2].replace(/^<rootDir>\//, "");
2511
+ const found = resolveFile(path6.resolve(path6.dirname(loaded), text), false) ?? resolveFile(path6.resolve(dir, text), false);
2512
+ if (found !== null && !text.includes("*") && isCompared(found)) {
2513
+ named.push(realpath(found));
2514
+ }
2515
+ }
2516
+ }
2517
+ }
2518
+ const gatePaths = new Map(
2519
+ manifest.gates.map((gate) => [realpath(path6.join(dir, gate.path)), gate.path])
2520
+ );
2521
+ const tests = [
2522
+ .../* @__PURE__ */ new Set([
2523
+ ...input.tests,
2524
+ ...manifest.gates.flatMap((gate) => gate.existingSpecs),
2525
+ ...Object.values(input.covering).flat()
2526
+ ])
2527
+ ];
2528
+ const runAsSuch = new Set(
2529
+ [...tests, ...manifest.gates.map((gate) => gate.path)].map(
2530
+ (file) => realpath(path6.join(dir, file))
2531
+ )
2532
+ );
2533
+ const subprojectKey = toPosix2(path6.relative(input.root, dir));
2534
+ const isConfig = (file) => input.configs.files.includes(file) || kindOf(keyOf2(file), subprojectKey) === "config";
2535
+ for (const file of named.filter((one) => !isConfig(one) && !runAsSuch.has(one))) {
2536
+ for (const loaded of closure(file, true)) {
2537
+ const gate = gatePaths.get(loaded);
2538
+ if (gate !== void 0) {
2539
+ throw new AffectedError(
2540
+ `${gate} is loaded through the test runner config for every spec (${shown(file)})`
2541
+ );
2542
+ }
2543
+ config.add(loaded);
2544
+ }
2545
+ }
2546
+ const fromSubproject = (file) => path6.join(dir, file);
2547
+ const closures = /* @__PURE__ */ Object.create(null);
2548
+ for (const file of [...manifest.gates.map((gate) => gate.path), ...tests]) {
2549
+ const absolute2 = fromSubproject(file);
2550
+ if (!fs6.existsSync(absolute2)) {
2551
+ continue;
2552
+ }
2553
+ closures[keysOf([absolute2])[0]] = keysOf(closure(realpath(absolute2), true));
2554
+ }
2555
+ const keyed = (files) => files.map((file) => toPosix2(path6.relative(input.root, path6.join(dir, file))));
2556
+ const dependencies = gateDependencies({
2557
+ gates: manifest.gates.map((gate) => ({
2558
+ key: keyed([gate.path])[0],
2559
+ specs: keyed(gate.existingSpecs)
2560
+ })),
2561
+ tests: keyed(tests),
2562
+ covering: Object.fromEntries(
2563
+ Object.entries(input.covering).map(([gate, specs]) => [keyed([gate])[0], keyed(specs)])
2564
+ ),
2565
+ closures
2566
+ });
2567
+ const subproject = toPosix2(path6.relative(input.root, dir));
2568
+ const prefix = subproject === "" ? "" : `${subproject}/`;
2569
+ const gates = /* @__PURE__ */ Object.create(null);
2570
+ for (const gate of manifest.gates) {
2571
+ const key = keyed([gate.path])[0];
2572
+ const found = dependencies[key];
2573
+ gates[gate.path] = {
2574
+ key,
2575
+ specs: found.specs.map(
2576
+ (spec) => spec.startsWith(prefix) ? spec.slice(prefix.length) : spec
2577
+ ),
2578
+ files: found.files
2579
+ };
2580
+ }
2581
+ return { subproject, config: keysOf([...config]).sort(), gates };
2582
+ }
2583
+ var NOT_THE_HELPER2 = "the runner config is not made with createJestGatesConfig or createVitestGatesConfig";
2584
+ var OWN = /@t09tanaka\/test-gates\/(?:jest|vitest)\b/;
2585
+ var PROJECTS = /\b(?:projects|workspace)\b/;
2586
+ var HELPERS = ["createJestGatesConfig", "createVitestGatesConfig"];
2587
+ function isHelperResult(text) {
2588
+ const names = new Set(HELPERS);
2589
+ for (const match of text.matchAll(
2590
+ /\b(?:createJestGatesConfig|createVitestGatesConfig)\s*(?::|\bas\b)\s*([\w$]+)/g
2591
+ )) {
2592
+ names.add(match[1]);
2593
+ }
2594
+ const escape = (name2) => name2.replace(/\$/g, "\\$");
2595
+ const call = `(?:defineConfig\\s*\\(\\s*)?(?:${[...names].map(escape).join("|")})\\s*\\(`;
2596
+ const exported = "(?:export\\s+default|module\\.exports\\s*=)\\s*";
2597
+ const reached = (name2) => new RegExp(`\\b${escape(name2)}\\s*(?:\\.|\\[)`).test(text);
2598
+ if (new RegExp(`${exported}${call}`).test(text)) {
2599
+ return !reached("module.exports") && !/\bexports\s*(?:\.|\[)/.test(text.replace(/\bmodule\.exports\s*=/g, ""));
2600
+ }
2601
+ const variable = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${call}`).exec(text);
2602
+ if (variable === null) {
2603
+ return false;
2604
+ }
2605
+ const name = escape(variable[1]);
2606
+ const uses = text.match(new RegExp(`(?<![\\w$.])${name}(?![\\w$])`, "g")) ?? [];
2607
+ return uses.length === 2 && new RegExp(`${exported}${name}\\s*(?:;|$)`, "m").test(text);
2608
+ }
2609
+ var READ_BY_NAME = [
2610
+ /^test-gates\.json$/,
2611
+ /^[jt]sconfig(?:\..+)?\.json$/,
2612
+ /^babel\.config\.[cm]?[jt]s(?:on)?$/,
2613
+ /^\.babelrc(?:\.[cm]?js(?:on)?)?$/,
2614
+ /^\.swcrc$/
2615
+ ];
2616
+ function loadedScope(manifest, configs, listed2) {
2617
+ const dir = realpath(manifest.dir);
2618
+ const cache = /* @__PURE__ */ new Map();
2619
+ const context = { dir, aliases: manifest.settings.importAliases, cache };
2620
+ const runner = configs.files.slice(1);
2621
+ const texts = runner.map((file) => withoutComments(fs6.readFileSync(file, "utf8")));
2622
+ if (runner.length === 0 || !texts.every((text) => OWN.test(text))) {
2623
+ return { every: NOT_THE_HELPER2 };
2624
+ }
2625
+ if (!texts.every(isHelperResult)) {
2626
+ return {
2627
+ every: "what the runner config exports is not what the helper returns (merged with another config, or changed after the call)"
2628
+ };
2629
+ }
2630
+ const parts = new Set(runner.map(realpath));
2631
+ for (const file of [...parts]) {
2632
+ if (SCRIPT.test(file)) {
2633
+ fileEdges(file, context).loads.forEach((loaded) => parts.add(loaded));
2634
+ }
2635
+ }
2636
+ if ([...parts].some((file) => PROJECTS.test(withoutComments(fs6.readFileSync(file, "utf8"))))) {
2637
+ return {
2638
+ every: "the runner config, or a file it is put together from, names projects or a workspace"
2639
+ };
2640
+ }
2641
+ const named = fs6.readdirSync(dir).filter((name) => READ_BY_NAME.some((pattern) => pattern.test(name))).map((name) => path6.join(dir, name));
2642
+ const mocks = path6.join(dir, "__mocks__");
2643
+ const seeds = [
2644
+ ...configs.files,
2645
+ ...named,
2646
+ ...manifest.gates.flatMap((gate) => [gate.path, ...gate.existingSpecs]).map((file) => path6.join(dir, file)),
2647
+ ...listed2.filter((file) => file.startsWith(`${mocks}${path6.sep}`))
2648
+ ].filter((file) => fs6.existsSync(file)).map(realpath);
2649
+ const naming = new Set(
2650
+ [...configs.files, ...named].filter((file) => fs6.existsSync(file)).map(realpath)
2651
+ );
2652
+ const seen = new Set(seeds);
2653
+ const queue = [...seeds];
2654
+ const add = (file) => {
2655
+ const real = realpath(file);
2656
+ if (!seen.has(real)) {
2657
+ seen.add(real);
2658
+ queue.push(real);
2659
+ }
2660
+ };
2661
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
2662
+ if (fs6.statSync(file).isDirectory()) {
2663
+ continue;
2664
+ }
2665
+ const edges = fileEdges(file, context);
2666
+ if (edges.computed) {
2667
+ return { every: edges.unknown ?? "a file loads modules that are not followed" };
2668
+ }
2669
+ edges.loads.forEach(add);
2670
+ for (const specifier of edges.unnamed) {
2671
+ endingAs(specifier, listed2).forEach(add);
2672
+ }
2673
+ const mock = path6.join(path6.dirname(file), "__mocks__", path6.basename(file));
2674
+ if (fs6.existsSync(mock)) {
2675
+ add(mock);
2676
+ }
2677
+ if (naming.has(file) || SCRIPT.test(file) && path6.dirname(file) === dir) {
2678
+ for (const named2 of namedIn(file)) {
2679
+ const text = named2.replace(/^<rootDir>\//, "");
2680
+ const found = resolveFile(path6.resolve(path6.dirname(file), text), false) ?? resolveFile(path6.resolve(dir, text), false);
2681
+ if (found !== null && !text.includes("*")) {
2682
+ if (!SCRIPT.test(found)) {
2683
+ naming.add(realpath(found));
2684
+ }
2685
+ add(found);
2686
+ }
2687
+ }
2688
+ }
2689
+ }
2690
+ return { files: seen };
2691
+ }
2692
+ function endingAs(specifier, files) {
2693
+ const parts = specifier.split("/");
2694
+ const rest = (parts.length > 1 ? parts.slice(1).join("/") : specifier.replace(/^[@#~$]+/, "")).replace(/\.[cm]?[jt]sx?$/, "");
2695
+ if (rest === "") {
2696
+ return [];
2697
+ }
2698
+ return files.filter((file) => {
2699
+ const bare2 = toPosix2(file).replace(/\.[^./]+$/, "");
2700
+ return [bare2, bare2.replace(/\/index$/, "")].some((one) => one.endsWith(`/${rest}`));
2701
+ });
2702
+ }
2703
+ function namedIn(file) {
2704
+ const text = fs6.readFileSync(file, "utf8");
2705
+ if (SCRIPT.test(file)) {
2706
+ return [...text.matchAll(QUOTED)].map((match) => match[2]);
2707
+ }
2708
+ return ["extends", "plugins", "presets"].flatMap((key) => valuesOf(text, key));
2709
+ }
2710
+ function valuesOf(text, key) {
2711
+ const found = [];
2712
+ for (const match of text.matchAll(new RegExp(`"${key}"\\s*:\\s*`, "g"))) {
2713
+ let at = (match.index ?? 0) + match[0].length;
2714
+ if (text[at] === '"') {
2715
+ const end = text.indexOf('"', at + 1);
2716
+ found.push(text.slice(at + 1, end === -1 ? void 0 : end));
2717
+ continue;
2718
+ }
2719
+ if (text[at] !== "[") {
2720
+ continue;
2721
+ }
2722
+ let depth = 0;
2723
+ for (; at < text.length; at += 1) {
2724
+ const char = text[at];
2725
+ if (char === '"') {
2726
+ const end = text.indexOf('"', at + 1);
2727
+ found.push(text.slice(at + 1, end === -1 ? void 0 : end));
2728
+ at = end === -1 ? text.length : end;
2729
+ } else if (char === "[") {
2730
+ depth += 1;
2731
+ } else if (char === "]") {
2732
+ depth -= 1;
2733
+ if (depth === 0) {
2734
+ break;
2735
+ }
2736
+ }
2737
+ }
2738
+ }
2739
+ return found;
2740
+ }
2741
+ function withoutComments(text) {
2742
+ let out = "";
2743
+ for (let at = 0; at < text.length; at += 1) {
2744
+ const char = text[at];
2745
+ if (char === "'" || char === '"' || char === "`") {
2746
+ let end = at + 1;
2747
+ while (end < text.length && text[end] !== char && text[end] !== "\n") {
2748
+ end += text[end] === "\\" ? 2 : 1;
2749
+ }
2750
+ out += text.slice(at, end + 1);
2751
+ at = end;
2752
+ } else if (char === "/" && text[at + 1] === "/") {
2753
+ const end = text.indexOf("\n", at);
2754
+ at = end === -1 ? text.length : end - 1;
2755
+ } else if (char === "/" && text[at + 1] === "*") {
2756
+ const end = text.indexOf("*/", at + 2);
2757
+ at = end === -1 ? text.length : end + 1;
2758
+ } else {
2759
+ out += char;
2760
+ }
2761
+ }
2762
+ return out;
2763
+ }
2764
+
2765
+ // src/commands/mutation-result.ts
2766
+ import fs7 from "fs";
2767
+ import path7 from "path";
1979
2768
 
1980
2769
  // src/core/mutation-result.ts
1981
2770
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -1984,7 +2773,7 @@ var NOT_EVALUABLE = /* @__PURE__ */ new Set(["RuntimeError", "CompileError"]);
1984
2773
  function normalizeCode(text) {
1985
2774
  return text.replace(/\s+/g, " ").trim();
1986
2775
  }
1987
- function normalizePath2(file) {
2776
+ function normalizePath(file) {
1988
2777
  return file.replace(/\\/g, "/").replace(/^\.\//, "");
1989
2778
  }
1990
2779
  function normalizeNewlines(text) {
@@ -2050,10 +2839,10 @@ function judgeMutationReport(input) {
2050
2839
  };
2051
2840
  const files = /* @__PURE__ */ new Map();
2052
2841
  for (const [file, result] of Object.entries(input.report.files ?? {})) {
2053
- files.set(normalizePath2(file), result);
2842
+ files.set(normalizePath(file), result);
2054
2843
  }
2055
2844
  for (const gate of input.gates) {
2056
- const gatePath = normalizePath2(gate.path);
2845
+ const gatePath = normalizePath(gate.path);
2057
2846
  const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
2058
2847
  violations.push(...shapeViolations);
2059
2848
  violations.push(
@@ -2225,17 +3014,17 @@ function formatSummary(summary) {
2225
3014
 
2226
3015
  // src/commands/mutation-result.ts
2227
3016
  function reportPathOf(manifest, override) {
2228
- return path6.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
3017
+ return path7.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
2229
3018
  }
2230
- function runMutationResult(dir, reportOverride, io2, only = []) {
3019
+ function runMutationResult(dir, reportOverride, io2, only = [], seen = () => void 0) {
2231
3020
  const manifest = readManifest(dir);
2232
3021
  const gates = only.length === 0 ? manifest.gates : manifest.gates.filter((gate) => only.includes(gate.path));
2233
3022
  const caveat = only.length === 0 ? "" : gateRunCaveat(gates.length, manifest.gates.length);
2234
3023
  const reportPath = reportPathOf(manifest, reportOverride);
2235
- const shown = path6.relative(manifest.dir, reportPath) || reportPath;
3024
+ const shown = path7.relative(manifest.dir, reportPath) || reportPath;
2236
3025
  let report;
2237
3026
  try {
2238
- report = JSON.parse(fs6.readFileSync(reportPath, "utf8"));
3027
+ report = JSON.parse(fs7.readFileSync(reportPath, "utf8"));
2239
3028
  } catch (error) {
2240
3029
  io2.err(
2241
3030
  `test-gates mutation: cannot read the Stryker JSON report ${shown} (${error.message}). Did Stryker finish, with the "json" reporter writing to this path?`
@@ -2252,12 +3041,13 @@ function runMutationResult(dir, reportOverride, io2, only = []) {
2252
3041
  maxTimeouts: manifest.settings.maxTimeouts,
2253
3042
  readSource: (gatePath) => {
2254
3043
  try {
2255
- return fs6.readFileSync(path6.join(manifest.dir, gatePath), "utf8");
3044
+ return fs7.readFileSync(path7.join(manifest.dir, gatePath), "utf8");
2256
3045
  } catch {
2257
3046
  return void 0;
2258
3047
  }
2259
3048
  }
2260
3049
  });
3050
+ seen({ report, verdict });
2261
3051
  const summary = formatSummary(verdict.summary);
2262
3052
  if (verdict.violations.length === 0 && verdict.survivors.length === 0) {
2263
3053
  io2.out(`test-gates mutation: OK (${summary})${caveat}`);
@@ -2319,15 +3109,15 @@ function runMutationResult(dir, reportOverride, io2, only = []) {
2319
3109
  // src/commands/mutation.ts
2320
3110
  var CONFIG_MARK = "\0test-gates-stryker-config\0";
2321
3111
  function loadStrykerConfig(manifest, config) {
2322
- const file = path7.resolve(manifest.dir, config);
3112
+ const file = path8.resolve(manifest.dir, config);
2323
3113
  if (file.endsWith(".json")) {
2324
3114
  try {
2325
- return JSON.parse(fs7.readFileSync(file, "utf8"));
3115
+ return JSON.parse(fs8.readFileSync(file, "utf8"));
2326
3116
  } catch {
2327
3117
  return void 0;
2328
3118
  }
2329
3119
  }
2330
- const script = `const loaded = await import(${JSON.stringify(pathToFileURL(file).href)});const config = loaded.default?.default ?? loaded.default ?? loaded;process.stdout.write(${JSON.stringify(CONFIG_MARK)} + JSON.stringify({incremental: config.incremental, force: config.force,incrementalFile: config.incrementalFile, tempDirName: config.tempDirName,}) + ${JSON.stringify(CONFIG_MARK)});`;
3120
+ const script = `const loaded = await import(${JSON.stringify(pathToFileURL(file).href)});const config = loaded.default?.default ?? loaded.default ?? loaded;process.stdout.write(${JSON.stringify(CONFIG_MARK)} + JSON.stringify({incremental: config.incremental, force: config.force,incrementalFile: config.incrementalFile, tempDirName: config.tempDirName,testRunner: config.testRunner, vitestConfig: config.vitest?.configFile,jestConfig: config.jest?.configFile,}) + ${JSON.stringify(CONFIG_MARK)});`;
2331
3121
  const result = spawnSync2(process.execPath, ["--input-type=module", "--eval", script], {
2332
3122
  cwd: manifest.dir,
2333
3123
  encoding: "utf8",
@@ -2341,7 +3131,7 @@ function loadStrykerConfig(manifest, config) {
2341
3131
  }
2342
3132
  function modifiedAt(file) {
2343
3133
  try {
2344
- const stat = fs7.statSync(file, { bigint: true });
3134
+ const stat = fs8.statSync(file, { bigint: true });
2345
3135
  return `${stat.mtimeNs} ${stat.ino}`;
2346
3136
  } catch {
2347
3137
  return null;
@@ -2350,145 +3140,612 @@ function modifiedAt(file) {
2350
3140
  function guardRecordPath(incrementalFile) {
2351
3141
  return `${incrementalFile}.test-gates.json`;
2352
3142
  }
2353
- function guardIncremental(manifest, config, strykerArgs, part) {
2354
- const nothing = { force: null, record: () => void 0 };
3143
+ function coveringSpecs(report, gates) {
3144
+ const fileOf = /* @__PURE__ */ new Map();
3145
+ for (const [file, entry] of Object.entries(report.testFiles ?? {})) {
3146
+ for (const test of entry.tests ?? []) {
3147
+ fileOf.set(test.id, file);
3148
+ }
3149
+ }
3150
+ const covering = /* @__PURE__ */ Object.create(null);
3151
+ for (const gate of gates) {
3152
+ const specs = /* @__PURE__ */ new Set();
3153
+ let placed = true;
3154
+ const key = withoutDots(gate);
3155
+ for (const mutant of report.files?.[key]?.mutants ?? []) {
3156
+ for (const id of [...mutant.coveredBy ?? [], ...mutant.killedBy ?? []]) {
3157
+ const file = fileOf.get(id);
3158
+ if (file === void 0) {
3159
+ placed = false;
3160
+ } else {
3161
+ specs.add(file);
3162
+ }
3163
+ }
3164
+ }
3165
+ covering[gate] = placed ? [...specs].sort() : null;
3166
+ }
3167
+ return covering;
3168
+ }
3169
+ function decideReuse(manifest, config, strykerArgs, part, affected) {
2355
3170
  const loaded = loadStrykerConfig(manifest, config);
2356
3171
  const options = incrementalOptions(loaded, strykerArgs);
3172
+ const base = fs8.realpathSync(manifest.dir);
3173
+ const configs = () => runnerConfigs(base, config, loaded ?? {});
3174
+ const decision = {
3175
+ incremental: false,
3176
+ incrementalFile: null,
3177
+ recordFile: null,
3178
+ incrementalHash: null,
3179
+ forced: options.force,
3180
+ snapshot: null,
3181
+ failure: null,
3182
+ root: base,
3183
+ widened: [],
3184
+ scope: null,
3185
+ force: null,
3186
+ plan: null,
3187
+ last: void 0,
3188
+ ci: affected ? affectedRefused(process.env) : null,
3189
+ configs
3190
+ };
2357
3191
  if (loaded !== void 0 && !options.incremental) {
2358
- return nothing;
3192
+ return decision;
2359
3193
  }
2360
- const base = fs7.realpathSync(manifest.dir);
2361
- const incrementalFile = path7.resolve(base, options.incrementalFile);
3194
+ decision.incremental = true;
3195
+ const incrementalFile = path8.resolve(base, options.incrementalFile);
2362
3196
  const recordFile = guardRecordPath(incrementalFile);
3197
+ decision.incrementalFile = incrementalFile;
3198
+ decision.recordFile = recordFile;
2363
3199
  let snapshot = null;
3200
+ let root = base;
2364
3201
  let failure = "the Stryker config could not be loaded to see where the incremental file is";
2365
3202
  if (loaded !== void 0) {
3203
+ const scope = (listed2) => {
3204
+ try {
3205
+ const found = loadedScope(manifest, configs(), listed2);
3206
+ decision.scope = "files" in found ? { files: found.files.size } : found;
3207
+ return "files" in found ? found.files : null;
3208
+ } catch (error) {
3209
+ decision.scope = { every: error.message };
3210
+ return null;
3211
+ }
3212
+ };
2366
3213
  try {
2367
- snapshot = takeSnapshot({
3214
+ let widened;
3215
+ ({ snapshot, root, widened } = snapshotOf({
3216
+ scope,
2368
3217
  dir: manifest.dir,
2369
3218
  written: [
2370
3219
  incrementalFile,
2371
3220
  recordFile,
2372
- path7.resolve(base, options.tempDirName),
2373
- path7.resolve(base, manifest.settings.strykerReportFile)
3221
+ path8.resolve(base, options.tempDirName),
3222
+ path8.resolve(base, manifest.settings.strykerReportFile)
2374
3223
  ],
2375
3224
  args: comparableArgs(strykerArgs)
2376
- });
3225
+ }));
3226
+ decision.widened = widened;
2377
3227
  } catch (error) {
2378
3228
  failure = `the files the run depends on could not be listed (${error.message})`;
2379
3229
  }
2380
3230
  }
2381
- let force = null;
3231
+ decision.snapshot = snapshot;
3232
+ decision.failure = snapshot === null ? failure : null;
3233
+ decision.root = root;
2382
3234
  const incremental = hashFile(incrementalFile);
3235
+ decision.incrementalHash = incremental;
2383
3236
  if (part) {
2384
- fs7.rmSync(recordFile, { force: true });
2385
- return {
2386
- force: incremental !== null && !options.force ? "with --gate nothing is taken over from an earlier run" : null,
2387
- record: () => void 0
2388
- };
3237
+ decision.force = incremental !== null && !options.force ? "with --gate nothing is taken over from an earlier run" : null;
3238
+ return decision;
2389
3239
  }
3240
+ let stored;
3241
+ try {
3242
+ stored = JSON.parse(fs8.readFileSync(recordFile, "utf8"));
3243
+ } catch {
3244
+ stored = void 0;
3245
+ }
3246
+ const ci = decision.ci;
3247
+ let force = null;
3248
+ let plan = null;
3249
+ let last;
2390
3250
  if (incremental !== null && !options.force) {
2391
3251
  if (snapshot === null) {
2392
3252
  force = failure;
3253
+ } else if (!affected) {
3254
+ force = refuseReuse({ record: stored, incremental, snapshot });
3255
+ } else if (ci !== null) {
3256
+ force = ci;
2393
3257
  } else {
2394
- let record;
2395
- try {
2396
- record = JSON.parse(fs7.readFileSync(recordFile, "utf8"));
2397
- } catch {
2398
- record = void 0;
3258
+ const read2 = readRecord({ record: stored, incremental, snapshot });
3259
+ if ("refused" in read2) {
3260
+ force = read2.refused;
3261
+ } else {
3262
+ last = read2.record.affected;
3263
+ try {
3264
+ const inputs = affectedInputs(manifest, {
3265
+ root,
3266
+ files: snapshot.files,
3267
+ configs: configs(),
3268
+ tests: last?.tests ?? [],
3269
+ covering: Object.fromEntries(
3270
+ Object.entries(last?.gates ?? {}).map(([gate, entry]) => [gate, entry.specs])
3271
+ )
3272
+ });
3273
+ plan = planAffected({
3274
+ before: { files: read2.record.snapshot.files, affected: last },
3275
+ now: snapshot.files,
3276
+ subproject: inputs.subproject,
3277
+ config: inputs.config,
3278
+ gates: manifest.gates.map((gate) => ({
3279
+ path: gate.path,
3280
+ files: inputs.gates[gate.path]?.files ?? []
3281
+ }))
3282
+ });
3283
+ } catch (error) {
3284
+ if (!(error instanceof AffectedError)) {
3285
+ throw error;
3286
+ }
3287
+ plan = { full: `what a gate depends on cannot be told: ${error.message}` };
3288
+ }
3289
+ if ("full" in plan) {
3290
+ force = plan.full;
3291
+ }
2399
3292
  }
2400
- force = refuseReuse({ record, incremental, snapshot });
2401
3293
  }
3294
+ } else if (affected && incremental === null && !options.force) {
3295
+ force = "there is no incremental file of an earlier run";
3296
+ }
3297
+ decision.force = force;
3298
+ decision.plan = plan;
3299
+ decision.last = last;
3300
+ return decision;
3301
+ }
3302
+ function guardIncremental(manifest, config, strykerArgs, part, affected) {
3303
+ const nothing = { force: null, mutate: null, said: [], record: () => void 0 };
3304
+ const decision = decideReuse(manifest, config, strykerArgs, part, affected);
3305
+ if (!decision.incremental) {
3306
+ return affected ? {
3307
+ ...nothing,
3308
+ said: [
3309
+ "test-gates mutation --affected: Stryker does not run in incremental mode, so every mutant is run"
3310
+ ]
3311
+ } : nothing;
3312
+ }
3313
+ const { snapshot, root, plan, last, ci, configs, force } = decision;
3314
+ const incrementalFile = decision.incrementalFile;
3315
+ const recordFile = decision.recordFile;
3316
+ const incremental = decision.incrementalHash;
3317
+ const options = { force: decision.forced };
3318
+ if (part) {
3319
+ fs8.rmSync(recordFile, { force: true });
3320
+ return { ...nothing, force };
2402
3321
  }
2403
- fs7.rmSync(recordFile, { force: true });
3322
+ fs8.rmSync(recordFile, { force: true });
2404
3323
  const before = modifiedAt(incrementalFile);
3324
+ const rerun = plan !== null && "rerun" in plan ? plan.rerun.map((entry) => entry.gate) : null;
3325
+ const everyGate = force ?? (rerun !== null && rerun.length > 0 && rerun.length === manifest.gates.length ? "every gate is affected" : null);
3326
+ let ranges = [];
3327
+ if (everyGate === null && incremental !== null && !options.force) {
3328
+ const kept = rerun === null ? manifest.gates.map((gate) => gate.path) : manifest.gates.map((gate) => gate.path).filter((gate) => !rerun.includes(gate));
3329
+ try {
3330
+ ranges = timeoutRanges(
3331
+ JSON.parse(fs8.readFileSync(incrementalFile, "utf8")),
3332
+ kept
3333
+ );
3334
+ } catch {
3335
+ ranges = [];
3336
+ }
3337
+ }
3338
+ const subset = rerun !== null && rerun.length > 0 && everyGate === null ? rerun : [];
3339
+ let said = [];
3340
+ if (affected && !options.force) {
3341
+ if (force !== null) {
3342
+ said = describePlan({ full: force });
3343
+ } else if (plan !== null) {
3344
+ said = describePlan(plan, 5, ranges.length);
3345
+ }
3346
+ } else if (ranges.length > 0) {
3347
+ said = [
3348
+ `test-gates mutation: ${ranges.length} mutant(s) that timed out in the last run are run again, the other results are reused`
3349
+ ];
3350
+ }
2405
3351
  return {
2406
- force,
2407
- record: () => {
3352
+ // Every gate is run again: the same as a run of every mutant.
3353
+ force: everyGate,
3354
+ mutate: subset.length + ranges.length > 0 ? [...subset, ...ranges] : null,
3355
+ said,
3356
+ record: (judged) => {
2408
3357
  const written = hashFile(incrementalFile);
2409
3358
  if (snapshot === null || written === null || modifiedAt(incrementalFile) === before) {
2410
3359
  return;
2411
3360
  }
2412
3361
  const record = { format: RECORD_FORMAT, incremental: written, snapshot };
3362
+ if (affected && ci === null && judged !== null) {
3363
+ record.affected = affectedRecord(judged, rerun, last);
3364
+ }
2413
3365
  try {
2414
- fs7.writeFileSync(recordFile, `${JSON.stringify(record)}
3366
+ fs8.writeFileSync(recordFile, `${JSON.stringify(record)}
2415
3367
  `);
2416
3368
  } catch {
2417
3369
  }
2418
3370
  }
2419
- };
3371
+ };
3372
+ function affectedRecord(judged, ran, previous) {
3373
+ const gates = manifest.gates.map((gate) => gate.path);
3374
+ const dirty = new Set(
3375
+ [...judged.verdict.survivors, ...judged.verdict.unexpectedTimeouts].map((one) => one.file)
3376
+ );
3377
+ const fresh = coveringSpecs(judged.report, gates);
3378
+ const covering = /* @__PURE__ */ Object.create(null);
3379
+ for (const gate of gates) {
3380
+ const specs = ran === null || ran.includes(gate) ? fresh[gate] : previous?.gates[gate]?.specs ?? null;
3381
+ if (specs !== null && specs !== void 0 && !dirty.has(gate)) {
3382
+ covering[gate] = specs;
3383
+ }
3384
+ }
3385
+ try {
3386
+ const inputs = affectedInputs(manifest, {
3387
+ root,
3388
+ files: snapshot.files,
3389
+ configs: configs(),
3390
+ tests: Object.keys(judged.report.testFiles ?? {}),
3391
+ covering
3392
+ });
3393
+ const recorded = /* @__PURE__ */ Object.create(null);
3394
+ for (const gate of Object.keys(covering)) {
3395
+ const entry = inputs.gates[gate];
3396
+ if (entry !== void 0) {
3397
+ recorded[gate] = { specs: entry.specs, files: entry.files };
3398
+ }
3399
+ }
3400
+ return { tests: Object.keys(judged.report.testFiles ?? {}).sort(), gates: recorded };
3401
+ } catch (error) {
3402
+ if (error instanceof AffectedError) {
3403
+ return void 0;
3404
+ }
3405
+ throw error;
3406
+ }
3407
+ }
3408
+ }
3409
+ function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
3410
+ const part = only.length > 0;
3411
+ if (affected && strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
3412
+ throw new UsageError(
3413
+ "--affected and --mutate cannot be combined: --affected sets --mutate itself"
3414
+ );
3415
+ }
3416
+ if (part) {
3417
+ const refused = gateRunRefused(process.env);
3418
+ if (refused !== null) {
3419
+ throw new UsageError(refused);
3420
+ }
3421
+ if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
3422
+ throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
3423
+ }
3424
+ }
3425
+ const manifest = readManifest(dir);
3426
+ const chosen = selectControls({
3427
+ gates: manifest.gates.map((gate) => gate.path),
3428
+ mode: "all",
3429
+ only,
3430
+ shard: null
3431
+ });
3432
+ if ("error" in chosen) {
3433
+ throw new UsageError(chosen.error);
3434
+ }
3435
+ const config = findStrykerConfig(manifest);
3436
+ if (config === null) {
3437
+ throw new ManifestError(
3438
+ "no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
3439
+ );
3440
+ }
3441
+ const stryker = findLocalBin(manifest.dir, "stryker");
3442
+ if (stryker === null) {
3443
+ throw new ManifestError(
3444
+ "stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
3445
+ );
3446
+ }
3447
+ const reportFile = reportPathOf(manifest, null);
3448
+ fs8.rmSync(reportFile, { force: true });
3449
+ const guard = guardIncremental(manifest, config, strykerArgs, part, affected);
3450
+ if (guard.said.length > 0) {
3451
+ guard.said.forEach((line) => io2.out(line));
3452
+ } else if (guard.force !== null) {
3453
+ io2.out(
3454
+ `test-gates mutation: running every mutant again (--force), the results of the last run are not reused: ${guard.force}`
3455
+ );
3456
+ }
3457
+ const guardArgs = guard.force === null && guard.mutate === null ? [] : ["--force"];
3458
+ const partArgs = part ? ["--mutate", chosen.selected.join(",")] : guard.mutate !== null ? ["--mutate", guard.mutate.join(",")] : [];
3459
+ const result = spawnSync2(stryker, ["run", config, ...strykerArgs, ...partArgs, ...guardArgs], {
3460
+ cwd: manifest.dir,
3461
+ stdio: "inherit",
3462
+ shell: process.platform === "win32"
3463
+ });
3464
+ if (result.error) {
3465
+ io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
3466
+ return 1;
3467
+ }
3468
+ if (result.status !== 0) {
3469
+ io2.err(
3470
+ `test-gates mutation: Stryker failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`}). The result was not judged`
3471
+ );
3472
+ return 1;
3473
+ }
3474
+ let judged = null;
3475
+ const status = runMutationResult(dir, null, io2, part ? chosen.selected : [], (seen) => {
3476
+ judged = seen;
3477
+ });
3478
+ guard.record(judged);
3479
+ if (part) {
3480
+ fs8.rmSync(reportFile, { force: true });
3481
+ }
3482
+ return status;
3483
+ }
3484
+
3485
+ // src/commands/doctor.ts
3486
+ function read(file) {
3487
+ try {
3488
+ return fs9.readFileSync(file, "utf8");
3489
+ } catch {
3490
+ return null;
3491
+ }
3492
+ }
3493
+ function git2(dir, args) {
3494
+ const result = spawnSync3("git", args, {
3495
+ cwd: dir,
3496
+ env: gitEnvironment(process.env),
3497
+ encoding: "utf8"
3498
+ });
3499
+ return result.status === 0 ? result.stdout.trim() : null;
3500
+ }
3501
+ function hooksOf(dir) {
3502
+ const root = git2(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
3503
+ const hooksDir = git2(dir, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]);
3504
+ const candidates = [
3505
+ ...["pre-push", "pre-commit"].map((name) => path9.join(root, ".husky", name)),
3506
+ ...hooksDir === null ? [] : ["pre-push", "pre-commit"].map((name) => path9.join(hooksDir, name)),
3507
+ ...["lefthook.yml", ".lefthook.yml"].map((name) => path9.join(root, name))
3508
+ ];
3509
+ let script = "";
3510
+ try {
3511
+ const manifest = JSON.parse(read(path9.join(dir, "package.json")) ?? "{}");
3512
+ script = manifest.scripts?.["test:gates:mutation"] ?? "";
3513
+ } catch {
3514
+ }
3515
+ const subproject = path9.relative(fs9.realpathSync(root), dir).split(path9.sep).join("/");
3516
+ const seen = /* @__PURE__ */ new Set();
3517
+ const uses = [];
3518
+ for (const file of candidates) {
3519
+ const text = read(file);
3520
+ if (text === null) {
3521
+ continue;
3522
+ }
3523
+ const real = fs9.realpathSync(file);
3524
+ if (seen.has(real)) {
3525
+ continue;
3526
+ }
3527
+ seen.add(real);
3528
+ const shown = path9.relative(root, file).split(path9.sep).join("/");
3529
+ uses.push(...hookUses({ file: shown, text, subproject, script }));
3530
+ }
3531
+ return uses;
3532
+ }
3533
+ function runDoctor(dir, io2) {
3534
+ const manifest = readManifest(dir);
3535
+ const config = findStrykerConfig(manifest);
3536
+ if (config === null) {
3537
+ throw new ManifestError(
3538
+ "no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
3539
+ );
3540
+ }
3541
+ const plain2 = decideReuse(manifest, config, [], false, false);
3542
+ const affected = decideReuse(manifest, config, [], false, true);
3543
+ io2.out(`test-gates doctor: ${manifest.dir} (${config})`);
3544
+ if (plain2.snapshot !== null) {
3545
+ const subproject = path9.relative(plain2.root, fs9.realpathSync(manifest.dir));
3546
+ const prefix = subproject === "" ? "" : `${subproject.split(path9.sep).join("/")}/`;
3547
+ const keys = Object.keys(plain2.snapshot.files);
3548
+ const outside = keys.filter((key) => !key.startsWith(prefix)).length;
3549
+ const followed = plain2.scope === null ? "" : "files" in plain2.scope ? `, paths followed in the ${plain2.scope.files} files the run loads` : ", paths followed in every file";
3550
+ io2.out(` compared: ${keys.length} files, ${outside} outside the subproject${followed}`);
3551
+ }
3552
+ if (plain2.incremental) {
3553
+ const reuse = plain2.forced ? "the Stryker config sets force" : plain2.force ?? (plain2.incrementalHash === null ? "there is no incremental file of an earlier run" : null);
3554
+ io2.out(
3555
+ reuse === null ? " next run: reuses the results of the last run" : ` next run: every mutant, ${reuse}`
3556
+ );
3557
+ if (affected.ci !== null) {
3558
+ io2.out(` next run with --affected: every mutant, ${affected.ci}`);
3559
+ } else if (affected.plan !== null && "rerun" in affected.plan) {
3560
+ const [first = "", ...rest] = describePlan(affected.plan);
3561
+ io2.out(
3562
+ ` next run with --affected: ${first.replace(/^test-gates mutation --affected: /, "")}`
3563
+ );
3564
+ rest.forEach((line) => io2.out(` ${line}`));
3565
+ } else {
3566
+ const why = affected.forced ? "the Stryker config sets force" : affected.force;
3567
+ io2.out(` next run with --affected: every mutant, ${why ?? "there is nothing to reuse"}`);
3568
+ }
3569
+ }
3570
+ const { lines, status } = formatFindings(
3571
+ diagnose({
3572
+ incremental: plain2.incremental,
3573
+ forced: plain2.forced,
3574
+ failure: plain2.failure,
3575
+ widened: plain2.widened,
3576
+ scope: plain2.scope,
3577
+ hooks: hooksOf(fs9.realpathSync(manifest.dir))
3578
+ })
3579
+ );
3580
+ lines.forEach((line) => io2.out(line));
3581
+ return status;
3582
+ }
3583
+
3584
+ // src/commands/lcov.ts
3585
+ import fs10 from "fs";
3586
+ import path10 from "path";
3587
+
3588
+ // src/core/lcov.ts
3589
+ function normalizePath2(file, rootDir) {
3590
+ let normalized = file.replace(/\\/g, "/");
3591
+ if (rootDir !== void 0) {
3592
+ const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
3593
+ if (normalized.startsWith(`${root}/`)) {
3594
+ normalized = normalized.slice(root.length + 1);
3595
+ }
3596
+ }
3597
+ return normalized.replace(/^\.\//, "");
3598
+ }
3599
+ function parseLcov(text, rootDir) {
3600
+ const files = /* @__PURE__ */ new Map();
3601
+ let record = null;
3602
+ const closeRecord = () => {
3603
+ if (record && !record.hasDa) {
3604
+ record.file.found += record.found;
3605
+ record.file.hit += record.hit;
3606
+ }
3607
+ record = null;
3608
+ };
3609
+ for (const rawLine of text.split("\n")) {
3610
+ const line = rawLine.trim();
3611
+ if (line.startsWith("SF:")) {
3612
+ closeRecord();
3613
+ const path13 = normalizePath2(line.slice(3), rootDir);
3614
+ const file = files.get(path13) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
3615
+ files.set(path13, file);
3616
+ record = { file, hasDa: false, found: 0, hit: 0 };
3617
+ } else if (line === "end_of_record") {
3618
+ closeRecord();
3619
+ } else if (record) {
3620
+ const open = record;
3621
+ if (line.startsWith("DA:")) {
3622
+ const [lineNumber, hits] = line.slice(3).split(",");
3623
+ const number = Number(lineNumber);
3624
+ const count = Number(hits);
3625
+ if (Number.isInteger(number) && Number.isFinite(count)) {
3626
+ open.hasDa = true;
3627
+ open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
3628
+ }
3629
+ } else if (line.startsWith("LF:")) {
3630
+ open.found = Number(line.slice(3)) || 0;
3631
+ } else if (line.startsWith("LH:")) {
3632
+ open.hit = Number(line.slice(3)) || 0;
3633
+ }
3634
+ }
3635
+ }
3636
+ closeRecord();
3637
+ const result = /* @__PURE__ */ new Map();
3638
+ for (const [file, accumulator] of files) {
3639
+ const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
3640
+ result.set(file, {
3641
+ found: accumulator.lines.size + accumulator.found,
3642
+ hit: accumulator.lines.size - uncovered.length + accumulator.hit,
3643
+ uncovered
3644
+ });
3645
+ }
3646
+ return result;
2420
3647
  }
2421
- function runMutation(dir, strykerArgs, io2, only = []) {
2422
- const part = only.length > 0;
2423
- if (part) {
2424
- const refused = gateRunRefused(process.env);
2425
- if (refused !== null) {
2426
- throw new UsageError(refused);
3648
+ function judgeLcov(gatePaths, coverage) {
3649
+ const violations = [];
3650
+ const results = [];
3651
+ for (const gatePath of gatePaths) {
3652
+ const file = coverage.get(normalizePath2(gatePath));
3653
+ if (!file) {
3654
+ violations.push({ file: gatePath, message: "no record in the lcov file" });
3655
+ results.push({ path: gatePath, found: 0, hit: 0, ok: false });
3656
+ continue;
2427
3657
  }
2428
- if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
2429
- throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
3658
+ if (file.found === 0) {
3659
+ violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
3660
+ results.push({ path: gatePath, found: 0, hit: 0, ok: false });
3661
+ continue;
2430
3662
  }
3663
+ const ok = file.hit === file.found;
3664
+ if (!ok) {
3665
+ const shown = file.uncovered.slice(0, 20).join(", ");
3666
+ const more = file.uncovered.length > 20 ? ", \u2026" : "";
3667
+ violations.push({
3668
+ file: gatePath,
3669
+ ...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
3670
+ message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
3671
+ });
3672
+ }
3673
+ results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
2431
3674
  }
2432
- const manifest = readManifest(dir);
2433
- const chosen = selectControls({
2434
- gates: manifest.gates.map((gate) => gate.path),
2435
- mode: "all",
2436
- only,
2437
- shard: null
2438
- });
2439
- if ("error" in chosen) {
2440
- throw new UsageError(chosen.error);
2441
- }
2442
- const config = findStrykerConfig(manifest);
2443
- if (config === null) {
2444
- throw new ManifestError(
2445
- "no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
2446
- );
3675
+ return { violations, results };
3676
+ }
3677
+ function summarizeLcov(coverage, exclude) {
3678
+ let found = 0;
3679
+ let hit = 0;
3680
+ for (const [file, lines] of coverage) {
3681
+ if (exclude.some((regex) => regex.test(file))) {
3682
+ continue;
3683
+ }
3684
+ found += lines.found;
3685
+ hit += lines.hit;
2447
3686
  }
2448
- const stryker = findLocalBin(manifest.dir, "stryker");
2449
- if (stryker === null) {
3687
+ return { found, hit };
3688
+ }
3689
+
3690
+ // src/commands/lcov.ts
3691
+ function runLcov(dir, fileOption, io2) {
3692
+ const manifest = readManifest(dir);
3693
+ const file = fileOption ?? manifest.settings.lcovFile;
3694
+ if (file === null) {
2450
3695
  throw new ManifestError(
2451
- "stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
3696
+ "no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
2452
3697
  );
2453
3698
  }
2454
- const reportFile = reportPathOf(manifest, null);
2455
- fs7.rmSync(reportFile, { force: true });
2456
- const guard = guardIncremental(manifest, config, strykerArgs, part);
2457
- if (guard.force !== null) {
2458
- io2.out(
2459
- `test-gates mutation: running every mutant again (--force), the results of the last run are not reused: ${guard.force}`
3699
+ const lcovPath = path10.resolve(manifest.dir, file);
3700
+ let text;
3701
+ try {
3702
+ text = fs10.readFileSync(lcovPath, "utf8");
3703
+ } catch (error) {
3704
+ io2.err(
3705
+ `test-gates lcov: cannot read ${file} (${error.message}). Run the tests with coverage first`
2460
3706
  );
3707
+ return 1;
2461
3708
  }
2462
- const guardArgs = guard.force === null ? [] : ["--force"];
2463
- const partArgs = part ? ["--mutate", chosen.selected.join(",")] : [];
2464
- const result = spawnSync2(stryker, ["run", config, ...strykerArgs, ...partArgs, ...guardArgs], {
2465
- cwd: manifest.dir,
2466
- stdio: "inherit",
2467
- shell: process.platform === "win32"
3709
+ const violations = [...manifest.violations];
3710
+ const listing = /* @__PURE__ */ new Map();
3711
+ const existing = manifest.gates.filter((gate) => {
3712
+ const exists = existsExact(manifest.dir, gate.path, "file", listing);
3713
+ if (!exists) {
3714
+ violations.push({
3715
+ file: gate.path,
3716
+ message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
3717
+ });
3718
+ }
3719
+ return exists;
2468
3720
  });
2469
- if (result.error) {
2470
- io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
2471
- return 1;
3721
+ const coverage = parseLcov(text, manifest.dir);
3722
+ const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
3723
+ io2.out(
3724
+ total.found === 0 ? "reference: no instrumented lines in the lcov file" : `reference: overall line coverage ${(total.hit / total.found * 100).toFixed(2)}% (${total.hit}/${total.found}, not gated)`
3725
+ );
3726
+ const verdict = judgeLcov(
3727
+ existing.map((gate) => gate.path),
3728
+ coverage
3729
+ );
3730
+ for (const result of verdict.results) {
3731
+ if (result.ok) {
3732
+ io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
3733
+ }
2472
3734
  }
2473
- if (result.status !== 0) {
2474
- io2.err(
2475
- `test-gates mutation: Stryker failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`}). The result was not judged`
2476
- );
3735
+ violations.push(...verdict.violations);
3736
+ if (violations.length > 0) {
3737
+ printViolations(io2, "lcov", violations);
2477
3738
  return 1;
2478
3739
  }
2479
- guard.record();
2480
- const status = runMutationResult(dir, null, io2, part ? chosen.selected : []);
2481
- if (part) {
2482
- fs7.rmSync(reportFile, { force: true });
2483
- }
2484
- return status;
3740
+ io2.out(`test-gates lcov: OK (${verdict.results.length} gate(s) at 100% line coverage)`);
3741
+ return 0;
2485
3742
  }
2486
3743
 
2487
3744
  // src/commands/selfcheck.ts
2488
- import { spawn, spawnSync as spawnSync3 } from "child_process";
2489
- import fs10 from "fs";
3745
+ import { spawn, spawnSync as spawnSync4 } from "child_process";
3746
+ import fs12 from "fs";
2490
3747
  import os2 from "os";
2491
- import path10 from "path";
3748
+ import path12 from "path";
2492
3749
 
2493
3750
  // src/core/jobs.ts
2494
3751
  var SWITCHES = {
@@ -2557,7 +3814,8 @@ var REACHES = [
2557
3814
  "eval(",
2558
3815
  "Function("
2559
3816
  ];
2560
- var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])/;
3817
+ var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])|\bimport\.meta\.glob\b/;
3818
+ var COMPUTED = "loads a module whose name is computed";
2561
3819
  var RUNNER_PACKAGES = ["vitest", "@jest/globals"];
2562
3820
  function reachesOutside(file, trusted) {
2563
3821
  const dense = file.source.replace(/\s/g, "");
@@ -2566,7 +3824,7 @@ function reachesOutside(file, trusted) {
2566
3824
  return `uses ${mark.replace(/[.?[(:]+$/, "")}`;
2567
3825
  }
2568
3826
  if (COMPUTED_LOAD.test(dense)) {
2569
- return "loads a module whose name is computed";
3827
+ return COMPUTED;
2570
3828
  }
2571
3829
  for (const { specifier, builtin } of file.imports) {
2572
3830
  if (builtin) {
@@ -2590,17 +3848,47 @@ function jobsRefused(input) {
2590
3848
  if (input.configSource === null || !input.configSource.includes(helper)) {
2591
3849
  return `the gate config does not use ${helper}`;
2592
3850
  }
2593
- if (input.lost !== null) {
2594
- return `${input.lost} cannot be followed, so what the gate run loads is not known`;
2595
- }
2596
- for (const file of input.files) {
2597
- const reach = reachesOutside(file, input.trusted);
2598
- if (reach !== null) {
2599
- return `${file.path} ${reach}, and may meet what another run is doing`;
2600
- }
2601
- }
2602
3851
  return null;
2603
3852
  }
3853
+ function jobsPlan(input) {
3854
+ const byPath = new Map(input.files.map((file) => [file.path, file]));
3855
+ const causes = input.roots.flatMap((root) => {
3856
+ const loaded = root.files.flatMap((name) => {
3857
+ const file = byPath.get(name);
3858
+ return file === void 0 ? [] : [file];
3859
+ });
3860
+ let computed = false;
3861
+ const reaches = loaded.flatMap((file) => {
3862
+ const reach = reachesOutside(file, input.trusted);
3863
+ const said = reach === null ? [] : [`${file.path} ${reach}`];
3864
+ if (!COMPUTED_LOAD.test(file.source.replace(/\s/g, ""))) {
3865
+ return said;
3866
+ }
3867
+ computed = true;
3868
+ return reach === COMPUTED ? said : [...said, `${file.path} ${COMPUTED}`];
3869
+ });
3870
+ const reasons = [
3871
+ ...root.lost === null ? [] : [`${root.lost} cannot be followed`],
3872
+ ...reaches
3873
+ ];
3874
+ if (reasons.length === 0) {
3875
+ return [];
3876
+ }
3877
+ const any = root.spec === null || root.lost !== null || computed;
3878
+ const gates = input.gates.filter(
3879
+ (gate) => gate.spec !== root.spec && (any || root.files.includes(withoutDots(gate.path)))
3880
+ ).map((gate) => gate.path);
3881
+ const by = root.spec === null ? "run with what the gate config names" : `${any ? "maybe loaded" : "loaded"} by ${root.spec}`;
3882
+ return gates.length === 0 ? [] : [{ gates, reason: `${by}, in which ${reasons.join("; ")}` }];
3883
+ });
3884
+ const kept = new Set(causes.flatMap((cause) => cause.gates));
3885
+ const paths = input.gates.map((gate) => gate.path);
3886
+ return {
3887
+ atOnce: paths.filter((gate) => !kept.has(gate)),
3888
+ alone: paths.filter((gate) => kept.has(gate)),
3889
+ causes
3890
+ };
3891
+ }
2604
3892
  var TROUBLE = [
2605
3893
  "fail",
2606
3894
  "fails",
@@ -2674,8 +3962,8 @@ function cleanHold(input) {
2674
3962
 
2675
3963
  // src/touch.ts
2676
3964
  import { createHash as createHash2 } from "crypto";
2677
- import fs8 from "fs";
2678
- import path8 from "path";
3965
+ import fs11 from "fs";
3966
+ import path11 from "path";
2679
3967
  import { fileURLToPath } from "url";
2680
3968
  var TOUCH_ENV = "TEST_GATES_TOUCH_DIR";
2681
3969
  function isObject(value) {
@@ -2707,18 +3995,18 @@ function isCounters(value) {
2707
3995
  );
2708
3996
  }
2709
3997
  function readNotes(directory) {
2710
- const read = (file) => {
3998
+ const read2 = (file) => {
2711
3999
  try {
2712
- return JSON.parse(fs8.readFileSync(path8.join(directory, file), "utf8"));
4000
+ return JSON.parse(fs11.readFileSync(path11.join(directory, file), "utf8"));
2713
4001
  } catch {
2714
4002
  return null;
2715
4003
  }
2716
4004
  };
2717
- const run = read("run.json");
4005
+ const run = read2("run.json");
2718
4006
  let notes = [];
2719
4007
  try {
2720
- for (const name of fs8.readdirSync(path8.join(directory, "specs"))) {
2721
- const note = read(path8.join("specs", name));
4008
+ for (const name of fs11.readdirSync(path11.join(directory, "specs"))) {
4009
+ const note = read2(path11.join("specs", name));
2722
4010
  if (!isObject(note) || typeof note.spec !== "string" || !isObject(note.files) || !Object.values(note.files).every(isCounters)) {
2723
4011
  throw new Error("not a note");
2724
4012
  }
@@ -2730,135 +4018,8 @@ function readNotes(directory) {
2730
4018
  return {
2731
4019
  run: isObject(run) && typeof run.runner === "string" && typeof run.version === "string" && typeof run.provider === "string" && typeof run.pool === "string" && typeof run.isolate === "boolean" && typeof run.projects === "number" && (run.problem === null || typeof run.problem === "string") ? run : null,
2732
4020
  notes,
2733
- totals: countersOfAll(read(path8.join("coverage", "coverage-final.json")))
2734
- };
2735
- }
2736
-
2737
- // src/commands/loaded-files.ts
2738
- import fs9 from "fs";
2739
- import { builtinModules } from "module";
2740
- import path9 from "path";
2741
- var SCRIPT = /\.[cm]?[jt]sx?$/;
2742
- var EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
2743
- var QUOTED = /(['"`])([^'"`\n]+)\1/g;
2744
- var RUNNER_LOAD = /\b(?:requireActual|requireMock|importActual|importMock|mock|doMock|unstable_mockModule)\(\s*(['"`])([^'"`\n]+)\1/g;
2745
- function isFile(file) {
2746
- try {
2747
- return fs9.statSync(file).isFile();
2748
- } catch {
2749
- return false;
2750
- }
2751
- }
2752
- function resolveFile(base, directory = true) {
2753
- const candidates = [
2754
- base,
2755
- ...EXTENSIONS.map((extension) => base + extension),
2756
- // `./fee.js` written in a TypeScript file is `./fee.ts`
2757
- ...EXTENSIONS.map((extension) => base.replace(/\.[cm]?jsx?$/, extension)),
2758
- ...directory ? EXTENSIONS.map((extension) => path9.join(base, `index${extension}`)) : []
2759
- ];
2760
- return candidates.find(isFile) ?? null;
2761
- }
2762
- function installed(specifier, from) {
2763
- const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
2764
- for (let current = path9.dirname(from); ; current = path9.dirname(current)) {
2765
- if (fs9.existsSync(path9.join(current, "node_modules", name))) {
2766
- return true;
2767
- }
2768
- if (current === path9.dirname(current)) {
2769
- return false;
2770
- }
2771
- }
2772
- }
2773
- function loadedFiles(manifest, configFile) {
2774
- const aliases = manifest.settings.importAliases;
2775
- const relative = (file) => path9.relative(manifest.dir, file).split(path9.sep).join("/");
2776
- const target = (specifier, from) => {
2777
- if (specifier.startsWith(".")) {
2778
- return resolveFile(path9.resolve(path9.dirname(from), specifier));
2779
- }
2780
- const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
2781
- return alias === void 0 ? null : resolveFile(
2782
- path9.resolve(manifest.dir, alias.target + specifier.slice(alias.prefix.length))
2783
- );
2784
- };
2785
- const read = (file) => {
2786
- const source = fs9.readFileSync(file, "utf8");
2787
- const imports = parseImports(source).filter((ref) => !ref.typeOnly);
2788
- const specifiers = [
2789
- .../* @__PURE__ */ new Set([
2790
- ...imports.map((ref) => ref.module),
2791
- ...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
2792
- ])
2793
- ];
2794
- const strings = [...source.matchAll(QUOTED)].map((match) => match[2]).filter((text) => !specifiers.includes(text));
2795
- return { source, specifiers, strings };
2796
- };
2797
- const named = /* @__PURE__ */ new Set();
2798
- const configs = configFile === null ? [] : [path9.resolve(manifest.dir, configFile)];
2799
- const seenConfigs = new Set(configs);
2800
- const entry = configs[0];
2801
- for (let file = configs.shift(); file !== void 0; file = configs.shift()) {
2802
- const { specifiers, strings } = read(file);
2803
- const paths = strings.filter((text) => file === entry || /^(?:\.|<rootDir>\/)/.test(text));
2804
- for (const text of paths) {
2805
- const stripped = text.replace(/^<rootDir>\//, "");
2806
- const found = resolveFile(path9.resolve(path9.dirname(file), stripped), false) ?? resolveFile(path9.resolve(manifest.dir, stripped), false);
2807
- if (found !== null && SCRIPT.test(found)) {
2808
- named.add(found);
2809
- }
2810
- }
2811
- for (const specifier of specifiers.filter((one) => isProjectImport(one, aliases))) {
2812
- const found = target(specifier, file);
2813
- if (found !== null && SCRIPT.test(found) && !seenConfigs.has(found)) {
2814
- seenConfigs.add(found);
2815
- configs.push(found);
2816
- }
2817
- }
2818
- }
2819
- const queue = [
2820
- ...manifest.gates.flatMap(
2821
- (gate) => gate.existingSpecs.map((spec) => path9.join(manifest.dir, spec))
2822
- ),
2823
- ...named
2824
- ];
2825
- const seen = new Set(queue);
2826
- const follow = (found) => {
2827
- if (SCRIPT.test(found) && !seen.has(found)) {
2828
- seen.add(found);
2829
- queue.push(found);
2830
- }
4021
+ totals: countersOfAll(read2(path11.join("coverage", "coverage-final.json")))
2831
4022
  };
2832
- const files = [];
2833
- let lost = null;
2834
- for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
2835
- const { source, specifiers, strings } = read(file);
2836
- const imports = [];
2837
- for (const specifier of specifiers) {
2838
- if (isProjectImport(specifier, aliases)) {
2839
- const found = target(specifier, file);
2840
- if (found === null) {
2841
- lost ??= `${specifier} (imported by ${relative(file)})`;
2842
- } else {
2843
- follow(found);
2844
- }
2845
- } else if (specifier.startsWith("node:") || builtinModules.includes(specifier)) {
2846
- imports.push({ specifier, builtin: true });
2847
- } else if (installed(specifier, file)) {
2848
- imports.push({ specifier, builtin: false });
2849
- } else {
2850
- lost ??= `${specifier} (imported by ${relative(file)})`;
2851
- }
2852
- }
2853
- for (const text of strings.filter((one) => one.startsWith("."))) {
2854
- const found = resolveFile(path9.resolve(path9.dirname(file), text));
2855
- if (found !== null) {
2856
- follow(found);
2857
- }
2858
- }
2859
- files.push({ path: relative(file), source, imports });
2860
- }
2861
- return { files, lost };
2862
4023
  }
2863
4024
 
2864
4025
  // src/commands/selfcheck.ts
@@ -2872,7 +4033,7 @@ function resolveGateCommand(manifest) {
2872
4033
  if (manifest.settings.gateCommand !== null) {
2873
4034
  return manifest.settings.gateCommand;
2874
4035
  }
2875
- const exists = (name) => fs10.existsSync(path10.join(manifest.dir, name));
4036
+ const exists = (name) => fs12.existsSync(path12.join(manifest.dir, name));
2876
4037
  const jest = JEST_CONFIGS.find(exists);
2877
4038
  const vitest = VITEST_CONFIGS.find(exists);
2878
4039
  if (jest && vitest) {
@@ -2893,7 +4054,7 @@ function resolveGateCommand(manifest) {
2893
4054
  var TOUCH_PREFIX = ".test-gates-touch-";
2894
4055
  function realPath(file) {
2895
4056
  try {
2896
- return fs10.realpathSync(file);
4057
+ return fs12.realpathSync(file);
2897
4058
  } catch {
2898
4059
  return file;
2899
4060
  }
@@ -2901,13 +4062,13 @@ function realPath(file) {
2901
4062
  function shareOneRun(manifest, targets, runGate) {
2902
4063
  let touchDir;
2903
4064
  try {
2904
- for (const name of fs10.readdirSync(manifest.dir)) {
2905
- const left = path10.join(manifest.dir, name);
2906
- if (name.startsWith(TOUCH_PREFIX) && Date.now() - fs10.statSync(left).mtimeMs > 36e5) {
2907
- fs10.rmSync(left, { recursive: true, force: true });
4065
+ for (const name of fs12.readdirSync(manifest.dir)) {
4066
+ const left = path12.join(manifest.dir, name);
4067
+ if (name.startsWith(TOUCH_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
4068
+ fs12.rmSync(left, { recursive: true, force: true });
2908
4069
  }
2909
4070
  }
2910
- touchDir = fs10.mkdtempSync(path10.join(manifest.dir, TOUCH_PREFIX));
4071
+ touchDir = fs12.mkdtempSync(path12.join(manifest.dir, TOUCH_PREFIX));
2911
4072
  } catch {
2912
4073
  return { reason: "the subproject cannot be written to" };
2913
4074
  }
@@ -2918,14 +4079,14 @@ function shareOneRun(manifest, targets, runGate) {
2918
4079
  return { reason: "the gate run with every spec does not pass" };
2919
4080
  }
2920
4081
  const base = realPath(manifest.dir);
2921
- const absolute = (file) => realPath(path10.resolve(base, file));
4082
+ const absolute = (file) => realPath(path12.resolve(base, file));
2922
4083
  const rekeyed = (files) => Object.fromEntries(Object.entries(files).map(([file, value]) => [absolute(file), value]));
2923
4084
  const left = readNotes(touchDir);
2924
4085
  plan = planControls({
2925
4086
  all: manifest.gates.map((gate) => ({
2926
4087
  path: gate.path,
2927
- file: path10.join(base, gate.path),
2928
- specFile: path10.join(base, gate.spec ?? "")
4088
+ file: path12.join(base, gate.path),
4089
+ specFile: path12.join(base, gate.spec ?? "")
2929
4090
  })),
2930
4091
  targets: targets.map((gate) => gate.path),
2931
4092
  run: left.run,
@@ -2934,7 +4095,7 @@ function shareOneRun(manifest, targets, runGate) {
2934
4095
  failurePattern: manifest.settings.selfcheckFailurePattern
2935
4096
  });
2936
4097
  } finally {
2937
- fs10.rmSync(touchDir, { recursive: true, force: true });
4098
+ fs12.rmSync(touchDir, { recursive: true, force: true });
2938
4099
  }
2939
4100
  if (plan.reason !== null) {
2940
4101
  return { reason: plan.reason };
@@ -2944,15 +4105,15 @@ function shareOneRun(manifest, targets, runGate) {
2944
4105
  return { gates, result: runGate({ excludeAll: gates.map((gate) => gate.spec) }) };
2945
4106
  }
2946
4107
  function installedVersion2(start, name) {
2947
- let current = path10.resolve(start);
4108
+ let current = path12.resolve(start);
2948
4109
  for (; ; ) {
2949
4110
  try {
2950
4111
  const manifest = JSON.parse(
2951
- fs10.readFileSync(path10.join(current, "node_modules", name, "package.json"), "utf8")
4112
+ fs12.readFileSync(path12.join(current, "node_modules", name, "package.json"), "utf8")
2952
4113
  );
2953
4114
  return typeof manifest.version === "string" ? manifest.version : null;
2954
4115
  } catch {
2955
- const parent = path10.dirname(current);
4116
+ const parent = path12.dirname(current);
2956
4117
  if (parent === current) {
2957
4118
  return null;
2958
4119
  }
@@ -2981,13 +4142,13 @@ async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
2981
4142
  const results = /* @__PURE__ */ new Map();
2982
4143
  let root;
2983
4144
  try {
2984
- for (const name of fs10.readdirSync(os2.tmpdir())) {
2985
- const left = path10.join(os2.tmpdir(), name);
2986
- if (name.startsWith(JOBS_PREFIX) && Date.now() - fs10.statSync(left).mtimeMs > 36e5) {
2987
- fs10.rmSync(left, { recursive: true, force: true });
4145
+ for (const name of fs12.readdirSync(os2.tmpdir())) {
4146
+ const left = path12.join(os2.tmpdir(), name);
4147
+ if (name.startsWith(JOBS_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
4148
+ fs12.rmSync(left, { recursive: true, force: true });
2988
4149
  }
2989
4150
  }
2990
- root = fs10.realpathSync(fs10.mkdtempSync(path10.join(os2.tmpdir(), JOBS_PREFIX)));
4151
+ root = fs12.realpathSync(fs12.mkdtempSync(path12.join(os2.tmpdir(), JOBS_PREFIX)));
2991
4152
  } catch {
2992
4153
  return results;
2993
4154
  }
@@ -3019,11 +4180,11 @@ async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
3019
4180
  await Promise.all(
3020
4181
  Array.from(
3021
4182
  { length: Math.min(jobs, gates.length) },
3022
- (_unused, index) => line(path10.join(root, `line-${index}`))
4183
+ (_unused, index) => line(path12.join(root, `line-${index}`))
3023
4184
  )
3024
4185
  );
3025
4186
  } finally {
3026
- fs10.rmSync(root, { recursive: true, force: true });
4187
+ fs12.rmSync(root, { recursive: true, force: true });
3027
4188
  }
3028
4189
  return results;
3029
4190
  }
@@ -3052,7 +4213,7 @@ async function runSelfcheck(dir, selection, commandOverride, io2) {
3052
4213
  const command = commandOverride.length > 0 ? commandOverride : resolveGateCommand(manifest);
3053
4214
  const [program, ...args] = command;
3054
4215
  const executable = findLocalBin(manifest.dir, program) ?? program;
3055
- const runGate = (env) => spawnSync3(executable, args, {
4216
+ const runGate = (env) => spawnSync4(executable, args, {
3056
4217
  cwd: manifest.dir,
3057
4218
  env: {
3058
4219
  ...process.env,
@@ -3098,48 +4259,60 @@ async function runSelfcheck(dir, selection, commandOverride, io2) {
3098
4259
  const jobs = selection.jobs ?? 1;
3099
4260
  if (jobs > 1 && waiting.length > 1) {
3100
4261
  const known = knownCommand(program, args);
3101
- const configFile = gateConfigFile(args);
3102
- const ask = (loaded) => jobsRefused({
4262
+ const refusal = jobsRefused({
3103
4263
  failurePattern: manifest.settings.selfcheckFailurePattern,
3104
4264
  command: known,
3105
- configSource: gateConfigSource(manifest, args),
3106
- ...loaded,
3107
- trusted: manifest.settings.importMode === "allowlist" ? manifest.settings.importAllow.flatMap(
3108
- (rule) => rule.module === void 0 ? [] : [rule.module]
3109
- ) : []
4265
+ configSource: gateConfigSource(manifest, args)
3110
4266
  });
3111
- const refusal = ask({ files: [], lost: null }) ?? ask(loadedFiles(manifest, configFile));
3112
4267
  if (refusal !== null || !("runner" in known)) {
3113
4268
  io2.out(`test-gates selfcheck: one negative control at a time (${refusal})`);
3114
4269
  } else {
3115
- const specs = new Set(manifest.gates.map((gate) => gate.spec)).size - 1;
3116
- const names = [
3117
- manifest.dir,
3118
- realPath(manifest.dir),
3119
- ...manifest.gates.flatMap((gate) => [gate.path, gate.spec])
3120
- ].sort((left, right) => right.length - left.length);
3121
- const results = await runAtOnce(manifest, waiting, jobs, known.runner, executable, args);
3122
- for (const [gate, result] of results) {
3123
- const output = `${result.stdout}${result.stderr}`;
3124
- const clean = cleanHold({
3125
- held: judgeSelfcheck({ status: result.status, output, gatePath: gate.path }) === null,
3126
- signal: result.signal,
3127
- output,
3128
- runner: known.runner,
3129
- specs,
3130
- gatePath: gate.path,
3131
- others: manifest.gates.map((other) => other.path).filter((other) => other !== gate.path),
3132
- names
3133
- });
3134
- if (clean) {
3135
- heldAtOnce.set(gate.path, result.status);
3136
- }
3137
- }
3138
- const again = waiting.length - heldAtOnce.size;
3139
- if (again > 0) {
4270
+ const plan = jobsPlan({
4271
+ gates: waiting.map((gate) => ({ path: gate.path, spec: gate.spec })),
4272
+ ...loadedFiles(manifest, gateConfigFile(args)),
4273
+ trusted: manifest.settings.importMode === "allowlist" ? manifest.settings.importAllow.flatMap(
4274
+ (rule) => rule.module === void 0 ? [] : [rule.module]
4275
+ ) : []
4276
+ });
4277
+ if (plan.alone.length > 0) {
3140
4278
  io2.out(
3141
- `test-gates selfcheck: ${again} of ${waiting.length} negative control(s) did not hold cleanly next to others and run again on their own`
4279
+ `test-gates selfcheck: ${plan.alone.length} of ${waiting.length} negative control(s) run one at a time, since in their runs something that loads the gate also loads a file that may meet what another run is doing:`
3142
4280
  );
4281
+ for (const { gates, reason } of plan.causes) {
4282
+ io2.out(` ${gates.join(", ")}: ${reason}`);
4283
+ }
4284
+ }
4285
+ const atOnce = waiting.filter((gate) => plan.atOnce.includes(gate.path));
4286
+ if (atOnce.length > 1) {
4287
+ const specs = new Set(manifest.gates.map((gate) => gate.spec)).size - 1;
4288
+ const names = [
4289
+ manifest.dir,
4290
+ realPath(manifest.dir),
4291
+ ...manifest.gates.flatMap((gate) => [gate.path, gate.spec])
4292
+ ].sort((left, right) => right.length - left.length);
4293
+ const results = await runAtOnce(manifest, atOnce, jobs, known.runner, executable, args);
4294
+ for (const [gate, result] of results) {
4295
+ const output = `${result.stdout}${result.stderr}`;
4296
+ const clean = cleanHold({
4297
+ held: judgeSelfcheck({ status: result.status, output, gatePath: gate.path }) === null,
4298
+ signal: result.signal,
4299
+ output,
4300
+ runner: known.runner,
4301
+ specs,
4302
+ gatePath: gate.path,
4303
+ others: manifest.gates.map((other) => other.path).filter((other) => other !== gate.path),
4304
+ names
4305
+ });
4306
+ if (clean) {
4307
+ heldAtOnce.set(gate.path, result.status);
4308
+ }
4309
+ }
4310
+ const again = atOnce.length - heldAtOnce.size;
4311
+ if (again > 0) {
4312
+ io2.out(
4313
+ `test-gates selfcheck: ${again} of ${atOnce.length} negative control(s) did not hold cleanly next to others and run again on their own`
4314
+ );
4315
+ }
3143
4316
  }
3144
4317
  }
3145
4318
  }
@@ -3210,6 +4383,8 @@ Commands:
3210
4383
  mutation-result Judge an existing Stryker JSON report
3211
4384
  selfcheck [-- <command>] Negative control: leave one spec out, the gate must fail
3212
4385
  lcov [--file <lcov.info>] Per-file 100% line coverage from an lcov tracefile
4386
+ doctor Why a mutation run would not reuse the last results, and what
4387
+ to change. Runs nothing; exit 1 when runs are full ones needlessly
3213
4388
 
3214
4389
  Options:
3215
4390
  --dir <subproject> Directory that holds test-gates.json (default: .)
@@ -3218,6 +4393,9 @@ Options:
3218
4393
  --gate <path> selfcheck: only this gate (may be given more than once).
3219
4394
  mutation: only the mutants of this gate, while working on it.
3220
4395
  Not the final check, and refused when CI is set
4396
+ --affected mutation: run again only the gates that a change since the last
4397
+ run can affect, and reuse the results of the others. Every
4398
+ mutant is run when CI is set
3221
4399
  --shard <index>/<total> selfcheck: one of <total> parts of the gates, for parallel CI jobs
3222
4400
  --jobs <n> selfcheck: run <n> negative controls at the same time. One that does
3223
4401
  not hold cleanly there is run again on its own
@@ -3250,8 +4428,10 @@ function main(argv) {
3250
4428
  switch (args.command) {
3251
4429
  case "check":
3252
4430
  return runCheck(args.dir, io);
4431
+ case "doctor":
4432
+ return runDoctor(args.dir, io);
3253
4433
  case "mutation":
3254
- return runMutation(args.dir, args.rest, io, args.gates);
4434
+ return runMutation(args.dir, args.rest, io, args.gates, args.affected);
3255
4435
  case "mutation-result":
3256
4436
  return runMutationResult(args.dir, args.report, io);
3257
4437
  case "selfcheck":