probatio 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadSealed } from "../memory/state.js";
4
+ export function seedQueue(root, ids) {
5
+ const sealed = new Set(loadSealed(root));
6
+ const queue = path.join(root, "queue");
7
+ mkdirSync(queue, { recursive: true });
8
+ const written = [];
9
+ for (const id of [...ids].sort()) {
10
+ if (sealed.has(id))
11
+ continue;
12
+ const dest = path.join(queue, `${id}.json`);
13
+ if (existsSync(dest))
14
+ continue;
15
+ writeFileSync(dest, `${JSON.stringify({ id })}\n`);
16
+ written.push(id);
17
+ }
18
+ return written;
19
+ }
20
+ /** Claim by atomic rename. A second claim fails because the queue file is already gone. */
21
+ export function claimItem(root, id, agent, now, leaseMs) {
22
+ const source = path.join(root, "queue", `${id}.json`);
23
+ const claimed = path.join(root, "claimed");
24
+ const dest = path.join(claimed, `${agent}-${id}.json`);
25
+ if (!existsSync(source))
26
+ return { ok: false, summary: `${id} is not in the queue.`, file: null };
27
+ mkdirSync(claimed, { recursive: true });
28
+ const body = JSON.parse(readFileSync(source, "utf8"));
29
+ body.id = id;
30
+ body.agent = agent;
31
+ body.leasedUntil = now + leaseMs;
32
+ writeFileSync(source, `${JSON.stringify(body)}\n`);
33
+ try {
34
+ renameSync(source, dest);
35
+ }
36
+ catch {
37
+ return { ok: false, summary: `${id} was claimed by someone else.`, file: null };
38
+ }
39
+ return { ok: true, summary: `${agent} claimed ${id} until ${body.leasedUntil}.`, file: dest };
40
+ }
41
+ /** Move claims whose lease has expired back to the queue. */
42
+ export function reapClaims(root, now) {
43
+ const claimed = path.join(root, "claimed");
44
+ const queue = path.join(root, "queue");
45
+ if (!existsSync(claimed))
46
+ return [];
47
+ mkdirSync(queue, { recursive: true });
48
+ const returned = [];
49
+ for (const name of readdirSync(claimed).sort()) {
50
+ if (!name.endsWith(".json"))
51
+ continue;
52
+ const file = path.join(claimed, name);
53
+ const body = JSON.parse(readFileSync(file, "utf8"));
54
+ if (typeof body.leasedUntil !== "number" || body.leasedUntil > now)
55
+ continue;
56
+ if (!body.id)
57
+ continue;
58
+ renameSync(file, path.join(queue, `${body.id}.json`));
59
+ returned.push(body.id);
60
+ }
61
+ return returned.sort();
62
+ }
@@ -0,0 +1,262 @@
1
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { SCHEMA_VERSION } from "../contract.js";
4
+ import { asTable } from "../golden/check.js";
5
+ import { affectedTests, directImporters } from "../mutate/affected.js";
6
+ import { findInSource } from "../mutate/find.js";
7
+ import { mutantId } from "../mutate/ids.js";
8
+ import { forwardDiff, git } from "../mutate/patch.js";
9
+ import { runMutants } from "../mutate/run.js";
10
+ import { leadSummary } from "../mutate/suite-decision.js";
11
+ import { discoverSuite } from "../mutate/suites.js";
12
+ /** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
13
+ export async function verifyChange(options) {
14
+ const repo = realpathSync(path.resolve(options.repoDir));
15
+ const packageDir = realpathSync(path.resolve(options.packageDir));
16
+ const prefix = packagePrefix(repo, packageDir);
17
+ // The package may sit inside a large checkout. Diff that directory only.
18
+ // A whole-repo unified diff can exceed the pipe and come back as a failed spawn.
19
+ const scope = prefix ? ["--", prefix] : [];
20
+ const diff = git(repo, ["diff", "-U0", "--no-renames", options.base, options.commit, ...scope]);
21
+ if (diff.status !== 0)
22
+ return fail(options.outDir, diff.stderr.trim() || "git diff failed");
23
+ const ranges = parseUnified(diff.stdout);
24
+ const names = git(repo, ["diff", "--name-only", "--no-renames", options.base, options.commit, ...scope]);
25
+ const changed = names.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
26
+ const goldenContract = goldenDiffs(repo, options.base, options.commit, changed);
27
+ const packageFiles = [];
28
+ const chosen = [];
29
+ for (const [file, lines] of ranges) {
30
+ const rel = toPackage(prefix, file);
31
+ if (!rel || !rel.startsWith("src/") || rel.endsWith(".d.ts"))
32
+ continue;
33
+ packageFiles.push(rel);
34
+ const text = showFile(repo, options.commit, file);
35
+ if (!text)
36
+ continue;
37
+ const found = findInSource(rel, text);
38
+ const lineSet = new Set(lines);
39
+ for (const point of found.points) {
40
+ if (!lineSet.has(point.line))
41
+ continue;
42
+ chosen.push({ rel, label: file, text, point });
43
+ }
44
+ }
45
+ chosen.sort((a, b) => a.rel.localeCompare(b.rel) || a.point.line - b.point.line || a.point.op.localeCompare(b.point.op) || a.point.start - b.point.start);
46
+ const capped = chosen.slice(0, options.maxMutants);
47
+ const mutantsDir = path.join(options.outDir, "mutants");
48
+ mkdirSync(mutantsDir, { recursive: true });
49
+ const written = [];
50
+ const notRun = chosen.slice(capped.length).map(locate);
51
+ for (const item of capped) {
52
+ const located = locate(item);
53
+ const mutated = item.text.slice(0, item.point.start) + item.point.replacement + item.text.slice(item.point.end);
54
+ const body = forwardDiff(item.label, item.text, mutated);
55
+ if (!body) {
56
+ notRun.push(located);
57
+ continue;
58
+ }
59
+ writeFileSync(path.join(mutantsDir, `${located.id}.patch`), body);
60
+ written.push(located);
61
+ }
62
+ writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, mutants: written }, null, 2)}\n`);
63
+ const affectedMap = affectedTests(packageDir, { testsDir: "tests" });
64
+ const affected = [...new Set(packageFiles.flatMap((file) => affectedMap[file] ?? []))].sort();
65
+ const direct = directImporters(packageDir, packageFiles, "tests");
66
+ const ran = options.maxTests == null ? direct : direct.slice(0, options.maxTests);
67
+ const notRan = direct.slice(ran.length);
68
+ let caught = [];
69
+ let missed = [];
70
+ let other = [];
71
+ let suiteCommand = "";
72
+ const suite = discoverSuite(packageDir, "tests");
73
+ if (written.length > 0 && suite && suite.files.length > 0) {
74
+ const report = await runMutants({
75
+ packageDir,
76
+ repoDir: repo,
77
+ commit: options.commit,
78
+ patchDirs: [mutantsDir],
79
+ outDir: path.join(options.outDir, "run"),
80
+ direction: "forward",
81
+ workers: 1,
82
+ concurrency: 1,
83
+ maxMinutes: options.maxMinutes,
84
+ maxMutants: null,
85
+ build: null,
86
+ testsDir: "tests",
87
+ unset: ["SOLARI_API_KEY", "AUSPEX_LIVE"],
88
+ suiteTimeoutMs: 600_000,
89
+ testTimeoutMs: 60_000,
90
+ confirm: false,
91
+ affected: false,
92
+ agent: null,
93
+ onProgress: options.onProgress,
94
+ });
95
+ suiteCommand = report.suiteCommand;
96
+ if (!report.ok)
97
+ return fail(options.outDir, report.summary, { affected, ran, notRan, goldenContract, notRun, next: report.next, suiteCommand });
98
+ const byId = new Map(written.map((item) => [item.id, item]));
99
+ const results = readOutcomes(path.join(options.outDir, "run"), written.map((item) => item.id));
100
+ caught = results.filter((item) => item.outcome === "killed").map((item) => byId.get(item.id)).filter((item) => item !== undefined);
101
+ missed = results.filter((item) => item.outcome === "survived").map((item) => byId.get(item.id)).filter((item) => item !== undefined);
102
+ other = results
103
+ .filter((item) => item.outcome !== "killed" && item.outcome !== "survived")
104
+ .map((item) => ({ id: item.id, outcome: item.outcome, file: byId.get(item.id)?.file ?? "", line: byId.get(item.id)?.line ?? 0 }));
105
+ }
106
+ else {
107
+ notRun.push(...written);
108
+ }
109
+ const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand);
110
+ writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
111
+ return envelope;
112
+ }
113
+ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
114
+ const uncovered = other.filter((item) => item.outcome === "no coverage");
115
+ const summary = ok
116
+ ? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
117
+ : "verify-change did not finish.";
118
+ const first = missed[0] ?? uncovered[0] ?? notRun[0] ?? caught[0];
119
+ return {
120
+ schemaVersion: SCHEMA_VERSION,
121
+ ok,
122
+ command: "verify-change",
123
+ summary,
124
+ next: first ? `${first.id} at ${first.file}:${first.line}` : "No diff-scoped mutant.",
125
+ nextCall: null,
126
+ affected,
127
+ ran,
128
+ notRan,
129
+ suiteCommand,
130
+ caught,
131
+ missed,
132
+ other,
133
+ notRun,
134
+ goldenContract,
135
+ rest: notRun.length,
136
+ full: path.join(outDir, "verify.json"),
137
+ };
138
+ }
139
+ function locate(item) {
140
+ return {
141
+ id: mutantId(item.rel, item.point.start, item.point.end, item.point.op),
142
+ file: item.rel,
143
+ line: item.point.line,
144
+ op: item.point.op,
145
+ };
146
+ }
147
+ function fail(outDir, summary, extra) {
148
+ mkdirSync(outDir, { recursive: true });
149
+ const notRun = extra?.notRun ?? [];
150
+ return {
151
+ schemaVersion: SCHEMA_VERSION,
152
+ ok: false,
153
+ command: "verify-change",
154
+ summary,
155
+ next: extra?.next ?? "Fix that and run the same command again.",
156
+ nextCall: null,
157
+ affected: extra?.affected ?? [],
158
+ ran: extra?.ran ?? [],
159
+ notRan: extra?.notRan ?? [],
160
+ suiteCommand: extra?.suiteCommand ?? "",
161
+ caught: [],
162
+ missed: [],
163
+ other: [],
164
+ notRun,
165
+ goldenContract: extra?.goldenContract ?? [],
166
+ rest: notRun.length,
167
+ full: path.join(outDir, "verify.json"),
168
+ };
169
+ }
170
+ function readOutcomes(outDir, ids) {
171
+ const file = path.join(outDir, "results");
172
+ const out = [];
173
+ for (const id of ids) {
174
+ try {
175
+ const result = JSON.parse(readFileSync(path.join(file, `${id}.json`), "utf8"));
176
+ out.push({ id, outcome: result.outcome });
177
+ }
178
+ catch {
179
+ out.push({ id, outcome: "missing" });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ function goldenDiffs(repo, base, commit, files) {
185
+ const found = [];
186
+ for (const file of files) {
187
+ if (!file.endsWith(".golden.json") && !file.includes("/golden/"))
188
+ continue;
189
+ if (!file.endsWith(".json"))
190
+ continue;
191
+ const before = asTable(showFile(repo, base, file));
192
+ const after = asTable(showFile(repo, commit, file));
193
+ const ids = [...new Set([...Object.keys(before.rows), ...Object.keys(after.rows)])].sort();
194
+ for (const row of ids) {
195
+ const left = contractOf(before.rows[row]);
196
+ const right = contractOf(after.rows[row]);
197
+ if (JSON.stringify(left) === JSON.stringify(right))
198
+ continue;
199
+ const fields = [...new Set([...Object.keys(left), ...Object.keys(right)])].filter((key) => JSON.stringify(left[key]) !== JSON.stringify(right[key])).sort();
200
+ found.push({ file, row, fields });
201
+ }
202
+ }
203
+ return found;
204
+ }
205
+ function contractOf(row) {
206
+ if (!row)
207
+ return {};
208
+ const out = {};
209
+ for (const key of Object.keys(row).sort()) {
210
+ if (key === "next" || key === "nextLead")
211
+ continue;
212
+ out[key] = row[key];
213
+ }
214
+ return out;
215
+ }
216
+ function parseUnified(diff) {
217
+ const lines = new Map();
218
+ let file = null;
219
+ for (const line of diff.split(/\r?\n/)) {
220
+ const plus = line.match(/^\+\+\+ ([^\t]+)/);
221
+ if (plus) {
222
+ let name = plus[1];
223
+ if (name === "/dev/null") {
224
+ file = null;
225
+ continue;
226
+ }
227
+ if (name.startsWith("a/") || name.startsWith("b/"))
228
+ name = name.slice(2);
229
+ file = name;
230
+ if (!lines.has(file))
231
+ lines.set(file, []);
232
+ continue;
233
+ }
234
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
235
+ if (!hunk || !file)
236
+ continue;
237
+ const start = Number(hunk[1]);
238
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
239
+ const list = lines.get(file) ?? [];
240
+ for (let offset = 0; offset < count; offset++)
241
+ list.push(start + offset);
242
+ lines.set(file, list);
243
+ }
244
+ return lines;
245
+ }
246
+ function showFile(repo, commit, file) {
247
+ const result = git(repo, ["show", `${commit}:${file}`]);
248
+ return result.status === 0 ? result.stdout : "";
249
+ }
250
+ function packagePrefix(repo, packageDir) {
251
+ const rel = path.relative(repo, packageDir);
252
+ if (rel.startsWith(".."))
253
+ throw new Error("package is outside the repository");
254
+ return rel.split(path.sep).join("/");
255
+ }
256
+ function toPackage(prefix, file) {
257
+ if (!prefix)
258
+ return file;
259
+ if (!file.startsWith(`${prefix}/`))
260
+ return null;
261
+ return file.slice(prefix.length + 1);
262
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
+ "type": "module",
6
+ "bin": {
7
+ "probatio": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/IronAdamant/Probatio.git"
15
+ },
16
+ "homepage": "https://github.com/IronAdamant/Probatio#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/IronAdamant/Probatio/issues"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
22
+ "test": "tsx --test tests/*.test.ts",
23
+ "probatio": "tsx src/cli.ts"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "dependencies": {
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.2",
33
+ "tsx": "^4.19.2"
34
+ }
35
+ }