@t09tanaka/test-gates 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,239 @@ 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.blind !== null) {
1701
+ findings.push({
1702
+ level: facts.hooks.some((hook) => hook.affected) ? "problem" : "note",
1703
+ text: `with --affected, a change of any file runs every mutant: what a gate depends on cannot be told (${facts.blind})`,
1704
+ fix: "keep what reads files or loads modules by a computed name out of what the gates and their specs import, or run without --affected"
1705
+ });
1706
+ }
1707
+ if (facts.scope !== null && "every" in facts.scope) {
1708
+ findings.push({
1709
+ level: "note",
1710
+ text: `the paths in every file of the subproject are followed, also in files the run does not load: ${facts.scope.every}`,
1711
+ fix: facts.scope.every.startsWith(NOT_THE_HELPER) ? "make the runner config with createJestGatesConfig or createVitestGatesConfig" : null
1712
+ });
1713
+ }
1714
+ if (facts.hooks.length === 0) {
1715
+ findings.push({
1716
+ level: "note",
1717
+ text: "no git hook runs test-gates mutation for this subproject",
1718
+ fix: null
1719
+ });
1720
+ }
1721
+ for (const hook of facts.hooks.filter((one) => !one.affected && facts.blind === null)) {
1722
+ findings.push({
1723
+ level: "problem",
1724
+ text: `${hook.file} runs test-gates mutation without --affected: a change of any file of the subproject runs every mutant`,
1725
+ fix: "pass --affected in the hook (npm run test:gates:mutation -- --affected); keep the run in CI without it"
1726
+ });
1727
+ }
1728
+ return findings;
1729
+ }
1730
+ function formatFindings(findings) {
1731
+ const lines = [];
1732
+ for (const finding of findings) {
1733
+ lines.push(`${finding.level === "problem" ? "problem" : "note"}: ${finding.text}`);
1734
+ if (finding.fix !== null) {
1735
+ lines.push(` fix: ${finding.fix}`);
1736
+ }
1737
+ }
1738
+ const problems = findings.filter((finding) => finding.level === "problem").length;
1739
+ lines.push(
1740
+ problems === 0 ? "test-gates doctor: OK" : `test-gates doctor: ${problems} problem(s) that make runs full ones`
1741
+ );
1742
+ return { lines, status: problems === 0 ? 0 : 1 };
1743
+ }
1744
+ var RUNS_MUTATION = /(?<![\w-])test-gates(?:\.js)?\s+mutation(?![\w-])|(?<![\w:-])test:gates:mutation(?![\w:-])/;
1745
+ var TARGET = /(?:--dir|--prefix|--workspace|-w)(?:=|\s+)(['"]?)([^\s'";&|)]+)\1/;
1746
+ var CD = /\bcd\s+(['"]?)([^\s'";&|)]+)\1/;
1747
+ function hookUses(input) {
1748
+ const lines = input.text.split("\n").map((line) => line.replace(/(?:^|\s)#.*/, ""));
1749
+ const uses = [];
1750
+ lines.forEach((line, index) => {
1751
+ if (!RUNS_MUTATION.test(line)) {
1752
+ return;
1753
+ }
1754
+ const named = TARGET.exec(line) ?? CD.exec(line);
1755
+ const target = named?.[2] ?? lines.slice(0, index).reverse().map((before) => CD.exec(before)?.[2]).find((found) => found !== void 0);
1756
+ const normalized = target?.split("/").filter((part) => part !== "." && part !== "").join("/");
1757
+ const ours = normalized === void 0 || normalized.includes("$") || normalized.includes("..") || normalized === input.subproject;
1758
+ if (ours) {
1759
+ uses.push({
1760
+ file: input.file,
1761
+ affected: /--affected\b/.test(line) || /test:gates:mutation\b/.test(line) && input.script.includes("--affected")
1762
+ });
1763
+ }
1764
+ });
1765
+ return uses;
1766
+ }
1767
+
1642
1768
  // src/snapshot.ts
1643
1769
  import { spawnSync } from "child_process";
1644
1770
  import { createHash } from "crypto";
@@ -1646,17 +1772,167 @@ import fs5 from "fs";
1646
1772
  import { createRequire } from "module";
1647
1773
  import os from "os";
1648
1774
  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",
1775
+
1776
+ // src/commands/loaded-files.ts
1777
+ import fs4 from "fs";
1778
+ import { builtinModules } from "module";
1779
+ import path4 from "path";
1780
+ var SCRIPT = /\.[cm]?[jt]sx?$/;
1781
+ var COMPONENT = /\.(?:vue|svelte|astro|mdx)$/;
1782
+ var EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
1783
+ var QUOTED = /(['"`])([^'"`\n]+)\1/g;
1784
+ var RUNNER_LOAD = /\b(?:requireActual|requireMock|importActual|importMock|mock|doMock|unstable_mockModule)\(\s*(['"`])([^'"`\n]+)\1/g;
1785
+ function isFile(file) {
1786
+ try {
1787
+ return fs4.statSync(file).isFile();
1788
+ } catch {
1789
+ return false;
1790
+ }
1791
+ }
1792
+ function resolveFile(base, directory = true) {
1793
+ const candidates = [
1794
+ base,
1795
+ ...EXTENSIONS.map((extension) => base + extension),
1796
+ // `./fee.js` written in a TypeScript file is `./fee.ts`
1797
+ ...EXTENSIONS.map((extension) => base.replace(/\.[cm]?jsx?$/, extension)),
1798
+ ...directory ? EXTENSIONS.map((extension) => path4.join(base, `index${extension}`)) : []
1799
+ ];
1800
+ return candidates.find(isFile) ?? null;
1801
+ }
1802
+ function installed(specifier, from) {
1803
+ const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
1804
+ for (let current = path4.dirname(from); ; current = path4.dirname(current)) {
1805
+ if (fs4.existsSync(path4.join(current, "node_modules", name))) {
1806
+ return true;
1807
+ }
1808
+ if (current === path4.dirname(current)) {
1809
+ return false;
1810
+ }
1811
+ }
1812
+ }
1813
+ function loadedFiles(manifest, configFile) {
1814
+ const aliases = manifest.settings.importAliases;
1815
+ const relative = (file) => path4.relative(manifest.dir, file).split(path4.sep).join("/");
1816
+ const target = (specifier, from) => {
1817
+ if (specifier.startsWith(".")) {
1818
+ return resolveFile(path4.resolve(path4.dirname(from), specifier));
1819
+ }
1820
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
1821
+ return alias === void 0 ? null : resolveFile(
1822
+ path4.resolve(manifest.dir, alias.target + specifier.slice(alias.prefix.length))
1823
+ );
1824
+ };
1825
+ const read2 = (file) => {
1826
+ const source = fs4.readFileSync(file, "utf8");
1827
+ const imports = parseImports(source).filter((ref) => !ref.typeOnly);
1828
+ const specifiers = [
1829
+ .../* @__PURE__ */ new Set([
1830
+ ...imports.map((ref) => ref.module),
1831
+ ...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
1832
+ ])
1833
+ ];
1834
+ const strings = [...source.matchAll(QUOTED)].map((match) => match[2]).filter((text) => !specifiers.includes(text));
1835
+ return { source, specifiers, strings };
1836
+ };
1837
+ const named = /* @__PURE__ */ new Set();
1838
+ const configs = configFile === null ? [] : [path4.resolve(manifest.dir, configFile)];
1839
+ const seenConfigs = new Set(configs);
1840
+ const entry = configs[0];
1841
+ for (let file = configs.shift(); file !== void 0; file = configs.shift()) {
1842
+ const { specifiers, strings } = read2(file);
1843
+ const paths = strings.filter((text) => file === entry || /^(?:\.|<rootDir>\/)/.test(text));
1844
+ for (const text of paths) {
1845
+ const stripped = text.replace(/^<rootDir>\//, "");
1846
+ const found = resolveFile(path4.resolve(path4.dirname(file), stripped), false) ?? resolveFile(path4.resolve(manifest.dir, stripped), false);
1847
+ if (found !== null && SCRIPT.test(found)) {
1848
+ named.add(found);
1849
+ }
1850
+ }
1851
+ for (const specifier of specifiers.filter((one) => isProjectImport(one, aliases))) {
1852
+ const found = target(specifier, file);
1853
+ if (found !== null && SCRIPT.test(found) && !seenConfigs.has(found)) {
1854
+ seenConfigs.add(found);
1855
+ configs.push(found);
1856
+ }
1857
+ }
1858
+ }
1859
+ const known = /* @__PURE__ */ new Map();
1860
+ const visit = (file) => {
1861
+ const done = known.get(file);
1862
+ if (done !== void 0) {
1863
+ return done;
1864
+ }
1865
+ const { source, specifiers, strings } = read2(file);
1866
+ const imports = [];
1867
+ const next = [];
1868
+ let lost = COMPONENT.test(file) ? `${relative(file)} is a component, which can use others that it does not import` : null;
1869
+ for (const specifier of specifiers) {
1870
+ if (isProjectImport(specifier, aliases)) {
1871
+ const found = target(specifier, file);
1872
+ if (found === null) {
1873
+ lost ??= `${specifier} (imported by ${relative(file)})`;
1874
+ } else {
1875
+ next.push(found);
1876
+ }
1877
+ } else if (specifier.startsWith("node:") || builtinModules.includes(specifier)) {
1878
+ imports.push({ specifier, builtin: true });
1879
+ } else if (installed(specifier, file)) {
1880
+ imports.push({ specifier, builtin: false });
1881
+ } else {
1882
+ lost ??= `${specifier} (imported by ${relative(file)})`;
1883
+ }
1884
+ }
1885
+ for (const text of strings.filter((one) => one.startsWith("."))) {
1886
+ const found = resolveFile(path4.resolve(path4.dirname(file), text));
1887
+ if (found !== null) {
1888
+ next.push(found);
1889
+ }
1890
+ }
1891
+ const visited = {
1892
+ file: { path: relative(file), source, imports },
1893
+ // A component imports like a script (see COMPONENT); data imports nothing.
1894
+ next: next.filter((found) => SCRIPT.test(found) || COMPONENT.test(found)),
1895
+ lost
1896
+ };
1897
+ known.set(file, visited);
1898
+ return visited;
1899
+ };
1900
+ const closure = (spec, starts) => {
1901
+ const queue = [...starts];
1902
+ const seen = new Set(queue);
1903
+ const files = [];
1904
+ let lost = null;
1905
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
1906
+ const visited = visit(file);
1907
+ files.push(visited.file.path);
1908
+ lost ??= visited.lost;
1909
+ for (const found of visited.next.filter((one) => !seen.has(one))) {
1910
+ seen.add(found);
1911
+ queue.push(found);
1912
+ }
1913
+ }
1914
+ return { spec, files, lost };
1915
+ };
1916
+ const specs = [...new Set(manifest.gates.flatMap((gate) => gate.existingSpecs))];
1917
+ const roots = [
1918
+ closure(null, [...named]),
1919
+ ...specs.map((spec) => closure(spec, [path4.join(manifest.dir, spec)]))
1920
+ ];
1921
+ return { files: [...known.values()].map((visited) => visited.file), roots };
1922
+ }
1923
+
1924
+ // src/snapshot.ts
1925
+ var SnapshotError = class extends Error {
1926
+ };
1927
+ var TOOLS = [
1928
+ "@stryker-mutator/core",
1929
+ "@stryker-mutator/jest-runner",
1930
+ "@stryker-mutator/vitest-runner",
1931
+ "@stryker-mutator/typescript-checker",
1932
+ "jest",
1933
+ "babel-jest",
1934
+ "ts-jest",
1935
+ "@swc/jest",
1660
1936
  "vitest",
1661
1937
  "vite",
1662
1938
  "@vitest/coverage-istanbul",
@@ -1678,8 +1954,14 @@ var LOCK_FILES = [
1678
1954
  "bun.lockb"
1679
1955
  ];
1680
1956
  var NEVER = ["node_modules", ".git"];
1957
+ var WIDENED_SHOWN = 5;
1681
1958
  var SCAN_LIMIT = 8 * 1024 * 1024;
1682
1959
  var UPWARD = /(['"`])((?:[^'"`\n]*\/)?\.\.(?:\/[^'"`\n]*)?)\1/g;
1960
+ var DOCUMENTS = [".md", ".markdown", ".txt", ".rst", ".adoc"];
1961
+ var HERE = /^\$\{(?:__dirname|import\.meta\.dirname)\}(?=\/)/;
1962
+ var FROM_HERE = /\b(?:__dirname|import\.meta\.dirname)((?:\s*,\s*(['"])[^'"`\n]*\2)+)(\s*\))?/g;
1963
+ var LITERAL = /(['"])([^'"`\n]*)\1/g;
1964
+ var isUpward = (segment) => segment.split("/").includes("..");
1683
1965
  function hashOf(content) {
1684
1966
  return createHash("sha256").update(content).digest("hex");
1685
1967
  }
@@ -1697,8 +1979,21 @@ function isInside(parent, child) {
1697
1979
  function toPosix(file) {
1698
1980
  return file.split(path5.sep).join("/");
1699
1981
  }
1982
+ function gitEnvironment(env) {
1983
+ if (env.GIT_DIR === void 0 || env.GIT_WORK_TREE !== void 0) {
1984
+ return env;
1985
+ }
1986
+ const rest = { ...env };
1987
+ delete rest.GIT_DIR;
1988
+ return rest;
1989
+ }
1700
1990
  function git(cwd, args) {
1701
- const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 });
1991
+ const result = spawnSync("git", args, {
1992
+ cwd,
1993
+ env: gitEnvironment(process.env),
1994
+ encoding: "utf8",
1995
+ maxBuffer: 1024 * 1024 * 1024
1996
+ });
1702
1997
  return result.error || result.status !== 0 ? null : result.stdout;
1703
1998
  }
1704
1999
  function gitLister(root) {
@@ -1831,7 +2126,7 @@ function ownVersion() {
1831
2126
  const { version } = createRequire(import.meta.url)("../package.json");
1832
2127
  return version;
1833
2128
  }
1834
- function takeSnapshot(input) {
2129
+ function snapshotOf(input) {
1835
2130
  const dir = fs5.realpathSync(input.dir);
1836
2131
  const top = git(dir, ["rev-parse", "--show-toplevel"]);
1837
2132
  let root = top === null ? dir : fs5.realpathSync(top.trim());
@@ -1845,6 +2140,7 @@ function takeSnapshot(input) {
1845
2140
  }
1846
2141
  const listRoot = root;
1847
2142
  const list = lister;
2143
+ const followed = input.scope === void 0 ? null : input.scope(list(dir, true).map((file) => path5.join(listRoot, file)));
1848
2144
  const walk = walkLister(listRoot);
1849
2145
  const written = input.written.map((file) => {
1850
2146
  const target = path5.resolve(dir, file);
@@ -1860,34 +2156,75 @@ function takeSnapshot(input) {
1860
2156
  }
1861
2157
  const files = /* @__PURE__ */ Object.create(null);
1862
2158
  const regions = [dir];
2159
+ const widened = [];
1863
2160
  const queued = new Set(regions);
1864
- const enqueue = (target, from) => {
1865
- const directory = isInside(target, dir) && isInside(listRoot, target) ? listRoot : target;
2161
+ const enqueue = (target, from, reference, anchored = false) => {
2162
+ const above2 = target !== dir && isInside(target, dir) && isInside(listRoot, target);
2163
+ if (above2 && anchored) {
2164
+ return;
2165
+ }
2166
+ const directory = above2 ? listRoot : target;
1866
2167
  if (!isInside(listRoot, directory)) {
2168
+ const written2 = reference === void 0 ? "" : ` with ${JSON.stringify(reference)}`;
1867
2169
  throw new SnapshotError(
1868
- `${toPosix(path5.relative(dir, from))} points outside of what can be listed (${directory})`
2170
+ `${toPosix(path5.relative(dir, from))} points outside of what can be listed${written2} (${directory})`
1869
2171
  );
1870
2172
  }
2173
+ if (directory === listRoot && listRoot !== dir && widened.length < WIDENED_SHOWN) {
2174
+ const written2 = reference === void 0 ? "" : ` ${JSON.stringify(reference)}`;
2175
+ widened.push(`${toPosix(path5.relative(listRoot, from))}${written2}`);
2176
+ }
1871
2177
  if (![...queued].some((region) => isInside(region, directory))) {
1872
2178
  queued.add(directory);
1873
2179
  regions.push(directory);
1874
2180
  }
1875
2181
  };
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
- }
2182
+ const follow = (fixed, open, anchored, file, reference) => {
1882
2183
  const target = path5.resolve(path5.dirname(file), fixed);
1883
2184
  let isDirectory = false;
1884
2185
  try {
1885
2186
  isDirectory = fs5.statSync(target).isDirectory();
1886
2187
  } catch {
1887
2188
  }
2189
+ if (!isDirectory && !isInside(listRoot, target) && resolveFile(target) === null) {
2190
+ files[toPosix(path5.relative(listRoot, target))] = "missing";
2191
+ return;
2192
+ }
1888
2193
  const directory = isDirectory ? target : path5.dirname(target);
1889
2194
  if (fs5.existsSync(directory)) {
1890
- enqueue(directory, file);
2195
+ enqueue(directory, file, reference, anchored && !open);
2196
+ }
2197
+ };
2198
+ const scan = (text, file) => {
2199
+ const taken = [];
2200
+ for (const match of text.matchAll(FROM_HERE)) {
2201
+ const literals = match[1];
2202
+ const segments = [...literals.matchAll(LITERAL)].map((literal) => literal[2]);
2203
+ if (!segments.some(isUpward)) {
2204
+ continue;
2205
+ }
2206
+ const start = (match.index ?? 0) + match[0].indexOf(literals);
2207
+ taken.push([start, start + literals.length]);
2208
+ const end = (match.index ?? 0) + match[0].length;
2209
+ const open = match[3] === void 0 || /^\s*[,+.}[]/.test(text.slice(end, end + 200));
2210
+ const reference = match[0].slice(0, match[0].length - (match[3]?.length ?? 0));
2211
+ follow(path5.posix.join(...segments), open, true, file, reference);
2212
+ }
2213
+ for (const match of text.matchAll(UPWARD)) {
2214
+ const at = match.index ?? 0;
2215
+ if (taken.some(([start, end]) => at >= start && at < end)) {
2216
+ continue;
2217
+ }
2218
+ const literal = match[2];
2219
+ const here = HERE.exec(literal);
2220
+ const relative = here === null ? literal : `.${literal.slice(here[0].length)}`;
2221
+ const open = relative.includes("${");
2222
+ const fixed = open ? relative.slice(0, relative.lastIndexOf("/", relative.indexOf("${")) + 1) : relative;
2223
+ if (here === null && !fixed.startsWith("../") || open && isUpward(relative.slice(relative.indexOf("${")))) {
2224
+ enqueue(listRoot, file, literal);
2225
+ } else {
2226
+ follow(fixed, open, here !== null, file, literal);
2227
+ }
1891
2228
  }
1892
2229
  };
1893
2230
  const add = (relative) => {
@@ -1919,11 +2256,10 @@ function takeSnapshot(input) {
1919
2256
  } else {
1920
2257
  const content = fs5.readFileSync(file);
1921
2258
  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
- }
2259
+ const document = DOCUMENTS.includes(path5.extname(file).toLowerCase());
2260
+ const loaded = followed === null || !isInside(dir, file) || followed.has(file);
2261
+ if (loaded && !document && content.length <= SCAN_LIMIT && !content.subarray(0, 8e3).includes(0)) {
2262
+ scan(content.toString("utf8").replace(/\\[\\/]/g, "/"), file);
1927
2263
  }
1928
2264
  }
1929
2265
  };
@@ -1965,17 +2301,477 @@ function takeSnapshot(input) {
1965
2301
  environment[name] = version;
1966
2302
  }
1967
2303
  }
1968
- return {
1969
- mode,
1970
- environment,
1971
- args: input.args,
1972
- files
1973
- };
2304
+ return { snapshot: { mode, environment, args: input.args, files }, root: listRoot, widened };
1974
2305
  }
1975
2306
 
1976
- // src/commands/mutation-result.ts
2307
+ // src/affected-files.ts
1977
2308
  import fs6 from "fs";
2309
+ import { builtinModules as builtinModules2 } from "module";
1978
2310
  import path6 from "path";
2311
+ var AffectedError = class extends Error {
2312
+ };
2313
+ var RUNTIME_PATHS = /\b(?:__dirname|__filename|require\.(?:context|resolve)|process\.cwd|createRequire|toMatchFileSnapshot|readFileSync|readdirSync)\b|\bimport\.meta\.(?:url|dirname|filename|glob|resolve)\b/;
2314
+ var FILE_MODULES = /* @__PURE__ */ new Set([
2315
+ "fs",
2316
+ "fs/promises",
2317
+ "child_process",
2318
+ "module",
2319
+ "worker_threads",
2320
+ "fs-extra",
2321
+ "graceful-fs",
2322
+ "glob",
2323
+ "fast-glob",
2324
+ "globby"
2325
+ ]);
2326
+ var bare = (specifier) => specifier.replace(/^node:/, "");
2327
+ function linkedPackage(specifier, from) {
2328
+ const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
2329
+ for (let current = path6.dirname(from); ; current = path6.dirname(current)) {
2330
+ const candidate = path6.join(current, "node_modules", name);
2331
+ if (fs6.existsSync(candidate)) {
2332
+ const target = realpath(candidate);
2333
+ return target.split(path6.sep).includes("node_modules") ? null : target;
2334
+ }
2335
+ if (current === path6.dirname(current)) {
2336
+ return null;
2337
+ }
2338
+ }
2339
+ }
2340
+ var toPosix2 = (file) => file.split(path6.sep).join("/");
2341
+ function realpath(file) {
2342
+ try {
2343
+ return fs6.realpathSync.native(file);
2344
+ } catch {
2345
+ return file;
2346
+ }
2347
+ }
2348
+ function runnerConfigs(dir, stryker, options) {
2349
+ const files = [path6.resolve(dir, stryker)];
2350
+ const named = options.testRunner === "vitest" ? options.vitestConfig : options.jestConfig;
2351
+ if (options.testRunner !== "vitest" && options.testRunner !== "jest") {
2352
+ throw new AffectedError(
2353
+ `--affected knows the test runners jest and vitest, the Stryker config uses ${String(options.testRunner)}`
2354
+ );
2355
+ }
2356
+ if (typeof named === "string") {
2357
+ files.push(path6.resolve(dir, named));
2358
+ } else {
2359
+ const names = options.testRunner === "vitest" ? ["vitest.config", "vite.config"] : ["jest.config"];
2360
+ for (const name of names) {
2361
+ for (const extension of [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"]) {
2362
+ const file = path6.join(dir, name + extension);
2363
+ if (fs6.existsSync(file)) {
2364
+ files.push(file);
2365
+ }
2366
+ }
2367
+ }
2368
+ }
2369
+ return { files: files.filter((file) => fs6.existsSync(file)).map(realpath) };
2370
+ }
2371
+ function fileEdges(file, context) {
2372
+ const { dir, aliases, cache } = context;
2373
+ const known = cache.get(file);
2374
+ if (known !== void 0) {
2375
+ return known;
2376
+ }
2377
+ const shown = (one) => toPosix2(path6.relative(dir, one));
2378
+ const target = (specifier, from) => {
2379
+ if (specifier.startsWith(".")) {
2380
+ return resolveFile(path6.resolve(path6.dirname(from), specifier));
2381
+ }
2382
+ const alias = aliases.find((candidate) => specifier.startsWith(candidate.prefix));
2383
+ return alias === void 0 ? null : resolveFile(path6.resolve(dir, alias.target + specifier.slice(alias.prefix.length)));
2384
+ };
2385
+ const found = { loads: [], unknown: null, computed: false, unnamed: [] };
2386
+ cache.set(file, found);
2387
+ const snapshotFile = path6.join(
2388
+ path6.dirname(file),
2389
+ "__snapshots__",
2390
+ `${path6.basename(file)}.snap`
2391
+ );
2392
+ if (fs6.existsSync(snapshotFile)) {
2393
+ found.loads.push(realpath(snapshotFile));
2394
+ }
2395
+ if (!SCRIPT.test(file) && !COMPONENT.test(file)) {
2396
+ return found;
2397
+ }
2398
+ const source = fs6.readFileSync(file, "utf8");
2399
+ if (COMPONENT.test(file)) {
2400
+ found.unknown = `${shown(file)} is a component, which can use others that it does not import`;
2401
+ }
2402
+ const uncheckable = findUncheckableImports(source)[0];
2403
+ const runtime = RUNTIME_PATHS.exec(source);
2404
+ if (uncheckable !== void 0) {
2405
+ found.unknown = `${shown(file)}:${uncheckable} imports a module whose path is not written out`;
2406
+ found.computed = true;
2407
+ } else if (runtime !== null) {
2408
+ found.unknown = `${shown(file)} uses ${runtime[0]}, which can reach files that no import names`;
2409
+ }
2410
+ const specifiers = /* @__PURE__ */ new Set([
2411
+ ...parseImports(source).map((ref) => ref.module),
2412
+ ...[...source.matchAll(RUNNER_LOAD)].map((match) => match[2])
2413
+ ]);
2414
+ for (const specifier of specifiers) {
2415
+ if (path6.isAbsolute(specifier) || specifier.startsWith("file:")) {
2416
+ const resolved = resolveFile(
2417
+ specifier.startsWith("file:") ? new URL(specifier).pathname : specifier
2418
+ );
2419
+ if (resolved === null) {
2420
+ found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
2421
+ } else {
2422
+ found.loads.push(realpath(resolved));
2423
+ }
2424
+ } else if (isProjectImport(specifier, aliases)) {
2425
+ const resolved = target(specifier, file);
2426
+ if (resolved === null) {
2427
+ found.unknown ??= `${shown(file)} imports ${specifier}, which leads to no file`;
2428
+ } else {
2429
+ found.loads.push(realpath(resolved));
2430
+ }
2431
+ } else if (FILE_MODULES.has(bare(specifier))) {
2432
+ found.unknown ??= `${shown(file)} imports ${specifier}, which can reach files that no import names`;
2433
+ } else if (!specifier.startsWith("node:") && !builtinModules2.includes(specifier)) {
2434
+ if (!installed(specifier, file)) {
2435
+ found.unknown ??= `${shown(file)} imports ${specifier}, which is neither a file nor an installed package (name an alias in settings.importAliases)`;
2436
+ found.unnamed.push(specifier);
2437
+ } else if (linkedPackage(specifier, file) !== null) {
2438
+ found.unknown ??= `${shown(file)} imports ${specifier}, a package of the repository linked into node_modules`;
2439
+ found.computed = true;
2440
+ }
2441
+ }
2442
+ }
2443
+ for (const match of source.matchAll(QUOTED)) {
2444
+ const text = match[2];
2445
+ if (text.startsWith(".") && !specifiers.has(text)) {
2446
+ const resolved = resolveFile(path6.resolve(path6.dirname(file), text));
2447
+ if (resolved !== null) {
2448
+ found.loads.push(realpath(resolved));
2449
+ }
2450
+ }
2451
+ }
2452
+ return found;
2453
+ }
2454
+ function affectedInputs(manifest, input) {
2455
+ const dir = realpath(manifest.dir);
2456
+ const aliases = manifest.settings.importAliases;
2457
+ const placed = [...input.tests, ...Object.values(input.covering).flat()];
2458
+ const absolute = placed.find((file) => path6.isAbsolute(file) || file.startsWith(".."));
2459
+ if (absolute !== void 0) {
2460
+ throw new AffectedError(`the report names a spec file outside of the subproject: ${absolute}`);
2461
+ }
2462
+ const keyOf2 = (file) => {
2463
+ const relative = path6.relative(input.root, realpath(file));
2464
+ if (relative.startsWith("..") || path6.isAbsolute(relative)) {
2465
+ throw new AffectedError(`${file} is outside of the files that are compared`);
2466
+ }
2467
+ return toPosix2(relative);
2468
+ };
2469
+ const shown = (file) => toPosix2(path6.relative(dir, file));
2470
+ const edges = /* @__PURE__ */ new Map();
2471
+ const edgesOf = (file) => fileEdges(file, { dir, aliases, cache: edges });
2472
+ const closure = (start, strict) => {
2473
+ const seen = /* @__PURE__ */ new Set([start]);
2474
+ const queue = [start];
2475
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
2476
+ const found = edgesOf(file);
2477
+ if (strict && found.unknown !== null) {
2478
+ throw new AffectedError(found.unknown);
2479
+ }
2480
+ for (const next of found.loads) {
2481
+ if (!seen.has(next)) {
2482
+ seen.add(next);
2483
+ queue.push(next);
2484
+ }
2485
+ }
2486
+ }
2487
+ return [...seen];
2488
+ };
2489
+ const isCompared = (file) => {
2490
+ const relative = path6.relative(input.root, realpath(file));
2491
+ return !relative.startsWith("..") && !path6.isAbsolute(relative) && toPosix2(relative) in input.files;
2492
+ };
2493
+ const keysOf = (files) => files.map((file) => {
2494
+ const key = keyOf2(file);
2495
+ if (!(key in input.files)) {
2496
+ throw new AffectedError(
2497
+ `${shown(file)} is loaded by a gate or a spec, but is not among the compared files (does git ignore it?)`
2498
+ );
2499
+ }
2500
+ return key;
2501
+ });
2502
+ const config = /* @__PURE__ */ new Set();
2503
+ const named = [];
2504
+ for (const file of input.configs.files) {
2505
+ for (const loaded of closure(file, false).filter(isCompared)) {
2506
+ config.add(loaded);
2507
+ if (!SCRIPT.test(loaded)) {
2508
+ continue;
2509
+ }
2510
+ for (const match of fs6.readFileSync(loaded, "utf8").matchAll(QUOTED)) {
2511
+ const text = match[2].replace(/^<rootDir>\//, "");
2512
+ const found = resolveFile(path6.resolve(path6.dirname(loaded), text), false) ?? resolveFile(path6.resolve(dir, text), false);
2513
+ if (found !== null && !text.includes("*") && isCompared(found)) {
2514
+ named.push(realpath(found));
2515
+ }
2516
+ }
2517
+ }
2518
+ }
2519
+ const gatePaths = new Map(
2520
+ manifest.gates.map((gate) => [realpath(path6.join(dir, gate.path)), gate.path])
2521
+ );
2522
+ const tests = [
2523
+ .../* @__PURE__ */ new Set([
2524
+ ...input.tests,
2525
+ ...manifest.gates.flatMap((gate) => gate.existingSpecs),
2526
+ ...Object.values(input.covering).flat()
2527
+ ])
2528
+ ];
2529
+ const runAsSuch = new Set(
2530
+ [...tests, ...manifest.gates.map((gate) => gate.path)].map(
2531
+ (file) => realpath(path6.join(dir, file))
2532
+ )
2533
+ );
2534
+ const subprojectKey = toPosix2(path6.relative(input.root, dir));
2535
+ const isConfig = (file) => input.configs.files.includes(file) || kindOf(keyOf2(file), subprojectKey) === "config";
2536
+ for (const file of named.filter((one) => !isConfig(one) && !runAsSuch.has(one))) {
2537
+ for (const loaded of closure(file, true)) {
2538
+ const gate = gatePaths.get(loaded);
2539
+ if (gate !== void 0) {
2540
+ throw new AffectedError(
2541
+ `${gate} is loaded through the test runner config for every spec (${shown(file)})`
2542
+ );
2543
+ }
2544
+ config.add(loaded);
2545
+ }
2546
+ }
2547
+ const fromSubproject = (file) => path6.join(dir, file);
2548
+ const closures = /* @__PURE__ */ Object.create(null);
2549
+ for (const file of [...manifest.gates.map((gate) => gate.path), ...tests]) {
2550
+ const absolute2 = fromSubproject(file);
2551
+ if (!fs6.existsSync(absolute2)) {
2552
+ continue;
2553
+ }
2554
+ closures[keysOf([absolute2])[0]] = keysOf(closure(realpath(absolute2), true));
2555
+ }
2556
+ const keyed = (files) => files.map((file) => toPosix2(path6.relative(input.root, path6.join(dir, file))));
2557
+ const dependencies = gateDependencies({
2558
+ gates: manifest.gates.map((gate) => ({
2559
+ key: keyed([gate.path])[0],
2560
+ specs: keyed(gate.existingSpecs)
2561
+ })),
2562
+ tests: keyed(tests),
2563
+ covering: Object.fromEntries(
2564
+ Object.entries(input.covering).map(([gate, specs]) => [keyed([gate])[0], keyed(specs)])
2565
+ ),
2566
+ closures
2567
+ });
2568
+ const subproject = toPosix2(path6.relative(input.root, dir));
2569
+ const prefix = subproject === "" ? "" : `${subproject}/`;
2570
+ const gates = /* @__PURE__ */ Object.create(null);
2571
+ for (const gate of manifest.gates) {
2572
+ const key = keyed([gate.path])[0];
2573
+ const found = dependencies[key];
2574
+ gates[gate.path] = {
2575
+ key,
2576
+ specs: found.specs.map(
2577
+ (spec) => spec.startsWith(prefix) ? spec.slice(prefix.length) : spec
2578
+ ),
2579
+ files: found.files
2580
+ };
2581
+ }
2582
+ return { subproject, config: keysOf([...config]).sort(), gates };
2583
+ }
2584
+ var NOT_THE_HELPER2 = "the runner config is not made with createJestGatesConfig or createVitestGatesConfig";
2585
+ var OWN = /@t09tanaka\/test-gates\/(?:jest|vitest)\b/;
2586
+ var PROJECTS = /\b(?:projects|workspace)\b/;
2587
+ var HELPERS = ["createJestGatesConfig", "createVitestGatesConfig"];
2588
+ function isHelperResult(text) {
2589
+ const names = new Set(HELPERS);
2590
+ for (const match of text.matchAll(
2591
+ /\b(?:createJestGatesConfig|createVitestGatesConfig)\s*(?::|\bas\b)\s*([\w$]+)/g
2592
+ )) {
2593
+ names.add(match[1]);
2594
+ }
2595
+ const escape = (name2) => name2.replace(/\$/g, "\\$");
2596
+ const call = `(?:defineConfig\\s*\\(\\s*)?(?:${[...names].map(escape).join("|")})\\s*\\(`;
2597
+ const exported = "(?:export\\s+default|module\\.exports\\s*=)\\s*";
2598
+ const reached = (name2) => new RegExp(`\\b${escape(name2)}\\s*(?:\\.|\\[)`).test(text);
2599
+ if (new RegExp(`${exported}${call}`).test(text)) {
2600
+ return !reached("module.exports") && !/\bexports\s*(?:\.|\[)/.test(text.replace(/\bmodule\.exports\s*=/g, ""));
2601
+ }
2602
+ const variable = new RegExp(`\\b(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*${call}`).exec(text);
2603
+ if (variable === null) {
2604
+ return false;
2605
+ }
2606
+ const name = escape(variable[1]);
2607
+ const uses = text.match(new RegExp(`(?<![\\w$.])${name}(?![\\w$])`, "g")) ?? [];
2608
+ return uses.length === 2 && new RegExp(`${exported}${name}\\s*(?:;|$)`, "m").test(text);
2609
+ }
2610
+ var READ_BY_NAME = [
2611
+ /^test-gates\.json$/,
2612
+ /^[jt]sconfig(?:\..+)?\.json$/,
2613
+ /^babel\.config\.[cm]?[jt]s(?:on)?$/,
2614
+ /^\.babelrc(?:\.[cm]?js(?:on)?)?$/,
2615
+ /^\.swcrc$/
2616
+ ];
2617
+ function loadedScope(manifest, configs, listed2) {
2618
+ const dir = realpath(manifest.dir);
2619
+ const cache = /* @__PURE__ */ new Map();
2620
+ const context = { dir, aliases: manifest.settings.importAliases, cache };
2621
+ const runner = configs.files.slice(1);
2622
+ const texts = runner.map((file) => withoutComments(fs6.readFileSync(file, "utf8")));
2623
+ if (runner.length === 0 || !texts.every((text) => OWN.test(text))) {
2624
+ return { every: NOT_THE_HELPER2 };
2625
+ }
2626
+ if (!texts.every(isHelperResult)) {
2627
+ return {
2628
+ every: "what the runner config exports is not what the helper returns (merged with another config, or changed after the call)"
2629
+ };
2630
+ }
2631
+ const parts = new Set(runner.map(realpath));
2632
+ for (const file of [...parts]) {
2633
+ if (SCRIPT.test(file)) {
2634
+ fileEdges(file, context).loads.forEach((loaded) => parts.add(loaded));
2635
+ }
2636
+ }
2637
+ if ([...parts].some((file) => PROJECTS.test(withoutComments(fs6.readFileSync(file, "utf8"))))) {
2638
+ return {
2639
+ every: "the runner config, or a file it is put together from, names projects or a workspace"
2640
+ };
2641
+ }
2642
+ const named = fs6.readdirSync(dir).filter((name) => READ_BY_NAME.some((pattern) => pattern.test(name))).map((name) => path6.join(dir, name));
2643
+ const mocks = path6.join(dir, "__mocks__");
2644
+ const seeds = [
2645
+ ...configs.files,
2646
+ ...named,
2647
+ ...manifest.gates.flatMap((gate) => [gate.path, ...gate.existingSpecs]).map((file) => path6.join(dir, file)),
2648
+ ...listed2.filter((file) => file.startsWith(`${mocks}${path6.sep}`))
2649
+ ].filter((file) => fs6.existsSync(file)).map(realpath);
2650
+ const naming = new Set(
2651
+ [...configs.files, ...named].filter((file) => fs6.existsSync(file)).map(realpath)
2652
+ );
2653
+ const seen = new Set(seeds);
2654
+ const queue = [...seeds];
2655
+ const add = (file) => {
2656
+ const real = realpath(file);
2657
+ if (!seen.has(real)) {
2658
+ seen.add(real);
2659
+ queue.push(real);
2660
+ }
2661
+ };
2662
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) {
2663
+ if (fs6.statSync(file).isDirectory()) {
2664
+ continue;
2665
+ }
2666
+ const edges = fileEdges(file, context);
2667
+ if (edges.computed) {
2668
+ return { every: edges.unknown ?? "a file loads modules that are not followed" };
2669
+ }
2670
+ edges.loads.forEach(add);
2671
+ for (const specifier of edges.unnamed) {
2672
+ endingAs(specifier, listed2).forEach(add);
2673
+ }
2674
+ const mock = path6.join(path6.dirname(file), "__mocks__", path6.basename(file));
2675
+ if (fs6.existsSync(mock)) {
2676
+ add(mock);
2677
+ }
2678
+ if (naming.has(file) || SCRIPT.test(file) && path6.dirname(file) === dir) {
2679
+ for (const named2 of namedIn(file)) {
2680
+ const text = named2.replace(/^<rootDir>\//, "");
2681
+ const found = resolveFile(path6.resolve(path6.dirname(file), text), false) ?? resolveFile(path6.resolve(dir, text), false);
2682
+ if (found !== null && !text.includes("*")) {
2683
+ if (!SCRIPT.test(found)) {
2684
+ naming.add(realpath(found));
2685
+ }
2686
+ add(found);
2687
+ }
2688
+ }
2689
+ }
2690
+ }
2691
+ return { files: seen };
2692
+ }
2693
+ function endingAs(specifier, files) {
2694
+ const parts = specifier.split("/");
2695
+ const rest = (parts.length > 1 ? parts.slice(1).join("/") : specifier.replace(/^[@#~$]+/, "")).replace(/\.[cm]?[jt]sx?$/, "");
2696
+ if (rest === "") {
2697
+ return [];
2698
+ }
2699
+ return files.filter((file) => {
2700
+ const bare2 = toPosix2(file).replace(/\.[^./]+$/, "");
2701
+ return [bare2, bare2.replace(/\/index$/, "")].some((one) => one.endsWith(`/${rest}`));
2702
+ });
2703
+ }
2704
+ function namedIn(file) {
2705
+ const text = fs6.readFileSync(file, "utf8");
2706
+ if (SCRIPT.test(file)) {
2707
+ return [...text.matchAll(QUOTED)].map((match) => match[2]);
2708
+ }
2709
+ return ["extends", "plugins", "presets"].flatMap((key) => valuesOf(text, key));
2710
+ }
2711
+ function valuesOf(text, key) {
2712
+ const found = [];
2713
+ for (const match of text.matchAll(new RegExp(`"${key}"\\s*:\\s*`, "g"))) {
2714
+ let at = (match.index ?? 0) + match[0].length;
2715
+ if (text[at] === '"') {
2716
+ const end = text.indexOf('"', at + 1);
2717
+ found.push(text.slice(at + 1, end === -1 ? void 0 : end));
2718
+ continue;
2719
+ }
2720
+ if (text[at] !== "[") {
2721
+ continue;
2722
+ }
2723
+ let depth = 0;
2724
+ for (; at < text.length; at += 1) {
2725
+ const char = text[at];
2726
+ if (char === '"') {
2727
+ const end = text.indexOf('"', at + 1);
2728
+ found.push(text.slice(at + 1, end === -1 ? void 0 : end));
2729
+ at = end === -1 ? text.length : end;
2730
+ } else if (char === "[") {
2731
+ depth += 1;
2732
+ } else if (char === "]") {
2733
+ depth -= 1;
2734
+ if (depth === 0) {
2735
+ break;
2736
+ }
2737
+ }
2738
+ }
2739
+ }
2740
+ return found;
2741
+ }
2742
+ function withoutComments(text) {
2743
+ let out = "";
2744
+ for (let at = 0; at < text.length; at += 1) {
2745
+ const char = text[at];
2746
+ if (char === "'" || char === '"' || char === "`") {
2747
+ let end = at + 1;
2748
+ while (end < text.length && text[end] !== char && text[end] !== "\n") {
2749
+ end += text[end] === "\\" ? 2 : 1;
2750
+ }
2751
+ out += text.slice(at, end + 1);
2752
+ at = end;
2753
+ } else if (char === "/" && text[at + 1] === "/") {
2754
+ const end = text.indexOf("\n", at);
2755
+ at = end === -1 ? text.length : end - 1;
2756
+ } else if (char === "/" && text[at + 1] === "*") {
2757
+ const end = text.indexOf("*/", at + 2);
2758
+ at = end === -1 ? text.length : end + 1;
2759
+ } else {
2760
+ out += char;
2761
+ }
2762
+ }
2763
+ return out;
2764
+ }
2765
+
2766
+ // src/commands/mutation.ts
2767
+ import { spawnSync as spawnSync2 } from "child_process";
2768
+ import fs8 from "fs";
2769
+ import path8 from "path";
2770
+ import { pathToFileURL } from "url";
2771
+
2772
+ // src/commands/mutation-result.ts
2773
+ import fs7 from "fs";
2774
+ import path7 from "path";
1979
2775
 
1980
2776
  // src/core/mutation-result.ts
1981
2777
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -1984,7 +2780,7 @@ var NOT_EVALUABLE = /* @__PURE__ */ new Set(["RuntimeError", "CompileError"]);
1984
2780
  function normalizeCode(text) {
1985
2781
  return text.replace(/\s+/g, " ").trim();
1986
2782
  }
1987
- function normalizePath2(file) {
2783
+ function normalizePath(file) {
1988
2784
  return file.replace(/\\/g, "/").replace(/^\.\//, "");
1989
2785
  }
1990
2786
  function normalizeNewlines(text) {
@@ -2050,10 +2846,10 @@ function judgeMutationReport(input) {
2050
2846
  };
2051
2847
  const files = /* @__PURE__ */ new Map();
2052
2848
  for (const [file, result] of Object.entries(input.report.files ?? {})) {
2053
- files.set(normalizePath2(file), result);
2849
+ files.set(normalizePath(file), result);
2054
2850
  }
2055
2851
  for (const gate of input.gates) {
2056
- const gatePath = normalizePath2(gate.path);
2852
+ const gatePath = normalizePath(gate.path);
2057
2853
  const shapeViolations = validateEquivalentMutants(gate.path, gate.equivalentMutants);
2058
2854
  violations.push(...shapeViolations);
2059
2855
  violations.push(
@@ -2225,17 +3021,17 @@ function formatSummary(summary) {
2225
3021
 
2226
3022
  // src/commands/mutation-result.ts
2227
3023
  function reportPathOf(manifest, override) {
2228
- return path6.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
3024
+ return path7.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
2229
3025
  }
2230
- function runMutationResult(dir, reportOverride, io2, only = []) {
3026
+ function runMutationResult(dir, reportOverride, io2, only = [], seen = () => void 0) {
2231
3027
  const manifest = readManifest(dir);
2232
3028
  const gates = only.length === 0 ? manifest.gates : manifest.gates.filter((gate) => only.includes(gate.path));
2233
3029
  const caveat = only.length === 0 ? "" : gateRunCaveat(gates.length, manifest.gates.length);
2234
3030
  const reportPath = reportPathOf(manifest, reportOverride);
2235
- const shown = path6.relative(manifest.dir, reportPath) || reportPath;
3031
+ const shown = path7.relative(manifest.dir, reportPath) || reportPath;
2236
3032
  let report;
2237
3033
  try {
2238
- report = JSON.parse(fs6.readFileSync(reportPath, "utf8"));
3034
+ report = JSON.parse(fs7.readFileSync(reportPath, "utf8"));
2239
3035
  } catch (error) {
2240
3036
  io2.err(
2241
3037
  `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 +3048,13 @@ function runMutationResult(dir, reportOverride, io2, only = []) {
2252
3048
  maxTimeouts: manifest.settings.maxTimeouts,
2253
3049
  readSource: (gatePath) => {
2254
3050
  try {
2255
- return fs6.readFileSync(path6.join(manifest.dir, gatePath), "utf8");
3051
+ return fs7.readFileSync(path7.join(manifest.dir, gatePath), "utf8");
2256
3052
  } catch {
2257
3053
  return void 0;
2258
3054
  }
2259
3055
  }
2260
3056
  });
3057
+ seen({ report, verdict });
2261
3058
  const summary = formatSummary(verdict.summary);
2262
3059
  if (verdict.violations.length === 0 && verdict.survivors.length === 0) {
2263
3060
  io2.out(`test-gates mutation: OK (${summary})${caveat}`);
@@ -2319,15 +3116,15 @@ function runMutationResult(dir, reportOverride, io2, only = []) {
2319
3116
  // src/commands/mutation.ts
2320
3117
  var CONFIG_MARK = "\0test-gates-stryker-config\0";
2321
3118
  function loadStrykerConfig(manifest, config) {
2322
- const file = path7.resolve(manifest.dir, config);
3119
+ const file = path8.resolve(manifest.dir, config);
2323
3120
  if (file.endsWith(".json")) {
2324
3121
  try {
2325
- return JSON.parse(fs7.readFileSync(file, "utf8"));
3122
+ return JSON.parse(fs8.readFileSync(file, "utf8"));
2326
3123
  } catch {
2327
3124
  return void 0;
2328
3125
  }
2329
3126
  }
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)});`;
3127
+ 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
3128
  const result = spawnSync2(process.execPath, ["--input-type=module", "--eval", script], {
2332
3129
  cwd: manifest.dir,
2333
3130
  encoding: "utf8",
@@ -2341,7 +3138,7 @@ function loadStrykerConfig(manifest, config) {
2341
3138
  }
2342
3139
  function modifiedAt(file) {
2343
3140
  try {
2344
- const stat = fs7.statSync(file, { bigint: true });
3141
+ const stat = fs8.statSync(file, { bigint: true });
2345
3142
  return `${stat.mtimeNs} ${stat.ino}`;
2346
3143
  } catch {
2347
3144
  return null;
@@ -2350,145 +3147,643 @@ function modifiedAt(file) {
2350
3147
  function guardRecordPath(incrementalFile) {
2351
3148
  return `${incrementalFile}.test-gates.json`;
2352
3149
  }
2353
- function guardIncremental(manifest, config, strykerArgs, part) {
2354
- const nothing = { force: null, record: () => void 0 };
3150
+ function coveringSpecs(report, gates) {
3151
+ const fileOf = /* @__PURE__ */ new Map();
3152
+ for (const [file, entry] of Object.entries(report.testFiles ?? {})) {
3153
+ for (const test of entry.tests ?? []) {
3154
+ fileOf.set(test.id, file);
3155
+ }
3156
+ }
3157
+ const covering = /* @__PURE__ */ Object.create(null);
3158
+ for (const gate of gates) {
3159
+ const specs = /* @__PURE__ */ new Set();
3160
+ let placed = true;
3161
+ const key = withoutDots(gate);
3162
+ for (const mutant of report.files?.[key]?.mutants ?? []) {
3163
+ for (const id of [...mutant.coveredBy ?? [], ...mutant.killedBy ?? []]) {
3164
+ const file = fileOf.get(id);
3165
+ if (file === void 0) {
3166
+ placed = false;
3167
+ } else {
3168
+ specs.add(file);
3169
+ }
3170
+ }
3171
+ }
3172
+ covering[gate] = placed ? [...specs].sort() : null;
3173
+ }
3174
+ return covering;
3175
+ }
3176
+ function decideReuse(manifest, config, strykerArgs, part, affected) {
2355
3177
  const loaded = loadStrykerConfig(manifest, config);
2356
3178
  const options = incrementalOptions(loaded, strykerArgs);
3179
+ const base = fs8.realpathSync(manifest.dir);
3180
+ const configs = () => runnerConfigs(base, config, loaded ?? {});
3181
+ const decision = {
3182
+ incremental: false,
3183
+ incrementalFile: null,
3184
+ recordFile: null,
3185
+ incrementalHash: null,
3186
+ forced: options.force,
3187
+ snapshot: null,
3188
+ failure: null,
3189
+ root: base,
3190
+ widened: [],
3191
+ scope: null,
3192
+ force: null,
3193
+ plan: null,
3194
+ last: void 0,
3195
+ ci: affected ? affectedRefused(process.env) : null,
3196
+ unchanged: null,
3197
+ configs
3198
+ };
2357
3199
  if (loaded !== void 0 && !options.incremental) {
2358
- return nothing;
3200
+ return decision;
2359
3201
  }
2360
- const base = fs7.realpathSync(manifest.dir);
2361
- const incrementalFile = path7.resolve(base, options.incrementalFile);
3202
+ decision.incremental = true;
3203
+ const incrementalFile = path8.resolve(base, options.incrementalFile);
2362
3204
  const recordFile = guardRecordPath(incrementalFile);
3205
+ decision.incrementalFile = incrementalFile;
3206
+ decision.recordFile = recordFile;
2363
3207
  let snapshot = null;
3208
+ let root = base;
2364
3209
  let failure = "the Stryker config could not be loaded to see where the incremental file is";
2365
3210
  if (loaded !== void 0) {
3211
+ const scope = (listed2) => {
3212
+ try {
3213
+ const found = loadedScope(manifest, configs(), listed2);
3214
+ decision.scope = "files" in found ? { files: found.files.size } : found;
3215
+ return "files" in found ? found.files : null;
3216
+ } catch (error) {
3217
+ decision.scope = { every: error.message };
3218
+ return null;
3219
+ }
3220
+ };
2366
3221
  try {
2367
- snapshot = takeSnapshot({
3222
+ let widened;
3223
+ ({ snapshot, root, widened } = snapshotOf({
3224
+ scope,
2368
3225
  dir: manifest.dir,
2369
3226
  written: [
2370
3227
  incrementalFile,
2371
3228
  recordFile,
2372
- path7.resolve(base, options.tempDirName),
2373
- path7.resolve(base, manifest.settings.strykerReportFile)
3229
+ path8.resolve(base, options.tempDirName),
3230
+ path8.resolve(base, manifest.settings.strykerReportFile)
2374
3231
  ],
2375
3232
  args: comparableArgs(strykerArgs)
2376
- });
3233
+ }));
3234
+ decision.widened = widened;
2377
3235
  } catch (error) {
2378
3236
  failure = `the files the run depends on could not be listed (${error.message})`;
2379
3237
  }
2380
3238
  }
2381
- let force = null;
3239
+ decision.snapshot = snapshot;
3240
+ decision.failure = snapshot === null ? failure : null;
3241
+ decision.root = root;
2382
3242
  const incremental = hashFile(incrementalFile);
3243
+ decision.incrementalHash = incremental;
2383
3244
  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
- };
3245
+ decision.force = incremental !== null && !options.force ? "with --gate nothing is taken over from an earlier run" : null;
3246
+ return decision;
3247
+ }
3248
+ let stored;
3249
+ try {
3250
+ stored = JSON.parse(fs8.readFileSync(recordFile, "utf8"));
3251
+ } catch {
3252
+ stored = void 0;
2389
3253
  }
3254
+ const ci = decision.ci;
3255
+ let force = null;
3256
+ let plan = null;
3257
+ let last;
2390
3258
  if (incremental !== null && !options.force) {
2391
3259
  if (snapshot === null) {
2392
3260
  force = failure;
3261
+ } else if (!affected) {
3262
+ force = refuseReuse({ record: stored, incremental, snapshot });
3263
+ } else if (ci !== null) {
3264
+ force = ci;
2393
3265
  } else {
2394
- let record;
2395
- try {
2396
- record = JSON.parse(fs7.readFileSync(recordFile, "utf8"));
2397
- } catch {
2398
- record = void 0;
3266
+ const read2 = readRecord({ record: stored, incremental, snapshot });
3267
+ if ("refused" in read2) {
3268
+ force = read2.refused;
3269
+ } else {
3270
+ last = read2.record.affected;
3271
+ try {
3272
+ const inputs = affectedInputs(manifest, {
3273
+ root,
3274
+ files: snapshot.files,
3275
+ configs: configs(),
3276
+ tests: last?.tests ?? [],
3277
+ covering: Object.fromEntries(
3278
+ Object.entries(last?.gates ?? {}).map(([gate, entry]) => [gate, entry.specs])
3279
+ )
3280
+ });
3281
+ plan = planAffected({
3282
+ before: { files: read2.record.snapshot.files, affected: last },
3283
+ now: snapshot.files,
3284
+ subproject: inputs.subproject,
3285
+ config: inputs.config,
3286
+ gates: manifest.gates.map((gate) => ({
3287
+ path: gate.path,
3288
+ files: inputs.gates[gate.path]?.files ?? []
3289
+ }))
3290
+ });
3291
+ } catch (error) {
3292
+ if (!(error instanceof AffectedError)) {
3293
+ throw error;
3294
+ }
3295
+ plan = { full: `what a gate depends on cannot be told: ${error.message}` };
3296
+ }
3297
+ if ("full" in plan) {
3298
+ if (refuseReuse({ record: stored, incremental, snapshot }) === null) {
3299
+ decision.unchanged = plan.full;
3300
+ plan = null;
3301
+ } else {
3302
+ force = plan.full;
3303
+ }
3304
+ }
2399
3305
  }
2400
- force = refuseReuse({ record, incremental, snapshot });
2401
3306
  }
3307
+ } else if (affected && incremental === null && !options.force) {
3308
+ force = "there is no incremental file of an earlier run";
3309
+ }
3310
+ decision.force = force;
3311
+ decision.plan = plan;
3312
+ decision.last = last;
3313
+ return decision;
3314
+ }
3315
+ function guardIncremental(manifest, config, strykerArgs, part, affected) {
3316
+ const nothing = { force: null, mutate: null, said: [], record: () => void 0 };
3317
+ const decision = decideReuse(manifest, config, strykerArgs, part, affected);
3318
+ if (!decision.incremental) {
3319
+ return affected ? {
3320
+ ...nothing,
3321
+ said: [
3322
+ "test-gates mutation --affected: Stryker does not run in incremental mode, so every mutant is run"
3323
+ ]
3324
+ } : nothing;
3325
+ }
3326
+ const { snapshot, root, plan, last, ci, configs, force } = decision;
3327
+ const incrementalFile = decision.incrementalFile;
3328
+ const recordFile = decision.recordFile;
3329
+ const incremental = decision.incrementalHash;
3330
+ const options = { force: decision.forced };
3331
+ if (part) {
3332
+ fs8.rmSync(recordFile, { force: true });
3333
+ return { ...nothing, force };
2402
3334
  }
2403
- fs7.rmSync(recordFile, { force: true });
3335
+ fs8.rmSync(recordFile, { force: true });
2404
3336
  const before = modifiedAt(incrementalFile);
3337
+ const rerun = plan !== null && "rerun" in plan ? plan.rerun.map((entry) => entry.gate) : null;
3338
+ const everyGate = force ?? (rerun !== null && rerun.length > 0 && rerun.length === manifest.gates.length ? "every gate is affected" : null);
3339
+ let ranges = [];
3340
+ if (everyGate === null && incremental !== null && !options.force) {
3341
+ const kept = rerun === null ? manifest.gates.map((gate) => gate.path) : manifest.gates.map((gate) => gate.path).filter((gate) => !rerun.includes(gate));
3342
+ try {
3343
+ ranges = timeoutRanges(
3344
+ JSON.parse(fs8.readFileSync(incrementalFile, "utf8")),
3345
+ kept
3346
+ );
3347
+ } catch {
3348
+ ranges = [];
3349
+ }
3350
+ }
3351
+ const subset = rerun !== null && rerun.length > 0 && everyGate === null ? rerun : [];
3352
+ let said = [];
3353
+ if (affected && !options.force) {
3354
+ if (force !== null) {
3355
+ said = describePlan({ full: force });
3356
+ } else if (plan !== null) {
3357
+ said = describePlan(plan, 5, ranges.length);
3358
+ } else if (decision.unchanged !== null) {
3359
+ said = [
3360
+ `test-gates mutation --affected: nothing changed since the last run, every result is reused (gate by gate, ${decision.unchanged.replace(/^what a gate/, "what each gate")})`,
3361
+ ...ranges.length > 0 ? [` and ${ranges.length} mutant(s) that timed out in the last run are run again`] : []
3362
+ ];
3363
+ }
3364
+ } else if (ranges.length > 0) {
3365
+ said = [
3366
+ `test-gates mutation: ${ranges.length} mutant(s) that timed out in the last run are run again, the other results are reused`
3367
+ ];
3368
+ }
2405
3369
  return {
2406
- force,
2407
- record: () => {
3370
+ // Every gate is run again: the same as a run of every mutant.
3371
+ force: everyGate,
3372
+ mutate: subset.length + ranges.length > 0 ? [...subset, ...ranges] : null,
3373
+ said,
3374
+ record: (judged) => {
2408
3375
  const written = hashFile(incrementalFile);
2409
3376
  if (snapshot === null || written === null || modifiedAt(incrementalFile) === before) {
2410
3377
  return;
2411
3378
  }
2412
3379
  const record = { format: RECORD_FORMAT, incremental: written, snapshot };
3380
+ if (affected && ci === null && judged !== null) {
3381
+ record.affected = affectedRecord(judged, rerun, last);
3382
+ }
2413
3383
  try {
2414
- fs7.writeFileSync(recordFile, `${JSON.stringify(record)}
3384
+ fs8.writeFileSync(recordFile, `${JSON.stringify(record)}
2415
3385
  `);
2416
3386
  } catch {
2417
3387
  }
2418
3388
  }
2419
- };
3389
+ };
3390
+ function affectedRecord(judged, ran, previous) {
3391
+ const gates = manifest.gates.map((gate) => gate.path);
3392
+ const dirty = new Set(
3393
+ [...judged.verdict.survivors, ...judged.verdict.unexpectedTimeouts].map((one) => one.file)
3394
+ );
3395
+ const fresh = coveringSpecs(judged.report, gates);
3396
+ const covering = /* @__PURE__ */ Object.create(null);
3397
+ for (const gate of gates) {
3398
+ const specs = ran === null || ran.includes(gate) ? fresh[gate] : previous?.gates[gate]?.specs ?? null;
3399
+ if (specs !== null && specs !== void 0 && !dirty.has(gate)) {
3400
+ covering[gate] = specs;
3401
+ }
3402
+ }
3403
+ try {
3404
+ const inputs = affectedInputs(manifest, {
3405
+ root,
3406
+ files: snapshot.files,
3407
+ configs: configs(),
3408
+ tests: Object.keys(judged.report.testFiles ?? {}),
3409
+ covering
3410
+ });
3411
+ const recorded = /* @__PURE__ */ Object.create(null);
3412
+ for (const gate of Object.keys(covering)) {
3413
+ const entry = inputs.gates[gate];
3414
+ if (entry !== void 0) {
3415
+ recorded[gate] = { specs: entry.specs, files: entry.files };
3416
+ }
3417
+ }
3418
+ return { tests: Object.keys(judged.report.testFiles ?? {}).sort(), gates: recorded };
3419
+ } catch (error) {
3420
+ if (error instanceof AffectedError) {
3421
+ return void 0;
3422
+ }
3423
+ throw error;
3424
+ }
3425
+ }
3426
+ }
3427
+ function runMutation(dir, strykerArgs, io2, only = [], affected = false) {
3428
+ const part = only.length > 0;
3429
+ if (affected && strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
3430
+ throw new UsageError(
3431
+ "--affected and --mutate cannot be combined: --affected sets --mutate itself"
3432
+ );
3433
+ }
3434
+ if (part) {
3435
+ const refused = gateRunRefused(process.env);
3436
+ if (refused !== null) {
3437
+ throw new UsageError(refused);
3438
+ }
3439
+ if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
3440
+ throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
3441
+ }
3442
+ }
3443
+ const manifest = readManifest(dir);
3444
+ const chosen = selectControls({
3445
+ gates: manifest.gates.map((gate) => gate.path),
3446
+ mode: "all",
3447
+ only,
3448
+ shard: null
3449
+ });
3450
+ if ("error" in chosen) {
3451
+ throw new UsageError(chosen.error);
3452
+ }
3453
+ const config = findStrykerConfig(manifest);
3454
+ if (config === null) {
3455
+ throw new ManifestError(
3456
+ "no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
3457
+ );
3458
+ }
3459
+ const stryker = findLocalBin(manifest.dir, "stryker");
3460
+ if (stryker === null) {
3461
+ throw new ManifestError(
3462
+ "stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
3463
+ );
3464
+ }
3465
+ const reportFile = reportPathOf(manifest, null);
3466
+ fs8.rmSync(reportFile, { force: true });
3467
+ const guard = guardIncremental(manifest, config, strykerArgs, part, affected);
3468
+ if (guard.said.length > 0) {
3469
+ guard.said.forEach((line) => io2.out(line));
3470
+ } else if (guard.force !== null) {
3471
+ io2.out(
3472
+ `test-gates mutation: running every mutant again (--force), the results of the last run are not reused: ${guard.force}`
3473
+ );
3474
+ }
3475
+ const guardArgs = guard.force === null && guard.mutate === null ? [] : ["--force"];
3476
+ const partArgs = part ? ["--mutate", chosen.selected.join(",")] : guard.mutate !== null ? ["--mutate", guard.mutate.join(",")] : [];
3477
+ const result = spawnSync2(stryker, ["run", config, ...strykerArgs, ...partArgs, ...guardArgs], {
3478
+ cwd: manifest.dir,
3479
+ stdio: "inherit",
3480
+ shell: process.platform === "win32"
3481
+ });
3482
+ if (result.error) {
3483
+ io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
3484
+ return 1;
3485
+ }
3486
+ if (result.status !== 0) {
3487
+ io2.err(
3488
+ `test-gates mutation: Stryker failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`}). The result was not judged`
3489
+ );
3490
+ return 1;
3491
+ }
3492
+ let judged = null;
3493
+ const status = runMutationResult(dir, null, io2, part ? chosen.selected : [], (seen) => {
3494
+ judged = seen;
3495
+ });
3496
+ guard.record(judged);
3497
+ if (part) {
3498
+ fs8.rmSync(reportFile, { force: true });
3499
+ }
3500
+ return status;
3501
+ }
3502
+
3503
+ // src/commands/doctor.ts
3504
+ function read(file) {
3505
+ try {
3506
+ return fs9.readFileSync(file, "utf8");
3507
+ } catch {
3508
+ return null;
3509
+ }
3510
+ }
3511
+ function git2(dir, args) {
3512
+ const result = spawnSync3("git", args, {
3513
+ cwd: dir,
3514
+ env: gitEnvironment(process.env),
3515
+ encoding: "utf8"
3516
+ });
3517
+ return result.status === 0 ? result.stdout.trim() : null;
3518
+ }
3519
+ function hooksOf(dir) {
3520
+ const root = git2(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
3521
+ const hooksDir = git2(dir, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]);
3522
+ const candidates = [
3523
+ ...["pre-push", "pre-commit"].map((name) => path9.join(root, ".husky", name)),
3524
+ ...hooksDir === null ? [] : ["pre-push", "pre-commit"].map((name) => path9.join(hooksDir, name)),
3525
+ ...["lefthook.yml", ".lefthook.yml"].map((name) => path9.join(root, name))
3526
+ ];
3527
+ let script = "";
3528
+ try {
3529
+ const manifest = JSON.parse(read(path9.join(dir, "package.json")) ?? "{}");
3530
+ script = manifest.scripts?.["test:gates:mutation"] ?? "";
3531
+ } catch {
3532
+ }
3533
+ const subproject = path9.relative(fs9.realpathSync(root), dir).split(path9.sep).join("/");
3534
+ const seen = /* @__PURE__ */ new Set();
3535
+ const uses = [];
3536
+ for (const file of candidates) {
3537
+ const text = read(file);
3538
+ if (text === null) {
3539
+ continue;
3540
+ }
3541
+ const real = fs9.realpathSync(file);
3542
+ if (seen.has(real)) {
3543
+ continue;
3544
+ }
3545
+ seen.add(real);
3546
+ const shown = path9.relative(root, file).split(path9.sep).join("/");
3547
+ uses.push(...hookUses({ file: shown, text, subproject, script }));
3548
+ }
3549
+ return uses;
3550
+ }
3551
+ function runDoctor(dir, io2) {
3552
+ const manifest = readManifest(dir);
3553
+ const config = findStrykerConfig(manifest);
3554
+ if (config === null) {
3555
+ throw new ManifestError(
3556
+ "no Stryker config found (stryker.gates.config.mjs). Create one, or set settings.stryker.configFile"
3557
+ );
3558
+ }
3559
+ const plain2 = decideReuse(manifest, config, [], false, false);
3560
+ const affected = decideReuse(manifest, config, [], false, true);
3561
+ io2.out(`test-gates doctor: ${manifest.dir} (${config})`);
3562
+ if (plain2.snapshot !== null) {
3563
+ const subproject = path9.relative(plain2.root, fs9.realpathSync(manifest.dir));
3564
+ const prefix = subproject === "" ? "" : `${subproject.split(path9.sep).join("/")}/`;
3565
+ const keys = Object.keys(plain2.snapshot.files);
3566
+ const outside = keys.filter((key) => !key.startsWith(prefix)).length;
3567
+ 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";
3568
+ io2.out(` compared: ${keys.length} files, ${outside} outside the subproject${followed}`);
3569
+ }
3570
+ if (plain2.incremental) {
3571
+ const reuse = plain2.forced ? "the Stryker config sets force" : plain2.force ?? (plain2.incrementalHash === null ? "there is no incremental file of an earlier run" : null);
3572
+ io2.out(
3573
+ reuse === null ? " next run: reuses the results of the last run" : ` next run: every mutant, ${reuse}`
3574
+ );
3575
+ if (affected.ci !== null) {
3576
+ io2.out(` next run with --affected: every mutant, ${affected.ci}`);
3577
+ } else if (affected.plan !== null && "rerun" in affected.plan) {
3578
+ const [first = "", ...rest] = describePlan(affected.plan);
3579
+ io2.out(
3580
+ ` next run with --affected: ${first.replace(/^test-gates mutation --affected: /, "")}`
3581
+ );
3582
+ rest.forEach((line) => io2.out(` ${line}`));
3583
+ } else if (affected.unchanged !== null) {
3584
+ io2.out(" next run with --affected: reuses the results of the last run (nothing changed)");
3585
+ } else {
3586
+ const why = affected.forced ? "the Stryker config sets force" : affected.force;
3587
+ io2.out(` next run with --affected: every mutant, ${why ?? "there is nothing to reuse"}`);
3588
+ }
3589
+ }
3590
+ let blind = null;
3591
+ if (plain2.snapshot !== null) {
3592
+ try {
3593
+ affectedInputs(manifest, {
3594
+ root: plain2.root,
3595
+ files: plain2.snapshot.files,
3596
+ configs: plain2.configs(),
3597
+ tests: [],
3598
+ covering: {}
3599
+ });
3600
+ } catch (error) {
3601
+ if (!(error instanceof AffectedError)) {
3602
+ throw error;
3603
+ }
3604
+ blind = error.message;
3605
+ }
3606
+ }
3607
+ const { lines, status } = formatFindings(
3608
+ diagnose({
3609
+ incremental: plain2.incremental,
3610
+ forced: plain2.forced,
3611
+ failure: plain2.failure,
3612
+ widened: plain2.widened,
3613
+ scope: plain2.scope,
3614
+ hooks: hooksOf(fs9.realpathSync(manifest.dir)),
3615
+ blind
3616
+ })
3617
+ );
3618
+ lines.forEach((line) => io2.out(line));
3619
+ return status;
3620
+ }
3621
+
3622
+ // src/commands/lcov.ts
3623
+ import fs10 from "fs";
3624
+ import path10 from "path";
3625
+
3626
+ // src/core/lcov.ts
3627
+ function normalizePath2(file, rootDir) {
3628
+ let normalized = file.replace(/\\/g, "/");
3629
+ if (rootDir !== void 0) {
3630
+ const root = rootDir.replace(/\\/g, "/").replace(/\/+$/, "");
3631
+ if (normalized.startsWith(`${root}/`)) {
3632
+ normalized = normalized.slice(root.length + 1);
3633
+ }
3634
+ }
3635
+ return normalized.replace(/^\.\//, "");
3636
+ }
3637
+ function parseLcov(text, rootDir) {
3638
+ const files = /* @__PURE__ */ new Map();
3639
+ let record = null;
3640
+ const closeRecord = () => {
3641
+ if (record && !record.hasDa) {
3642
+ record.file.found += record.found;
3643
+ record.file.hit += record.hit;
3644
+ }
3645
+ record = null;
3646
+ };
3647
+ for (const rawLine of text.split("\n")) {
3648
+ const line = rawLine.trim();
3649
+ if (line.startsWith("SF:")) {
3650
+ closeRecord();
3651
+ const path13 = normalizePath2(line.slice(3), rootDir);
3652
+ const file = files.get(path13) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
3653
+ files.set(path13, file);
3654
+ record = { file, hasDa: false, found: 0, hit: 0 };
3655
+ } else if (line === "end_of_record") {
3656
+ closeRecord();
3657
+ } else if (record) {
3658
+ const open = record;
3659
+ if (line.startsWith("DA:")) {
3660
+ const [lineNumber, hits] = line.slice(3).split(",");
3661
+ const number = Number(lineNumber);
3662
+ const count = Number(hits);
3663
+ if (Number.isInteger(number) && Number.isFinite(count)) {
3664
+ open.hasDa = true;
3665
+ open.file.lines.set(number, Math.max(open.file.lines.get(number) ?? 0, count));
3666
+ }
3667
+ } else if (line.startsWith("LF:")) {
3668
+ open.found = Number(line.slice(3)) || 0;
3669
+ } else if (line.startsWith("LH:")) {
3670
+ open.hit = Number(line.slice(3)) || 0;
3671
+ }
3672
+ }
3673
+ }
3674
+ closeRecord();
3675
+ const result = /* @__PURE__ */ new Map();
3676
+ for (const [file, accumulator] of files) {
3677
+ const uncovered = [...accumulator.lines.entries()].filter(([, hits]) => hits <= 0).map(([lineNumber]) => lineNumber).sort((a, b) => a - b);
3678
+ result.set(file, {
3679
+ found: accumulator.lines.size + accumulator.found,
3680
+ hit: accumulator.lines.size - uncovered.length + accumulator.hit,
3681
+ uncovered
3682
+ });
3683
+ }
3684
+ return result;
2420
3685
  }
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);
3686
+ function judgeLcov(gatePaths, coverage) {
3687
+ const violations = [];
3688
+ const results = [];
3689
+ for (const gatePath of gatePaths) {
3690
+ const file = coverage.get(normalizePath2(gatePath));
3691
+ if (!file) {
3692
+ violations.push({ file: gatePath, message: "no record in the lcov file" });
3693
+ results.push({ path: gatePath, found: 0, hit: 0, ok: false });
3694
+ continue;
2427
3695
  }
2428
- if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
2429
- throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
3696
+ if (file.found === 0) {
3697
+ violations.push({ file: gatePath, message: "no instrumented lines in the lcov record" });
3698
+ results.push({ path: gatePath, found: 0, hit: 0, ok: false });
3699
+ continue;
2430
3700
  }
3701
+ const ok = file.hit === file.found;
3702
+ if (!ok) {
3703
+ const shown = file.uncovered.slice(0, 20).join(", ");
3704
+ const more = file.uncovered.length > 20 ? ", \u2026" : "";
3705
+ violations.push({
3706
+ file: gatePath,
3707
+ ...file.uncovered[0] === void 0 ? {} : { line: file.uncovered[0] },
3708
+ message: `${file.hit}/${file.found} lines covered` + (shown === "" ? "" : ` (uncovered: ${shown}${more})`)
3709
+ });
3710
+ }
3711
+ results.push({ path: gatePath, found: file.found, hit: file.hit, ok });
2431
3712
  }
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
- );
3713
+ return { violations, results };
3714
+ }
3715
+ function summarizeLcov(coverage, exclude) {
3716
+ let found = 0;
3717
+ let hit = 0;
3718
+ for (const [file, lines] of coverage) {
3719
+ if (exclude.some((regex) => regex.test(file))) {
3720
+ continue;
3721
+ }
3722
+ found += lines.found;
3723
+ hit += lines.hit;
2447
3724
  }
2448
- const stryker = findLocalBin(manifest.dir, "stryker");
2449
- if (stryker === null) {
3725
+ return { found, hit };
3726
+ }
3727
+
3728
+ // src/commands/lcov.ts
3729
+ function runLcov(dir, fileOption, io2) {
3730
+ const manifest = readManifest(dir);
3731
+ const file = fileOption ?? manifest.settings.lcovFile;
3732
+ if (file === null) {
2450
3733
  throw new ManifestError(
2451
- "stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
3734
+ "no lcov file given. Pass --file <lcov.info> or set settings.lcov.file"
2452
3735
  );
2453
3736
  }
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}`
3737
+ const lcovPath = path10.resolve(manifest.dir, file);
3738
+ let text;
3739
+ try {
3740
+ text = fs10.readFileSync(lcovPath, "utf8");
3741
+ } catch (error) {
3742
+ io2.err(
3743
+ `test-gates lcov: cannot read ${file} (${error.message}). Run the tests with coverage first`
2460
3744
  );
3745
+ return 1;
2461
3746
  }
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"
3747
+ const violations = [...manifest.violations];
3748
+ const listing = /* @__PURE__ */ new Map();
3749
+ const existing = manifest.gates.filter((gate) => {
3750
+ const exists = existsExact(manifest.dir, gate.path, "file", listing);
3751
+ if (!exists) {
3752
+ violations.push({
3753
+ file: gate.path,
3754
+ message: "file not found (the check is case-sensitive). Update test-gates.json if it moved"
3755
+ });
3756
+ }
3757
+ return exists;
2468
3758
  });
2469
- if (result.error) {
2470
- io2.err(`test-gates mutation: cannot start Stryker (${result.error.message})`);
2471
- return 1;
3759
+ const coverage = parseLcov(text, manifest.dir);
3760
+ const total = summarizeLcov(coverage, manifest.settings.lcovSummaryExclude);
3761
+ io2.out(
3762
+ 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)`
3763
+ );
3764
+ const verdict = judgeLcov(
3765
+ existing.map((gate) => gate.path),
3766
+ coverage
3767
+ );
3768
+ for (const result of verdict.results) {
3769
+ if (result.ok) {
3770
+ io2.out(` \u2713 ${result.path}: ${result.hit}/${result.found} lines`);
3771
+ }
2472
3772
  }
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
- );
3773
+ violations.push(...verdict.violations);
3774
+ if (violations.length > 0) {
3775
+ printViolations(io2, "lcov", violations);
2477
3776
  return 1;
2478
3777
  }
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;
3778
+ io2.out(`test-gates lcov: OK (${verdict.results.length} gate(s) at 100% line coverage)`);
3779
+ return 0;
2485
3780
  }
2486
3781
 
2487
3782
  // src/commands/selfcheck.ts
2488
- import { spawn, spawnSync as spawnSync3 } from "child_process";
2489
- import fs10 from "fs";
3783
+ import { spawn, spawnSync as spawnSync4 } from "child_process";
3784
+ import fs12 from "fs";
2490
3785
  import os2 from "os";
2491
- import path10 from "path";
3786
+ import path12 from "path";
2492
3787
 
2493
3788
  // src/core/jobs.ts
2494
3789
  var SWITCHES = {
@@ -2557,7 +3852,8 @@ var REACHES = [
2557
3852
  "eval(",
2558
3853
  "Function("
2559
3854
  ];
2560
- var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])/;
3855
+ var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])|\bimport\.meta\.glob\b/;
3856
+ var COMPUTED = "loads a module whose name is computed";
2561
3857
  var RUNNER_PACKAGES = ["vitest", "@jest/globals"];
2562
3858
  function reachesOutside(file, trusted) {
2563
3859
  const dense = file.source.replace(/\s/g, "");
@@ -2566,7 +3862,7 @@ function reachesOutside(file, trusted) {
2566
3862
  return `uses ${mark.replace(/[.?[(:]+$/, "")}`;
2567
3863
  }
2568
3864
  if (COMPUTED_LOAD.test(dense)) {
2569
- return "loads a module whose name is computed";
3865
+ return COMPUTED;
2570
3866
  }
2571
3867
  for (const { specifier, builtin } of file.imports) {
2572
3868
  if (builtin) {
@@ -2590,17 +3886,47 @@ function jobsRefused(input) {
2590
3886
  if (input.configSource === null || !input.configSource.includes(helper)) {
2591
3887
  return `the gate config does not use ${helper}`;
2592
3888
  }
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
3889
  return null;
2603
3890
  }
3891
+ function jobsPlan(input) {
3892
+ const byPath = new Map(input.files.map((file) => [file.path, file]));
3893
+ const causes = input.roots.flatMap((root) => {
3894
+ const loaded = root.files.flatMap((name) => {
3895
+ const file = byPath.get(name);
3896
+ return file === void 0 ? [] : [file];
3897
+ });
3898
+ let computed = false;
3899
+ const reaches = loaded.flatMap((file) => {
3900
+ const reach = reachesOutside(file, input.trusted);
3901
+ const said = reach === null ? [] : [`${file.path} ${reach}`];
3902
+ if (!COMPUTED_LOAD.test(file.source.replace(/\s/g, ""))) {
3903
+ return said;
3904
+ }
3905
+ computed = true;
3906
+ return reach === COMPUTED ? said : [...said, `${file.path} ${COMPUTED}`];
3907
+ });
3908
+ const reasons = [
3909
+ ...root.lost === null ? [] : [`${root.lost} cannot be followed`],
3910
+ ...reaches
3911
+ ];
3912
+ if (reasons.length === 0) {
3913
+ return [];
3914
+ }
3915
+ const any = root.spec === null || root.lost !== null || computed;
3916
+ const gates = input.gates.filter(
3917
+ (gate) => gate.spec !== root.spec && (any || root.files.includes(withoutDots(gate.path)))
3918
+ ).map((gate) => gate.path);
3919
+ const by = root.spec === null ? "run with what the gate config names" : `${any ? "maybe loaded" : "loaded"} by ${root.spec}`;
3920
+ return gates.length === 0 ? [] : [{ gates, reason: `${by}, in which ${reasons.join("; ")}` }];
3921
+ });
3922
+ const kept = new Set(causes.flatMap((cause) => cause.gates));
3923
+ const paths = input.gates.map((gate) => gate.path);
3924
+ return {
3925
+ atOnce: paths.filter((gate) => !kept.has(gate)),
3926
+ alone: paths.filter((gate) => kept.has(gate)),
3927
+ causes
3928
+ };
3929
+ }
2604
3930
  var TROUBLE = [
2605
3931
  "fail",
2606
3932
  "fails",
@@ -2674,8 +4000,8 @@ function cleanHold(input) {
2674
4000
 
2675
4001
  // src/touch.ts
2676
4002
  import { createHash as createHash2 } from "crypto";
2677
- import fs8 from "fs";
2678
- import path8 from "path";
4003
+ import fs11 from "fs";
4004
+ import path11 from "path";
2679
4005
  import { fileURLToPath } from "url";
2680
4006
  var TOUCH_ENV = "TEST_GATES_TOUCH_DIR";
2681
4007
  function isObject(value) {
@@ -2707,18 +4033,18 @@ function isCounters(value) {
2707
4033
  );
2708
4034
  }
2709
4035
  function readNotes(directory) {
2710
- const read = (file) => {
4036
+ const read2 = (file) => {
2711
4037
  try {
2712
- return JSON.parse(fs8.readFileSync(path8.join(directory, file), "utf8"));
4038
+ return JSON.parse(fs11.readFileSync(path11.join(directory, file), "utf8"));
2713
4039
  } catch {
2714
4040
  return null;
2715
4041
  }
2716
4042
  };
2717
- const run = read("run.json");
4043
+ const run = read2("run.json");
2718
4044
  let notes = [];
2719
4045
  try {
2720
- for (const name of fs8.readdirSync(path8.join(directory, "specs"))) {
2721
- const note = read(path8.join("specs", name));
4046
+ for (const name of fs11.readdirSync(path11.join(directory, "specs"))) {
4047
+ const note = read2(path11.join("specs", name));
2722
4048
  if (!isObject(note) || typeof note.spec !== "string" || !isObject(note.files) || !Object.values(note.files).every(isCounters)) {
2723
4049
  throw new Error("not a note");
2724
4050
  }
@@ -2730,135 +4056,8 @@ function readNotes(directory) {
2730
4056
  return {
2731
4057
  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
4058
  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
- }
4059
+ totals: countersOfAll(read2(path11.join("coverage", "coverage-final.json")))
2831
4060
  };
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
4061
  }
2863
4062
 
2864
4063
  // src/commands/selfcheck.ts
@@ -2872,7 +4071,7 @@ function resolveGateCommand(manifest) {
2872
4071
  if (manifest.settings.gateCommand !== null) {
2873
4072
  return manifest.settings.gateCommand;
2874
4073
  }
2875
- const exists = (name) => fs10.existsSync(path10.join(manifest.dir, name));
4074
+ const exists = (name) => fs12.existsSync(path12.join(manifest.dir, name));
2876
4075
  const jest = JEST_CONFIGS.find(exists);
2877
4076
  const vitest = VITEST_CONFIGS.find(exists);
2878
4077
  if (jest && vitest) {
@@ -2893,7 +4092,7 @@ function resolveGateCommand(manifest) {
2893
4092
  var TOUCH_PREFIX = ".test-gates-touch-";
2894
4093
  function realPath(file) {
2895
4094
  try {
2896
- return fs10.realpathSync(file);
4095
+ return fs12.realpathSync(file);
2897
4096
  } catch {
2898
4097
  return file;
2899
4098
  }
@@ -2901,13 +4100,13 @@ function realPath(file) {
2901
4100
  function shareOneRun(manifest, targets, runGate) {
2902
4101
  let touchDir;
2903
4102
  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 });
4103
+ for (const name of fs12.readdirSync(manifest.dir)) {
4104
+ const left = path12.join(manifest.dir, name);
4105
+ if (name.startsWith(TOUCH_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
4106
+ fs12.rmSync(left, { recursive: true, force: true });
2908
4107
  }
2909
4108
  }
2910
- touchDir = fs10.mkdtempSync(path10.join(manifest.dir, TOUCH_PREFIX));
4109
+ touchDir = fs12.mkdtempSync(path12.join(manifest.dir, TOUCH_PREFIX));
2911
4110
  } catch {
2912
4111
  return { reason: "the subproject cannot be written to" };
2913
4112
  }
@@ -2918,14 +4117,14 @@ function shareOneRun(manifest, targets, runGate) {
2918
4117
  return { reason: "the gate run with every spec does not pass" };
2919
4118
  }
2920
4119
  const base = realPath(manifest.dir);
2921
- const absolute = (file) => realPath(path10.resolve(base, file));
4120
+ const absolute = (file) => realPath(path12.resolve(base, file));
2922
4121
  const rekeyed = (files) => Object.fromEntries(Object.entries(files).map(([file, value]) => [absolute(file), value]));
2923
4122
  const left = readNotes(touchDir);
2924
4123
  plan = planControls({
2925
4124
  all: manifest.gates.map((gate) => ({
2926
4125
  path: gate.path,
2927
- file: path10.join(base, gate.path),
2928
- specFile: path10.join(base, gate.spec ?? "")
4126
+ file: path12.join(base, gate.path),
4127
+ specFile: path12.join(base, gate.spec ?? "")
2929
4128
  })),
2930
4129
  targets: targets.map((gate) => gate.path),
2931
4130
  run: left.run,
@@ -2934,7 +4133,7 @@ function shareOneRun(manifest, targets, runGate) {
2934
4133
  failurePattern: manifest.settings.selfcheckFailurePattern
2935
4134
  });
2936
4135
  } finally {
2937
- fs10.rmSync(touchDir, { recursive: true, force: true });
4136
+ fs12.rmSync(touchDir, { recursive: true, force: true });
2938
4137
  }
2939
4138
  if (plan.reason !== null) {
2940
4139
  return { reason: plan.reason };
@@ -2944,15 +4143,15 @@ function shareOneRun(manifest, targets, runGate) {
2944
4143
  return { gates, result: runGate({ excludeAll: gates.map((gate) => gate.spec) }) };
2945
4144
  }
2946
4145
  function installedVersion2(start, name) {
2947
- let current = path10.resolve(start);
4146
+ let current = path12.resolve(start);
2948
4147
  for (; ; ) {
2949
4148
  try {
2950
4149
  const manifest = JSON.parse(
2951
- fs10.readFileSync(path10.join(current, "node_modules", name, "package.json"), "utf8")
4150
+ fs12.readFileSync(path12.join(current, "node_modules", name, "package.json"), "utf8")
2952
4151
  );
2953
4152
  return typeof manifest.version === "string" ? manifest.version : null;
2954
4153
  } catch {
2955
- const parent = path10.dirname(current);
4154
+ const parent = path12.dirname(current);
2956
4155
  if (parent === current) {
2957
4156
  return null;
2958
4157
  }
@@ -2981,13 +4180,13 @@ async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
2981
4180
  const results = /* @__PURE__ */ new Map();
2982
4181
  let root;
2983
4182
  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 });
4183
+ for (const name of fs12.readdirSync(os2.tmpdir())) {
4184
+ const left = path12.join(os2.tmpdir(), name);
4185
+ if (name.startsWith(JOBS_PREFIX) && Date.now() - fs12.statSync(left).mtimeMs > 36e5) {
4186
+ fs12.rmSync(left, { recursive: true, force: true });
2988
4187
  }
2989
4188
  }
2990
- root = fs10.realpathSync(fs10.mkdtempSync(path10.join(os2.tmpdir(), JOBS_PREFIX)));
4189
+ root = fs12.realpathSync(fs12.mkdtempSync(path12.join(os2.tmpdir(), JOBS_PREFIX)));
2991
4190
  } catch {
2992
4191
  return results;
2993
4192
  }
@@ -3019,11 +4218,11 @@ async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
3019
4218
  await Promise.all(
3020
4219
  Array.from(
3021
4220
  { length: Math.min(jobs, gates.length) },
3022
- (_unused, index) => line(path10.join(root, `line-${index}`))
4221
+ (_unused, index) => line(path12.join(root, `line-${index}`))
3023
4222
  )
3024
4223
  );
3025
4224
  } finally {
3026
- fs10.rmSync(root, { recursive: true, force: true });
4225
+ fs12.rmSync(root, { recursive: true, force: true });
3027
4226
  }
3028
4227
  return results;
3029
4228
  }
@@ -3052,7 +4251,7 @@ async function runSelfcheck(dir, selection, commandOverride, io2) {
3052
4251
  const command = commandOverride.length > 0 ? commandOverride : resolveGateCommand(manifest);
3053
4252
  const [program, ...args] = command;
3054
4253
  const executable = findLocalBin(manifest.dir, program) ?? program;
3055
- const runGate = (env) => spawnSync3(executable, args, {
4254
+ const runGate = (env) => spawnSync4(executable, args, {
3056
4255
  cwd: manifest.dir,
3057
4256
  env: {
3058
4257
  ...process.env,
@@ -3098,48 +4297,60 @@ async function runSelfcheck(dir, selection, commandOverride, io2) {
3098
4297
  const jobs = selection.jobs ?? 1;
3099
4298
  if (jobs > 1 && waiting.length > 1) {
3100
4299
  const known = knownCommand(program, args);
3101
- const configFile = gateConfigFile(args);
3102
- const ask = (loaded) => jobsRefused({
4300
+ const refusal = jobsRefused({
3103
4301
  failurePattern: manifest.settings.selfcheckFailurePattern,
3104
4302
  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
- ) : []
4303
+ configSource: gateConfigSource(manifest, args)
3110
4304
  });
3111
- const refusal = ask({ files: [], lost: null }) ?? ask(loadedFiles(manifest, configFile));
3112
4305
  if (refusal !== null || !("runner" in known)) {
3113
4306
  io2.out(`test-gates selfcheck: one negative control at a time (${refusal})`);
3114
4307
  } 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) {
4308
+ const plan = jobsPlan({
4309
+ gates: waiting.map((gate) => ({ path: gate.path, spec: gate.spec })),
4310
+ ...loadedFiles(manifest, gateConfigFile(args)),
4311
+ trusted: manifest.settings.importMode === "allowlist" ? manifest.settings.importAllow.flatMap(
4312
+ (rule) => rule.module === void 0 ? [] : [rule.module]
4313
+ ) : []
4314
+ });
4315
+ if (plan.alone.length > 0) {
3140
4316
  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`
4317
+ `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
4318
  );
4319
+ for (const { gates, reason } of plan.causes) {
4320
+ io2.out(` ${gates.join(", ")}: ${reason}`);
4321
+ }
4322
+ }
4323
+ const atOnce = waiting.filter((gate) => plan.atOnce.includes(gate.path));
4324
+ if (atOnce.length > 1) {
4325
+ const specs = new Set(manifest.gates.map((gate) => gate.spec)).size - 1;
4326
+ const names = [
4327
+ manifest.dir,
4328
+ realPath(manifest.dir),
4329
+ ...manifest.gates.flatMap((gate) => [gate.path, gate.spec])
4330
+ ].sort((left, right) => right.length - left.length);
4331
+ const results = await runAtOnce(manifest, atOnce, jobs, known.runner, executable, args);
4332
+ for (const [gate, result] of results) {
4333
+ const output = `${result.stdout}${result.stderr}`;
4334
+ const clean = cleanHold({
4335
+ held: judgeSelfcheck({ status: result.status, output, gatePath: gate.path }) === null,
4336
+ signal: result.signal,
4337
+ output,
4338
+ runner: known.runner,
4339
+ specs,
4340
+ gatePath: gate.path,
4341
+ others: manifest.gates.map((other) => other.path).filter((other) => other !== gate.path),
4342
+ names
4343
+ });
4344
+ if (clean) {
4345
+ heldAtOnce.set(gate.path, result.status);
4346
+ }
4347
+ }
4348
+ const again = atOnce.length - heldAtOnce.size;
4349
+ if (again > 0) {
4350
+ io2.out(
4351
+ `test-gates selfcheck: ${again} of ${atOnce.length} negative control(s) did not hold cleanly next to others and run again on their own`
4352
+ );
4353
+ }
3143
4354
  }
3144
4355
  }
3145
4356
  }
@@ -3210,6 +4421,8 @@ Commands:
3210
4421
  mutation-result Judge an existing Stryker JSON report
3211
4422
  selfcheck [-- <command>] Negative control: leave one spec out, the gate must fail
3212
4423
  lcov [--file <lcov.info>] Per-file 100% line coverage from an lcov tracefile
4424
+ doctor Why a mutation run would not reuse the last results, and what
4425
+ to change. Runs nothing; exit 1 when runs are full ones needlessly
3213
4426
 
3214
4427
  Options:
3215
4428
  --dir <subproject> Directory that holds test-gates.json (default: .)
@@ -3218,6 +4431,9 @@ Options:
3218
4431
  --gate <path> selfcheck: only this gate (may be given more than once).
3219
4432
  mutation: only the mutants of this gate, while working on it.
3220
4433
  Not the final check, and refused when CI is set
4434
+ --affected mutation: run again only the gates that a change since the last
4435
+ run can affect, and reuse the results of the others. Every
4436
+ mutant is run when CI is set
3221
4437
  --shard <index>/<total> selfcheck: one of <total> parts of the gates, for parallel CI jobs
3222
4438
  --jobs <n> selfcheck: run <n> negative controls at the same time. One that does
3223
4439
  not hold cleanly there is run again on its own
@@ -3250,8 +4466,10 @@ function main(argv) {
3250
4466
  switch (args.command) {
3251
4467
  case "check":
3252
4468
  return runCheck(args.dir, io);
4469
+ case "doctor":
4470
+ return runDoctor(args.dir, io);
3253
4471
  case "mutation":
3254
- return runMutation(args.dir, args.rest, io, args.gates);
4472
+ return runMutation(args.dir, args.rest, io, args.gates, args.affected);
3255
4473
  case "mutation-result":
3256
4474
  return runMutationResult(args.dir, args.report, io);
3257
4475
  case "selfcheck":