probatio 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ export function readHeader(raw) {
6
+ const lines = raw.split(/\r?\n/);
7
+ let direction = null;
8
+ let index = 0;
9
+ while (index < lines.length && !lines[index].startsWith("---") && !lines[index].startsWith("diff ")) {
10
+ const match = lines[index].match(/direction=(forward|reverse)/);
11
+ if (match)
12
+ direction = match[1];
13
+ index++;
14
+ }
15
+ const body = lines.slice(index).join("\n");
16
+ return { direction, body: body.endsWith("\n") || body.length === 0 ? body : `${body}\n` };
17
+ }
18
+ /** `diff -u` patch that applies onto the original and produces the mutant. */
19
+ export function forwardDiff(rel, original, mutated) {
20
+ const dir = mkdtempSync(path.join(tmpdir(), "probatio-diff-"));
21
+ try {
22
+ const before = path.join(dir, "before");
23
+ const after = path.join(dir, "after");
24
+ writeFileSync(before, original);
25
+ writeFileSync(after, mutated);
26
+ const diff = spawnSync("diff", ["-u", "-L", `a/${rel}`, "-L", `b/${rel}`, before, after], { encoding: "utf8" });
27
+ if (diff.status === 0)
28
+ return "";
29
+ if (diff.status !== 1)
30
+ throw new Error(diff.stderr.trim() || "diff failed");
31
+ const body = diff.stdout.endsWith("\n") ? diff.stdout : `${diff.stdout}\n`;
32
+ return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n${body}`;
33
+ }
34
+ finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+ export function filesInDiff(raw) {
39
+ const body = readHeader(raw).body;
40
+ const files = [];
41
+ const chunks = body.split(/^diff --git .*$/m);
42
+ const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
43
+ for (const piece of pieces) {
44
+ const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
45
+ const hunk = piece.match(/^@@ -\d+(?:,\d+)? \+(\d+)/m);
46
+ if (!plus)
47
+ continue;
48
+ let file = plus[1];
49
+ if (file === "/dev/null")
50
+ continue;
51
+ // git diff -R swaps the labels, so the path may be a/ or b/.
52
+ if (file.startsWith("a/") || file.startsWith("b/"))
53
+ file = file.slice(2);
54
+ files.push({ file, line: hunk ? Number(hunk[1]) : 1 });
55
+ }
56
+ return files;
57
+ }
58
+ /**
59
+ * Original lines a patch removes or replaces.
60
+ * A unified hunk starts on context, so the hunk header is not the edited line.
61
+ */
62
+ export function changedLines(raw) {
63
+ const body = readHeader(raw).body;
64
+ const chunks = body.split(/^diff --git .*$/m);
65
+ const pieces = chunks.length > 1 ? chunks.slice(1) : [body];
66
+ const found = [];
67
+ for (const piece of pieces) {
68
+ const plus = piece.match(/^\+\+\+ ([^\t\n]+)/m);
69
+ if (!plus)
70
+ continue;
71
+ let file = plus[1];
72
+ if (file === "/dev/null")
73
+ continue;
74
+ if (file.startsWith("a/") || file.startsWith("b/"))
75
+ file = file.slice(2);
76
+ let oldLine = 0;
77
+ let inHunk = false;
78
+ for (const line of piece.split(/\r?\n/)) {
79
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+\d+/.exec(line);
80
+ if (hunk) {
81
+ oldLine = Number(hunk[1]);
82
+ inHunk = true;
83
+ continue;
84
+ }
85
+ if (!inHunk)
86
+ continue;
87
+ if (line.startsWith("+") || line.startsWith("\\"))
88
+ continue;
89
+ if (line.startsWith("-")) {
90
+ found.push({ file, line: oldLine });
91
+ oldLine += 1;
92
+ continue;
93
+ }
94
+ if (line.startsWith(" ")) {
95
+ oldLine += 1;
96
+ continue;
97
+ }
98
+ inHunk = false;
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+ export function git(repo, args, input) {
104
+ const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", input });
105
+ const stderr = [result.stderr ?? "", result.error?.message ?? ""].filter((part) => part.length > 0).join("\n");
106
+ return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr };
107
+ }
108
+ export function patchApplies(repo, raw, direction) {
109
+ const args = ["apply", "--check", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
110
+ return git(repo, args, readHeader(raw).body).status === 0;
111
+ }
112
+ export function chooseDirection(repo, raw, requested) {
113
+ if (requested !== "auto")
114
+ return requested;
115
+ const header = readHeader(raw).direction;
116
+ if (header)
117
+ return header;
118
+ if (patchApplies(repo, raw, "forward"))
119
+ return "forward";
120
+ if (patchApplies(repo, raw, "reverse"))
121
+ return "reverse";
122
+ return null;
123
+ }
124
+ export function applyPatch(repo, raw, direction) {
125
+ const args = ["apply", "--whitespace=nowarn", ...(direction === "reverse" ? ["-R"] : []), "-"];
126
+ const result = git(repo, args, readHeader(raw).body);
127
+ if (result.status === 0)
128
+ return { ok: true };
129
+ return { ok: false, error: (result.stderr || result.stdout).trim() || "apply failed" };
130
+ }
131
+ /** Put a throwaway worktree back. A diff label is not trusted for the path list. */
132
+ export function restoreTree(repo) {
133
+ git(repo, ["checkout", "--", "."]);
134
+ git(repo, ["clean", "-fd", "-e", "node_modules"]);
135
+ }
@@ -0,0 +1,81 @@
1
+ import java.io.ByteArrayInputStream;
2
+ import java.io.File;
3
+ import java.nio.file.Files;
4
+ import java.util.List;
5
+ import org.jacoco.agent.rt.IAgent;
6
+ import org.jacoco.agent.rt.RT;
7
+ import org.jacoco.core.analysis.Analyzer;
8
+ import org.jacoco.core.analysis.CoverageBuilder;
9
+ import org.jacoco.core.analysis.IClassCoverage;
10
+ import org.jacoco.core.analysis.ILine;
11
+ import org.jacoco.core.data.ExecutionDataReader;
12
+ import org.jacoco.core.data.ExecutionDataStore;
13
+ import org.junit.runner.JUnitCore;
14
+ import org.junit.runner.Request;
15
+
16
+ /** One JaCoCo dump per JUnit 4 method. Prints "HIT <file> <line>" lines. */
17
+ public class ProbatioJacocoRun {
18
+ public static void main(String[] args) throws Exception {
19
+ if (args.length != 4) {
20
+ System.err.println("usage: ProbatioJacocoRun classDirs testsFile outFile packageRoot");
21
+ System.exit(2);
22
+ }
23
+ String[] classDirs = args[0].split(File.pathSeparator);
24
+ List<String> tests = Files.readAllLines(new File(args[1]).toPath());
25
+ File out = new File(args[2]);
26
+ File root = new File(args[3]);
27
+ IAgent agent = RT.getAgent();
28
+ StringBuilder text = new StringBuilder();
29
+ for (String spec : tests) {
30
+ int hash = spec.lastIndexOf('#');
31
+ if (hash <= 0) continue;
32
+ String className = spec.substring(0, hash);
33
+ String method = spec.substring(hash + 1);
34
+ agent.getExecutionData(true);
35
+ try {
36
+ new JUnitCore().run(Request.method(Class.forName(className), method));
37
+ } catch (Throwable ignored) {
38
+ continue;
39
+ }
40
+ byte[] data = agent.getExecutionData(false);
41
+ ExecutionDataStore store = new ExecutionDataStore();
42
+ ExecutionDataReader reader = new ExecutionDataReader(new ByteArrayInputStream(data));
43
+ reader.setExecutionDataVisitor(store);
44
+ reader.setSessionInfoVisitor(info -> { });
45
+ reader.read();
46
+ String testName = className + "." + method;
47
+ text.append("TEST ").append(testName).append('\n');
48
+ for (String dir : classDirs) {
49
+ if (dir.isEmpty()) continue;
50
+ File classes = new File(dir);
51
+ if (!classes.isDirectory()) continue;
52
+ CoverageBuilder builder = new CoverageBuilder();
53
+ Analyzer analyzer = new Analyzer(store, builder);
54
+ analyzer.analyzeAll(classes);
55
+ for (IClassCoverage cc : builder.getClasses()) {
56
+ String source = cc.getSourceFileName();
57
+ if (source == null) continue;
58
+ File file = sourceFile(classes, cc.getPackageName(), source);
59
+ if (file == null || !file.isFile()) continue;
60
+ String rel = root.toPath().relativize(file.toPath()).toString().replace('\\', '/');
61
+ int first = cc.getFirstLine();
62
+ int last = cc.getLastLine();
63
+ for (int line = first; line <= last; line++) {
64
+ ILine cov = cc.getLine(line);
65
+ if (cov.getInstructionCounter().getCoveredCount() <= 0) continue;
66
+ text.append("HIT ").append(rel).append(' ').append(line).append(' ').append(testName).append('\n');
67
+ }
68
+ }
69
+ }
70
+ }
71
+ Files.writeString(out.toPath(), text.toString());
72
+ }
73
+
74
+ static File sourceFile(File classes, String pkg, String source) {
75
+ File target = classes.getParentFile();
76
+ File module = target == null ? null : target.getParentFile();
77
+ if (module == null) return null;
78
+ String rel = (pkg == null || pkg.isEmpty() ? "" : pkg + "/") + source;
79
+ return new File(module, "src/main/java/" + rel);
80
+ }
81
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """Run pytest and print one Probatio report as a single JSON line.
3
+
4
+ When PROBATIO_COVERAGE_MAP is set and coverage.py imports, also write a
5
+ per-test line map. A missing coverage module leaves the map unwritten.
6
+ """
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ # The sysmon core on this interpreter drops dynamic context switches.
12
+ os.environ.setdefault("COVERAGE_CORE", "ctrace")
13
+
14
+ import pytest
15
+
16
+
17
+ class Collector:
18
+ def __init__(self):
19
+ self.names = []
20
+ self.failed = []
21
+
22
+ def pytest_collectreport(self, report):
23
+ if not report.failed:
24
+ return
25
+ self._add(getattr(report, "nodeid", "") or "pytest", report)
26
+
27
+ def pytest_runtest_logreport(self, report):
28
+ if report.when not in ("setup", "call"):
29
+ return
30
+ if report.when == "setup" and not report.failed:
31
+ return
32
+ name = report.nodeid
33
+ if report.when == "call" and name not in self.names:
34
+ self.names.append(name)
35
+ if report.failed:
36
+ self._add(name, report)
37
+
38
+ def _add(self, name, report):
39
+ if any(item["name"] == name for item in self.failed):
40
+ return
41
+ location = getattr(report, "location", None) or ("", 0, "")
42
+ self.failed.append({"name": name, "file": str(location[0] or ""), "line": int(location[1] or 0)})
43
+
44
+
45
+ class ContextSwitch:
46
+ def __init__(self, cov):
47
+ self.cov = cov
48
+
49
+ def pytest_runtest_setup(self, item):
50
+ self.cov.switch_context(item.nodeid)
51
+
52
+
53
+ def start_coverage():
54
+ dest = os.environ.get("PROBATIO_COVERAGE_MAP")
55
+ if not dest:
56
+ return None, None
57
+ try:
58
+ import coverage
59
+ except ImportError:
60
+ return None, None
61
+ data_file = dest + ".data"
62
+ try:
63
+ cov = coverage.Coverage(cover_pylib=False, data_file=data_file)
64
+ cov.start()
65
+ return cov, dest
66
+ except Exception:
67
+ return None, None
68
+
69
+
70
+ def write_coverage(cov, dest):
71
+ try:
72
+ cov.stop()
73
+ cov.save()
74
+ except Exception:
75
+ return
76
+ try:
77
+ data = cov.get_data()
78
+ except Exception:
79
+ return
80
+ root = os.path.realpath(os.getcwd())
81
+ files = {}
82
+ for filename in data.measured_files():
83
+ full = os.path.realpath(filename)
84
+ if not full.startswith(root + os.sep):
85
+ continue
86
+ if "/site-packages/" in full or "/node_modules/" in full:
87
+ continue
88
+ rel = full[len(root) + 1 :]
89
+ try:
90
+ by_line = data.contexts_by_lineno(filename) or {}
91
+ except Exception:
92
+ continue
93
+ lines = {}
94
+ for lineno, contexts in by_line.items():
95
+ names = [item for item in contexts if item]
96
+ if names:
97
+ lines[str(lineno)] = names
98
+ if lines:
99
+ files[rel] = lines
100
+ with open(dest, "w", encoding="utf-8") as handle:
101
+ json.dump({"files": files}, handle)
102
+ handle.write("\n")
103
+
104
+
105
+ def main():
106
+ args = []
107
+ index = 1
108
+ while index < len(sys.argv):
109
+ if sys.argv[index] == "--pattern":
110
+ args.extend(["-k", sys.argv[index + 1]])
111
+ index += 2
112
+ continue
113
+ args.append(sys.argv[index])
114
+ index += 1
115
+ cov, dest = start_coverage()
116
+ collector = Collector()
117
+ plugins = [collector]
118
+ if cov is not None:
119
+ plugins.append(ContextSwitch(cov))
120
+ code = 1
121
+ try:
122
+ code = pytest.main([*args, "-p", "no:cacheprovider", "--tb=no", "-q"], plugins=plugins) or 0
123
+ except BaseException:
124
+ code = 1
125
+ finally:
126
+ if cov is not None and dest:
127
+ write_coverage(cov, dest)
128
+ names = collector.names or [item["name"] for item in collector.failed]
129
+ failed = collector.failed
130
+ # Exit 4 is a usage error (a bad -k expression). No test ran, so this is not a kill.
131
+ if not names and code != 0:
132
+ names = []
133
+ failed = []
134
+ report = {
135
+ "tests": len(names),
136
+ "pass": len(names) - len(failed),
137
+ "fail": len(failed),
138
+ "failed": failed,
139
+ "names": names,
140
+ }
141
+ sys.stdout.write(json.dumps(report) + "\n")
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Run unittest modules and print one Probatio report as a single JSON line."""
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+
12
+ def load(path: Path):
13
+ name = f"probatio_{path.stem}"
14
+ spec = importlib.util.spec_from_file_location(name, path)
15
+ if spec is None or spec.loader is None:
16
+ raise RuntimeError(f"cannot load {path}")
17
+ module = importlib.util.module_from_spec(spec)
18
+ sys.modules[name] = module
19
+ spec.loader.exec_module(module)
20
+ return module
21
+
22
+
23
+ def walk(suite):
24
+ for item in suite:
25
+ if isinstance(item, unittest.TestSuite):
26
+ yield from walk(item)
27
+ else:
28
+ yield item
29
+
30
+
31
+ def parse_args(argv):
32
+ pattern = None
33
+ files = []
34
+ index = 0
35
+ while index < len(argv):
36
+ if argv[index] == "--pattern":
37
+ pattern = argv[index + 1]
38
+ index += 2
39
+ continue
40
+ files.append(argv[index])
41
+ index += 1
42
+ return pattern, files
43
+
44
+
45
+ def main():
46
+ pattern, files = parse_args(sys.argv[1:])
47
+ sys.path.insert(0, str(Path.cwd()))
48
+ loader = unittest.TestLoader()
49
+ suite = unittest.TestSuite()
50
+ compiled = re.compile(pattern) if pattern else None
51
+ for file in files:
52
+ loaded = loader.loadTestsFromModule(load(Path(file)))
53
+ if compiled is None:
54
+ suite.addTest(loaded)
55
+ else:
56
+ for case in walk(loaded):
57
+ if compiled.fullmatch(case.id()):
58
+ suite.addTest(case)
59
+ cases = [case for case in walk(suite) if case is not None]
60
+ names = [case.id() for case in cases]
61
+ with open(os.devnull, "w") as stream:
62
+ result = unittest.TextTestRunner(stream=stream, verbosity=0).run(suite)
63
+ failed = []
64
+ for case, tb in list(result.failures) + list(result.errors):
65
+ frames = re.findall(r'File "([^"]+)", line (\d+)', tb)
66
+ file, line = frames[-1] if frames else ("", "0")
67
+ failed.append({"name": case.id(), "file": file, "line": int(line)})
68
+ report = {
69
+ "tests": len(names),
70
+ "pass": len(names) - len(failed),
71
+ "fail": len(failed),
72
+ "failed": failed,
73
+ "names": names,
74
+ }
75
+ sys.stdout.write(json.dumps(report) + "\n")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()