probatio 0.1.3 → 0.2.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.
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  /** The label stays in its own file. The run report must not repeat these three values. */
@@ -62,3 +63,56 @@ function field(text, key) {
62
63
  const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
63
64
  return match?.[1]?.trim() ?? "";
64
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,6 +1,7 @@
1
1
  /** Pure suite decisions. The process spawn stays in the runner. */
2
2
  export const UNKNOWN_NEXT = "The suite command is unknown. Pass --suite-command with the command that runs this package's tests.";
3
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.";
4
5
  export const WHOLE_SUITE_NEXT = "The suite command cannot take a test name, so the whole suite ran.";
5
6
  export const TIMEOUT_MULTIPLE = 5;
6
7
  /** cargo-mutants uses 20s. A fixture can pass a smaller positive floor. */
@@ -22,7 +23,7 @@ export function leadSummary(noCoverage, survivors, survivorWord, rest) {
22
23
  return `${noCoverage} no coverage, ${survivors} ${survivorWord}, ${rest}`;
23
24
  }
24
25
  export function campaignSummary(counts) {
25
- return leadSummary(counts.noCoverage, counts.survived, "survived", `${counts.killed} killed, ${counts.flaky} flaky, ${counts.timeouts} timed out, ${counts.errors} errored, of ${counts.finished} finished.`);
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.`);
26
27
  }
27
28
  export function timeoutLimitMs(baselineMs, multiple, floorMs) {
28
29
  const floor = Math.max(1, floorMs);
@@ -1,8 +1,8 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
5
+ import { projectConfig } from "./project-config.js";
6
6
  const SKIP_DIR = new Set(["node_modules", ".git", "target", "dist", "bin", "obj", ".build", "vendor"]);
7
7
  export function discoverSuite(pkg, testsDir) {
8
8
  return (scriptSuite(pkg) ||
@@ -34,13 +34,14 @@ export async function runDiscoveredSuite(pkg, files, testsDir, pattern, timeoutM
34
34
  if (!spec)
35
35
  return empty;
36
36
  const selected = files.length > 0 ? files : spec.files;
37
- const child = { ...env };
37
+ // The project's own .probatio.json env, then Probatio's collectors.
38
+ const child = { ...env, ...projectConfig(pkg).env };
38
39
  if (extra?.coverageMap)
39
40
  child.PROBATIO_COVERAGE_MAP = extra.coverageMap;
40
41
  if (extra?.profileFile)
41
42
  child.LLVM_PROFILE_FILE = extra.profileFile;
42
43
  const result = await launch(pkg, spec, selected, pattern, extra?.names ?? null, timeoutMs, child, hooks);
43
- const compileToken = result.timedOut ? null : detectCompile(spec.kind, result.stdout, result.stderr, result.code);
44
+ const compileToken = result.timedOut ? null : detectCompile(spec.kind, result.stdout, result.stderr, result.code, projectConfig(pkg));
44
45
  // Ignore a report read from disk when this run failed to compile. The previous
45
46
  // suite's files are still in the worktree and are not this mutant's result.
46
47
  const report = result.timedOut || compileToken ? null : adapt(spec.kind, pkg, result.stdout, result.stderr, result.code);
@@ -72,7 +73,11 @@ function cargoSuite(pkg) {
72
73
  return { kind: "cargo", files, command: "cargo test" };
73
74
  }
74
75
  function goSuite(pkg) {
75
- const files = walk(pkg, (rel) => rel.endsWith("_test.go"));
76
+ // `go test ./...` needs a module at or above the package and skips a nested module.
77
+ // Go files in an example module inside a Node or Python package are not this suite.
78
+ if (!moduleAbove(pkg, "go.mod"))
79
+ return null;
80
+ const files = outsideNested(pkg, ["go.mod"], walk(pkg, (rel) => rel.endsWith("_test.go")));
76
81
  if (files.length === 0)
77
82
  return null;
78
83
  return { kind: "go", files, command: "go test -json ./..." };
@@ -186,17 +191,39 @@ const SOURCE_EXT = /\.(?:cobol|cbl|cob|cpp|cxx|cc|java|asm|nasm|yasm|swift|py|go
186
191
  export function sourceCandidates(pkg) {
187
192
  return walk(pkg, (rel) => SOURCE_EXT.test(rel) && (hasSegment(rel, "tests") || hasSegment(rel, "test")));
188
193
  }
194
+ /** Files that mark a directory as its own Python project. */
195
+ const PYTHON_PROJECT = ["pytest.ini", "pyproject.toml", "setup.py", "setup.cfg", "tox.ini"];
189
196
  function pythonTests(pkg, testsDir) {
190
197
  const configured = pytestTestPaths(pkg);
191
198
  const roots = new Set(configured ?? [testsDir, "tests", "test"]);
192
- return walk(pkg, (rel) => {
199
+ // A nested Python project (an example with its own pytest.ini) is not this package's suite.
200
+ return outsideNested(pkg, PYTHON_PROJECT, walk(pkg, (rel) => {
193
201
  const base = path.posix.basename(rel);
194
202
  if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base) && !/^tests_.*\.py$/.test(base))
195
203
  return false;
196
204
  if (configured)
197
205
  return configured.some((root) => rel === root || rel.startsWith(`${root}/`));
198
206
  return rel.split("/").some((part) => roots.has(part));
199
- });
207
+ }));
208
+ }
209
+ /** True when `name` sits in the package or one of its parents. */
210
+ function moduleAbove(pkg, name) {
211
+ let dir = path.resolve(pkg);
212
+ for (;;) {
213
+ if (existsSync(path.join(dir, name)))
214
+ return true;
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir)
217
+ return false;
218
+ dir = parent;
219
+ }
220
+ }
221
+ /** Drops files inside a subdirectory that has one of these manifests. That subdirectory is another project. */
222
+ function outsideNested(pkg, manifests, files) {
223
+ const nested = walk(pkg, (rel) => rel.includes("/") && manifests.includes(path.posix.basename(rel))).map((rel) => path.posix.dirname(rel));
224
+ if (nested.length === 0)
225
+ return files;
226
+ return files.filter((rel) => !nested.some((dir) => rel.startsWith(`${dir}/`)));
200
227
  }
201
228
  /** Directories pytest itself would collect. A missing setting keeps the broader walk. */
202
229
  function pytestTestPaths(pkg) {
@@ -405,7 +432,7 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
405
432
  const command = grep ? `${spec.command} --grep ${JSON.stringify(grep)}` : spec.command;
406
433
  return tag(runMocha(pkg, files, timeoutMs, env, selection), command, false, selection);
407
434
  }
408
- // A tty is only for a suite that runs `docker run -it` (tini). Everything else is bash.
435
+ // A tty is only for a suite whose scripts run `docker run -it`. Everything else is bash.
409
436
  if (spec.kind === "script") {
410
437
  const launched = scriptInvocation(pkg);
411
438
  return tag(runScript(pkg, timeoutMs, env, launched), launched.command, wantsNames, selection);
@@ -807,139 +834,10 @@ function scriptInvocation(pkg) {
807
834
  return { bin: "script", args, command: shown("script", args) };
808
835
  }
809
836
  function runScript(pkg, timeoutMs, env, launched) {
810
- const next = { ...env };
811
- const shim = writeDockerShim(pkg);
812
- if (shim)
813
- next.PATH = `${shim}${path.delimiter}${next.PATH ?? ""}`;
814
- // This project's CI sets ARCH_NATIVE=1. Without it, ci/run_build.sh skips the tests.
815
- const inner = path.join(pkg, "test", "run_inner_tests.py");
816
- const build = path.join(pkg, "ci", "run_build.sh");
817
- if (!next.ARCH_NATIVE && existsSync(inner) && existsSync(build) && readFileSync(build, "utf8").includes("ARCH_NATIVE")) {
818
- next.ARCH_NATIVE = "1";
819
- }
820
- return spawnCollected(launched.bin, launched.args, pkg, next, timeoutMs);
821
- }
822
- function writeDockerShim(pkg) {
823
- const found = spawnSync("sh", ["-c", "command -v docker"], { encoding: "utf8" });
824
- const real = found.stdout?.trim() ?? "";
825
- if (found.status !== 0 || !real)
826
- return null;
827
- const dir = path.join(pkg, ".probatio-suite", "bin");
828
- mkdirSync(dir, { recursive: true });
829
- const file = path.join(dir, "docker");
830
- writeFileSync(file, dockerShimScript(real), { mode: 0o755 });
831
- return dir;
832
- }
833
- function dockerShimScript(real) {
834
- return `#!/bin/bash
835
- set -euo pipefail
836
- REAL=${JSON.stringify(real)}
837
- if [[ "\${1:-}" != "build" ]]; then
838
- exec "$REAL" "$@"
839
- fi
840
- shift
841
- args=()
842
- context=""
843
- while [[ $# -gt 0 ]]; do
844
- case "$1" in
845
- --build-arg|--file|-f|--tag|-t|--target|--platform|--iidfile|--label|--network|--output|-o|--secret|--ssh|--cache-from|--cache-to|--build-context|--ulimit|--add-host)
846
- args+=("$1" "$2"); shift 2 ;;
847
- --build-arg=*|--file=*|--tag=*|--target=*|--platform=*|--network=*|--output=*)
848
- args+=("$1"); shift ;;
849
- --)
850
- shift; context="\${1:-}"; break ;;
851
- -*)
852
- args+=("$1"); shift ;;
853
- *)
854
- context="$1"; shift ;;
855
- esac
856
- done
857
- if [[ -z "$context" ]]; then
858
- exec "$REAL" build --pull=false "\${args[@]}"
859
- fi
860
- src=$(cd "$context" && pwd)
861
- tmp=$(mktemp -d)
862
- trap 'rm -rf "$tmp"' EXIT
863
- cp -a "$src"/. "$tmp"/
864
- deps="$tmp/ci/install_deps.sh"
865
- if [[ -f "$deps" ]] && grep -q "pip install --upgrade pip" "$deps"; then
866
- python3 - "$deps" << 'PY'
867
- import sys
868
- from pathlib import Path
869
- p = Path(sys.argv[1])
870
- text = p.read_text()
871
- old = "python3 -m pip install --upgrade pip\\npython3 -m pip install virtualenv\\n"
872
- new = """python3 -m pip install 'pip==20.3.4'
873
- printf '%s\\\\n' '[global]' 'constraint = /etc/pip-constraints.txt' > /etc/pip.conf
874
- printf '%s\\\\n' 'virtualenv==16.7.10' 'psutil==5.6.7' 'python-prctl==1.8.1' > /etc/pip-constraints.txt
875
- python3 -m pip install 'virtualenv==16.7.10'
876
- """
877
- if old not in text:
878
- raise SystemExit(0)
879
- p.write_text(text.replace(old, new, 1))
880
- p.chmod(0o755)
881
- PY
882
- fi
883
- "$REAL" build --pull=false "\${args[@]}" "$tmp"
884
- `;
837
+ return spawnCollected(launched.bin, launched.args, pkg, env, timeoutMs);
885
838
  }
886
839
  function runSwift(pkg, timeoutMs, env) {
887
- const args = ["test"];
888
- if (declaresOwnPredicate(pkg)) {
889
- const overlay = foundationPredicateOverlay();
890
- if (overlay) {
891
- args.push("-Xswiftc", "-vfsoverlay", "-Xswiftc", overlay.file, "-Xswiftc", "-module-cache-path", "-Xswiftc", overlay.cache);
892
- }
893
- }
894
- return spawnCollected("swift", args, pkg, env, timeoutMs);
895
- }
896
- function declaresOwnPredicate(pkg) {
897
- return walk(pkg, (rel) => rel.endsWith(".swift")).some((rel) => /struct\s+Predicate\s*</.test(readFileSync(path.join(pkg, rel), "utf8")));
898
- }
899
- function foundationPredicateOverlay() {
900
- const sdk = spawnSync("xcrun", ["--sdk", "macosx", "--show-sdk-path"], { encoding: "utf8" });
901
- const sdkPath = sdk.stdout?.trim() ?? "";
902
- if (sdk.status !== 0 || !sdkPath)
903
- return null;
904
- const moduleDir = path.join(sdkPath, "System/Library/Frameworks/Foundation.framework/Modules/Foundation.swiftmodule");
905
- if (!existsSync(moduleDir))
906
- return null;
907
- const interfaces = readdirSync(moduleDir).filter((name) => name.endsWith(".swiftinterface"));
908
- if (interfaces.length === 0)
909
- return null;
910
- const stamp = String(statSync(path.join(moduleDir, interfaces[0])).mtimeMs);
911
- const root = path.join(tmpdir(), "probatio-foundation-overlay");
912
- const edited = path.join(root, "interfaces");
913
- const file = path.join(root, "overlay.json");
914
- const cache = path.join(root, "cache");
915
- const stampFile = path.join(root, "stamp");
916
- mkdirSync(cache, { recursive: true });
917
- if (!existsSync(stampFile) || readFileSync(stampFile, "utf8") !== stamp || !existsSync(file)) {
918
- mkdirSync(edited, { recursive: true });
919
- const contents = [];
920
- for (const name of readdirSync(moduleDir).sort()) {
921
- const original = path.join(moduleDir, name);
922
- if (!statSync(original).isFile())
923
- continue;
924
- let external = original;
925
- if (name.endsWith(".swiftinterface")) {
926
- external = path.join(edited, name);
927
- writeFileSync(external, hideFoundationPredicate(readFileSync(original, "utf8")));
928
- }
929
- contents.push({ name, type: "file", "external-contents": external });
930
- }
931
- writeFileSync(file, `${JSON.stringify({ version: 0, roots: [{ name: moduleDir, type: "directory", contents }] }, null, 2)}\n`);
932
- writeFileSync(stampFile, stamp);
933
- }
934
- return { file, cache };
935
- }
936
- function hideFoundationPredicate(text) {
937
- return text
938
- .split("public struct Predicate<each Input>")
939
- .join("public struct HiddenPredicate<each Input>")
940
- .split("public macro Predicate<each Input>")
941
- .join("public macro HiddenPredicate<each Input>")
942
- .replace(/Foundation::Predicate(?![A-Za-z])/g, "Foundation::HiddenPredicate");
840
+ return spawnCollected("swift", ["test"], pkg, env, timeoutMs);
943
841
  }
944
842
  function readMochaConfig(pkg) {
945
843
  const file = path.join(pkg, "package.json");
@@ -1145,7 +1043,7 @@ function compileFailed(result) {
1145
1043
  return { ...result, stderr: `PROB_COMPILE_FAILED\n${result.stderr}` };
1146
1044
  }
1147
1045
  /** A compiler token when this run failed before any test result exists. Null when tests ran. */
1148
- export function detectCompile(kind, stdout, stderr, code) {
1046
+ export function detectCompile(kind, stdout, stderr, code, config = projectConfig("")) {
1149
1047
  if (code === 0)
1150
1048
  return null;
1151
1049
  const text = `${stdout}\n${stderr}`;
@@ -1169,14 +1067,19 @@ export function detectCompile(kind, stdout, stderr, code) {
1169
1067
  return "tsc";
1170
1068
  if (kind === "make-test" && !/Tests run:\s*\d+/.test(text) && /error:|cobc:|syntax error/i.test(text))
1171
1069
  return "cobc";
1172
- // A shell suite that dies in the compiler never prints a test title. That is a compile kill.
1173
- if (kind === "script" && !scriptReported(text) && /:\s*error:|fatal error:|undefined reference to|\[-Werror/.test(text))
1070
+ // A shell suite that dies in the compiler never prints a test line. That mutant did not build.
1071
+ if (kind === "script" && !scriptReported(text, config) && COMPILER_ERROR.test(text))
1174
1072
  return "clang";
1175
1073
  return null;
1176
1074
  }
1177
- function scriptReported(text) {
1178
- const testLine = /^(Testing (?!with FORCE_SUBREAPER\b).+|Running (?:exit code|reaping|process group|zombie|signal configuration|parent death).+|running signal test .+)$/;
1179
- return text.split(/\r?\n/).some((raw) => testLine.test(scriptLine(raw)));
1075
+ /** A C or C++ compiler or linker error: `file.c:12:5: error:`, or an undefined symbol. */
1076
+ const COMPILER_ERROR = /^[^\s:]+\.(?:c|cc|cpp|cxx|h|hh|hpp|m|mm):\d+:\d+:\s+(?:fatal\s+)?error:|undefined reference to|Undefined symbols for architecture/m;
1077
+ /** True when the output already has a test line, so a later error is a test, not the build. */
1078
+ function scriptReported(text, config) {
1079
+ return text.split(/\r?\n/).some((raw) => {
1080
+ const line = scriptLine(raw);
1081
+ return (config.testLine !== null && config.testLine.test(line)) || TAP_LINE.test(line);
1082
+ });
1180
1083
  }
1181
1084
  function scriptLine(raw) {
1182
1085
  // `script` prints ^D and a backspace before the program's own line.
@@ -1214,7 +1117,7 @@ function adapt(kind, pkg, stdout, stderr, code) {
1214
1117
  if (kind === "swift")
1215
1118
  return parseSwift(`${stdout}\n${stderr}`, code);
1216
1119
  if (kind === "script")
1217
- return parseScriptReport(`${stdout}\n${stderr}`, code);
1120
+ return parseScriptReport(`${stdout}\n${stderr}`, code, projectConfig(pkg));
1218
1121
  if (kind === "maven")
1219
1122
  return mavenReport(pkg, `${stdout}\n${stderr}`, code);
1220
1123
  if (kind === "dotnet")
@@ -1291,19 +1194,48 @@ function parseCargo(text) {
1291
1194
  return null;
1292
1195
  return { tests: names.length, pass: names.length - failed.length, fail: failed.length, failed, names };
1293
1196
  }
1294
- function parseScriptReport(text, code) {
1197
+ /** TAP: `ok 1 - name`, `not ok 2 - name # SKIP`. Many shell suites print it. */
1198
+ const TAP_LINE = /^(not )?ok\b\s*\d*\s*(?:-\s*)?(.*?)\s*(?:#\s*(SKIP|TODO)\b.*)?$/i;
1199
+ /**
1200
+ * A shell suite names its tests with the project's `testLine` from .probatio.json, or with TAP.
1201
+ * With neither, it is one command: exit 0 passes, and a failure is `::command`, not an invented name.
1202
+ */
1203
+ function parseScriptReport(text, code, config) {
1204
+ const lines = text.split(/\r?\n/).map(scriptLine);
1205
+ if (config.testLine) {
1206
+ const names = [];
1207
+ for (const line of lines) {
1208
+ const match = config.testLine.exec(line);
1209
+ if (!match)
1210
+ continue;
1211
+ const name = (match[1] ?? match[0]).trim();
1212
+ if (name && !names.includes(name))
1213
+ names.push(name);
1214
+ }
1215
+ // A configured test line that never printed is not a report. The baseline stops on that.
1216
+ if (names.length === 0)
1217
+ return null;
1218
+ const passed = code === 0 || (config.passLine !== null && lines.some((line) => config.passLine.test(line)));
1219
+ // The suite stops at the test that failed, so the last test line is the one that was running.
1220
+ const failed = passed ? [] : [{ name: names[names.length - 1], file: "", line: 0 }];
1221
+ return { tests: names.length, pass: names.length - failed.length, fail: failed.length, failed, names };
1222
+ }
1295
1223
  const names = [];
1296
- const testLine = /^(Testing (?!with FORCE_SUBREAPER\b).+|Running (?:exit code|reaping|process group|zombie|signal configuration|parent death).+|running signal test .+)$/;
1297
- for (const raw of text.split(/\r?\n/)) {
1298
- const line = scriptLine(raw);
1299
- if (!testLine.test(line) || names.includes(line))
1224
+ const failed = [];
1225
+ for (const line of lines) {
1226
+ const match = TAP_LINE.exec(line);
1227
+ if (!match || !match[2])
1300
1228
  continue;
1301
- names.push(line);
1229
+ const name = match[2];
1230
+ if (names.includes(name))
1231
+ continue;
1232
+ names.push(name);
1233
+ if (match[1] && !match[3])
1234
+ failed.push({ name, file: "", line: 0 });
1302
1235
  }
1303
- if (names.length === 0)
1304
- return null;
1305
- const failed = text.includes("All done, tests as expected") || code === 0 ? [] : [{ name: names[names.length - 1], file: "", line: 0 }];
1306
- return { tests: names.length, pass: names.length - failed.length, fail: failed.length, failed, names };
1236
+ if (names.length > 0)
1237
+ return { tests: names.length, pass: names.length - failed.length, fail: failed.length, failed, names };
1238
+ return exitReport("command", code);
1307
1239
  }
1308
1240
  function parseSwift(text, code) {
1309
1241
  const names = [];
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { SCHEMA_VERSION } from "../contract.js";
4
- import { advisePrune } from "../matrix/report.js";
5
4
  import { isBuildName } from "./kill-label.js";
5
+ import { normalizeResult } from "./run.js";
6
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
  /**
@@ -21,7 +21,7 @@ export function tallyRun(outDir) {
21
21
  next: "Run mutate run, then tally the same --out directory.",
22
22
  nextCall: null,
23
23
  keep: [],
24
- drop: [],
24
+ noKillsYet: [],
25
25
  gaps: [],
26
26
  rest: 0,
27
27
  pruning: { mode: "advisory", deletedTests: 0, advice: [] },
@@ -38,7 +38,7 @@ export function tallyRun(outDir) {
38
38
  next: "Run mutate run, then tally the same --out directory.",
39
39
  nextCall: null,
40
40
  keep: [],
41
- drop: [],
41
+ noKillsYet: [],
42
42
  gaps: [],
43
43
  rest: 0,
44
44
  pruning: { mode: "advisory", deletedTests: 0, advice: [] },
@@ -55,26 +55,37 @@ export function tallyRun(outDir) {
55
55
  killCounts.set(name, (killCounts.get(name) ?? 0) + 1);
56
56
  }
57
57
  }
58
- const seen = new Set(killCounts.keys());
59
- for (const name of coverageNames(path.join(outDir, "coverage-map.json"))) {
60
- if (isTestName(name))
61
- seen.add(name);
62
- }
58
+ // One test can be spelled two ways: the kill names its file, the line map does not.
59
+ // The first spelling seen wins, and kill spellings are added first.
60
+ const seen = [];
61
+ const see = (name) => {
62
+ if (!isTestName(name))
63
+ return;
64
+ if (seen.some((known) => namesMatch(known, name)))
65
+ return;
66
+ seen.push(name);
67
+ };
68
+ for (const name of killCounts.keys())
69
+ see(name);
70
+ for (const name of coverageNames(path.join(outDir, "coverage-map.json")))
71
+ see(name);
63
72
  for (const result of results) {
64
- for (const name of result.selectedTests ?? []) {
65
- if (isTestName(name))
66
- seen.add(name);
67
- }
73
+ for (const name of result.selectedTests ?? [])
74
+ see(name);
68
75
  }
69
76
  const counts = new Map();
70
77
  for (const name of seen)
71
78
  counts.set(name, countFor(name, killCounts));
72
- const pruning = advisePrune(counts);
73
79
  const keep = [...counts.entries()]
74
80
  .filter(([, killed]) => killed > 0)
75
81
  .map(([name]) => name)
76
82
  .sort();
77
- const drop = pruning.advice;
83
+ // One batch is not evidence for a deletion. These tests saw no mutant die in this batch, nothing more.
84
+ const noKillsYet = [...counts.entries()]
85
+ .filter(([, killed]) => killed === 0)
86
+ .map(([name]) => name)
87
+ .sort();
88
+ const pruning = { mode: "advisory", deletedTests: 0, advice: [] };
78
89
  const noCoverage = results.filter((result) => result.outcome === "no coverage").length;
79
90
  const survived = results.filter((result) => result.outcome === "survived").length;
80
91
  const timeouts = results.filter((result) => result.outcome === "timeout").length;
@@ -88,12 +99,13 @@ export function tallyRun(outDir) {
88
99
  }))
89
100
  .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.id.localeCompare(b.id));
90
101
  const shown = gaps.slice(0, 10);
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.`);
102
+ const summary = leadSummary(noCoverage, survived, "survived", `${keep.length} tests to keep, ${noKillsYet.length} tests saw no kill in this batch, ${gaps.length} gaps. No test file was deleted.`);
92
103
  const notes = [];
93
104
  if (noCoverage > 0)
94
105
  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.`);
106
+ if (gaps.length > 0) {
107
+ notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass. Add one test that fails on that mutant and passes on the code, commit it, then run nextCall.`);
108
+ }
97
109
  if (timeouts > 0)
98
110
  notes.push(TIMEOUT_NEXT);
99
111
  if (notes.length === 0)
@@ -105,9 +117,9 @@ export function tallyRun(outDir) {
105
117
  command: "mutate.tally",
106
118
  summary,
107
119
  next,
108
- nextCall: resumeArgv(outDir, keep),
120
+ nextCall: nextArgv(outDir, keep, gaps.length > 0),
109
121
  keep,
110
- drop,
122
+ noKillsYet,
111
123
  gaps,
112
124
  rest: 0,
113
125
  pruning,
@@ -117,8 +129,13 @@ export function tallyRun(outDir) {
117
129
  writeFileSync(fullPath, `${JSON.stringify(complete, null, 2)}\n`);
118
130
  return { ...complete, gaps: shown, rest: gaps.length - shown.length };
119
131
  }
120
- function resumeArgv(outDir, keep) {
121
- if (keep.length === 0)
132
+ /**
133
+ * The command after this tally. A gap reruns the batch on HEAD once the new test is committed.
134
+ * Otherwise the keep ids are rescored at the same commit. Either way the out dir is fresh:
135
+ * the stored out dir would skip every finished mutant and echo the old result.
136
+ */
137
+ function nextArgv(outDir, keep, hasGaps) {
138
+ if (!hasGaps && keep.length === 0)
122
139
  return null;
123
140
  let stored = {};
124
141
  try {
@@ -127,15 +144,16 @@ function resumeArgv(outDir, keep) {
127
144
  catch {
128
145
  stored = {};
129
146
  }
147
+ const base = stored.out || outDir;
130
148
  const argv = ["mutate", "run"];
131
149
  if (stored.package)
132
150
  argv.push("--package", stored.package);
133
151
  for (const dir of stored.patches ?? [])
134
152
  argv.push("--patches", dir);
135
- argv.push("--out", stored.out || outDir);
153
+ argv.push("--out", freshDir(base, hasGaps ? "after-gaps" : "rescore"));
136
154
  if (stored.repo)
137
155
  argv.push("--repo", stored.repo);
138
- if (stored.commit)
156
+ if (!hasGaps && stored.commit)
139
157
  argv.push("--commit", stored.commit);
140
158
  if (typeof stored.workers === "number")
141
159
  argv.push("--workers", String(stored.workers));
@@ -149,11 +167,21 @@ function resumeArgv(outDir, keep) {
149
167
  argv.push("--test-timeout-ms", String(stored.testTimeoutMs));
150
168
  if (stored.direction && stored.direction !== "auto")
151
169
  argv.push("--direction", stored.direction);
152
- if (stored.suiteCommand)
170
+ // --only-test refuses --suite-command, so a keep rescore uses discovery.
171
+ if (stored.suiteCommand && hasGaps)
153
172
  argv.push("--suite-command", stored.suiteCommand);
154
- argv.push(...keep.flatMap((id) => ["--only-test", id]));
173
+ if (!hasGaps)
174
+ argv.push(...keep.flatMap((id) => ["--only-test", id]));
155
175
  return { argv };
156
176
  }
177
+ /** First sibling of the out dir that does not exist yet. The same tally prints the same path. */
178
+ function freshDir(base, label) {
179
+ for (let index = 1;; index++) {
180
+ const candidate = index === 1 ? `${base}.${label}` : `${base}.${label}-${index}`;
181
+ if (!existsSync(candidate))
182
+ return candidate;
183
+ }
184
+ }
157
185
  function readResults(dir) {
158
186
  const out = [];
159
187
  for (const name of readdirSync(dir)) {
@@ -165,7 +193,7 @@ function readResults(dir) {
165
193
  continue;
166
194
  if (!parsed.id)
167
195
  parsed.id = name.slice(0, -".json".length);
168
- out.push(parsed);
196
+ out.push(normalizeResult(parsed));
169
197
  }
170
198
  catch {
171
199
  continue;
@@ -4,8 +4,8 @@ import { SCHEMA_VERSION } from "../contract.js";
4
4
  import { directImporters } from "../mutate/affected.js";
5
5
  import { filesInDiff } from "../mutate/patch.js";
6
6
  import { killLabel } from "../mutate/kill-label.js";
7
- import { runMutants } from "../mutate/run.js";
8
- import { campaignSummary } from "../mutate/suite-decision.js";
7
+ import { normalizeResult, runMutants } from "../mutate/run.js";
8
+ import { campaignSummary, UNVIABLE_NEXT } from "../mutate/suite-decision.js";
9
9
  /** The gap is done only when this mutant is killed and the baseline suite is green. */
10
10
  export async function checkKill(id, options) {
11
11
  const importers = importersOf(id, options);
@@ -48,18 +48,24 @@ export async function checkKill(id, options) {
48
48
  let outcome = null;
49
49
  let killedBy = [];
50
50
  if (existsSync(resultFile)) {
51
- const result = JSON.parse(readFileSync(resultFile, "utf8"));
51
+ const result = normalizeResult(JSON.parse(readFileSync(resultFile, "utf8")));
52
52
  outcome = result.outcome ?? null;
53
53
  killedBy = Array.isArray(result.killedBy) ? result.killedBy : [];
54
54
  }
55
- const done = suiteGreen && outcome === "killed";
56
- const label = done ? killLabel(killedBy) : null;
55
+ // A mutant that does not build needs no test. The task is closed, and it is not called a kill.
56
+ const unviableMutant = suiteGreen && outcome === "unviable";
57
+ const done = (suiteGreen && outcome === "killed") || unviableMutant;
58
+ const label = unviableMutant ? { cause: "build", next: UNVIABLE_NEXT } : done ? killLabel(killedBy) : null;
57
59
  const head = checkHead(report);
58
60
  return {
59
61
  schemaVersion: SCHEMA_VERSION,
60
62
  ok: done,
61
63
  command: "check-kill",
62
- summary: done ? `${head} ${id} is dead and the suite is green.` : `${head} ${id} is not done.`,
64
+ summary: unviableMutant
65
+ ? `${head} ${id} does not build. No test is needed.`
66
+ : done
67
+ ? `${head} ${id} is dead and the suite is green.`
68
+ : `${head} ${id} is not done.`,
63
69
  next: label?.next ?? (done ? "The gap-fixing task is done." : "The task is not done."),
64
70
  nextCall: null,
65
71
  done,
@@ -78,11 +84,12 @@ function importersOf(id, options) {
78
84
  return directImporters(options.packageDir, rels, options.testsDir);
79
85
  }
80
86
  function checkHead(report) {
81
- const finished = report.killed + report.survived + report.flaky + report.timeouts + report.errors + report.noCoverage;
87
+ const finished = report.killed + report.unviable + report.survived + report.flaky + report.timeouts + report.errors + report.noCoverage;
82
88
  return campaignSummary({
83
89
  noCoverage: report.noCoverage,
84
90
  survived: report.survived,
85
91
  killed: report.killed,
92
+ unviable: report.unviable,
86
93
  flaky: report.flaky,
87
94
  timeouts: report.timeouts,
88
95
  errors: report.errors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "url": "https://github.com/IronAdamant/Probatio/issues"
21
21
  },
22
22
  "scripts": {
23
- "build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/precise-lines.mjs src/mutate/child-lines.mjs src/mutate/mocha-coverage.cjs src/mutate/node-batch.mjs src/mutate/node-batch-loader.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
23
+ "build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/precise-lines.mjs src/mutate/child-lines.mjs src/mutate/mocha-coverage.cjs src/mutate/node-batch.mjs src/mutate/node-batch-loader.mjs src/mutate/load-failure.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
24
24
  "prepack": "npm run build",
25
25
  "test": "tsx --test tests/*.test.ts",
26
26
  "probatio": "tsx src/cli.ts"