probatio 0.0.3 → 0.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aron Amos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Probatio
2
2
 
3
- Probatio (Latin: a testing, a proof) is a free, open-source testing toolkit built for AI agents
3
+ Probatio (Latin: a testing, a proof) is a free testing toolkit under the MIT license, built for AI agents
4
4
  (Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable;
5
5
  it remembers what was fixed so agents with small context do not redo or undo work; and it finds
6
6
  the bugs a test suite would miss instead of asking anyone to read more tests.
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { appendFinding, chooseSealed, fixGap, guardAllows, lineHash, loadState,
10
10
  import { git } from "./mutate/patch.js";
11
11
  import { generateMutants } from "./mutate/generate.js";
12
12
  import { runMutants } from "./mutate/run.js";
13
+ import { LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel } from "./mutate/sealed.js";
13
14
  import { generateNext } from "./mutate/suite-decision.js";
14
15
  import { discoverSuite } from "./mutate/suites.js";
15
16
  import { tallyRun } from "./mutate/tally.js";
@@ -23,6 +24,8 @@ try {
23
24
  finish(await generateCommand(parsed.flags), parsed.human);
24
25
  if (group === "mutate" && action === "run")
25
26
  finish(await runCommand(parsed.flags), parsed.human);
27
+ if (group === "mutate" && action === "sealed")
28
+ finish(await sealedCommand(parsed.flags), parsed.human);
26
29
  if (group === "mutate" && action === "tally")
27
30
  finish(tallyCommand(parsed.flags), parsed.human);
28
31
  if (group === "ledger" && action === "build")
@@ -72,7 +75,7 @@ function usage(ok, summary) {
72
75
  ok,
73
76
  command: "mutate.help",
74
77
  summary,
75
- next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
78
+ next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate sealed runs one forward bug and keeps the label file out of the report. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
76
79
  nextCall: null,
77
80
  };
78
81
  }
@@ -129,7 +132,7 @@ async function generateCommand(flags) {
129
132
  full: path.join(patches, "mutants.json"),
130
133
  };
131
134
  }
132
- async function runCommand(flags) {
135
+ async function runCommand(flags, hide = null) {
133
136
  const packageDir = path.resolve(requireText(flags, "package"));
134
137
  const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
135
138
  if (patchDirs.length === 0)
@@ -168,6 +171,7 @@ async function runCommand(flags) {
168
171
  timeoutMultiple: int(flags, "timeout-multiple") ?? 5,
169
172
  timeoutFloorMs: int(flags, "timeout-floor-ms") ?? 20_000,
170
173
  budgetMs: int(flags, "budget-ms") ?? null,
174
+ hide,
171
175
  onProgress: (line) => process.stderr.write(`${line}\n`),
172
176
  });
173
177
  const resume = report.budgetHit
@@ -203,6 +207,22 @@ async function runCommand(flags) {
203
207
  function tallyCommand(flags) {
204
208
  return tallyRun(path.resolve(requireText(flags, "out")));
205
209
  }
210
+ async function sealedCommand(flags) {
211
+ const label = readSealedLabel(path.resolve(requireText(flags, "label")));
212
+ const hide = texts(flags, "hide");
213
+ const report = await runCommand(flags, hide);
214
+ const body = JSON.stringify(report);
215
+ if (!labelLeaked(body, label))
216
+ return report;
217
+ return {
218
+ schemaVersion: SCHEMA_VERSION,
219
+ ok: false,
220
+ command: "mutate.sealed",
221
+ summary: LABEL_LEAK_SUMMARY,
222
+ next: "Keep the label in its own file. Run the same command again.",
223
+ nextCall: null,
224
+ };
225
+ }
206
226
  async function ledgerCommand(flags) {
207
227
  const packageDir = path.resolve(requireText(flags, "package"));
208
228
  const outDir = path.resolve(requireText(flags, "out"));
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * One Coverlet run per test name. The collector's report does not name the test,
6
+ * so each name gets its own results directory.
7
+ */
8
+ export function writeCsharpCoverage(pkg, testNames, dest, project) {
9
+ const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0))];
10
+ if (names.length === 0)
11
+ return { ok: false, detail: "csharp coverage: no test names" };
12
+ const work = path.join(path.dirname(dest), "csharp-cover");
13
+ mkdirSync(work, { recursive: true });
14
+ const settings = path.join(work, "coverlet.runsettings");
15
+ // Coverlet skips the test assembly unless asked. A one-project suite keeps the code there.
16
+ writeFileSync(settings, [
17
+ '<?xml version="1.0" encoding="utf-8"?>',
18
+ "<RunSettings>",
19
+ " <DataCollectionRunSettings>",
20
+ " <DataCollectors>",
21
+ ' <DataCollector friendlyName="XPlat Code Coverage">',
22
+ " <Configuration>",
23
+ " <Format>cobertura</Format>",
24
+ " <IncludeTestAssembly>true</IncludeTestAssembly>",
25
+ " </Configuration>",
26
+ " </DataCollector>",
27
+ " </DataCollectors>",
28
+ " </DataCollectionRunSettings>",
29
+ "</RunSettings>",
30
+ "",
31
+ ].join("\n"));
32
+ const files = {};
33
+ const env = dotnetEnv();
34
+ for (const name of names) {
35
+ const results = path.join(work, safe(name));
36
+ mkdirSync(results, { recursive: true });
37
+ const args = ["test", "--nologo", "--verbosity", "quiet", "--settings", settings];
38
+ if (project)
39
+ args.push(project);
40
+ args.push("--filter", `FullyQualifiedName~${name}`, "--collect", "XPlat Code Coverage", "--results-directory", results);
41
+ const ran = spawnSync("dotnet", args, { cwd: pkg, encoding: "utf8", env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
42
+ if (ran.status !== 0 || ran.error)
43
+ continue;
44
+ const report = findCobertura(results);
45
+ if (!report)
46
+ continue;
47
+ let text = "";
48
+ try {
49
+ text = readFileSync(report, "utf8");
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ addCobertura(files, text, pkg, name);
55
+ }
56
+ if (Object.keys(files).length === 0)
57
+ return { ok: false, detail: "csharp coverage: no covered lines" };
58
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
59
+ return { ok: true, detail: `${Object.keys(files).length} files` };
60
+ }
61
+ function addCobertura(files, text, pkg, test) {
62
+ const sources = [...text.matchAll(/<source>([^<]+)<\/source>/g)].map((match) => match[1].trim());
63
+ for (const part of text.split(/<class\b/).slice(1)) {
64
+ const headEnd = part.indexOf(">");
65
+ if (headEnd < 0)
66
+ continue;
67
+ const filename = /filename="([^"]+)"/.exec(part.slice(0, headEnd))?.[1];
68
+ if (!filename)
69
+ continue;
70
+ const bodyEnd = part.indexOf("</class>");
71
+ const body = bodyEnd >= 0 ? part.slice(0, bodyEnd) : part;
72
+ const rel = coberturaRel(filename, sources, pkg);
73
+ if (!rel)
74
+ continue;
75
+ for (const tag of body.match(/<line\b[^>]*\/?>/g) ?? []) {
76
+ const number = /number="(\d+)"/.exec(tag);
77
+ const hits = /hits="(\d+)"/.exec(tag);
78
+ if (!number || !hits || Number(hits[1]) <= 0)
79
+ continue;
80
+ addHit(files, rel, Number(number[1]), test);
81
+ }
82
+ }
83
+ }
84
+ function coberturaRel(filename, sources, pkg) {
85
+ const norm = filename.split("\\").join("/");
86
+ const candidates = norm.startsWith("/") || /^[A-Za-z]:\//.test(norm) ? [norm] : [norm];
87
+ for (const source of sources) {
88
+ const root = source.endsWith("/") || source.endsWith("\\") ? source : `${source}/`;
89
+ candidates.push(path.normalize(`${root}${norm}`));
90
+ }
91
+ for (const candidate of candidates) {
92
+ const rel = underPkg(candidate, pkg);
93
+ if (rel)
94
+ return rel;
95
+ }
96
+ return null;
97
+ }
98
+ function underPkg(filename, pkg) {
99
+ if (!existsSync(filename))
100
+ return null;
101
+ let file = filename;
102
+ let root = pkg;
103
+ try {
104
+ file = realpathSync(filename);
105
+ root = realpathSync(pkg);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ const rel = path.relative(root, file);
111
+ if (!rel || rel.startsWith(".."))
112
+ return null;
113
+ return rel.split(path.sep).join("/");
114
+ }
115
+ function findCobertura(dir) {
116
+ const found = [];
117
+ const visit = (current) => {
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(current);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ for (const entry of entries) {
126
+ const full = path.join(current, entry);
127
+ let info;
128
+ try {
129
+ info = statSync(full);
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (info.isDirectory()) {
135
+ visit(full);
136
+ continue;
137
+ }
138
+ if (entry === "coverage.cobertura.xml")
139
+ found.push(full);
140
+ }
141
+ };
142
+ visit(dir);
143
+ return found[0] ?? null;
144
+ }
145
+ function addHit(files, file, line, test) {
146
+ const lines = files[file] ?? {};
147
+ const key = String(line);
148
+ const names = lines[key] ?? [];
149
+ if (!names.includes(test))
150
+ names.push(test);
151
+ lines[key] = names;
152
+ files[file] = lines;
153
+ }
154
+ function dotnetEnv() {
155
+ if (process.env.DOTNET_ROLL_FORWARD)
156
+ return process.env;
157
+ return { ...process.env, DOTNET_ROLL_FORWARD: "Major" };
158
+ }
159
+ function safe(value) {
160
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
161
+ }
@@ -1,13 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { affectedTests } from "./affected.js";
8
8
  import { binaryFromCommand, readCoverageMap, selectionFor, writeLlvmCoverage } from "./coverage-map.js";
9
+ import { writeCsharpCoverage } from "./csharp-coverage.js";
9
10
  import { writeGoCoverage } from "./go-coverage.js";
10
11
  import { writeJavaCoverage } from "./java-coverage.js";
12
+ import { writeRustCoverage } from "./rust-coverage.js";
11
13
  import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
12
14
  import { killLabel } from "./kill-label.js";
13
15
  import { TIMEOUT_FLOOR_MS, TIMEOUT_MULTIPLE, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST, WHOLE_SUITE_NEXT, campaignSummary, decideSuite, hasLineMap, noCoverageNext, resumeNext, scaleStop, timeoutLimitMs, } from "./suite-decision.js";
@@ -64,6 +66,7 @@ export async function runMutants(options) {
64
66
  const worktrees = await createWorktrees(repo, packageDir, run.id, commit, options.workers, options.outDir);
65
67
  if ("error" in worktrees)
66
68
  return failReport(options.outDir, run.id, commit, worktrees.error);
69
+ hidePaths(worktrees.dirs, repo, packageDir, options.hide ?? []);
67
70
  const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
68
71
  let budgetStarted = 0;
69
72
  const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
@@ -196,11 +199,9 @@ async function prepare(pkg, options, progress) {
196
199
  if (baselineFailed.length > 0) {
197
200
  return { ok: false, error: "baseline already failing", baselineFailed, suiteCommand: spec.command };
198
201
  }
199
- if (collect && !existsSync(mapPath) && (spec.kind === "go" || spec.kind === "maven")) {
200
- progress(spec.kind === "go" ? "go line map" : "java line map");
201
- const written = spec.kind === "go"
202
- ? writeGoCoverage(pkg, baseline.report.names, mapPath)
203
- : writeJavaCoverage(pkg, baseline.report.names, mapPath);
202
+ if (collect && !existsSync(mapPath) && languageMapKind(spec.kind)) {
203
+ progress(languageMapLabel(spec.kind));
204
+ const written = writeLanguageMap(spec.kind, pkg, baseline.report.names, mapPath, spec.project);
204
205
  if (!written.ok)
205
206
  writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
206
207
  }
@@ -541,14 +542,29 @@ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir)
541
542
  dirs.push(dir);
542
543
  }
543
544
  writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
544
- const linked = nodeModulesOf(repo, packageDir);
545
+ const sources = nodeModuleSources(repo, packageDir);
545
546
  for (const dir of dirs) {
546
- const dest = path.join(packageIn(dir, repo, packageDir), "node_modules");
547
- if (linked && !existsSync(dest))
548
- symlinkSync(linked, dest, "dir");
547
+ const pkgRoot = packageIn(dir, repo, packageDir);
548
+ for (const source of sources) {
549
+ const dest = path.join(pkgRoot, source.rel);
550
+ if (existsSync(dest))
551
+ continue;
552
+ mkdirSync(path.dirname(dest), { recursive: true });
553
+ symlinkSync(source.abs, dest, "dir");
554
+ }
549
555
  }
550
556
  return { dirs };
551
557
  }
558
+ function hidePaths(dirs, repo, packageDir, hide) {
559
+ for (const dir of dirs) {
560
+ const pkg = packageIn(dir, repo, packageDir);
561
+ for (const rel of hide) {
562
+ if (!rel || path.isAbsolute(rel))
563
+ continue;
564
+ rmSync(path.join(pkg, rel), { force: true });
565
+ }
566
+ }
567
+ }
552
568
  function removeWorktrees(repo, dirs, outDir) {
553
569
  for (const dir of dirs) {
554
570
  git(repo, ["worktree", "remove", "--force", dir]);
@@ -569,11 +585,30 @@ function recordedWorktrees(outDir) {
569
585
  return [];
570
586
  }
571
587
  }
572
- /** A fresh worktree has no installed dependencies. Use the main checkout's node_modules. */
573
- function nodeModulesOf(repo, packageDir) {
574
- const local = path.join(packageDir, "node_modules");
575
- if (existsSync(local))
576
- return local;
588
+ /**
589
+ * Installed dependency directories to mirror into a fresh worktree.
590
+ * A package can keep node_modules at its root or under a nested example.
591
+ * A run that starts from a worktree looks beside the main checkout too.
592
+ */
593
+ function nodeModuleSources(repo, packageDir) {
594
+ const roots = [packageDir];
595
+ const main = mainCheckoutPackage(repo, packageDir);
596
+ if (main && path.resolve(main) !== path.resolve(packageDir))
597
+ roots.push(main);
598
+ const found = [];
599
+ const seen = new Set();
600
+ for (const root of roots) {
601
+ for (const abs of findNodeModules(root)) {
602
+ const rel = path.relative(root, abs);
603
+ if (seen.has(rel))
604
+ continue;
605
+ seen.add(rel);
606
+ found.push({ rel, abs });
607
+ }
608
+ }
609
+ return found;
610
+ }
611
+ function mainCheckoutPackage(repo, packageDir) {
577
612
  const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
578
613
  if (common.status !== 0)
579
614
  return null;
@@ -583,8 +618,40 @@ function nodeModulesOf(repo, packageDir) {
583
618
  const rel = path.relative(repo, packageDir);
584
619
  if (rel.startsWith(".."))
585
620
  return null;
586
- const candidate = path.join(path.dirname(gitDir), rel, "node_modules");
587
- return existsSync(candidate) ? candidate : null;
621
+ return path.join(path.dirname(gitDir), rel);
622
+ }
623
+ function findNodeModules(root) {
624
+ const out = [];
625
+ const visit = (dir) => {
626
+ let entries;
627
+ try {
628
+ entries = readdirSync(dir);
629
+ }
630
+ catch {
631
+ return;
632
+ }
633
+ for (const name of entries) {
634
+ if (name === ".git" || name === "target" || name === "dist")
635
+ continue;
636
+ const full = path.join(dir, name);
637
+ let info;
638
+ try {
639
+ info = statSync(full);
640
+ }
641
+ catch {
642
+ continue;
643
+ }
644
+ if (!info.isDirectory())
645
+ continue;
646
+ if (name === "node_modules") {
647
+ out.push(full);
648
+ continue;
649
+ }
650
+ visit(full);
651
+ }
652
+ };
653
+ visit(root);
654
+ return out;
588
655
  }
589
656
  function packageIn(worktree, repo, packageDir) {
590
657
  const rel = path.relative(repo, packageDir);
@@ -672,10 +739,33 @@ function reportOf(results, context) {
672
739
  flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
673
740
  kills: results
674
741
  .filter((result) => result.outcome === "killed" && result.cause !== null)
675
- .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next })),
742
+ .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next, files: result.files })),
676
743
  full: path.join(context.outDir, "results"),
677
744
  };
678
745
  }
746
+ function languageMapKind(kind) {
747
+ return kind === "go" || kind === "maven" || kind === "cargo" || kind === "dotnet";
748
+ }
749
+ function languageMapLabel(kind) {
750
+ if (kind === "go")
751
+ return "go line map";
752
+ if (kind === "maven")
753
+ return "java line map";
754
+ if (kind === "cargo")
755
+ return "rust line map";
756
+ return "csharp line map";
757
+ }
758
+ function writeLanguageMap(kind, pkg, names, dest, project) {
759
+ if (kind === "go")
760
+ return writeGoCoverage(pkg, names, dest);
761
+ if (kind === "maven")
762
+ return writeJavaCoverage(pkg, names, dest);
763
+ if (kind === "cargo")
764
+ return writeRustCoverage(pkg, names, dest);
765
+ if (kind === "dotnet")
766
+ return writeCsharpCoverage(pkg, names, dest, project);
767
+ return { ok: false, detail: "no line map writer" };
768
+ }
679
769
  function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
680
770
  const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
681
771
  return {
@@ -838,10 +928,20 @@ function sha256(text) {
838
928
  return createHash("sha256").update(text).digest("hex");
839
929
  }
840
930
  function failureId(pkg, failure) {
931
+ // Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
932
+ // file doubles it, and pytest then exits 4 and collects nothing.
933
+ if (pytestNodeId(failure.name))
934
+ return failure.name;
841
935
  const rel = failure.file ? path.relative(pkg, failure.file) : "";
842
936
  const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
843
937
  return `${file}::${failure.name}`;
844
938
  }
939
+ function pytestNodeId(name) {
940
+ const mark = name.indexOf("::");
941
+ if (mark <= 0)
942
+ return false;
943
+ return name.slice(0, mark).endsWith(".py");
944
+ }
845
945
  function testArg(pkg, file, testsDir) {
846
946
  const rel = path.relative(pkg, file);
847
947
  if (rel && !rel.startsWith(".."))
@@ -0,0 +1,159 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * One instrumented test binary, then one profile per test name.
6
+ * A single cargo test profile does not say which test hit the line.
7
+ */
8
+ export function writeRustCoverage(pkg, testNames, dest) {
9
+ const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0 && !/\s/.test(name)))];
10
+ if (names.length === 0)
11
+ return { ok: false, detail: "rust coverage: no test names" };
12
+ const llvmCov = findTool("llvm-cov");
13
+ const llvmProf = findTool("llvm-profdata");
14
+ if (!llvmCov || !llvmProf)
15
+ return { ok: false, detail: "rust coverage: llvm tool missing" };
16
+ const env = { ...process.env, RUSTFLAGS: "-C instrument-coverage", CARGO_INCREMENTAL: "0" };
17
+ const built = spawnSync("cargo", ["test", "--no-run", "--message-format=json"], {
18
+ cwd: pkg,
19
+ encoding: "utf8",
20
+ env,
21
+ timeout: 180_000,
22
+ maxBuffer: 32 * 1024 * 1024,
23
+ });
24
+ if (built.status !== 0 || built.error)
25
+ return { ok: false, detail: clip(built.stderr || built.stdout || built.error?.message || "cargo test --no-run failed") };
26
+ const exes = testExecutables(built.stdout);
27
+ if (exes.length === 0)
28
+ return { ok: false, detail: "rust coverage: no test executable" };
29
+ const work = path.join(path.dirname(dest), "rust-cover");
30
+ mkdirSync(work, { recursive: true });
31
+ const files = {};
32
+ for (const name of names) {
33
+ for (const exe of exes) {
34
+ const raw = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profraw`);
35
+ const ran = spawnSync(exe, [name, "--exact", "--test-threads=1"], {
36
+ cwd: pkg,
37
+ encoding: "utf8",
38
+ env: { ...env, LLVM_PROFILE_FILE: raw },
39
+ timeout: 60_000,
40
+ });
41
+ if (!existsSync(raw))
42
+ continue;
43
+ if (!new RegExp(`^test ${escapeRegExp(name)} \\.\\.\\. ok`, "m").test(`${ran.stdout}\n${ran.stderr}`))
44
+ continue;
45
+ const merged = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profdata`);
46
+ const merge = spawnSync(llvmProf, ["merge", "-sparse", "-o", merged, raw], { encoding: "utf8" });
47
+ if (merge.status !== 0)
48
+ continue;
49
+ const exported = spawnSync(llvmCov, ["export", exe, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
50
+ if (exported.status !== 0)
51
+ continue;
52
+ for (const hit of llvmHits(exported.stdout, pkg))
53
+ addHit(files, hit.file, hit.line, name);
54
+ }
55
+ }
56
+ if (Object.keys(files).length === 0)
57
+ return { ok: false, detail: "rust coverage: no covered lines" };
58
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
59
+ return { ok: true, detail: `${Object.keys(files).length} files` };
60
+ }
61
+ function testExecutables(stdout) {
62
+ const found = [];
63
+ for (const line of stdout.split("\n")) {
64
+ if (!line.startsWith("{"))
65
+ continue;
66
+ try {
67
+ const msg = JSON.parse(line);
68
+ if (msg.reason !== "compiler-artifact" || !msg.executable || !msg.profile?.test)
69
+ continue;
70
+ if (!found.includes(msg.executable))
71
+ found.push(msg.executable);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ }
77
+ return found;
78
+ }
79
+ function llvmHits(stdout, pkg) {
80
+ const start = stdout.indexOf("{");
81
+ if (start < 0)
82
+ return [];
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(stdout.slice(start));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const hits = [];
91
+ for (const group of parsed.data ?? []) {
92
+ for (const file of group.files ?? []) {
93
+ if (!file.filename)
94
+ continue;
95
+ const rel = underPkg(file.filename, pkg);
96
+ if (!rel)
97
+ continue;
98
+ for (const segment of file.segments ?? []) {
99
+ const line = segment[0];
100
+ const count = segment[2] ?? 0;
101
+ if (!line || count <= 0)
102
+ continue;
103
+ hits.push({ file: rel, line });
104
+ }
105
+ }
106
+ }
107
+ return hits;
108
+ }
109
+ function underPkg(filename, pkg) {
110
+ let file = filename;
111
+ let root = pkg;
112
+ try {
113
+ if (existsSync(filename))
114
+ file = realpathSync(filename);
115
+ }
116
+ catch {
117
+ // keep the path llvm reported
118
+ }
119
+ try {
120
+ root = realpathSync(pkg);
121
+ }
122
+ catch {
123
+ // keep the package path
124
+ }
125
+ const rel = path.relative(root, file);
126
+ if (!rel || rel.startsWith(".."))
127
+ return null;
128
+ return rel.split(path.sep).join("/");
129
+ }
130
+ function addHit(files, file, line, test) {
131
+ const lines = files[file] ?? {};
132
+ const key = String(line);
133
+ const names = lines[key] ?? [];
134
+ if (!names.includes(test))
135
+ names.push(test);
136
+ lines[key] = names;
137
+ files[file] = lines;
138
+ }
139
+ function findTool(name) {
140
+ const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
141
+ const xcrunPath = fromXcrun.stdout?.trim() ?? "";
142
+ if (fromXcrun.status === 0 && xcrunPath && existsSync(xcrunPath))
143
+ return xcrunPath;
144
+ const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
145
+ const found = which.stdout?.trim() ?? "";
146
+ if (which.status === 0 && found)
147
+ return found;
148
+ return null;
149
+ }
150
+ function safe(value) {
151
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
152
+ }
153
+ function escapeRegExp(value) {
154
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
155
+ }
156
+ function clip(text) {
157
+ const line = text.trim().split("\n").find((item) => item.trim()) ?? "rust coverage failed";
158
+ return line.slice(0, 240);
159
+ }
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+ /** The label stays in its own file. The run report must not repeat these three values. */
3
+ export function readSealedLabel(file) {
4
+ let text = "";
5
+ try {
6
+ text = readFileSync(file, "utf8");
7
+ }
8
+ catch {
9
+ throw new Error("label file could not be read");
10
+ }
11
+ const bugId = field(text, "bug_id");
12
+ const issueId = field(text, "issue_id");
13
+ const expectedFail = field(text, "expected_fail");
14
+ if (!bugId || !issueId || !expectedFail)
15
+ throw new Error("label file needs bug_id, issue_id, and expected_fail");
16
+ return { bugId, issueId, expectedFail };
17
+ }
18
+ export function labelLeaked(body, label) {
19
+ return body.includes(label.bugId) || body.includes(label.issueId) || body.includes(label.expectedFail);
20
+ }
21
+ export const LABEL_LEAK_SUMMARY = "The label was copied into the report.";
22
+ function field(text, key) {
23
+ const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
24
+ return match?.[1]?.trim() ?? "";
25
+ }
@@ -30,7 +30,7 @@ export function timeoutLimitMs(baselineMs, multiple, floorMs) {
30
30
  return Math.max(floor, scaled);
31
31
  }
32
32
  /** Kinds that collect a per-line map on the baseline. Other kinds pay the whole suite. */
33
- const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven"]);
33
+ const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet"]);
34
34
  /** A baseline this long, with no line map, is too expensive to repeat for a large batch. */
35
35
  export const SLOW_BASELINE_MS = 5_000;
36
36
  /** Pending mutants above this count, on a slow unmapped suite, are not started. */
@@ -131,10 +131,12 @@ function nodeSuite(pkg) {
131
131
  return { kind: "node", files, command: "node --test" };
132
132
  }
133
133
  function mochaSuite(pkg) {
134
- const files = walk(pkg, (rel) => isNodeTest(rel) || (rel.split("/").includes("test") && rel.endsWith(".ts")));
135
- if (files.length === 0 || !usesMocha(pkg))
134
+ if (!usesMocha(pkg))
136
135
  return null;
137
- return { kind: "mocha", files, command: "mocha --reporter json" };
136
+ const files = mochaFiles(pkg);
137
+ if (files.length === 0)
138
+ return null;
139
+ return { kind: "mocha", files, command: shown("mocha", buildMochaArgs(pkg, files)) };
138
140
  }
139
141
  /** A Makefile `test` target that links the project's own COBOL tests, instead of one file. */
140
142
  function makeTestSuite(pkg) {
@@ -185,14 +187,119 @@ export function sourceCandidates(pkg) {
185
187
  return walk(pkg, (rel) => SOURCE_EXT.test(rel) && (hasSegment(rel, "tests") || hasSegment(rel, "test")));
186
188
  }
187
189
  function pythonTests(pkg, testsDir) {
188
- const roots = new Set([testsDir, "tests", "test"]);
190
+ const configured = pytestTestPaths(pkg);
191
+ const roots = new Set(configured ?? [testsDir, "tests", "test"]);
189
192
  return walk(pkg, (rel) => {
190
193
  const base = path.posix.basename(rel);
191
- if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base))
194
+ if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base) && !/^tests_.*\.py$/.test(base))
192
195
  return false;
196
+ if (configured)
197
+ return configured.some((root) => rel === root || rel.startsWith(`${root}/`));
193
198
  return rel.split("/").some((part) => roots.has(part));
194
199
  });
195
200
  }
201
+ /** Directories pytest itself would collect. A missing setting keeps the broader walk. */
202
+ function pytestTestPaths(pkg) {
203
+ const fromPyproject = readPyprojectTestPaths(path.join(pkg, "pyproject.toml"));
204
+ if (fromPyproject)
205
+ return fromPyproject;
206
+ for (const file of ["pytest.ini", "tox.ini", "setup.cfg"]) {
207
+ const found = readIniTestPaths(path.join(pkg, file));
208
+ if (found)
209
+ return found;
210
+ }
211
+ return null;
212
+ }
213
+ function readPyprojectTestPaths(file) {
214
+ if (!existsSync(file))
215
+ return null;
216
+ const text = readFileSync(file, "utf8");
217
+ const section = /\[tool\.pytest\.ini_options\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
218
+ if (!section)
219
+ return null;
220
+ const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
221
+ if (!key)
222
+ return null;
223
+ const quoted = [...key.matchAll(/"([^"]+)"|'([^']+)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
224
+ return quoted.length > 0 ? quoted : null;
225
+ }
226
+ function readIniTestPaths(file) {
227
+ if (!existsSync(file))
228
+ return null;
229
+ const text = readFileSync(file, "utf8");
230
+ const section = /\[(?:pytest|tool:pytest)\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
231
+ if (!section)
232
+ return null;
233
+ const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
234
+ if (!key)
235
+ return null;
236
+ const paths = key
237
+ .split(/\s+/)
238
+ .map((item) => item.trim())
239
+ .filter((item) => item.length > 0 && !item.startsWith("#"));
240
+ return paths.length > 0 ? paths : null;
241
+ }
242
+ const MOCHA_SKIP_DIR = new Set(["support", "fixtures", "helpers", "helper"]);
243
+ /** Mocha files the project would run: node-style names, TypeScript under test/, or describe/it scripts. */
244
+ function mochaFiles(pkg) {
245
+ return walk(pkg, (rel) => {
246
+ const parts = rel.split("/");
247
+ if (parts.some((part) => MOCHA_SKIP_DIR.has(part.toLowerCase())))
248
+ return false;
249
+ const inTest = parts.some((part) => part === "test" || part === "tests");
250
+ if (!inTest)
251
+ return false;
252
+ if (isNodeTest(rel))
253
+ return true;
254
+ if (parts.includes("test") && /\.tsx?$/.test(rel))
255
+ return true;
256
+ if (!/\.(?:js|mjs|cjs)$/.test(rel))
257
+ return false;
258
+ let text = "";
259
+ try {
260
+ text = readFileSync(path.join(pkg, rel), "utf8");
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ return /\bdescribe\s*\(/.test(text) || /\bit\s*\(/.test(text);
266
+ });
267
+ }
268
+ /** Args after the mocha binary: reporter, the project's own --require, and files when package.json has no mocha.spec. */
269
+ export function buildMochaArgs(pkg, files) {
270
+ const config = readMochaConfig(pkg);
271
+ const args = ["--reporter", "json", "--timeout", "20000"];
272
+ const fromConfig = new Set((config?.require ?? []).filter((name) => name !== "esm"));
273
+ for (const name of scriptRequires(pkg)) {
274
+ if (name === "esm" || fromConfig.has(name))
275
+ continue;
276
+ args.push("--require", name);
277
+ }
278
+ const hasSpec = Boolean(config?.spec && config.spec.length > 0);
279
+ if (!hasSpec)
280
+ args.push(...files);
281
+ return args;
282
+ }
283
+ function scriptRequires(pkg) {
284
+ const file = path.join(pkg, "package.json");
285
+ if (!existsSync(file))
286
+ return [];
287
+ let script = "";
288
+ try {
289
+ const body = JSON.parse(readFileSync(file, "utf8"));
290
+ script = body.scripts?.test ?? "";
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ const found = [];
296
+ for (const match of script.matchAll(/(?:^|\s)--require(?:=|\s+)(\S+)/g)) {
297
+ const name = match[1].replace(/^['"]|['"]$/g, "");
298
+ if (name && name !== "esm" && !found.includes(name))
299
+ found.push(name);
300
+ }
301
+ return found;
302
+ }
196
303
  function isPytest(pkg, testsDir, files) {
197
304
  if (existsSync(path.join(pkg, "pytest.ini")) || existsSync(path.join(pkg, "conftest.py")))
198
305
  return true;
@@ -224,6 +331,11 @@ function tag(result, command, wholeSuite, selectedTests) {
224
331
  function shown(bin, args) {
225
332
  return [bin, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
226
333
  }
334
+ function shellQuote(value) {
335
+ if (/^[A-Za-z0-9_:.]+$/.test(value))
336
+ return value;
337
+ return `'${value.replace(/'/g, `'\\''`)}'`;
338
+ }
227
339
  async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
228
340
  const selection = names && names.length > 0 ? names : namesFromPattern(pattern);
229
341
  const wantsNames = selection.length > 0;
@@ -256,12 +368,15 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
256
368
  return tag(runPython(pkg, files, k, timeoutMs, env, "py-pytest-reporter.py", plan.nodeIds), command, false, selection);
257
369
  }
258
370
  if (spec.kind === "cargo") {
259
- if (selection.length > 1) {
260
- return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1", true, selection);
371
+ if (selection.length === 0) {
372
+ return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1", false, selection);
373
+ }
374
+ if (selection.length === 1) {
375
+ const args = ["test", selection[0], "--", "--exact", "--test-threads=1"];
376
+ return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
261
377
  }
262
- const filter = selection.length === 1 ? selection[0] : null;
263
- const args = filter ? ["test", filter, "--", "--test-threads=1"] : ["test", "--", "--test-threads=1"];
264
- return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
378
+ const script = selection.map((name) => `cargo test ${shellQuote(name)} -- --exact --test-threads=1`).join(" && ");
379
+ return tag(spawnCollected("sh", ["-c", script], pkg, env, timeoutMs), script, false, selection);
265
380
  }
266
381
  if (spec.kind === "go") {
267
382
  const filter = goRunFilter(names, pattern);
@@ -357,8 +472,10 @@ function escapeRegExp(value) {
357
472
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
358
473
  }
359
474
  function runShell(pkg, command, timeoutMs, env) {
475
+ // A command can be pytest. Baseline writes a .pyc, and a same-size patch in that same second leaves it valid.
476
+ clearBytecode(pkg);
360
477
  const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
361
- return spawnCollected("sh", ["-c", command], pkg, child, timeoutMs);
478
+ return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
362
479
  }
363
480
  async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
364
481
  const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
@@ -434,20 +551,15 @@ async function runMocha(pkg, files, timeoutMs, env) {
434
551
  if (prepared && (prepared.code !== 0 || prepared.timedOut))
435
552
  return prepared;
436
553
  const config = readMochaConfig(pkg);
437
- const args = [mocha, "--reporter", "json", "--timeout", "20000"];
438
- if (config) {
554
+ const args = [mocha, ...buildMochaArgs(pkg, files)];
555
+ if (config && config.require.includes("esm")) {
439
556
  // The `esm` loader throws on current Node before any test runs. Drop that one require and keep the rest of the project's mocha config.
440
557
  const requires = config.require.filter((name) => name !== "esm");
441
- if (requires.length !== config.require.length) {
442
- const dir = path.join(pkg, ".probatio-suite");
443
- mkdirSync(dir, { recursive: true });
444
- const override = path.join(dir, "mocha-package.json");
445
- writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
446
- args.push("--package", override);
447
- }
448
- }
449
- else {
450
- args.push(...files);
558
+ const dir = path.join(pkg, ".probatio-suite");
559
+ mkdirSync(dir, { recursive: true });
560
+ const override = path.join(dir, "mocha-package.json");
561
+ writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
562
+ args.push("--package", override);
451
563
  }
452
564
  return spawnCollected(process.execPath, args, pkg, env, timeoutMs);
453
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "url": "git+https://github.com/IronAdamant/Probatio.git"
15
15
  },
16
16
  "homepage": "https://github.com/IronAdamant/Probatio#readme",
17
+ "license": "MIT",
17
18
  "bugs": {
18
19
  "url": "https://github.com/IronAdamant/Probatio/issues"
19
20
  },