probatio 0.0.1 → 0.0.3

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.
@@ -274,6 +274,9 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
274
274
  const tests = selection.map(surefireTest).filter((name) => name.length > 0);
275
275
  const args = ["-B", "test", ...(tests.length > 0 ? [`-Dtest=${tests.join(",")}`] : [])];
276
276
  const whole = wantsNames && tests.length === 0;
277
+ // Surefire leaves the previous run's XML in place. A confirm that did not
278
+ // re-execute a class would otherwise read that class as failed again.
279
+ rmSync(path.join(pkg, "target", "surefire-reports"), { recursive: true, force: true });
277
280
  return tag(spawnCollected("mvn", args, pkg, javaEnv(env), timeoutMs), shown("mvn", args), whole, selection);
278
281
  }
279
282
  if (spec.kind === "dotnet") {
@@ -284,9 +287,11 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
284
287
  return tag(runDotnetExe(pkg, spec, files, timeoutMs, env), spec.command, wantsNames, selection);
285
288
  if (spec.kind === "mocha")
286
289
  return tag(runMocha(pkg, files, timeoutMs, env), spec.command, wantsNames, selection);
287
- // macOS `script` allocates a tty. tini's suite calls `docker run -it`, which exits immediately without one.
288
- if (spec.kind === "script")
289
- return tag(runScript(pkg, timeoutMs, env), spec.command, wantsNames, selection);
290
+ // A tty is only for a suite that runs `docker run -it` (tini). Everything else is bash.
291
+ if (spec.kind === "script") {
292
+ const launched = scriptInvocation(pkg);
293
+ return tag(runScript(pkg, timeoutMs, env, launched), launched.command, wantsNames, selection);
294
+ }
290
295
  if (spec.kind === "c" || spec.kind === "cpp")
291
296
  return tag(compileAndRun(pkg, files, spec.kind === "cpp", timeoutMs, env), spec.command, wantsNames, selection);
292
297
  if (spec.kind === "java")
@@ -549,7 +554,72 @@ function clearBytecode(pkg) {
549
554
  }
550
555
  }
551
556
  }
552
- function runScript(pkg, timeoutMs, env) {
557
+ let gnuScriptCache = null;
558
+ function suiteNeedsTty(pkg) {
559
+ const files = new Set();
560
+ const rootScript = path.join(pkg, "run_tests.sh");
561
+ if (existsSync(rootScript))
562
+ files.add(rootScript);
563
+ try {
564
+ for (const name of readdirSync(pkg)) {
565
+ if (name.endsWith(".sh"))
566
+ files.add(path.join(pkg, name));
567
+ }
568
+ }
569
+ catch {
570
+ // A missing directory is not a tty.
571
+ }
572
+ let entry = "";
573
+ try {
574
+ entry = readFileSync(rootScript, "utf8");
575
+ }
576
+ catch {
577
+ entry = "";
578
+ }
579
+ for (const match of entry.matchAll(/(?:^|[\s"'`/])([\w.-]+\.sh)\b/g)) {
580
+ const full = path.resolve(pkg, match[1]);
581
+ if (full.startsWith(pkg + path.sep) && existsSync(full))
582
+ files.add(full);
583
+ }
584
+ for (const file of files) {
585
+ let body = "";
586
+ try {
587
+ body = readFileSync(file, "utf8");
588
+ }
589
+ catch {
590
+ continue;
591
+ }
592
+ if (dockerWantsTty(body))
593
+ return true;
594
+ }
595
+ return false;
596
+ }
597
+ function dockerWantsTty(text) {
598
+ return text.split("\n").some((line) => {
599
+ if (!/\bdocker\s+run\b/.test(line))
600
+ return false;
601
+ return /(?:^|\s)--tty(?:\s|=|$)/.test(line) || /(?:^|\s)-[A-Za-z]*t[A-Za-z]*(?:\s|$)/.test(line);
602
+ });
603
+ }
604
+ function gnuScript() {
605
+ if (gnuScriptCache !== null)
606
+ return gnuScriptCache;
607
+ const probe = spawnSync("script", ["--version"], { encoding: "utf8", timeout: 2000 });
608
+ const text = `${probe.stdout ?? ""}\n${probe.stderr ?? ""}`;
609
+ gnuScriptCache = probe.status === 0 && /util-linux|\bGNU\b/.test(text);
610
+ return gnuScriptCache;
611
+ }
612
+ function scriptInvocation(pkg) {
613
+ if (!suiteNeedsTty(pkg))
614
+ return { bin: "bash", args: ["run_tests.sh"], command: "bash run_tests.sh" };
615
+ if (gnuScript()) {
616
+ const args = ["-q", "-c", "bash run_tests.sh", "/dev/null"];
617
+ return { bin: "script", args, command: shown("script", args) };
618
+ }
619
+ const args = ["-q", "/dev/null", "bash", "run_tests.sh"];
620
+ return { bin: "script", args, command: shown("script", args) };
621
+ }
622
+ function runScript(pkg, timeoutMs, env, launched) {
553
623
  const next = { ...env };
554
624
  const shim = writeDockerShim(pkg);
555
625
  if (shim)
@@ -560,7 +630,7 @@ function runScript(pkg, timeoutMs, env) {
560
630
  if (!next.ARCH_NATIVE && existsSync(inner) && existsSync(build) && readFileSync(build, "utf8").includes("ARCH_NATIVE")) {
561
631
  next.ARCH_NATIVE = "1";
562
632
  }
563
- return spawnCollected("script", ["-q", "/dev/null", "bash", "run_tests.sh"], pkg, next, timeoutMs);
633
+ return spawnCollected(launched.bin, launched.args, pkg, next, timeoutMs);
564
634
  }
565
635
  function writeDockerShim(pkg) {
566
636
  const found = spawnSync("sh", ["-c", "command -v docker"], { encoding: "utf8" });
@@ -1132,7 +1202,9 @@ function parseDotnet(text, code) {
1132
1202
  const names = [];
1133
1203
  const failed = [];
1134
1204
  for (const line of text.split("\n")) {
1135
- const match = line.match(/^\s*(Passed|Failed)\s+(\S+)/);
1205
+ // A result line carries a duration: "Passed StaysClosed [4 ms]".
1206
+ // "Failed to load ..." is a diagnostic, not a test, and must not redden the baseline.
1207
+ const match = line.match(/^\s*(Passed|Failed)\s+(.+?)\s+\[(?:<\s*)?\d+(?:\.\d+)?\s*(?:ms|s)\]\s*$/);
1136
1208
  if (!match)
1137
1209
  continue;
1138
1210
  names.push(match[2]);
@@ -3,11 +3,11 @@ import path from "node:path";
3
3
  import { SCHEMA_VERSION } from "../contract.js";
4
4
  import { advisePrune } from "../matrix/report.js";
5
5
  import { isBuildName } from "./kill-label.js";
6
- import { WHOLE_PROGRAM_TEST } from "./suite-decision.js";
6
+ import { leadSummary, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST } from "./suite-decision.js";
7
7
  const NOT_A_TEST = new Set(["suite", "pytest", "unittest", "command", WHOLE_PROGRAM_TEST]);
8
8
  /**
9
9
  * Read a finished mutate run. Name tests that killed a mutant, tests that killed
10
- * nothing, and one gap per survivor or uncovered line. This never deletes a file.
10
+ * nothing, and one gap per survivor. An unseen line is not a gap. This never deletes a file.
11
11
  */
12
12
  export function tallyRun(outDir) {
13
13
  const resultsDir = path.join(outDir, "results");
@@ -75,18 +75,30 @@ export function tallyRun(outDir) {
75
75
  .map(([name]) => name)
76
76
  .sort();
77
77
  const drop = pruning.advice;
78
+ const noCoverage = results.filter((result) => result.outcome === "no coverage").length;
79
+ const survived = results.filter((result) => result.outcome === "survived").length;
80
+ const timeouts = results.filter((result) => result.outcome === "timeout").length;
78
81
  const gaps = results
79
- .filter((result) => result.outcome === "survived" || result.outcome === "no coverage")
82
+ .filter((result) => result.outcome === "survived")
80
83
  .map((result) => ({
81
84
  id: result.id,
82
85
  file: result.files?.[0]?.file ?? "",
83
86
  line: result.files?.[0]?.line ?? 0,
84
- outcome: result.outcome,
87
+ outcome: "survived",
85
88
  }))
86
89
  .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.id.localeCompare(b.id));
87
90
  const shown = gaps.slice(0, 10);
88
- const summary = `${keep.length} tests to keep, ${drop.length} tests killed nothing, ${gaps.length} gaps. No test file was deleted.`;
89
- const next = gaps.length === 0 ? "No gap in this run." : `First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}.`;
91
+ const summary = leadSummary(noCoverage, survived, "survived", `${keep.length} tests to keep, ${drop.length} tests killed nothing, ${gaps.length} gaps. No test file was deleted.`);
92
+ const notes = [];
93
+ if (noCoverage > 0)
94
+ notes.push(`${noCoverage} no coverage. That line was not seen. It is not a pass and not a gap.`);
95
+ if (gaps.length > 0)
96
+ notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass.`);
97
+ if (timeouts > 0)
98
+ notes.push(TIMEOUT_NEXT);
99
+ if (notes.length === 0)
100
+ notes.push("No survivor in this run. No test file was deleted.");
101
+ const next = notes.join(" ");
90
102
  const complete = {
91
103
  schemaVersion: SCHEMA_VERSION,
92
104
  ok: true,
@@ -5,6 +5,7 @@ import { directImporters } from "../mutate/affected.js";
5
5
  import { filesInDiff } from "../mutate/patch.js";
6
6
  import { killLabel } from "../mutate/kill-label.js";
7
7
  import { runMutants } from "../mutate/run.js";
8
+ import { campaignSummary } from "../mutate/suite-decision.js";
8
9
  /** The gap is done only when this mutant is killed and the baseline suite is green. */
9
10
  export async function checkKill(id, options) {
10
11
  const importers = importersOf(id, options);
@@ -28,6 +29,21 @@ export async function checkKill(id, options) {
28
29
  const baseline = JSON.parse(readFileSync(baselineFile, "utf8"));
29
30
  suiteGreen = baseline.ok === true;
30
31
  }
32
+ // A red baseline stops inside the run. Do not read a result that names the broken test.
33
+ if (!report.ok) {
34
+ return {
35
+ schemaVersion: SCHEMA_VERSION,
36
+ ok: false,
37
+ command: "check-kill",
38
+ summary: `${id} is not done. ${report.summary}`,
39
+ next: "The task is not done.",
40
+ nextCall: null,
41
+ done: false,
42
+ outcome: null,
43
+ suiteGreen,
44
+ killedBy: [],
45
+ };
46
+ }
31
47
  const resultFile = path.join(options.outDir, "results", `${id}.json`);
32
48
  let outcome = null;
33
49
  let killedBy = [];
@@ -38,11 +54,12 @@ export async function checkKill(id, options) {
38
54
  }
39
55
  const done = suiteGreen && outcome === "killed";
40
56
  const label = done ? killLabel(killedBy) : null;
57
+ const head = checkHead(report);
41
58
  return {
42
59
  schemaVersion: SCHEMA_VERSION,
43
60
  ok: done,
44
61
  command: "check-kill",
45
- summary: done ? `${id} is dead and the suite is green.` : `${id} is not done. ${report.summary}`,
62
+ summary: done ? `${head} ${id} is dead and the suite is green.` : `${head} ${id} is not done.`,
46
63
  next: label?.next ?? (done ? "The gap-fixing task is done." : "The task is not done."),
47
64
  nextCall: null,
48
65
  done,
@@ -60,6 +77,18 @@ function importersOf(id, options) {
60
77
  const rels = filesInDiff(readFileSync(patch, "utf8")).map((item) => packageRelative(options.repoDir, options.packageDir, item.file));
61
78
  return directImporters(options.packageDir, rels, options.testsDir);
62
79
  }
80
+ function checkHead(report) {
81
+ const finished = report.killed + report.survived + report.flaky + report.timeouts + report.errors + report.noCoverage;
82
+ return campaignSummary({
83
+ noCoverage: report.noCoverage,
84
+ survived: report.survived,
85
+ killed: report.killed,
86
+ flaky: report.flaky,
87
+ timeouts: report.timeouts,
88
+ errors: report.errors,
89
+ finished,
90
+ });
91
+ }
63
92
  function packageRelative(repoDir, packageDir, file) {
64
93
  const norm = file.split(path.sep).join("/");
65
94
  let repo = repoDir;
@@ -7,6 +7,7 @@ import { findInSource } from "../mutate/find.js";
7
7
  import { mutantId } from "../mutate/ids.js";
8
8
  import { forwardDiff, git } from "../mutate/patch.js";
9
9
  import { runMutants } from "../mutate/run.js";
10
+ import { leadSummary } from "../mutate/suite-decision.js";
10
11
  import { discoverSuite } from "../mutate/suites.js";
11
12
  /** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
12
13
  export async function verifyChange(options) {
@@ -112,7 +113,7 @@ export async function verifyChange(options) {
112
113
  function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
113
114
  const uncovered = other.filter((item) => item.outcome === "no coverage");
114
115
  const summary = ok
115
- ? `${caught.length} caught, ${missed.length} missed, ${notRun.length} not run, ${uncovered.length} no coverage, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`
116
+ ? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
116
117
  : "verify-change did not finish.";
117
118
  const first = missed[0] ?? uncovered[0] ?? notRun[0] ?? caught[0];
118
119
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "url": "https://github.com/IronAdamant/Probatio/issues"
19
19
  },
20
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 dist/mutate/",
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
22
  "test": "tsx --test tests/*.test.ts",
23
23
  "probatio": "tsx src/cli.ts"
24
24
  },