probatio 0.3.0 → 0.5.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 +10 -198
- package/bin/probatio.js +52 -0
- package/package.json +21 -29
- package/LICENSE +0 -21
- package/dist/cli.js +0 -721
- package/dist/contract.js +0 -32
- package/dist/flags.js +0 -87
- package/dist/golden/check.js +0 -188
- package/dist/golden/record.js +0 -395
- package/dist/golden/recorder.mjs +0 -78
- package/dist/ledger/build.js +0 -348
- package/dist/ledger/check.js +0 -133
- package/dist/matrix/report.js +0 -256
- package/dist/mcp.js +0 -188
- package/dist/memory/state.js +0 -222
- package/dist/mutate/affected.js +0 -173
- package/dist/mutate/child-lines.mjs +0 -39
- package/dist/mutate/coverage-map.js +0 -229
- package/dist/mutate/csharp-coverage.js +0 -161
- package/dist/mutate/fail-reporter.mjs +0 -65
- package/dist/mutate/find.js +0 -13
- package/dist/mutate/generate.js +0 -156
- package/dist/mutate/go-coverage.js +0 -100
- package/dist/mutate/ids.js +0 -22
- package/dist/mutate/java-coverage.js +0 -217
- package/dist/mutate/kill-label.js +0 -34
- package/dist/mutate/load-failure.mjs +0 -40
- package/dist/mutate/mocha-coverage.cjs +0 -77
- package/dist/mutate/node-batch-loader.mjs +0 -99
- package/dist/mutate/node-batch.mjs +0 -167
- package/dist/mutate/node-coverage.mjs +0 -77
- package/dist/mutate/operators.js +0 -181
- package/dist/mutate/patch.js +0 -136
- package/dist/mutate/precise-lines.mjs +0 -304
- package/dist/mutate/probatio-jacoco-run.java +0 -516
- package/dist/mutate/project-config.js +0 -58
- package/dist/mutate/py-pytest-reporter.py +0 -145
- package/dist/mutate/py-reporter.py +0 -79
- package/dist/mutate/run.js +0 -1335
- package/dist/mutate/rust-coverage.js +0 -144
- package/dist/mutate/sealed.js +0 -118
- package/dist/mutate/suite-decision.js +0 -130
- package/dist/mutate/suites.js +0 -1524
- package/dist/mutate/tally.js +0 -240
- package/dist/mutate/text-operators.js +0 -637
- package/dist/mutate/titles.js +0 -23
- package/dist/node-version.js +0 -13
- package/dist/swarm/check-kill.js +0 -114
- package/dist/swarm/queue.js +0 -68
- package/dist/verify/change.js +0 -340
- package/schemas/check-kill.schema.json +0 -93
- package/schemas/findings.add.schema.json +0 -53
- package/schemas/gap.fix.schema.json +0 -53
- package/schemas/gap.revert.schema.json +0 -53
- package/schemas/golden.check.schema.json +0 -89
- package/schemas/golden.compare.schema.json +0 -151
- package/schemas/golden.record.schema.json +0 -126
- package/schemas/guard.check.schema.json +0 -53
- package/schemas/ledger.build.schema.json +0 -132
- package/schemas/ledger.check.schema.json +0 -143
- package/schemas/matrix.report.schema.json +0 -129
- package/schemas/mcp.schema.json +0 -53
- package/schemas/mutate.generate.schema.json +0 -127
- package/schemas/mutate.help.schema.json +0 -53
- package/schemas/mutate.run.schema.json +0 -272
- package/schemas/mutate.sealed.schema.json +0 -53
- package/schemas/mutate.tally.schema.json +0 -137
- package/schemas/queue.claim.schema.json +0 -53
- package/schemas/queue.reap.schema.json +0 -71
- package/schemas/queue.seed.schema.json +0 -71
- package/schemas/schema.schema.json +0 -74
- package/schemas/seal.schema.json +0 -69
- package/schemas/status.schema.json +0 -103
- package/schemas/verify-change.schema.json +0 -222
package/dist/golden/record.js
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/golden/recorder.mjs
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|