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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import ts from "typescript";
|
|
8
|
+
import { SCHEMA_VERSION } from "../contract.js";
|
|
9
|
+
import { generateMutants } from "../mutate/generate.js";
|
|
10
|
+
import { createWorktrees, packageIn, removeWorktrees, runMutants } from "../mutate/run.js";
|
|
11
|
+
/**
|
|
12
|
+
* Golden tables recorded from the unit tests a project already has.
|
|
13
|
+
*
|
|
14
|
+
* `golden record` runs those tests once with every exported function of a module wrapped. Each call
|
|
15
|
+
* whose arguments and result are plain data becomes a row: the function, the arguments, and what it
|
|
16
|
+
* returned or threw. A replay test runs every row against the code. `golden compare` then mutates
|
|
17
|
+
* the module and reports whether the table kills every mutant those unit tests kill.
|
|
18
|
+
*
|
|
19
|
+
* Nothing is deleted. A table that kills what the unit tests kill is evidence for replacing them,
|
|
20
|
+
* and the decision stays with whoever writes the commit. A test with hidden inputs (callbacks,
|
|
21
|
+
* stubbed globals, a clock the function does not take) is not recordable and stays as code.
|
|
22
|
+
*/
|
|
23
|
+
const RECORDER = fileURLToPath(new URL("./recorder.mjs", import.meta.url));
|
|
24
|
+
const MODULE_EXT = /\.(ts|tsx|mts|js|mjs)$/;
|
|
25
|
+
export async function recordGoldens(options) {
|
|
26
|
+
const repo = realpathSync(path.resolve(options.repoDir));
|
|
27
|
+
const packageDir = realpathSync(path.resolve(options.packageDir));
|
|
28
|
+
const fail = (summary, next = "Fix the command and run it again.") => ({
|
|
29
|
+
schemaVersion: SCHEMA_VERSION,
|
|
30
|
+
ok: false,
|
|
31
|
+
command: "golden.record",
|
|
32
|
+
summary,
|
|
33
|
+
next,
|
|
34
|
+
nextCall: null,
|
|
35
|
+
});
|
|
36
|
+
if (options.modules.length === 0)
|
|
37
|
+
return fail("Pass --module with the source file to record.");
|
|
38
|
+
if (options.tests.length === 0)
|
|
39
|
+
return fail("Pass --tests with the unit test files that call it.");
|
|
40
|
+
for (const module of options.modules) {
|
|
41
|
+
if (!MODULE_EXT.test(module))
|
|
42
|
+
return fail(`${module} is not a JavaScript or TypeScript module.`);
|
|
43
|
+
if (!existsSync(path.join(packageDir, module)))
|
|
44
|
+
return fail(`${module} is not in the package.`);
|
|
45
|
+
}
|
|
46
|
+
for (const file of options.tests)
|
|
47
|
+
if (!existsSync(path.join(packageDir, file)))
|
|
48
|
+
return fail(`${file} is not in the package.`);
|
|
49
|
+
const sha = spawnSync("git", ["-C", repo, "rev-parse", "--verify", `${options.commit}^{commit}`], { encoding: "utf8" });
|
|
50
|
+
if (sha.status !== 0)
|
|
51
|
+
return fail("that commit does not resolve");
|
|
52
|
+
const commit = sha.stdout.trim();
|
|
53
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "probatio-golden-"));
|
|
54
|
+
const log = path.join(scratch, "calls.jsonl");
|
|
55
|
+
const runId = randomBytes(4).toString("hex");
|
|
56
|
+
const worktrees = await createWorktrees(repo, packageDir, runId, commit, 1, scratch);
|
|
57
|
+
if ("error" in worktrees) {
|
|
58
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
59
|
+
return fail(worktrees.error);
|
|
60
|
+
}
|
|
61
|
+
let testRun;
|
|
62
|
+
const exported = new Map();
|
|
63
|
+
try {
|
|
64
|
+
const pkg = packageIn(worktrees.dirs[0], repo, packageDir);
|
|
65
|
+
for (const module of options.modules) {
|
|
66
|
+
const names = exportedFunctions(module, readFileSync(path.join(pkg, module), "utf8"));
|
|
67
|
+
exported.set(module, names);
|
|
68
|
+
wrapModule(pkg, module, names);
|
|
69
|
+
}
|
|
70
|
+
testRun = runTests(pkg, options.tests, log, options.timeoutMs);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
removeWorktrees(repo, worktrees.dirs, scratch);
|
|
74
|
+
}
|
|
75
|
+
const calls = readCalls(log);
|
|
76
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
77
|
+
if (testRun.fail > 0 || testRun.code !== 0) {
|
|
78
|
+
return fail(`The unit tests did not pass while recording (${testRun.pass} passed, ${testRun.fail} failed). A table recorded from a red run is not a contract.`, "Run those tests green first.");
|
|
79
|
+
}
|
|
80
|
+
const written = [];
|
|
81
|
+
const modules = [];
|
|
82
|
+
for (const module of options.modules) {
|
|
83
|
+
const mine = calls.filter((call) => call.module === module);
|
|
84
|
+
const clock = /Date\.now|new Date\(/.test(readFileSync(path.join(packageDir, module), "utf8"));
|
|
85
|
+
const built = buildRows(mine, clock);
|
|
86
|
+
const base = path.basename(module).replace(MODULE_EXT, "");
|
|
87
|
+
const tableRel = path.posix.join(options.outDir, `${base}.golden.json`);
|
|
88
|
+
const testRel = path.posix.join(options.outDir, `${base}.golden.test${module.endsWith(".ts") || module.endsWith(".tsx") || module.endsWith(".mts") ? ".ts" : ".mjs"}`);
|
|
89
|
+
mkdirSync(path.join(packageDir, options.outDir), { recursive: true });
|
|
90
|
+
writeFileSync(path.join(packageDir, tableRel), `${JSON.stringify({ module, rows: built.rows }, null, 2)}\n`);
|
|
91
|
+
writeFileSync(path.join(packageDir, testRel), replayTest(module, tableRel, testRel));
|
|
92
|
+
written.push(tableRel, testRel);
|
|
93
|
+
modules.push({
|
|
94
|
+
module,
|
|
95
|
+
rows: Object.keys(built.rows).length,
|
|
96
|
+
hidden: mine.filter((call) => call.hidden).length,
|
|
97
|
+
unstable: built.unstable,
|
|
98
|
+
functions: exported.get(module) ?? [],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const rows = modules.reduce((sum, item) => sum + item.rows, 0);
|
|
102
|
+
const hidden = modules.reduce((sum, item) => sum + item.hidden, 0);
|
|
103
|
+
const unstable = modules.reduce((sum, item) => sum + item.unstable, 0);
|
|
104
|
+
const empty = modules.filter((item) => item.rows === 0).map((item) => item.module);
|
|
105
|
+
const notes = [`${rows} rows from ${testRun.pass} passing tests.`];
|
|
106
|
+
if (hidden > 0)
|
|
107
|
+
notes.push(`${hidden} calls had a callback, an instance, or another hidden input. Tests built on those stay as code.`);
|
|
108
|
+
if (unstable > 0)
|
|
109
|
+
notes.push(`${unstable} calls gave two answers for the same arguments, so they were left out.`);
|
|
110
|
+
if (empty.length > 0)
|
|
111
|
+
notes.push(`No row for ${empty.join(", ")}: those tests never called an export with plain data.`);
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: SCHEMA_VERSION,
|
|
114
|
+
ok: rows > 0,
|
|
115
|
+
command: "golden.record",
|
|
116
|
+
summary: notes.join(" "),
|
|
117
|
+
next: rows > 0
|
|
118
|
+
? "Commit the tables and replay tests, then run nextCall to see whether the table kills what the unit tests kill. Nothing was deleted."
|
|
119
|
+
: "Nothing to replay. Pick tests that call the module's exports with plain data.",
|
|
120
|
+
nextCall: rows > 0
|
|
121
|
+
? {
|
|
122
|
+
argv: [
|
|
123
|
+
"golden",
|
|
124
|
+
"compare",
|
|
125
|
+
"--package",
|
|
126
|
+
packageDir,
|
|
127
|
+
...options.modules.flatMap((module) => ["--module", module]),
|
|
128
|
+
...options.tests.flatMap((file) => ["--tests", file]),
|
|
129
|
+
...written.filter((file) => /\.golden\.test\./.test(file)).flatMap((file) => ["--golden", file]),
|
|
130
|
+
"--out",
|
|
131
|
+
path.join(packageDir, ".probatio", "golden-compare"),
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
: null,
|
|
135
|
+
rows,
|
|
136
|
+
hidden,
|
|
137
|
+
unstable,
|
|
138
|
+
modules,
|
|
139
|
+
written,
|
|
140
|
+
commit,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Exported names that may be functions. Classes and default exports stay as code. */
|
|
144
|
+
export function exportedFunctions(file, text) {
|
|
145
|
+
const kind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : /\.m?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
|
|
146
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
|
|
147
|
+
const names = new Set();
|
|
148
|
+
const exported = (node) => ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword) &&
|
|
149
|
+
!(ts.getModifiers(node) ?? []).some((mod) => mod.kind === ts.SyntaxKind.DefaultKeyword);
|
|
150
|
+
for (const statement of source.statements) {
|
|
151
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && exported(statement))
|
|
152
|
+
names.add(statement.name.text);
|
|
153
|
+
if (ts.isVariableStatement(statement) && exported(statement)) {
|
|
154
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
155
|
+
if (ts.isIdentifier(declaration.name))
|
|
156
|
+
names.add(declaration.name.text);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && !statement.isTypeOnly && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
160
|
+
for (const element of statement.exportClause.elements)
|
|
161
|
+
if (!element.isTypeOnly)
|
|
162
|
+
names.add(element.name.text);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...names].sort();
|
|
166
|
+
}
|
|
167
|
+
/** The module becomes a wrapper that records each call and forwards it to the real file beside it. */
|
|
168
|
+
function wrapModule(pkg, module, names) {
|
|
169
|
+
const abs = path.join(pkg, module);
|
|
170
|
+
const ext = path.extname(module);
|
|
171
|
+
const realBase = `${path.basename(module, ext)}.probatio-real${ext}`;
|
|
172
|
+
renameSync(abs, path.join(path.dirname(abs), realBase));
|
|
173
|
+
const lines = [
|
|
174
|
+
`export * from "./${realBase}"`,
|
|
175
|
+
`import * as real from "./${realBase}"`,
|
|
176
|
+
`import { record } from ${JSON.stringify(pathToFileURL(RECORDER).href)}`,
|
|
177
|
+
...names.map((name) => `export const ${name} = record(${JSON.stringify(module)}, ${JSON.stringify(name)}, real.${name})`),
|
|
178
|
+
"",
|
|
179
|
+
];
|
|
180
|
+
writeFileSync(abs, lines.join("\n"));
|
|
181
|
+
}
|
|
182
|
+
function runTests(pkg, tests, log, timeoutMs) {
|
|
183
|
+
const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
|
|
184
|
+
const bin = existsSync(tsx) ? tsx : process.execPath;
|
|
185
|
+
const args = [...(existsSync(tsx) ? [] : ["--experimental-strip-types"]), "--test", "--test-reporter=tap", ...tests];
|
|
186
|
+
const env = { ...process.env, PROBATIO_GOLDEN_LOG: log };
|
|
187
|
+
for (const key of Object.keys(env))
|
|
188
|
+
if (key === "NODE_TEST_CONTEXT" || key.startsWith("NODE_TEST_"))
|
|
189
|
+
delete env[key];
|
|
190
|
+
const run = spawnSync(bin, args, { cwd: pkg, env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
191
|
+
const count = (name) => Number(new RegExp(`^# ${name} (\\d+)$`, "m").exec(run.stdout ?? "")?.[1] ?? 0);
|
|
192
|
+
return { code: run.status ?? 1, pass: count("pass"), fail: count("fail") };
|
|
193
|
+
}
|
|
194
|
+
function readCalls(log) {
|
|
195
|
+
if (!existsSync(log))
|
|
196
|
+
return [];
|
|
197
|
+
return readFileSync(log, "utf8")
|
|
198
|
+
.split("\n")
|
|
199
|
+
.filter((line) => line.trim().length > 0)
|
|
200
|
+
.flatMap((line) => {
|
|
201
|
+
try {
|
|
202
|
+
return [JSON.parse(line)];
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** One row per distinct call. A call that answered two ways for the same arguments is left out. */
|
|
210
|
+
function buildRows(calls, clock) {
|
|
211
|
+
const byKey = new Map();
|
|
212
|
+
for (const call of calls) {
|
|
213
|
+
if (call.hidden || !call.args)
|
|
214
|
+
continue;
|
|
215
|
+
const key = `${call.fn}${JSON.stringify(call.args)}${clock ? `@${call.at}` : ""}`;
|
|
216
|
+
const outcome = JSON.stringify(call.error ? { error: call.error } : { result: call.result });
|
|
217
|
+
const known = byKey.get(key);
|
|
218
|
+
if (known) {
|
|
219
|
+
known.outcomes.add(outcome);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const row = { fn: call.fn, args: call.args, ...(call.error ? { error: call.error } : { result: call.result }) };
|
|
223
|
+
if (call.async)
|
|
224
|
+
row.async = true;
|
|
225
|
+
if (clock)
|
|
226
|
+
row.now = call.at;
|
|
227
|
+
byKey.set(key, { row, outcomes: new Set([outcome]) });
|
|
228
|
+
}
|
|
229
|
+
const rows = {};
|
|
230
|
+
let unstable = 0;
|
|
231
|
+
const ids = new Set();
|
|
232
|
+
for (const { row, outcomes } of byKey.values()) {
|
|
233
|
+
if (outcomes.size > 1) {
|
|
234
|
+
unstable += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const preview = row.args.map((arg) => (arg.u ? "undefined" : arg.url !== undefined ? `URL(${arg.url})` : JSON.stringify(arg.v))).join(", ");
|
|
238
|
+
let id = `${row.fn}(${preview.length > 80 ? `${preview.slice(0, 77)}...` : preview})`;
|
|
239
|
+
for (let n = 2; ids.has(id); n += 1)
|
|
240
|
+
id = `${id.replace(/ #\d+$/, "")} #${n}`;
|
|
241
|
+
ids.add(id);
|
|
242
|
+
rows[id] = row;
|
|
243
|
+
}
|
|
244
|
+
return { rows, unstable };
|
|
245
|
+
}
|
|
246
|
+
/** A self-contained node:test file. It does not import Probatio, so the project keeps it without the tool. */
|
|
247
|
+
function replayTest(module, tableRel, testRel) {
|
|
248
|
+
const fromTest = (target) => {
|
|
249
|
+
const rel = path.posix.relative(path.posix.dirname(testRel), target);
|
|
250
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
251
|
+
};
|
|
252
|
+
return `// @ts-nocheck
|
|
253
|
+
// Generated by \`probatio golden record\` from the unit tests that called ${module}.
|
|
254
|
+
// The table is ${path.posix.basename(tableRel)}: one row per call, with what it returned or threw.
|
|
255
|
+
// A failing row is a behaviour change. Fix the code, or re-record and review the JSON diff.
|
|
256
|
+
import assert from "node:assert/strict"
|
|
257
|
+
import { readFileSync } from "node:fs"
|
|
258
|
+
import test, { mock } from "node:test"
|
|
259
|
+
import * as subject from ${JSON.stringify(fromTest(module))}
|
|
260
|
+
|
|
261
|
+
const table = JSON.parse(readFileSync(new URL(${JSON.stringify(fromTest(tableRel))}, import.meta.url), "utf8"))
|
|
262
|
+
|
|
263
|
+
function decode(arg) {
|
|
264
|
+
if (arg.u) return undefined
|
|
265
|
+
if (arg.url !== undefined) return new URL(arg.url)
|
|
266
|
+
return structuredClone(arg.v)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function encode(value) {
|
|
270
|
+
if (value === undefined) return { u: true }
|
|
271
|
+
if (value instanceof URL) return { url: value.href }
|
|
272
|
+
return { v: JSON.parse(JSON.stringify(value)) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [id, row] of Object.entries(table.rows)) {
|
|
276
|
+
test(\`golden ${module} \${id}\`, async () => {
|
|
277
|
+
if (typeof row.now === "number") mock.timers.enable({ apis: ["Date"], now: row.now })
|
|
278
|
+
try {
|
|
279
|
+
const fn = subject[row.fn]
|
|
280
|
+
assert.equal(typeof fn, "function", \`\${row.fn} is no longer exported\`)
|
|
281
|
+
let actual
|
|
282
|
+
try {
|
|
283
|
+
const out = fn(...row.args.map(decode))
|
|
284
|
+
actual = { result: encode(row.async ? await out : out) }
|
|
285
|
+
} catch (error) {
|
|
286
|
+
actual = { error: { name: typeof error?.name === "string" ? error.name : "Error", message: String(error?.message ?? error) } }
|
|
287
|
+
}
|
|
288
|
+
assert.deepStrictEqual(actual, row.error ? { error: row.error } : { result: row.result })
|
|
289
|
+
} finally {
|
|
290
|
+
mock.timers.reset()
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Mutate the module, then score the same mutants twice: once with only the unit tests, once with
|
|
298
|
+
* only the replay tests. The question is whether the table kills every mutant the unit tests kill.
|
|
299
|
+
*/
|
|
300
|
+
export async function compareGoldens(options) {
|
|
301
|
+
const fail = (summary, next = "Fix the command and run it again.") => ({
|
|
302
|
+
schemaVersion: SCHEMA_VERSION,
|
|
303
|
+
ok: false,
|
|
304
|
+
command: "golden.compare",
|
|
305
|
+
summary,
|
|
306
|
+
next,
|
|
307
|
+
nextCall: null,
|
|
308
|
+
});
|
|
309
|
+
if (options.modules.length === 0 || options.tests.length === 0 || options.golden.length === 0) {
|
|
310
|
+
return fail("Pass --module, --tests, and --golden.");
|
|
311
|
+
}
|
|
312
|
+
const outDir = path.resolve(options.outDir);
|
|
313
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
314
|
+
const patchDirs = [];
|
|
315
|
+
for (const [index, module] of options.modules.entries()) {
|
|
316
|
+
const out = path.join(outDir, `generate-${index}`);
|
|
317
|
+
const generated = generateMutants({
|
|
318
|
+
packageDir: options.packageDir,
|
|
319
|
+
srcDir: module,
|
|
320
|
+
outDir: out,
|
|
321
|
+
perFile: null,
|
|
322
|
+
skip: 0,
|
|
323
|
+
seed: 20261003,
|
|
324
|
+
maxMutants: null,
|
|
325
|
+
skipFiles: [],
|
|
326
|
+
commit: options.commit,
|
|
327
|
+
operators: options.operators,
|
|
328
|
+
});
|
|
329
|
+
if (generated.error)
|
|
330
|
+
return fail(generated.error, "Commit the module, the table, and the replay test, then run the same command.");
|
|
331
|
+
if (generated.violations.length > 0)
|
|
332
|
+
return fail(`Refusing to write mutants: ${generated.violations[0].file}:${generated.violations[0].line} sits inside a string or a comment.`);
|
|
333
|
+
patchDirs.push(path.join(out, "mutants"));
|
|
334
|
+
}
|
|
335
|
+
const score = (label, tests) => runMutants(baseRun({ ...options, outDir: path.join(outDir, label), patchDirs, onlyTests: tests }));
|
|
336
|
+
const unit = await score("unit", options.tests);
|
|
337
|
+
if (!unit.ok)
|
|
338
|
+
return fail(`unit tests: ${unit.summary}`, unit.next);
|
|
339
|
+
const golden = await score("golden", options.golden);
|
|
340
|
+
if (!golden.ok)
|
|
341
|
+
return fail(`golden replay: ${golden.summary}`, golden.next);
|
|
342
|
+
const killedBy = (report) => new Set(report.kills.map((kill) => kill.id));
|
|
343
|
+
const unitKills = killedBy(unit);
|
|
344
|
+
const goldenKills = killedBy(golden);
|
|
345
|
+
const unitOnly = [...unitKills].filter((id) => !goldenKills.has(id)).sort();
|
|
346
|
+
const goldenOnly = [...goldenKills].filter((id) => !unitKills.has(id)).sort();
|
|
347
|
+
const both = [...unitKills].filter((id) => goldenKills.has(id)).length;
|
|
348
|
+
const covers = unitOnly.length === 0 && unitKills.size > 0;
|
|
349
|
+
return {
|
|
350
|
+
schemaVersion: SCHEMA_VERSION,
|
|
351
|
+
ok: true,
|
|
352
|
+
command: "golden.compare",
|
|
353
|
+
summary: `The table kills ${both} of the ${unitKills.size} mutants the unit tests kill. ${unitOnly.length} only the unit tests kill. ${goldenOnly.length} only the table kills. Operators: ${options.operators}.`,
|
|
354
|
+
next: covers
|
|
355
|
+
? "On these mutants the table catches everything the unit tests catch. Replacing those tests is still a decision: keep any test with hidden inputs, run a sealed or ledger check, and give the reason in the commit. Nothing was deleted."
|
|
356
|
+
: unitKills.size === 0
|
|
357
|
+
? "The unit tests killed nothing here, so there is nothing to compare. Try --operators wide."
|
|
358
|
+
: `Keep the unit tests that kill ${unitOnly.slice(0, 5).join(", ")}${unitOnly.length > 5 ? ", and more" : ""}, or record rows that do. Nothing was deleted.`,
|
|
359
|
+
nextCall: null,
|
|
360
|
+
operators: options.operators,
|
|
361
|
+
unitKills: unitKills.size,
|
|
362
|
+
goldenKills: goldenKills.size,
|
|
363
|
+
both,
|
|
364
|
+
unitOnly: unitOnly.slice(0, 10),
|
|
365
|
+
goldenOnly: goldenOnly.slice(0, 10),
|
|
366
|
+
rest: Math.max(unitOnly.length - 10, 0),
|
|
367
|
+
unit: { summary: unit.summary, full: unit.full },
|
|
368
|
+
golden: { summary: golden.summary, full: golden.full },
|
|
369
|
+
pruning: { mode: "advisory", deletedTests: 0 },
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function baseRun(input) {
|
|
373
|
+
return {
|
|
374
|
+
packageDir: input.packageDir,
|
|
375
|
+
repoDir: input.repoDir,
|
|
376
|
+
commit: input.commit,
|
|
377
|
+
patchDirs: input.patchDirs,
|
|
378
|
+
outDir: input.outDir,
|
|
379
|
+
direction: "forward",
|
|
380
|
+
workers: 1,
|
|
381
|
+
concurrency: 1,
|
|
382
|
+
maxMinutes: null,
|
|
383
|
+
maxMutants: null,
|
|
384
|
+
build: null,
|
|
385
|
+
testsDir: "tests",
|
|
386
|
+
unset: [],
|
|
387
|
+
suiteTimeoutMs: input.timeoutMs,
|
|
388
|
+
testTimeoutMs: 60_000,
|
|
389
|
+
confirm: true,
|
|
390
|
+
affected: false,
|
|
391
|
+
agent: null,
|
|
392
|
+
onlyTests: input.onlyTests,
|
|
393
|
+
onProgress: input.onProgress,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Loaded by the wrapper `probatio golden record` puts in front of a module.
|
|
2
|
+
// Each call to an exported function is written to PROBATIO_GOLDEN_LOG: the arguments as they were
|
|
3
|
+
// before the call, and what it returned or threw. A call whose arguments or result are not plain
|
|
4
|
+
// data (a callback, a class instance, a stubbed global) is a hidden input. It is counted, not recorded.
|
|
5
|
+
import { appendFileSync } from "node:fs"
|
|
6
|
+
|
|
7
|
+
const LOG = process.env.PROBATIO_GOLDEN_LOG
|
|
8
|
+
|
|
9
|
+
/** `{ u: true }` for undefined, `{ url }` for a URL, `{ v }` for plain JSON data, null otherwise. */
|
|
10
|
+
export function encode(value) {
|
|
11
|
+
if (value === undefined) return { u: true }
|
|
12
|
+
if (value instanceof URL) return { url: value.href }
|
|
13
|
+
return plain(value, 0) ? { v: JSON.parse(JSON.stringify(value)) } : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function plain(value, depth) {
|
|
17
|
+
if (depth > 40) return false
|
|
18
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true
|
|
19
|
+
if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0)
|
|
20
|
+
if (Array.isArray(value)) return value.every((item) => item !== undefined && plain(item, depth + 1))
|
|
21
|
+
if (typeof value === "object") {
|
|
22
|
+
const proto = Object.getPrototypeOf(value)
|
|
23
|
+
if (proto !== Object.prototype && proto !== null) return false
|
|
24
|
+
return Object.values(value).every((item) => item !== undefined && plain(item, depth + 1))
|
|
25
|
+
}
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function errorOf(error) {
|
|
30
|
+
return { name: typeof error?.name === "string" ? error.name : "Error", message: String(error?.message ?? error) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function write(row) {
|
|
34
|
+
if (!LOG) return
|
|
35
|
+
try {
|
|
36
|
+
appendFileSync(LOG, `${JSON.stringify(row)}\n`)
|
|
37
|
+
} catch {
|
|
38
|
+
// A full disk must not fail the test that is being recorded.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function record(module, name, fn) {
|
|
43
|
+
if (typeof fn !== "function") return fn
|
|
44
|
+
if (/^class[\s{]/.test(Function.prototype.toString.call(fn))) return fn
|
|
45
|
+
const wrapped = function (...args) {
|
|
46
|
+
const encoded = args.map(encode)
|
|
47
|
+
const base = { module, fn: name, at: Date.now() }
|
|
48
|
+
if (encoded.some((item) => item === null)) {
|
|
49
|
+
write({ ...base, hidden: "argument" })
|
|
50
|
+
return fn.apply(this, args)
|
|
51
|
+
}
|
|
52
|
+
let out
|
|
53
|
+
try {
|
|
54
|
+
out = fn.apply(this, args)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
write({ ...base, args: encoded, error: errorOf(error) })
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
if (out && typeof out.then === "function") {
|
|
60
|
+
return out.then(
|
|
61
|
+
(value) => {
|
|
62
|
+
const result = encode(value)
|
|
63
|
+
write(result ? { ...base, args: encoded, async: true, result } : { ...base, hidden: "result" })
|
|
64
|
+
return value
|
|
65
|
+
},
|
|
66
|
+
(error) => {
|
|
67
|
+
write({ ...base, args: encoded, async: true, error: errorOf(error) })
|
|
68
|
+
throw error
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
const result = encode(out)
|
|
73
|
+
write(result ? { ...base, args: encoded, result } : { ...base, hidden: "result" })
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
Object.defineProperty(wrapped, "name", { value: fn.name })
|
|
77
|
+
return wrapped
|
|
78
|
+
}
|
package/dist/ledger/build.js
CHANGED
|
@@ -43,6 +43,13 @@ export function buildLedger(options) {
|
|
|
43
43
|
return fail(outDir, commit, work.error);
|
|
44
44
|
workDir = work.dir;
|
|
45
45
|
}
|
|
46
|
+
// A hand-made mutant that names this fix wins over the history revert. The revert can be
|
|
47
|
+
// unviable (it removes an export a test imports), and the hand mutant keeps the API.
|
|
48
|
+
const named = disk.find((item) => item.kind === "hand" && item.fix === candidate.commit);
|
|
49
|
+
if (named && handmadeApplies(workDir, named.raw)) {
|
|
50
|
+
entries.push({ ...entry(candidate, id, "handmade", filesInDiff(named.raw)), patch: `${named.id}.patch` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
46
53
|
const applied = git(workDir, ["apply", "--check", "--whitespace=nowarn", "-"], bugDiff);
|
|
47
54
|
if (applied.status === 0) {
|
|
48
55
|
bodies.set(id, historyPatch(candidate.commit, bugDiff));
|
|
@@ -106,6 +113,9 @@ function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
|
|
|
106
113
|
continue;
|
|
107
114
|
if (!hasTrailer && lines === 0)
|
|
108
115
|
continue;
|
|
116
|
+
// A version bump ships whatever was finished. Reverting it puts back several changes, not one bug.
|
|
117
|
+
if (!hasTrailer && bumpsVersion(repo, sha))
|
|
118
|
+
continue;
|
|
109
119
|
found.push({
|
|
110
120
|
commit: sha,
|
|
111
121
|
date: date ?? "",
|
|
@@ -116,6 +126,17 @@ function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
|
|
|
116
126
|
}
|
|
117
127
|
return { found, scanned: limited.length, budgetHit };
|
|
118
128
|
}
|
|
129
|
+
const MANIFESTS = ["package.json", "Cargo.toml", "pyproject.toml", "setup.cfg"].map((name) => `:(glob)**/${name}`);
|
|
130
|
+
/** True when the commit changes a project version line in a manifest. */
|
|
131
|
+
function bumpsVersion(repo, sha) {
|
|
132
|
+
const diff = git(repo, ["show", "--format=", "-U0", sha, "--", ...MANIFESTS]);
|
|
133
|
+
if (diff.status !== 0)
|
|
134
|
+
return false;
|
|
135
|
+
return diff.stdout
|
|
136
|
+
.split("\n")
|
|
137
|
+
.filter((line) => /^[-+]/.test(line) && !line.startsWith("+++") && !line.startsWith("---"))
|
|
138
|
+
.some((line) => /"version"\s*:/.test(line) || /^[-+]\s*version\s*=/.test(line));
|
|
139
|
+
}
|
|
119
140
|
function entry(candidate, id, status, files, reason) {
|
|
120
141
|
return {
|
|
121
142
|
id,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SCHEMA_VERSION } from "../contract.js";
|
|
4
|
+
import { goldenChanges } from "../golden/check.js";
|
|
5
|
+
import { normalizeResult, runMutants } from "../mutate/run.js";
|
|
6
|
+
/** The contract is the outcome. The reason follows from it, so a renamed test does not fail the check. */
|
|
7
|
+
const REASON = {
|
|
8
|
+
killed: "A test the suite already had caught this ledger bug.",
|
|
9
|
+
survived: "The suite ran the line and did not catch this ledger bug.",
|
|
10
|
+
"no coverage": "No test runs the line this ledger bug changes.",
|
|
11
|
+
unviable: "This ledger bug does not build as it stands. A hand-made mutant that keeps the API would guard it.",
|
|
12
|
+
flaky: "A test failed once and passed on the rerun. That is not a catch.",
|
|
13
|
+
timeout: "The run passed the baseline clock. That is not a catch.",
|
|
14
|
+
error: "The ledger patch did not run. It may no longer apply: rebuild the ledger or refresh the hand-made mutant.",
|
|
15
|
+
missing: "No result was written for this ledger bug.",
|
|
16
|
+
};
|
|
17
|
+
export async function checkLedger(options) {
|
|
18
|
+
const fail = (summary, next = "Fix that and run the same command again.") => ({
|
|
19
|
+
schemaVersion: SCHEMA_VERSION,
|
|
20
|
+
ok: false,
|
|
21
|
+
command: "ledger.check",
|
|
22
|
+
summary,
|
|
23
|
+
next,
|
|
24
|
+
nextCall: null,
|
|
25
|
+
});
|
|
26
|
+
const ledgerFile = path.join(options.ledgerDir, "ledger.json");
|
|
27
|
+
if (!existsSync(ledgerFile))
|
|
28
|
+
return fail(`No ledger.json in ${options.ledgerDir}.`, "Run probatio ledger build first.");
|
|
29
|
+
const ledger = JSON.parse(readFileSync(ledgerFile, "utf8"));
|
|
30
|
+
const guarded = (ledger.entries ?? []).filter((item) => (item.status === "clean" || item.status === "handmade") && item.patch);
|
|
31
|
+
if (guarded.length === 0)
|
|
32
|
+
return fail("The ledger has no bug that applies.", "Rebuild the ledger, or write a hand-made mutant for a fix.");
|
|
33
|
+
const outDir = path.resolve(options.outDir);
|
|
34
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
35
|
+
const patchDir = path.join(outDir, "patches");
|
|
36
|
+
mkdirSync(patchDir, { recursive: true });
|
|
37
|
+
for (const item of guarded) {
|
|
38
|
+
const source = path.join(options.ledgerDir, item.patch);
|
|
39
|
+
if (!existsSync(source))
|
|
40
|
+
return fail(`${item.patch} is listed in ledger.json and is not in ${options.ledgerDir}.`);
|
|
41
|
+
copyFileSync(source, path.join(patchDir, item.patch));
|
|
42
|
+
}
|
|
43
|
+
const report = await runMutants({
|
|
44
|
+
packageDir: options.packageDir,
|
|
45
|
+
repoDir: options.repoDir,
|
|
46
|
+
commit: options.commit,
|
|
47
|
+
patchDirs: [patchDir],
|
|
48
|
+
outDir: path.join(outDir, "run"),
|
|
49
|
+
direction: "forward",
|
|
50
|
+
workers: 1,
|
|
51
|
+
concurrency: options.concurrency,
|
|
52
|
+
maxMinutes: null,
|
|
53
|
+
maxMutants: null,
|
|
54
|
+
build: null,
|
|
55
|
+
testsDir: "tests",
|
|
56
|
+
unset: [],
|
|
57
|
+
suiteTimeoutMs: options.suiteTimeoutMs,
|
|
58
|
+
testTimeoutMs: options.testTimeoutMs,
|
|
59
|
+
confirm: true,
|
|
60
|
+
affected: false,
|
|
61
|
+
agent: null,
|
|
62
|
+
onProgress: options.onProgress,
|
|
63
|
+
});
|
|
64
|
+
if (!report.ok)
|
|
65
|
+
return fail(`The ledger run did not score: ${report.summary}`, report.next);
|
|
66
|
+
const allowed = goldenChanges(options.message);
|
|
67
|
+
const rows = [];
|
|
68
|
+
const regressed = [];
|
|
69
|
+
const changed = [];
|
|
70
|
+
const unrecorded = [];
|
|
71
|
+
const unauthorized = [];
|
|
72
|
+
for (const item of guarded) {
|
|
73
|
+
const patchId = path.basename(item.patch, ".patch");
|
|
74
|
+
const resultFile = path.join(outDir, "run", "results", `${patchId}.json`);
|
|
75
|
+
const result = existsSync(resultFile) ? normalizeResult(JSON.parse(readFileSync(resultFile, "utf8"))) : null;
|
|
76
|
+
const outcome = result?.outcome ?? "missing";
|
|
77
|
+
const next = outcome === "killed" ? `Killed by ${(result?.killedBy ?? []).join(", ")}.` : result?.next || result?.error || REASON[outcome] || outcome;
|
|
78
|
+
const actual = { ok: true, outcome, reason: REASON[outcome] ?? outcome, next };
|
|
79
|
+
const goldenFile = path.join(options.ledgerDir, `${item.id}.golden.json`);
|
|
80
|
+
const recorded = existsSync(goldenFile)
|
|
81
|
+
? (JSON.parse(readFileSync(goldenFile, "utf8")).rows?.[item.id]?.outcome ?? null)
|
|
82
|
+
: null;
|
|
83
|
+
rows.push({ id: item.id, recorded, actual: outcome, next });
|
|
84
|
+
if (recorded === null)
|
|
85
|
+
unrecorded.push(item.id);
|
|
86
|
+
else if (recorded === "killed" && outcome !== "killed")
|
|
87
|
+
regressed.push(item.id);
|
|
88
|
+
else if (recorded !== outcome)
|
|
89
|
+
changed.push(item.id);
|
|
90
|
+
const differs = recorded !== outcome;
|
|
91
|
+
if (differs && recorded !== null && !allowed.has(item.id))
|
|
92
|
+
unauthorized.push(item.id);
|
|
93
|
+
if (options.update && (!differs || recorded === null || allowed.has(item.id))) {
|
|
94
|
+
writeFileSync(goldenFile, `${JSON.stringify({ rows: { [item.id]: actual } }, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const killed = rows.filter((row) => row.actual === "killed").length;
|
|
98
|
+
const written = options.update ? rows.filter((row) => !unauthorized.includes(row.id)).length : 0;
|
|
99
|
+
const ok = options.update ? unauthorized.length === 0 : regressed.length === 0 && changed.length === 0 && unrecorded.length === 0;
|
|
100
|
+
const parts = [`${killed} of ${rows.length} ledger bugs caught.`];
|
|
101
|
+
if (regressed.length > 0)
|
|
102
|
+
parts.push(`Regressed: ${regressed.join(", ")}.`);
|
|
103
|
+
if (changed.length > 0)
|
|
104
|
+
parts.push(`Changed: ${changed.join(", ")}.`);
|
|
105
|
+
if (unrecorded.length > 0)
|
|
106
|
+
parts.push(`Not recorded yet: ${unrecorded.join(", ")}.`);
|
|
107
|
+
if (options.update)
|
|
108
|
+
parts.push(`${written} golden row(s) written.`);
|
|
109
|
+
let next = "Every ledger bug has the outcome recorded for it.";
|
|
110
|
+
if (regressed.length > 0)
|
|
111
|
+
next = `A fixed bug is back in reach of the suite: ${regressed[0]} was caught and is not now. Find the test that stopped guarding it.`;
|
|
112
|
+
else if (unauthorized.length > 0 && options.update)
|
|
113
|
+
next = `Not written: ${unauthorized.join(", ")} changed outcome. Add Golden-Change: <id>: <why> to --message for each.`;
|
|
114
|
+
else if (options.update)
|
|
115
|
+
next = "Re-recorded. Commit the goldens with the reason for each changed outcome.";
|
|
116
|
+
else if (changed.length > 0 || unrecorded.length > 0)
|
|
117
|
+
next = "Review the changed outcomes, then re-record with --update and a Golden-Change line for each changed id.";
|
|
118
|
+
return {
|
|
119
|
+
schemaVersion: SCHEMA_VERSION,
|
|
120
|
+
ok,
|
|
121
|
+
command: "ledger.check",
|
|
122
|
+
summary: parts.join(" "),
|
|
123
|
+
next,
|
|
124
|
+
nextCall: null,
|
|
125
|
+
caught: killed,
|
|
126
|
+
total: rows.length,
|
|
127
|
+
regressed,
|
|
128
|
+
changed,
|
|
129
|
+
unrecorded,
|
|
130
|
+
rows,
|
|
131
|
+
run: { summary: report.summary, commit: report.commit, full: report.full },
|
|
132
|
+
};
|
|
133
|
+
}
|