probatio 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -2
- package/dist/cli.js +498 -0
- package/dist/contract.js +29 -0
- package/dist/flags.js +87 -0
- package/dist/golden/check.js +188 -0
- package/dist/ledger/build.js +327 -0
- package/dist/matrix/report.js +256 -0
- package/dist/mcp.js +119 -0
- package/dist/memory/state.js +222 -0
- package/dist/mutate/affected.js +173 -0
- package/dist/mutate/coverage-map.js +172 -0
- package/dist/mutate/fail-reporter.mjs +44 -0
- package/dist/mutate/find.js +13 -0
- package/dist/mutate/generate.js +132 -0
- package/dist/mutate/ids.js +22 -0
- package/dist/mutate/kill-label.js +34 -0
- package/dist/mutate/node-coverage.mjs +245 -0
- package/dist/mutate/operators.js +122 -0
- package/dist/mutate/patch.js +135 -0
- package/dist/mutate/py-pytest-reporter.py +145 -0
- package/dist/mutate/py-reporter.py +79 -0
- package/dist/mutate/run.js +864 -0
- package/dist/mutate/suite-decision.js +122 -0
- package/dist/mutate/suites.js +1299 -0
- package/dist/mutate/tally.js +163 -0
- package/dist/mutate/text-operators.js +547 -0
- package/dist/mutate/titles.js +23 -0
- package/dist/swarm/check-kill.js +78 -0
- package/dist/swarm/queue.js +62 -0
- package/dist/verify/change.js +261 -0
- package/package.json +33 -4
package/dist/flags.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** `--key value`, `--key=value`, `--flag`, `--no-flag`. Words before the first flag are the command. */
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
const command = [];
|
|
4
|
+
const flags = new Map();
|
|
5
|
+
let seenFlag = false;
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const token = argv[i];
|
|
8
|
+
if (token === "--") {
|
|
9
|
+
seenFlag = true;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (!token.startsWith("--")) {
|
|
13
|
+
if (!seenFlag)
|
|
14
|
+
command.push(token);
|
|
15
|
+
else
|
|
16
|
+
push(flags, "_", token);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
seenFlag = true;
|
|
20
|
+
const body = token.slice(2);
|
|
21
|
+
const eq = body.indexOf("=");
|
|
22
|
+
if (eq !== -1) {
|
|
23
|
+
push(flags, body.slice(0, eq), body.slice(eq + 1));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (body.startsWith("no-") && body.length > 3) {
|
|
27
|
+
push(flags, body.slice(3), false);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
if (next === undefined || next.startsWith("--"))
|
|
32
|
+
push(flags, body, true);
|
|
33
|
+
else {
|
|
34
|
+
push(flags, body, next);
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const human = flags.get("human")?.some((value) => value === true) ?? false;
|
|
39
|
+
return { command, flags, human };
|
|
40
|
+
}
|
|
41
|
+
function push(flags, key, value) {
|
|
42
|
+
const list = flags.get(key);
|
|
43
|
+
if (list)
|
|
44
|
+
list.push(value);
|
|
45
|
+
else
|
|
46
|
+
flags.set(key, [value]);
|
|
47
|
+
}
|
|
48
|
+
export function text(flags, key) {
|
|
49
|
+
const values = flags.get(key);
|
|
50
|
+
if (!values || values.length === 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
const last = values[values.length - 1];
|
|
53
|
+
if (typeof last === "boolean")
|
|
54
|
+
return undefined;
|
|
55
|
+
return last;
|
|
56
|
+
}
|
|
57
|
+
export function texts(flags, key) {
|
|
58
|
+
return (flags.get(key) ?? []).filter((value) => typeof value === "string");
|
|
59
|
+
}
|
|
60
|
+
export function bool(flags, key, fallback) {
|
|
61
|
+
const values = flags.get(key);
|
|
62
|
+
if (!values || values.length === 0)
|
|
63
|
+
return fallback;
|
|
64
|
+
const last = values[values.length - 1];
|
|
65
|
+
if (typeof last === "boolean")
|
|
66
|
+
return last;
|
|
67
|
+
if (last === "true")
|
|
68
|
+
return true;
|
|
69
|
+
if (last === "false")
|
|
70
|
+
return false;
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
export function int(flags, key) {
|
|
74
|
+
const raw = text(flags, key);
|
|
75
|
+
if (raw === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
const value = Number(raw);
|
|
78
|
+
if (!Number.isInteger(value) || value < 0)
|
|
79
|
+
throw new Error(`--${key} must be a non-negative integer`);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
export function requireText(flags, key) {
|
|
83
|
+
const value = text(flags, key);
|
|
84
|
+
if (!value)
|
|
85
|
+
throw new Error(`--${key} is required`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { SCHEMA_VERSION } from "../contract.js";
|
|
4
|
+
const WORDING_KEYS = ["next", "nextLead"];
|
|
5
|
+
/** `Golden-Change: <row>: <why>`. The why starts after the first colon-and-space, so a row may contain colons. */
|
|
6
|
+
export function goldenChanges(message) {
|
|
7
|
+
const named = new Map();
|
|
8
|
+
for (const match of message.matchAll(/^Golden-Change:\s*(.+?):\s+(.+)$/gm))
|
|
9
|
+
named.set(match[1], match[2]);
|
|
10
|
+
return named;
|
|
11
|
+
}
|
|
12
|
+
export function readGolden(file) {
|
|
13
|
+
return asTable(readFileSync(file, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
export function checkGolden(input) {
|
|
16
|
+
const home = input.home ?? homedir();
|
|
17
|
+
const recorded = normalizeTable(input.recorded, input.roots);
|
|
18
|
+
const actual = normalizeTable(input.actual, input.roots);
|
|
19
|
+
const ids = [...new Set([...Object.keys(recorded.rows), ...Object.keys(actual.rows)])].sort();
|
|
20
|
+
const wording = [];
|
|
21
|
+
const contract = [];
|
|
22
|
+
const invariant = [];
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
const before = recorded.rows[id];
|
|
25
|
+
const after = actual.rows[id];
|
|
26
|
+
if (after) {
|
|
27
|
+
const violation = invariantOf(id, after, home);
|
|
28
|
+
if (violation)
|
|
29
|
+
invariant.push(violation);
|
|
30
|
+
}
|
|
31
|
+
if (JSON.stringify(wordingOf(before)) !== JSON.stringify(wordingOf(after)))
|
|
32
|
+
wording.push(id);
|
|
33
|
+
if (JSON.stringify(contractOf(before)) !== JSON.stringify(contractOf(after)))
|
|
34
|
+
contract.push(id);
|
|
35
|
+
}
|
|
36
|
+
const allowed = goldenChanges(input.message);
|
|
37
|
+
const unauthorized = contract.filter((id) => !allowed.has(id));
|
|
38
|
+
const canWrite = input.update !== null && invariant.length === 0 && unauthorized.length === 0;
|
|
39
|
+
const wordingWrite = canWrite && (input.update === "wording" || input.update === "all");
|
|
40
|
+
const contractWrite = canWrite && contract.every((id) => allowed.has(id));
|
|
41
|
+
const table = structuredClone(input.recorded);
|
|
42
|
+
if (wordingWrite || (canWrite && contractWrite && contract.length > 0)) {
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
const after = input.actual.rows[id];
|
|
45
|
+
const contractRow = contract.includes(id);
|
|
46
|
+
const wordingRow = wording.includes(id);
|
|
47
|
+
if (contractRow && contractWrite) {
|
|
48
|
+
if (!after)
|
|
49
|
+
delete table.rows[id];
|
|
50
|
+
else
|
|
51
|
+
table.rows[id] = after;
|
|
52
|
+
}
|
|
53
|
+
else if (wordingRow && wordingWrite && after && table.rows[id]) {
|
|
54
|
+
table.rows[id] = { ...table.rows[id], ...wordingOf(after) };
|
|
55
|
+
}
|
|
56
|
+
else if (wordingRow && wordingWrite && after && !table.rows[id] && !contractRow) {
|
|
57
|
+
table.rows[id] = wordingOf(after);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const write = invariant.length === 0 && unauthorized.length === 0 && input.update !== null && (wording.length > 0 || contract.length > 0);
|
|
62
|
+
const ok = invariant.length === 0 && unauthorized.length === 0 && (wording.length === 0 || input.update !== null);
|
|
63
|
+
const summary = ok
|
|
64
|
+
? wording.length === 0 && contract.length === 0
|
|
65
|
+
? "Golden rows match."
|
|
66
|
+
: `Re-recorded ${wording.length} wording row(s) and ${contract.length} contract row(s).`
|
|
67
|
+
: invariant.length > 0
|
|
68
|
+
? `Invariant failed: ${invariant.join("; ")}.`
|
|
69
|
+
: unauthorized.length > 0
|
|
70
|
+
? `Contract changed for ${unauthorized.join(", ")} without a Golden-Change trailer.`
|
|
71
|
+
: `Wording changed for ${wording.join(", ")}. Re-record with --update wording.`;
|
|
72
|
+
return {
|
|
73
|
+
write: write && ok,
|
|
74
|
+
table,
|
|
75
|
+
envelope: {
|
|
76
|
+
schemaVersion: SCHEMA_VERSION,
|
|
77
|
+
ok,
|
|
78
|
+
command: "golden.check",
|
|
79
|
+
summary,
|
|
80
|
+
next: ok ? "Review the re-recorded rows." : "The table was not written.",
|
|
81
|
+
nextCall: null,
|
|
82
|
+
wording,
|
|
83
|
+
contract,
|
|
84
|
+
invariant,
|
|
85
|
+
updated: write && ok,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function goldenShape(text) {
|
|
90
|
+
if (!text.trim())
|
|
91
|
+
return "rows";
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return "rows";
|
|
98
|
+
}
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
100
|
+
return "flat";
|
|
101
|
+
const rows = parsed.rows;
|
|
102
|
+
if (rows && typeof rows === "object" && !Array.isArray(rows))
|
|
103
|
+
return "rows";
|
|
104
|
+
return "flat";
|
|
105
|
+
}
|
|
106
|
+
export function writeGolden(file, table, shape = "rows") {
|
|
107
|
+
const rows = shape === "flat" ? table.rows : sortRows(table.rows);
|
|
108
|
+
const payload = shape === "flat" ? rows : { rows };
|
|
109
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
function invariantOf(id, row, home) {
|
|
112
|
+
if (row.ok === true && (typeof row.reason !== "string" || row.reason.trim().length === 0)) {
|
|
113
|
+
return `${id} has ok without a reason`;
|
|
114
|
+
}
|
|
115
|
+
if (home && containsHome(row, home))
|
|
116
|
+
return `${id} contains a home path`;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function containsHome(value, home) {
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return value.includes(home);
|
|
122
|
+
if (Array.isArray(value))
|
|
123
|
+
return value.some((item) => containsHome(item, home));
|
|
124
|
+
if (value && typeof value === "object")
|
|
125
|
+
return Object.values(value).some((item) => containsHome(item, home));
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function wordingOf(row) {
|
|
129
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
130
|
+
return {};
|
|
131
|
+
const out = {};
|
|
132
|
+
for (const key of WORDING_KEYS) {
|
|
133
|
+
if (Object.prototype.hasOwnProperty.call(row, key))
|
|
134
|
+
out[key] = row[key];
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function contractOf(row) {
|
|
139
|
+
if (!row)
|
|
140
|
+
return {};
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const key of Object.keys(row).sort()) {
|
|
143
|
+
if (WORDING_KEYS.includes(key))
|
|
144
|
+
continue;
|
|
145
|
+
out[key] = row[key];
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function normalizeTable(table, roots) {
|
|
150
|
+
return { rows: normalize(table.rows, roots) };
|
|
151
|
+
}
|
|
152
|
+
function normalize(value, roots) {
|
|
153
|
+
if (typeof value === "string") {
|
|
154
|
+
let out = value;
|
|
155
|
+
for (const root of roots) {
|
|
156
|
+
if (root.length > 1 && out.includes(root))
|
|
157
|
+
out = out.split(root).join("{root}");
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(value))
|
|
162
|
+
return value.map((item) => normalize(item, roots));
|
|
163
|
+
if (value && typeof value === "object") {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [key, item] of Object.entries(value))
|
|
166
|
+
out[key] = normalize(item, roots);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
export function asTable(text) {
|
|
172
|
+
if (!text.trim())
|
|
173
|
+
return { rows: {} };
|
|
174
|
+
const parsed = JSON.parse(text);
|
|
175
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
176
|
+
return { rows: {} };
|
|
177
|
+
const record = parsed;
|
|
178
|
+
const rows = record.rows && typeof record.rows === "object" && !Array.isArray(record.rows)
|
|
179
|
+
? record.rows
|
|
180
|
+
: record;
|
|
181
|
+
return { rows };
|
|
182
|
+
}
|
|
183
|
+
function sortRows(rows) {
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const key of Object.keys(rows).sort())
|
|
186
|
+
out[key] = rows[key];
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
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
|
+
const applied = git(workDir, ["apply", "--check", "--whitespace=nowarn", "-"], bugDiff);
|
|
47
|
+
if (applied.status === 0) {
|
|
48
|
+
bodies.set(id, historyPatch(candidate.commit, bugDiff));
|
|
49
|
+
entries.push({ ...entry(candidate, id, "clean", filesInDiff(fixDiff)), patch: `${id}.patch` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const where = failedAt(applied.stderr) ?? filesInDiff(fixDiff)[0] ?? { file: srcPrefix, line: 1 };
|
|
53
|
+
const owned = disk.find((item) => item.kind !== "history" && (item.fix === candidate.commit || item.id === id));
|
|
54
|
+
if (owned && handmadeApplies(workDir, owned.raw)) {
|
|
55
|
+
entries.push({ ...entry(candidate, id, "handmade", [where]), patch: `${owned.id}.patch` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const reason = owned ? "hand-made patch does not apply" : "patch does not apply";
|
|
59
|
+
entries.push(entry(candidate, id, "hand", [where], reason));
|
|
60
|
+
}
|
|
61
|
+
writeOut(outDir, commit, packageRel || ".", entries, bodies, disk);
|
|
62
|
+
return report(outDir, repo, packageDir, commit, entries, collected);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (workDir)
|
|
66
|
+
removeWorktree(repo, workDir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
|
|
70
|
+
const args = ["rev-list", "--no-merges"];
|
|
71
|
+
if (maxCommits !== null)
|
|
72
|
+
args.push("-n", String(maxCommits + 1));
|
|
73
|
+
args.push(commit);
|
|
74
|
+
const list = git(repo, args);
|
|
75
|
+
if (list.status !== 0)
|
|
76
|
+
return { found: [], scanned: 0, budgetHit: false };
|
|
77
|
+
const shas = list.stdout.split("\n").filter((sha) => sha.length > 0);
|
|
78
|
+
const budgetHit = maxCommits !== null && shas.length > maxCommits;
|
|
79
|
+
const limited = budgetHit ? shas.slice(0, maxCommits) : shas;
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const sha of limited) {
|
|
82
|
+
if (git(repo, ["rev-parse", "--verify", `${sha}^`]).status !== 0)
|
|
83
|
+
continue;
|
|
84
|
+
const rows = numstat(repo, sha);
|
|
85
|
+
let lines = 0;
|
|
86
|
+
let src = false;
|
|
87
|
+
let tests = false;
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if (under(row.file, srcPrefix)) {
|
|
90
|
+
src = true;
|
|
91
|
+
if (row.add !== "-" && row.del !== "-")
|
|
92
|
+
lines += Number(row.add) + Number(row.del);
|
|
93
|
+
}
|
|
94
|
+
if (under(row.file, testsPrefix))
|
|
95
|
+
tests = true;
|
|
96
|
+
}
|
|
97
|
+
if (!src)
|
|
98
|
+
continue;
|
|
99
|
+
const meta = git(repo, ["log", "-1", `--format=%cI%n%s%n%b`, sha]);
|
|
100
|
+
if (meta.status !== 0)
|
|
101
|
+
continue;
|
|
102
|
+
const [date, subject, ...rest] = meta.stdout.replace(/\n$/, "").split("\n");
|
|
103
|
+
const body = rest.join("\n");
|
|
104
|
+
const hasTrailer = /^Fixes-bug:/m.test(body);
|
|
105
|
+
if (!hasTrailer && !(src && tests))
|
|
106
|
+
continue;
|
|
107
|
+
if (!hasTrailer && lines === 0)
|
|
108
|
+
continue;
|
|
109
|
+
found.push({
|
|
110
|
+
commit: sha,
|
|
111
|
+
date: date ?? "",
|
|
112
|
+
subject: subject ?? "",
|
|
113
|
+
fixer: /^Made-by:[ \t]*\S+[ \t]*$/m.test(body) ? "agent" : "human",
|
|
114
|
+
lines,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return { found, scanned: limited.length, budgetHit };
|
|
118
|
+
}
|
|
119
|
+
function entry(candidate, id, status, files, reason) {
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
commit: candidate.commit,
|
|
123
|
+
date: candidate.date,
|
|
124
|
+
subject: candidate.subject,
|
|
125
|
+
fixer: candidate.fixer,
|
|
126
|
+
status,
|
|
127
|
+
lines: candidate.lines,
|
|
128
|
+
files,
|
|
129
|
+
...(reason ? { reason } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function writeOut(outDir, commit, packageRel, entries, bodies, disk) {
|
|
133
|
+
const cleanIds = new Set(entries.filter((item) => item.status === "clean").map((item) => item.id));
|
|
134
|
+
for (const item of disk) {
|
|
135
|
+
if (item.kind === "history" && !cleanIds.has(item.id))
|
|
136
|
+
unlinkSync(path.join(outDir, `${item.id}.patch`));
|
|
137
|
+
}
|
|
138
|
+
for (const item of entries) {
|
|
139
|
+
const body = bodies.get(item.id);
|
|
140
|
+
if (item.status !== "clean" || !body)
|
|
141
|
+
continue;
|
|
142
|
+
const dest = path.join(outDir, `${item.id}.patch`);
|
|
143
|
+
const temp = `${dest}.${process.pid}.tmp`;
|
|
144
|
+
writeFileSync(temp, body);
|
|
145
|
+
renameSync(temp, dest);
|
|
146
|
+
}
|
|
147
|
+
const recorded = {
|
|
148
|
+
commit,
|
|
149
|
+
package: packageRel.split(path.sep).join("/"),
|
|
150
|
+
entries,
|
|
151
|
+
};
|
|
152
|
+
const dest = path.join(outDir, "ledger.json");
|
|
153
|
+
const temp = `${dest}.${process.pid}.tmp`;
|
|
154
|
+
writeFileSync(temp, `${JSON.stringify(scrub(recorded), null, 2)}\n`);
|
|
155
|
+
renameSync(temp, dest);
|
|
156
|
+
}
|
|
157
|
+
function report(outDir, repo, packageDir, commit, entries, collected) {
|
|
158
|
+
const clean = entries.filter((item) => item.status === "clean").length;
|
|
159
|
+
const handEntries = entries.filter((item) => item.status === "hand").sort(byNewest);
|
|
160
|
+
const handmade = entries.filter((item) => item.status === "handmade").length;
|
|
161
|
+
const skipped = entries.filter((item) => item.status === "skipped").length;
|
|
162
|
+
const shown = handEntries.slice(0, 10).map((item) => ({
|
|
163
|
+
id: item.id,
|
|
164
|
+
commit: item.commit,
|
|
165
|
+
file: item.files[0]?.file ?? "",
|
|
166
|
+
line: item.files[0]?.line ?? 0,
|
|
167
|
+
}));
|
|
168
|
+
const parts = [`${clean} fixes reverse cleanly`];
|
|
169
|
+
if (handEntries.length > 0)
|
|
170
|
+
parts.push(`${handEntries.length} need a hand-made mutant`);
|
|
171
|
+
if (handmade > 0)
|
|
172
|
+
parts.push(`${handmade} hand-made mutants apply`);
|
|
173
|
+
if (skipped > 0)
|
|
174
|
+
parts.push(`${skipped} are over the line cap`);
|
|
175
|
+
if (collected.budgetHit) {
|
|
176
|
+
const commits = collected.scanned === 1 ? "1 commit" : `${collected.scanned} commits`;
|
|
177
|
+
parts.push(`Stopped after ${commits}. History was not fully scanned`);
|
|
178
|
+
}
|
|
179
|
+
const summary = `${parts.join(". ")}.`;
|
|
180
|
+
let next = "No fix commit to revert.";
|
|
181
|
+
if (handEntries.length > 0) {
|
|
182
|
+
const first = shown[0];
|
|
183
|
+
next = first.file
|
|
184
|
+
? `First fix that needs a hand-made mutant is ${first.id} at ${first.file}:${first.line}.`
|
|
185
|
+
: `First fix that needs a hand-made mutant is ${first.id}.`;
|
|
186
|
+
}
|
|
187
|
+
else if (clean + handmade > 0)
|
|
188
|
+
next = "Run the ledger mutants.";
|
|
189
|
+
const nextCall = handEntries.length === 0 && clean + handmade > 0
|
|
190
|
+
? {
|
|
191
|
+
argv: [
|
|
192
|
+
"mutate",
|
|
193
|
+
"run",
|
|
194
|
+
"--package",
|
|
195
|
+
packageDir,
|
|
196
|
+
"--repo",
|
|
197
|
+
repo,
|
|
198
|
+
"--commit",
|
|
199
|
+
commit,
|
|
200
|
+
"--patches",
|
|
201
|
+
outDir,
|
|
202
|
+
"--out",
|
|
203
|
+
path.join(outDir, "runs"),
|
|
204
|
+
],
|
|
205
|
+
}
|
|
206
|
+
: null;
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
summary,
|
|
210
|
+
next,
|
|
211
|
+
nextCall,
|
|
212
|
+
commit,
|
|
213
|
+
clean,
|
|
214
|
+
hand: handEntries.length,
|
|
215
|
+
handmade,
|
|
216
|
+
skipped,
|
|
217
|
+
handNeeded: shown,
|
|
218
|
+
rest: Math.max(handEntries.length - shown.length, 0),
|
|
219
|
+
full: path.join(outDir, "ledger.json"),
|
|
220
|
+
historyBudgetHit: collected.budgetHit,
|
|
221
|
+
commitsScanned: collected.scanned,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function fail(outDir, commit, error) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
summary: error,
|
|
228
|
+
next: "Fix that and run the same command again.",
|
|
229
|
+
nextCall: null,
|
|
230
|
+
commit,
|
|
231
|
+
clean: 0,
|
|
232
|
+
hand: 0,
|
|
233
|
+
handmade: 0,
|
|
234
|
+
skipped: 0,
|
|
235
|
+
handNeeded: [],
|
|
236
|
+
rest: 0,
|
|
237
|
+
full: path.join(outDir, "ledger.json"),
|
|
238
|
+
historyBudgetHit: false,
|
|
239
|
+
commitsScanned: 0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function historyPatch(commit, bugDiff) {
|
|
243
|
+
const body = bugDiff.endsWith("\n") ? bugDiff : `${bugDiff}\n`;
|
|
244
|
+
return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n# probatio-ledger source=history fix=${commit}\n${body}`;
|
|
245
|
+
}
|
|
246
|
+
function handmadeApplies(worktree, raw) {
|
|
247
|
+
const named = readHeader(raw).direction;
|
|
248
|
+
if (named)
|
|
249
|
+
return patchApplies(worktree, raw, named);
|
|
250
|
+
return patchApplies(worktree, raw, "forward");
|
|
251
|
+
}
|
|
252
|
+
function failedAt(stderr) {
|
|
253
|
+
const match = stderr.match(/patch failed: ([^:\n]+):(\d+)/);
|
|
254
|
+
if (!match)
|
|
255
|
+
return null;
|
|
256
|
+
return { file: match[1], line: Number(match[2]) };
|
|
257
|
+
}
|
|
258
|
+
function numstat(repo, sha) {
|
|
259
|
+
const result = git(repo, ["diff-tree", "-r", "--numstat", "--no-commit-id", sha]);
|
|
260
|
+
if (result.status !== 0)
|
|
261
|
+
return [];
|
|
262
|
+
const rows = [];
|
|
263
|
+
for (const line of result.stdout.split("\n")) {
|
|
264
|
+
if (!line.trim())
|
|
265
|
+
continue;
|
|
266
|
+
const [add, del, file] = line.split("\t");
|
|
267
|
+
if (!file)
|
|
268
|
+
continue;
|
|
269
|
+
rows.push({ add, del, file });
|
|
270
|
+
}
|
|
271
|
+
return rows;
|
|
272
|
+
}
|
|
273
|
+
function loadDisk(outDir) {
|
|
274
|
+
if (!existsSync(outDir))
|
|
275
|
+
return [];
|
|
276
|
+
const found = [];
|
|
277
|
+
for (const name of readdirSync(outDir)) {
|
|
278
|
+
if (!name.endsWith(".patch"))
|
|
279
|
+
continue;
|
|
280
|
+
const raw = readFileSync(path.join(outDir, name), "utf8");
|
|
281
|
+
const header = raw.split("\n").filter((line) => line.startsWith("#")).join("\n");
|
|
282
|
+
const kind = /source=hand\b/.test(header) ? "hand" : /source=history\b/.test(header) ? "history" : "user";
|
|
283
|
+
const fix = header.match(/\bfix=([0-9a-f]{7,40})\b/)?.[1] ?? null;
|
|
284
|
+
found.push({ id: path.basename(name, ".patch"), raw, kind, fix });
|
|
285
|
+
}
|
|
286
|
+
return found;
|
|
287
|
+
}
|
|
288
|
+
function assignIds(shas) {
|
|
289
|
+
const used = new Set();
|
|
290
|
+
const ids = new Map();
|
|
291
|
+
for (const sha of [...shas].sort()) {
|
|
292
|
+
let size = Math.min(7, sha.length);
|
|
293
|
+
let id = sha.slice(0, size);
|
|
294
|
+
while (used.has(id) && size < sha.length) {
|
|
295
|
+
size += 1;
|
|
296
|
+
id = sha.slice(0, size);
|
|
297
|
+
}
|
|
298
|
+
used.add(id);
|
|
299
|
+
ids.set(sha, id);
|
|
300
|
+
}
|
|
301
|
+
return ids;
|
|
302
|
+
}
|
|
303
|
+
function withWorktree(repo, commit) {
|
|
304
|
+
const dir = path.join(tmpdir(), `probatio-ledger-${randomBytes(4).toString("hex")}`);
|
|
305
|
+
rmSync(dir, { recursive: true, force: true });
|
|
306
|
+
const added = git(repo, ["worktree", "add", "--detach", "--quiet", dir, commit]);
|
|
307
|
+
if (added.status !== 0)
|
|
308
|
+
return { error: (added.stderr || "worktree add failed").trim() };
|
|
309
|
+
return { dir };
|
|
310
|
+
}
|
|
311
|
+
function removeWorktree(repo, dir) {
|
|
312
|
+
git(repo, ["worktree", "remove", "--force", dir]);
|
|
313
|
+
rmSync(dir, { recursive: true, force: true });
|
|
314
|
+
git(repo, ["worktree", "prune"]);
|
|
315
|
+
}
|
|
316
|
+
function gitPath(packageRel, dir) {
|
|
317
|
+
const joined = packageRel ? path.join(packageRel, dir) : dir;
|
|
318
|
+
return joined.split(path.sep).join("/");
|
|
319
|
+
}
|
|
320
|
+
function under(file, prefix) {
|
|
321
|
+
return file === prefix || file.startsWith(`${prefix}/`);
|
|
322
|
+
}
|
|
323
|
+
function byNewest(left, right) {
|
|
324
|
+
if (left.date !== right.date)
|
|
325
|
+
return right.date < left.date ? -1 : 1;
|
|
326
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
327
|
+
}
|