probatio 0.1.3 → 0.3.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.
- package/README.md +93 -8
- package/dist/cli.js +136 -6
- package/dist/contract.js +2 -1
- package/dist/golden/record.js +395 -0
- package/dist/golden/recorder.mjs +78 -0
- package/dist/ledger/build.js +21 -0
- package/dist/ledger/check.js +133 -0
- package/dist/mcp.js +76 -27
- package/dist/mutate/child-lines.mjs +23 -18
- package/dist/mutate/fail-reporter.mjs +21 -0
- package/dist/mutate/find.js +3 -3
- package/dist/mutate/generate.js +5 -2
- package/dist/mutate/load-failure.mjs +40 -0
- package/dist/mutate/mocha-coverage.cjs +7 -1
- package/dist/mutate/node-batch-loader.mjs +15 -1
- package/dist/mutate/node-batch.mjs +40 -3
- package/dist/mutate/node-coverage.mjs +7 -32
- package/dist/mutate/operators.js +60 -1
- package/dist/mutate/patch.js +2 -1
- package/dist/mutate/precise-lines.mjs +147 -31
- package/dist/mutate/probatio-jacoco-run.java +2 -2
- package/dist/mutate/project-config.js +58 -0
- package/dist/mutate/run.js +100 -19
- package/dist/mutate/sealed.js +54 -0
- package/dist/mutate/suite-decision.js +2 -1
- package/dist/mutate/suites.js +87 -155
- package/dist/mutate/tally.js +54 -26
- package/dist/mutate/text-operators.js +92 -2
- package/dist/swarm/check-kill.js +14 -7
- package/dist/verify/change.js +1 -1
- package/package.json +8 -4
- package/schemas/check-kill.schema.json +93 -0
- package/schemas/findings.add.schema.json +53 -0
- package/schemas/gap.fix.schema.json +53 -0
- package/schemas/gap.revert.schema.json +53 -0
- package/schemas/golden.check.schema.json +89 -0
- package/schemas/golden.compare.schema.json +151 -0
- package/schemas/golden.record.schema.json +126 -0
- package/schemas/guard.check.schema.json +53 -0
- package/schemas/ledger.build.schema.json +132 -0
- package/schemas/ledger.check.schema.json +143 -0
- package/schemas/matrix.report.schema.json +129 -0
- package/schemas/mcp.schema.json +53 -0
- package/schemas/mutate.generate.schema.json +127 -0
- package/schemas/mutate.help.schema.json +53 -0
- package/schemas/mutate.run.schema.json +272 -0
- package/schemas/mutate.sealed.schema.json +53 -0
- package/schemas/mutate.tally.schema.json +137 -0
- package/schemas/queue.claim.schema.json +53 -0
- package/schemas/queue.reap.schema.json +71 -0
- package/schemas/queue.seed.schema.json +71 -0
- package/schemas/schema.schema.json +74 -0
- package/schemas/seal.schema.json +69 -0
- package/schemas/status.schema.json +103 -0
- package/schemas/verify-change.schema.json +222 -0
|
@@ -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);
|
package/dist/mutate/suites.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1173
|
-
if (kind === "script" && !scriptReported(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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
|
1297
|
-
for (const
|
|
1298
|
-
const
|
|
1299
|
-
if (!
|
|
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
|
-
|
|
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
|
|
1304
|
-
return
|
|
1305
|
-
|
|
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 = [];
|
package/dist/mutate/tally.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, ${
|
|
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:
|
|
120
|
+
nextCall: nextArgv(outDir, keep, gaps.length > 0),
|
|
109
121
|
keep,
|
|
110
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -101,7 +101,7 @@ export function textKind(file) {
|
|
|
101
101
|
* Operator mutants for one non-TypeScript source. Strings and comments are skipped
|
|
102
102
|
* before a point is proposed, so a `/*`, `*>`, or `#` inside a string cannot hide later code.
|
|
103
103
|
*/
|
|
104
|
-
export function findTextMutants(file, text) {
|
|
104
|
+
export function findTextMutants(file, text, operators = "core") {
|
|
105
105
|
const kind = resolveKind(file, text);
|
|
106
106
|
if (!kind)
|
|
107
107
|
return { points: [], violations: [] };
|
|
@@ -115,7 +115,7 @@ export function findTextMutants(file, text) {
|
|
|
115
115
|
index += 1;
|
|
116
116
|
continue;
|
|
117
117
|
}
|
|
118
|
-
const match = code[index] ? matchAt(text, index, code, kind) : null;
|
|
118
|
+
const match = code[index] ? matchAt(text, index, code, kind) ?? (operators === "wide" ? wideAt(text, index, code, kind) : null) : null;
|
|
119
119
|
if (!match) {
|
|
120
120
|
index += 1;
|
|
121
121
|
continue;
|
|
@@ -131,8 +131,43 @@ export function findTextMutants(file, text) {
|
|
|
131
131
|
});
|
|
132
132
|
index += match.original.length;
|
|
133
133
|
}
|
|
134
|
+
if (operators === "wide")
|
|
135
|
+
points.push(...droppedStatements(file, text, code, kind));
|
|
134
136
|
return { points, violations: [] };
|
|
135
137
|
}
|
|
138
|
+
const DROP_KINDS = new Set(["c", "csharp", "js", "rust"]);
|
|
139
|
+
const NOT_DROPPED = /^(return|throw|break|continue|goto|case|default|yield|else|if|for|while|do|switch|try|catch|finally|new|package|import|using|let|const|var|val|super|assert)\b/;
|
|
140
|
+
const CALL = /^(this\.)?[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*\s*\(.*\)\s*;$/;
|
|
141
|
+
const ASSIGN = /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*|\[[^\]]*\])*\s*([-+*/%|&^]|<<|>>)?=(?!=).+;$/;
|
|
142
|
+
const STEP = /^((\+\+|--)[A-Za-z_$][\w$.]*|[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*(\+\+|--));$/;
|
|
143
|
+
/**
|
|
144
|
+
* Wide set, semicolon languages only: drop a call, an assignment, or an increment that is a whole
|
|
145
|
+
* statement on its own line. `;` takes its place, which is an empty statement in every one of them.
|
|
146
|
+
* A declaration has a type before the name, so it never matches. A line inside a string, a comment,
|
|
147
|
+
* or the middle of a multi-line statement does not start with code that matches either.
|
|
148
|
+
*/
|
|
149
|
+
function droppedStatements(file, text, code, kind) {
|
|
150
|
+
if (!DROP_KINDS.has(kind))
|
|
151
|
+
return [];
|
|
152
|
+
const points = [];
|
|
153
|
+
let offset = 0;
|
|
154
|
+
const lines = text.split("\n");
|
|
155
|
+
for (const [index, raw] of lines.entries()) {
|
|
156
|
+
const lead = raw.length - raw.trimStart().length;
|
|
157
|
+
const statement = raw.trim();
|
|
158
|
+
const start = offset + lead;
|
|
159
|
+
const end = start + statement.length;
|
|
160
|
+
offset += raw.length + 1;
|
|
161
|
+
if (!statement.endsWith(";") || !code[start] || !code[end - 1])
|
|
162
|
+
continue;
|
|
163
|
+
if (NOT_DROPPED.test(statement))
|
|
164
|
+
continue;
|
|
165
|
+
if (!CALL.test(statement) && !ASSIGN.test(statement) && !STEP.test(statement))
|
|
166
|
+
continue;
|
|
167
|
+
points.push({ file, start, end, line: index + 1, op: "drop-statement", original: statement, replacement: ";" });
|
|
168
|
+
}
|
|
169
|
+
return points;
|
|
170
|
+
}
|
|
136
171
|
function matchAt(text, index, code, kind) {
|
|
137
172
|
const word = matchWord(text, index, code, kind);
|
|
138
173
|
if (word)
|
|
@@ -148,6 +183,61 @@ function matchAt(text, index, code, kind) {
|
|
|
148
183
|
}
|
|
149
184
|
return null;
|
|
150
185
|
}
|
|
186
|
+
const ARITHMETIC = [
|
|
187
|
+
{ original: "+", op: "add-to-sub", replacement: "-" },
|
|
188
|
+
{ original: "-", op: "sub-to-add", replacement: "+" },
|
|
189
|
+
{ original: "*", op: "mul-to-div", replacement: "/" },
|
|
190
|
+
{ original: "/", op: "div-to-mul", replacement: "*" },
|
|
191
|
+
{ original: "%", op: "mod-to-mul", replacement: "*" },
|
|
192
|
+
];
|
|
193
|
+
/**
|
|
194
|
+
* Wide set for text languages: spaced arithmetic and integer constants. No statement deletion here:
|
|
195
|
+
* without a parser, a removed line can be half of a statement. COBOL and assembly are left out,
|
|
196
|
+
* because a COBOL level number or an assembler immediate is not a value the program computes.
|
|
197
|
+
*/
|
|
198
|
+
function wideAt(text, index, code, kind) {
|
|
199
|
+
if (kind === "cobol" || kind.startsWith("asm-"))
|
|
200
|
+
return null;
|
|
201
|
+
const prev = text[index - 1] ?? "";
|
|
202
|
+
const next = text[index + 1] ?? "";
|
|
203
|
+
for (const op of ARITHMETIC) {
|
|
204
|
+
if (text[index] !== op.original)
|
|
205
|
+
continue;
|
|
206
|
+
// Spaced on both sides, the way a binary operator is written. That leaves `++`, `+=`, `->`,
|
|
207
|
+
// `//`, `**`, unary minus, and most pointer stars alone.
|
|
208
|
+
if (!(/[ \t]/.test(prev) && /[ \t]/.test(next)))
|
|
209
|
+
return null;
|
|
210
|
+
const before = text.slice(0, index).trimEnd().slice(-1);
|
|
211
|
+
if (before === "" || /[=(,[{:?!<>&|+\-*/%^~]/.test(before))
|
|
212
|
+
return null;
|
|
213
|
+
// `"x" + y` builds a string. In Java or C# a `-` there does not compile, so it is not a useful mutant.
|
|
214
|
+
if (op.original === "+" && (stringBeside(text, code, index, -1) || stringBeside(text, code, index, 1)))
|
|
215
|
+
return null;
|
|
216
|
+
return spanIsCode(code, index, 1) ? op : null;
|
|
217
|
+
}
|
|
218
|
+
if (!/[0-9]/.test(text[index]) || /[A-Za-z0-9_.$#]/.test(prev))
|
|
219
|
+
return null;
|
|
220
|
+
const digits = /^[0-9]+/.exec(text.slice(index, index + 20))?.[0] ?? "";
|
|
221
|
+
// A hex, binary, float, exponent, or suffixed literal (`0x10`, `1.5`, `1e5`, `1u32`) is left alone.
|
|
222
|
+
if (!digits || /[A-Za-z0-9_.]/.test(text[index + digits.length] ?? ""))
|
|
223
|
+
return null;
|
|
224
|
+
if (!spanIsCode(code, index, digits.length))
|
|
225
|
+
return null;
|
|
226
|
+
if (digits === "0")
|
|
227
|
+
return { original: "0", op: "const-zero-to-one", replacement: "1" };
|
|
228
|
+
if (digits === "1")
|
|
229
|
+
return { original: "1", op: "const-one-to-zero", replacement: "0" };
|
|
230
|
+
if (digits.length > 15 || digits.startsWith("0"))
|
|
231
|
+
return null;
|
|
232
|
+
return { original: digits, op: "const-inc", replacement: String(Number(digits) + 1) };
|
|
233
|
+
}
|
|
234
|
+
/** True when the nearest non-space character on that side is a quote that the mask hides: a string literal's edge. */
|
|
235
|
+
function stringBeside(text, code, index, step) {
|
|
236
|
+
let at = index + step;
|
|
237
|
+
while (at >= 0 && at < text.length && (text[at] === " " || text[at] === "\t"))
|
|
238
|
+
at += step;
|
|
239
|
+
return at >= 0 && at < text.length && !code[at] && /["'`]/.test(text[at]);
|
|
240
|
+
}
|
|
151
241
|
function resolveKind(file, text) {
|
|
152
242
|
const kind = textKind(file);
|
|
153
243
|
if (!kind)
|