probatio 0.1.3 → 0.3.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/README.md +93 -8
- package/dist/cli.js +136 -6
- package/dist/contract.js +2 -1
- package/dist/golden/record.js +395 -0
- package/dist/golden/recorder.mjs +78 -0
- package/dist/ledger/build.js +21 -0
- package/dist/ledger/check.js +133 -0
- package/dist/mcp.js +76 -27
- package/dist/mutate/child-lines.mjs +23 -18
- package/dist/mutate/fail-reporter.mjs +21 -0
- package/dist/mutate/find.js +3 -3
- package/dist/mutate/generate.js +5 -2
- package/dist/mutate/load-failure.mjs +40 -0
- package/dist/mutate/mocha-coverage.cjs +7 -1
- package/dist/mutate/node-batch-loader.mjs +15 -1
- package/dist/mutate/node-batch.mjs +40 -3
- package/dist/mutate/node-coverage.mjs +7 -32
- package/dist/mutate/operators.js +60 -1
- package/dist/mutate/patch.js +2 -1
- package/dist/mutate/precise-lines.mjs +147 -31
- package/dist/mutate/probatio-jacoco-run.java +2 -2
- package/dist/mutate/project-config.js +58 -0
- package/dist/mutate/run.js +100 -19
- package/dist/mutate/sealed.js +54 -0
- package/dist/mutate/suite-decision.js +2 -1
- package/dist/mutate/suites.js +87 -155
- package/dist/mutate/tally.js +54 -26
- package/dist/mutate/text-operators.js +92 -2
- package/dist/swarm/check-kill.js +14 -7
- package/dist/verify/change.js +1 -1
- package/package.json +8 -4
- package/schemas/check-kill.schema.json +93 -0
- package/schemas/findings.add.schema.json +53 -0
- package/schemas/gap.fix.schema.json +53 -0
- package/schemas/gap.revert.schema.json +53 -0
- package/schemas/golden.check.schema.json +89 -0
- package/schemas/golden.compare.schema.json +151 -0
- package/schemas/golden.record.schema.json +126 -0
- package/schemas/guard.check.schema.json +53 -0
- package/schemas/ledger.build.schema.json +132 -0
- package/schemas/ledger.check.schema.json +143 -0
- package/schemas/matrix.report.schema.json +129 -0
- package/schemas/mcp.schema.json +53 -0
- package/schemas/mutate.generate.schema.json +127 -0
- package/schemas/mutate.help.schema.json +53 -0
- package/schemas/mutate.run.schema.json +272 -0
- package/schemas/mutate.sealed.schema.json +53 -0
- package/schemas/mutate.tally.schema.json +137 -0
- package/schemas/queue.claim.schema.json +53 -0
- package/schemas/queue.reap.schema.json +71 -0
- package/schemas/queue.seed.schema.json +71 -0
- package/schemas/schema.schema.json +74 -0
- package/schemas/seal.schema.json +69 -0
- package/schemas/status.schema.json +103 -0
- package/schemas/verify-change.schema.json +222 -0
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -7,7 +7,7 @@ import { nodeTooOld } from "./node-version.js";
|
|
|
7
7
|
const tooOld = nodeTooOld(process.versions.node);
|
|
8
8
|
if (tooOld) {
|
|
9
9
|
process.stdout.write(`${JSON.stringify({
|
|
10
|
-
schemaVersion:
|
|
10
|
+
schemaVersion: 2,
|
|
11
11
|
ok: false,
|
|
12
12
|
command: "mcp",
|
|
13
13
|
summary: tooOld,
|
|
@@ -16,6 +16,8 @@ if (tooOld) {
|
|
|
16
16
|
})}\n`);
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
|
+
/** Protocol versions this server speaks. The client's choice wins when it is one of these. */
|
|
20
|
+
const PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18"];
|
|
19
21
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const version = readVersion();
|
|
21
23
|
const tool = {
|
|
@@ -28,6 +30,9 @@ const tool = {
|
|
|
28
30
|
},
|
|
29
31
|
};
|
|
30
32
|
let buffer = "";
|
|
33
|
+
let closing = false;
|
|
34
|
+
/** Tool calls still running, by request id. A cancel kills the child and drops the reply. */
|
|
35
|
+
const running = new Map();
|
|
31
36
|
process.stdin.setEncoding("utf8");
|
|
32
37
|
process.stdin.on("data", (chunk) => {
|
|
33
38
|
buffer += chunk;
|
|
@@ -35,7 +40,10 @@ process.stdin.on("data", (chunk) => {
|
|
|
35
40
|
});
|
|
36
41
|
process.stdin.on("end", () => {
|
|
37
42
|
drain(true);
|
|
38
|
-
|
|
43
|
+
closing = true;
|
|
44
|
+
// A call already running still gets its reply before the server exits.
|
|
45
|
+
if (running.size === 0)
|
|
46
|
+
process.exit(0);
|
|
39
47
|
});
|
|
40
48
|
function drain(flush = false) {
|
|
41
49
|
const lines = buffer.split("\n");
|
|
@@ -57,6 +65,22 @@ function drain(flush = false) {
|
|
|
57
65
|
function handle(message) {
|
|
58
66
|
if (!message.method)
|
|
59
67
|
return;
|
|
68
|
+
if (message.method === "notifications/cancelled") {
|
|
69
|
+
const key = message.params?.requestId;
|
|
70
|
+
if (key === undefined)
|
|
71
|
+
return;
|
|
72
|
+
const child = running.get(String(key));
|
|
73
|
+
running.delete(String(key));
|
|
74
|
+
if (child?.pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-child.pid, "SIGTERM");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
child.kill("SIGTERM");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
60
84
|
if (message.id === undefined || message.id === null)
|
|
61
85
|
return;
|
|
62
86
|
if (message.method === "initialize") {
|
|
@@ -64,7 +88,7 @@ function handle(message) {
|
|
|
64
88
|
jsonrpc: "2.0",
|
|
65
89
|
id: message.id,
|
|
66
90
|
result: {
|
|
67
|
-
protocolVersion: "
|
|
91
|
+
protocolVersion: PROTOCOLS.includes(message.params?.protocolVersion ?? "") ? message.params?.protocolVersion : PROTOCOLS[0],
|
|
68
92
|
capabilities: { tools: {} },
|
|
69
93
|
serverInfo: { name: "probatio", version },
|
|
70
94
|
tools: [tool],
|
|
@@ -86,29 +110,7 @@ function handle(message) {
|
|
|
86
110
|
send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "argv must be an array of strings" } });
|
|
87
111
|
return;
|
|
88
112
|
}
|
|
89
|
-
|
|
90
|
-
const child = spawnSync(cli.bin, [...cli.args, ...argv], { encoding: "utf8" });
|
|
91
|
-
const text = child.stdout ?? "";
|
|
92
|
-
let envelope = null;
|
|
93
|
-
try {
|
|
94
|
-
envelope = JSON.parse(text);
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
envelope = null;
|
|
98
|
-
}
|
|
99
|
-
if (!envelope || typeof envelope !== "object") {
|
|
100
|
-
send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "the command did not return one JSON object" } });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
send({
|
|
104
|
-
jsonrpc: "2.0",
|
|
105
|
-
id: message.id,
|
|
106
|
-
result: {
|
|
107
|
-
content: [{ type: "text", text: text.trim() }],
|
|
108
|
-
structuredContent: envelope,
|
|
109
|
-
isError: envelope.ok !== true,
|
|
110
|
-
},
|
|
111
|
-
});
|
|
113
|
+
call(message.id, argv);
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
send({
|
|
@@ -117,6 +119,53 @@ function handle(message) {
|
|
|
117
119
|
error: { code: -32601, message: `method not found: ${message.method}` },
|
|
118
120
|
});
|
|
119
121
|
}
|
|
122
|
+
/** One CLI command per call. It runs beside other calls, so ping and cancel are answered meanwhile. */
|
|
123
|
+
function call(id, argv) {
|
|
124
|
+
const key = String(id);
|
|
125
|
+
const cli = cliCommand();
|
|
126
|
+
// Its own process group, so a cancel also stops the suite the command started.
|
|
127
|
+
const child = spawn(cli.bin, [...cli.args, ...argv], { stdio: ["ignore", "pipe", "ignore"], detached: true });
|
|
128
|
+
running.set(key, child);
|
|
129
|
+
let text = "";
|
|
130
|
+
child.stdout?.setEncoding("utf8");
|
|
131
|
+
child.stdout?.on("data", (chunk) => {
|
|
132
|
+
text += chunk;
|
|
133
|
+
});
|
|
134
|
+
const settle = (failure) => {
|
|
135
|
+
// A cancelled call is no longer in the map. Its reply is dropped.
|
|
136
|
+
const live = running.get(key) === child;
|
|
137
|
+
running.delete(key);
|
|
138
|
+
if (live)
|
|
139
|
+
reply(id, text, failure);
|
|
140
|
+
if (closing && running.size === 0)
|
|
141
|
+
process.exit(0);
|
|
142
|
+
};
|
|
143
|
+
child.on("error", (error) => settle(error.message));
|
|
144
|
+
child.on("close", () => settle(null));
|
|
145
|
+
}
|
|
146
|
+
function reply(id, text, failure) {
|
|
147
|
+
let envelope = null;
|
|
148
|
+
try {
|
|
149
|
+
envelope = JSON.parse(text);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
envelope = null;
|
|
153
|
+
}
|
|
154
|
+
if (!envelope || typeof envelope !== "object") {
|
|
155
|
+
const message = failure ? `the command did not start: ${failure}` : "the command did not return one JSON object";
|
|
156
|
+
send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
send({
|
|
160
|
+
jsonrpc: "2.0",
|
|
161
|
+
id,
|
|
162
|
+
result: {
|
|
163
|
+
content: [{ type: "text", text: text.trim() }],
|
|
164
|
+
structuredContent: envelope,
|
|
165
|
+
isError: envelope.ok !== true,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
120
169
|
function cliCommand() {
|
|
121
170
|
const compiled = path.join(here, "cli.js");
|
|
122
171
|
if (existsSync(compiled))
|
|
@@ -1,34 +1,39 @@
|
|
|
1
|
-
// Nameless line dump for a
|
|
1
|
+
// Nameless line dump for a node child of a test. This is not a node:test process and it
|
|
2
2
|
// does not write a coverage shard. The parent test's afterEach reads the dump.
|
|
3
|
+
// The test-process collector puts this file in NODE_OPTIONS, so the child needs no import.
|
|
4
|
+
// The dump is written on `exit`, which also runs after process.exit(), so it is synchronous.
|
|
3
5
|
import { randomBytes } from "node:crypto"
|
|
4
6
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
5
7
|
import path from "node:path"
|
|
6
|
-
import {
|
|
8
|
+
import { openSyncLineSampler } from "./precise-lines.mjs"
|
|
7
9
|
|
|
8
10
|
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
9
|
-
if (mapPath) {
|
|
11
|
+
if (mapPath && !globalThis.__probatioChildLines) {
|
|
12
|
+
globalThis.__probatioChildLines = true
|
|
10
13
|
let sampler = null
|
|
11
14
|
try {
|
|
12
|
-
sampler =
|
|
15
|
+
sampler = openSyncLineSampler(
|
|
16
|
+
(rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"),
|
|
17
|
+
process.env.PROBATIO_PACKAGE_ROOT || process.cwd(),
|
|
18
|
+
)
|
|
13
19
|
} catch (err) {
|
|
14
20
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
15
21
|
}
|
|
16
22
|
if (sampler) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
+
process.on("exit", () => {
|
|
24
|
+
// A node:test process that loaded this through an inherited NODE_OPTIONS has its own
|
|
25
|
+
// hooks. Its hits are already named. A nameless dump would land on the wrong test.
|
|
26
|
+
if (globalThis.__probatioTestHooks) return
|
|
27
|
+
try {
|
|
28
|
+
const hits = sampler.positiveLines()
|
|
29
|
+
// A child started by a mapped test writes where that test's process reads.
|
|
30
|
+
// A child that was not (a script that imports this file itself) uses the shared dir.
|
|
31
|
+
const dir = process.env.PROBATIO_CHILD_DIR || `${mapPath}.children`
|
|
23
32
|
mkdirSync(dir, { recursive: true })
|
|
24
|
-
writeFileSync(
|
|
25
|
-
|
|
26
|
-
JSON.stringify({ hits }),
|
|
27
|
-
)
|
|
28
|
-
}).catch((err) => {
|
|
33
|
+
writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ hits }))
|
|
34
|
+
} catch (err) {
|
|
29
35
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
process.on("beforeExit", dump)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
33
38
|
}
|
|
34
39
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// node --test custom reporter. One JSON object on stdout: failures with the file
|
|
2
2
|
// the runner itself recorded. TAP headings are not used.
|
|
3
|
+
import { staticLoadFailure } from "./load-failure.mjs"
|
|
4
|
+
|
|
3
5
|
export default async function* reporter(source) {
|
|
6
|
+
// A file that fails to load prints its error on stderr before the file-level failure.
|
|
7
|
+
const stderr = new Map()
|
|
4
8
|
const failed = []
|
|
5
9
|
const names = []
|
|
6
10
|
let tests = 0
|
|
7
11
|
let pass = 0
|
|
8
12
|
let fail = 0
|
|
9
13
|
for await (const event of source) {
|
|
14
|
+
if (event.type === "test:stderr") {
|
|
15
|
+
const key = String(event.data?.file ?? "")
|
|
16
|
+
stderr.set(key, `${stderr.get(key) ?? ""}${String(event.data?.message ?? "")}`)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
10
19
|
if (event.type === "test:pass" || event.type === "test:fail") {
|
|
11
20
|
const data = event.data ?? {}
|
|
12
21
|
if (data.details && data.details.type === "suite") continue
|
|
@@ -18,6 +27,12 @@ export default async function* reporter(source) {
|
|
|
18
27
|
// An open handle is reported as a timeout whose name is the file path.
|
|
19
28
|
// failureType lives on the error; the text is on error.cause, not message.
|
|
20
29
|
if (fileTimedOut(name, file, data.details?.error)) failure.fileTimeout = true
|
|
30
|
+
// The file itself failed and no test in it ran: it did not load.
|
|
31
|
+
else if (isFileName(name, file)) {
|
|
32
|
+
failure.fileLoad = true
|
|
33
|
+
// It did not link (removed export, missing module, no parse): no test code ran.
|
|
34
|
+
if (staticLoadFailure(stderr.get(file) ?? "")) failure.staticLoad = true
|
|
35
|
+
}
|
|
21
36
|
failed.push(failure)
|
|
22
37
|
}
|
|
23
38
|
} else if (event.type === "test:diagnostic") {
|
|
@@ -33,6 +48,12 @@ export default async function* reporter(source) {
|
|
|
33
48
|
yield JSON.stringify({ tests, pass, fail, failed, names })
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
function isFileName(name, file) {
|
|
52
|
+
const base = String(file).split(/[/\\]/).pop()
|
|
53
|
+
if (!base) return false
|
|
54
|
+
return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
function fileTimedOut(name, file, error) {
|
|
37
58
|
if (!error || typeof error !== "object") return false
|
|
38
59
|
const cause = typeof error.cause === "string" ? error.cause : ""
|
package/dist/mutate/find.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { findMutants } from "./operators.js";
|
|
2
2
|
import { findTextMutants, textKind } from "./text-operators.js";
|
|
3
3
|
/** TypeScript stays on the TypeScript parser. Every other recognized source uses its own finder. */
|
|
4
|
-
export function findInSource(file, text) {
|
|
4
|
+
export function findInSource(file, text, operators = "core") {
|
|
5
5
|
if (textKind(file))
|
|
6
|
-
return findTextMutants(file, text);
|
|
7
|
-
return findMutants(file, text);
|
|
6
|
+
return findTextMutants(file, text, operators);
|
|
7
|
+
return findMutants(file, text, operators);
|
|
8
8
|
}
|
|
9
9
|
export function isGeneratedSource(name) {
|
|
10
10
|
if (textKind(name))
|
package/dist/mutate/generate.js
CHANGED
|
@@ -32,7 +32,7 @@ export function generateMutants(options) {
|
|
|
32
32
|
source = loaded.source;
|
|
33
33
|
const text = loaded.text;
|
|
34
34
|
const rel = posix(path.relative(options.packageDir, file));
|
|
35
|
-
const found = findInSource(rel, text);
|
|
35
|
+
const found = findInSource(rel, text, options.operators ?? "core");
|
|
36
36
|
violations.push(...found.violations);
|
|
37
37
|
const ordered = shuffle(found.points, rng);
|
|
38
38
|
const slice = options.perFile === null ? ordered.slice(options.skip) : ordered.slice(options.skip, options.skip + options.perFile);
|
|
@@ -60,7 +60,7 @@ export function generateMutants(options) {
|
|
|
60
60
|
writeFileSync(path.join(mutantsDir, patch), diff);
|
|
61
61
|
mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
|
|
62
62
|
}
|
|
63
|
-
writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
|
|
63
|
+
writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, operators: options.operators ?? "core", mutants }, null, 2)}\n`);
|
|
64
64
|
return { mutants, violations, filesVisited, budgetHit, source };
|
|
65
65
|
}
|
|
66
66
|
function fileText(packageDir, absFile, options) {
|
|
@@ -81,6 +81,9 @@ function fileText(packageDir, absFile, options) {
|
|
|
81
81
|
function walkSources(dir, over) {
|
|
82
82
|
if (!statExists(dir))
|
|
83
83
|
return { files: [], stopped: false };
|
|
84
|
+
// `--src src/text.ts` mutates that one file.
|
|
85
|
+
if (statSync(dir).isFile())
|
|
86
|
+
return { files: isSource(path.basename(dir)) ? [dir] : [], stopped: false };
|
|
84
87
|
const out = [];
|
|
85
88
|
let stopped = false;
|
|
86
89
|
const walk = (current) => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A test file that failed before any test ran either did not link or threw while loading.
|
|
2
|
+
// Did not link: a removed export, a missing module, a file that does not parse. No code ran.
|
|
3
|
+
// A compiled language rejects the same change at compile time, so that mutant is unviable.
|
|
4
|
+
// Threw while loading: the file's own top-level code ran the program and it broke. That is a kill.
|
|
5
|
+
|
|
6
|
+
const STATIC_ERROR = /^(?:SyntaxError|(?:Type)?Error \[ERR_(?:MODULE_NOT_FOUND|UNKNOWN_FILE_EXTENSION|UNSUPPORTED_DIR_IMPORT|PACKAGE_PATH_NOT_EXPORTED)\]):/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `text` is the error as printed: a `Name: message` line, then `at` frames.
|
|
10
|
+
* Static when the error is a link or parse error and no frame is in the project's own code.
|
|
11
|
+
* A SyntaxError thrown by running code (JSON.parse at the top of a test) has a frame in that file.
|
|
12
|
+
*/
|
|
13
|
+
export function staticLoadFailure(text) {
|
|
14
|
+
const lines = String(text).split(/\r?\n/)
|
|
15
|
+
const head = lines.findIndex((line) => STATIC_ERROR.test(line.trim()))
|
|
16
|
+
if (head === -1) return false
|
|
17
|
+
for (const raw of lines.slice(head + 1)) {
|
|
18
|
+
const line = raw.trim()
|
|
19
|
+
if (!line.startsWith("at ")) break
|
|
20
|
+
if (projectFrame(line)) return false
|
|
21
|
+
}
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The error object a node:test event carries, printed the way node prints it. */
|
|
26
|
+
export function errorText(error) {
|
|
27
|
+
if (!error || typeof error !== "object") return ""
|
|
28
|
+
const name = typeof error.name === "string" ? error.name : "Error"
|
|
29
|
+
const code = typeof error.code === "string" ? ` [${error.code}]` : ""
|
|
30
|
+
const message = typeof error.message === "string" ? error.message : ""
|
|
31
|
+
const stack = typeof error.stack === "string" ? error.stack.split("\n").slice(1).join("\n") : ""
|
|
32
|
+
return `${name}${code}: ${message}\n${stack}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function projectFrame(frame) {
|
|
36
|
+
if (/\bnode:/.test(frame) || frame.includes("<anonymous>")) return false
|
|
37
|
+
const location = /(?:file:\/\/)?(\/[^\s)]+|[A-Za-z]:\\[^\s)]+):\d+:\d+\)?$/.exec(frame)
|
|
38
|
+
if (!location) return false
|
|
39
|
+
return !location[1].split(/[\\/]/).includes("node_modules")
|
|
40
|
+
}
|
|
@@ -12,6 +12,8 @@ if (mapPath) {
|
|
|
12
12
|
let prelude = []
|
|
13
13
|
/** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
|
|
14
14
|
let sampler = null
|
|
15
|
+
/** @type {((mapPath: string) => Array<{ file: string, line: number }>) | null} */
|
|
16
|
+
let takeChildHits = null
|
|
15
17
|
|
|
16
18
|
function add(hits, name) {
|
|
17
19
|
if (!name) return
|
|
@@ -40,7 +42,10 @@ if (mapPath) {
|
|
|
40
42
|
async beforeAll() {
|
|
41
43
|
try {
|
|
42
44
|
const opened = await import("./precise-lines.mjs")
|
|
43
|
-
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs"))
|
|
45
|
+
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs") || rel.endsWith("child-lines.mjs"))
|
|
46
|
+
// A node child of a Mocha test is mapped under that test, the same as under node:test.
|
|
47
|
+
opened.exposeChildLines()
|
|
48
|
+
takeChildHits = opened.takeChildHits
|
|
44
49
|
prelude = await sampler.positiveLines()
|
|
45
50
|
} catch (err) {
|
|
46
51
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
@@ -52,6 +57,7 @@ if (mapPath) {
|
|
|
52
57
|
try {
|
|
53
58
|
add(prelude, title)
|
|
54
59
|
add(await sampler.positiveLines(), title)
|
|
60
|
+
if (takeChildHits) add(takeChildHits(mapPath), title)
|
|
55
61
|
} catch (err) {
|
|
56
62
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
57
63
|
}
|
|
@@ -40,8 +40,22 @@ function underPackage(abs) {
|
|
|
40
40
|
return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** TypeScript with NodeNext writes `./limit.js` for `limit.ts`. tsx resolves that, so this loader does too. */
|
|
44
|
+
function typeScriptSibling(specifier) {
|
|
45
|
+
if (!/^(\.{1,2}\/|\/|file:)/.test(specifier)) return null
|
|
46
|
+
const swapped = specifier.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts").replace(/\.cjs$/, ".cts").replace(/\.jsx$/, ".tsx")
|
|
47
|
+
return swapped === specifier ? null : swapped
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
export async function resolve(specifier, context, nextResolve) {
|
|
44
|
-
|
|
51
|
+
let resolved
|
|
52
|
+
try {
|
|
53
|
+
resolved = await nextResolve(specifier, context)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const sibling = typeScriptSibling(specifier)
|
|
56
|
+
if (!sibling) throw error
|
|
57
|
+
resolved = await nextResolve(sibling, context)
|
|
58
|
+
}
|
|
45
59
|
const gen = generation()
|
|
46
60
|
if (!gen) return resolved
|
|
47
61
|
let abs
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// One suite process for covered mutants of one file. Jobs arrive on stdin.
|
|
2
2
|
// A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
|
|
3
3
|
import { createInterface } from "node:readline"
|
|
4
|
-
import { realpathSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
5
5
|
import { register } from "node:module"
|
|
6
6
|
import { run } from "node:test"
|
|
7
7
|
import path from "node:path"
|
|
8
|
+
import { errorText, staticLoadFailure } from "./load-failure.mjs"
|
|
8
9
|
import { openLineSampler } from "./precise-lines.mjs"
|
|
9
10
|
|
|
10
11
|
const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
|
|
@@ -33,6 +34,12 @@ async function runJob(job) {
|
|
|
33
34
|
const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
|
|
34
35
|
const failed = []
|
|
35
36
|
const names = []
|
|
37
|
+
// run() with isolation "none" ignores testNamePatterns on Node 22, so every test in the file runs.
|
|
38
|
+
// Only a requested test, or a subtest of one, is a result. Another test failing is not a kill.
|
|
39
|
+
const wanted = new Set((job.names || []).filter((name) => name.length > 0))
|
|
40
|
+
const stack = []
|
|
41
|
+
const requested = (name, nesting) =>
|
|
42
|
+
wanted.size === 0 || wanted.has(name) || stack.slice(0, nesting).some((parent) => wanted.has(parent))
|
|
36
43
|
const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
|
|
37
44
|
const stream = run({
|
|
38
45
|
files,
|
|
@@ -47,10 +54,28 @@ async function runJob(job) {
|
|
|
47
54
|
const collected = (async () => {
|
|
48
55
|
for await (const event of stream) {
|
|
49
56
|
lastEvent = Date.now()
|
|
50
|
-
if (event.type !== "test:pass" && event.type !== "test:fail") continue
|
|
51
57
|
const data = event.data || {}
|
|
58
|
+
const nesting = Number.isInteger(data.nesting) ? data.nesting : 0
|
|
59
|
+
if (event.type === "test:start") {
|
|
60
|
+
stack.length = nesting
|
|
61
|
+
stack[nesting] = typeof data.name === "string" ? data.name : ""
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (event.type !== "test:pass" && event.type !== "test:fail") continue
|
|
52
65
|
const name = typeof data.name === "string" ? data.name : ""
|
|
53
|
-
if (!name
|
|
66
|
+
if (!name) continue
|
|
67
|
+
if (files.includes(name)) {
|
|
68
|
+
// The file itself failed: it did not load (a removed export, a throw at import time).
|
|
69
|
+
// No test in it ran. That is a failure of the file, the same as node --test reports it.
|
|
70
|
+
if (event.type === "test:fail" && fileHasRequested(name, wanted)) {
|
|
71
|
+
const posix = path.relative(job.root, name).split(path.sep).join("/")
|
|
72
|
+
const linked = !staticLoadFailure(errorText(data.details && data.details.error))
|
|
73
|
+
failed.push({ name: posix, file: name, line: 1, fileLoad: true, ...(linked ? {} : { staticLoad: true }) })
|
|
74
|
+
if (!names.includes(posix)) names.push(posix)
|
|
75
|
+
}
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (!requested(name, nesting)) continue
|
|
54
79
|
if (!names.includes(name)) names.push(name)
|
|
55
80
|
if (event.type === "test:fail") {
|
|
56
81
|
const error = data.details && data.details.error
|
|
@@ -101,6 +126,18 @@ async function runJob(job) {
|
|
|
101
126
|
return { id: job.id, pid: process.pid, failed, names, command, dirty }
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
/** A file with no requested test in it does not speak for the requested tests. */
|
|
130
|
+
function fileHasRequested(file, wanted) {
|
|
131
|
+
if (wanted.size === 0) return true
|
|
132
|
+
let text = ""
|
|
133
|
+
try {
|
|
134
|
+
text = readFileSync(file, "utf8")
|
|
135
|
+
} catch {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
return [...wanted].some((name) => text.includes(name))
|
|
139
|
+
}
|
|
140
|
+
|
|
104
141
|
function fileNameIs(name, file) {
|
|
105
142
|
const base = String(file).split(/[/\\]/).pop()
|
|
106
143
|
if (!base) return false
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Baseline-only per-test line map for node --test. Loaded with --import in each
|
|
2
2
|
// test-file process. Writes one shard; the runner merges shards after the suite.
|
|
3
3
|
// takePreciseCoverage resets counters, so each sample is only what ran since the last take.
|
|
4
|
-
// A
|
|
5
|
-
//
|
|
4
|
+
// A node child of a test does not run these hooks. exposeChildLines puts child-lines.mjs in
|
|
5
|
+
// its NODE_OPTIONS, so it leaves a nameless hit list in ${map}.children when it exits.
|
|
6
|
+
// afterEach stores that list under the parent test before the shard is written.
|
|
6
7
|
import { randomBytes } from "node:crypto"
|
|
7
|
-
import {
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
8
9
|
import { after, afterEach, before } from "node:test"
|
|
9
10
|
import path from "node:path"
|
|
10
|
-
import { openLineSampler } from "./precise-lines.mjs"
|
|
11
|
+
import { exposeChildLines, openLineSampler, takeChildHits } from "./precise-lines.mjs"
|
|
11
12
|
|
|
12
13
|
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
13
14
|
if (mapPath) {
|
|
@@ -19,6 +20,7 @@ if (mapPath) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
if (sampler) {
|
|
23
|
+
exposeChildLines()
|
|
22
24
|
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
23
25
|
const byFile = new Map()
|
|
24
26
|
/** @type {Array<{ file: string, line: number }>} */
|
|
@@ -41,33 +43,6 @@ if (mapPath) {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
function takeChildHits() {
|
|
45
|
-
const dir = `${mapPath}.children`
|
|
46
|
-
if (!existsSync(dir)) return []
|
|
47
|
-
/** @type {Array<{ file: string, line: number }>} */
|
|
48
|
-
const hits = []
|
|
49
|
-
for (const name of readdirSync(dir)) {
|
|
50
|
-
if (!name.endsWith(".json")) continue
|
|
51
|
-
const full = path.join(dir, name)
|
|
52
|
-
try {
|
|
53
|
-
const parsed = JSON.parse(readFileSync(full, "utf8"))
|
|
54
|
-
const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
|
|
55
|
-
for (const hit of list) {
|
|
56
|
-
if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
|
|
57
|
-
hits.push({ file: hit.file, line: hit.line })
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// A torn dump is ignored. The parent does not invent a line.
|
|
61
|
-
}
|
|
62
|
-
try {
|
|
63
|
-
unlinkSync(full)
|
|
64
|
-
} catch {
|
|
65
|
-
// The next test must not inherit this dump.
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return hits
|
|
69
|
-
}
|
|
70
|
-
|
|
71
46
|
before(async () => {
|
|
72
47
|
try {
|
|
73
48
|
prelude = await sampler.positiveLines()
|
|
@@ -80,7 +55,7 @@ if (mapPath) {
|
|
|
80
55
|
try {
|
|
81
56
|
add(prelude, t.name)
|
|
82
57
|
add(await sampler.positiveLines(), t.name)
|
|
83
|
-
add(takeChildHits(), t.name)
|
|
58
|
+
add(takeChildHits(mapPath), t.name)
|
|
84
59
|
} catch (err) {
|
|
85
60
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
86
61
|
}
|
package/dist/mutate/operators.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
+
const ARITHMETIC = {
|
|
3
|
+
[ts.SyntaxKind.PlusToken]: { op: "add-to-sub", replacement: "-" },
|
|
4
|
+
[ts.SyntaxKind.MinusToken]: { op: "sub-to-add", replacement: "+" },
|
|
5
|
+
[ts.SyntaxKind.AsteriskToken]: { op: "mul-to-div", replacement: "/" },
|
|
6
|
+
[ts.SyntaxKind.SlashToken]: { op: "div-to-mul", replacement: "*" },
|
|
7
|
+
[ts.SyntaxKind.PercentToken]: { op: "mod-to-mul", replacement: "*" },
|
|
8
|
+
};
|
|
2
9
|
const SWAP = {
|
|
3
10
|
[ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
|
|
4
11
|
[ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
|
|
@@ -14,7 +21,7 @@ const SWAP = {
|
|
|
14
21
|
* A condition that contains a string (`name === "x"`) is still code: the span is not
|
|
15
22
|
* itself inside the string.
|
|
16
23
|
*/
|
|
17
|
-
export function findMutants(file, text) {
|
|
24
|
+
export function findMutants(file, text, operators = "core") {
|
|
18
25
|
const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
19
26
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
|
|
20
27
|
const raw = [];
|
|
@@ -24,6 +31,11 @@ export function findMutants(file, text) {
|
|
|
24
31
|
const point = pointAt(source, file, text, node);
|
|
25
32
|
if (point)
|
|
26
33
|
raw.push(point);
|
|
34
|
+
if (operators === "wide") {
|
|
35
|
+
const extra = widePointAt(source, file, text, node);
|
|
36
|
+
if (extra)
|
|
37
|
+
raw.push(extra);
|
|
38
|
+
}
|
|
27
39
|
ts.forEachChild(node, visit);
|
|
28
40
|
};
|
|
29
41
|
visit(source);
|
|
@@ -81,6 +93,53 @@ function pointAt(source, file, text, node) {
|
|
|
81
93
|
}
|
|
82
94
|
return undefined;
|
|
83
95
|
}
|
|
96
|
+
/** Arithmetic, constants, and a dropped call. Only in the wide set. */
|
|
97
|
+
function widePointAt(source, file, text, node) {
|
|
98
|
+
if (ts.isBinaryExpression(node)) {
|
|
99
|
+
const swap = ARITHMETIC[node.operatorToken.kind];
|
|
100
|
+
if (!swap)
|
|
101
|
+
return undefined;
|
|
102
|
+
// `"a" + n` builds a string. Swapping it for `-` changes the type, not the arithmetic.
|
|
103
|
+
if (node.operatorToken.kind === ts.SyntaxKind.PlusToken && (stringish(node.left) || stringish(node.right)))
|
|
104
|
+
return undefined;
|
|
105
|
+
const start = node.operatorToken.getStart(source);
|
|
106
|
+
return make(source, file, text, start, node.operatorToken.getEnd(), swap.op, swap.replacement);
|
|
107
|
+
}
|
|
108
|
+
if (ts.isNumericLiteral(node) && /^\d+$/.test(node.text) && node.getText(source) === node.text) {
|
|
109
|
+
// A property name or an enum key written as a number is a name, not a value.
|
|
110
|
+
if (ts.isPropertyAssignment(node.parent) && node.parent.name === node)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (ts.isEnumMember(node.parent) && node.parent.name === node)
|
|
113
|
+
return undefined;
|
|
114
|
+
const start = node.getStart(source);
|
|
115
|
+
const value = node.text;
|
|
116
|
+
if (value === "0")
|
|
117
|
+
return make(source, file, text, start, node.getEnd(), "const-zero-to-one", "1");
|
|
118
|
+
if (value === "1")
|
|
119
|
+
return make(source, file, text, start, node.getEnd(), "const-one-to-zero", "0");
|
|
120
|
+
if (value.length <= 15)
|
|
121
|
+
return make(source, file, text, start, node.getEnd(), "const-inc", String(Number(value) + 1));
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (ts.isExpressionStatement(node) && ts.isBlock(node.parent)) {
|
|
125
|
+
const call = ts.isAwaitExpression(node.expression) ? node.expression.expression : node.expression;
|
|
126
|
+
if (!ts.isCallExpression(call) || call.expression.kind === ts.SyntaxKind.SuperKeyword)
|
|
127
|
+
return undefined;
|
|
128
|
+
// `void 0` keeps the statement valid wherever it stands. The call and its effects are gone.
|
|
129
|
+
const start = node.expression.getStart(source);
|
|
130
|
+
return make(source, file, text, start, node.expression.getEnd(), "drop-call", "void 0");
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
function stringish(node) {
|
|
135
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node))
|
|
136
|
+
return true;
|
|
137
|
+
if (ts.isParenthesizedExpression(node))
|
|
138
|
+
return stringish(node.expression);
|
|
139
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
|
|
140
|
+
return stringish(node.left) || stringish(node.right);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
84
143
|
function negate(source, file, text, expr, op) {
|
|
85
144
|
const start = expr.getStart(source);
|
|
86
145
|
const end = expr.getEnd();
|
package/dist/mutate/patch.js
CHANGED
|
@@ -131,5 +131,6 @@ export function applyPatch(repo, raw, direction) {
|
|
|
131
131
|
/** Put a throwaway worktree back. A diff label is not trusted for the path list. */
|
|
132
132
|
export function restoreTree(repo) {
|
|
133
133
|
git(repo, ["checkout", "--", "."]);
|
|
134
|
-
|
|
134
|
+
// .probatio.json may be the user's uncommitted copy, put there when the worktree was made.
|
|
135
|
+
git(repo, ["clean", "-fd", "-e", "node_modules", "-e", ".probatio.json"]);
|
|
135
136
|
}
|