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.
Files changed (74) hide show
  1. package/README.md +10 -198
  2. package/bin/probatio.js +52 -0
  3. package/package.json +21 -29
  4. package/LICENSE +0 -21
  5. package/dist/cli.js +0 -721
  6. package/dist/contract.js +0 -32
  7. package/dist/flags.js +0 -87
  8. package/dist/golden/check.js +0 -188
  9. package/dist/golden/record.js +0 -395
  10. package/dist/golden/recorder.mjs +0 -78
  11. package/dist/ledger/build.js +0 -348
  12. package/dist/ledger/check.js +0 -133
  13. package/dist/matrix/report.js +0 -256
  14. package/dist/mcp.js +0 -188
  15. package/dist/memory/state.js +0 -222
  16. package/dist/mutate/affected.js +0 -173
  17. package/dist/mutate/child-lines.mjs +0 -39
  18. package/dist/mutate/coverage-map.js +0 -229
  19. package/dist/mutate/csharp-coverage.js +0 -161
  20. package/dist/mutate/fail-reporter.mjs +0 -65
  21. package/dist/mutate/find.js +0 -13
  22. package/dist/mutate/generate.js +0 -156
  23. package/dist/mutate/go-coverage.js +0 -100
  24. package/dist/mutate/ids.js +0 -22
  25. package/dist/mutate/java-coverage.js +0 -217
  26. package/dist/mutate/kill-label.js +0 -34
  27. package/dist/mutate/load-failure.mjs +0 -40
  28. package/dist/mutate/mocha-coverage.cjs +0 -77
  29. package/dist/mutate/node-batch-loader.mjs +0 -99
  30. package/dist/mutate/node-batch.mjs +0 -167
  31. package/dist/mutate/node-coverage.mjs +0 -77
  32. package/dist/mutate/operators.js +0 -181
  33. package/dist/mutate/patch.js +0 -136
  34. package/dist/mutate/precise-lines.mjs +0 -304
  35. package/dist/mutate/probatio-jacoco-run.java +0 -516
  36. package/dist/mutate/project-config.js +0 -58
  37. package/dist/mutate/py-pytest-reporter.py +0 -145
  38. package/dist/mutate/py-reporter.py +0 -79
  39. package/dist/mutate/run.js +0 -1335
  40. package/dist/mutate/rust-coverage.js +0 -144
  41. package/dist/mutate/sealed.js +0 -118
  42. package/dist/mutate/suite-decision.js +0 -130
  43. package/dist/mutate/suites.js +0 -1524
  44. package/dist/mutate/tally.js +0 -240
  45. package/dist/mutate/text-operators.js +0 -637
  46. package/dist/mutate/titles.js +0 -23
  47. package/dist/node-version.js +0 -13
  48. package/dist/swarm/check-kill.js +0 -114
  49. package/dist/swarm/queue.js +0 -68
  50. package/dist/verify/change.js +0 -340
  51. package/schemas/check-kill.schema.json +0 -93
  52. package/schemas/findings.add.schema.json +0 -53
  53. package/schemas/gap.fix.schema.json +0 -53
  54. package/schemas/gap.revert.schema.json +0 -53
  55. package/schemas/golden.check.schema.json +0 -89
  56. package/schemas/golden.compare.schema.json +0 -151
  57. package/schemas/golden.record.schema.json +0 -126
  58. package/schemas/guard.check.schema.json +0 -53
  59. package/schemas/ledger.build.schema.json +0 -132
  60. package/schemas/ledger.check.schema.json +0 -143
  61. package/schemas/matrix.report.schema.json +0 -129
  62. package/schemas/mcp.schema.json +0 -53
  63. package/schemas/mutate.generate.schema.json +0 -127
  64. package/schemas/mutate.help.schema.json +0 -53
  65. package/schemas/mutate.run.schema.json +0 -272
  66. package/schemas/mutate.sealed.schema.json +0 -53
  67. package/schemas/mutate.tally.schema.json +0 -137
  68. package/schemas/queue.claim.schema.json +0 -53
  69. package/schemas/queue.reap.schema.json +0 -71
  70. package/schemas/queue.seed.schema.json +0 -71
  71. package/schemas/schema.schema.json +0 -74
  72. package/schemas/seal.schema.json +0 -69
  73. package/schemas/status.schema.json +0 -103
  74. package/schemas/verify-change.schema.json +0 -222
@@ -1,144 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { exportLlvmProfile, llvmToolsPresent } from "./coverage-map.js";
5
- /**
6
- * One instrumented test binary, then one profile per test name.
7
- * A single cargo test profile does not say which test hit the line.
8
- */
9
- export function writeRustCoverage(pkg, testNames, dest) {
10
- const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0 && !/\s/.test(name)))];
11
- if (names.length === 0)
12
- return { ok: false, detail: "rust coverage: no test names" };
13
- if (!llvmToolsPresent())
14
- return { ok: false, detail: "rust coverage: llvm tool missing" };
15
- const env = { ...process.env, RUSTFLAGS: "-C instrument-coverage", CARGO_INCREMENTAL: "0" };
16
- const built = spawnSync("cargo", ["test", "--no-run", "--message-format=json"], {
17
- cwd: pkg,
18
- encoding: "utf8",
19
- env,
20
- timeout: 180_000,
21
- maxBuffer: 32 * 1024 * 1024,
22
- });
23
- if (built.status !== 0 || built.error)
24
- return { ok: false, detail: clip(built.stderr || built.stdout || built.error?.message || "cargo test --no-run failed") };
25
- const exes = testExecutables(built.stdout);
26
- if (exes.length === 0)
27
- return { ok: false, detail: "rust coverage: no test executable" };
28
- const work = path.join(path.dirname(dest), "rust-cover");
29
- mkdirSync(work, { recursive: true });
30
- const files = {};
31
- for (const name of names) {
32
- for (const exe of exes) {
33
- const raw = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profraw`);
34
- const ran = spawnSync(exe, [name, "--exact", "--test-threads=1"], {
35
- cwd: pkg,
36
- encoding: "utf8",
37
- env: { ...env, LLVM_PROFILE_FILE: raw },
38
- timeout: 60_000,
39
- });
40
- if (!existsSync(raw))
41
- continue;
42
- if (!new RegExp(`^test ${escapeRegExp(name)} \\.\\.\\. ok`, "m").test(`${ran.stdout}\n${ran.stderr}`))
43
- continue;
44
- const merged = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profdata`);
45
- const exported = exportLlvmProfile(exe, [raw], merged);
46
- if (!exported.ok)
47
- continue;
48
- for (const hit of llvmHits(exported.stdout, pkg))
49
- addHit(files, hit.file, hit.line, name);
50
- }
51
- }
52
- if (Object.keys(files).length === 0)
53
- return { ok: false, detail: "rust coverage: no covered lines" };
54
- writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
55
- return { ok: true, detail: `${Object.keys(files).length} files` };
56
- }
57
- function testExecutables(stdout) {
58
- const found = [];
59
- for (const line of stdout.split("\n")) {
60
- if (!line.startsWith("{"))
61
- continue;
62
- try {
63
- const msg = JSON.parse(line);
64
- if (msg.reason !== "compiler-artifact" || !msg.executable || !msg.profile?.test)
65
- continue;
66
- if (!found.includes(msg.executable))
67
- found.push(msg.executable);
68
- }
69
- catch {
70
- continue;
71
- }
72
- }
73
- return found;
74
- }
75
- function llvmHits(stdout, pkg) {
76
- const start = stdout.indexOf("{");
77
- if (start < 0)
78
- return [];
79
- let parsed;
80
- try {
81
- parsed = JSON.parse(stdout.slice(start));
82
- }
83
- catch {
84
- return [];
85
- }
86
- const hits = [];
87
- for (const group of parsed.data ?? []) {
88
- for (const file of group.files ?? []) {
89
- if (!file.filename)
90
- continue;
91
- const rel = underPkg(file.filename, pkg);
92
- if (!rel)
93
- continue;
94
- for (const segment of file.segments ?? []) {
95
- const line = segment[0];
96
- const count = segment[2] ?? 0;
97
- if (!line || count <= 0)
98
- continue;
99
- hits.push({ file: rel, line });
100
- }
101
- }
102
- }
103
- return hits;
104
- }
105
- function underPkg(filename, pkg) {
106
- let file = filename;
107
- let root = pkg;
108
- try {
109
- if (existsSync(filename))
110
- file = realpathSync(filename);
111
- }
112
- catch {
113
- // keep the path llvm reported
114
- }
115
- try {
116
- root = realpathSync(pkg);
117
- }
118
- catch {
119
- // keep the package path
120
- }
121
- const rel = path.relative(root, file);
122
- if (!rel || rel.startsWith(".."))
123
- return null;
124
- return rel.split(path.sep).join("/");
125
- }
126
- function addHit(files, file, line, test) {
127
- const lines = files[file] ?? {};
128
- const key = String(line);
129
- const names = lines[key] ?? [];
130
- if (!names.includes(test))
131
- names.push(test);
132
- lines[key] = names;
133
- files[file] = lines;
134
- }
135
- function safe(value) {
136
- return value.replace(/[^A-Za-z0-9._-]+/g, "_");
137
- }
138
- function escapeRegExp(value) {
139
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
- }
141
- function clip(text) {
142
- const line = text.trim().split("\n").find((item) => item.trim()) ?? "rust coverage failed";
143
- return line.slice(0, 240);
144
- }
@@ -1,118 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { readdirSync, readFileSync, statSync } from "node:fs";
3
- import path from "node:path";
4
- /** The label stays in its own file. The run report must not repeat these three values. */
5
- export function readSealedLabel(file) {
6
- let text = "";
7
- try {
8
- text = readFileSync(file, "utf8");
9
- }
10
- catch {
11
- throw new Error("label file could not be read");
12
- }
13
- const bugId = field(text, "bug_id");
14
- const issueId = field(text, "issue_id");
15
- const expectedFail = field(text, "expected_fail");
16
- if (!bugId || !issueId || !expectedFail)
17
- throw new Error("label file needs bug_id, issue_id, and expected_fail");
18
- return { bugId, issueId, expectedFail };
19
- }
20
- export function labelLeaked(body, label) {
21
- return body.includes(label.bugId) || body.includes(label.issueId) || body.includes(label.expectedFail);
22
- }
23
- /** The envelope can be clean while a result file on disk still repeats the label. */
24
- export function sealedTreeLeaked(dir, label) {
25
- const visit = (current) => {
26
- let names;
27
- try {
28
- names = readdirSync(current);
29
- }
30
- catch {
31
- return false;
32
- }
33
- for (const name of names) {
34
- const full = path.join(current, name);
35
- let info;
36
- try {
37
- info = statSync(full);
38
- }
39
- catch {
40
- continue;
41
- }
42
- if (info.isDirectory()) {
43
- if (visit(full))
44
- return true;
45
- continue;
46
- }
47
- if (!info.isFile() || info.size > 2_000_000)
48
- continue;
49
- try {
50
- if (labelLeaked(readFileSync(full, "utf8"), label))
51
- return true;
52
- }
53
- catch {
54
- // A binary artifact is not a report.
55
- }
56
- }
57
- return false;
58
- };
59
- return visit(dir);
60
- }
61
- export const LABEL_LEAK_SUMMARY = "The label was copied into the report.";
62
- function field(text, key) {
63
- const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
64
- return match?.[1]?.trim() ?? "";
65
- }
66
- /**
67
- * A sealed run hides the revealing test, but a fix commit often edits an older test too.
68
- * A kill by that edited test is the fix's own test, not an older test that caught the bug.
69
- * Returns the edited test files that killed, and whether any kill came from a test the fix left alone.
70
- */
71
- export function fixEditedKillers(repo, packageDir, commit, hide, kills) {
72
- if (kills.length === 0)
73
- return { files: [], olderTestCatch: false };
74
- const diff = spawnSync("git", ["-C", repo, "diff", "--name-only", `${commit}^`, commit], { encoding: "utf8" });
75
- // No parent, or git failed: nothing is known to be edited, so every kill stays an older-test catch.
76
- if (diff.status !== 0)
77
- return { files: [], olderTestCatch: true };
78
- const hidden = new Set(hide.map((rel) => rel.split(path.sep).join("/")));
79
- const edited = diff.stdout
80
- .split("\n")
81
- .filter((rel) => rel.length > 0)
82
- .map((rel) => ({ repoRel: rel, pkgRel: path.relative(packageDir, path.join(repo, rel)).split(path.sep).join("/") }))
83
- .filter((item) => !item.pkgRel.startsWith("..") && !hidden.has(item.pkgRel));
84
- const text = new Map();
85
- const contents = (repoRel) => {
86
- if (!text.has(repoRel)) {
87
- const shown = spawnSync("git", ["-C", repo, "show", `${commit}:${repoRel}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
88
- text.set(repoRel, shown.status === 0 ? shown.stdout : "");
89
- }
90
- return text.get(repoRel) ?? "";
91
- };
92
- const fileFor = (id) => edited.find((item) => killerInFile(id, item.pkgRel, () => contents(item.repoRel)));
93
- const files = new Set();
94
- let olderTestCatch = false;
95
- for (const kill of kills) {
96
- const owners = kill.killedBy.map(fileFor);
97
- for (const owner of owners)
98
- if (owner)
99
- files.add(owner.pkgRel);
100
- if (owners.some((owner) => !owner))
101
- olderTestCatch = true;
102
- }
103
- return { files: [...files].sort(), olderTestCatch };
104
- }
105
- /** True when a kill id names this file, its class, or a test written in it. */
106
- function killerInFile(id, rel, read) {
107
- if (id.includes(rel) || id.startsWith(`${path.posix.basename(rel)}::`))
108
- return true;
109
- const stem = path.posix.basename(rel).replace(/\.[^.]+$/, "");
110
- // JUnit `pkg.ClassTest.method` or `ClassTest#method`, Go and Rust paths.
111
- if (stem.length > 0 && id.split(/[.#:/]+/).includes(stem))
112
- return true;
113
- const leaf = id.split(/::|#/).pop()?.split(/\.(?=[^.]+$)/).pop()?.replace(/\[.*\]$/, "").trim() ?? "";
114
- if (leaf.length < 4)
115
- return false;
116
- const body = read();
117
- return [`"${leaf}"`, `'${leaf}'`, `\`${leaf}\``, `${leaf}(`, `def ${leaf}`, `func ${leaf}`, `fn ${leaf}`].some((needle) => body.includes(needle));
118
- }
@@ -1,130 +0,0 @@
1
- /** Pure suite decisions. The process spawn stays in the runner. */
2
- export const UNKNOWN_NEXT = "The suite command is unknown. Pass --suite-command with the command that runs this package's tests.";
3
- export const TIMEOUT_NEXT = "Timed out. The mutant ran longer than the baseline allows.";
4
- export const UNVIABLE_NEXT = "The mutant did not build. No test saw it, so it is not a kill and not a gap. No test is needed.";
5
- export const WHOLE_SUITE_NEXT = "The suite command cannot take a test name, so the whole suite ran.";
6
- export const TIMEOUT_MULTIPLE = 5;
7
- /** cargo-mutants uses 20s. A fixture can pass a smaller positive floor. */
8
- export const TIMEOUT_FLOOR_MS = 20_000;
9
- /** Marker on a coverage map when the binary has no per-test names. */
10
- export const WHOLE_PROGRAM_TEST = "suite";
11
- export function cannotRunNext(artifact) {
12
- return `The suite cannot run. ${artifact} is missing and the recipe would download it. Nothing was fetched.`;
13
- }
14
- export function resumeNext(ids) {
15
- const listed = ids.length > 0 ? ` Not started: ${ids.join(", ")}.` : "";
16
- return `Budget stopped the run.${listed} The same command resumes from the mutants already written.`;
17
- }
18
- export function noCoverageNext(file, line) {
19
- return `No coverage for ${file}:${line}. No test executed that line.`;
20
- }
21
- /** Every agent-facing count starts with unseen lines, then survivors. */
22
- export function leadSummary(noCoverage, survivors, survivorWord, rest) {
23
- return `${noCoverage} no coverage, ${survivors} ${survivorWord}, ${rest}`;
24
- }
25
- export function campaignSummary(counts) {
26
- return leadSummary(counts.noCoverage, counts.survived, "survived", `${counts.killed} killed, ${counts.unviable ? `${counts.unviable} did not build, ` : ""}${counts.flaky} flaky, ${counts.timeouts} timed out, ${counts.errors} errored, of ${counts.finished} finished.`);
27
- }
28
- export function timeoutLimitMs(baselineMs, multiple, floorMs) {
29
- const floor = Math.max(1, floorMs);
30
- const scaled = Math.ceil(Math.max(0, baselineMs) * Math.max(1, multiple));
31
- return Math.max(floor, scaled);
32
- }
33
- /** Kinds that collect a per-line map on the baseline. Other kinds pay the whole suite. */
34
- const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet", "mocha"]);
35
- /** A baseline this long, with no line map, is too expensive to repeat for a large batch. */
36
- export const SLOW_BASELINE_MS = 5_000;
37
- /** Pending mutants above this count, on a slow unmapped suite, are not started. */
38
- export const LARGE_BATCH = 30;
39
- export function hasLineMap(kind) {
40
- return LINE_MAP_KINDS.has(kind);
41
- }
42
- export function scaleStop(input) {
43
- if (hasLineMap(input.kind))
44
- return { action: "run" };
45
- if (input.baselineMs == null || input.baselineMs < SLOW_BASELINE_MS)
46
- return { action: "run" };
47
- if (input.pending <= LARGE_BATCH)
48
- return { action: "run" };
49
- const seconds = (input.baselineMs / 1000).toFixed(1);
50
- const next = `No line map for this suite. The baseline took ${seconds}s and ${input.pending} mutants are waiting. Narrow --src or pass a patch slice. Nothing was started.`;
51
- return { action: "stop", summary: next, next };
52
- }
53
- /** What generate tells the agent before any mutant runs. */
54
- export function generateNext(count, suite) {
55
- if (count === 0)
56
- return "Nothing to run.";
57
- if (!suite)
58
- return `${count} mutants. No suite was discovered. Pass --suite-command when you run the batch.`;
59
- const map = hasLineMap(suite.kind)
60
- ? "The first run pays that suite once, then a line map narrows each mutant."
61
- : "This suite has no line map. A baseline of 5 seconds or more with more than 30 mutants stops before the first mutant.";
62
- return `${count} mutants. Suite is ${suite.command}. ${map}`;
63
- }
64
- /**
65
- * Unknown layout asks for a command. An explicit command is what runs.
66
- * `make test` that would curl or wget a missing file does not run.
67
- */
68
- export function decideSuite(input) {
69
- const command = input.suiteCommand?.trim() ? input.suiteCommand.trim() : input.discovered?.command ?? "";
70
- if (isMakeTest(command) && input.makefile) {
71
- const artifact = missingFetchArtifact(input.makefile, input.artifactExists);
72
- if (artifact) {
73
- const next = cannotRunNext(artifact);
74
- return { action: "cannot-run", summary: next, next, command: "make test" };
75
- }
76
- }
77
- if (input.suiteCommand?.trim())
78
- return { action: "run" };
79
- if (input.discovered)
80
- return { action: "run" };
81
- if (input.candidates.length > 0) {
82
- return { action: "unknown", summary: "The suite command is unknown.", next: UNKNOWN_NEXT };
83
- }
84
- return { action: "no-tests" };
85
- }
86
- export function isMakeTest(command) {
87
- return /^\s*make\s+test\b/.test(command);
88
- }
89
- /**
90
- * Output paths of curl or wget recipes whose file is not on disk.
91
- * Returns the base name, which is the artifact named in `next`.
92
- * A Makefile that fetches for `test` is refused even when variable
93
- * expansion cannot prove the edge, so a recipe cannot download a jar.
94
- */
95
- export function missingFetchArtifact(makefile, exists) {
96
- const lines = makefile.split(/\r?\n/);
97
- for (const raw of lines) {
98
- if (/^\s*#/.test(raw))
99
- continue;
100
- const line = raw.trim();
101
- if (!/\b(curl|wget)\b/.test(line))
102
- continue;
103
- const artifact = artifactOf(line);
104
- if (!artifact)
105
- continue;
106
- const rel = artifact.replace(/^\.\//, "");
107
- if (exists(rel) || exists(pathBase(rel)))
108
- continue;
109
- return pathBase(rel);
110
- }
111
- return null;
112
- }
113
- function artifactOf(line) {
114
- const flagged = line.match(/(?:-o|--output|-O|--output-document)(?:=|\s+)["']?([^\s"']+)["']?/);
115
- if (flagged && !flagged[1].startsWith("$") && !flagged[1].startsWith("-"))
116
- return flagged[1];
117
- const url = line.match(/https?:\/\/\S+/);
118
- if (url) {
119
- const base = url[0].replace(/["']$/, "").split("?")[0].split("/").pop() ?? "";
120
- if (base.includes("."))
121
- return base;
122
- }
123
- if (flagged && !flagged[1].startsWith("-"))
124
- return flagged[1];
125
- return null;
126
- }
127
- function pathBase(rel) {
128
- const parts = rel.split(/[/\\]/);
129
- return parts[parts.length - 1] || rel;
130
- }