@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/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