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/ledger/build.js
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { scrub } from "../contract.js";
|
|
6
|
-
import { filesInDiff, git, patchApplies, readHeader } from "../mutate/patch.js";
|
|
7
|
-
/** Revert each fix commit's src diff. A clean apply becomes a forward patch. A conflict is reported, never fuzzy-applied. */
|
|
8
|
-
export function buildLedger(options) {
|
|
9
|
-
const repo = realpathSync(path.resolve(options.repoDir));
|
|
10
|
-
const packageDir = realpathSync(path.resolve(options.packageDir));
|
|
11
|
-
const outDir = path.resolve(options.outDir);
|
|
12
|
-
const resolved = git(repo, ["rev-parse", "--verify", `${options.commit}^{commit}`]);
|
|
13
|
-
if (resolved.status !== 0)
|
|
14
|
-
return fail(outDir, options.commit, "that commit does not resolve");
|
|
15
|
-
const commit = resolved.stdout.trim();
|
|
16
|
-
const packageRel = path.relative(repo, packageDir);
|
|
17
|
-
if (packageRel.startsWith(".."))
|
|
18
|
-
return fail(outDir, commit, "package is outside the repository");
|
|
19
|
-
const srcPrefix = gitPath(packageRel, options.srcDir);
|
|
20
|
-
const testsPrefix = gitPath(packageRel, options.testsDir);
|
|
21
|
-
const collected = collect(repo, commit, srcPrefix, testsPrefix, options.maxCommits ?? null);
|
|
22
|
-
const candidates = collected.found;
|
|
23
|
-
mkdirSync(outDir, { recursive: true });
|
|
24
|
-
const ids = assignIds(candidates.map((item) => item.commit));
|
|
25
|
-
const disk = loadDisk(outDir);
|
|
26
|
-
let workDir = null;
|
|
27
|
-
try {
|
|
28
|
-
const entries = [];
|
|
29
|
-
const bodies = new Map();
|
|
30
|
-
for (const candidate of candidates) {
|
|
31
|
-
const id = ids.get(candidate.commit) ?? candidate.commit.slice(0, 7);
|
|
32
|
-
if (candidate.lines > options.maxLines) {
|
|
33
|
-
entries.push(entry(candidate, id, "skipped", [], `src diff is ${candidate.lines} lines, over the ${options.maxLines} line cap`));
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
const fixDiff = git(repo, ["diff", `${candidate.commit}^`, candidate.commit, "--", srcPrefix]).stdout;
|
|
37
|
-
const bugDiff = git(repo, ["diff", "-R", `${candidate.commit}^`, candidate.commit, "--", srcPrefix]).stdout;
|
|
38
|
-
if (!bugDiff.trim())
|
|
39
|
-
continue;
|
|
40
|
-
if (!workDir) {
|
|
41
|
-
const work = withWorktree(repo, commit);
|
|
42
|
-
if ("error" in work)
|
|
43
|
-
return fail(outDir, commit, work.error);
|
|
44
|
-
workDir = work.dir;
|
|
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
|
-
}
|
|
53
|
-
const applied = git(workDir, ["apply", "--check", "--whitespace=nowarn", "-"], bugDiff);
|
|
54
|
-
if (applied.status === 0) {
|
|
55
|
-
bodies.set(id, historyPatch(candidate.commit, bugDiff));
|
|
56
|
-
entries.push({ ...entry(candidate, id, "clean", filesInDiff(fixDiff)), patch: `${id}.patch` });
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
const where = failedAt(applied.stderr) ?? filesInDiff(fixDiff)[0] ?? { file: srcPrefix, line: 1 };
|
|
60
|
-
const owned = disk.find((item) => item.kind !== "history" && (item.fix === candidate.commit || item.id === id));
|
|
61
|
-
if (owned && handmadeApplies(workDir, owned.raw)) {
|
|
62
|
-
entries.push({ ...entry(candidate, id, "handmade", [where]), patch: `${owned.id}.patch` });
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const reason = owned ? "hand-made patch does not apply" : "patch does not apply";
|
|
66
|
-
entries.push(entry(candidate, id, "hand", [where], reason));
|
|
67
|
-
}
|
|
68
|
-
writeOut(outDir, commit, packageRel || ".", entries, bodies, disk);
|
|
69
|
-
return report(outDir, repo, packageDir, commit, entries, collected);
|
|
70
|
-
}
|
|
71
|
-
finally {
|
|
72
|
-
if (workDir)
|
|
73
|
-
removeWorktree(repo, workDir);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
|
|
77
|
-
const args = ["rev-list", "--no-merges"];
|
|
78
|
-
if (maxCommits !== null)
|
|
79
|
-
args.push("-n", String(maxCommits + 1));
|
|
80
|
-
args.push(commit);
|
|
81
|
-
const list = git(repo, args);
|
|
82
|
-
if (list.status !== 0)
|
|
83
|
-
return { found: [], scanned: 0, budgetHit: false };
|
|
84
|
-
const shas = list.stdout.split("\n").filter((sha) => sha.length > 0);
|
|
85
|
-
const budgetHit = maxCommits !== null && shas.length > maxCommits;
|
|
86
|
-
const limited = budgetHit ? shas.slice(0, maxCommits) : shas;
|
|
87
|
-
const found = [];
|
|
88
|
-
for (const sha of limited) {
|
|
89
|
-
if (git(repo, ["rev-parse", "--verify", `${sha}^`]).status !== 0)
|
|
90
|
-
continue;
|
|
91
|
-
const rows = numstat(repo, sha);
|
|
92
|
-
let lines = 0;
|
|
93
|
-
let src = false;
|
|
94
|
-
let tests = false;
|
|
95
|
-
for (const row of rows) {
|
|
96
|
-
if (under(row.file, srcPrefix)) {
|
|
97
|
-
src = true;
|
|
98
|
-
if (row.add !== "-" && row.del !== "-")
|
|
99
|
-
lines += Number(row.add) + Number(row.del);
|
|
100
|
-
}
|
|
101
|
-
if (under(row.file, testsPrefix))
|
|
102
|
-
tests = true;
|
|
103
|
-
}
|
|
104
|
-
if (!src)
|
|
105
|
-
continue;
|
|
106
|
-
const meta = git(repo, ["log", "-1", `--format=%cI%n%s%n%b`, sha]);
|
|
107
|
-
if (meta.status !== 0)
|
|
108
|
-
continue;
|
|
109
|
-
const [date, subject, ...rest] = meta.stdout.replace(/\n$/, "").split("\n");
|
|
110
|
-
const body = rest.join("\n");
|
|
111
|
-
const hasTrailer = /^Fixes-bug:/m.test(body);
|
|
112
|
-
if (!hasTrailer && !(src && tests))
|
|
113
|
-
continue;
|
|
114
|
-
if (!hasTrailer && lines === 0)
|
|
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;
|
|
119
|
-
found.push({
|
|
120
|
-
commit: sha,
|
|
121
|
-
date: date ?? "",
|
|
122
|
-
subject: subject ?? "",
|
|
123
|
-
fixer: /^Made-by:[ \t]*\S+[ \t]*$/m.test(body) ? "agent" : "human",
|
|
124
|
-
lines,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
return { found, scanned: limited.length, budgetHit };
|
|
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
|
-
}
|
|
140
|
-
function entry(candidate, id, status, files, reason) {
|
|
141
|
-
return {
|
|
142
|
-
id,
|
|
143
|
-
commit: candidate.commit,
|
|
144
|
-
date: candidate.date,
|
|
145
|
-
subject: candidate.subject,
|
|
146
|
-
fixer: candidate.fixer,
|
|
147
|
-
status,
|
|
148
|
-
lines: candidate.lines,
|
|
149
|
-
files,
|
|
150
|
-
...(reason ? { reason } : {}),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
function writeOut(outDir, commit, packageRel, entries, bodies, disk) {
|
|
154
|
-
const cleanIds = new Set(entries.filter((item) => item.status === "clean").map((item) => item.id));
|
|
155
|
-
for (const item of disk) {
|
|
156
|
-
if (item.kind === "history" && !cleanIds.has(item.id))
|
|
157
|
-
unlinkSync(path.join(outDir, `${item.id}.patch`));
|
|
158
|
-
}
|
|
159
|
-
for (const item of entries) {
|
|
160
|
-
const body = bodies.get(item.id);
|
|
161
|
-
if (item.status !== "clean" || !body)
|
|
162
|
-
continue;
|
|
163
|
-
const dest = path.join(outDir, `${item.id}.patch`);
|
|
164
|
-
const temp = `${dest}.${process.pid}.tmp`;
|
|
165
|
-
writeFileSync(temp, body);
|
|
166
|
-
renameSync(temp, dest);
|
|
167
|
-
}
|
|
168
|
-
const recorded = {
|
|
169
|
-
commit,
|
|
170
|
-
package: packageRel.split(path.sep).join("/"),
|
|
171
|
-
entries,
|
|
172
|
-
};
|
|
173
|
-
const dest = path.join(outDir, "ledger.json");
|
|
174
|
-
const temp = `${dest}.${process.pid}.tmp`;
|
|
175
|
-
writeFileSync(temp, `${JSON.stringify(scrub(recorded), null, 2)}\n`);
|
|
176
|
-
renameSync(temp, dest);
|
|
177
|
-
}
|
|
178
|
-
function report(outDir, repo, packageDir, commit, entries, collected) {
|
|
179
|
-
const clean = entries.filter((item) => item.status === "clean").length;
|
|
180
|
-
const handEntries = entries.filter((item) => item.status === "hand").sort(byNewest);
|
|
181
|
-
const handmade = entries.filter((item) => item.status === "handmade").length;
|
|
182
|
-
const skipped = entries.filter((item) => item.status === "skipped").length;
|
|
183
|
-
const shown = handEntries.slice(0, 10).map((item) => ({
|
|
184
|
-
id: item.id,
|
|
185
|
-
commit: item.commit,
|
|
186
|
-
file: item.files[0]?.file ?? "",
|
|
187
|
-
line: item.files[0]?.line ?? 0,
|
|
188
|
-
}));
|
|
189
|
-
const parts = [`${clean} fixes reverse cleanly`];
|
|
190
|
-
if (handEntries.length > 0)
|
|
191
|
-
parts.push(`${handEntries.length} need a hand-made mutant`);
|
|
192
|
-
if (handmade > 0)
|
|
193
|
-
parts.push(`${handmade} hand-made mutants apply`);
|
|
194
|
-
if (skipped > 0)
|
|
195
|
-
parts.push(`${skipped} are over the line cap`);
|
|
196
|
-
if (collected.budgetHit) {
|
|
197
|
-
const commits = collected.scanned === 1 ? "1 commit" : `${collected.scanned} commits`;
|
|
198
|
-
parts.push(`Stopped after ${commits}. History was not fully scanned`);
|
|
199
|
-
}
|
|
200
|
-
const summary = `${parts.join(". ")}.`;
|
|
201
|
-
let next = "No fix commit to revert.";
|
|
202
|
-
if (handEntries.length > 0) {
|
|
203
|
-
const first = shown[0];
|
|
204
|
-
next = first.file
|
|
205
|
-
? `First fix that needs a hand-made mutant is ${first.id} at ${first.file}:${first.line}.`
|
|
206
|
-
: `First fix that needs a hand-made mutant is ${first.id}.`;
|
|
207
|
-
}
|
|
208
|
-
else if (clean + handmade > 0)
|
|
209
|
-
next = "Run the ledger mutants.";
|
|
210
|
-
const nextCall = handEntries.length === 0 && clean + handmade > 0
|
|
211
|
-
? {
|
|
212
|
-
argv: [
|
|
213
|
-
"mutate",
|
|
214
|
-
"run",
|
|
215
|
-
"--package",
|
|
216
|
-
packageDir,
|
|
217
|
-
"--repo",
|
|
218
|
-
repo,
|
|
219
|
-
"--commit",
|
|
220
|
-
commit,
|
|
221
|
-
"--patches",
|
|
222
|
-
outDir,
|
|
223
|
-
"--out",
|
|
224
|
-
path.join(outDir, "runs"),
|
|
225
|
-
],
|
|
226
|
-
}
|
|
227
|
-
: null;
|
|
228
|
-
return {
|
|
229
|
-
ok: true,
|
|
230
|
-
summary,
|
|
231
|
-
next,
|
|
232
|
-
nextCall,
|
|
233
|
-
commit,
|
|
234
|
-
clean,
|
|
235
|
-
hand: handEntries.length,
|
|
236
|
-
handmade,
|
|
237
|
-
skipped,
|
|
238
|
-
handNeeded: shown,
|
|
239
|
-
rest: Math.max(handEntries.length - shown.length, 0),
|
|
240
|
-
full: path.join(outDir, "ledger.json"),
|
|
241
|
-
historyBudgetHit: collected.budgetHit,
|
|
242
|
-
commitsScanned: collected.scanned,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
function fail(outDir, commit, error) {
|
|
246
|
-
return {
|
|
247
|
-
ok: false,
|
|
248
|
-
summary: error,
|
|
249
|
-
next: "Fix that and run the same command again.",
|
|
250
|
-
nextCall: null,
|
|
251
|
-
commit,
|
|
252
|
-
clean: 0,
|
|
253
|
-
hand: 0,
|
|
254
|
-
handmade: 0,
|
|
255
|
-
skipped: 0,
|
|
256
|
-
handNeeded: [],
|
|
257
|
-
rest: 0,
|
|
258
|
-
full: path.join(outDir, "ledger.json"),
|
|
259
|
-
historyBudgetHit: false,
|
|
260
|
-
commitsScanned: 0,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
function historyPatch(commit, bugDiff) {
|
|
264
|
-
const body = bugDiff.endsWith("\n") ? bugDiff : `${bugDiff}\n`;
|
|
265
|
-
return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n# probatio-ledger source=history fix=${commit}\n${body}`;
|
|
266
|
-
}
|
|
267
|
-
function handmadeApplies(worktree, raw) {
|
|
268
|
-
const named = readHeader(raw).direction;
|
|
269
|
-
if (named)
|
|
270
|
-
return patchApplies(worktree, raw, named);
|
|
271
|
-
return patchApplies(worktree, raw, "forward");
|
|
272
|
-
}
|
|
273
|
-
function failedAt(stderr) {
|
|
274
|
-
const match = stderr.match(/patch failed: ([^:\n]+):(\d+)/);
|
|
275
|
-
if (!match)
|
|
276
|
-
return null;
|
|
277
|
-
return { file: match[1], line: Number(match[2]) };
|
|
278
|
-
}
|
|
279
|
-
function numstat(repo, sha) {
|
|
280
|
-
const result = git(repo, ["diff-tree", "-r", "--numstat", "--no-commit-id", sha]);
|
|
281
|
-
if (result.status !== 0)
|
|
282
|
-
return [];
|
|
283
|
-
const rows = [];
|
|
284
|
-
for (const line of result.stdout.split("\n")) {
|
|
285
|
-
if (!line.trim())
|
|
286
|
-
continue;
|
|
287
|
-
const [add, del, file] = line.split("\t");
|
|
288
|
-
if (!file)
|
|
289
|
-
continue;
|
|
290
|
-
rows.push({ add, del, file });
|
|
291
|
-
}
|
|
292
|
-
return rows;
|
|
293
|
-
}
|
|
294
|
-
function loadDisk(outDir) {
|
|
295
|
-
if (!existsSync(outDir))
|
|
296
|
-
return [];
|
|
297
|
-
const found = [];
|
|
298
|
-
for (const name of readdirSync(outDir)) {
|
|
299
|
-
if (!name.endsWith(".patch"))
|
|
300
|
-
continue;
|
|
301
|
-
const raw = readFileSync(path.join(outDir, name), "utf8");
|
|
302
|
-
const header = raw.split("\n").filter((line) => line.startsWith("#")).join("\n");
|
|
303
|
-
const kind = /source=hand\b/.test(header) ? "hand" : /source=history\b/.test(header) ? "history" : "user";
|
|
304
|
-
const fix = header.match(/\bfix=([0-9a-f]{7,40})\b/)?.[1] ?? null;
|
|
305
|
-
found.push({ id: path.basename(name, ".patch"), raw, kind, fix });
|
|
306
|
-
}
|
|
307
|
-
return found;
|
|
308
|
-
}
|
|
309
|
-
function assignIds(shas) {
|
|
310
|
-
const used = new Set();
|
|
311
|
-
const ids = new Map();
|
|
312
|
-
for (const sha of [...shas].sort()) {
|
|
313
|
-
let size = Math.min(7, sha.length);
|
|
314
|
-
let id = sha.slice(0, size);
|
|
315
|
-
while (used.has(id) && size < sha.length) {
|
|
316
|
-
size += 1;
|
|
317
|
-
id = sha.slice(0, size);
|
|
318
|
-
}
|
|
319
|
-
used.add(id);
|
|
320
|
-
ids.set(sha, id);
|
|
321
|
-
}
|
|
322
|
-
return ids;
|
|
323
|
-
}
|
|
324
|
-
function withWorktree(repo, commit) {
|
|
325
|
-
const dir = path.join(tmpdir(), `probatio-ledger-${randomBytes(4).toString("hex")}`);
|
|
326
|
-
rmSync(dir, { recursive: true, force: true });
|
|
327
|
-
const added = git(repo, ["worktree", "add", "--detach", "--quiet", dir, commit]);
|
|
328
|
-
if (added.status !== 0)
|
|
329
|
-
return { error: (added.stderr || "worktree add failed").trim() };
|
|
330
|
-
return { dir };
|
|
331
|
-
}
|
|
332
|
-
function removeWorktree(repo, dir) {
|
|
333
|
-
git(repo, ["worktree", "remove", "--force", dir]);
|
|
334
|
-
rmSync(dir, { recursive: true, force: true });
|
|
335
|
-
git(repo, ["worktree", "prune"]);
|
|
336
|
-
}
|
|
337
|
-
function gitPath(packageRel, dir) {
|
|
338
|
-
const joined = packageRel ? path.join(packageRel, dir) : dir;
|
|
339
|
-
return joined.split(path.sep).join("/");
|
|
340
|
-
}
|
|
341
|
-
function under(file, prefix) {
|
|
342
|
-
return file === prefix || file.startsWith(`${prefix}/`);
|
|
343
|
-
}
|
|
344
|
-
function byNewest(left, right) {
|
|
345
|
-
if (left.date !== right.date)
|
|
346
|
-
return right.date < left.date ? -1 : 1;
|
|
347
|
-
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
348
|
-
}
|
package/dist/ledger/check.js
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
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
|
-
}
|