@sackville-mcp/mutate 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/index.d.mts +347 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +793 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +33 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { parse, stringify } from "smol-toml";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* Diff-scoping config EMITTERS for the Python mutation tools (ADR 0010 addendum 2). These turn a
|
|
9
|
+
* selected-file scope into a per-run tool config, PINNED to the slice-0 captures of the installed
|
|
10
|
+
* cosmic-ray 8.4.6 / mutmut 3.5.0 (see `test/fixtures/README.md`) — never doc-derived guesses:
|
|
11
|
+
*
|
|
12
|
+
* - cosmic-ray scopes via a `module-path` FILE LIST (Fork A — verified 8.4.6 accepts a list); its
|
|
13
|
+
* `excluded-modules` SUBTRACTS from the scope via exact path AND fnmatch glob, so an inherited
|
|
14
|
+
* exclusion that matches a selected file is reconciled (stripped), never copied blind (blocker #3).
|
|
15
|
+
* - mutmut scopes via `paths_to_mutate` (Fork B was WRONG — 3.5.0 has NO `only_mutate`/`source_paths`;
|
|
16
|
+
* `paths_to_mutate` + `do_not_mutate` are the real keys, Fork F). Scoping a subset breaks the
|
|
17
|
+
* baseline unless the rest of the source tree is `also_copy`'d so unscoped tests still import; an
|
|
18
|
+
* inherited `do_not_mutate` glob matching a selected file is stripped (blocker #3, mutmut form).
|
|
19
|
+
*
|
|
20
|
+
* Both emitters are PURE (TOML in → TOML out via `smol-toml`). They reduce SPURIOUS inconclusives by
|
|
21
|
+
* reconciling exclusions up front; the load-bearing under-scope guarantee is the POST-SPAWN
|
|
22
|
+
* {@link reconcileScope} guard in the runner, which folds any genuinely-unmutated selected file to
|
|
23
|
+
* inconclusive regardless of why (absence-is-never-a-pass).
|
|
24
|
+
*/
|
|
25
|
+
/** Thrown when a scoped config cannot be safely synthesized (empty scope, missing base table, etc.). */
|
|
26
|
+
var ScopeEmitError = class extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ScopeEmitError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** Normalize a path to a comparable repo-relative POSIX form (backslashes → `/`, drop a leading `./`). */
|
|
33
|
+
function normalizePath$1(p) {
|
|
34
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
35
|
+
}
|
|
36
|
+
/** Dedupe + normalize + sort, the canonical scope form used everywhere. */
|
|
37
|
+
function canonicalize(paths) {
|
|
38
|
+
return [...new Set(paths.map(normalizePath$1))].sort();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Translate a Python `fnmatch` pattern to an anchored RegExp. Mirrors `fnmatch.translate`: a star →
|
|
42
|
+
* `.*` (crosses `/`, unlike shell glob — so a `star + /strutil.py` glob matches `pkg/strutil.py`),
|
|
43
|
+
* `?` → any one char, `[seq]`/`[!seq]` → char class, everything else escaped. This is the matcher
|
|
44
|
+
* cosmic-ray's `excluded-modules` and mutmut's `do_not_mutate` both use (the latter via
|
|
45
|
+
* `fnmatch.fnmatch` in `Config.should_ignore_for_mutation`).
|
|
46
|
+
*/
|
|
47
|
+
function fnmatchToRegExp(pattern) {
|
|
48
|
+
let re = "";
|
|
49
|
+
let i = 0;
|
|
50
|
+
const n = pattern.length;
|
|
51
|
+
while (i < n) {
|
|
52
|
+
const c = pattern[i++];
|
|
53
|
+
if (c === "*") re += ".*";
|
|
54
|
+
else if (c === "?") re += ".";
|
|
55
|
+
else if (c === "[") {
|
|
56
|
+
let j = i;
|
|
57
|
+
if (j < n && (pattern[j] === "!" || pattern[j] === "]")) j++;
|
|
58
|
+
while (j < n && pattern[j] !== "]") j++;
|
|
59
|
+
if (j >= n) re += "\\[";
|
|
60
|
+
else {
|
|
61
|
+
let stuff = pattern.slice(i, j).replace(/\\/g, "\\\\");
|
|
62
|
+
i = j + 1;
|
|
63
|
+
if (stuff.startsWith("!")) stuff = `^${stuff.slice(1)}`;
|
|
64
|
+
else if (stuff.startsWith("^")) stuff = `\\${stuff}`;
|
|
65
|
+
re += `[${stuff}]`;
|
|
66
|
+
}
|
|
67
|
+
} else re += (c ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
}
|
|
69
|
+
return new RegExp(`^(?:${re})$`, "s");
|
|
70
|
+
}
|
|
71
|
+
/** True if an exclusion entry (exact path or fnmatch glob) matches any of the selected files. */
|
|
72
|
+
function exclusionCollides(entry, selected) {
|
|
73
|
+
const norm = normalizePath$1(entry);
|
|
74
|
+
if (selected.includes(norm)) return true;
|
|
75
|
+
const re = fnmatchToRegExp(norm);
|
|
76
|
+
return selected.some((f) => re.test(f));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The operator's declared source tree, read from a cosmic-ray base config's `module-path` (a single
|
|
80
|
+
* string or a list). Used as the default `ownedRoots` to confine the diff scope (Fork C): a changed
|
|
81
|
+
* `.py` outside this tree is `unmatched` (report-gap), never silently scoped. Pure.
|
|
82
|
+
*/
|
|
83
|
+
function cosmicModulePathRoots(baseToml) {
|
|
84
|
+
const cr = parse(baseToml)["cosmic-ray"];
|
|
85
|
+
if (!cr || typeof cr !== "object") return [];
|
|
86
|
+
const mp = cr["module-path"];
|
|
87
|
+
if (typeof mp === "string") return [normalizePath$1(mp)];
|
|
88
|
+
if (Array.isArray(mp)) return mp.map((x) => normalizePath$1(String(x)));
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Synthesize a per-run cosmic-ray config from a base config + the selected files. Overrides
|
|
93
|
+
* `module-path` to the canonical selected FILE LIST and reconciles `excluded-modules`: any entry
|
|
94
|
+
* (exact or fnmatch glob) that matches a selected file is stripped (it would otherwise silently
|
|
95
|
+
* subtract that file — blocker #3); non-colliding entries are preserved. All other keys/tables
|
|
96
|
+
* (`timeout`, `test-command`, `[cosmic-ray.distributor]`, …) are preserved verbatim. Pure.
|
|
97
|
+
*/
|
|
98
|
+
function synthesizeScopedCosmicRayConfig(baseToml, selectedFiles) {
|
|
99
|
+
const selected = canonicalize(selectedFiles);
|
|
100
|
+
if (selected.length === 0) throw new ScopeEmitError("cannot synthesize a scoped cosmic-ray config for an empty selection");
|
|
101
|
+
const data = parse(baseToml);
|
|
102
|
+
const cr = data["cosmic-ray"];
|
|
103
|
+
if (cr === void 0 || typeof cr !== "object") throw new ScopeEmitError("base config has no [cosmic-ray] table");
|
|
104
|
+
const table = cr;
|
|
105
|
+
table["module-path"] = selected;
|
|
106
|
+
const strippedExclusions = [];
|
|
107
|
+
table["excluded-modules"] = (Array.isArray(table["excluded-modules"]) ? table["excluded-modules"].map(String) : []).filter((entry) => {
|
|
108
|
+
if (exclusionCollides(entry, selected)) {
|
|
109
|
+
strippedExclusions.push(entry);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
toml: stringify(data),
|
|
116
|
+
modulePath: selected,
|
|
117
|
+
strippedExclusions
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** The `[tool.mutmut]` table of a pyproject (empty when absent / unparseable-as-table). */
|
|
121
|
+
function mutmutTable(basePyproject) {
|
|
122
|
+
if (!basePyproject.trim()) return {};
|
|
123
|
+
const tool = parse(basePyproject).tool;
|
|
124
|
+
if (!tool || typeof tool !== "object") return {};
|
|
125
|
+
const m = tool.mutmut;
|
|
126
|
+
return m && typeof m === "object" ? m : {};
|
|
127
|
+
}
|
|
128
|
+
/** The operator's `[tool.mutmut] paths_to_mutate` — the default `ownedRoots` for a scoped mutmut run. */
|
|
129
|
+
function mutmutPathsToMutate(basePyproject) {
|
|
130
|
+
const v = mutmutTable(basePyproject).paths_to_mutate;
|
|
131
|
+
return Array.isArray(v) ? v.map((x) => normalizePath$1(String(x))) : [];
|
|
132
|
+
}
|
|
133
|
+
/** The operator's inherited `[tool.mutmut] do_not_mutate` globs (reconciled against the scope). */
|
|
134
|
+
function mutmutDoNotMutate(basePyproject) {
|
|
135
|
+
const v = mutmutTable(basePyproject).do_not_mutate;
|
|
136
|
+
return Array.isArray(v) ? v.map(String) : [];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Plan a scoped mutmut run. `paths_to_mutate` = the selected files; `also_copy` = every other source
|
|
140
|
+
* file in `allSourceFiles` (so a test importing an unscoped sibling module still resolves in mutmut's
|
|
141
|
+
* `mutants/` sandbox — verified necessary in slice 0). An inherited `do_not_mutate` glob that matches
|
|
142
|
+
* a selected file is stripped (it would otherwise exclude a file we deliberately scoped). Pure given
|
|
143
|
+
* `allSourceFiles` (the runner derives it by walking the owned roots).
|
|
144
|
+
*/
|
|
145
|
+
function planMutmutScope(selectedFiles, allSourceFiles, inheritedDoNotMutate = []) {
|
|
146
|
+
const pathsToMutate = canonicalize(selectedFiles);
|
|
147
|
+
if (pathsToMutate.length === 0) throw new ScopeEmitError("cannot plan a scoped mutmut run for an empty selection");
|
|
148
|
+
const scope = new Set(pathsToMutate);
|
|
149
|
+
const alsoCopy = canonicalize(allSourceFiles).filter((f) => !scope.has(f));
|
|
150
|
+
const strippedDoNotMutate = [];
|
|
151
|
+
return {
|
|
152
|
+
pathsToMutate,
|
|
153
|
+
alsoCopy,
|
|
154
|
+
doNotMutate: inheritedDoNotMutate.filter((entry) => {
|
|
155
|
+
if (exclusionCollides(entry, pathsToMutate)) {
|
|
156
|
+
strippedDoNotMutate.push(entry);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}),
|
|
161
|
+
strippedDoNotMutate
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Render a `pyproject.toml` for a scoped mutmut run: merge the {@link MutmutScopePlan}'s
|
|
166
|
+
* `paths_to_mutate`/`also_copy` (+ reconciled `do_not_mutate`) into the base pyproject's
|
|
167
|
+
* `[tool.mutmut]` table (Fork F: only slice-0-verified keys), preserving every other section and any
|
|
168
|
+
* other verified `[tool.mutmut]` key the operator set (e.g. `pytest_add_cli_args`). An empty
|
|
169
|
+
* `do_not_mutate` is omitted entirely. Pure.
|
|
170
|
+
*/
|
|
171
|
+
function synthesizeScopedMutmutPyproject(basePyproject, plan) {
|
|
172
|
+
const data = basePyproject.trim() ? parse(basePyproject) : {};
|
|
173
|
+
const tool = data.tool && typeof data.tool === "object" ? data.tool : {};
|
|
174
|
+
const mutmut = tool.mutmut && typeof tool.mutmut === "object" ? tool.mutmut : {};
|
|
175
|
+
mutmut.paths_to_mutate = plan.pathsToMutate;
|
|
176
|
+
mutmut.also_copy = plan.alsoCopy;
|
|
177
|
+
if (plan.doNotMutate.length > 0) mutmut.do_not_mutate = plan.doNotMutate;
|
|
178
|
+
else delete mutmut.do_not_mutate;
|
|
179
|
+
tool.mutmut = mutmut;
|
|
180
|
+
data.tool = tool;
|
|
181
|
+
return stringify(data);
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/cosmic-ray.ts
|
|
185
|
+
/** worker_outcome (other than `normal`) → status. `normal` defers to test_outcome. */
|
|
186
|
+
const WORKER_STATUS = {
|
|
187
|
+
no_test: "NoCoverage",
|
|
188
|
+
skipped: "Ignored",
|
|
189
|
+
exception: "RuntimeError",
|
|
190
|
+
abnormal: "RuntimeError",
|
|
191
|
+
timeout: "Timeout"
|
|
192
|
+
};
|
|
193
|
+
/** test_outcome (when worker_outcome === 'normal') → status. */
|
|
194
|
+
const TEST_STATUS = {
|
|
195
|
+
killed: "Killed",
|
|
196
|
+
survived: "Survived",
|
|
197
|
+
incompetent: "RuntimeError"
|
|
198
|
+
};
|
|
199
|
+
function statusOf(result) {
|
|
200
|
+
if (result === null) return "Pending";
|
|
201
|
+
const worker = result.worker_outcome;
|
|
202
|
+
if (worker === "normal") return TEST_STATUS[result.test_outcome ?? ""] ?? "Pending";
|
|
203
|
+
return worker !== void 0 && WORKER_STATUS[worker] || "Pending";
|
|
204
|
+
}
|
|
205
|
+
function lineColOf(pos) {
|
|
206
|
+
if (Array.isArray(pos)) return {
|
|
207
|
+
line: pos[0],
|
|
208
|
+
column: pos[1]
|
|
209
|
+
};
|
|
210
|
+
if (pos && typeof pos.line === "number") return {
|
|
211
|
+
line: pos.line,
|
|
212
|
+
column: pos.column
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Parse `cosmic-ray dump <session.sqlite>` JSON-lines into a mutation-testing-elements report. Pure. */
|
|
216
|
+
function parseCosmicRayDump(jsonl) {
|
|
217
|
+
const files = {};
|
|
218
|
+
let index = 0;
|
|
219
|
+
for (const raw of jsonl.split(/\r?\n/)) {
|
|
220
|
+
const line = raw.trim();
|
|
221
|
+
if (!line) continue;
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(line);
|
|
225
|
+
} catch {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const [workItem, workResult] = parsed;
|
|
229
|
+
const mutation = workItem?.mutations?.[0];
|
|
230
|
+
const path = mutation?.module_path;
|
|
231
|
+
if (!path) continue;
|
|
232
|
+
const status = statusOf(workResult ?? null);
|
|
233
|
+
const loc = lineColOf(mutation.start_pos);
|
|
234
|
+
const mutant = {
|
|
235
|
+
id: `${path}:${index}`,
|
|
236
|
+
mutatorName: mutation.operator_name ?? "unknown",
|
|
237
|
+
status
|
|
238
|
+
};
|
|
239
|
+
if (loc) mutant.location = { start: loc };
|
|
240
|
+
const entry = files[path] ?? {
|
|
241
|
+
language: "python",
|
|
242
|
+
mutants: []
|
|
243
|
+
};
|
|
244
|
+
entry.mutants.push(mutant);
|
|
245
|
+
files[path] = entry;
|
|
246
|
+
index++;
|
|
247
|
+
}
|
|
248
|
+
return { files };
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/mutmut.ts
|
|
252
|
+
const MUTMUT_STATUS = {
|
|
253
|
+
killed: "Killed",
|
|
254
|
+
survived: "Survived",
|
|
255
|
+
"no tests": "NoCoverage",
|
|
256
|
+
timeout: "Timeout",
|
|
257
|
+
suspicious: "Survived",
|
|
258
|
+
skipped: "Ignored",
|
|
259
|
+
segfault: "RuntimeError"
|
|
260
|
+
};
|
|
261
|
+
/** The module portion of a mutmut mutant name (`pkg.mod.x_fn__mutmut_3` → `pkg.mod`). */
|
|
262
|
+
function moduleOf(name) {
|
|
263
|
+
const m = /^(.*)\.x_.+__mutmut_\d+$/.exec(name);
|
|
264
|
+
if (m?.[1]) return m[1];
|
|
265
|
+
const dot = name.lastIndexOf(".");
|
|
266
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
267
|
+
}
|
|
268
|
+
/** Parse `mutmut results --all true` text into a mutation-testing-elements report. Pure. */
|
|
269
|
+
function parseMutmutResults(text) {
|
|
270
|
+
const files = {};
|
|
271
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
272
|
+
const line = raw.trim();
|
|
273
|
+
if (!line) continue;
|
|
274
|
+
const idx = line.indexOf(":");
|
|
275
|
+
if (idx === -1) continue;
|
|
276
|
+
const name = line.slice(0, idx).trim();
|
|
277
|
+
if (!name) continue;
|
|
278
|
+
const status = MUTMUT_STATUS[line.slice(idx + 1).trim().toLowerCase()] ?? "Pending";
|
|
279
|
+
const file = moduleOf(name);
|
|
280
|
+
const entry = files[file] ?? { mutants: [] };
|
|
281
|
+
entry.mutants.push({
|
|
282
|
+
id: name,
|
|
283
|
+
mutatorName: name,
|
|
284
|
+
status
|
|
285
|
+
});
|
|
286
|
+
files[file] = entry;
|
|
287
|
+
}
|
|
288
|
+
return { files };
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/scope.ts
|
|
292
|
+
/** Normalize a path to a comparable repo-relative POSIX form (backslashes → `/`, drop a leading `./`). */
|
|
293
|
+
function normalizePath(p) {
|
|
294
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Derive the mutation scope from the changed files. A changed file is selected only when it is a
|
|
298
|
+
* `.py` file, lives under one of the operator's `ownedRoots`, AND exists on disk (`exists`,
|
|
299
|
+
* injected — FS by default in the runner, faked in tests, mirroring `selectPytestScope`'s
|
|
300
|
+
* `testExists`). A `.py` file that is out-of-tree or non-existent (deleted/renamed/typo) is
|
|
301
|
+
* `unmatched`, never scoped. Non-`.py` files are irrelevant to mutation and dropped. Pure given
|
|
302
|
+
* `exists`. Whole-project fallback is NOT decided here — it is the caller's (`mutateFiles ===
|
|
303
|
+
* undefined`) or the cosmic-ray emitter's (`no-scope` on the base config) concern.
|
|
304
|
+
*/
|
|
305
|
+
function selectMutationScope(mutateFiles, ownedRoots, exists) {
|
|
306
|
+
const roots = ownedRoots.map(normalizePath);
|
|
307
|
+
const underRoot = (p) => roots.some((r) => p === r || p.startsWith(`${r}/`));
|
|
308
|
+
const files = /* @__PURE__ */ new Set();
|
|
309
|
+
const unmatched = /* @__PURE__ */ new Set();
|
|
310
|
+
for (const raw of mutateFiles) {
|
|
311
|
+
const p = normalizePath(raw);
|
|
312
|
+
if (!p.endsWith(".py")) continue;
|
|
313
|
+
if (!underRoot(p) || !exists(p)) {
|
|
314
|
+
unmatched.add(p);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
files.add(p);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
files: [...files].sort(),
|
|
321
|
+
unmatched: [...unmatched].sort()
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Post-spawn partial-under-scope guard. Given the files the run was SELECTED to mutate and the
|
|
326
|
+
* tool's {@link MutationSummary} (whose `files[]` carries a per-file record for every file the
|
|
327
|
+
* tool SAW — even one it found no mutants in), determine which selected files were genuinely
|
|
328
|
+
* mutated and which the tool never saw at all. A selected file present in the summary with zero
|
|
329
|
+
* mutants is SEEN-BUT-EMPTY (benign — no mutable code); a selected file ABSENT from the summary
|
|
330
|
+
* was never mutated (the partial-scope sentinel) and goes in `missing`. Paths are normalized on
|
|
331
|
+
* both sides before comparison. Pure.
|
|
332
|
+
*/
|
|
333
|
+
function reconcileScope(selected, summary) {
|
|
334
|
+
const seen = new Set(summary.files.map((f) => normalizePath(f.path)));
|
|
335
|
+
const mutated = new Set(summary.files.filter((f) => f.metrics.totalMutants > 0).map((f) => normalizePath(f.path)));
|
|
336
|
+
const sel = [...new Set(selected.map(normalizePath))].sort();
|
|
337
|
+
return {
|
|
338
|
+
mutatedFiles: sel.filter((p) => mutated.has(p)),
|
|
339
|
+
missing: sel.filter((p) => !seen.has(p))
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Convert a Python source PATH to its dotted module form (`pkg/calc.py` → `pkg.calc`,
|
|
344
|
+
* `pkg/__init__.py` → `pkg`). The package-relative prefix is unknown here, so this is the FULL
|
|
345
|
+
* relative-path dotted form; {@link reconcileMutmutScope} matches it against mutmut's module names
|
|
346
|
+
* by suffix to tolerate src-layout projects.
|
|
347
|
+
*/
|
|
348
|
+
function pyPathToModule(path) {
|
|
349
|
+
let p = normalizePath(path).replace(/\.py$/, "");
|
|
350
|
+
if (p.endsWith("/__init__")) p = p.slice(0, -9);
|
|
351
|
+
return p.replace(/\//g, ".");
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The mutmut-specific post-spawn guard. Unlike cosmic-ray, mutmut's {@link MutationSummary} is keyed
|
|
355
|
+
* by DOTTED MODULE (`pkg.calc`), not path, AND it emits NO record for a scoped file that produced
|
|
356
|
+
* zero mutants (slice 0: seen-but-empty is INDISTINGUISHABLE from never-seen — Fork B2). So this is
|
|
357
|
+
* deliberately CONSERVATIVE: a selected file is "mutated" only if some module with ≥1 mutant matches
|
|
358
|
+
* its dotted form (equal, or a suffix either way — tolerating flat AND src layouts); every other
|
|
359
|
+
* selected file is `missing` (⇒ the runner throws inconclusive). Some false-inconclusives, ZERO
|
|
360
|
+
* false-passes (absence-is-never-a-pass). Pure.
|
|
361
|
+
*/
|
|
362
|
+
function reconcileMutmutScope(selectedPaths, summary) {
|
|
363
|
+
const mutatedModules = summary.files.filter((f) => f.metrics.totalMutants > 0).map((f) => normalizePath(f.path));
|
|
364
|
+
const matches = (mod, sel) => mod === sel || mod.endsWith(`.${sel}`) || sel.endsWith(`.${mod}`);
|
|
365
|
+
const sel = [...new Set(selectedPaths.map(normalizePath))].sort();
|
|
366
|
+
const mutatedFiles = [];
|
|
367
|
+
const missing = [];
|
|
368
|
+
for (const path of sel) {
|
|
369
|
+
const mod = pyPathToModule(path);
|
|
370
|
+
if (mutatedModules.some((m) => matches(m, mod))) mutatedFiles.push(path);
|
|
371
|
+
else missing.push(path);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
mutatedFiles,
|
|
375
|
+
missing
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/summarize.ts
|
|
380
|
+
const ZERO = {
|
|
381
|
+
killed: 0,
|
|
382
|
+
survived: 0,
|
|
383
|
+
timeout: 0,
|
|
384
|
+
noCoverage: 0,
|
|
385
|
+
compileErrors: 0,
|
|
386
|
+
runtimeErrors: 0,
|
|
387
|
+
ignored: 0,
|
|
388
|
+
pending: 0
|
|
389
|
+
};
|
|
390
|
+
function tally(mutants) {
|
|
391
|
+
const c = { ...ZERO };
|
|
392
|
+
for (const m of mutants) switch (m.status) {
|
|
393
|
+
case "Killed":
|
|
394
|
+
c.killed++;
|
|
395
|
+
break;
|
|
396
|
+
case "Survived":
|
|
397
|
+
c.survived++;
|
|
398
|
+
break;
|
|
399
|
+
case "Timeout":
|
|
400
|
+
c.timeout++;
|
|
401
|
+
break;
|
|
402
|
+
case "NoCoverage":
|
|
403
|
+
c.noCoverage++;
|
|
404
|
+
break;
|
|
405
|
+
case "CompileError":
|
|
406
|
+
c.compileErrors++;
|
|
407
|
+
break;
|
|
408
|
+
case "RuntimeError":
|
|
409
|
+
c.runtimeErrors++;
|
|
410
|
+
break;
|
|
411
|
+
case "Ignored":
|
|
412
|
+
c.ignored++;
|
|
413
|
+
break;
|
|
414
|
+
case "Pending":
|
|
415
|
+
c.pending++;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
return c;
|
|
419
|
+
}
|
|
420
|
+
function metricsFrom(c) {
|
|
421
|
+
const detected = c.killed + c.timeout;
|
|
422
|
+
const undetected = c.survived + c.noCoverage;
|
|
423
|
+
const covered = detected + c.survived;
|
|
424
|
+
const valid = detected + undetected;
|
|
425
|
+
const invalid = c.compileErrors + c.runtimeErrors;
|
|
426
|
+
return {
|
|
427
|
+
counts: c,
|
|
428
|
+
detected,
|
|
429
|
+
undetected,
|
|
430
|
+
covered,
|
|
431
|
+
valid,
|
|
432
|
+
invalid,
|
|
433
|
+
totalMutants: valid + invalid + c.ignored + c.pending,
|
|
434
|
+
mutationScore: valid > 0 ? detected / valid * 100 : null,
|
|
435
|
+
mutationScoreBasedOnCoveredCode: covered > 0 ? detected / covered * 100 : null
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function sumCounts(a, b) {
|
|
439
|
+
return {
|
|
440
|
+
killed: a.killed + b.killed,
|
|
441
|
+
survived: a.survived + b.survived,
|
|
442
|
+
timeout: a.timeout + b.timeout,
|
|
443
|
+
noCoverage: a.noCoverage + b.noCoverage,
|
|
444
|
+
compileErrors: a.compileErrors + b.compileErrors,
|
|
445
|
+
runtimeErrors: a.runtimeErrors + b.runtimeErrors,
|
|
446
|
+
ignored: a.ignored + b.ignored,
|
|
447
|
+
pending: a.pending + b.pending
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** Summarize a Stryker mutation report into aggregate + per-file metrics and the survivor list. Pure. */
|
|
451
|
+
function summarizeMutation(report) {
|
|
452
|
+
const files = [];
|
|
453
|
+
const survivors = [];
|
|
454
|
+
let total = { ...ZERO };
|
|
455
|
+
for (const path of Object.keys(report.files).sort()) {
|
|
456
|
+
const file = report.files[path];
|
|
457
|
+
if (!file) continue;
|
|
458
|
+
const counts = tally(file.mutants);
|
|
459
|
+
total = sumCounts(total, counts);
|
|
460
|
+
files.push({
|
|
461
|
+
path,
|
|
462
|
+
metrics: metricsFrom(counts)
|
|
463
|
+
});
|
|
464
|
+
for (const m of file.mutants) if (m.status === "Survived" || m.status === "NoCoverage") survivors.push({
|
|
465
|
+
file: path,
|
|
466
|
+
mutatorName: m.mutatorName,
|
|
467
|
+
status: m.status,
|
|
468
|
+
line: m.location?.start.line ?? 0
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
survivors.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line);
|
|
472
|
+
return {
|
|
473
|
+
metrics: metricsFrom(total),
|
|
474
|
+
files,
|
|
475
|
+
survivors
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/run.ts
|
|
480
|
+
/**
|
|
481
|
+
* The gated, diff-scoped mutation run — the live half of `@sackville-mcp/mutate`. It **spawns**
|
|
482
|
+
* Stryker (`stryker run`, an injected subprocess like flake's `vitest` and coverage's
|
|
483
|
+
* `vitest related`), then reads the JSON report Stryker writes and feeds it to the pure
|
|
484
|
+
* {@link summarizeMutation}.
|
|
485
|
+
*
|
|
486
|
+
* Per ADR 0010 (+ its 2026-06-01 spike update):
|
|
487
|
+
* 1. **It runs code** — and a mutation run is *expensive* (the suite re-runs per mutant) —
|
|
488
|
+
* so it sits behind the house paired deny-by-default operator gate (`allowRun` +
|
|
489
|
+
* `allowedRoots` allowlist, load-bearing on its own, + a wall-clock cap). All
|
|
490
|
+
* operator-set; no caller input self-authorizes.
|
|
491
|
+
* 2. **Stryker is NOT a dependency of this package.** A real mutation run is slow and
|
|
492
|
+
* non-deterministic, so it never runs in `pnpm gate`. The `stryker` invocation is the
|
|
493
|
+
* injected {@link MutationRunner} (the bin spawns the operator's local Stryker); the
|
|
494
|
+
* engine owns the gate, argv, report plumbing, and summary, and is unit-tested with a
|
|
495
|
+
* fake runner.
|
|
496
|
+
* 3. **Diff-scoped.** `mutateFiles` (the changed source files) become Stryker's `--mutate`
|
|
497
|
+
* glob list, and `--incremental` reuses Stryker's cache — so a change mutates only what
|
|
498
|
+
* it touched, not the whole tree.
|
|
499
|
+
*/
|
|
500
|
+
/** The zero-mutant summary returned by a pre-spawn noop (folds to no-signal ⇒ inconclusive). */
|
|
501
|
+
function emptyMutationSummary() {
|
|
502
|
+
return summarizeMutation({ files: {} });
|
|
503
|
+
}
|
|
504
|
+
/** Read a file, or undefined if it is absent (a missing base config is not an error). */
|
|
505
|
+
function readFileIfExists(path) {
|
|
506
|
+
try {
|
|
507
|
+
return readFileSync(path, "utf8");
|
|
508
|
+
} catch {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Directories never copied into the mutmut sandbox (heavy / irrelevant / the sticky mutants cache). */
|
|
513
|
+
const SANDBOX_EXCLUDE = /(?:^|\/)(?:node_modules|\.git|\.venv|venv|__pycache__|mutants|\.mutmut-cache|dist|\.tox)(?:\/|$)/;
|
|
514
|
+
/** Recursively list the `.py` files under each owned root, repo-relative (FS default for runMutmut). */
|
|
515
|
+
function defaultListSources(ownedRoots, projectRoot) {
|
|
516
|
+
const out = [];
|
|
517
|
+
for (const root of ownedRoots) {
|
|
518
|
+
const abs = join(projectRoot, root);
|
|
519
|
+
let entries;
|
|
520
|
+
try {
|
|
521
|
+
entries = readdirSync(abs, { recursive: true });
|
|
522
|
+
} catch {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
for (const rel of entries) {
|
|
526
|
+
const p = rel.replace(/\\/g, "/");
|
|
527
|
+
if (p.endsWith(".py") && !SANDBOX_EXCLUDE.test(p)) out.push(`${root}/${p}`.replace(/\/+/g, "/"));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
/** Copy a project into a fresh sandbox dir, excluding heavy dirs + the sticky `mutants/` cache. */
|
|
533
|
+
function copyProjectInto(from, to) {
|
|
534
|
+
cpSync(from, to, {
|
|
535
|
+
recursive: true,
|
|
536
|
+
filter: (src) => !SANDBOX_EXCLUDE.test(src.slice(from.length).replace(/\\/g, "/"))
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** Thrown when the paired operator gate denies a run. */
|
|
540
|
+
var MutateGateError = class extends Error {
|
|
541
|
+
constructor(message) {
|
|
542
|
+
super(message);
|
|
543
|
+
this.name = "MutateGateError";
|
|
544
|
+
this[Symbol.for("sackville.gate-denial")] = true;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
/** Stryker's default JSON-report location, relative to the project root. */
|
|
548
|
+
function defaultReportPath(projectRoot) {
|
|
549
|
+
return join(projectRoot, "reports", "mutation", "mutation.json");
|
|
550
|
+
}
|
|
551
|
+
/** Build the `stryker run` argv: JSON reporter, optional diff scope + incremental cache. */
|
|
552
|
+
function runArgv(input) {
|
|
553
|
+
const argv = [
|
|
554
|
+
"run",
|
|
555
|
+
"--reporters",
|
|
556
|
+
"json"
|
|
557
|
+
];
|
|
558
|
+
if (input.mutateFiles && input.mutateFiles.length > 0) argv.push("--mutate", input.mutateFiles.join(","));
|
|
559
|
+
if (input.incremental) argv.push("--incremental");
|
|
560
|
+
return argv;
|
|
561
|
+
}
|
|
562
|
+
/** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */
|
|
563
|
+
function spawnMutationRunner(command) {
|
|
564
|
+
return (argv, opts) => new Promise((res) => {
|
|
565
|
+
execFile(command, argv, {
|
|
566
|
+
cwd: opts.cwd,
|
|
567
|
+
timeout: opts.timeoutMs,
|
|
568
|
+
maxBuffer: 64 * 1024 * 1024
|
|
569
|
+
}, (err, stdout, stderr) => {
|
|
570
|
+
res({
|
|
571
|
+
exitCode: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
|
|
572
|
+
stdout: String(stdout),
|
|
573
|
+
stderr: String(stderr)
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/** Default live runner: spawn the local `stryker` as a subprocess (used by the bin, not the gate). */
|
|
579
|
+
const defaultStrykerRunner = spawnMutationRunner("stryker");
|
|
580
|
+
/** Default live runner: spawn the local `mutmut` as a subprocess (used by the bin, not the gate). */
|
|
581
|
+
const defaultMutmutRunner = spawnMutationRunner("mutmut");
|
|
582
|
+
/** Default live runner: spawn the local `cosmic-ray` as a subprocess (used by the bin, not the gate). */
|
|
583
|
+
const defaultCosmicRayRunner = spawnMutationRunner("cosmic-ray");
|
|
584
|
+
function assertAllowed(config) {
|
|
585
|
+
if (!config.allowRun) throw new MutateGateError("mutation runs are not enabled (the operator must set allowRun)");
|
|
586
|
+
const root = resolve(config.projectRoot);
|
|
587
|
+
if (!config.allowedRoots.map((r) => resolve(r)).includes(root)) throw new MutateGateError(`project root ${config.projectRoot} is not in the operator allowlist`);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Run mutation testing behind the operator gate and summarize the report. The actual
|
|
591
|
+
* `stryker` invocation is the injected `runner` (default {@link defaultStrykerRunner}); the
|
|
592
|
+
* JSON report is read from `deps.reportPath` (default: Stryker's
|
|
593
|
+
* `<projectRoot>/reports/mutation/mutation.json`).
|
|
594
|
+
*/
|
|
595
|
+
async function runMutation(config, input, deps = {}) {
|
|
596
|
+
assertAllowed(config);
|
|
597
|
+
const runner = deps.runner ?? defaultStrykerRunner;
|
|
598
|
+
const reportPath = deps.reportPath ?? defaultReportPath(config.projectRoot);
|
|
599
|
+
const { exitCode } = await runner(runArgv(input), {
|
|
600
|
+
cwd: config.projectRoot,
|
|
601
|
+
timeoutMs: config.timeoutMs
|
|
602
|
+
});
|
|
603
|
+
let report;
|
|
604
|
+
try {
|
|
605
|
+
report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
606
|
+
} catch {
|
|
607
|
+
throw new Error(`mutation run did not produce a JSON report at ${reportPath} (exit code ${exitCode}); ensure the project enables the Stryker \`json\` reporter`);
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
ran: true,
|
|
611
|
+
exitCode,
|
|
612
|
+
scopedFiles: input.mutateFiles ?? [],
|
|
613
|
+
tool: "stryker",
|
|
614
|
+
reportPath,
|
|
615
|
+
summary: summarizeMutation(report)
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Transport-completeness guard for the Python tools, mirroring the capture/HAR guards: a run that
|
|
620
|
+
* produced NO mutants (an empty/failed session), or a cosmic-ray session with unexecuted/ambiguous
|
|
621
|
+
* (`Pending`) mutants, is INCONCLUSIVE — it must never be reported as a clean pass
|
|
622
|
+
* (absence-is-never-a-pass). Throws so the caller (verify) folds it to inconclusive.
|
|
623
|
+
*/
|
|
624
|
+
function assertComplete(tool, summary) {
|
|
625
|
+
if (summary.metrics.totalMutants === 0) throw new Error(`${tool} produced no mutants — inconclusive (never a clean pass)`);
|
|
626
|
+
if (summary.metrics.counts.pending > 0) throw new Error(`${tool} session is incomplete: ${summary.metrics.counts.pending} unexecuted/ambiguous mutant(s) — inconclusive`);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* mutmut sibling of {@link runMutation} (ADR 0010 addendum, the lightweight Python option). Spawns
|
|
630
|
+
* `mutmut run` (mutate + test; a non-zero exit just means survivors exist, not an error) then reads
|
|
631
|
+
* `mutmut results --all true` from STDOUT and feeds the pure {@link parseMutmutResults}. No report
|
|
632
|
+
* file. (Diff-scoping is staged — mutmut 3.x scopes via its own config, not a clean CLI file list.)
|
|
633
|
+
*/
|
|
634
|
+
async function runMutmut(config, input, deps = {}) {
|
|
635
|
+
assertAllowed(config);
|
|
636
|
+
const runner = deps.runner ?? defaultMutmutRunner;
|
|
637
|
+
if (input.mutateFiles === void 0) {
|
|
638
|
+
const opts = {
|
|
639
|
+
cwd: config.projectRoot,
|
|
640
|
+
timeoutMs: config.timeoutMs
|
|
641
|
+
};
|
|
642
|
+
await runner(["run"], opts);
|
|
643
|
+
const { exitCode, stdout } = await runner([
|
|
644
|
+
"results",
|
|
645
|
+
"--all",
|
|
646
|
+
"true"
|
|
647
|
+
], opts);
|
|
648
|
+
const summary = summarizeMutation(parseMutmutResults(stdout));
|
|
649
|
+
assertComplete("mutmut", summary);
|
|
650
|
+
return {
|
|
651
|
+
ran: true,
|
|
652
|
+
exitCode,
|
|
653
|
+
scopedFiles: [],
|
|
654
|
+
tool: "mutmut",
|
|
655
|
+
summary
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const pyprojectPath = input.configPath ?? "pyproject.toml";
|
|
659
|
+
const basePyproject = readFileIfExists(join(config.projectRoot, pyprojectPath)) ?? "";
|
|
660
|
+
const ownedRoots = input.ownedRoots ?? mutmutPathsToMutate(basePyproject);
|
|
661
|
+
const exists = deps.exists ?? ((p) => existsSync(join(config.projectRoot, p)));
|
|
662
|
+
const { files, unmatched } = selectMutationScope(input.mutateFiles, ownedRoots, exists);
|
|
663
|
+
const unmatchedOut = unmatched.length > 0 ? unmatched : void 0;
|
|
664
|
+
if (files.length === 0) return {
|
|
665
|
+
ran: false,
|
|
666
|
+
exitCode: 0,
|
|
667
|
+
scopedFiles: [],
|
|
668
|
+
tool: "mutmut",
|
|
669
|
+
summary: emptyMutationSummary(),
|
|
670
|
+
scopeEmpty: true,
|
|
671
|
+
unmatched: unmatchedOut,
|
|
672
|
+
requestedFiles: []
|
|
673
|
+
};
|
|
674
|
+
const scopedPyproject = synthesizeScopedMutmutPyproject(basePyproject, planMutmutScope(files, (deps.listSourceFiles ?? defaultListSources)(ownedRoots, config.projectRoot), mutmutDoNotMutate(basePyproject)));
|
|
675
|
+
const sandbox = deps.sandboxDir ?? mkdtempSync(join(tmpdir(), "sackville-mutmut-"));
|
|
676
|
+
copyProjectInto(config.projectRoot, sandbox);
|
|
677
|
+
writeFileSync(join(sandbox, "pyproject.toml"), scopedPyproject);
|
|
678
|
+
const opts = {
|
|
679
|
+
cwd: sandbox,
|
|
680
|
+
timeoutMs: config.timeoutMs
|
|
681
|
+
};
|
|
682
|
+
await runner(["run"], opts);
|
|
683
|
+
const { exitCode, stdout } = await runner([
|
|
684
|
+
"results",
|
|
685
|
+
"--all",
|
|
686
|
+
"true"
|
|
687
|
+
], opts);
|
|
688
|
+
const summary = summarizeMutation(parseMutmutResults(stdout));
|
|
689
|
+
assertComplete("mutmut", summary);
|
|
690
|
+
const { mutatedFiles, missing } = reconcileMutmutScope(files, summary);
|
|
691
|
+
if (missing.length > 0) throw new Error(`mutmut under-scoped: ${missing.join(", ")} selected but produced no mutants — inconclusive`);
|
|
692
|
+
return {
|
|
693
|
+
ran: true,
|
|
694
|
+
exitCode,
|
|
695
|
+
scopedFiles: mutatedFiles,
|
|
696
|
+
tool: "mutmut",
|
|
697
|
+
summary,
|
|
698
|
+
requestedFiles: files,
|
|
699
|
+
unmatched: unmatchedOut
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* cosmic-ray sibling of {@link runMutation} (ADR 0010 addendum, the PRIMARY Python tool — its dump
|
|
704
|
+
* carries real file:line:operator, so survivors are actionable). Drives the three-step workflow
|
|
705
|
+
* against an operator-authored config (`input.configPath`, default `cosmic-ray.toml` — it carries
|
|
706
|
+
* the project's test-command + module scope) over a throwaway session DB: `init` → `exec` → `dump`,
|
|
707
|
+
* reading the `dump` JSON-lines from STDOUT and feeding the pure {@link parseCosmicRayDump}. The
|
|
708
|
+
* {@link assertComplete} guard makes an empty or partially-executed session inconclusive, never a
|
|
709
|
+
* clean pass. (Diff-scoping by synthesizing the per-run config from `mutateFiles` is staged.)
|
|
710
|
+
*/
|
|
711
|
+
async function runCosmicRay(config, input, deps = {}) {
|
|
712
|
+
assertAllowed(config);
|
|
713
|
+
const runner = deps.runner ?? defaultCosmicRayRunner;
|
|
714
|
+
const opts = {
|
|
715
|
+
cwd: config.projectRoot,
|
|
716
|
+
timeoutMs: config.timeoutMs
|
|
717
|
+
};
|
|
718
|
+
const configPath = input.configPath ?? "cosmic-ray.toml";
|
|
719
|
+
const session = join(deps.sessionDir ?? mkdtempSync(join(tmpdir(), "sackville-mutate-")), "session.sqlite");
|
|
720
|
+
if (input.mutateFiles === void 0) {
|
|
721
|
+
await runner([
|
|
722
|
+
"init",
|
|
723
|
+
configPath,
|
|
724
|
+
session
|
|
725
|
+
], opts);
|
|
726
|
+
const exec = await runner([
|
|
727
|
+
"exec",
|
|
728
|
+
configPath,
|
|
729
|
+
session
|
|
730
|
+
], opts);
|
|
731
|
+
const { stdout } = await runner(["dump", session], opts);
|
|
732
|
+
const summary = summarizeMutation(parseCosmicRayDump(stdout));
|
|
733
|
+
assertComplete("cosmic-ray", summary);
|
|
734
|
+
return {
|
|
735
|
+
ran: true,
|
|
736
|
+
exitCode: exec.exitCode,
|
|
737
|
+
scopedFiles: [],
|
|
738
|
+
tool: "cosmic-ray",
|
|
739
|
+
summary
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const baseToml = readFileSync(join(config.projectRoot, configPath), "utf8");
|
|
743
|
+
const ownedRoots = input.ownedRoots ?? cosmicModulePathRoots(baseToml);
|
|
744
|
+
const exists = deps.exists ?? ((p) => existsSync(join(config.projectRoot, p)));
|
|
745
|
+
const { files, unmatched } = selectMutationScope(input.mutateFiles, ownedRoots, exists);
|
|
746
|
+
const unmatchedOut = unmatched.length > 0 ? unmatched : void 0;
|
|
747
|
+
if (files.length === 0) return {
|
|
748
|
+
ran: false,
|
|
749
|
+
exitCode: 0,
|
|
750
|
+
scopedFiles: [],
|
|
751
|
+
tool: "cosmic-ray",
|
|
752
|
+
summary: emptyMutationSummary(),
|
|
753
|
+
scopeEmpty: true,
|
|
754
|
+
unmatched: unmatchedOut,
|
|
755
|
+
requestedFiles: []
|
|
756
|
+
};
|
|
757
|
+
const scoped = synthesizeScopedCosmicRayConfig(baseToml, files);
|
|
758
|
+
const scopedName = deps.scopedConfigName ?? ".sackville-cosmic.toml";
|
|
759
|
+
const scopedAbs = join(config.projectRoot, scopedName);
|
|
760
|
+
writeFileSync(scopedAbs, scoped.toml);
|
|
761
|
+
try {
|
|
762
|
+
await runner([
|
|
763
|
+
"init",
|
|
764
|
+
scopedName,
|
|
765
|
+
session
|
|
766
|
+
], opts);
|
|
767
|
+
const exec = await runner([
|
|
768
|
+
"exec",
|
|
769
|
+
scopedName,
|
|
770
|
+
session
|
|
771
|
+
], opts);
|
|
772
|
+
const { stdout } = await runner(["dump", session], opts);
|
|
773
|
+
const summary = summarizeMutation(parseCosmicRayDump(stdout));
|
|
774
|
+
assertComplete("cosmic-ray", summary);
|
|
775
|
+
const { mutatedFiles, missing } = reconcileScope(files, summary);
|
|
776
|
+
if (missing.length > 0) throw new Error(`cosmic-ray under-scoped: ${missing.join(", ")} selected but never mutated — inconclusive`);
|
|
777
|
+
return {
|
|
778
|
+
ran: true,
|
|
779
|
+
exitCode: exec.exitCode,
|
|
780
|
+
scopedFiles: mutatedFiles,
|
|
781
|
+
tool: "cosmic-ray",
|
|
782
|
+
summary,
|
|
783
|
+
requestedFiles: files,
|
|
784
|
+
unmatched: unmatchedOut
|
|
785
|
+
};
|
|
786
|
+
} finally {
|
|
787
|
+
rmSync(scopedAbs, { force: true });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
export { MutateGateError, ScopeEmitError, cosmicModulePathRoots, defaultCosmicRayRunner, defaultMutmutRunner, defaultStrykerRunner, mutmutDoNotMutate, mutmutPathsToMutate, parseCosmicRayDump, parseMutmutResults, planMutmutScope, pyPathToModule, reconcileMutmutScope, reconcileScope, runCosmicRay, runMutation, runMutmut, selectMutationScope, summarizeMutation, synthesizeScopedCosmicRayConfig, synthesizeScopedMutmutPyproject };
|
|
792
|
+
|
|
793
|
+
//# sourceMappingURL=index.mjs.map
|