@t09tanaka/test-gates 0.3.2 → 0.3.4

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
@@ -1,18 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createRequire } from "module";
4
+ import { createRequire as createRequire2 } from "module";
5
5
 
6
6
  // src/core/args.ts
7
7
  var COMMANDS = ["check", "mutation", "mutation-result", "selfcheck", "lcov"];
8
8
  var UsageError = class extends Error {
9
9
  };
10
- var VALUE_OPTIONS = ["--dir", "--report", "--file"];
10
+ var VALUE_OPTIONS = ["--dir", "--report", "--file", "--gate", "--shard", "--jobs"];
11
+ function parseShard(value) {
12
+ const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value);
13
+ const index = Number(match?.[1]);
14
+ const total = Number(match?.[2]);
15
+ if (!Number.isSafeInteger(total) || index > total) {
16
+ throw new UsageError(
17
+ `--shard needs <index>/<total> with 1 <= index <= total (for example 2/4), got ${value}`
18
+ );
19
+ }
20
+ return { index, total };
21
+ }
11
22
  var OPTIONS_BY_COMMAND = {
12
23
  check: ["--dir"],
13
- mutation: ["--dir"],
24
+ mutation: ["--dir", "--gate"],
14
25
  "mutation-result": ["--dir", "--report"],
15
- selfcheck: ["--dir", "--all", "--first"],
26
+ selfcheck: ["--dir", "--all", "--first", "--gate", "--shard", "--quick", "--jobs"],
16
27
  lcov: ["--dir", "--file"]
17
28
  };
18
29
  function consume(parsed, argv, index, allowed) {
@@ -41,13 +52,31 @@ function consume(parsed, argv, index, allowed) {
41
52
  if (value === void 0 || value === "") {
42
53
  throw new UsageError(`${name} needs a value`);
43
54
  }
44
- const key = name.slice(2);
45
- parsed[key] = value;
55
+ if (name === "--gate") {
56
+ parsed.gates.push(value);
57
+ } else if (name === "--jobs") {
58
+ if (!/^[1-9]\d{0,2}$/.test(value)) {
59
+ throw new UsageError(`--jobs needs a number from 1 to 999, got ${value}`);
60
+ }
61
+ parsed.jobs = Number(value);
62
+ } else if (name === "--shard") {
63
+ if (parsed.shard !== null) {
64
+ throw new UsageError("--shard can be given only once");
65
+ }
66
+ parsed.shard = parseShard(value);
67
+ } else {
68
+ const key = name.slice(2);
69
+ parsed[key] = value;
70
+ }
46
71
  return equals !== -1 ? next : next + 1;
47
72
  }
48
73
  if (equals !== -1) {
49
74
  throw new UsageError(`${name} does not take a value`);
50
75
  }
76
+ if (name === "--quick") {
77
+ parsed.quick = true;
78
+ return next;
79
+ }
51
80
  const mode = name === "--all" ? "all" : "first";
52
81
  if (parsed.mode !== null && parsed.mode !== mode) {
53
82
  throw new UsageError("--all and --first cannot be combined");
@@ -72,6 +101,10 @@ function parseArgs(argv) {
72
101
  report: null,
73
102
  file: null,
74
103
  mode: null,
104
+ gates: [],
105
+ shard: null,
106
+ quick: false,
107
+ jobs: null,
75
108
  rest: []
76
109
  };
77
110
  let index = 0;
@@ -87,6 +120,23 @@ function parseArgs(argv) {
87
120
  while (index < argv.length) {
88
121
  index = consume(parsed, argv, index, allowed);
89
122
  }
123
+ const selections = [
124
+ parsed.mode === null ? null : `--${parsed.mode}`,
125
+ parsed.gates.length > 0 ? "--gate" : null,
126
+ parsed.shard === null ? null : "--shard"
127
+ ].filter((option) => option !== null);
128
+ if (selections.length > 1) {
129
+ throw new UsageError(`${selections.join(" and ")} cannot be combined`);
130
+ }
131
+ const misplaced = parsed.rest.find((arg) => /^--(gate|shard|quick|jobs)(=|$)/.test(arg));
132
+ if (parsed.command === "selfcheck" && misplaced !== void 0) {
133
+ throw new UsageError(
134
+ `${misplaced.split("=")[0]} after "--" would go to the gate command. Write it before "--"`
135
+ );
136
+ }
137
+ if (parsed.rest.some((arg) => arg === "--gate" || arg.startsWith("--gate="))) {
138
+ throw new UsageError('--gate after "--" would go to Stryker. Write it before "--"');
139
+ }
90
140
  if (parsed.command === "selfcheck" && parsed.rest.length === 0 && argv.includes("--")) {
91
141
  throw new UsageError('"--" must be followed by the gate command');
92
142
  }
@@ -559,19 +609,22 @@ function vitestCoverageViolation(input) {
559
609
  // src/fs.ts
560
610
  import fs from "fs";
561
611
  import path from "path";
562
- function existsExact(rootDir, relativePath, kind = "file") {
612
+ function existsExact(rootDir, relativePath, kind = "file", listing = /* @__PURE__ */ new Map()) {
563
613
  let current = rootDir;
564
614
  for (const segment of relativePath.split("/")) {
565
615
  if (segment === "" || segment === ".") {
566
616
  continue;
567
617
  }
568
- let entries;
569
- try {
570
- entries = fs.readdirSync(current);
571
- } catch {
572
- return false;
618
+ let entries = listing.get(current);
619
+ if (entries === void 0) {
620
+ try {
621
+ entries = fs.readdirSync(current);
622
+ } catch {
623
+ entries = null;
624
+ }
625
+ listing.set(current, entries);
573
626
  }
574
- if (!entries.includes(segment)) {
627
+ if (entries === null || !entries.includes(segment)) {
575
628
  return false;
576
629
  }
577
630
  current = path.join(current, segment);
@@ -927,6 +980,7 @@ function resolveSettings(raw) {
927
980
 
928
981
  // src/core/selfcheck.ts
929
982
  var EXCLUDE_ENV = "TEST_GATES_EXCLUDE_SPEC";
983
+ var EXCLUDE_ALL_ENV = "TEST_GATES_EXCLUDE_SPECS";
930
984
  var THRESHOLD_MESSAGE = /threshold|coverage data for/i;
931
985
  function escapeRegExp2(text) {
932
986
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -946,6 +1000,194 @@ function judgeSelfcheck(input) {
946
1000
  const reported = output.split("\n").some((line) => line.includes(gatePath) && THRESHOLD_MESSAGE.test(line));
947
1001
  return reported ? null : `the gate failed (exit ${status}) but not on the coverage threshold of ${gatePath}. It may be failing for another reason`;
948
1002
  }
1003
+ function selectControls(input) {
1004
+ const { gates, mode, only, shard } = input;
1005
+ if (only.length > 0) {
1006
+ const unknown = only.filter((path11) => !gates.includes(path11));
1007
+ if (unknown.length > 0) {
1008
+ return {
1009
+ error: `--gate ${unknown.join(", ")}: not a gate of test-gates.json (write the path as it is written there)`
1010
+ };
1011
+ }
1012
+ return { selected: gates.filter((path11) => only.includes(path11)) };
1013
+ }
1014
+ if (shard !== null) {
1015
+ if (shard.total > gates.length) {
1016
+ return {
1017
+ error: `--shard ${shard.index}/${shard.total}: there are only ${gates.length} gate(s). A part without a gate would pass without checking anything`
1018
+ };
1019
+ }
1020
+ return {
1021
+ selected: gates.filter((_path, position) => position % shard.total === shard.index - 1)
1022
+ };
1023
+ }
1024
+ return { selected: mode === "first" ? gates.slice(0, 1) : gates };
1025
+ }
1026
+ var VERIFIED_RUNS = [
1027
+ { runner: "jest", versions: ["30.5"], provider: "babel", pools: ["default"] },
1028
+ { runner: "vitest", versions: ["3.2"], provider: "istanbul", pools: ["forks", "threads"] }
1029
+ ];
1030
+ function minorOf(version) {
1031
+ const match = /^(\d+\.\d+)\./.exec(version);
1032
+ return match === null ? version : match[1];
1033
+ }
1034
+ function unverifiedVersion(runner, version, table = VERIFIED_RUNS) {
1035
+ const known = table.find((entry) => entry.runner === runner);
1036
+ if (known === void 0) {
1037
+ return `the shared run is not verified with ${runner}`;
1038
+ }
1039
+ return known.versions.includes(minorOf(version)) ? null : `the shared run is verified with ${runner} ${known.versions.join(", ")}, not with ${version}`;
1040
+ }
1041
+ var SURROUNDINGS = /\bprocess\s*\.\s*env\b|\bimport\.meta\.env\b|['"`](?:node:)?(?:fs|fs\/promises|child_process|worker_threads|net|http|https)['"`]/;
1042
+ function inCi(env) {
1043
+ const ci = (env.CI ?? "").trim().toLowerCase();
1044
+ return ci !== "" && ci !== "false" && ci !== "0";
1045
+ }
1046
+ function quickRefused(env) {
1047
+ return inCi(env) ? "--quick is refused in CI (the variable CI is set): what it reports as holding is not the final check. Run selfcheck without it, with --shard to split it over jobs" : null;
1048
+ }
1049
+ function gateRunRefused(env) {
1050
+ 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
+ }
1052
+ function gateRunCaveat(judged, all) {
1053
+ 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
+ }
1055
+ function quickCaveat(shared) {
1056
+ return shared === 0 ? "" : `. Of the controls that held, ${shared} came from one shared run (--quick), which a spec that depends on other specs can make differ from a run per gate: run without --quick for the final check`;
1057
+ }
1058
+ function sharedRunPrecheck(input) {
1059
+ const reaching = input.specs.find((spec) => SURROUNDINGS.test(spec.source));
1060
+ if (reaching !== void 0) {
1061
+ return `${reaching.path} reads the environment or files, so what it executes may depend on which other specs ran`;
1062
+ }
1063
+ const runner = ["jest", "vitest"].find((name) => name === input.program);
1064
+ if (runner === void 0) {
1065
+ return null;
1066
+ }
1067
+ if (input.version !== null) {
1068
+ const unverified = unverifiedVersion(runner, input.version);
1069
+ if (unverified !== null) {
1070
+ return unverified;
1071
+ }
1072
+ }
1073
+ if (input.configSource === null) {
1074
+ return null;
1075
+ }
1076
+ const helper = runner === "jest" ? "createJestGatesConfig" : "createVitestGatesConfig";
1077
+ if (!input.configSource.includes(helper)) {
1078
+ return `the gate config does not use ${helper}`;
1079
+ }
1080
+ if (runner === "vitest" && !input.configSource.includes("istanbul")) {
1081
+ return "Vitest measures with the v8 provider; the shared run needs coverageProvider: 'istanbul'";
1082
+ }
1083
+ return null;
1084
+ }
1085
+ function unverifiedRun(run) {
1086
+ if (run.problem !== null) {
1087
+ return run.problem;
1088
+ }
1089
+ const version = unverifiedVersion(run.runner, run.version);
1090
+ if (version !== null) {
1091
+ return version;
1092
+ }
1093
+ const known = VERIFIED_RUNS.find(
1094
+ (entry) => entry.runner === run.runner
1095
+ );
1096
+ if (known.provider !== run.provider) {
1097
+ return `the shared run is not verified with the coverage provider ${run.provider}`;
1098
+ }
1099
+ if (!known.pools.includes(run.pool)) {
1100
+ return `the shared run is not verified with the pool ${run.pool}`;
1101
+ }
1102
+ if (!run.isolate) {
1103
+ return "the shared run is not verified with spec files that share their modules (isolate: false)";
1104
+ }
1105
+ if (run.projects !== 1) {
1106
+ return `the shared run is not verified with ${run.projects} projects in one run`;
1107
+ }
1108
+ return null;
1109
+ }
1110
+ function sorted(counts) {
1111
+ return [...counts].sort((left, right) => left - right);
1112
+ }
1113
+ function same(left, right) {
1114
+ return left.every((count, index) => count === right[index]);
1115
+ }
1116
+ function contained(part, whole) {
1117
+ const left = sorted(whole);
1118
+ return sorted(part).every((count) => {
1119
+ const position = left.indexOf(count);
1120
+ if (position === -1) {
1121
+ return false;
1122
+ }
1123
+ left.splice(position, 1);
1124
+ return true;
1125
+ });
1126
+ }
1127
+ function accountedFor(total, parts) {
1128
+ const first = parts[0];
1129
+ if (total === void 0 || first === void 0) {
1130
+ return "unknown";
1131
+ }
1132
+ const lined = parts.every(
1133
+ (part) => part.s.length === first.s.length && part.f.length === first.f.length && part.b.length === first.b.length
1134
+ );
1135
+ if (!lined || total.f.length !== first.f.length || total.b.length !== first.b.length) {
1136
+ return "unknown";
1137
+ }
1138
+ const sum = (key) => first[key].map(
1139
+ (_count, index) => parts.reduce((added, part) => added + part[key][index], 0)
1140
+ );
1141
+ return same(sorted(total.f), sorted(sum("f"))) && same(sorted(total.b), sorted(sum("b"))) && contained(total.s, sum("s")) ? "yes" : "no";
1142
+ }
1143
+ function planControls(input) {
1144
+ const { all, targets, run, notes, totals } = input;
1145
+ const everyoneAlone = (reason) => ({ together: [], alone: targets, reason });
1146
+ if (input.failurePattern && !input.failurePattern.includes("{gate}")) {
1147
+ return everyoneAlone("settings.selfcheck.failurePattern does not contain {gate}");
1148
+ }
1149
+ if (run === null || notes === null || totals === null) {
1150
+ return everyoneAlone(
1151
+ "the gate run left no notes (the gate config does not use the helpers of this package, or Vitest measures with the v8 provider)"
1152
+ );
1153
+ }
1154
+ const unverified = unverifiedRun(run);
1155
+ if (unverified !== null) {
1156
+ return everyoneAlone(unverified);
1157
+ }
1158
+ const silent = all.find((gate) => !notes.some((note) => note.spec === gate.specFile));
1159
+ if (silent !== void 0) {
1160
+ return everyoneAlone(`the gate run left no note for the spec of ${silent.path}`);
1161
+ }
1162
+ const partsOf = (gate) => notes.flatMap((note) => gate.file in note.files ? [note.files[gate.file]] : []);
1163
+ const short = all.find((gate) => accountedFor(totals[gate.file], partsOf(gate)) === "no");
1164
+ if (short !== void 0) {
1165
+ return everyoneAlone(
1166
+ `the notes of the specs do not add up to what the runner counted for ${short.path}`
1167
+ );
1168
+ }
1169
+ const together = targets.filter((target) => {
1170
+ const gate = all.find((candidate) => candidate.path === target);
1171
+ if (gate === void 0) {
1172
+ return false;
1173
+ }
1174
+ if (all.some((other) => other.path !== gate.path && other.path.includes(gate.path))) {
1175
+ return false;
1176
+ }
1177
+ if (accountedFor(totals[gate.file], partsOf(gate)) !== "yes") {
1178
+ return false;
1179
+ }
1180
+ const touching = notes.filter((note) => {
1181
+ const counters = note.files[gate.file];
1182
+ return counters !== void 0 && [...counters.s, ...counters.f, ...counters.b].some((count) => count !== 0);
1183
+ }).map((note) => note.spec);
1184
+ return touching.length === 1 && touching.includes(gate.specFile);
1185
+ });
1186
+ if (together.length < 2) {
1187
+ return everyoneAlone("fewer than two gates are touched by their own spec only");
1188
+ }
1189
+ return { together, alone: targets.filter((target) => !together.includes(target)), reason: null };
1190
+ }
949
1191
 
950
1192
  // src/load.ts
951
1193
  function readManifest(dir) {
@@ -970,9 +1212,12 @@ function readManifest(dir) {
970
1212
  }
971
1213
  throw error;
972
1214
  }
1215
+ const listing = /* @__PURE__ */ new Map();
973
1216
  const gates = parsed.gates.map((gate) => {
974
1217
  const specCandidates = specCandidatesFor(gate.path, settings);
975
- const existingSpecs = specCandidates.filter((candidate) => existsExact(absoluteDir, candidate));
1218
+ const existingSpecs = specCandidates.filter(
1219
+ (candidate) => existsExact(absoluteDir, candidate, "file", listing)
1220
+ );
976
1221
  return {
977
1222
  ...gate,
978
1223
  specCandidates,
@@ -1020,6 +1265,7 @@ function isPlainRelativePath(file) {
1020
1265
  function checkGates(manifest) {
1021
1266
  const { dir, settings } = manifest;
1022
1267
  const violations = [...manifest.violations];
1268
+ const listing = /* @__PURE__ */ new Map();
1023
1269
  for (const gate of manifest.gates) {
1024
1270
  if (!isPlainRelativePath(gate.path)) {
1025
1271
  violations.push({
@@ -1047,7 +1293,7 @@ function checkGates(manifest) {
1047
1293
  });
1048
1294
  }
1049
1295
  }
1050
- if (!existsExact(dir, gate.path)) {
1296
+ if (!existsExact(dir, gate.path, "file", listing)) {
1051
1297
  violations.push({
1052
1298
  file: gate.path,
1053
1299
  message: "file not found (the check is case-sensitive)"
@@ -1076,7 +1322,7 @@ function checkGates(manifest) {
1076
1322
  violations.push(...scanSpecSource(gate.path, specPath, readText(dir, specPath), settings));
1077
1323
  }
1078
1324
  for (const candidate of manifest.candidates) {
1079
- if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any")) {
1325
+ if (!isPlainRelativePath(candidate.path) || !existsExact(dir, candidate.path, "any", listing)) {
1080
1326
  violations.push({
1081
1327
  file: MANIFEST_FILE,
1082
1328
  message: `${candidate.path}: candidate not found. Remove it, or fix the path`
@@ -1167,9 +1413,9 @@ function parseLcov(text, rootDir) {
1167
1413
  const line = rawLine.trim();
1168
1414
  if (line.startsWith("SF:")) {
1169
1415
  closeRecord();
1170
- const path7 = normalizePath(line.slice(3), rootDir);
1171
- const file = files.get(path7) ?? { lines: /* @__PURE__ */ new Map(), found: 0, hit: 0 };
1172
- files.set(path7, file);
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);
1173
1419
  record = { file, hasDa: false, found: 0, hit: 0 };
1174
1420
  } else if (line === "end_of_record") {
1175
1421
  closeRecord();
@@ -1264,8 +1510,9 @@ function runLcov(dir, fileOption, io2) {
1264
1510
  return 1;
1265
1511
  }
1266
1512
  const violations = [...manifest.violations];
1513
+ const listing = /* @__PURE__ */ new Map();
1267
1514
  const existing = manifest.gates.filter((gate) => {
1268
- const exists = existsExact(manifest.dir, gate.path);
1515
+ const exists = existsExact(manifest.dir, gate.path, "file", listing);
1269
1516
  if (!exists) {
1270
1517
  violations.push({
1271
1518
  file: gate.path,
@@ -1298,12 +1545,437 @@ function runLcov(dir, fileOption, io2) {
1298
1545
  }
1299
1546
 
1300
1547
  // src/commands/mutation.ts
1301
- import { spawnSync } from "child_process";
1302
- import fs6 from "fs";
1548
+ import { spawnSync as spawnSync2 } from "child_process";
1549
+ import fs7 from "fs";
1550
+ import path7 from "path";
1551
+ import { pathToFileURL } from "url";
1303
1552
 
1304
- // src/commands/mutation-result.ts
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");
1557
+ }
1558
+ function parseRecord(value) {
1559
+ const { format, incremental, snapshot } = value ?? {};
1560
+ if (format !== RECORD_FORMAT || typeof incremental !== "string") {
1561
+ return null;
1562
+ }
1563
+ if (typeof snapshot !== "object" || snapshot === null) {
1564
+ return null;
1565
+ }
1566
+ const { mode, environment, args, files } = snapshot;
1567
+ if (mode !== "git" && mode !== "walk") {
1568
+ return null;
1569
+ }
1570
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
1571
+ return null;
1572
+ }
1573
+ if (!isStringRecord(environment) || !isStringRecord(files)) {
1574
+ return null;
1575
+ }
1576
+ return { format, incremental, snapshot: { mode, environment, args, files } };
1577
+ }
1578
+ function listed(names) {
1579
+ const shown = names.slice(0, 3).join(", ");
1580
+ return names.length > 3 ? `${shown} and ${names.length - 3} more` : shown;
1581
+ }
1582
+ function differingKeys(before, after) {
1583
+ return [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => before[key] !== after[key]).sort();
1584
+ }
1585
+ function refuseReuse(input) {
1586
+ if (input.record === void 0) {
1587
+ return "there is no record of the run that wrote the incremental file";
1588
+ }
1589
+ const record = parseRecord(input.record);
1590
+ if (record === null) {
1591
+ return "the record of the last run is not one this version can read";
1592
+ }
1593
+ if (record.incremental !== input.incremental) {
1594
+ return "the incremental file is not the one the last recorded run wrote";
1595
+ }
1596
+ const before = record.snapshot;
1597
+ const now = input.snapshot;
1598
+ if (before.mode !== now.mode) {
1599
+ return `the files are listed in another way than in the last run (${before.mode}, now ${now.mode})`;
1600
+ }
1601
+ const environment = differingKeys(before.environment, now.environment);
1602
+ if (environment.length > 0) {
1603
+ const changes = environment.map(
1604
+ (key) => `${key} ${before.environment[key] ?? "none"} \u2192 ${now.environment[key] ?? "none"}`
1605
+ );
1606
+ return `the tools changed since the last run: ${listed(changes)}`;
1607
+ }
1608
+ if (JSON.stringify(before.args) !== JSON.stringify(now.args)) {
1609
+ return "the arguments for Stryker are not those of the last run";
1610
+ }
1611
+ const files = differingKeys(before.files, now.files);
1612
+ if (files.length > 0) {
1613
+ return `changed since the last run: ${listed(files)}`;
1614
+ }
1615
+ return null;
1616
+ }
1617
+ function incrementalOptions(config, args) {
1618
+ const options = typeof config === "object" && config !== null ? config : {};
1619
+ const valueOf = (name) => {
1620
+ let value;
1621
+ args.forEach((arg, position) => {
1622
+ if (arg === name) {
1623
+ value = args[position + 1];
1624
+ } else if (arg.startsWith(`${name}=`)) {
1625
+ value = arg.slice(name.length + 1);
1626
+ }
1627
+ });
1628
+ return value;
1629
+ };
1630
+ const fromConfig = (name, fallback) => typeof options[name] === "string" ? options[name] : fallback;
1631
+ return {
1632
+ incremental: options.incremental === true || args.includes("--incremental"),
1633
+ force: options.force === true || args.includes("--force"),
1634
+ incrementalFile: valueOf("--incrementalFile") ?? fromConfig("incrementalFile", "reports/stryker-incremental.json"),
1635
+ tempDirName: valueOf("--tempDirName") ?? fromConfig("tempDirName", ".stryker-tmp")
1636
+ };
1637
+ }
1638
+ function comparableArgs(args) {
1639
+ return args.filter((arg) => arg !== "--force");
1640
+ }
1641
+
1642
+ // src/snapshot.ts
1643
+ import { spawnSync } from "child_process";
1644
+ import { createHash } from "crypto";
1305
1645
  import fs5 from "fs";
1646
+ import { createRequire } from "module";
1647
+ import os from "os";
1306
1648
  import path5 from "path";
1649
+ var SnapshotError = class extends Error {
1650
+ };
1651
+ var TOOLS = [
1652
+ "@stryker-mutator/core",
1653
+ "@stryker-mutator/jest-runner",
1654
+ "@stryker-mutator/vitest-runner",
1655
+ "@stryker-mutator/typescript-checker",
1656
+ "jest",
1657
+ "babel-jest",
1658
+ "ts-jest",
1659
+ "@swc/jest",
1660
+ "vitest",
1661
+ "vite",
1662
+ "@vitest/coverage-istanbul",
1663
+ "@vitest/coverage-v8",
1664
+ "typescript"
1665
+ ];
1666
+ var INSTALL_MARKERS = [
1667
+ ".package-lock.json",
1668
+ ".yarn-integrity",
1669
+ ".yarn-state.yml",
1670
+ ".modules.yaml"
1671
+ ];
1672
+ var LOCK_FILES = [
1673
+ "package-lock.json",
1674
+ "npm-shrinkwrap.json",
1675
+ "yarn.lock",
1676
+ "pnpm-lock.yaml",
1677
+ "bun.lock",
1678
+ "bun.lockb"
1679
+ ];
1680
+ var NEVER = ["node_modules", ".git"];
1681
+ var SCAN_LIMIT = 8 * 1024 * 1024;
1682
+ var UPWARD = /(['"`])((?:[^'"`\n]*\/)?\.\.(?:\/[^'"`\n]*)?)\1/g;
1683
+ function hashOf(content) {
1684
+ return createHash("sha256").update(content).digest("hex");
1685
+ }
1686
+ function hashFile(file) {
1687
+ try {
1688
+ return hashOf(fs5.readFileSync(file));
1689
+ } catch {
1690
+ return null;
1691
+ }
1692
+ }
1693
+ function isInside(parent, child) {
1694
+ const relative = path5.relative(parent, child);
1695
+ return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
1696
+ }
1697
+ function toPosix(file) {
1698
+ return file.split(path5.sep).join("/");
1699
+ }
1700
+ function git(cwd, args) {
1701
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 1024 });
1702
+ return result.error || result.status !== 0 ? null : result.stdout;
1703
+ }
1704
+ function gitLister(root) {
1705
+ return (directory, recursive) => {
1706
+ const relative = toPosix(path5.relative(root, directory));
1707
+ const pattern = recursive ? `${relative === "" ? "" : `${relative}/`}**` : `${relative === "" ? "" : `${relative}/`}*`;
1708
+ const listed2 = git(root, [
1709
+ "ls-files",
1710
+ "-z",
1711
+ "--cached",
1712
+ "--others",
1713
+ "--exclude-standard",
1714
+ "--",
1715
+ `:(glob)${pattern}`
1716
+ ]);
1717
+ if (listed2 === null) {
1718
+ throw new SnapshotError("git could not list the files");
1719
+ }
1720
+ return listed2.split("\0").filter((file) => file !== "");
1721
+ };
1722
+ }
1723
+ function walkLister(root) {
1724
+ const walk = (directory, recursive, found) => {
1725
+ for (const entry of fs5.readdirSync(directory, { withFileTypes: true })) {
1726
+ const file = path5.join(directory, entry.name);
1727
+ if (NEVER.includes(entry.name)) {
1728
+ continue;
1729
+ }
1730
+ if (entry.isDirectory()) {
1731
+ if (recursive) {
1732
+ walk(file, true, found);
1733
+ }
1734
+ } else {
1735
+ found.push(toPosix(path5.relative(root, file)));
1736
+ }
1737
+ }
1738
+ return found;
1739
+ };
1740
+ return (directory, recursive) => walk(directory, recursive, []);
1741
+ }
1742
+ function upTo(from, to) {
1743
+ const directories = [from];
1744
+ while (directories[directories.length - 1] !== to) {
1745
+ directories.push(path5.dirname(directories[directories.length - 1]));
1746
+ }
1747
+ return directories;
1748
+ }
1749
+ function declaresWorkspaces(directory) {
1750
+ if (fs5.existsSync(path5.join(directory, "pnpm-workspace.yaml"))) {
1751
+ return true;
1752
+ }
1753
+ try {
1754
+ const manifest = JSON.parse(fs5.readFileSync(path5.join(directory, "package.json"), "utf8"));
1755
+ return manifest.workspaces !== void 0;
1756
+ } catch {
1757
+ return false;
1758
+ }
1759
+ }
1760
+ function walkRoot(dir) {
1761
+ const candidates = withinBounds(dir);
1762
+ const repository = candidates.find((directory) => fs5.existsSync(path5.join(directory, ".git")));
1763
+ if (repository !== void 0) {
1764
+ return repository;
1765
+ }
1766
+ return candidates.filter(declaresWorkspaces).pop() ?? dir;
1767
+ }
1768
+ function withinBounds(dir) {
1769
+ let home = null;
1770
+ try {
1771
+ home = fs5.realpathSync(os.homedir());
1772
+ } catch {
1773
+ }
1774
+ return upTo(dir, path5.parse(dir).root).filter(
1775
+ (directory) => directory !== path5.parse(dir).root && (home === null || !isInside(directory, home))
1776
+ );
1777
+ }
1778
+ function linkedPackages(nodeModules, root) {
1779
+ const found = [];
1780
+ const visit = (directory, depth) => {
1781
+ let entries;
1782
+ try {
1783
+ entries = fs5.readdirSync(directory, { withFileTypes: true });
1784
+ } catch {
1785
+ return;
1786
+ }
1787
+ for (const entry of entries) {
1788
+ const file = path5.join(directory, entry.name);
1789
+ if (entry.isSymbolicLink()) {
1790
+ let target;
1791
+ try {
1792
+ target = fs5.realpathSync(file);
1793
+ } catch {
1794
+ continue;
1795
+ }
1796
+ if (target.split(path5.sep).includes("node_modules") || !fs5.statSync(target).isDirectory()) {
1797
+ continue;
1798
+ }
1799
+ if (!isInside(root, target)) {
1800
+ throw new SnapshotError(
1801
+ `${file} is a link to a directory outside of what can be listed (${target})`
1802
+ );
1803
+ }
1804
+ found.push(target);
1805
+ } else if (depth === 0 && entry.isDirectory() && entry.name.startsWith("@")) {
1806
+ visit(file, 1);
1807
+ }
1808
+ }
1809
+ };
1810
+ visit(nodeModules, 0);
1811
+ return found;
1812
+ }
1813
+ function installedVersion(start, name) {
1814
+ let current = start;
1815
+ for (; ; ) {
1816
+ try {
1817
+ const manifest = JSON.parse(
1818
+ fs5.readFileSync(path5.join(current, "node_modules", name, "package.json"), "utf8")
1819
+ );
1820
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
1821
+ } catch {
1822
+ const parent = path5.dirname(current);
1823
+ if (parent === current) {
1824
+ return null;
1825
+ }
1826
+ current = parent;
1827
+ }
1828
+ }
1829
+ }
1830
+ function ownVersion() {
1831
+ const { version } = createRequire(import.meta.url)("../package.json");
1832
+ return version;
1833
+ }
1834
+ function takeSnapshot(input) {
1835
+ const dir = fs5.realpathSync(input.dir);
1836
+ const top = git(dir, ["rev-parse", "--show-toplevel"]);
1837
+ let root = top === null ? dir : fs5.realpathSync(top.trim());
1838
+ let lister = gitLister(root);
1839
+ let mode = "git";
1840
+ const manifest = toPosix(path5.relative(root, path5.join(dir, "test-gates.json")));
1841
+ if (top === null || !lister(dir, false).includes(manifest)) {
1842
+ mode = "walk";
1843
+ root = walkRoot(dir);
1844
+ lister = walkLister(root);
1845
+ }
1846
+ const listRoot = root;
1847
+ const list = lister;
1848
+ const walk = walkLister(listRoot);
1849
+ const written = input.written.map((file) => {
1850
+ const target = path5.resolve(dir, file);
1851
+ try {
1852
+ return fs5.realpathSync(target);
1853
+ } catch {
1854
+ return target;
1855
+ }
1856
+ });
1857
+ const swallowing = written.find((target) => isInside(target, path5.join(dir, "test-gates.json")));
1858
+ if (swallowing !== void 0) {
1859
+ throw new SnapshotError(`the run writes to ${swallowing}, which holds test-gates.json`);
1860
+ }
1861
+ const files = /* @__PURE__ */ Object.create(null);
1862
+ const regions = [dir];
1863
+ const queued = new Set(regions);
1864
+ const enqueue = (target, from) => {
1865
+ const directory = isInside(target, dir) && isInside(listRoot, target) ? listRoot : target;
1866
+ if (!isInside(listRoot, directory)) {
1867
+ throw new SnapshotError(
1868
+ `${toPosix(path5.relative(dir, from))} points outside of what can be listed (${directory})`
1869
+ );
1870
+ }
1871
+ if (![...queued].some((region) => isInside(region, directory))) {
1872
+ queued.add(directory);
1873
+ regions.push(directory);
1874
+ }
1875
+ };
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
+ }
1882
+ const target = path5.resolve(path5.dirname(file), fixed);
1883
+ let isDirectory = false;
1884
+ try {
1885
+ isDirectory = fs5.statSync(target).isDirectory();
1886
+ } catch {
1887
+ }
1888
+ const directory = isDirectory ? target : path5.dirname(target);
1889
+ if (fs5.existsSync(directory)) {
1890
+ enqueue(directory, file);
1891
+ }
1892
+ };
1893
+ const add = (relative) => {
1894
+ const file = path5.join(listRoot, relative);
1895
+ if (relative in files || written.some((target) => isInside(target, file))) {
1896
+ return;
1897
+ }
1898
+ let stat;
1899
+ try {
1900
+ stat = fs5.lstatSync(file);
1901
+ } catch {
1902
+ files[relative] = "missing";
1903
+ return;
1904
+ }
1905
+ if (stat.isSymbolicLink()) {
1906
+ files[relative] = `link ${fs5.readlinkSync(file)}`;
1907
+ let target;
1908
+ try {
1909
+ target = fs5.realpathSync(file);
1910
+ } catch {
1911
+ return;
1912
+ }
1913
+ enqueue(fs5.statSync(target).isDirectory() ? target : path5.dirname(target), file);
1914
+ } else if (stat.isDirectory()) {
1915
+ files[relative] = "directory";
1916
+ walk(file, true).forEach(add);
1917
+ } else if (!stat.isFile()) {
1918
+ files[relative] = "special";
1919
+ } else {
1920
+ const content = fs5.readFileSync(file);
1921
+ 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
+ }
1927
+ }
1928
+ }
1929
+ };
1930
+ const ancestors = upTo(dir, listRoot).slice(1);
1931
+ for (const directory of ancestors) {
1932
+ list(directory, false).forEach(add);
1933
+ }
1934
+ for (const directory of [dir, ...ancestors]) {
1935
+ for (const linked of linkedPackages(path5.join(directory, "node_modules"), listRoot)) {
1936
+ if (!isInside(linked, dir)) {
1937
+ enqueue(linked, path5.join(directory, "node_modules"));
1938
+ }
1939
+ }
1940
+ }
1941
+ const above = withinBounds(listRoot).slice(1);
1942
+ for (const directory of [dir, ...ancestors, ...above]) {
1943
+ const candidates = [
1944
+ ...INSTALL_MARKERS.map((marker) => path5.join(directory, "node_modules", marker)),
1945
+ ...above.includes(directory) ? LOCK_FILES.map((name) => path5.join(directory, name)) : []
1946
+ ];
1947
+ for (const file of candidates) {
1948
+ const hash = hashFile(file);
1949
+ if (hash !== null) {
1950
+ files[toPosix(path5.relative(listRoot, file))] = hash;
1951
+ }
1952
+ }
1953
+ }
1954
+ for (let position = 0; position < regions.length; position += 1) {
1955
+ list(regions[position], true).forEach(add);
1956
+ }
1957
+ const environment = {
1958
+ "Node.js": process.version,
1959
+ platform: `${process.platform} ${process.arch}`,
1960
+ "test-gates": ownVersion()
1961
+ };
1962
+ for (const name of TOOLS) {
1963
+ const version = installedVersion(dir, name);
1964
+ if (version !== null) {
1965
+ environment[name] = version;
1966
+ }
1967
+ }
1968
+ return {
1969
+ mode,
1970
+ environment,
1971
+ args: input.args,
1972
+ files
1973
+ };
1974
+ }
1975
+
1976
+ // src/commands/mutation-result.ts
1977
+ import fs6 from "fs";
1978
+ import path6 from "path";
1307
1979
 
1308
1980
  // src/core/mutation-result.ts
1309
1981
  var DETECTED = /* @__PURE__ */ new Set(["Killed", "Timeout"]);
@@ -1413,11 +2085,11 @@ function judgeMutationReport(input) {
1413
2085
  }
1414
2086
  const located = [];
1415
2087
  const byKey = /* @__PURE__ */ new Map();
1416
- const sorted = [...result.mutants].sort(
2088
+ const sorted2 = [...result.mutants].sort(
1417
2089
  (a, b) => a.location.start.line - b.location.start.line || a.location.start.column - b.location.start.column
1418
2090
  );
1419
2091
  let locationsValid = true;
1420
- for (const mutant of sorted) {
2092
+ for (const mutant of sorted2) {
1421
2093
  const original = sliceByLocation(normalizeNewlines(source), mutant.location);
1422
2094
  if (original === null) {
1423
2095
  violations.push({
@@ -1553,15 +2225,17 @@ function formatSummary(summary) {
1553
2225
 
1554
2226
  // src/commands/mutation-result.ts
1555
2227
  function reportPathOf(manifest, override) {
1556
- return path5.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
2228
+ return path6.resolve(manifest.dir, override ?? manifest.settings.strykerReportFile);
1557
2229
  }
1558
- function runMutationResult(dir, reportOverride, io2) {
2230
+ function runMutationResult(dir, reportOverride, io2, only = []) {
1559
2231
  const manifest = readManifest(dir);
2232
+ const gates = only.length === 0 ? manifest.gates : manifest.gates.filter((gate) => only.includes(gate.path));
2233
+ const caveat = only.length === 0 ? "" : gateRunCaveat(gates.length, manifest.gates.length);
1560
2234
  const reportPath = reportPathOf(manifest, reportOverride);
1561
- const shown = path5.relative(manifest.dir, reportPath) || reportPath;
2235
+ const shown = path6.relative(manifest.dir, reportPath) || reportPath;
1562
2236
  let report;
1563
2237
  try {
1564
- report = JSON.parse(fs5.readFileSync(reportPath, "utf8"));
2238
+ report = JSON.parse(fs6.readFileSync(reportPath, "utf8"));
1565
2239
  } catch (error) {
1566
2240
  io2.err(
1567
2241
  `test-gates mutation: cannot read the Stryker JSON report ${shown} (${error.message}). Did Stryker finish, with the "json" reporter writing to this path?`
@@ -1573,12 +2247,12 @@ function runMutationResult(dir, reportOverride, io2) {
1573
2247
  return 1;
1574
2248
  }
1575
2249
  const verdict = judgeMutationReport({
1576
- gates: manifest.gates,
2250
+ gates,
1577
2251
  report,
1578
2252
  maxTimeouts: manifest.settings.maxTimeouts,
1579
2253
  readSource: (gatePath) => {
1580
2254
  try {
1581
- return fs5.readFileSync(path5.join(manifest.dir, gatePath), "utf8");
2255
+ return fs6.readFileSync(path6.join(manifest.dir, gatePath), "utf8");
1582
2256
  } catch {
1583
2257
  return void 0;
1584
2258
  }
@@ -1586,7 +2260,7 @@ function runMutationResult(dir, reportOverride, io2) {
1586
2260
  });
1587
2261
  const summary = formatSummary(verdict.summary);
1588
2262
  if (verdict.violations.length === 0 && verdict.survivors.length === 0) {
1589
- io2.out(`test-gates mutation: OK (${summary})`);
2263
+ io2.out(`test-gates mutation: OK (${summary})${caveat}`);
1590
2264
  return 0;
1591
2265
  }
1592
2266
  if (verdict.survivors.length > 0) {
@@ -1638,13 +2312,133 @@ function runMutationResult(dir, reportOverride, io2) {
1638
2312
  if (verdict.violations.length > 0) {
1639
2313
  printViolations(io2, "mutation", verdict.violations);
1640
2314
  }
1641
- io2.err(`test-gates mutation: FAILED (${summary})`);
2315
+ io2.err(`test-gates mutation: FAILED (${summary})${caveat}`);
1642
2316
  return 1;
1643
2317
  }
1644
2318
 
1645
2319
  // src/commands/mutation.ts
1646
- function runMutation(dir, strykerArgs, io2) {
2320
+ var CONFIG_MARK = "\0test-gates-stryker-config\0";
2321
+ function loadStrykerConfig(manifest, config) {
2322
+ const file = path7.resolve(manifest.dir, config);
2323
+ if (file.endsWith(".json")) {
2324
+ try {
2325
+ return JSON.parse(fs7.readFileSync(file, "utf8"));
2326
+ } catch {
2327
+ return void 0;
2328
+ }
2329
+ }
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)});`;
2331
+ const result = spawnSync2(process.execPath, ["--input-type=module", "--eval", script], {
2332
+ cwd: manifest.dir,
2333
+ encoding: "utf8",
2334
+ timeout: 6e4
2335
+ });
2336
+ const printed = result.stdout?.split(CONFIG_MARK)[1];
2337
+ if (result.status !== 0 || printed === void 0) {
2338
+ return void 0;
2339
+ }
2340
+ return JSON.parse(printed);
2341
+ }
2342
+ function modifiedAt(file) {
2343
+ try {
2344
+ const stat = fs7.statSync(file, { bigint: true });
2345
+ return `${stat.mtimeNs} ${stat.ino}`;
2346
+ } catch {
2347
+ return null;
2348
+ }
2349
+ }
2350
+ function guardRecordPath(incrementalFile) {
2351
+ return `${incrementalFile}.test-gates.json`;
2352
+ }
2353
+ function guardIncremental(manifest, config, strykerArgs, part) {
2354
+ const nothing = { force: null, record: () => void 0 };
2355
+ const loaded = loadStrykerConfig(manifest, config);
2356
+ const options = incrementalOptions(loaded, strykerArgs);
2357
+ if (loaded !== void 0 && !options.incremental) {
2358
+ return nothing;
2359
+ }
2360
+ const base = fs7.realpathSync(manifest.dir);
2361
+ const incrementalFile = path7.resolve(base, options.incrementalFile);
2362
+ const recordFile = guardRecordPath(incrementalFile);
2363
+ let snapshot = null;
2364
+ let failure = "the Stryker config could not be loaded to see where the incremental file is";
2365
+ if (loaded !== void 0) {
2366
+ try {
2367
+ snapshot = takeSnapshot({
2368
+ dir: manifest.dir,
2369
+ written: [
2370
+ incrementalFile,
2371
+ recordFile,
2372
+ path7.resolve(base, options.tempDirName),
2373
+ path7.resolve(base, manifest.settings.strykerReportFile)
2374
+ ],
2375
+ args: comparableArgs(strykerArgs)
2376
+ });
2377
+ } catch (error) {
2378
+ failure = `the files the run depends on could not be listed (${error.message})`;
2379
+ }
2380
+ }
2381
+ let force = null;
2382
+ const incremental = hashFile(incrementalFile);
2383
+ 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
+ };
2389
+ }
2390
+ if (incremental !== null && !options.force) {
2391
+ if (snapshot === null) {
2392
+ force = failure;
2393
+ } else {
2394
+ let record;
2395
+ try {
2396
+ record = JSON.parse(fs7.readFileSync(recordFile, "utf8"));
2397
+ } catch {
2398
+ record = void 0;
2399
+ }
2400
+ force = refuseReuse({ record, incremental, snapshot });
2401
+ }
2402
+ }
2403
+ fs7.rmSync(recordFile, { force: true });
2404
+ const before = modifiedAt(incrementalFile);
2405
+ return {
2406
+ force,
2407
+ record: () => {
2408
+ const written = hashFile(incrementalFile);
2409
+ if (snapshot === null || written === null || modifiedAt(incrementalFile) === before) {
2410
+ return;
2411
+ }
2412
+ const record = { format: RECORD_FORMAT, incremental: written, snapshot };
2413
+ try {
2414
+ fs7.writeFileSync(recordFile, `${JSON.stringify(record)}
2415
+ `);
2416
+ } catch {
2417
+ }
2418
+ }
2419
+ };
2420
+ }
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);
2427
+ }
2428
+ if (strykerArgs.some((arg) => /^(?:--mutate|-m)(?:=|$)/.test(arg))) {
2429
+ throw new UsageError("--gate and --mutate cannot be combined: --gate sets --mutate itself");
2430
+ }
2431
+ }
1647
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
+ }
1648
2442
  const config = findStrykerConfig(manifest);
1649
2443
  if (config === null) {
1650
2444
  throw new ManifestError(
@@ -1657,8 +2451,17 @@ function runMutation(dir, strykerArgs, io2) {
1657
2451
  "stryker is not installed in this project (node_modules/.bin/stryker). Add @stryker-mutator/core as a devDependency"
1658
2452
  );
1659
2453
  }
1660
- fs6.rmSync(reportPathOf(manifest, null), { force: true });
1661
- const result = spawnSync(stryker, ["run", config, ...strykerArgs], {
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}`
2460
+ );
2461
+ }
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], {
1662
2465
  cwd: manifest.dir,
1663
2466
  stdio: "inherit",
1664
2467
  shell: process.platform === "win32"
@@ -1673,13 +2476,392 @@ function runMutation(dir, strykerArgs, io2) {
1673
2476
  );
1674
2477
  return 1;
1675
2478
  }
1676
- return runMutationResult(dir, null, io2);
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;
2485
+ }
2486
+
2487
+ // src/commands/selfcheck.ts
2488
+ import { spawn, spawnSync as spawnSync3 } from "child_process";
2489
+ import fs10 from "fs";
2490
+ import os2 from "os";
2491
+ import path10 from "path";
2492
+
2493
+ // src/core/jobs.ts
2494
+ var SWITCHES = {
2495
+ jest: ["--runInBand", "-i", "--ci"],
2496
+ vitest: ["--no-file-parallelism", "--fileParallelism=false", "--pool=forks", "--pool=threads"]
2497
+ };
2498
+ var COUNTS = {
2499
+ jest: ["--maxWorkers", "-w"],
2500
+ vitest: ["--maxWorkers", "--minWorkers"]
2501
+ };
2502
+ var COUNT = /^\d+%?$/;
2503
+ function knownCommand(program, args) {
2504
+ const runner = ["jest", "vitest"].find((name) => name === program);
2505
+ if (runner === void 0) {
2506
+ return { stray: program };
2507
+ }
2508
+ if (runner === "vitest" && args[0] !== "run") {
2509
+ return { stray: args[0] ?? 'vitest without "run"' };
2510
+ }
2511
+ const rest = runner === "vitest" ? args.slice(1) : [...args];
2512
+ let configs = 0;
2513
+ for (let arg = rest.shift(); arg !== void 0; arg = rest.shift()) {
2514
+ if (SWITCHES[runner].includes(arg)) {
2515
+ continue;
2516
+ }
2517
+ const equals = arg.indexOf("=");
2518
+ const name = equals === -1 ? arg : arg.slice(0, equals);
2519
+ const value = equals === -1 ? rest.shift() : arg.slice(equals + 1);
2520
+ if (value !== void 0 && (name === "--config" || name === "-c")) {
2521
+ configs += 1;
2522
+ } else if (!COUNTS[runner].includes(name) || !COUNT.test(String(value))) {
2523
+ return { stray: arg };
2524
+ }
2525
+ }
2526
+ return configs === 1 ? { runner } : { stray: configs === 0 ? "no --config" : "two --config" };
2527
+ }
2528
+ function jobFlags(runner, dir) {
2529
+ return runner === "jest" ? [`--coverageDirectory=${dir}/coverage`, `--cacheDirectory=${dir}/cache`] : [`--coverage.reportsDirectory=${dir}/coverage`, "--no-cache"];
2530
+ }
2531
+ var REACHES = [
2532
+ "process.",
2533
+ "process?.",
2534
+ "process[",
2535
+ "globalThis",
2536
+ "global.",
2537
+ "global[",
2538
+ "import.meta.env",
2539
+ "Date.now",
2540
+ "Date()",
2541
+ "performance.now",
2542
+ "setTimeout",
2543
+ "setInterval",
2544
+ "setImmediate",
2545
+ "waitFor",
2546
+ "waitUntil",
2547
+ "expect.poll",
2548
+ "retryTimes",
2549
+ "retry:",
2550
+ "fetch(",
2551
+ "WebSocket",
2552
+ "XMLHttpRequest",
2553
+ "navigator",
2554
+ "Atomics",
2555
+ "WeakRef",
2556
+ "FinalizationRegistry",
2557
+ "eval(",
2558
+ "Function("
2559
+ ];
2560
+ var COMPUTED_LOAD = /(?:require|import)\((?!['"])|\brequire(?!\(|\.resolve\b|[\w$])/;
2561
+ var RUNNER_PACKAGES = ["vitest", "@jest/globals"];
2562
+ function reachesOutside(file, trusted) {
2563
+ const dense = file.source.replace(/\s/g, "");
2564
+ const mark = REACHES.find((text) => dense.includes(text));
2565
+ if (mark !== void 0) {
2566
+ return `uses ${mark.replace(/[.?[(:]+$/, "")}`;
2567
+ }
2568
+ if (COMPUTED_LOAD.test(dense)) {
2569
+ return "loads a module whose name is computed";
2570
+ }
2571
+ for (const { specifier, builtin } of file.imports) {
2572
+ if (builtin) {
2573
+ return `imports ${specifier}`;
2574
+ } else if (![...RUNNER_PACKAGES, ...trusted].some(
2575
+ (name) => specifier === name || specifier.startsWith(`${name}/`)
2576
+ )) {
2577
+ return `imports the package ${specifier}, which is not looked into`;
2578
+ }
2579
+ }
2580
+ return null;
2581
+ }
2582
+ function jobsRefused(input) {
2583
+ if (input.failurePattern) {
2584
+ return "with settings.selfcheck.failurePattern, a run that was disturbed cannot be told from one that was not";
2585
+ }
2586
+ if ("stray" in input.command) {
2587
+ return `the gate command is not known to keep runs at the same time apart: only jest or "vitest run" with --config and options for the number of workers is (found: ${input.command.stray})`;
2588
+ }
2589
+ const helper = input.command.runner === "jest" ? "createJestGatesConfig" : "createVitestGatesConfig";
2590
+ if (input.configSource === null || !input.configSource.includes(helper)) {
2591
+ return `the gate config does not use ${helper}`;
2592
+ }
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
+ return null;
2603
+ }
2604
+ var TROUBLE = [
2605
+ "fail",
2606
+ "fails",
2607
+ "failed",
2608
+ "failing",
2609
+ "failure",
2610
+ "failures",
2611
+ "err",
2612
+ "timeout",
2613
+ "timeouts",
2614
+ "timed",
2615
+ "unhandled",
2616
+ "uncaught",
2617
+ "killed",
2618
+ "terminated",
2619
+ "crashed",
2620
+ "aborted",
2621
+ "skipped",
2622
+ "todo",
2623
+ "retry",
2624
+ "retried",
2625
+ "obsolete",
2626
+ "sigkill",
2627
+ "sigterm",
2628
+ "sigabrt",
2629
+ "sigsegv",
2630
+ "sigint"
2631
+ ];
2632
+ var TROUBLE_SIGNS = ["\u2717", "\xD7", "\u2715", "\u2716"];
2633
+ function troubled(line) {
2634
+ const words = line.toLowerCase().split(/[^a-z]/);
2635
+ return (
2636
+ // (`TypeError`, `errors`)
2637
+ words.some((word) => TROUBLE.includes(word) || /errors?$/.test(word)) || TROUBLE_SIGNS.some((sign) => line.includes(sign))
2638
+ );
2639
+ }
2640
+ function plain(line) {
2641
+ return line.split("\x1B[").map((part, index) => index === 0 ? part : part.slice(part.indexOf("m") + 1)).join("").trim();
2642
+ }
2643
+ var SUMMARY = {
2644
+ jest: {
2645
+ files: "Test Suites:",
2646
+ tests: "Tests:",
2647
+ all: (count) => `${count} passed, ${count} total`
2648
+ },
2649
+ vitest: { files: "Test Files", tests: "Tests", all: (count) => `${count} passed (${count})` }
2650
+ };
2651
+ function cleanHold(input) {
2652
+ const { output, gatePath, others } = input;
2653
+ if (!input.held || input.signal !== null) {
2654
+ return false;
2655
+ }
2656
+ const lines = output.split("\n").map(plain);
2657
+ const summary = SUMMARY[input.runner];
2658
+ const after = (label) => lines.filter((line) => line.startsWith(label)).map((line) => line.slice(label.length).trim());
2659
+ const files = after(summary.files);
2660
+ const tests = after(summary.tests);
2661
+ const count = (tests[0] ?? "").split(" ")[0];
2662
+ if (files.length !== 1 || files[0] !== summary.all(String(input.specs)) || tests.length !== 1 || !/^\d+$/.test(count) || tests[0] !== summary.all(count)) {
2663
+ return false;
2664
+ }
2665
+ return lines.every((line) => {
2666
+ const lower = line.toLowerCase();
2667
+ if (lower.includes("threshold") || lower.includes("coverage data for")) {
2668
+ return line.includes(gatePath) && !others.some((other) => line.includes(other));
2669
+ }
2670
+ return line.split("|").length >= 5 || // What stands around the names of the project, piece by piece.
2671
+ !input.names.reduce((pieces, name) => pieces.flatMap((piece) => piece.split(name)), [line]).some(troubled);
2672
+ });
2673
+ }
2674
+
2675
+ // src/touch.ts
2676
+ import { createHash as createHash2 } from "crypto";
2677
+ import fs8 from "fs";
2678
+ import path8 from "path";
2679
+ import { fileURLToPath } from "url";
2680
+ var TOUCH_ENV = "TEST_GATES_TOUCH_DIR";
2681
+ function isObject(value) {
2682
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2683
+ }
2684
+ function countersOf(coverage) {
2685
+ return {
2686
+ s: Object.values(coverage.s ?? {}),
2687
+ f: Object.values(coverage.f ?? {}),
2688
+ b: Object.values(coverage.b ?? {}).flat()
2689
+ };
2690
+ }
2691
+ function countersOfAll(coverage) {
2692
+ if (!isObject(coverage)) {
2693
+ return null;
2694
+ }
2695
+ const files = {};
2696
+ for (const [file, counters] of Object.entries(coverage)) {
2697
+ if (!isObject(counters)) {
2698
+ return null;
2699
+ }
2700
+ files[file] = countersOf(counters);
2701
+ }
2702
+ return files;
2703
+ }
2704
+ function isCounters(value) {
2705
+ return isObject(value) && ["s", "f", "b"].every(
2706
+ (key) => Array.isArray(value[key]) && value[key].every((count) => typeof count === "number")
2707
+ );
2708
+ }
2709
+ function readNotes(directory) {
2710
+ const read = (file) => {
2711
+ try {
2712
+ return JSON.parse(fs8.readFileSync(path8.join(directory, file), "utf8"));
2713
+ } catch {
2714
+ return null;
2715
+ }
2716
+ };
2717
+ const run = read("run.json");
2718
+ let notes = [];
2719
+ try {
2720
+ for (const name of fs8.readdirSync(path8.join(directory, "specs"))) {
2721
+ const note = read(path8.join("specs", name));
2722
+ if (!isObject(note) || typeof note.spec !== "string" || !isObject(note.files) || !Object.values(note.files).every(isCounters)) {
2723
+ throw new Error("not a note");
2724
+ }
2725
+ notes.push(note);
2726
+ }
2727
+ } catch {
2728
+ notes = null;
2729
+ }
2730
+ return {
2731
+ 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
+ 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
+ }
2831
+ };
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 };
1677
2862
  }
1678
2863
 
1679
2864
  // src/commands/selfcheck.ts
1680
- import { spawnSync as spawnSync2 } from "child_process";
1681
- import fs7 from "fs";
1682
- import path6 from "path";
1683
2865
  var JEST_CONFIGS = ["js", "cjs", "mjs", "ts", "cts", "mts"].map(
1684
2866
  (extension) => `jest.gates.config.${extension}`
1685
2867
  );
@@ -1690,7 +2872,7 @@ function resolveGateCommand(manifest) {
1690
2872
  if (manifest.settings.gateCommand !== null) {
1691
2873
  return manifest.settings.gateCommand;
1692
2874
  }
1693
- const exists = (name) => fs7.existsSync(path6.join(manifest.dir, name));
2875
+ const exists = (name) => fs10.existsSync(path10.join(manifest.dir, name));
1694
2876
  const jest = JEST_CONFIGS.find(exists);
1695
2877
  const vitest = VITEST_CONFIGS.find(exists);
1696
2878
  if (jest && vitest) {
@@ -1708,16 +2890,259 @@ function resolveGateCommand(manifest) {
1708
2890
  "no gate config found (jest.gates.config.* / vitest.gates.config.*). Set settings.gateCommand"
1709
2891
  );
1710
2892
  }
1711
- function runSelfcheck(dir, mode, commandOverride, io2) {
2893
+ var TOUCH_PREFIX = ".test-gates-touch-";
2894
+ function realPath(file) {
2895
+ try {
2896
+ return fs10.realpathSync(file);
2897
+ } catch {
2898
+ return file;
2899
+ }
2900
+ }
2901
+ function shareOneRun(manifest, targets, runGate) {
2902
+ let touchDir;
2903
+ 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 });
2908
+ }
2909
+ }
2910
+ touchDir = fs10.mkdtempSync(path10.join(manifest.dir, TOUCH_PREFIX));
2911
+ } catch {
2912
+ return { reason: "the subproject cannot be written to" };
2913
+ }
2914
+ let plan;
2915
+ try {
2916
+ const recorded = runGate({ touchDir });
2917
+ if (recorded.status !== 0) {
2918
+ return { reason: "the gate run with every spec does not pass" };
2919
+ }
2920
+ const base = realPath(manifest.dir);
2921
+ const absolute = (file) => realPath(path10.resolve(base, file));
2922
+ const rekeyed = (files) => Object.fromEntries(Object.entries(files).map(([file, value]) => [absolute(file), value]));
2923
+ const left = readNotes(touchDir);
2924
+ plan = planControls({
2925
+ all: manifest.gates.map((gate) => ({
2926
+ path: gate.path,
2927
+ file: path10.join(base, gate.path),
2928
+ specFile: path10.join(base, gate.spec ?? "")
2929
+ })),
2930
+ targets: targets.map((gate) => gate.path),
2931
+ run: left.run,
2932
+ notes: left.notes?.map((note) => ({ spec: absolute(note.spec), files: rekeyed(note.files) })) ?? null,
2933
+ totals: left.totals === null ? null : rekeyed(left.totals),
2934
+ failurePattern: manifest.settings.selfcheckFailurePattern
2935
+ });
2936
+ } finally {
2937
+ fs10.rmSync(touchDir, { recursive: true, force: true });
2938
+ }
2939
+ if (plan.reason !== null) {
2940
+ return { reason: plan.reason };
2941
+ }
2942
+ const together = plan.together;
2943
+ const gates = targets.filter((gate) => together.includes(gate.path));
2944
+ return { gates, result: runGate({ excludeAll: gates.map((gate) => gate.spec) }) };
2945
+ }
2946
+ function installedVersion2(start, name) {
2947
+ let current = path10.resolve(start);
2948
+ for (; ; ) {
2949
+ try {
2950
+ const manifest = JSON.parse(
2951
+ fs10.readFileSync(path10.join(current, "node_modules", name, "package.json"), "utf8")
2952
+ );
2953
+ return typeof manifest.version === "string" ? manifest.version : null;
2954
+ } catch {
2955
+ const parent = path10.dirname(current);
2956
+ if (parent === current) {
2957
+ return null;
2958
+ }
2959
+ current = parent;
2960
+ }
2961
+ }
2962
+ }
2963
+ function gateConfigFile(args) {
2964
+ const joined = args.find((arg) => arg.startsWith("--config="));
2965
+ const position = args.findIndex((arg) => arg === "--config" || arg === "-c");
2966
+ return joined?.slice("--config=".length) ?? (position === -1 ? null : args[position + 1] ?? null);
2967
+ }
2968
+ function gateConfigSource(manifest, args) {
2969
+ const file = gateConfigFile(args);
2970
+ if (file === null) {
2971
+ return null;
2972
+ }
2973
+ try {
2974
+ return readText(manifest.dir, file);
2975
+ } catch {
2976
+ return null;
2977
+ }
2978
+ }
2979
+ var JOBS_PREFIX = "test-gates-jobs-";
2980
+ async function runAtOnce(manifest, gates, jobs, runner, executable, args) {
2981
+ const results = /* @__PURE__ */ new Map();
2982
+ let root;
2983
+ 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 });
2988
+ }
2989
+ }
2990
+ root = fs10.realpathSync(fs10.mkdtempSync(path10.join(os2.tmpdir(), JOBS_PREFIX)));
2991
+ } catch {
2992
+ return results;
2993
+ }
2994
+ const queue = [...gates];
2995
+ const line = async (dir) => {
2996
+ for (let gate = queue.shift(); gate !== void 0; gate = queue.shift()) {
2997
+ const spec = gate.spec;
2998
+ results.set(
2999
+ gate,
3000
+ await new Promise((resolve) => {
3001
+ const child = spawn(executable, [...args, ...jobFlags(runner, dir)], {
3002
+ cwd: manifest.dir,
3003
+ env: { ...process.env, [EXCLUDE_ENV]: spec, [EXCLUDE_ALL_ENV]: "", [TOUCH_ENV]: "" },
3004
+ shell: process.platform === "win32"
3005
+ });
3006
+ let stdout = "";
3007
+ let stderr = "";
3008
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
3009
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
3010
+ child.stdin.on("error", () => void 0);
3011
+ child.stdin.end();
3012
+ child.on("error", () => resolve({ status: null, signal: null, stdout, stderr }));
3013
+ child.on("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
3014
+ })
3015
+ );
3016
+ }
3017
+ };
3018
+ try {
3019
+ await Promise.all(
3020
+ Array.from(
3021
+ { length: Math.min(jobs, gates.length) },
3022
+ (_unused, index) => line(path10.join(root, `line-${index}`))
3023
+ )
3024
+ );
3025
+ } finally {
3026
+ fs10.rmSync(root, { recursive: true, force: true });
3027
+ }
3028
+ return results;
3029
+ }
3030
+ async function runSelfcheck(dir, selection, commandOverride, io2) {
3031
+ const refused = selection.quick === true ? quickRefused(process.env) : null;
3032
+ if (refused !== null) {
3033
+ throw new UsageError(refused);
3034
+ }
1712
3035
  const manifest = readManifest(dir);
1713
3036
  if (manifest.violations.length > 0 || manifest.gates.length === 0) {
1714
3037
  io2.err('test-gates selfcheck: test-gates.json has problems. Run "test-gates check" first');
1715
3038
  return 1;
1716
3039
  }
3040
+ const chosen = selectControls({
3041
+ gates: manifest.gates.map((gate) => gate.path),
3042
+ mode: selection.mode ?? manifest.settings.selfcheckMode,
3043
+ only: selection.gates,
3044
+ shard: selection.shard
3045
+ });
3046
+ if ("error" in chosen) {
3047
+ throw new UsageError(chosen.error);
3048
+ }
3049
+ const targets = manifest.gates.filter((gate) => chosen.selected.includes(gate.path));
3050
+ const skipped = manifest.gates.length - targets.length;
3051
+ const notSelected = selection.shard !== null ? `, ${skipped} gate(s) not selected by --shard ${selection.shard.index}/${selection.shard.total}` : selection.gates.length > 0 ? `, ${skipped} gate(s) not selected by --gate` : "";
1717
3052
  const command = commandOverride.length > 0 ? commandOverride : resolveGateCommand(manifest);
1718
3053
  const [program, ...args] = command;
1719
3054
  const executable = findLocalBin(manifest.dir, program) ?? program;
1720
- const targets = (mode ?? manifest.settings.selfcheckMode) === "first" ? manifest.gates.slice(0, 1) : manifest.gates;
3055
+ const runGate = (env) => spawnSync3(executable, args, {
3056
+ cwd: manifest.dir,
3057
+ env: {
3058
+ ...process.env,
3059
+ [EXCLUDE_ENV]: env.exclude ?? "",
3060
+ [EXCLUDE_ALL_ENV]: env.excludeAll === void 0 ? "" : JSON.stringify(env.excludeAll),
3061
+ [TOUCH_ENV]: env.touchDir ?? ""
3062
+ },
3063
+ encoding: "utf8",
3064
+ maxBuffer: 256 * 1024 * 1024,
3065
+ shell: process.platform === "win32"
3066
+ });
3067
+ const judge = (result, gatePath) => result.error ? `cannot start "${command.join(" ")}" (${result.error.message})` : judgeSelfcheck({
3068
+ status: result.status,
3069
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
3070
+ gatePath,
3071
+ failurePattern: manifest.settings.selfcheckFailurePattern
3072
+ });
3073
+ const heldTogether = /* @__PURE__ */ new Map();
3074
+ const single = targets.filter((gate) => gate.spec !== null && gate.existingSpecs.length === 1);
3075
+ if (selection.quick === true && single.length >= 3) {
3076
+ const configSource = gateConfigSource(manifest, args);
3077
+ const shared = sharedRunPrecheck({
3078
+ program,
3079
+ version: installedVersion2(manifest.dir, program),
3080
+ configSource,
3081
+ specs: manifest.gates.flatMap(
3082
+ (gate) => gate.existingSpecs.map((spec) => ({ path: spec, source: readText(manifest.dir, spec) }))
3083
+ )
3084
+ }) ?? shareOneRun(manifest, single, runGate);
3085
+ if (typeof shared === "string" || "reason" in shared) {
3086
+ const reason = typeof shared === "string" ? shared : shared.reason;
3087
+ io2.out(`test-gates selfcheck: a run of the gate command for every gate (${reason})`);
3088
+ } else {
3089
+ for (const gate of shared.gates) {
3090
+ if (judge(shared.result, gate.path) === null) {
3091
+ heldTogether.set(gate.path, shared.result.status);
3092
+ }
3093
+ }
3094
+ }
3095
+ }
3096
+ const heldAtOnce = /* @__PURE__ */ new Map();
3097
+ const waiting = single.filter((gate) => !heldTogether.has(gate.path));
3098
+ const jobs = selection.jobs ?? 1;
3099
+ if (jobs > 1 && waiting.length > 1) {
3100
+ const known = knownCommand(program, args);
3101
+ const configFile = gateConfigFile(args);
3102
+ const ask = (loaded) => jobsRefused({
3103
+ failurePattern: manifest.settings.selfcheckFailurePattern,
3104
+ 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
+ ) : []
3110
+ });
3111
+ const refusal = ask({ files: [], lost: null }) ?? ask(loadedFiles(manifest, configFile));
3112
+ if (refusal !== null || !("runner" in known)) {
3113
+ io2.out(`test-gates selfcheck: one negative control at a time (${refusal})`);
3114
+ } 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) {
3140
+ 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`
3142
+ );
3143
+ }
3144
+ }
3145
+ }
1721
3146
  let failed = 0;
1722
3147
  for (const gate of targets) {
1723
3148
  if (gate.spec === null || gate.existingSpecs.length !== 1) {
@@ -1725,20 +3150,19 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
1725
3150
  failed += 1;
1726
3151
  continue;
1727
3152
  }
1728
- const result = spawnSync2(executable, args, {
1729
- cwd: manifest.dir,
1730
- env: { ...process.env, [EXCLUDE_ENV]: gate.spec },
1731
- encoding: "utf8",
1732
- maxBuffer: 256 * 1024 * 1024,
1733
- shell: process.platform === "win32"
1734
- });
3153
+ if (heldTogether.has(gate.path)) {
3154
+ io2.out(
3155
+ ` \u2713 without ${gate.spec} the gate fails (exit ${heldTogether.get(gate.path)}, in the shared run)`
3156
+ );
3157
+ continue;
3158
+ }
3159
+ if (heldAtOnce.has(gate.path)) {
3160
+ io2.out(` \u2713 without ${gate.spec} the gate fails (exit ${heldAtOnce.get(gate.path)})`);
3161
+ continue;
3162
+ }
3163
+ const result = runGate({ exclude: gate.spec });
1735
3164
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
1736
- const reason = result.error ? `cannot start "${command.join(" ")}" (${result.error.message})` : judgeSelfcheck({
1737
- status: result.status,
1738
- output,
1739
- gatePath: gate.path,
1740
- failurePattern: manifest.settings.selfcheckFailurePattern
1741
- });
3165
+ const reason = judge(result, gate.path);
1742
3166
  if (reason === null) {
1743
3167
  io2.out(` \u2713 without ${gate.spec} the gate fails (exit ${result.status})`);
1744
3168
  } else {
@@ -1763,10 +3187,14 @@ function runSelfcheck(dir, mode, commandOverride, io2) {
1763
3187
  }
1764
3188
  }
1765
3189
  if (failed > 0) {
1766
- io2.err(`test-gates selfcheck: FAILED (${failed} of ${targets.length} negative control(s))`);
3190
+ io2.err(
3191
+ `test-gates selfcheck: FAILED (${failed} of ${targets.length} negative control(s)${notSelected})${quickCaveat(heldTogether.size)}`
3192
+ );
1767
3193
  return 1;
1768
3194
  }
1769
- io2.out(`test-gates selfcheck: OK (${targets.length} negative control(s) failed as they should)`);
3195
+ io2.out(
3196
+ `test-gates selfcheck: OK (${targets.length} negative control(s) failed as they should${notSelected})${quickCaveat(heldTogether.size)}`
3197
+ );
1770
3198
  return 0;
1771
3199
  }
1772
3200
 
@@ -1781,12 +3209,21 @@ Commands:
1781
3209
  Unknown arguments and everything after "--" go to Stryker
1782
3210
  mutation-result Judge an existing Stryker JSON report
1783
3211
  selfcheck [-- <command>] Negative control: leave one spec out, the gate must fail
1784
- lcov --file <lcov.info> Per-file 100% line coverage from an lcov tracefile
3212
+ lcov [--file <lcov.info>] Per-file 100% line coverage from an lcov tracefile
1785
3213
 
1786
3214
  Options:
1787
3215
  --dir <subproject> Directory that holds test-gates.json (default: .)
1788
3216
  --report <path> mutation-result: report to judge (default: settings.stryker.reportFile)
1789
3217
  --all | --first selfcheck: every gate in turn (default) or only the first
3218
+ --gate <path> selfcheck: only this gate (may be given more than once).
3219
+ mutation: only the mutants of this gate, while working on it.
3220
+ Not the final check, and refused when CI is set
3221
+ --shard <index>/<total> selfcheck: one of <total> parts of the gates, for parallel CI jobs
3222
+ --jobs <n> selfcheck: run <n> negative controls at the same time. One that does
3223
+ not hold cleanly there is run again on its own
3224
+ --quick selfcheck: one run for the gates only their own spec touches.
3225
+ Not the final check: it assumes that no spec depends on which
3226
+ other specs run. Refused when CI is set
1790
3227
  --file <path> lcov: tracefile (default: settings.lcov.file)
1791
3228
  -h, --help Show this help
1792
3229
  -v, --version Show the version
@@ -1802,7 +3239,7 @@ var io = {
1802
3239
  function main(argv) {
1803
3240
  const args = parseArgs(argv);
1804
3241
  if (args.version) {
1805
- const { version } = createRequire(import.meta.url)("../package.json");
3242
+ const { version } = createRequire2(import.meta.url)("../package.json");
1806
3243
  io.out(version);
1807
3244
  return 0;
1808
3245
  }
@@ -1814,11 +3251,22 @@ function main(argv) {
1814
3251
  case "check":
1815
3252
  return runCheck(args.dir, io);
1816
3253
  case "mutation":
1817
- return runMutation(args.dir, args.rest, io);
3254
+ return runMutation(args.dir, args.rest, io, args.gates);
1818
3255
  case "mutation-result":
1819
3256
  return runMutationResult(args.dir, args.report, io);
1820
3257
  case "selfcheck":
1821
- return runSelfcheck(args.dir, args.mode, args.rest, io);
3258
+ return runSelfcheck(
3259
+ args.dir,
3260
+ {
3261
+ mode: args.mode,
3262
+ gates: args.gates,
3263
+ shard: args.shard,
3264
+ quick: args.quick,
3265
+ jobs: args.jobs
3266
+ },
3267
+ args.rest,
3268
+ io
3269
+ );
1822
3270
  case "lcov":
1823
3271
  return runLcov(args.dir, args.file, io);
1824
3272
  case null:
@@ -1826,7 +3274,7 @@ function main(argv) {
1826
3274
  }
1827
3275
  }
1828
3276
  try {
1829
- process.exitCode = main(process.argv.slice(2));
3277
+ process.exitCode = await main(process.argv.slice(2));
1830
3278
  } catch (error) {
1831
3279
  if (error instanceof UsageError) {
1832
3280
  io.err(`test-gates: ${error.message}