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.
- package/README.md +83 -2
- package/dist/cli.js +499 -0
- package/dist/contract.js +29 -0
- package/dist/flags.js +87 -0
- package/dist/golden/check.js +188 -0
- package/dist/ledger/build.js +327 -0
- package/dist/matrix/report.js +256 -0
- package/dist/mcp.js +116 -0
- package/dist/memory/state.js +222 -0
- package/dist/mutate/affected.js +173 -0
- package/dist/mutate/coverage-map.js +172 -0
- package/dist/mutate/fail-reporter.mjs +44 -0
- package/dist/mutate/find.js +13 -0
- package/dist/mutate/generate.js +132 -0
- package/dist/mutate/go-coverage.js +100 -0
- package/dist/mutate/ids.js +22 -0
- package/dist/mutate/java-coverage.js +188 -0
- package/dist/mutate/kill-label.js +34 -0
- package/dist/mutate/node-coverage.mjs +245 -0
- package/dist/mutate/operators.js +122 -0
- package/dist/mutate/patch.js +135 -0
- package/dist/mutate/probatio-jacoco-run.java +81 -0
- package/dist/mutate/py-pytest-reporter.py +145 -0
- package/dist/mutate/py-reporter.py +79 -0
- package/dist/mutate/run.js +868 -0
- package/dist/mutate/suite-decision.js +129 -0
- package/dist/mutate/suites.js +1371 -0
- package/dist/mutate/tally.js +175 -0
- package/dist/mutate/text-operators.js +547 -0
- package/dist/mutate/titles.js +23 -0
- package/dist/swarm/check-kill.js +107 -0
- package/dist/swarm/queue.js +62 -0
- package/dist/verify/change.js +262 -0
- package/package.json +33 -4
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const JACOCO = "0.8.15";
|
|
6
|
+
const ASM = "9.10.1";
|
|
7
|
+
const RUNNER = fileURLToPath(new URL("./probatio-jacoco-run.java", import.meta.url));
|
|
8
|
+
/**
|
|
9
|
+
* One JVM, one JaCoCo dump per Surefire test method.
|
|
10
|
+
* The default jacoco.exec file does not name the test.
|
|
11
|
+
*/
|
|
12
|
+
export function writeJavaCoverage(pkg, testNames, dest) {
|
|
13
|
+
const tests = testNames.map(junitMethod).filter((name) => name !== null);
|
|
14
|
+
if (tests.length === 0)
|
|
15
|
+
return { ok: false, detail: "java coverage: no class.method test names" };
|
|
16
|
+
const classDirs = findClassDirs(pkg);
|
|
17
|
+
if (classDirs.length === 0)
|
|
18
|
+
return { ok: false, detail: "java coverage: no target/classes" };
|
|
19
|
+
const work = path.join(path.dirname(dest), "java-cover");
|
|
20
|
+
mkdirSync(work, { recursive: true });
|
|
21
|
+
const jars = resolveJars(pkg, work);
|
|
22
|
+
if ("error" in jars)
|
|
23
|
+
return { ok: false, detail: jars.error };
|
|
24
|
+
const source = path.join(work, "ProbatioJacocoRun.java");
|
|
25
|
+
writeFileSync(source, readFileSync(RUNNER));
|
|
26
|
+
const compileCp = [jars.agent, jars.junit, jars.core, jars.asm, jars.asmCommons, jars.asmTree].join(path.delimiter);
|
|
27
|
+
const compiled = run(javaTool("javac"), ["-encoding", "UTF-8", "-cp", compileCp, "-d", work, source], pkg);
|
|
28
|
+
if (compiled.status !== 0)
|
|
29
|
+
return { ok: false, detail: clip(compiled.stderr || compiled.stdout || compiled.error?.message || "javac failed") };
|
|
30
|
+
const testsFile = path.join(work, "tests.txt");
|
|
31
|
+
writeFileSync(testsFile, `${tests.join("\n")}\n`);
|
|
32
|
+
const projectCp = buildClasspath(pkg, work);
|
|
33
|
+
if ("error" in projectCp)
|
|
34
|
+
return { ok: false, detail: projectCp.error };
|
|
35
|
+
// Our asm and JaCoCo must win over a project that still ships an older asm. That older reader rejects Java 27 class files.
|
|
36
|
+
const cp = [work, jars.core, jars.asm, jars.asmCommons, jars.asmTree, jars.junit, ...classDirs, ...testClassDirs(pkg), projectCp.cp].filter(Boolean).join(path.delimiter);
|
|
37
|
+
const dump = path.join(work, "hits.txt");
|
|
38
|
+
const ran = run(javaTool("java"), [`-javaagent:${jars.agent}=output=none,excludes=ProbatioJacocoRun`, "-cp", cp, "ProbatioJacocoRun", classDirs.join(path.delimiter), testsFile, dump, pkg], pkg);
|
|
39
|
+
if (ran.error && ran.error.code === "ETIMEDOUT") {
|
|
40
|
+
return { ok: false, detail: "java coverage: timed out" };
|
|
41
|
+
}
|
|
42
|
+
if (ran.status !== 0 || !existsSync(dump)) {
|
|
43
|
+
return { ok: false, detail: clip(ran.stderr || ran.stdout || ran.error?.message || "java coverage runner failed") };
|
|
44
|
+
}
|
|
45
|
+
const files = hitsToMap(readFileSync(dump, "utf8"));
|
|
46
|
+
if (Object.keys(files).length === 0)
|
|
47
|
+
return { ok: false, detail: "java coverage: no covered lines" };
|
|
48
|
+
writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
|
|
49
|
+
return { ok: true, detail: `${Object.keys(files).length} files` };
|
|
50
|
+
}
|
|
51
|
+
export function hitsToMap(text) {
|
|
52
|
+
const files = {};
|
|
53
|
+
for (const raw of text.split("\n")) {
|
|
54
|
+
const line = raw.trim();
|
|
55
|
+
if (!line.startsWith("HIT "))
|
|
56
|
+
continue;
|
|
57
|
+
const parts = line.slice(4).split(" ");
|
|
58
|
+
if (parts.length < 3)
|
|
59
|
+
continue;
|
|
60
|
+
const test = parts.pop() ?? "";
|
|
61
|
+
const number = parts.pop() ?? "";
|
|
62
|
+
const file = parts.join(" ");
|
|
63
|
+
if (!file || !/^\d+$/.test(number) || !test)
|
|
64
|
+
continue;
|
|
65
|
+
const lines = files[file] ?? {};
|
|
66
|
+
const names = lines[number] ?? [];
|
|
67
|
+
if (!names.includes(test))
|
|
68
|
+
names.push(test);
|
|
69
|
+
lines[number] = names;
|
|
70
|
+
files[file] = lines;
|
|
71
|
+
}
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
function junitMethod(name) {
|
|
75
|
+
const leaf = name.split("::").pop() ?? name;
|
|
76
|
+
const dot = leaf.lastIndexOf(".");
|
|
77
|
+
if (dot <= 0 || dot === leaf.length - 1)
|
|
78
|
+
return null;
|
|
79
|
+
if (leaf.startsWith("maven-") || leaf === "javac")
|
|
80
|
+
return null;
|
|
81
|
+
return `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
|
|
82
|
+
}
|
|
83
|
+
function findClassDirs(root) {
|
|
84
|
+
const found = [];
|
|
85
|
+
const walk = (dir, depth) => {
|
|
86
|
+
if (depth > 4)
|
|
87
|
+
return;
|
|
88
|
+
let entries = [];
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(dir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const name of entries) {
|
|
96
|
+
if (name === "node_modules" || name === ".git" || name === "java-cover")
|
|
97
|
+
continue;
|
|
98
|
+
const full = path.join(dir, name);
|
|
99
|
+
if (name === "target") {
|
|
100
|
+
const classes = path.join(full, "classes");
|
|
101
|
+
if (existsSync(classes))
|
|
102
|
+
found.push(classes);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (name.startsWith("."))
|
|
106
|
+
continue;
|
|
107
|
+
walk(full, depth + 1);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(root, 0);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
function testClassDirs(root) {
|
|
114
|
+
return findClassDirs(root).map((dir) => path.join(path.dirname(dir), "test-classes")).filter((dir) => existsSync(dir));
|
|
115
|
+
}
|
|
116
|
+
function resolveJars(pkg, work) {
|
|
117
|
+
const copies = [
|
|
118
|
+
[`org.jacoco:org.jacoco.agent:${JACOCO}:jar:runtime`, "agent.jar"],
|
|
119
|
+
[`org.jacoco:org.jacoco.core:${JACOCO}`, "core.jar"],
|
|
120
|
+
[`org.ow2.asm:asm:${ASM}`, "asm.jar"],
|
|
121
|
+
[`org.ow2.asm:asm-commons:${ASM}`, "asm-commons.jar"],
|
|
122
|
+
[`org.ow2.asm:asm-tree:${ASM}`, "asm-tree.jar"],
|
|
123
|
+
["junit:junit:4.13.2", "junit.jar"],
|
|
124
|
+
];
|
|
125
|
+
const paths = {};
|
|
126
|
+
for (const [artifact, name] of copies) {
|
|
127
|
+
const dest = path.join(work, name);
|
|
128
|
+
if (!existsSync(dest)) {
|
|
129
|
+
const before = new Set(readdirSync(work));
|
|
130
|
+
const copied = run("mvn", ["-B", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy", `-Dartifact=${artifact}`, `-DoutputDirectory=${work}`], pkg);
|
|
131
|
+
if (copied.status !== 0)
|
|
132
|
+
return { error: clip(copied.stderr || copied.stdout || copied.error?.message || `mvn copy ${artifact} failed`) };
|
|
133
|
+
const created = readdirSync(work).find((item) => !before.has(item) && item.endsWith(".jar"));
|
|
134
|
+
if (!created)
|
|
135
|
+
return { error: `java coverage: missing ${artifact}` };
|
|
136
|
+
writeFileSync(dest, readFileSync(path.join(work, created)));
|
|
137
|
+
}
|
|
138
|
+
paths[name] = dest;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
agent: paths["agent.jar"],
|
|
142
|
+
core: paths["core.jar"],
|
|
143
|
+
asm: paths["asm.jar"],
|
|
144
|
+
asmCommons: paths["asm-commons.jar"],
|
|
145
|
+
asmTree: paths["asm-tree.jar"],
|
|
146
|
+
junit: paths["junit.jar"],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function buildClasspath(pkg, work) {
|
|
150
|
+
const file = path.join(work, "classpath.txt");
|
|
151
|
+
const built = run("mvn", ["-B", "-q", "dependency:build-classpath", "-DincludeScope=test", `-Dmdep.outputFile=${file}`], pkg);
|
|
152
|
+
if (built.status !== 0)
|
|
153
|
+
return { error: clip(built.stderr || built.stdout || built.error?.message || "mvn classpath failed") };
|
|
154
|
+
try {
|
|
155
|
+
return { cp: readFileSync(file, "utf8").trim() };
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return { error: "java coverage: classpath file missing" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function javaTool(name) {
|
|
162
|
+
const home = process.env.JAVA_HOME;
|
|
163
|
+
if (home) {
|
|
164
|
+
const candidate = path.join(home, "bin", name);
|
|
165
|
+
if (existsSync(candidate) && toolWorks(candidate))
|
|
166
|
+
return candidate;
|
|
167
|
+
}
|
|
168
|
+
if (toolWorks(name))
|
|
169
|
+
return name;
|
|
170
|
+
for (const dir of ["/opt/homebrew/opt/openjdk/bin", "/usr/local/opt/openjdk/bin"]) {
|
|
171
|
+
const candidate = path.join(dir, name);
|
|
172
|
+
if (existsSync(candidate) && toolWorks(candidate))
|
|
173
|
+
return candidate;
|
|
174
|
+
}
|
|
175
|
+
return name;
|
|
176
|
+
}
|
|
177
|
+
function toolWorks(bin) {
|
|
178
|
+
const probed = spawnSync(bin, ["-version"], { encoding: "utf8" });
|
|
179
|
+
const text = `${probed.stdout ?? ""}${probed.stderr ?? ""}`;
|
|
180
|
+
return probed.status === 0 && !/Unable to locate a Java Runtime/i.test(text);
|
|
181
|
+
}
|
|
182
|
+
function run(bin, args, cwd) {
|
|
183
|
+
return spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 180_000 });
|
|
184
|
+
}
|
|
185
|
+
function clip(text) {
|
|
186
|
+
const line = text.trim().split("\n").find((item) => item.trim()) ?? "java coverage failed";
|
|
187
|
+
return line.slice(0, 240);
|
|
188
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Names that mean the process failed to compile. These are not test titles. */
|
|
2
|
+
const BUILD_NAMES = new Set([
|
|
3
|
+
"build",
|
|
4
|
+
"javac",
|
|
5
|
+
"compiler",
|
|
6
|
+
"cobc",
|
|
7
|
+
"nasm",
|
|
8
|
+
"clang",
|
|
9
|
+
"cargo",
|
|
10
|
+
"tsc",
|
|
11
|
+
"dotnet",
|
|
12
|
+
"swiftc",
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* A named test and a compile failure are different facts.
|
|
16
|
+
* The build next step says the build failed and does not ask for a test
|
|
17
|
+
* named after the compiler.
|
|
18
|
+
*/
|
|
19
|
+
export function killLabel(killedBy) {
|
|
20
|
+
if (killedBy.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
if (killedBy.every((item) => BUILD_NAMES.has(bareName(item)))) {
|
|
23
|
+
return { cause: "build", next: "Killed because the build failed. Record it as a compile kill." };
|
|
24
|
+
}
|
|
25
|
+
return { cause: "test", next: `Killed by ${killedBy.join(", ")}.` };
|
|
26
|
+
}
|
|
27
|
+
/** True when the id names a compiler, not a test. */
|
|
28
|
+
export function isBuildName(id) {
|
|
29
|
+
return BUILD_NAMES.has(bareName(id));
|
|
30
|
+
}
|
|
31
|
+
function bareName(id) {
|
|
32
|
+
const mark = id.lastIndexOf("::");
|
|
33
|
+
return (mark >= 0 ? id.slice(mark + 2) : id).trim().toLowerCase();
|
|
34
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Baseline-only per-test line map for node --test. Loaded with --import in each
|
|
2
|
+
// test-file process. Writes one shard; the runner merges shards after the suite.
|
|
3
|
+
// takePreciseCoverage resets counters, so each sample is only what ran since the last take.
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { Session } from "node:inspector/promises"
|
|
7
|
+
import { findSourceMap } from "node:module"
|
|
8
|
+
import { after, afterEach, before } from "node:test"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
|
|
12
|
+
const B64 = new Int32Array(128)
|
|
13
|
+
for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
|
|
14
|
+
|
|
15
|
+
function decodeSegments(row) {
|
|
16
|
+
const segments = []
|
|
17
|
+
let i = 0
|
|
18
|
+
while (i < row.length) {
|
|
19
|
+
const fields = []
|
|
20
|
+
while (i < row.length && row.charCodeAt(i) !== 44) {
|
|
21
|
+
let value = 0
|
|
22
|
+
let shift = 0
|
|
23
|
+
let digit = 0
|
|
24
|
+
do {
|
|
25
|
+
digit = B64[row.charCodeAt(i++)]
|
|
26
|
+
value |= (digit & 31) << shift
|
|
27
|
+
shift += 5
|
|
28
|
+
} while (digit & 32)
|
|
29
|
+
fields.push(value & 1 ? -(value >> 1) : value >> 1)
|
|
30
|
+
}
|
|
31
|
+
if (row.charCodeAt(i) === 44) i++
|
|
32
|
+
if (fields.length > 0) segments.push(fields)
|
|
33
|
+
}
|
|
34
|
+
return segments
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
38
|
+
if (mapPath) {
|
|
39
|
+
const session = new Session()
|
|
40
|
+
let started = false
|
|
41
|
+
try {
|
|
42
|
+
session.connect()
|
|
43
|
+
await session.post("Profiler.enable")
|
|
44
|
+
await session.post("Debugger.enable")
|
|
45
|
+
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
|
|
46
|
+
started = true
|
|
47
|
+
} catch (err) {
|
|
48
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (started) {
|
|
52
|
+
const textCache = new Map()
|
|
53
|
+
const indexCache = new Map()
|
|
54
|
+
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
55
|
+
const byFile = new Map()
|
|
56
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
57
|
+
let prelude = []
|
|
58
|
+
|
|
59
|
+
async function scriptText(script) {
|
|
60
|
+
if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
|
|
61
|
+
let text = ""
|
|
62
|
+
try {
|
|
63
|
+
const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
|
|
64
|
+
text = got.scriptSource || ""
|
|
65
|
+
} catch {
|
|
66
|
+
text = ""
|
|
67
|
+
}
|
|
68
|
+
textCache.set(script.scriptId, text)
|
|
69
|
+
return text
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function relPath(url) {
|
|
73
|
+
if (!url || url.startsWith("node:")) return null
|
|
74
|
+
let abs
|
|
75
|
+
try {
|
|
76
|
+
abs = fileURLToPath(url.split("?")[0].split("#")[0])
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
|
|
81
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
|
|
82
|
+
if (rel.split("/").includes("node_modules")) return null
|
|
83
|
+
if (rel.endsWith("node-coverage.mjs")) return null
|
|
84
|
+
return rel
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function indexFor(scriptId, text, sm) {
|
|
88
|
+
if (indexCache.has(scriptId)) return indexCache.get(scriptId)
|
|
89
|
+
const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
|
|
90
|
+
const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
|
|
91
|
+
indexCache.set(scriptId, lines)
|
|
92
|
+
return lines
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasLine(lines) {
|
|
96
|
+
for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function linesFromNewlines(text, sm) {
|
|
101
|
+
const lines = new Int32Array(text.length + 1)
|
|
102
|
+
if (!sm) {
|
|
103
|
+
let line = 1
|
|
104
|
+
for (let i = 0; i < text.length; i++) {
|
|
105
|
+
lines[i] = line
|
|
106
|
+
if (text.charCodeAt(i) === 10) line++
|
|
107
|
+
}
|
|
108
|
+
return lines
|
|
109
|
+
}
|
|
110
|
+
// Node 22 reports SourceMap.findEntry originalLine as 0-based.
|
|
111
|
+
let genLine = 0
|
|
112
|
+
let col = 0
|
|
113
|
+
for (let i = 0; i < text.length; i++) {
|
|
114
|
+
const entry = sm.findEntry(genLine, col)
|
|
115
|
+
if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
|
|
116
|
+
if (text.charCodeAt(i) === 10) {
|
|
117
|
+
genLine++
|
|
118
|
+
col = 0
|
|
119
|
+
} else {
|
|
120
|
+
col++
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return lines
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function linesFromMappings(text, mappings) {
|
|
127
|
+
const lines = new Int32Array(text.length + 1)
|
|
128
|
+
const starts = [0]
|
|
129
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
|
|
130
|
+
let carryLine = 0
|
|
131
|
+
const rows = mappings.split(";")
|
|
132
|
+
for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
|
|
133
|
+
const start = starts[genLine]
|
|
134
|
+
const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
|
|
135
|
+
let col = 0
|
|
136
|
+
let cursor = start
|
|
137
|
+
let active = -1
|
|
138
|
+
for (const fields of decodeSegments(rows[genLine])) {
|
|
139
|
+
col += fields[0]
|
|
140
|
+
const at = Math.min(next, start + Math.max(0, col))
|
|
141
|
+
if (active >= 0) {
|
|
142
|
+
for (let i = cursor; i < at; i++) lines[i] = active + 1
|
|
143
|
+
}
|
|
144
|
+
cursor = at
|
|
145
|
+
if (fields.length >= 4) {
|
|
146
|
+
carryLine += fields[2]
|
|
147
|
+
active = carryLine
|
|
148
|
+
} else {
|
|
149
|
+
active = -1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (active >= 0) {
|
|
153
|
+
for (let i = cursor; i < next; i++) lines[i] = active + 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return lines
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyRanges(text, script, lines) {
|
|
160
|
+
const ranges = []
|
|
161
|
+
for (const fn of script.functions || []) {
|
|
162
|
+
for (const range of fn.ranges || []) ranges.push(range)
|
|
163
|
+
}
|
|
164
|
+
// Outer ranges first. A nested block then overwrites its own offsets, so a dead
|
|
165
|
+
// branch stays at 0 while a condition earlier on that same line can stay covered.
|
|
166
|
+
ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
|
|
167
|
+
const offsets = new Int32Array(text.length)
|
|
168
|
+
for (const range of ranges) {
|
|
169
|
+
const end = Math.min(text.length, range.endOffset)
|
|
170
|
+
for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
|
|
171
|
+
}
|
|
172
|
+
const counts = new Map()
|
|
173
|
+
for (let i = 0; i < text.length; i++) {
|
|
174
|
+
const line = lines[i]
|
|
175
|
+
if (line <= 0 || offsets[i] <= 0) continue
|
|
176
|
+
if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
|
|
177
|
+
}
|
|
178
|
+
return counts
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function positiveLines() {
|
|
182
|
+
const cov = await session.post("Profiler.takePreciseCoverage")
|
|
183
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
184
|
+
const found = []
|
|
185
|
+
for (const script of cov.result || []) {
|
|
186
|
+
const file = relPath(script.url)
|
|
187
|
+
if (!file) continue
|
|
188
|
+
const text = await scriptText(script)
|
|
189
|
+
if (!text) continue
|
|
190
|
+
const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
|
|
191
|
+
for (const [line, count] of counts) {
|
|
192
|
+
if (count > 0) found.push({ file, line })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return found
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function add(hits, name) {
|
|
199
|
+
if (!name) return
|
|
200
|
+
for (const hit of hits) {
|
|
201
|
+
let lines = byFile.get(hit.file)
|
|
202
|
+
if (!lines) {
|
|
203
|
+
lines = new Map()
|
|
204
|
+
byFile.set(hit.file, lines)
|
|
205
|
+
}
|
|
206
|
+
let names = lines.get(hit.line)
|
|
207
|
+
if (!names) {
|
|
208
|
+
names = new Set()
|
|
209
|
+
lines.set(hit.line, names)
|
|
210
|
+
}
|
|
211
|
+
names.add(name)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
before(async () => {
|
|
216
|
+
try {
|
|
217
|
+
prelude = await positiveLines()
|
|
218
|
+
} catch (err) {
|
|
219
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
afterEach(async (t) => {
|
|
224
|
+
try {
|
|
225
|
+
add(prelude, t.name)
|
|
226
|
+
add(await positiveLines(), t.name)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
after(() => {
|
|
233
|
+
if (byFile.size === 0) return
|
|
234
|
+
const files = {}
|
|
235
|
+
for (const [file, lines] of byFile) {
|
|
236
|
+
const bucket = {}
|
|
237
|
+
for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
|
|
238
|
+
files[file] = bucket
|
|
239
|
+
}
|
|
240
|
+
const dir = `${mapPath}.parts`
|
|
241
|
+
mkdirSync(dir, { recursive: true })
|
|
242
|
+
writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ files }))
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
const SWAP = {
|
|
3
|
+
[ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
|
|
4
|
+
[ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
|
|
5
|
+
[ts.SyntaxKind.EqualsEqualsEqualsToken]: { op: "eq-to-neq", replacement: "!==" },
|
|
6
|
+
[ts.SyntaxKind.ExclamationEqualsEqualsToken]: { op: "neq-to-eq", replacement: "===" },
|
|
7
|
+
[ts.SyntaxKind.LessThanToken]: { op: "lt-to-le", replacement: "<=" },
|
|
8
|
+
[ts.SyntaxKind.LessThanEqualsToken]: { op: "le-to-lt", replacement: "<" },
|
|
9
|
+
[ts.SyntaxKind.GreaterThanToken]: { op: "gt-to-ge", replacement: ">=" },
|
|
10
|
+
[ts.SyntaxKind.GreaterThanEqualsToken]: { op: "ge-to-gt", replacement: ">" },
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Operator mutants in code only. Strings, comments, and type positions are not code.
|
|
14
|
+
* A condition that contains a string (`name === "x"`) is still code: the span is not
|
|
15
|
+
* itself inside the string.
|
|
16
|
+
*/
|
|
17
|
+
export function findMutants(file, text) {
|
|
18
|
+
const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
19
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
|
|
20
|
+
const raw = [];
|
|
21
|
+
const visit = (node) => {
|
|
22
|
+
if (isTypePosition(node))
|
|
23
|
+
return;
|
|
24
|
+
const point = pointAt(source, file, text, node);
|
|
25
|
+
if (point)
|
|
26
|
+
raw.push(point);
|
|
27
|
+
ts.forEachChild(node, visit);
|
|
28
|
+
};
|
|
29
|
+
visit(source);
|
|
30
|
+
const hidden = hiddenSpans(source, text, scriptKind);
|
|
31
|
+
const points = [];
|
|
32
|
+
const violations = [];
|
|
33
|
+
for (const point of raw) {
|
|
34
|
+
const inside = hidden.some((span) => point.start >= span.start && point.end <= span.end);
|
|
35
|
+
if (inside)
|
|
36
|
+
violations.push(point);
|
|
37
|
+
else
|
|
38
|
+
points.push(point);
|
|
39
|
+
}
|
|
40
|
+
return { points, violations };
|
|
41
|
+
}
|
|
42
|
+
function isTypePosition(node) {
|
|
43
|
+
return (ts.isTypeNode(node) ||
|
|
44
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
45
|
+
ts.isInterfaceDeclaration(node) ||
|
|
46
|
+
ts.isTypeParameterDeclaration(node) ||
|
|
47
|
+
ts.isIndexSignatureDeclaration(node));
|
|
48
|
+
}
|
|
49
|
+
function pointAt(source, file, text, node) {
|
|
50
|
+
if (ts.isIfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
|
|
51
|
+
return negate(source, file, text, node.expression, "negate-condition");
|
|
52
|
+
}
|
|
53
|
+
if (ts.isConditionalExpression(node)) {
|
|
54
|
+
return negate(source, file, text, node.condition, "negate-ternary");
|
|
55
|
+
}
|
|
56
|
+
if (ts.isForStatement(node) && node.condition) {
|
|
57
|
+
return negate(source, file, text, node.condition, "negate-condition");
|
|
58
|
+
}
|
|
59
|
+
if (ts.isBinaryExpression(node)) {
|
|
60
|
+
const swap = SWAP[node.operatorToken.kind];
|
|
61
|
+
if (!swap)
|
|
62
|
+
return undefined;
|
|
63
|
+
const start = node.operatorToken.getStart(source);
|
|
64
|
+
const end = node.operatorToken.getEnd();
|
|
65
|
+
return make(source, file, text, start, end, swap.op, swap.replacement);
|
|
66
|
+
}
|
|
67
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
|
|
68
|
+
const start = node.getStart(source);
|
|
69
|
+
const end = node.getEnd();
|
|
70
|
+
const original = text.slice(start, end);
|
|
71
|
+
const replacement = original === "true" ? "false" : "true";
|
|
72
|
+
const op = original === "true" ? "true-to-false" : "false-to-true";
|
|
73
|
+
return make(source, file, text, start, end, op, replacement);
|
|
74
|
+
}
|
|
75
|
+
if (ts.isPrefixUnaryExpression(node) &&
|
|
76
|
+
node.operator === ts.SyntaxKind.ExclamationToken &&
|
|
77
|
+
!ts.isPrefixUnaryExpression(node.operand)) {
|
|
78
|
+
const start = node.getStart(source);
|
|
79
|
+
const end = node.operand.getStart(source);
|
|
80
|
+
return make(source, file, text, start, end, "drop-not", "");
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function negate(source, file, text, expr, op) {
|
|
85
|
+
const start = expr.getStart(source);
|
|
86
|
+
const end = expr.getEnd();
|
|
87
|
+
return make(source, file, text, start, end, op, `!(${text.slice(start, end)})`);
|
|
88
|
+
}
|
|
89
|
+
function make(source, file, text, start, end, op, replacement) {
|
|
90
|
+
const { line } = source.getLineAndCharacterOfPosition(start);
|
|
91
|
+
return { file, start, end, line: line + 1, op, original: text.slice(start, end), replacement };
|
|
92
|
+
}
|
|
93
|
+
function hiddenSpans(source, text, scriptKind) {
|
|
94
|
+
const literals = [];
|
|
95
|
+
const visit = (node) => {
|
|
96
|
+
if (ts.isStringLiteral(node) ||
|
|
97
|
+
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
98
|
+
ts.isTemplateHead(node) ||
|
|
99
|
+
ts.isTemplateMiddle(node) ||
|
|
100
|
+
ts.isTemplateTail(node) ||
|
|
101
|
+
ts.isRegularExpressionLiteral(node) ||
|
|
102
|
+
ts.isJsxText(node)) {
|
|
103
|
+
literals.push({ start: node.getStart(source), end: node.getEnd() });
|
|
104
|
+
}
|
|
105
|
+
ts.forEachChild(node, visit);
|
|
106
|
+
};
|
|
107
|
+
visit(source);
|
|
108
|
+
const variant = scriptKind === ts.ScriptKind.TSX ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard;
|
|
109
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, variant, text);
|
|
110
|
+
const comments = [];
|
|
111
|
+
let token = scanner.scan();
|
|
112
|
+
while (token !== ts.SyntaxKind.EndOfFileToken) {
|
|
113
|
+
const leading = ts.getLeadingCommentRanges(text, scanner.getTokenStart()) ?? [];
|
|
114
|
+
const trailing = ts.getTrailingCommentRanges(text, scanner.getTokenEnd()) ?? [];
|
|
115
|
+
for (const comment of [...leading, ...trailing])
|
|
116
|
+
comments.push({ start: comment.pos, end: comment.end });
|
|
117
|
+
token = scanner.scan();
|
|
118
|
+
}
|
|
119
|
+
// A `//` inside a template is text, not a comment. Drop ranges that begin in a literal.
|
|
120
|
+
const real = comments.filter((comment) => !literals.some((span) => comment.start >= span.start && comment.start < span.end));
|
|
121
|
+
return [...literals, ...real];
|
|
122
|
+
}
|