@blzsky/variant 0.3.0-canary.0 → 0.3.0-canary.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/README.md +155 -72
- package/dist/{build-GKWNLU4Q.js → build-GCUFPPIB.js} +11 -11
- package/dist/check-AKYABBIV.js +1459 -0
- package/dist/{chunk-CWF6QXEW.js → chunk-2UVLKBLV.js} +1 -1
- package/dist/chunk-34VGKPEM.js +59 -0
- package/dist/{chunk-KCEGA56S.js → chunk-BNACT47L.js} +39 -119
- package/dist/{chunk-ACTY42RE.js → chunk-C3NZIFW6.js} +66 -0
- package/dist/{chunk-CN25SRKD.js → chunk-EFCMBBBT.js} +71 -109
- package/dist/chunk-H55GYSQ6.js +102 -0
- package/dist/{chunk-MT4BOAAV.js → chunk-HL65IPEQ.js} +2 -2
- package/dist/{chunk-MAK3HG46.js → chunk-JEJRXPCU.js} +3 -3
- package/dist/chunk-LUTP2TVU.js +94 -0
- package/dist/{chunk-DMDAJPFJ.js → chunk-MKUYZIXF.js} +1 -1
- package/dist/{chunk-SGP5JX6I.js → chunk-MNYKLWRC.js} +1 -1
- package/dist/{chunk-YII4TAKS.js → chunk-NJSTAWDM.js} +3 -3
- package/dist/{chunk-RJBFCURK.js → chunk-NOC53Z5Q.js} +1 -1
- package/dist/{chunk-H5C4SE73.js → chunk-O6FCFZDF.js} +6 -63
- package/dist/{chunk-RSIOYQOQ.js → chunk-OON6O4S5.js} +34 -0
- package/dist/{chunk-7NC7AO75.js → chunk-TPMHY4L6.js} +15 -17
- package/dist/{chunk-IFFZ46YS.js → chunk-TRUK3TJF.js} +1 -1
- package/dist/chunk-WTXWEEI2.js +21 -0
- package/dist/{chunk-DSQDMFQ6.js → chunk-WULBOSG5.js} +6 -1
- package/dist/{chunk-NFFXYYM6.js → chunk-XXMD4NC5.js} +4 -4
- package/dist/cli.js +41 -17
- package/dist/{diff-A3TQWWUJ.js → diff-ST5KHT7P.js} +5 -5
- package/dist/{doctor-6ONS6LKY.js → doctor-OEIY4Y5X.js} +39 -6
- package/dist/{env-XF2US3CB.js → env-EQAOZCFR.js} +8 -9
- package/dist/{git-W6N3WMYQ.js → git-VGHUF2SY.js} +7 -1
- package/dist/{impact-DPZIAJR4.js → impact-AXLZTLVT.js} +20 -17
- package/dist/index.js +2 -2
- package/dist/{init-VNK5E4H3.js → init-QCOZWLOO.js} +27 -11
- package/dist/{insight-LNYVMCAE.js → insight-KRWT6Z47.js} +9 -10
- package/dist/{pr-6CYMM2XU.js → pr-7JXUC5BQ.js} +24 -21
- package/dist/{run-44PKMOV5.js → run-UOPS5FRU.js} +11 -11
- package/dist/stryker-host.d.ts +2 -0
- package/dist/stryker-host.js +135 -0
- package/dist/stryker-plugins.d.ts +33 -0
- package/dist/stryker-plugins.js +142 -0
- package/dist/{symbol-graph-OB4AJCLW.js → symbol-graph-GYOI4GU4.js} +3 -3
- package/dist/vitest.js +7 -5
- package/dist/{workspace-PIQXG4ZD.js → workspace-IQFLR3RY.js} +5 -5
- package/package.json +5 -2
- package/dist/check-KO5VY67Z.js +0 -44
- package/dist/chunk-QRPI6TJE.js +0 -68
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultIsTestFile
|
|
3
|
+
} from "./chunk-BNACT47L.js";
|
|
4
|
+
import {
|
|
5
|
+
assertBaseRef,
|
|
6
|
+
createClassifier
|
|
7
|
+
} from "./chunk-JEJRXPCU.js";
|
|
8
|
+
import "./chunk-TRUK3TJF.js";
|
|
9
|
+
import {
|
|
10
|
+
mapLimit
|
|
11
|
+
} from "./chunk-GDSZFIHM.js";
|
|
12
|
+
import "./chunk-MKUYZIXF.js";
|
|
13
|
+
import {
|
|
14
|
+
plural
|
|
15
|
+
} from "./chunk-CXNXZLDG.js";
|
|
16
|
+
import "./chunk-3E5LBU3O.js";
|
|
17
|
+
import {
|
|
18
|
+
errorLines,
|
|
19
|
+
lines
|
|
20
|
+
} from "./chunk-6J4XDG44.js";
|
|
21
|
+
import {
|
|
22
|
+
getColors
|
|
23
|
+
} from "./chunk-YYGCSKIQ.js";
|
|
24
|
+
import {
|
|
25
|
+
getPrinter
|
|
26
|
+
} from "./chunk-EISTWXDW.js";
|
|
27
|
+
import {
|
|
28
|
+
changeBaseDeps,
|
|
29
|
+
resolveChangeBase
|
|
30
|
+
} from "./chunk-O6FCFZDF.js";
|
|
31
|
+
import {
|
|
32
|
+
EXCLUDED_MUTATORS
|
|
33
|
+
} from "./chunk-WTXWEEI2.js";
|
|
34
|
+
import "./chunk-X3VVGTEV.js";
|
|
35
|
+
import {
|
|
36
|
+
hasLocalChanges,
|
|
37
|
+
listChangedFiles,
|
|
38
|
+
listUntrackedFiles,
|
|
39
|
+
readFileAtRef,
|
|
40
|
+
readZeroContextDiff
|
|
41
|
+
} from "./chunk-OON6O4S5.js";
|
|
42
|
+
import "./chunk-XXMD4NC5.js";
|
|
43
|
+
import "./chunk-WULBOSG5.js";
|
|
44
|
+
import {
|
|
45
|
+
BaselineFailedError,
|
|
46
|
+
CheckBackendError,
|
|
47
|
+
CheckRunnerError,
|
|
48
|
+
CheckWorkspaceRootError,
|
|
49
|
+
CliUsageError,
|
|
50
|
+
GitRefError,
|
|
51
|
+
NoChangeBaseError
|
|
52
|
+
} from "./chunk-C3NZIFW6.js";
|
|
53
|
+
|
|
54
|
+
// src/adapters/mutation/stryker.ts
|
|
55
|
+
import { fork, spawnSync } from "child_process";
|
|
56
|
+
import { existsSync, realpathSync } from "fs";
|
|
57
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
58
|
+
import path from "path";
|
|
59
|
+
import { fileURLToPath } from "url";
|
|
60
|
+
var EXCLUDED = new Set(EXCLUDED_MUTATORS);
|
|
61
|
+
var OUTPUT_LINES = 200;
|
|
62
|
+
var KILL_WAIT_MS = 5e3;
|
|
63
|
+
var OUTPUT_SETTLE_MS = 250;
|
|
64
|
+
var CHECK_TMP = path.join(".variant", "check-tmp");
|
|
65
|
+
function toPosix(file) {
|
|
66
|
+
return file.replace(/\\/g, "/");
|
|
67
|
+
}
|
|
68
|
+
function projectRoots(cwd) {
|
|
69
|
+
try {
|
|
70
|
+
const real = realpathSync(cwd);
|
|
71
|
+
return real === cwd ? [cwd] : [cwd, real];
|
|
72
|
+
} catch {
|
|
73
|
+
return [cwd];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function relativeFile(roots, fileName) {
|
|
77
|
+
for (const root of roots) {
|
|
78
|
+
const relative = path.relative(root, fileName);
|
|
79
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
80
|
+
return toPosix(relative);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return toPosix(path.relative(roots[0] ?? "", fileName));
|
|
84
|
+
}
|
|
85
|
+
function toPlannedMutant(roots, raw) {
|
|
86
|
+
return {
|
|
87
|
+
id: raw.id,
|
|
88
|
+
file: relativeFile(roots, raw.fileName),
|
|
89
|
+
mutator: raw.mutatorName,
|
|
90
|
+
replacement: raw.replacement,
|
|
91
|
+
start: {
|
|
92
|
+
line: raw.location.start.line + 1,
|
|
93
|
+
column: raw.location.start.column + 1
|
|
94
|
+
},
|
|
95
|
+
end: {
|
|
96
|
+
line: raw.location.end.line + 1,
|
|
97
|
+
column: raw.location.end.column + 1
|
|
98
|
+
},
|
|
99
|
+
// No coverage information at all means Stryker runs every test
|
|
100
|
+
// against it: it counts toward the limit like any covered mutant.
|
|
101
|
+
covered: raw.coveredBy === void 0 || raw.coveredBy.length > 0,
|
|
102
|
+
static: raw.static === true
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
var STATUSES = /* @__PURE__ */ new Set([
|
|
106
|
+
"Killed",
|
|
107
|
+
"Survived",
|
|
108
|
+
"NoCoverage",
|
|
109
|
+
"Timeout",
|
|
110
|
+
"RuntimeError",
|
|
111
|
+
"CompileError",
|
|
112
|
+
"Ignored",
|
|
113
|
+
"Pending"
|
|
114
|
+
]);
|
|
115
|
+
function toTestedMutant(raw) {
|
|
116
|
+
const status = STATUSES.has(raw.status) ? raw.status : "RuntimeError";
|
|
117
|
+
return {
|
|
118
|
+
id: raw.id,
|
|
119
|
+
status,
|
|
120
|
+
...raw.statusReason !== void 0 && { statusReason: raw.statusReason },
|
|
121
|
+
killedBy: raw.killedBy ?? [],
|
|
122
|
+
...raw.testsCompleted !== void 0 && {
|
|
123
|
+
testsCompleted: raw.testsCompleted
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function toBaselineTest(roots, raw) {
|
|
128
|
+
return {
|
|
129
|
+
id: raw.id,
|
|
130
|
+
name: raw.name,
|
|
131
|
+
file: raw.fileName === void 0 ? raw.id.split("#")[0] ?? "" : relativeFile(roots, raw.fileName),
|
|
132
|
+
skipped: raw.skipped === true
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
136
|
+
var SANDBOX_PREFIX = /(?:[A-Za-z]:)?[^\s'"`]*?[\\/]\.variant[\\/]check-tmp[\\/][^\\/\s]+[\\/]sandbox-[^\\/\s]+[\\/]/g;
|
|
137
|
+
function withoutSandbox(text) {
|
|
138
|
+
return text.replace(SANDBOX_PREFIX, "");
|
|
139
|
+
}
|
|
140
|
+
function parseFailingTests(output) {
|
|
141
|
+
const lines2 = withoutSandbox(output.replace(ANSI, "")).split(/\r?\n/);
|
|
142
|
+
const start = lines2.findIndex(
|
|
143
|
+
(line) => /tests failed in the initial test run:\s*$/i.test(line)
|
|
144
|
+
);
|
|
145
|
+
if (start === -1) return [];
|
|
146
|
+
const tests = [];
|
|
147
|
+
for (const line of lines2.slice(start + 1)) {
|
|
148
|
+
if (/^\t\t/.test(line)) {
|
|
149
|
+
const last = tests[tests.length - 1];
|
|
150
|
+
if (last !== void 0) {
|
|
151
|
+
last.message = last.message === "" ? line.trim() : `${last.message} ${line.trim()}`;
|
|
152
|
+
}
|
|
153
|
+
} else if (/^\t\S/.test(line)) {
|
|
154
|
+
tests.push({ name: line.trim(), message: "" });
|
|
155
|
+
} else {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return tests;
|
|
160
|
+
}
|
|
161
|
+
function hostEnvironment(env, ceiling) {
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const [key, value] of Object.entries(env)) {
|
|
164
|
+
if (value === void 0) continue;
|
|
165
|
+
if (/^GIT_(DIR|INDEX_FILE|WORK_TREE|COMMON_DIR|OBJECT_DIRECTORY|PREFIX)$/.test(
|
|
166
|
+
key
|
|
167
|
+
)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (/^GITHUB_(STEP_SUMMARY|OUTPUT|ENV|PATH)$/.test(key)) continue;
|
|
171
|
+
if (key.startsWith("VITEST_")) continue;
|
|
172
|
+
out[key] = value;
|
|
173
|
+
}
|
|
174
|
+
out["GIT_CEILING_DIRECTORIES"] = ceiling;
|
|
175
|
+
out["VARIANT_SHADOW"] = "0";
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
function isAlive(pid) {
|
|
179
|
+
try {
|
|
180
|
+
process.kill(pid, 0);
|
|
181
|
+
return true;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return error.code === "EPERM";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function removeInside(root, target) {
|
|
187
|
+
const resolved = path.resolve(target);
|
|
188
|
+
if (!resolved.startsWith(path.resolve(root) + path.sep)) return;
|
|
189
|
+
await rm(resolved, { recursive: true, force: true, maxRetries: 5 });
|
|
190
|
+
}
|
|
191
|
+
async function sweep(root) {
|
|
192
|
+
const names = await readdir(root).catch(() => []);
|
|
193
|
+
await Promise.all(
|
|
194
|
+
names.filter((name) => name.endsWith(".owner")).map(async (name) => {
|
|
195
|
+
const pid = Number(
|
|
196
|
+
(await readFile(path.join(root, name), "utf8").catch(() => "")).trim()
|
|
197
|
+
);
|
|
198
|
+
if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) return;
|
|
199
|
+
const runId = name.slice(0, -".owner".length);
|
|
200
|
+
await Promise.all([
|
|
201
|
+
removeInside(root, path.join(root, runId)),
|
|
202
|
+
removeInside(root, path.join(root, `${runId}.json`)),
|
|
203
|
+
removeInside(root, path.join(root, name))
|
|
204
|
+
]);
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
var Channel = class {
|
|
209
|
+
queue = [];
|
|
210
|
+
waiting;
|
|
211
|
+
closed = false;
|
|
212
|
+
push(value) {
|
|
213
|
+
if (this.closed) return;
|
|
214
|
+
const waiting = this.waiting;
|
|
215
|
+
if (waiting !== void 0) {
|
|
216
|
+
this.waiting = void 0;
|
|
217
|
+
waiting({ value, done: false });
|
|
218
|
+
} else {
|
|
219
|
+
this.queue.push(value);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
close() {
|
|
223
|
+
this.closed = true;
|
|
224
|
+
const waiting = this.waiting;
|
|
225
|
+
this.waiting = void 0;
|
|
226
|
+
waiting?.({ value: void 0, done: true });
|
|
227
|
+
}
|
|
228
|
+
[Symbol.asyncIterator]() {
|
|
229
|
+
return {
|
|
230
|
+
next: () => {
|
|
231
|
+
const value = this.queue.shift();
|
|
232
|
+
if (value !== void 0) return Promise.resolve({ value, done: false });
|
|
233
|
+
if (this.closed)
|
|
234
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
this.waiting = resolve;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function killTree(child) {
|
|
243
|
+
const pid = child.pid;
|
|
244
|
+
if (pid === void 0 || child.exitCode !== null || child.signalCode !== null) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (process.platform === "win32") {
|
|
248
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
process.kill(-pid, "SIGKILL");
|
|
253
|
+
} catch {
|
|
254
|
+
child.kill("SIGKILL");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function exited(child) {
|
|
258
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
259
|
+
return Promise.resolve();
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
const timer = setTimeout(resolve, KILL_WAIT_MS);
|
|
262
|
+
child.once("close", () => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
resolve();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function builtSibling(name) {
|
|
269
|
+
return fileURLToPath(new URL(`./${name}`, import.meta.url));
|
|
270
|
+
}
|
|
271
|
+
function createStrykerBackend(options = {}) {
|
|
272
|
+
return {
|
|
273
|
+
start: (request) => startRun(request, options)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function startRun(request, options) {
|
|
277
|
+
const channel = new Channel();
|
|
278
|
+
const output = [];
|
|
279
|
+
const remember = (chunk) => {
|
|
280
|
+
output.push(...new TextDecoder().decode(chunk).split(/\r?\n/));
|
|
281
|
+
if (output.length > OUTPUT_LINES)
|
|
282
|
+
output.splice(0, output.length - OUTPUT_LINES);
|
|
283
|
+
};
|
|
284
|
+
const root = path.join(request.cwd, CHECK_TMP);
|
|
285
|
+
const roots = projectRoots(request.cwd);
|
|
286
|
+
const variantDir = path.join(request.cwd, ".variant");
|
|
287
|
+
const createdVariantDir = !existsSync(variantDir);
|
|
288
|
+
const runId = `${Date.now().toString(36)}-${process.pid}`;
|
|
289
|
+
let child;
|
|
290
|
+
let stopping = false;
|
|
291
|
+
let stopped2;
|
|
292
|
+
const cleanup = async () => {
|
|
293
|
+
await Promise.all([
|
|
294
|
+
removeInside(root, path.join(root, runId)),
|
|
295
|
+
removeInside(root, path.join(root, `${runId}.json`)),
|
|
296
|
+
removeInside(root, path.join(root, `${runId}.owner`))
|
|
297
|
+
]);
|
|
298
|
+
const left = await readdir(root).catch(() => []);
|
|
299
|
+
if (left.every((name) => name === ".gitignore")) {
|
|
300
|
+
await rm(root, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
if (createdVariantDir) {
|
|
303
|
+
const rest = await readdir(variantDir).catch(() => []);
|
|
304
|
+
if (rest.length === 0)
|
|
305
|
+
await rm(variantDir, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const prepared = (async () => {
|
|
309
|
+
try {
|
|
310
|
+
await mkdir(root, { recursive: true });
|
|
311
|
+
await writeFile(path.join(root, ".gitignore"), "*\n");
|
|
312
|
+
await sweep(root);
|
|
313
|
+
await writeFile(path.join(root, `${runId}.owner`), String(process.pid));
|
|
314
|
+
await writeFile(path.join(root, `${runId}.json`), "{}\n");
|
|
315
|
+
return true;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
channel.push({
|
|
318
|
+
kind: "error",
|
|
319
|
+
message: `Could not prepare ${CHECK_TMP}: ${error instanceof Error ? error.message : String(error)}`,
|
|
320
|
+
output: ""
|
|
321
|
+
});
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
const stop = () => {
|
|
326
|
+
stopped2 ??= (async () => {
|
|
327
|
+
stopping = true;
|
|
328
|
+
await prepared;
|
|
329
|
+
if (child !== void 0) {
|
|
330
|
+
killTree(child);
|
|
331
|
+
await exited(child);
|
|
332
|
+
}
|
|
333
|
+
await cleanup();
|
|
334
|
+
channel.close();
|
|
335
|
+
})();
|
|
336
|
+
return stopped2;
|
|
337
|
+
};
|
|
338
|
+
void (async () => {
|
|
339
|
+
if (!await prepared) {
|
|
340
|
+
channel.close();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (stopping) return;
|
|
344
|
+
const host = {
|
|
345
|
+
mutate: request.mutate,
|
|
346
|
+
...request.vitestConfig !== void 0 && {
|
|
347
|
+
vitestConfig: request.vitestConfig
|
|
348
|
+
},
|
|
349
|
+
baselineTimeoutMinutes: request.baselineTimeoutMinutes,
|
|
350
|
+
tempDirName: toPosix(path.join(CHECK_TMP, runId)),
|
|
351
|
+
configFile: toPosix(path.join(CHECK_TMP, `${runId}.json`)),
|
|
352
|
+
plugins: options.pluginsPath ?? builtSibling("stryker-plugins.js")
|
|
353
|
+
};
|
|
354
|
+
const spawned = fork(
|
|
355
|
+
options.hostPath ?? builtSibling("stryker-host.js"),
|
|
356
|
+
[],
|
|
357
|
+
{
|
|
358
|
+
cwd: request.cwd,
|
|
359
|
+
env: { ...hostEnvironment(process.env, root), ...options.env },
|
|
360
|
+
execArgv: options.execArgv ?? [],
|
|
361
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
362
|
+
// A process group of its own on POSIX, so one signal ends Stryker's
|
|
363
|
+
// workers too. On Windows a detached process opens a console of its
|
|
364
|
+
// own, and taskkill follows the process tree without one.
|
|
365
|
+
detached: process.platform !== "win32"
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
child = spawned;
|
|
369
|
+
spawned.stdout?.on("data", remember);
|
|
370
|
+
spawned.stderr?.on("data", remember);
|
|
371
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
372
|
+
let terminal;
|
|
373
|
+
spawned.on("message", (message) => {
|
|
374
|
+
if (message.kind === "baseline") {
|
|
375
|
+
channel.push({
|
|
376
|
+
kind: "baseline",
|
|
377
|
+
tests: message.tests.map((test) => toBaselineTest(roots, test))
|
|
378
|
+
});
|
|
379
|
+
} else if (message.kind === "plan") {
|
|
380
|
+
const kept = message.mutants.filter((mutant) => {
|
|
381
|
+
if (!EXCLUDED.has(mutant.mutatorName)) return true;
|
|
382
|
+
dropped.add(mutant.id);
|
|
383
|
+
return false;
|
|
384
|
+
});
|
|
385
|
+
channel.push({
|
|
386
|
+
kind: "plan",
|
|
387
|
+
mutants: kept.map((mutant) => toPlannedMutant(roots, mutant)),
|
|
388
|
+
coverageMeasured: kept.length === 0 || kept.some((mutant) => mutant.coveredBy !== void 0)
|
|
389
|
+
});
|
|
390
|
+
} else if (message.kind === "tested") {
|
|
391
|
+
if (dropped.has(message.mutant.id)) return;
|
|
392
|
+
channel.push({
|
|
393
|
+
kind: "tested",
|
|
394
|
+
mutant: toTestedMutant(message.mutant)
|
|
395
|
+
});
|
|
396
|
+
} else if (message.kind === "note") {
|
|
397
|
+
channel.push({ kind: "note", text: message.text });
|
|
398
|
+
} else {
|
|
399
|
+
terminal = message;
|
|
400
|
+
if (message.kind === "finished") channel.push({ kind: "finished" });
|
|
401
|
+
else setTimeout(report, OUTPUT_SETTLE_MS);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
spawned.on("error", (error) => {
|
|
405
|
+
if (stopping) return;
|
|
406
|
+
channel.push({
|
|
407
|
+
kind: "error",
|
|
408
|
+
message: error.message,
|
|
409
|
+
output: output.join("\n")
|
|
410
|
+
});
|
|
411
|
+
channel.close();
|
|
412
|
+
});
|
|
413
|
+
let reported = false;
|
|
414
|
+
let exit = "";
|
|
415
|
+
function report() {
|
|
416
|
+
if (reported || stopping) return;
|
|
417
|
+
reported = true;
|
|
418
|
+
const text = withoutSandbox(output.join("\n").trim());
|
|
419
|
+
if (terminal?.kind === "baseline-failed") {
|
|
420
|
+
channel.push({
|
|
421
|
+
kind: "baseline-failed",
|
|
422
|
+
failingTests: parseFailingTests(text),
|
|
423
|
+
output: text
|
|
424
|
+
});
|
|
425
|
+
} else if (terminal?.kind === "error") {
|
|
426
|
+
channel.push({
|
|
427
|
+
kind: "error",
|
|
428
|
+
message: terminal.message,
|
|
429
|
+
output: text
|
|
430
|
+
});
|
|
431
|
+
} else if (terminal === void 0) {
|
|
432
|
+
channel.push({
|
|
433
|
+
kind: "error",
|
|
434
|
+
message: `The mutation-testing engine exited (${exit}) before it finished`,
|
|
435
|
+
output: text
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
channel.close();
|
|
439
|
+
}
|
|
440
|
+
spawned.on("close", (code2, signal) => {
|
|
441
|
+
exit = signal ?? `code ${code2}`;
|
|
442
|
+
report();
|
|
443
|
+
});
|
|
444
|
+
spawned.send(host);
|
|
445
|
+
})();
|
|
446
|
+
return { events: channel, stop };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/core/check/run.ts
|
|
450
|
+
import { createHash } from "crypto";
|
|
451
|
+
import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
|
|
452
|
+
import path2 from "path";
|
|
453
|
+
|
|
454
|
+
// src/core/semantic/equivalence.ts
|
|
455
|
+
function shape(node, tsm, out) {
|
|
456
|
+
const { SyntaxKind, ts } = tsm;
|
|
457
|
+
if (node.getKind() === SyntaxKind.SingleLineCommentTrivia || node.getKind() === SyntaxKind.MultiLineCommentTrivia || tsm.Node.isJSDoc(node)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (tsm.Node.isParenthesizedExpression(node) && !(tsm.Node.isExpressionStatement(node.getParent()) && tsm.Node.isStringLiteral(node.getExpression()))) {
|
|
461
|
+
shape(node.getExpression(), tsm, out);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const children = node.getChildren();
|
|
465
|
+
if (children.length === 0) {
|
|
466
|
+
if (node.getKind() !== SyntaxKind.EndOfFileToken) out.push(node.getText());
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const chain = (node.compilerNode.flags & ts.NodeFlags.OptionalChain) !== 0;
|
|
470
|
+
out.push(`(${node.getKindName()}${chain ? "?" : ""}`);
|
|
471
|
+
children.forEach((child, index) => {
|
|
472
|
+
const isLastComma = child.getKind() === SyntaxKind.CommaToken && index === children.length - 1 && node.getKind() === SyntaxKind.SyntaxList;
|
|
473
|
+
if (!isLastComma) shape(child, tsm, out);
|
|
474
|
+
});
|
|
475
|
+
out.push(")");
|
|
476
|
+
}
|
|
477
|
+
function createProgramComparer() {
|
|
478
|
+
let project;
|
|
479
|
+
let id = 0;
|
|
480
|
+
return async (filePath, before, after) => {
|
|
481
|
+
const tsm = await import("ts-morph");
|
|
482
|
+
project ??= new tsm.Project({ useInMemoryFileSystem: true });
|
|
483
|
+
const extension = /\.[cm]?tsx?$/.exec(filePath)?.[0] ?? ".ts";
|
|
484
|
+
const n = id++;
|
|
485
|
+
const a = project.createSourceFile(`/__a_${n}${extension}`, before);
|
|
486
|
+
const b = project.createSourceFile(`/__b_${n}${extension}`, after);
|
|
487
|
+
try {
|
|
488
|
+
const left = [];
|
|
489
|
+
const right = [];
|
|
490
|
+
shape(a, tsm, left);
|
|
491
|
+
shape(b, tsm, right);
|
|
492
|
+
return left.join(" ") === right.join(" ");
|
|
493
|
+
} finally {
|
|
494
|
+
project.removeSourceFile(a);
|
|
495
|
+
project.removeSourceFile(b);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/core/check/base.ts
|
|
501
|
+
var NAMED = /* @__PURE__ */ new Set(["flag", "environment", "pull-request", "push"]);
|
|
502
|
+
async function resolveCheckBase(explicit, deps) {
|
|
503
|
+
const detected = await resolveChangeBase(explicit, deps);
|
|
504
|
+
if (NAMED.has(detected.source)) {
|
|
505
|
+
return { ref: detected.ref, label: detected.label };
|
|
506
|
+
}
|
|
507
|
+
if (await deps.hasLocalChanges() === true) {
|
|
508
|
+
return { ref: "HEAD", label: "HEAD: your uncommitted changes" };
|
|
509
|
+
}
|
|
510
|
+
if (detected.source === "default-branch") {
|
|
511
|
+
return { ref: detected.ref, label: detected.label };
|
|
512
|
+
}
|
|
513
|
+
throw new NoChangeBaseError(
|
|
514
|
+
"Nothing to compare against: the working tree is clean and this branch has not left the default branch"
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/core/check/hunks.ts
|
|
519
|
+
var HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
520
|
+
function parseZeroContextDiff(diff) {
|
|
521
|
+
const added = [];
|
|
522
|
+
let deletedLines = 0;
|
|
523
|
+
for (const line of diff.split("\n")) {
|
|
524
|
+
const match = HUNK_HEADER.exec(line);
|
|
525
|
+
if (match === null) continue;
|
|
526
|
+
const oldCount = match[1] === void 0 ? 1 : Number(match[1]);
|
|
527
|
+
const start = Number(match[2]);
|
|
528
|
+
const newCount = match[3] === void 0 ? 1 : Number(match[3]);
|
|
529
|
+
if (newCount === 0) {
|
|
530
|
+
deletedLines += oldCount;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
added.push({ start, end: start + newCount - 1 });
|
|
534
|
+
}
|
|
535
|
+
return { added, deletedLines };
|
|
536
|
+
}
|
|
537
|
+
function countLines(ranges) {
|
|
538
|
+
return ranges.reduce((sum, range) => sum + range.end - range.start + 1, 0);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/core/check/explain.ts
|
|
542
|
+
var BOUNDARY = {
|
|
543
|
+
">=": ">",
|
|
544
|
+
">": ">=",
|
|
545
|
+
"<=": "<",
|
|
546
|
+
"<": "<="
|
|
547
|
+
};
|
|
548
|
+
var MAX_OPERAND = 40;
|
|
549
|
+
function operatorChange(original, replacement) {
|
|
550
|
+
const a = original.replace(/\s+/g, "");
|
|
551
|
+
const b = replacement.replace(/\s+/g, "");
|
|
552
|
+
let prefix = 0;
|
|
553
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) {
|
|
554
|
+
prefix++;
|
|
555
|
+
}
|
|
556
|
+
let suffix = 0;
|
|
557
|
+
while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) {
|
|
558
|
+
suffix++;
|
|
559
|
+
}
|
|
560
|
+
while (prefix > 0 && /[<>=!&|?]/.test(a[prefix - 1] ?? "")) prefix--;
|
|
561
|
+
while (suffix > 0 && /[<>=!&|?]/.test(a[a.length - suffix] ?? "")) suffix--;
|
|
562
|
+
const from = a.slice(prefix, a.length - suffix);
|
|
563
|
+
const to = b.slice(prefix, b.length - suffix);
|
|
564
|
+
if (!/^[<>=!&|?+\-*/%]+$/.test(from) || !/^[<>=!&|?+\-*/%]+$/.test(to)) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
let seen = 0;
|
|
568
|
+
let index = 0;
|
|
569
|
+
while (index < original.length && seen < prefix) {
|
|
570
|
+
if (!/\s/.test(original[index] ?? "")) seen++;
|
|
571
|
+
index++;
|
|
572
|
+
}
|
|
573
|
+
const left = original.slice(0, index).trim();
|
|
574
|
+
const rest = original.slice(index).trimStart();
|
|
575
|
+
const right = rest.slice(from.length).trim();
|
|
576
|
+
return { left, from, to, right };
|
|
577
|
+
}
|
|
578
|
+
function code(text) {
|
|
579
|
+
return text.replace(/\s+/g, " ").trim();
|
|
580
|
+
}
|
|
581
|
+
function explainSurvivor(mutation) {
|
|
582
|
+
const { mutator, original, replacement } = mutation;
|
|
583
|
+
const change = operatorChange(original, replacement);
|
|
584
|
+
if (mutator === "EqualityOperator" && change !== null) {
|
|
585
|
+
const head = `Changing ${change.from} to ${change.to} went undetected.`;
|
|
586
|
+
if (BOUNDARY[change.from] === change.to) {
|
|
587
|
+
return change.left.length <= MAX_OPERAND && change.right.length <= MAX_OPERAND ? `${head} Review the behavior at ${code(change.left)} = ${code(change.right)}.` : `${head} Review the behavior where both sides are equal.`;
|
|
588
|
+
}
|
|
589
|
+
return `${head} The tests that ran passed with the comparison inverted: review a case on each side of ${code(original)}.`;
|
|
590
|
+
}
|
|
591
|
+
if (mutator === "ConditionalExpression" && /^(true|false)$/.test(replacement)) {
|
|
592
|
+
const other = replacement === "true" ? "false" : "true";
|
|
593
|
+
return `Replacing the condition ${code(original)} with ${replacement} went undetected. The tests that ran passed as if it were always ${replacement}: review a case where it is ${other}.`;
|
|
594
|
+
}
|
|
595
|
+
if (mutator === "LogicalOperator" && change !== null) {
|
|
596
|
+
return `Changing ${change.from} to ${change.to} went undetected. Review a case where only one side of ${code(original)} holds.`;
|
|
597
|
+
}
|
|
598
|
+
if (mutator === "BooleanLiteral") {
|
|
599
|
+
if (/^!/.test(original.trim()) && !/^!/.test(replacement.trim())) {
|
|
600
|
+
return `Removing the ! from ${code(original)} went undetected.`;
|
|
601
|
+
}
|
|
602
|
+
return `Changing ${code(original)} to ${code(replacement)} went undetected.`;
|
|
603
|
+
}
|
|
604
|
+
if (mutator === "ArithmeticOperator" && change !== null) {
|
|
605
|
+
return `Changing ${change.from} to ${change.to} went undetected. The tests that ran did not tell ${code(original)} from ${code(replacement)}.`;
|
|
606
|
+
}
|
|
607
|
+
return `Replacing ${code(original)} with ${code(replacement)} went undetected.`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/core/check/result.ts
|
|
611
|
+
var STATUS_OUTCOME = {
|
|
612
|
+
Killed: "detected",
|
|
613
|
+
Survived: "undetected",
|
|
614
|
+
NoCoverage: "no-coverage",
|
|
615
|
+
Timeout: "timeout",
|
|
616
|
+
RuntimeError: "runtime-error",
|
|
617
|
+
CompileError: "compile-error",
|
|
618
|
+
Ignored: "ignored",
|
|
619
|
+
Pending: "not-run"
|
|
620
|
+
};
|
|
621
|
+
function sliceSource(content, start, end) {
|
|
622
|
+
const lines2 = content.split("\n");
|
|
623
|
+
const offset = (position) => {
|
|
624
|
+
let total = 0;
|
|
625
|
+
for (let line = 1; line < position.line; line++) {
|
|
626
|
+
total += (lines2[line - 1]?.length ?? 0) + 1;
|
|
627
|
+
}
|
|
628
|
+
return total + position.column - 1;
|
|
629
|
+
};
|
|
630
|
+
return content.slice(offset(start), offset(end));
|
|
631
|
+
}
|
|
632
|
+
function outcomeOf(mutant, input) {
|
|
633
|
+
const counted = input.selection === void 0 || input.selection.selected.has(mutant.id) || input.selection.free.has(mutant.id);
|
|
634
|
+
const tested = input.tested.get(mutant.id);
|
|
635
|
+
if (!counted || tested === void 0) {
|
|
636
|
+
return {
|
|
637
|
+
outcome: "not-run",
|
|
638
|
+
reason: !counted ? "past --max-mutants" : input.stopReason === "budget" ? "the --budget ran out" : input.stopReason === "interrupted" ? "interrupted" : "not reported"
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const outcome = STATUS_OUTCOME[tested.status];
|
|
642
|
+
return {
|
|
643
|
+
outcome,
|
|
644
|
+
...outcome !== "detected" && outcome !== "undetected" && tested.statusReason !== void 0 && {
|
|
645
|
+
reason: tested.statusReason.split("\n")[0] ?? ""
|
|
646
|
+
},
|
|
647
|
+
...tested.killedBy[0] !== void 0 && { killedBy: tested.killedBy[0] },
|
|
648
|
+
...tested.testsCompleted !== void 0 && {
|
|
649
|
+
testsRun: tested.testsCompleted
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function assembleCheckResult(input) {
|
|
654
|
+
const mutants = (input.plan ?? []).map((mutant) => {
|
|
655
|
+
const original = sliceSource(
|
|
656
|
+
input.snapshot.get(mutant.file) ?? "",
|
|
657
|
+
mutant.start,
|
|
658
|
+
mutant.end
|
|
659
|
+
);
|
|
660
|
+
const result = outcomeOf(mutant, input);
|
|
661
|
+
const symbol = input.symbols?.get(mutant.file)?.(mutant.start.line);
|
|
662
|
+
return {
|
|
663
|
+
id: mutant.id,
|
|
664
|
+
file: mutant.file,
|
|
665
|
+
start: mutant.start,
|
|
666
|
+
end: mutant.end,
|
|
667
|
+
mutator: mutant.mutator,
|
|
668
|
+
original,
|
|
669
|
+
replacement: mutant.replacement,
|
|
670
|
+
...result,
|
|
671
|
+
...result.outcome === "undetected" && {
|
|
672
|
+
explanation: explainSurvivor({
|
|
673
|
+
mutator: mutant.mutator,
|
|
674
|
+
original,
|
|
675
|
+
replacement: mutant.replacement
|
|
676
|
+
})
|
|
677
|
+
},
|
|
678
|
+
...symbol !== void 0 && { symbol },
|
|
679
|
+
...mutant.static && { static: true }
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
mutants.sort(
|
|
683
|
+
(a, b) => a.file.localeCompare(b.file) || a.start.line - b.start.line || a.start.column - b.start.column || a.id.localeCompare(b.id, void 0, { numeric: true })
|
|
684
|
+
);
|
|
685
|
+
const counts = {
|
|
686
|
+
detected: 0,
|
|
687
|
+
undetected: 0,
|
|
688
|
+
"no-coverage": 0,
|
|
689
|
+
timeout: 0,
|
|
690
|
+
"runtime-error": 0,
|
|
691
|
+
"compile-error": 0,
|
|
692
|
+
ignored: 0,
|
|
693
|
+
"not-run": 0,
|
|
694
|
+
attempted: 0
|
|
695
|
+
};
|
|
696
|
+
for (const mutant of mutants) {
|
|
697
|
+
counts[mutant.outcome]++;
|
|
698
|
+
if (mutant.outcome !== "not-run" && mutant.outcome !== "ignored") {
|
|
699
|
+
counts.attempted++;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const files = input.targets.map((target) => {
|
|
703
|
+
const lookup = input.symbols?.get(target.file);
|
|
704
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
705
|
+
if (lookup !== void 0) {
|
|
706
|
+
for (const range of target.ranges) {
|
|
707
|
+
for (let line = range.start; line <= range.end; line++) {
|
|
708
|
+
const symbol = lookup(line);
|
|
709
|
+
if (symbol !== void 0) symbols.add(symbol);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
file: target.file,
|
|
715
|
+
ranges: target.ranges,
|
|
716
|
+
untracked: target.untracked,
|
|
717
|
+
changedLines: countLines(target.ranges),
|
|
718
|
+
mutants: mutants.filter((mutant) => mutant.file === target.file).length,
|
|
719
|
+
symbols: [...symbols]
|
|
720
|
+
};
|
|
721
|
+
});
|
|
722
|
+
const baselineFinished = input.baselineTests !== void 0;
|
|
723
|
+
const status = input.targets.length === 0 ? "nothing-to-mutate" : input.plan === void 0 || input.stopReason !== void 0 || counts["not-run"] > 0 ? "incomplete" : "complete";
|
|
724
|
+
return {
|
|
725
|
+
base: input.base,
|
|
726
|
+
status,
|
|
727
|
+
...input.stopReason !== void 0 && { stopReason: input.stopReason },
|
|
728
|
+
baselineFinished,
|
|
729
|
+
files,
|
|
730
|
+
excluded: [...input.excluded],
|
|
731
|
+
deletedLines: input.deletedLines,
|
|
732
|
+
...input.baselineTests !== void 0 && {
|
|
733
|
+
baseline: {
|
|
734
|
+
tests: input.baselineTests.length,
|
|
735
|
+
files: new Set(input.baselineTests.map((test) => test.file)).size,
|
|
736
|
+
skipped: input.baselineTests.filter((test) => test.skipped).length
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
...input.vitestConfig !== void 0 && {
|
|
740
|
+
vitestConfig: input.vitestConfig
|
|
741
|
+
},
|
|
742
|
+
coverageMeasured: input.coverageMeasured ?? true,
|
|
743
|
+
mutants,
|
|
744
|
+
counts,
|
|
745
|
+
notes: [...input.notes ?? []],
|
|
746
|
+
durationMs: input.durationMs
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function allDetected(result) {
|
|
750
|
+
const { counts } = result;
|
|
751
|
+
return result.status === "complete" && counts.attempted > 0 && counts.detected === counts.attempted;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/core/check/runner.ts
|
|
755
|
+
function parse(manifest) {
|
|
756
|
+
if (manifest === null) return null;
|
|
757
|
+
try {
|
|
758
|
+
return JSON.parse(manifest);
|
|
759
|
+
} catch {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function detectRunner(manifestText, fileNames) {
|
|
764
|
+
const manifest = parse(manifestText);
|
|
765
|
+
const declares = (name) => name in (manifest?.devDependencies ?? {}) || name in (manifest?.dependencies ?? {});
|
|
766
|
+
const scripts = Object.entries(manifest?.scripts ?? {});
|
|
767
|
+
const scriptRunning = (tool) => scripts.find(
|
|
768
|
+
([, command]) => typeof command === "string" && tool.test(command)
|
|
769
|
+
)?.[0];
|
|
770
|
+
if (declares("vitest") || scriptRunning(/\bvitest\b/) !== void 0 || fileNames.some((name) => /^vitest\.(config|workspace)\./.test(name))) {
|
|
771
|
+
return { kind: "vitest" };
|
|
772
|
+
}
|
|
773
|
+
const jestConfig = fileNames.find((name) => /^jest\.config\./.test(name));
|
|
774
|
+
if (jestConfig !== void 0) return { kind: "jest", evidence: jestConfig };
|
|
775
|
+
const jestScript = scriptRunning(/\bjest\b/);
|
|
776
|
+
if (jestScript !== void 0) {
|
|
777
|
+
return { kind: "jest", evidence: `the \`${jestScript}\` script` };
|
|
778
|
+
}
|
|
779
|
+
if (manifest?.jest !== void 0) {
|
|
780
|
+
return { kind: "jest", evidence: "the `jest` key in package.json" };
|
|
781
|
+
}
|
|
782
|
+
if (declares("jest"))
|
|
783
|
+
return { kind: "jest", evidence: "a `jest` dependency" };
|
|
784
|
+
return { kind: "none" };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/core/check/selection.ts
|
|
788
|
+
function selectMutants(plan, maxMutants) {
|
|
789
|
+
const selected = /* @__PURE__ */ new Set();
|
|
790
|
+
const free = /* @__PURE__ */ new Set();
|
|
791
|
+
const overLimit = [];
|
|
792
|
+
for (const mutant of plan) {
|
|
793
|
+
if (!mutant.covered && !mutant.static) {
|
|
794
|
+
free.add(mutant.id);
|
|
795
|
+
} else if (selected.size < maxMutants) {
|
|
796
|
+
selected.add(mutant.id);
|
|
797
|
+
} else {
|
|
798
|
+
overLimit.push(mutant.id);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return { selected, free, overLimit };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// src/core/check/symbols.ts
|
|
805
|
+
function createSymbolReader() {
|
|
806
|
+
let project;
|
|
807
|
+
return async (file, content) => {
|
|
808
|
+
const tsm = await import("ts-morph");
|
|
809
|
+
project ??= new tsm.Project({ useInMemoryFileSystem: true });
|
|
810
|
+
const source = project.createSourceFile(`/${file}`, content, {
|
|
811
|
+
overwrite: true
|
|
812
|
+
});
|
|
813
|
+
const spans = [];
|
|
814
|
+
const add = (name, node) => {
|
|
815
|
+
if (name === void 0 || name === "") return;
|
|
816
|
+
spans.push({
|
|
817
|
+
name,
|
|
818
|
+
start: node.getStartLineNumber(),
|
|
819
|
+
end: node.getEndLineNumber()
|
|
820
|
+
});
|
|
821
|
+
};
|
|
822
|
+
source.forEachDescendant((node) => {
|
|
823
|
+
if (tsm.Node.isFunctionDeclaration(node) || tsm.Node.isClassDeclaration(node)) {
|
|
824
|
+
add(node.getName(), node);
|
|
825
|
+
} else if (tsm.Node.isMethodDeclaration(node) || tsm.Node.isGetAccessorDeclaration(node) || tsm.Node.isSetAccessorDeclaration(node)) {
|
|
826
|
+
const owner = node.getParent();
|
|
827
|
+
const ownerName = tsm.Node.isClassDeclaration(owner) ? owner.getName() : void 0;
|
|
828
|
+
add(
|
|
829
|
+
ownerName === void 0 ? node.getName() : `${ownerName}.${node.getName()}`,
|
|
830
|
+
node
|
|
831
|
+
);
|
|
832
|
+
} else if (tsm.Node.isConstructorDeclaration(node)) {
|
|
833
|
+
const owner = node.getParent();
|
|
834
|
+
add(
|
|
835
|
+
tsm.Node.isClassDeclaration(owner) ? `${owner.getName() ?? "class"}.constructor` : void 0,
|
|
836
|
+
node
|
|
837
|
+
);
|
|
838
|
+
} else if (tsm.Node.isVariableDeclaration(node)) {
|
|
839
|
+
const initializer = node.getInitializer();
|
|
840
|
+
if (tsm.Node.isArrowFunction(initializer) || tsm.Node.isFunctionExpression(initializer)) {
|
|
841
|
+
add(node.getName(), node);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
project.removeSourceFile(source);
|
|
846
|
+
return (line) => {
|
|
847
|
+
let best;
|
|
848
|
+
for (const span of spans) {
|
|
849
|
+
if (line < span.start || line > span.end) continue;
|
|
850
|
+
if (best === void 0 || span.end - span.start < best.end - best.start) {
|
|
851
|
+
best = span;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return best?.name;
|
|
855
|
+
};
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/core/check/targets.ts
|
|
860
|
+
var TYPESCRIPT = /\.(?:ts|tsx|mts|cts)$/;
|
|
861
|
+
var DECLARATION = /\.d\.(?:ts|mts|cts)$/;
|
|
862
|
+
var INSTALLED = /(^|\/)node_modules\//;
|
|
863
|
+
function exclusionBeforeDiff(file, isTestFile) {
|
|
864
|
+
if (file.startsWith("../")) return "outside";
|
|
865
|
+
if (!TYPESCRIPT.test(file)) return "not-typescript";
|
|
866
|
+
if (DECLARATION.test(file)) return "declaration";
|
|
867
|
+
if (isTestFile(file)) return "test";
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
function isNewSource(file) {
|
|
871
|
+
return TYPESCRIPT.test(file) && !DECLARATION.test(file) && !INSTALLED.test(file);
|
|
872
|
+
}
|
|
873
|
+
function selectTargets(input) {
|
|
874
|
+
const targets = [];
|
|
875
|
+
const excluded = [];
|
|
876
|
+
let deletedLines = 0;
|
|
877
|
+
const consider = (file, untracked) => {
|
|
878
|
+
const reason = exclusionBeforeDiff(file, input.isTestFile);
|
|
879
|
+
if (reason !== null) {
|
|
880
|
+
excluded.push({ file, reason });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const hunks = input.hunks.get(file);
|
|
884
|
+
if (hunks === void 0 || hunks.added.length === 0) {
|
|
885
|
+
excluded.push({ file, reason: "deleted-lines-only" });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (input.formattingOnly.has(file)) {
|
|
889
|
+
excluded.push({ file, reason: "formatting-only" });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
deletedLines += hunks.deletedLines;
|
|
893
|
+
targets.push({ file, ranges: hunks.added, untracked });
|
|
894
|
+
};
|
|
895
|
+
for (const file of [...input.changed].sort()) consider(file, false);
|
|
896
|
+
for (const file of [...input.untracked].sort()) {
|
|
897
|
+
if (isNewSource(file)) consider(file, true);
|
|
898
|
+
}
|
|
899
|
+
return { targets, excluded, deletedLines };
|
|
900
|
+
}
|
|
901
|
+
function mutateEntries(targets) {
|
|
902
|
+
return targets.flatMap(
|
|
903
|
+
(target) => target.ranges.map((range) => `${target.file}:${range.start}-${range.end}`)
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/core/check/run.ts
|
|
908
|
+
var DEFAULT_MAX_MUTANTS = 50;
|
|
909
|
+
var DEFAULT_BUDGET_MS = 5 * 6e4;
|
|
910
|
+
async function readText(file) {
|
|
911
|
+
try {
|
|
912
|
+
return await readFile2(file, "utf8");
|
|
913
|
+
} catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function declaresWorkspaces(pnpmWorkspace, manifest) {
|
|
918
|
+
if (pnpmWorkspace !== null && /^packages\s*:/m.test(pnpmWorkspace)) {
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (manifest === null) return false;
|
|
922
|
+
try {
|
|
923
|
+
const parsed = JSON.parse(manifest);
|
|
924
|
+
return parsed.workspaces !== void 0;
|
|
925
|
+
} catch {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function isWorkspaceRoot(cwd) {
|
|
930
|
+
const [pnpmWorkspace, manifest] = await Promise.all([
|
|
931
|
+
readText(path2.join(cwd, "pnpm-workspace.yaml")),
|
|
932
|
+
readText(path2.join(cwd, "package.json"))
|
|
933
|
+
]);
|
|
934
|
+
return declaresWorkspaces(pnpmWorkspace, manifest);
|
|
935
|
+
}
|
|
936
|
+
function formattingOnly(classify, sameProgram) {
|
|
937
|
+
return async (filePath, before, after) => (await classify({ filePath, before, after })).classification === "non-impacting" || await sameProgram(filePath, before, after);
|
|
938
|
+
}
|
|
939
|
+
function checkDeps(cwd, backend) {
|
|
940
|
+
return {
|
|
941
|
+
backend,
|
|
942
|
+
now: () => Date.now(),
|
|
943
|
+
resolveBase: (explicit) => resolveCheckBase(explicit, {
|
|
944
|
+
...changeBaseDeps(cwd),
|
|
945
|
+
hasLocalChanges: () => hasLocalChanges(cwd)
|
|
946
|
+
}),
|
|
947
|
+
assertBase: (ref) => assertBaseRef(cwd, ref),
|
|
948
|
+
listChangedFiles: (ref) => listChangedFiles(cwd, ref),
|
|
949
|
+
listUntrackedFiles: () => listUntrackedFiles(cwd),
|
|
950
|
+
readDiff: (ref, file) => readZeroContextDiff(cwd, ref, file),
|
|
951
|
+
readFile: (file) => readText(path2.join(cwd, file)),
|
|
952
|
+
readFileAtBase: (ref, file) => readFileAtRef(cwd, ref, file),
|
|
953
|
+
isFormattingOnly: formattingOnly(
|
|
954
|
+
createClassifier(),
|
|
955
|
+
createProgramComparer()
|
|
956
|
+
),
|
|
957
|
+
isWorkspaceRoot: () => isWorkspaceRoot(cwd),
|
|
958
|
+
detectRunner: async () => detectRunner(
|
|
959
|
+
await readText(path2.join(cwd, "package.json")),
|
|
960
|
+
await readdir2(cwd).catch(() => [])
|
|
961
|
+
),
|
|
962
|
+
symbols: createSymbolReader()
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function digest(text) {
|
|
966
|
+
return createHash("sha256").update(text ?? "\0missing").digest("hex");
|
|
967
|
+
}
|
|
968
|
+
function nextOrStop(iterator, msLeft, signal) {
|
|
969
|
+
if (signal?.aborted === true) return Promise.resolve("interrupted");
|
|
970
|
+
if (msLeft <= 0) return Promise.resolve("budget");
|
|
971
|
+
return new Promise((resolve, reject) => {
|
|
972
|
+
const timer = setTimeout(() => finish("budget"), msLeft);
|
|
973
|
+
const onAbort = () => finish("interrupted");
|
|
974
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
975
|
+
function finish(value) {
|
|
976
|
+
clearTimeout(timer);
|
|
977
|
+
signal?.removeEventListener("abort", onAbort);
|
|
978
|
+
resolve(value);
|
|
979
|
+
}
|
|
980
|
+
iterator.next().then(finish, (error) => {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
signal?.removeEventListener("abort", onAbort);
|
|
983
|
+
reject(error);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
async function runCheck(cwd, options, deps) {
|
|
988
|
+
const startedAt = deps.now();
|
|
989
|
+
const base = await deps.resolveBase(options.base);
|
|
990
|
+
await deps.assertBase(base.ref);
|
|
991
|
+
if (await deps.isWorkspaceRoot()) throw new CheckWorkspaceRootError();
|
|
992
|
+
if (options.vitestConfig === void 0) {
|
|
993
|
+
const runner = await deps.detectRunner();
|
|
994
|
+
if (runner.kind !== "vitest") throw new CheckRunnerError(runner);
|
|
995
|
+
}
|
|
996
|
+
const [changed, untracked] = await Promise.all([
|
|
997
|
+
deps.listChangedFiles(base.ref),
|
|
998
|
+
deps.listUntrackedFiles()
|
|
999
|
+
]);
|
|
1000
|
+
if (changed === null || untracked === null) {
|
|
1001
|
+
throw new GitRefError(
|
|
1002
|
+
`Could not compare the working tree with ${base.ref}`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
const mutable = (file) => exclusionBeforeDiff(file, defaultIsTestFile) === null;
|
|
1006
|
+
const hunks = /* @__PURE__ */ new Map();
|
|
1007
|
+
const formatting = /* @__PURE__ */ new Set();
|
|
1008
|
+
const contents = /* @__PURE__ */ new Map();
|
|
1009
|
+
await mapLimit(changed.filter(mutable), 8, async (file) => {
|
|
1010
|
+
const diff = await deps.readDiff(base.ref, file);
|
|
1011
|
+
if (diff === null) return;
|
|
1012
|
+
const fileHunks = parseZeroContextDiff(diff);
|
|
1013
|
+
hunks.set(file, fileHunks);
|
|
1014
|
+
if (fileHunks.added.length === 0) return;
|
|
1015
|
+
const [before, after] = await Promise.all([
|
|
1016
|
+
deps.readFileAtBase(base.ref, file),
|
|
1017
|
+
deps.readFile(file)
|
|
1018
|
+
]);
|
|
1019
|
+
if (after !== null) contents.set(file, after);
|
|
1020
|
+
if (before !== null && after !== null && await deps.isFormattingOnly(file, before, after).catch(() => false)) {
|
|
1021
|
+
formatting.add(file);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
const newFiles = untracked.filter(
|
|
1025
|
+
(file) => isNewSource(file) && mutable(file)
|
|
1026
|
+
);
|
|
1027
|
+
await mapLimit(newFiles, 8, async (file) => {
|
|
1028
|
+
const content = await deps.readFile(file);
|
|
1029
|
+
if (content === null) return;
|
|
1030
|
+
contents.set(file, content);
|
|
1031
|
+
const lines2 = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
1032
|
+
if (lines2 > 0) {
|
|
1033
|
+
hunks.set(file, { added: [{ start: 1, end: lines2 }], deletedLines: 0 });
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
const { targets, excluded, deletedLines } = selectTargets({
|
|
1037
|
+
changed,
|
|
1038
|
+
untracked,
|
|
1039
|
+
hunks,
|
|
1040
|
+
formattingOnly: formatting,
|
|
1041
|
+
isTestFile: defaultIsTestFile
|
|
1042
|
+
});
|
|
1043
|
+
const assembleBase = {
|
|
1044
|
+
base,
|
|
1045
|
+
targets,
|
|
1046
|
+
excluded,
|
|
1047
|
+
deletedLines,
|
|
1048
|
+
...options.vitestConfig !== void 0 && {
|
|
1049
|
+
vitestConfig: options.vitestConfig
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
if (targets.length === 0) {
|
|
1053
|
+
return assembleCheckResult({
|
|
1054
|
+
...assembleBase,
|
|
1055
|
+
tested: /* @__PURE__ */ new Map(),
|
|
1056
|
+
snapshot: /* @__PURE__ */ new Map(),
|
|
1057
|
+
durationMs: deps.now() - startedAt
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
1061
|
+
await Promise.all(
|
|
1062
|
+
targets.map(async (target) => {
|
|
1063
|
+
snapshot.set(
|
|
1064
|
+
target.file,
|
|
1065
|
+
contents.get(target.file) ?? await deps.readFile(target.file) ?? ""
|
|
1066
|
+
);
|
|
1067
|
+
})
|
|
1068
|
+
);
|
|
1069
|
+
const symbols = /* @__PURE__ */ new Map();
|
|
1070
|
+
if (deps.symbols !== void 0) {
|
|
1071
|
+
for (const [file, content] of snapshot) {
|
|
1072
|
+
const lookup = await deps.symbols(file, content).catch(() => void 0);
|
|
1073
|
+
if (lookup !== void 0) symbols.set(file, lookup);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const deadline = startedAt + options.budgetMs;
|
|
1077
|
+
const run = deps.backend.start({
|
|
1078
|
+
cwd,
|
|
1079
|
+
mutate: mutateEntries(targets),
|
|
1080
|
+
...options.vitestConfig !== void 0 && {
|
|
1081
|
+
vitestConfig: options.vitestConfig
|
|
1082
|
+
},
|
|
1083
|
+
// The engine's own limit on the baseline, kept inside the budget so
|
|
1084
|
+
// that the budget is what stops a slow one, with a reason.
|
|
1085
|
+
baselineTimeoutMinutes: Math.max(1, Math.ceil(options.budgetMs / 6e4))
|
|
1086
|
+
});
|
|
1087
|
+
let baselineTests;
|
|
1088
|
+
let plan;
|
|
1089
|
+
let coverageMeasured = true;
|
|
1090
|
+
let selection;
|
|
1091
|
+
const tested = /* @__PURE__ */ new Map();
|
|
1092
|
+
let stopReason;
|
|
1093
|
+
let failure;
|
|
1094
|
+
const engineNotes = [];
|
|
1095
|
+
const countedDone = () => selection !== void 0 && [...selection.selected, ...selection.free].every((id) => tested.has(id));
|
|
1096
|
+
const iterator = run.events[Symbol.asyncIterator]();
|
|
1097
|
+
try {
|
|
1098
|
+
for (; ; ) {
|
|
1099
|
+
const next = await nextOrStop(
|
|
1100
|
+
iterator,
|
|
1101
|
+
deadline - deps.now(),
|
|
1102
|
+
options.signal
|
|
1103
|
+
);
|
|
1104
|
+
if (next === "budget" || next === "interrupted") {
|
|
1105
|
+
stopReason = next;
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
if (next.done === true) break;
|
|
1109
|
+
const event = next.value;
|
|
1110
|
+
if (event.kind === "baseline") baselineTests = event.tests;
|
|
1111
|
+
else if (event.kind === "plan") {
|
|
1112
|
+
plan = event.mutants;
|
|
1113
|
+
coverageMeasured = event.coverageMeasured;
|
|
1114
|
+
selection = selectMutants(plan, options.maxMutants);
|
|
1115
|
+
} else if (event.kind === "tested")
|
|
1116
|
+
tested.set(event.mutant.id, event.mutant);
|
|
1117
|
+
else if (event.kind === "note") engineNotes.push(event.text);
|
|
1118
|
+
else if (event.kind === "finished") break;
|
|
1119
|
+
else {
|
|
1120
|
+
failure = event;
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
if (countedDone() && selection !== void 0) {
|
|
1124
|
+
if (selection.overLimit.length > 0) stopReason = "limit";
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
} finally {
|
|
1129
|
+
await run.stop();
|
|
1130
|
+
}
|
|
1131
|
+
if (options.signal?.aborted === true) stopReason = "interrupted";
|
|
1132
|
+
else if (failure?.kind === "baseline-failed") {
|
|
1133
|
+
throw new BaselineFailedError(failure.failingTests, failure.output);
|
|
1134
|
+
} else if (failure?.kind === "error") {
|
|
1135
|
+
throw new CheckBackendError(failure.message, failure.output);
|
|
1136
|
+
} else if (stopReason === void 0 && plan === void 0) {
|
|
1137
|
+
throw new CheckBackendError(
|
|
1138
|
+
"The mutation-testing engine stopped without a result",
|
|
1139
|
+
""
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
const notes = [...engineNotes];
|
|
1143
|
+
await Promise.all(
|
|
1144
|
+
targets.map(async (target) => {
|
|
1145
|
+
const now = await deps.readFile(target.file);
|
|
1146
|
+
if (digest(now) !== digest(snapshot.get(target.file) ?? null)) {
|
|
1147
|
+
notes.push(
|
|
1148
|
+
`${target.file} changed while variant check ran; the report quotes it as it was when the run started.`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
})
|
|
1152
|
+
);
|
|
1153
|
+
return assembleCheckResult({
|
|
1154
|
+
...assembleBase,
|
|
1155
|
+
...baselineTests !== void 0 && { baselineTests },
|
|
1156
|
+
...plan !== void 0 && { plan },
|
|
1157
|
+
...selection !== void 0 && { selection },
|
|
1158
|
+
tested,
|
|
1159
|
+
coverageMeasured,
|
|
1160
|
+
...stopReason !== void 0 && { stopReason },
|
|
1161
|
+
snapshot,
|
|
1162
|
+
symbols,
|
|
1163
|
+
notes: notes.sort(),
|
|
1164
|
+
durationMs: deps.now() - startedAt
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/cli/render/check.ts
|
|
1169
|
+
var EXCLUSION = {
|
|
1170
|
+
test: "a test file",
|
|
1171
|
+
declaration: "a declaration file",
|
|
1172
|
+
"not-typescript": "not TypeScript",
|
|
1173
|
+
outside: "outside this directory",
|
|
1174
|
+
"deleted-lines-only": "only lines removed, which cannot be mutated",
|
|
1175
|
+
"formatting-only": "only formatting or comments changed"
|
|
1176
|
+
};
|
|
1177
|
+
var MAX_EXCLUDED = 10;
|
|
1178
|
+
function exclusionRank(reason) {
|
|
1179
|
+
if (reason === "outside") return 2;
|
|
1180
|
+
if (reason === "not-typescript") return 1;
|
|
1181
|
+
return 0;
|
|
1182
|
+
}
|
|
1183
|
+
var INCONCLUSIVE = {
|
|
1184
|
+
timeout: "timed out",
|
|
1185
|
+
"runtime-error": "the tests errored",
|
|
1186
|
+
"compile-error": "did not compile"
|
|
1187
|
+
};
|
|
1188
|
+
function shortRef(ref) {
|
|
1189
|
+
return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 12) : ref;
|
|
1190
|
+
}
|
|
1191
|
+
function seconds(ms) {
|
|
1192
|
+
return `${(ms / 1e3).toFixed(1)} s`;
|
|
1193
|
+
}
|
|
1194
|
+
function where(mutant) {
|
|
1195
|
+
const at = `${mutant.file}:${mutant.start.line}:${mutant.start.column}`;
|
|
1196
|
+
return mutant.symbol === void 0 ? at : `${at} in ${mutant.symbol}`;
|
|
1197
|
+
}
|
|
1198
|
+
function oneLine(text) {
|
|
1199
|
+
return text.replace(/\s+/g, " ").trim();
|
|
1200
|
+
}
|
|
1201
|
+
function ranAgainstTests(result) {
|
|
1202
|
+
return result.counts.attempted - result.counts["no-coverage"];
|
|
1203
|
+
}
|
|
1204
|
+
function stopped(result) {
|
|
1205
|
+
const tried = ranAgainstTests(result);
|
|
1206
|
+
const planned = result.mutants.filter(
|
|
1207
|
+
(mutant) => mutant.outcome !== "no-coverage"
|
|
1208
|
+
).length;
|
|
1209
|
+
switch (result.stopReason) {
|
|
1210
|
+
case "limit":
|
|
1211
|
+
return `stopped at --max-mutants after ${plural(tried, "mutation")}; ${result.counts["not-run"].toLocaleString("en-US")} more were not run`;
|
|
1212
|
+
case "budget":
|
|
1213
|
+
return result.baselineFinished ? `the --budget ran out after ${tried} of ${plural(planned, "mutation")}` : "the --budget ran out before the tests finished their first, unmutated run";
|
|
1214
|
+
case "interrupted":
|
|
1215
|
+
return `interrupted after ${tried} of ${plural(planned, "mutation")}`;
|
|
1216
|
+
default:
|
|
1217
|
+
return `${result.counts["not-run"].toLocaleString("en-US")} of ${plural(planned, "mutation")} were not run`;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function renderCheck(result, printer = getPrinter()) {
|
|
1221
|
+
const colors = getColors();
|
|
1222
|
+
const base = result.base.label === result.base.ref ? shortRef(result.base.ref) : `${shortRef(result.base.ref)} (${result.base.label.replace(/^HEAD: /, "")})`;
|
|
1223
|
+
lines(printer, "", `Compared with ${base}.`);
|
|
1224
|
+
if (result.files.length > 0) {
|
|
1225
|
+
lines(printer, "", "Mutated:");
|
|
1226
|
+
for (const file of result.files) {
|
|
1227
|
+
const symbols = file.symbols.length > 0 ? ` (${file.symbols.join(", ")})` : "";
|
|
1228
|
+
const lineCount = file.untracked ? `new, untracked: ${plural(file.changedLines, "line")}` : plural(file.changedLines, "changed line");
|
|
1229
|
+
lines(
|
|
1230
|
+
printer,
|
|
1231
|
+
` ${file.file} ${lineCount}, ${plural(file.mutants, "mutation")}${symbols}`
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (result.excluded.length > 0) {
|
|
1236
|
+
lines(printer, "", "Not mutated:");
|
|
1237
|
+
const ordered = [...result.excluded].sort(
|
|
1238
|
+
(a, b) => exclusionRank(a.reason) - exclusionRank(b.reason)
|
|
1239
|
+
);
|
|
1240
|
+
for (const exclusion of ordered.slice(0, MAX_EXCLUDED)) {
|
|
1241
|
+
lines(printer, ` ${exclusion.file} ${EXCLUSION[exclusion.reason]}`);
|
|
1242
|
+
}
|
|
1243
|
+
const more = result.excluded.length - MAX_EXCLUDED;
|
|
1244
|
+
if (more > 0) {
|
|
1245
|
+
lines(
|
|
1246
|
+
printer,
|
|
1247
|
+
` \u2026 and ${more.toLocaleString("en-US")} more (all of them in --json)`
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (result.status === "nothing-to-mutate") {
|
|
1252
|
+
lines(
|
|
1253
|
+
printer,
|
|
1254
|
+
"",
|
|
1255
|
+
result.excluded.some(
|
|
1256
|
+
(exclusion) => exclusion.reason === "formatting-only"
|
|
1257
|
+
) ? "Nothing to check: the TypeScript here changed only in formatting or comments." : "Nothing to check: no TypeScript source line changed here, so there is nothing to mutate."
|
|
1258
|
+
);
|
|
1259
|
+
renderFooter(result, printer);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const undetected = result.mutants.filter((m) => m.outcome === "undetected");
|
|
1263
|
+
if (undetected.length > 0) {
|
|
1264
|
+
lines(
|
|
1265
|
+
printer,
|
|
1266
|
+
"",
|
|
1267
|
+
colors.bold(colors.yellow(`Undetected: ${undetected.length}`))
|
|
1268
|
+
);
|
|
1269
|
+
for (const mutant of undetected) {
|
|
1270
|
+
lines(
|
|
1271
|
+
printer,
|
|
1272
|
+
` ${where(mutant)}`,
|
|
1273
|
+
` ${colors.red(`- ${oneLine(mutant.original)}`)}`,
|
|
1274
|
+
` ${colors.green(`+ ${oneLine(mutant.replacement)}`)}`,
|
|
1275
|
+
` ${mutant.explanation ?? ""}`,
|
|
1276
|
+
...mutant.static === true ? [
|
|
1277
|
+
" This runs when its module loads, so every test ran against it."
|
|
1278
|
+
] : []
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const unreached = result.mutants.filter((m) => m.outcome === "no-coverage");
|
|
1283
|
+
if (unreached.length > 0) {
|
|
1284
|
+
lines(
|
|
1285
|
+
printer,
|
|
1286
|
+
"",
|
|
1287
|
+
colors.bold(colors.yellow(`No test reached: ${unreached.length}`)),
|
|
1288
|
+
" No test executed this code in the test process, so none could notice a change to it.",
|
|
1289
|
+
...(result.baseline?.skipped ?? 0) > 0 ? [
|
|
1290
|
+
` ${plural(result.baseline?.skipped ?? 0, "test")} were skipped; code only they would reach reads as reached by no test.`
|
|
1291
|
+
] : []
|
|
1292
|
+
);
|
|
1293
|
+
for (const mutant of unreached) {
|
|
1294
|
+
lines(
|
|
1295
|
+
printer,
|
|
1296
|
+
` ${where(mutant)} ${oneLine(mutant.original)} \u2192 ${oneLine(mutant.replacement)}`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const inconclusive = result.mutants.filter(
|
|
1301
|
+
(m) => INCONCLUSIVE[m.outcome] !== void 0
|
|
1302
|
+
);
|
|
1303
|
+
if (inconclusive.length > 0) {
|
|
1304
|
+
lines(
|
|
1305
|
+
printer,
|
|
1306
|
+
"",
|
|
1307
|
+
`Inconclusive: ${inconclusive.length} (neither detected nor undetected)`
|
|
1308
|
+
);
|
|
1309
|
+
for (const mutant of inconclusive) {
|
|
1310
|
+
const reason = mutant.reason === void 0 ? "" : `: ${oneLine(mutant.reason)}`;
|
|
1311
|
+
lines(
|
|
1312
|
+
printer,
|
|
1313
|
+
` ${where(mutant)} ${oneLine(mutant.original)} \u2192 ${oneLine(mutant.replacement)} ${INCONCLUSIVE[mutant.outcome]}${reason}`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
lines(printer, "");
|
|
1318
|
+
const { counts } = result;
|
|
1319
|
+
if (result.status === "incomplete") {
|
|
1320
|
+
lines(
|
|
1321
|
+
printer,
|
|
1322
|
+
colors.bold(`Incomplete: ${stopped(result)}.`),
|
|
1323
|
+
"Nothing here says how the rest of the change would fare."
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
if (counts.attempted === 0 && result.status === "complete") {
|
|
1327
|
+
lines(
|
|
1328
|
+
printer,
|
|
1329
|
+
"The changed lines hold no comparison, condition or arithmetic to mutate."
|
|
1330
|
+
);
|
|
1331
|
+
} else if (allDetected(result)) {
|
|
1332
|
+
lines(
|
|
1333
|
+
printer,
|
|
1334
|
+
colors.green(
|
|
1335
|
+
counts.attempted === 1 ? "The one mutation tried was detected by the tests that ran." : `All ${plural(counts.attempted, "mutation")} were detected by the tests that ran.`
|
|
1336
|
+
),
|
|
1337
|
+
"That is evidence these tests check these lines, not proof the change is correct."
|
|
1338
|
+
);
|
|
1339
|
+
} else if (counts.attempted > 0) {
|
|
1340
|
+
const ran = ranAgainstTests(result);
|
|
1341
|
+
const unreachedCount = counts["no-coverage"];
|
|
1342
|
+
const outcomes = [
|
|
1343
|
+
`${counts.detected.toLocaleString("en-US")} detected`,
|
|
1344
|
+
`${counts.undetected.toLocaleString("en-US")} undetected`,
|
|
1345
|
+
...inconclusive.length > 0 ? [`${inconclusive.length.toLocaleString("en-US")} inconclusive`] : []
|
|
1346
|
+
].join(", ");
|
|
1347
|
+
const one = unreachedCount === 1;
|
|
1348
|
+
const unreachedPart = `not run because no test executes ${one ? "its" : "their"} code (${one ? "it does" : "they do"} not count toward --max-mutants)`;
|
|
1349
|
+
if (unreachedCount === 0) {
|
|
1350
|
+
lines(
|
|
1351
|
+
printer,
|
|
1352
|
+
`${plural(ran, "mutation")} run against the tests: ${outcomes}.`
|
|
1353
|
+
);
|
|
1354
|
+
} else if (ran === 0) {
|
|
1355
|
+
lines(
|
|
1356
|
+
printer,
|
|
1357
|
+
`${plural(unreachedCount, "mutation")}, none run: no test executes ${one ? "its" : "their"} code (${one ? "it does" : "they do"} not count toward --max-mutants).`
|
|
1358
|
+
);
|
|
1359
|
+
} else {
|
|
1360
|
+
lines(
|
|
1361
|
+
printer,
|
|
1362
|
+
`${plural(counts.attempted, "mutation")}: ${ran.toLocaleString("en-US")} run against the tests (${outcomes}), ${unreachedCount.toLocaleString("en-US")} ${unreachedPart}.`
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
renderFooter(result, printer);
|
|
1367
|
+
}
|
|
1368
|
+
function renderFooter(result, printer) {
|
|
1369
|
+
const scope = [];
|
|
1370
|
+
if (result.baseline !== void 0) {
|
|
1371
|
+
const config = result.vitestConfig === void 0 ? "the Vitest config here" : result.vitestConfig;
|
|
1372
|
+
scope.push(
|
|
1373
|
+
`the tests passed unmutated first (${plural(result.baseline.tests, "test")} in ${plural(result.baseline.files, "file")}${result.baseline.skipped > 0 ? `, ${plural(result.baseline.skipped, "test")} skipped` : ""}, from ${config}); ${result.coverageMeasured ? "each mutation then ran against the tests that executed its code" : "which test executes what could not be measured, so each mutation ran against every test and none could be found unreached"}`
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
if (result.status !== "nothing-to-mutate") {
|
|
1377
|
+
scope.push(
|
|
1378
|
+
"only comparisons, boolean conditions and arithmetic were mutated, and mutations are not type-checked"
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
if (result.deletedLines > 0) {
|
|
1382
|
+
scope.push(
|
|
1383
|
+
`${plural(result.deletedLines, "deleted line")} could not be mutated`
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
if (scope.length > 0) lines(printer, "", `Scope: ${scope.join("; ")}.`);
|
|
1387
|
+
for (const note of result.notes) lines(printer, `Note: ${note}`);
|
|
1388
|
+
lines(printer, `Time: ${seconds(result.durationMs)}`);
|
|
1389
|
+
}
|
|
1390
|
+
function renderCheckJson(result, printer = getPrinter()) {
|
|
1391
|
+
lines(
|
|
1392
|
+
printer,
|
|
1393
|
+
JSON.stringify({ ...result, allDetected: allDetected(result) }, null, 2)
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// src/cli/commands/check.ts
|
|
1398
|
+
function parseMaxMutants(value) {
|
|
1399
|
+
if (value === void 0) return DEFAULT_MAX_MUTANTS;
|
|
1400
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
1401
|
+
throw new CliUsageError(
|
|
1402
|
+
`--max-mutants takes a whole number of at least 1, not "${value}"`
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
return Number(value);
|
|
1406
|
+
}
|
|
1407
|
+
function parseBudget(value) {
|
|
1408
|
+
if (value === void 0) return DEFAULT_BUDGET_MS;
|
|
1409
|
+
const match = /^(\d+)(s|m)?$/.exec(value.trim());
|
|
1410
|
+
if (match === null || Number(match[1]) < 1) {
|
|
1411
|
+
throw new CliUsageError(
|
|
1412
|
+
`--budget takes seconds (300, 90s) or minutes (5m), not "${value}"`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
return Number(match[1]) * (match[2] === "m" ? 6e4 : 1e3);
|
|
1416
|
+
}
|
|
1417
|
+
function hasFindings(result) {
|
|
1418
|
+
return result.status === "incomplete" || result.counts.undetected > 0 || result.counts["no-coverage"] > 0;
|
|
1419
|
+
}
|
|
1420
|
+
async function registerCheckAction(opts = {}, backend = createStrykerBackend()) {
|
|
1421
|
+
const maxMutants = parseMaxMutants(opts.maxMutants);
|
|
1422
|
+
const budgetMs = parseBudget(opts.budget);
|
|
1423
|
+
const controller = new AbortController();
|
|
1424
|
+
const onInterrupt = () => {
|
|
1425
|
+
if (controller.signal.aborted) process.exit(130);
|
|
1426
|
+
errorLines(getPrinter(), "variant check: stopping\u2026");
|
|
1427
|
+
controller.abort();
|
|
1428
|
+
};
|
|
1429
|
+
process.on("SIGINT", onInterrupt);
|
|
1430
|
+
let result;
|
|
1431
|
+
try {
|
|
1432
|
+
result = await runCheck(
|
|
1433
|
+
process.cwd(),
|
|
1434
|
+
{
|
|
1435
|
+
maxMutants,
|
|
1436
|
+
budgetMs,
|
|
1437
|
+
signal: controller.signal,
|
|
1438
|
+
...opts.base !== void 0 && { base: opts.base },
|
|
1439
|
+
...opts.vitestConfig !== void 0 && {
|
|
1440
|
+
vitestConfig: opts.vitestConfig
|
|
1441
|
+
}
|
|
1442
|
+
},
|
|
1443
|
+
checkDeps(process.cwd(), backend)
|
|
1444
|
+
);
|
|
1445
|
+
} finally {
|
|
1446
|
+
process.off("SIGINT", onInterrupt);
|
|
1447
|
+
}
|
|
1448
|
+
if (opts.json === true) renderCheckJson(result);
|
|
1449
|
+
else renderCheck(result);
|
|
1450
|
+
if (opts.failOnSurvivors === true && hasFindings(result)) {
|
|
1451
|
+
process.exitCode = 1;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
export {
|
|
1455
|
+
hasFindings,
|
|
1456
|
+
parseBudget,
|
|
1457
|
+
parseMaxMutants,
|
|
1458
|
+
registerCheckAction
|
|
1459
|
+
};
|