probatio 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ const IMPORT_SPEC = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.{1,2}\/[^"']+)["']/g;
4
+ /** Package-relative source file to the test files that can see a change in it. */
5
+ export function affectedTests(packageDir, options = {}) {
6
+ const srcRel = options.srcDir ?? "src";
7
+ const testsRel = options.testsDir ?? "tests";
8
+ const srcDir = path.join(packageDir, srcRel);
9
+ const testsDir = path.join(packageDir, testsRel);
10
+ const alwaysPattern = options.alwaysPattern ?? /\b(?:spawn|execFile|execSync)\b|dist\//;
11
+ const sources = walk(srcDir, (name) => /\.(ts|tsx|py)$/.test(name) && !name.endsWith(".d.ts"));
12
+ const tests = existsSync(testsDir)
13
+ ? readdirSync(testsDir)
14
+ .filter((name) => /\.test\.(ts|tsx|mts|js|mjs)$/.test(name))
15
+ .sort()
16
+ : [];
17
+ const imports = new Map();
18
+ const read = (file) => {
19
+ const found = imports.get(file);
20
+ if (found)
21
+ return found;
22
+ const out = new Set();
23
+ imports.set(file, out);
24
+ if (!existsSync(file))
25
+ return out;
26
+ const text = readFileSync(file, "utf8");
27
+ for (const match of text.matchAll(IMPORT_SPEC)) {
28
+ const resolved = resolveImport(path.dirname(file), match[1]);
29
+ if (resolved)
30
+ out.add(resolved);
31
+ }
32
+ return out;
33
+ };
34
+ const closureOf = (file) => {
35
+ const seen = new Set();
36
+ const stack = [file];
37
+ while (stack.length) {
38
+ const current = stack.pop();
39
+ if (!current || seen.has(current))
40
+ continue;
41
+ seen.add(current);
42
+ for (const next of read(current))
43
+ stack.push(next);
44
+ }
45
+ return seen;
46
+ };
47
+ const rel = (file) => path.relative(packageDir, file).split(path.sep).join("/");
48
+ const byModule = new Map();
49
+ for (const source of sources)
50
+ byModule.set(rel(source), new Set());
51
+ const testRel = (name) => `${testsRel}/${name}`.replace(/\\/g, "/");
52
+ const always = [];
53
+ for (const test of tests) {
54
+ const text = readFileSync(path.join(testsDir, test), "utf8");
55
+ if (alwaysPattern.test(text))
56
+ always.push(testRel(test));
57
+ }
58
+ for (const names of byModule.values())
59
+ for (const test of always)
60
+ names.add(test);
61
+ for (const test of tests) {
62
+ const seen = closureOf(path.join(testsDir, test));
63
+ for (const file of seen) {
64
+ const key = rel(file);
65
+ const testsForFile = byModule.get(key);
66
+ if (testsForFile)
67
+ testsForFile.add(testRel(test));
68
+ }
69
+ const blob = [path.join(testsDir, test), ...seen].map((file) => (existsSync(file) ? readFileSync(file, "utf8") : "")).join("\n");
70
+ for (const source of sources) {
71
+ if (!source.endsWith(".py"))
72
+ continue;
73
+ if (blob.includes(path.basename(source)))
74
+ byModule.get(rel(source))?.add(testRel(test));
75
+ }
76
+ }
77
+ for (const testFile of walk(testsDir, (name) => /^test_.*\.py$/.test(name) || /_test\.py$/.test(name))) {
78
+ const text = readFileSync(testFile, "utf8");
79
+ const relTest = rel(testFile);
80
+ for (const source of sources) {
81
+ if (!source.endsWith(".py"))
82
+ continue;
83
+ const moduleName = pythonModuleName(rel(source));
84
+ if (!moduleName)
85
+ continue;
86
+ const pattern = new RegExp(`(?<![\\w.])${escapeRegExp(moduleName)}(?![\\w.])`);
87
+ if (pattern.test(text))
88
+ byModule.get(rel(source))?.add(relTest);
89
+ }
90
+ }
91
+ return Object.fromEntries([...byModule.entries()].sort().map(([file, names]) => [file, [...names].sort()]));
92
+ }
93
+ /** Test files that import one of `changed` themselves, not through another module. */
94
+ export function directImporters(packageDir, changed, testsDir = "tests") {
95
+ const testsRoot = path.join(packageDir, testsDir);
96
+ if (!existsSync(testsRoot))
97
+ return [];
98
+ const wanted = new Set(changed.map((file) => path.resolve(packageDir, file)));
99
+ const out = [];
100
+ const seen = new Set();
101
+ const add = (rel) => {
102
+ if (seen.has(rel))
103
+ return;
104
+ seen.add(rel);
105
+ out.push(rel);
106
+ };
107
+ for (const name of readdirSync(testsRoot).filter((item) => /\.test\.(ts|tsx|mts|js|mjs)$/.test(item)).sort()) {
108
+ const file = path.join(testsRoot, name);
109
+ const text = readFileSync(file, "utf8");
110
+ for (const match of text.matchAll(IMPORT_SPEC)) {
111
+ const resolved = resolveImport(path.dirname(file), match[1]);
112
+ if (resolved && wanted.has(resolved)) {
113
+ add(`${testsDir}/${name}`.replace(/\\/g, "/"));
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ const patterns = changed
119
+ .map((file) => pythonModuleName(file))
120
+ .filter((name) => name !== null)
121
+ .map((name) => new RegExp(`(?<![\\w.])${escapeRegExp(name)}(?![\\w.])`));
122
+ if (patterns.length > 0) {
123
+ for (const file of walk(testsRoot, (name) => /^test_.*\.py$/.test(name) || /_test\.py$/.test(name))) {
124
+ const text = readFileSync(file, "utf8");
125
+ if (!patterns.some((pattern) => pattern.test(text)))
126
+ continue;
127
+ add(path.relative(packageDir, file).split(path.sep).join("/"));
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /** `src/pkg/mod.py` is the import name `pkg.mod`. */
133
+ function pythonModuleName(file) {
134
+ const norm = file.split(path.sep).join("/");
135
+ if (!norm.endsWith(".py"))
136
+ return null;
137
+ let body = norm.slice(0, -3);
138
+ if (body.startsWith("src/"))
139
+ body = body.slice(4);
140
+ if (body.endsWith("/__init__"))
141
+ body = body.slice(0, -"/__init__".length);
142
+ if (!body || body.startsWith("../"))
143
+ return null;
144
+ return body.split("/").join(".");
145
+ }
146
+ function escapeRegExp(value) {
147
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ }
149
+ function resolveImport(fromDir, spec) {
150
+ const target = path.resolve(fromDir, spec);
151
+ const candidates = [target, `${target}.ts`, `${target}.tsx`, `${target}.mts`, `${target}.js`, `${target}.mjs`, `${target}.py`];
152
+ for (const candidate of candidates) {
153
+ if (existsSync(candidate) && statSync(candidate).isFile())
154
+ return candidate;
155
+ }
156
+ return null;
157
+ }
158
+ function walk(dir, keep) {
159
+ if (!existsSync(dir))
160
+ return [];
161
+ const out = [];
162
+ const visit = (current) => {
163
+ for (const entry of readdirSync(current).sort()) {
164
+ const full = path.join(current, entry);
165
+ if (statSync(full).isDirectory())
166
+ visit(full);
167
+ else if (keep(entry))
168
+ out.push(full);
169
+ }
170
+ };
171
+ visit(dir);
172
+ return out;
173
+ }
@@ -0,0 +1,172 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { WHOLE_PROGRAM_TEST } from "./suite-decision.js";
5
+ export function readCoverageMap(file) {
6
+ if (!existsSync(file))
7
+ return null;
8
+ try {
9
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
10
+ if (!parsed || typeof parsed.files !== "object" || parsed.files === null)
11
+ return null;
12
+ return parsed;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** Lines the baseline executed, and the tests that hit them. No map means no decision. */
19
+ export function selectionFor(map, files) {
20
+ if (!map || Object.keys(map.files).length === 0 || files.length === 0)
21
+ return { state: "none" };
22
+ const tests = new Set();
23
+ let sawUncovered = false;
24
+ let uncovered = files[0];
25
+ for (const item of files) {
26
+ const hit = testsOnLine(map, item.file, item.line);
27
+ if (hit === null) {
28
+ sawUncovered = true;
29
+ uncovered = item;
30
+ continue;
31
+ }
32
+ for (const name of hit)
33
+ tests.add(name);
34
+ }
35
+ if (tests.size === 0 && sawUncovered)
36
+ return { state: "uncovered", file: uncovered.file, line: uncovered.line };
37
+ if (tests.size === 0)
38
+ return { state: "none" };
39
+ const [file, line] = coveredAnchor(map, files);
40
+ return { state: "covered", file, line, tests: [...tests] };
41
+ }
42
+ function coveredAnchor(map, files) {
43
+ for (const item of files) {
44
+ if (testsOnLine(map, item.file, item.line))
45
+ return [item.file, item.line];
46
+ }
47
+ return [files[0].file, files[0].line];
48
+ }
49
+ function testsOnLine(map, file, line) {
50
+ const lines = linesFor(map, file);
51
+ if (!lines)
52
+ return null;
53
+ const tests = lines[String(line)];
54
+ if (!tests || tests.length === 0)
55
+ return null;
56
+ return tests;
57
+ }
58
+ function linesFor(map, file) {
59
+ const norm = file.split(path.sep).join("/");
60
+ if (map.files[norm])
61
+ return map.files[norm];
62
+ if (map.files[file])
63
+ return map.files[file];
64
+ // A repo-relative patch can be longer than the package-relative map key.
65
+ // The longest suffix wins, so span/simple.py does not steal span_handlers/simple.py.
66
+ let best = null;
67
+ let bestLen = -1;
68
+ const baseHits = [];
69
+ const base = norm.split("/").pop() ?? norm;
70
+ for (const [name, lines] of Object.entries(map.files)) {
71
+ const key = name.split(path.sep).join("/");
72
+ if (key === norm || norm.endsWith(`/${key}`) || key.endsWith(`/${norm}`)) {
73
+ if (key.length > bestLen) {
74
+ best = lines;
75
+ bestLen = key.length;
76
+ }
77
+ }
78
+ if (key === base || key.endsWith(`/${base}`))
79
+ baseHits.push(lines);
80
+ }
81
+ if (best)
82
+ return best;
83
+ if (baseHits.length === 1)
84
+ return baseHits[0];
85
+ return null;
86
+ }
87
+ export function binaryFromCommand(command, pkg) {
88
+ const match = command.match(/(?:^|\s)-o\s+["']?([^\s"']+)["']?/);
89
+ if (!match)
90
+ return null;
91
+ const rel = match[1];
92
+ const full = path.isAbsolute(rel) ? rel : path.join(pkg, rel);
93
+ return existsSync(full) ? full : null;
94
+ }
95
+ /**
96
+ * Build a line map from LLVM profraw left by an instrumented baseline.
97
+ * Writes nothing when llvm-cov cannot start or no profile was produced.
98
+ */
99
+ export function writeLlvmCoverage(pkg, profileDir, binary, dest) {
100
+ if (!existsSync(profileDir))
101
+ return { ok: false, detail: "no profile directory" };
102
+ const raws = readdirSync(profileDir).filter((name) => name.endsWith(".profraw")).map((name) => path.join(profileDir, name));
103
+ if (raws.length === 0)
104
+ return { ok: false, detail: "no profraw" };
105
+ if (!binary)
106
+ return { ok: false, detail: "no instrumented binary" };
107
+ const llvmCov = findTool("llvm-cov");
108
+ const llvmProf = findTool("llvm-profdata");
109
+ if (!llvmCov || !llvmProf)
110
+ return { ok: false, detail: `llvm tool missing (${llvmCov ? "llvm-profdata" : "llvm-cov"})` };
111
+ const merged = path.join(profileDir, "baseline.profdata");
112
+ const merge = spawnSync(llvmProf, ["merge", "-sparse", "-o", merged, ...raws], { encoding: "utf8" });
113
+ if (merge.status !== 0)
114
+ return { ok: false, detail: (merge.stderr || merge.stdout || "llvm-profdata failed").trim() };
115
+ const exported = spawnSync(llvmCov, ["export", binary, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
116
+ if (exported.status !== 0)
117
+ return { ok: false, detail: (exported.stderr || exported.stdout || "llvm-cov failed").trim() };
118
+ const map = llvmExportToMap(exported.stdout, pkg);
119
+ if (!map)
120
+ return { ok: false, detail: "llvm-cov export was not a coverage map" };
121
+ writeFileSync(dest, `${JSON.stringify(map, null, 2)}\n`);
122
+ return { ok: true, detail: `${Object.keys(map.files).length} files` };
123
+ }
124
+ function llvmExportToMap(stdout, pkg) {
125
+ const start = stdout.indexOf("{");
126
+ if (start < 0)
127
+ return null;
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(stdout.slice(start));
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ const files = {};
136
+ const root = pkg.endsWith(path.sep) ? pkg : `${pkg}${path.sep}`;
137
+ for (const group of parsed.data ?? []) {
138
+ for (const file of group.files ?? []) {
139
+ if (!file.filename)
140
+ continue;
141
+ const lines = {};
142
+ for (const segment of file.segments ?? []) {
143
+ const line = segment[0];
144
+ const count = segment[2] ?? 0;
145
+ if (!line || count <= 0)
146
+ continue;
147
+ lines[String(line)] = [WHOLE_PROGRAM_TEST];
148
+ }
149
+ if (Object.keys(lines).length === 0)
150
+ continue;
151
+ let rel = file.filename;
152
+ if (rel.startsWith(root))
153
+ rel = rel.slice(root.length);
154
+ rel = rel.split(path.sep).join("/");
155
+ files[rel] = { ...(files[rel] ?? {}), ...lines };
156
+ }
157
+ }
158
+ if (Object.keys(files).length === 0)
159
+ return null;
160
+ return { files };
161
+ }
162
+ function findTool(name) {
163
+ const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
164
+ const xcrunPath = fromXcrun.stdout?.trim() ?? "";
165
+ if (fromXcrun.status === 0 && xcrunPath && existsSync(xcrunPath))
166
+ return xcrunPath;
167
+ const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
168
+ const found = which.stdout?.trim() ?? "";
169
+ if (which.status === 0 && found)
170
+ return found;
171
+ return null;
172
+ }
@@ -0,0 +1,44 @@
1
+ // node --test custom reporter. One JSON object on stdout: failures with the file
2
+ // the runner itself recorded. TAP headings are not used.
3
+ export default async function* reporter(source) {
4
+ const failed = []
5
+ const names = []
6
+ let tests = 0
7
+ let pass = 0
8
+ let fail = 0
9
+ for await (const event of source) {
10
+ if (event.type === "test:pass" || event.type === "test:fail") {
11
+ const data = event.data ?? {}
12
+ if (data.details && data.details.type === "suite") continue
13
+ const name = String(data.name ?? "")
14
+ const file = String(data.file ?? "")
15
+ names.push(name)
16
+ if (event.type === "test:fail") {
17
+ const failure = { name, file, line: Number(data.line ?? 0) }
18
+ // An open handle is reported as a timeout whose name is the file path.
19
+ // failureType lives on the error; the text is on error.cause, not message.
20
+ if (fileTimedOut(name, file, data.details?.error)) failure.fileTimeout = true
21
+ failed.push(failure)
22
+ }
23
+ } else if (event.type === "test:diagnostic") {
24
+ const message = String(event.data?.message ?? "")
25
+ const match = message.match(/^(tests|pass|fail) (\d+)$/)
26
+ if (!match) continue
27
+ const count = Number(match[2])
28
+ if (match[1] === "tests") tests = count
29
+ else if (match[1] === "pass") pass = count
30
+ else fail = count
31
+ }
32
+ }
33
+ yield JSON.stringify({ tests, pass, fail, failed, names })
34
+ }
35
+
36
+ function fileTimedOut(name, file, error) {
37
+ if (!error || typeof error !== "object") return false
38
+ const cause = typeof error.cause === "string" ? error.cause : ""
39
+ const text = `${error.failureType ?? ""} ${error.message ?? ""} ${cause}`
40
+ if (!/testTimeoutFailure|timed out/i.test(text)) return false
41
+ const base = String(file).split(/[/\\]/).pop()
42
+ if (!base) return false
43
+ return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
44
+ }
@@ -0,0 +1,13 @@
1
+ import { findMutants } from "./operators.js";
2
+ import { findTextMutants, textKind } from "./text-operators.js";
3
+ /** TypeScript stays on the TypeScript parser. Every other recognized source uses its own finder. */
4
+ export function findInSource(file, text) {
5
+ if (textKind(file))
6
+ return findTextMutants(file, text);
7
+ return findMutants(file, text);
8
+ }
9
+ export function isGeneratedSource(name) {
10
+ if (textKind(name))
11
+ return true;
12
+ return (name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts");
13
+ }
@@ -0,0 +1,132 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { mutantId, makeRng, shuffle } from "./ids.js";
5
+ import { findInSource, isGeneratedSource } from "./find.js";
6
+ import { forwardDiff } from "./patch.js";
7
+ export function generateMutants(options) {
8
+ const started = Date.now();
9
+ const limit = options.maxMinutes;
10
+ const over = () => limit != null && Date.now() - started >= limit * 60_000;
11
+ if (over())
12
+ return { mutants: [], violations: [], filesVisited: 0, budgetHit: true };
13
+ const srcRoot = path.resolve(options.packageDir, options.srcDir);
14
+ const walked = walkSources(srcRoot, over);
15
+ const files = walked.files.filter((file) => !options.skipFiles.includes(path.basename(file)));
16
+ const rng = makeRng(options.seed);
17
+ const violations = [];
18
+ const chosen = [];
19
+ let filesVisited = 0;
20
+ let budgetHit = walked.stopped;
21
+ for (const file of files) {
22
+ if (over()) {
23
+ budgetHit = true;
24
+ break;
25
+ }
26
+ filesVisited += 1;
27
+ const text = readFileSync(file, "utf8");
28
+ const rel = posix(path.relative(options.packageDir, file));
29
+ const found = findInSource(rel, text);
30
+ violations.push(...found.violations);
31
+ const ordered = shuffle(found.points, rng);
32
+ const slice = options.perFile === null ? ordered.slice(options.skip) : ordered.slice(options.skip, options.skip + options.perFile);
33
+ for (const point of slice)
34
+ chosen.push({ rel, text, point });
35
+ }
36
+ if (violations.length > 0)
37
+ return { mutants: [], violations, filesVisited, budgetHit };
38
+ const capped = options.maxMutants === null ? chosen : chosen.slice(0, options.maxMutants);
39
+ const packageReal = real(options.packageDir);
40
+ const patchRoot = gitRoot(packageReal);
41
+ const prefix = patchRoot ? path.relative(patchRoot, packageReal) : "";
42
+ const mutantsDir = path.join(options.outDir, "mutants");
43
+ rmSync(mutantsDir, { recursive: true, force: true });
44
+ mkdirSync(mutantsDir, { recursive: true });
45
+ const mutants = [];
46
+ for (const item of capped) {
47
+ const id = mutantId(item.rel, item.point.start, item.point.end, item.point.op);
48
+ const mutated = item.text.slice(0, item.point.start) + item.point.replacement + item.text.slice(item.point.end);
49
+ const label = prefix && !prefix.startsWith("..") ? posix(path.join(prefix, item.rel)) : item.rel;
50
+ const diff = forwardDiff(label, item.text, mutated);
51
+ if (!diff)
52
+ continue;
53
+ const patch = `${id}.patch`;
54
+ writeFileSync(path.join(mutantsDir, patch), diff);
55
+ mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
56
+ }
57
+ writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
58
+ return { mutants, violations, filesVisited, budgetHit };
59
+ }
60
+ function walkSources(dir, over) {
61
+ if (!statExists(dir))
62
+ return { files: [], stopped: false };
63
+ const out = [];
64
+ let stopped = false;
65
+ const walk = (current) => {
66
+ if (over()) {
67
+ stopped = true;
68
+ return;
69
+ }
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(current).sort();
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ if (over()) {
79
+ stopped = true;
80
+ return;
81
+ }
82
+ if (entry === "node_modules" || entry === ".git")
83
+ continue;
84
+ const full = path.join(current, entry);
85
+ let info;
86
+ try {
87
+ info = statSync(full);
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (info.isDirectory()) {
93
+ walk(full);
94
+ if (stopped)
95
+ return;
96
+ }
97
+ else if (isSource(entry))
98
+ out.push(full);
99
+ }
100
+ };
101
+ walk(dir);
102
+ return { files: out, stopped };
103
+ }
104
+ function isSource(name) {
105
+ return isGeneratedSource(name);
106
+ }
107
+ function statExists(file) {
108
+ try {
109
+ statSync(file);
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ function gitRoot(cwd) {
117
+ const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
118
+ if (result.status !== 0)
119
+ return null;
120
+ return real(result.stdout.trim());
121
+ }
122
+ function real(file) {
123
+ try {
124
+ return realpathSync(file);
125
+ }
126
+ catch {
127
+ return path.resolve(file);
128
+ }
129
+ }
130
+ function posix(file) {
131
+ return file.split(path.sep).join("/");
132
+ }
@@ -0,0 +1,100 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ /** Blocks in a Go cover profile whose count is above zero. */
5
+ export function parseCoverProfile(text, modulePath) {
6
+ const hits = [];
7
+ const prefix = modulePath ? `${modulePath}/` : "";
8
+ for (const raw of text.split("\n")) {
9
+ const line = raw.trim();
10
+ if (!line || line.startsWith("mode:"))
11
+ continue;
12
+ const match = /^(.+):(\d+)\.\d+,(\d+)\.\d+\s+\d+\s+(\d+)$/.exec(line);
13
+ if (!match)
14
+ continue;
15
+ if (Number(match[4]) <= 0)
16
+ continue;
17
+ let file = match[1];
18
+ if (prefix && file.startsWith(prefix))
19
+ file = file.slice(prefix.length);
20
+ file = file.split("\\").join("/");
21
+ const start = Number(match[2]);
22
+ const end = Number(match[3]);
23
+ for (let number = start; number <= end; number++)
24
+ hits.push({ file, line: number });
25
+ }
26
+ return hits;
27
+ }
28
+ /**
29
+ * One compiled test binary per package, then one profile per test name.
30
+ * A stock cover profile does not name the test, so each test gets its own.
31
+ */
32
+ export function writeGoCoverage(pkg, testNames, dest) {
33
+ const wanted = new Set(testNames.filter((name) => name.length > 0 && !name.includes("/")));
34
+ if (wanted.size === 0)
35
+ return { ok: false, detail: "go coverage: no test names" };
36
+ const mod = run("go", ["list", "-m"], pkg);
37
+ if (mod.status !== 0)
38
+ return { ok: false, detail: clip(mod.stderr || mod.stdout || mod.error?.message || "go list -m failed") };
39
+ const modulePath = mod.stdout.trim().split("\n")[0]?.trim() ?? "";
40
+ const listed = run("go", ["list", "-f", "{{.ImportPath}}\t{{.Dir}}\t{{len .TestGoFiles}}\t{{len .XTestGoFiles}}", "./..."], pkg);
41
+ if (listed.status !== 0)
42
+ return { ok: false, detail: clip(listed.stderr || listed.stdout || listed.error?.message || "go list failed") };
43
+ const work = path.join(path.dirname(dest), "go-cover");
44
+ mkdirSync(work, { recursive: true });
45
+ const files = {};
46
+ const packages = listed.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
47
+ for (const row of packages) {
48
+ const [importPath, dir, testFiles, xTestFiles] = row.split("\t");
49
+ if (!importPath || !dir)
50
+ continue;
51
+ if (Number(testFiles ?? "0") + Number(xTestFiles ?? "0") <= 0)
52
+ continue;
53
+ const bin = path.join(work, `${safe(importPath)}.test`);
54
+ const compiled = run("go", ["test", "-c", "-covermode=set", "-o", bin, importPath], pkg);
55
+ if (compiled.status !== 0 || compiled.error)
56
+ continue;
57
+ const listedTests = run(bin, ["-test.list", "."], dir);
58
+ if (listedTests.status !== 0)
59
+ continue;
60
+ for (const name of listedTests.stdout.split("\n").map((item) => item.trim()).filter((item) => wanted.has(item))) {
61
+ const profile = path.join(work, `${safe(importPath)}-${safe(name)}.out`);
62
+ run(bin, ["-test.run", `^${escapeRegExp(name)}$`, "-test.coverprofile", profile], dir);
63
+ let text = "";
64
+ try {
65
+ text = readFileSync(profile, "utf8");
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ for (const hit of parseCoverProfile(text, modulePath))
71
+ addHit(files, hit.file, hit.line, name);
72
+ }
73
+ }
74
+ if (Object.keys(files).length === 0)
75
+ return { ok: false, detail: "go coverage: no covered lines" };
76
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
77
+ return { ok: true, detail: `${Object.keys(files).length} files` };
78
+ }
79
+ function addHit(files, file, line, test) {
80
+ const lines = files[file] ?? {};
81
+ const key = String(line);
82
+ const names = lines[key] ?? [];
83
+ if (!names.includes(test))
84
+ names.push(test);
85
+ lines[key] = names;
86
+ files[file] = lines;
87
+ }
88
+ function safe(value) {
89
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
90
+ }
91
+ function escapeRegExp(value) {
92
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
+ }
94
+ function run(bin, args, cwd) {
95
+ return spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 120_000 });
96
+ }
97
+ function clip(text) {
98
+ const line = text.trim().split("\n").find((item) => item.trim()) ?? "go coverage failed";
99
+ return line.slice(0, 240);
100
+ }
@@ -0,0 +1,22 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Stable across reruns: hash of the package-relative file, the span, and the operator. */
3
+ export function mutantId(file, start, end, op) {
4
+ const hex = createHash("sha256").update(`${file}\0${start}\0${end}\0${op}`).digest("hex").slice(0, 12);
5
+ return `m${hex}`;
6
+ }
7
+ /** Same sequence for the same seed. Not the Auspex experiment's floating-point shuffle. */
8
+ export function makeRng(seed) {
9
+ let state = seed >>> 0;
10
+ return () => {
11
+ state = (Math.imul(state, 1103515245) + 12345) >>> 0;
12
+ return state / 4294967296;
13
+ };
14
+ }
15
+ export function shuffle(items, rng) {
16
+ const out = [...items];
17
+ for (let index = out.length - 1; index > 0; index--) {
18
+ const swap = Math.floor(rng() * (index + 1));
19
+ [out[index], out[swap]] = [out[swap], out[index]];
20
+ }
21
+ return out;
22
+ }