@orangepro/orangepro-mcp 0.2.44 → 0.2.45
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/dist/local/analyze/analyzer.js +318 -30
- package/dist/local/analyze/treeSitter/engine.js +1058 -90
- package/dist/local/autoProve.js +208 -102
- package/dist/local/operations.js +65 -16
- package/dist/local/proofDoctor.js +20 -3
- package/dist/local/provenance.js +2 -2
- package/dist/local/score/risk.js +284 -104
- package/dist/local/score/riskConfig.js +17 -1
- package/dist/local/viz/behaviorReportData.js +54 -18
- package/dist/local/viz/behaviorReportHtml.js +12 -1
- package/package.json +1 -1
- package/scripts/spikes/python-dynamic-proof-spike.mjs +399 -103
- package/scripts/spikes/python-mutate.py +72 -24
|
@@ -1,54 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// mutation, and proves only when the mutant fails with a real assertion failure.
|
|
2
|
+
// Python dynamic-proof mechanism. It proves only a targeted sentinel mutation killed
|
|
3
|
+
// by the exact pytest assertion, and otherwise fails closed with redacted diagnostics.
|
|
5
4
|
|
|
6
|
-
import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
7
6
|
import { spawnSync } from "node:child_process";
|
|
8
7
|
import { tmpdir } from "node:os";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { fileURLToPath } from "node:url";
|
|
11
10
|
|
|
12
11
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PROJECT_MARKERS = ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini", ".pytest.ini", "tox.ini"];
|
|
13
13
|
|
|
14
14
|
function usage() {
|
|
15
15
|
return [
|
|
16
|
-
"Usage: node scripts/spikes/python-dynamic-proof-spike.mjs --root <repo> --test <nodeid> --target <rel.py> --func <name> [--mode sentinel|equivalent] [--json]",
|
|
16
|
+
"Usage: node scripts/spikes/python-dynamic-proof-spike.mjs --root <repo> --test <nodeid> --target <rel.py> --func <name> [--mode sentinel|equivalent] [--python-runner auto|<command>] [--skip-preflight] [--preflight] [--json]",
|
|
17
17
|
"",
|
|
18
|
-
"
|
|
18
|
+
"Only an exact pytest function selector and an annotation-aware, type-compatible sentinel may mint proof."
|
|
19
19
|
].join("\n");
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function parseArgs(argv) {
|
|
23
|
-
const out = { mode: "sentinel", json: false, timeoutMs: 30000 };
|
|
23
|
+
const out = { mode: "sentinel", json: false, preflight: false, skipPreflight: false, pythonRunner: "auto", timeoutMs: 30000, explicitTimeout: false };
|
|
24
24
|
for (let i = 0; i < argv.length; i++) {
|
|
25
25
|
const arg = argv[i];
|
|
26
|
-
if (arg === "--json") {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
26
|
+
if (arg === "--json") { out.json = true; continue; }
|
|
27
|
+
if (arg === "--preflight") { out.preflight = true; continue; }
|
|
28
|
+
if (arg === "--skip-preflight") { out.skipPreflight = true; continue; }
|
|
30
29
|
if (!arg.startsWith("--")) throw new Error(`Unexpected positional arg: ${arg}`);
|
|
31
30
|
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
32
31
|
const value = argv[++i];
|
|
33
32
|
if (!value) throw new Error(`Missing value for ${arg}`);
|
|
34
33
|
out[key] = value;
|
|
34
|
+
if (key === "timeoutMs") out.explicitTimeout = true;
|
|
35
35
|
}
|
|
36
|
-
for (const required of ["root", "test", "target", "func"]) {
|
|
36
|
+
for (const required of out.preflight ? ["root", "test"] : ["root", "test", "target", "func"]) {
|
|
37
37
|
if (!out[required]) throw new Error(`Missing required --${required.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
|
|
38
38
|
}
|
|
39
39
|
const timeoutMs = Number(out.timeoutMs);
|
|
40
40
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("--timeout-ms must be positive");
|
|
41
41
|
out.timeoutMs = timeoutMs;
|
|
42
|
-
if (![
|
|
42
|
+
if (!['sentinel', 'equivalent'].includes(out.mode)) throw new Error("--mode must be sentinel or equivalent");
|
|
43
|
+
if (typeof out.pythonRunner !== "string" || out.pythonRunner.trim() === "") throw new Error("--python-runner must be auto or a non-empty command");
|
|
44
|
+
if (out.preflight && out.skipPreflight) throw new Error("--preflight and --skip-preflight cannot be combined");
|
|
43
45
|
return out;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function inside(root, candidate) {
|
|
49
|
+
const rel = path.relative(root, candidate);
|
|
50
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
function contained(root, rel) {
|
|
47
54
|
const absRoot = path.resolve(root);
|
|
48
55
|
const abs = path.resolve(absRoot, rel);
|
|
49
56
|
const relative = path.relative(absRoot, abs);
|
|
50
57
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path escapes root: ${rel}`);
|
|
51
|
-
return { absRoot, abs, rel: relative };
|
|
58
|
+
return { absRoot, abs, rel: relative.split(path.sep).join("/") };
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
function copyRoot(root, dest) {
|
|
@@ -57,6 +64,9 @@ function copyRoot(root, dest) {
|
|
|
57
64
|
dereference: false,
|
|
58
65
|
filter(src) {
|
|
59
66
|
const base = path.basename(src);
|
|
67
|
+
// Cargo build output is not source and may be written concurrently by a
|
|
68
|
+
// native extension build. Keep Cargo manifests and the source itself.
|
|
69
|
+
if (base === "target" && existsSync(path.join(path.dirname(src), "Cargo.toml"))) return false;
|
|
60
70
|
return ![".git", ".orangepro", ".pytest_cache", "__pycache__", ".venv", "venv", "node_modules"].includes(base);
|
|
61
71
|
}
|
|
62
72
|
});
|
|
@@ -64,39 +74,273 @@ function copyRoot(root, dest) {
|
|
|
64
74
|
|
|
65
75
|
function cleanEnv(repoRoot) {
|
|
66
76
|
const keep = {};
|
|
67
|
-
for (const key of ["PATH", "HOME", "SystemRoot", "WINDIR"])
|
|
68
|
-
if (process.env[key]) keep[key] = process.env[key];
|
|
69
|
-
}
|
|
77
|
+
for (const key of ["PATH", "HOME", "SystemRoot", "WINDIR"]) if (process.env[key]) keep[key] = process.env[key];
|
|
70
78
|
keep.PYTHONDONTWRITEBYTECODE = "1";
|
|
71
|
-
keep.
|
|
72
|
-
delete keep.PYTHONPATH;
|
|
73
|
-
if (repoRoot) {
|
|
74
|
-
const srcRoot = path.join(repoRoot, "src");
|
|
75
|
-
if (existsSync(srcRoot)) keep.PYTHONPATH = srcRoot;
|
|
76
|
-
}
|
|
79
|
+
if (repoRoot) keep.PYTHONPATH = [repoRoot, ...(existsSync(path.join(repoRoot, "src")) ? [path.join(repoRoot, "src")] : [])].join(path.delimiter);
|
|
77
80
|
return keep;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
/** The nearest bounded project that owns the selected test file. */
|
|
84
|
+
function pythonProjectRoot(repoRoot, testRel) {
|
|
85
|
+
const root = path.resolve(repoRoot);
|
|
86
|
+
const testAbs = path.resolve(root, testRel.split("::", 1)[0]);
|
|
87
|
+
if (!inside(root, testAbs)) throw new Error(`Path escapes root: ${testRel}`);
|
|
88
|
+
let dir = path.dirname(testAbs);
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (PROJECT_MARKERS.some((marker) => existsSync(path.join(dir, marker)))) return dir;
|
|
91
|
+
if (dir === root) return root;
|
|
92
|
+
const parent = path.dirname(dir);
|
|
93
|
+
if (parent === dir || !inside(root, parent)) return root;
|
|
94
|
+
dir = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pyprojectUsesUv(file) {
|
|
99
|
+
try { return /^\s*\[tool\.uv\]/m.test(readFileSync(file, "utf8")); } catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function commandAvailable(command, env) {
|
|
103
|
+
const result = spawnSync(command, ["--version"], { env, encoding: "utf8", stdio: ["ignore", "ignore", "ignore"], timeout: 5000 });
|
|
104
|
+
return !result.error && typeof result.status === "number";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parse a configured command into argv without invoking a shell. */
|
|
108
|
+
function commandArgv(command) {
|
|
109
|
+
const argv = [];
|
|
110
|
+
let token = "";
|
|
111
|
+
let quote = null;
|
|
112
|
+
let escaped = false;
|
|
113
|
+
let started = false;
|
|
114
|
+
for (const ch of command.trim()) {
|
|
115
|
+
if (ch === "\0") throw new Error("python runner command contains NUL");
|
|
116
|
+
if (escaped) {
|
|
117
|
+
token += ch;
|
|
118
|
+
escaped = false;
|
|
119
|
+
started = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (quote === "'") {
|
|
123
|
+
if (ch === "'") quote = null;
|
|
124
|
+
else token += ch;
|
|
125
|
+
started = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (quote === '"') {
|
|
129
|
+
if (ch === '"') quote = null;
|
|
130
|
+
else if (ch === "\\") escaped = true;
|
|
131
|
+
else token += ch;
|
|
132
|
+
started = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ch === "'" || ch === '"') {
|
|
136
|
+
quote = ch;
|
|
137
|
+
started = true;
|
|
138
|
+
} else if (ch === "\\") {
|
|
139
|
+
escaped = true;
|
|
140
|
+
started = true;
|
|
141
|
+
} else if (/\s/.test(ch)) {
|
|
142
|
+
if (started) {
|
|
143
|
+
argv.push(token);
|
|
144
|
+
token = "";
|
|
145
|
+
started = false;
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
token += ch;
|
|
149
|
+
started = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (escaped || quote) throw new Error("python runner command has an unterminated quote or escape");
|
|
153
|
+
if (started) argv.push(token);
|
|
154
|
+
if (!argv[0]) throw new Error("python runner command is empty");
|
|
155
|
+
return argv;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ancestorDirs(projectRoot, repoRoot) {
|
|
159
|
+
const dirs = [];
|
|
160
|
+
let dir = projectRoot;
|
|
161
|
+
for (;;) {
|
|
162
|
+
dirs.push(dir);
|
|
163
|
+
if (dir === repoRoot) break;
|
|
164
|
+
const parent = path.dirname(dir);
|
|
165
|
+
if (parent === dir || !inside(repoRoot, parent)) break;
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
return dirs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Select one manager from the test project upward (bounded by the copied repo). The
|
|
173
|
+
* returned object is immutable by convention and reused verbatim for baseline/mutant.
|
|
174
|
+
*/
|
|
175
|
+
function pythonRunnerPlan(repoRoot, projectRoot, sourceRepoRoot, sourceProjectRoot, configuredRunner) {
|
|
176
|
+
const root = path.resolve(repoRoot);
|
|
177
|
+
const env = cleanEnv(projectRoot);
|
|
178
|
+
// A copied workspace must import the copied source, not the user's installed
|
|
179
|
+
// project. Keep the bounded project first and include its containing repo.
|
|
180
|
+
env.PYTHONPATH = [...new Set([projectRoot, root, ...(existsSync(path.join(projectRoot, "src")) ? [path.join(projectRoot, "src")] : []), ...(existsSync(path.join(root, "src")) ? [path.join(root, "src")] : [])])].join(path.delimiter);
|
|
181
|
+
const fallback = [];
|
|
182
|
+
const base = { cwd: projectRoot, displayCwd: sourceProjectRoot, env, fallback };
|
|
183
|
+
if (configuredRunner !== "auto") {
|
|
184
|
+
const argv = commandArgv(configuredRunner);
|
|
185
|
+
let command = argv[0];
|
|
186
|
+
if ((command.includes("/") || command.includes(path.sep)) && !path.isAbsolute(command)) {
|
|
187
|
+
const sourceCommand = path.resolve(sourceProjectRoot, command);
|
|
188
|
+
if (existsSync(sourceCommand)) command = sourceCommand;
|
|
189
|
+
}
|
|
190
|
+
return { ...base, command, args: argv.slice(1), manager: "custom" };
|
|
191
|
+
}
|
|
192
|
+
// Reuse an original prepared interpreter before even considering uv. A locked
|
|
193
|
+
// uv invocation can still build the copied workspace, invalidating the budget.
|
|
194
|
+
for (const dir of [...new Set([sourceProjectRoot, sourceRepoRoot])]) {
|
|
195
|
+
const python = path.join(dir, ".venv", "bin", "python");
|
|
196
|
+
if (existsSync(python)) return { ...base, command: python, args: ["-m", "pytest"], manager: "venv", environment: "existing" };
|
|
197
|
+
}
|
|
198
|
+
const dirs = ancestorDirs(projectRoot, root);
|
|
199
|
+
const sourceDirs = ancestorDirs(sourceProjectRoot, path.resolve(sourceRepoRoot));
|
|
200
|
+
const firstWith = (marker) => dirs.find((dir) => existsSync(path.join(dir, marker)));
|
|
201
|
+
const firstUvConfig = dirs.find((dir) => pyprojectUsesUv(path.join(dir, "pyproject.toml")));
|
|
202
|
+
const external = [
|
|
203
|
+
{ found: firstWith("uv.lock"), command: "uv", args: ["run", "--frozen", "python", "-m", "pytest"], manager: "uv" },
|
|
204
|
+
{ found: firstUvConfig, command: "uv", args: ["run", "python", "-m", "pytest"], manager: "uv" },
|
|
205
|
+
{ found: firstWith("poetry.lock"), command: "poetry", args: ["run", "python", "-m", "pytest"], manager: "poetry" },
|
|
206
|
+
{ found: firstWith("pdm.lock"), command: "pdm", args: ["run", "python", "-m", "pytest"], manager: "pdm" }
|
|
207
|
+
];
|
|
208
|
+
for (const candidate of external) {
|
|
209
|
+
if (!candidate.found) continue;
|
|
210
|
+
if (commandAvailable(candidate.command, env)) {
|
|
211
|
+
if (candidate.manager !== "uv") return { ...base, command: candidate.command, args: candidate.args, manager: candidate.manager };
|
|
212
|
+
const sourceDir = sourceDirs[dirs.indexOf(candidate.found)];
|
|
213
|
+
const sourceVenv = [sourceProjectRoot, sourceDir].filter(Boolean).flatMap((dir) => [".venv", "venv"].map((name) => path.join(dir, name))).find((dir) => existsSync(path.join(dir, "bin", "python")));
|
|
214
|
+
if (sourceVenv) {
|
|
215
|
+
// uv run --frozen still syncs and rebuilds the local project. --no-sync
|
|
216
|
+
// against an existing matching environment leaves the user environment
|
|
217
|
+
// untouched; PYTHONPATH points at the isolated source copy above.
|
|
218
|
+
env.UV_PROJECT_ENVIRONMENT = sourceVenv;
|
|
219
|
+
return { ...base, command: "uv", args: ["run", "--no-sync", ...(candidate.args.includes("--frozen") ? ["--frozen"] : []), "python", "-m", "pytest"], manager: "uv", environment: "existing" };
|
|
220
|
+
}
|
|
221
|
+
// Install locked dependencies into the disposable copy without compiling
|
|
222
|
+
// the root project or any workspace member. A missing import fails closed.
|
|
223
|
+
return { ...base, command: "uv", args: ["run", "--no-sync", ...(candidate.args.includes("--frozen") ? ["--frozen"] : []), "python", "-m", "pytest"], manager: "uv", environment: "scratch", setupArgs: ["sync", ...(candidate.args.includes("--frozen") ? ["--frozen"] : []), "--no-install-workspace"] };
|
|
224
|
+
}
|
|
225
|
+
fallback.push(`${candidate.manager} marker found but ${candidate.command} is unavailable; falling back`);
|
|
226
|
+
}
|
|
227
|
+
for (const venv of [".venv", "venv"]) {
|
|
228
|
+
const sourceDir = sourceDirs.find((dir) => existsSync(path.join(dir, venv, "bin", "python")));
|
|
229
|
+
if (sourceDir) return { ...base, command: path.join(sourceDir, venv, "bin", "python"), args: ["-m", "pytest"], manager: venv };
|
|
230
|
+
}
|
|
231
|
+
const fallbackPython = commandAvailable("python", env)
|
|
232
|
+
? "python"
|
|
233
|
+
: commandAvailable("python3", env)
|
|
234
|
+
? "python3"
|
|
235
|
+
: "python";
|
|
236
|
+
if (fallbackPython === "python3") fallback.push("python is unavailable; using python3");
|
|
237
|
+
return { ...base, command: fallbackPython, args: ["-m", "pytest"], manager: "fallback" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Derive only unambiguous regular Python modules; namespace packages fail closed. */
|
|
241
|
+
function targetModule(repoRoot, projectRoot, targetAbs) {
|
|
242
|
+
const roots = [...new Set([projectRoot, repoRoot, path.join(projectRoot, "src"), path.join(repoRoot, "src")])];
|
|
243
|
+
const matches = [];
|
|
244
|
+
for (const root of roots) {
|
|
245
|
+
if (!inside(root, targetAbs)) continue;
|
|
246
|
+
const parts = path.relative(root, targetAbs).split(path.sep);
|
|
247
|
+
if (!parts.every((part) => /^[A-Za-z_]\w*(?:\.py)?$/.test(part)) || !parts.at(-1)?.endsWith(".py")) continue;
|
|
248
|
+
const file = parts.pop();
|
|
249
|
+
if (parts.some((_, i) => !existsSync(path.join(root, ...parts.slice(0, i + 1), "__init__.py")))) continue;
|
|
250
|
+
const name = file === "__init__.py" ? parts.join(".") : [...parts, file.slice(0, -3)].join(".");
|
|
251
|
+
if (name) matches.push(name);
|
|
252
|
+
}
|
|
253
|
+
return new Set(matches).size === 1 ? matches[0] : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkTargetImport(plan, moduleName, copiedTarget, timeoutMs) {
|
|
257
|
+
const pytestIndex = plan.args.findIndex((arg, i) => arg === "-m" && plan.args[i + 1] === "pytest");
|
|
258
|
+
const prefix = pytestIndex >= 0 ? plan.args.slice(0, pytestIndex) : plan.args;
|
|
259
|
+
const probe = runCommand(plan, [...prefix, "-c", "import importlib,sys; m=importlib.import_module(sys.argv[1]); print(getattr(m, '__file__', '') or '')", moduleName], timeoutMs);
|
|
260
|
+
let matches = false;
|
|
261
|
+
try {
|
|
262
|
+
const imported = probe.stdout.trim().split(/\r?\n/).at(-1);
|
|
263
|
+
matches = probe.exitCode === 0 && !!imported && realpathSync(imported) === realpathSync(copiedTarget);
|
|
264
|
+
} catch { /* Missing file and namespace imports cannot prove a copied target. */ }
|
|
265
|
+
return { probe, matches };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function redactedTail(text) {
|
|
269
|
+
return text.split(/\r?\n/).slice(-40).map((line) =>
|
|
270
|
+
line
|
|
271
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----.*$/gi, "<redacted:private-key>")
|
|
272
|
+
.replace(/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, "<redacted:anthropic-key>")
|
|
273
|
+
.replace(/\bsk-[A-Za-z0-9]{20,}\b/g, "<redacted:openai-key>")
|
|
274
|
+
.replace(/\bgh[po]_[A-Za-z0-9]{20,}\b/g, "<redacted:github-token>")
|
|
275
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "<redacted:aws-access-key>")
|
|
276
|
+
.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "<redacted:slack-token>")
|
|
277
|
+
.replace(/\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*["']?[^\s"']{8,}/gi, "<redacted:credential>")
|
|
278
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer <redacted:credential>")
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function diagnostic(run, failureClass) {
|
|
283
|
+
return {
|
|
284
|
+
command: [run.command, ...run.args].join(" "),
|
|
285
|
+
cwd: run.displayCwd,
|
|
286
|
+
exit_code: run.exitCode,
|
|
287
|
+
duration_ms: run.durationMs,
|
|
288
|
+
...(failureClass ? { failure_class: failureClass } : {}),
|
|
289
|
+
stdout_tail: redactedTail(run.stdout),
|
|
290
|
+
stderr_tail: redactedTail(run.stderr),
|
|
291
|
+
...(run.fallback.length ? { runner_fallback: run.fallback } : {})
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function diagnosticFailureClass(run, classified, phase) {
|
|
296
|
+
if (run.timedOut) return "timeout";
|
|
297
|
+
if (classified.kind === "collectionError") return "collection_error";
|
|
298
|
+
if (phase === "preflight" && (run.spawnError || classified.kind !== "passed")) return "env_unavailable";
|
|
299
|
+
if (classified.kind !== "passed") return "test_failed";
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runCommand(plan, args, timeoutMs) {
|
|
304
|
+
const started = Date.now();
|
|
305
|
+
const result = spawnSync(plan.command, args, {
|
|
306
|
+
cwd: plan.cwd,
|
|
307
|
+
env: plan.env,
|
|
84
308
|
encoding: "utf8",
|
|
85
309
|
stdio: ["ignore", "pipe", "pipe"],
|
|
310
|
+
// A timed-out environment manager may leave compiler children writing to
|
|
311
|
+
// the scratch copy. Isolate its process group so they can be stopped too.
|
|
312
|
+
detached: process.platform !== "win32",
|
|
86
313
|
timeout: timeoutMs
|
|
87
314
|
});
|
|
315
|
+
if (result.error?.code === "ETIMEDOUT" && result.pid && process.platform !== "win32") {
|
|
316
|
+
try { process.kill(-result.pid, "SIGKILL"); } catch (error) {
|
|
317
|
+
if (error?.code !== "ESRCH") throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
88
320
|
const stdout = result.stdout ?? "";
|
|
89
321
|
const stderr = result.stderr ?? "";
|
|
90
322
|
return {
|
|
323
|
+
command: plan.command,
|
|
324
|
+
args,
|
|
325
|
+
cwd: plan.cwd,
|
|
326
|
+
displayCwd: plan.displayCwd,
|
|
327
|
+
fallback: plan.fallback,
|
|
328
|
+
manager: plan.manager,
|
|
91
329
|
exitCode: result.status ?? null,
|
|
92
330
|
signal: result.signal ?? null,
|
|
93
331
|
timedOut: Boolean(result.error && result.error.code === "ETIMEDOUT"),
|
|
332
|
+
spawnError: result.error?.code ?? null,
|
|
333
|
+
durationMs: Date.now() - started,
|
|
94
334
|
stdout,
|
|
95
335
|
stderr,
|
|
96
336
|
output: `${stdout}\n${stderr}`
|
|
97
337
|
};
|
|
98
338
|
}
|
|
99
339
|
|
|
340
|
+
function runPytest(plan, nodeid, timeoutMs, collectOnly = false) {
|
|
341
|
+
return runCommand(plan, [...plan.args, "-q", ...(collectOnly ? ["--collect-only"] : []), nodeid, "--tb=short"], timeoutMs);
|
|
342
|
+
}
|
|
343
|
+
|
|
100
344
|
function exactNodeIdPattern(nodeid) {
|
|
101
345
|
const escaped = nodeid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\//g, "[/\\\\]");
|
|
102
346
|
const selectedParamSet = /\[[^\]]+\]$/.test(nodeid);
|
|
@@ -105,140 +349,192 @@ function exactNodeIdPattern(nodeid) {
|
|
|
105
349
|
|
|
106
350
|
function isExactPytestNodeId(nodeid) {
|
|
107
351
|
const parts = nodeid.split("::");
|
|
108
|
-
|
|
109
|
-
const last = parts[parts.length - 1];
|
|
110
|
-
return /^test[A-Za-z0-9_]*(?:\[.+\])?$/.test(last);
|
|
352
|
+
return parts.length >= 2 && /^test[A-Za-z0-9_]*(?:\[.+\])?$/.test(parts[parts.length - 1]);
|
|
111
353
|
}
|
|
112
354
|
|
|
113
355
|
function classifyPytest(run, nodeid) {
|
|
114
356
|
if (run.timedOut) return { kind: "otherError", reason: "pytest timed out" };
|
|
115
357
|
if (run.exitCode === 0) return { kind: "passed" };
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
/(^|\n)ERROR(?:S)?(?:\s|$)/.test(normalized) ||
|
|
120
|
-
/(^|\n)ERROR\s+collecting\s+/i.test(normalized) ||
|
|
121
|
-
/(^|\n)ImportError\b|(^|\n)ModuleNotFoundError\b|(^|\n)SyntaxError\b/i.test(normalized);
|
|
122
|
-
if (hasErrorSummary) return { kind: "otherError", reason: "pytest reported collection/import/setup error" };
|
|
358
|
+
const normalized = run.output.replace(/\r/g, "");
|
|
359
|
+
const hasErrorSummary = /(^|\n)ERROR(?:S)?(?:\s|$)/.test(normalized) || /(^|\n)ERROR\s+collecting\s+/i.test(normalized) || /(^|\n)ImportError\b|(^|\n)ModuleNotFoundError\b|(^|\n)SyntaxError\b/i.test(normalized);
|
|
360
|
+
if (hasErrorSummary) return { kind: "collectionError", reason: "pytest reported collection/import/setup error" };
|
|
123
361
|
const failedTarget = exactNodeIdPattern(nodeid).test(normalized);
|
|
124
362
|
const exceptionLine = normalized.match(/(?:^|\n)E\s+([A-Za-z_][A-Za-z0-9_.]*):/);
|
|
125
|
-
if (exceptionLine && exceptionLine[1] !== "AssertionError" && !exceptionLine[1].endsWith(".AssertionError")) {
|
|
126
|
-
return { kind: "otherError", reason: "pytest failure raised before a trusted assertion mismatch" };
|
|
127
|
-
}
|
|
363
|
+
if (exceptionLine && exceptionLine[1] !== "AssertionError" && !exceptionLine[1].endsWith(".AssertionError")) return { kind: "otherError", reason: "pytest failure raised before a trusted assertion mismatch" };
|
|
128
364
|
const assertionLike = /\bAssertionError\b|(^|\n)E\s+assert\s/m.test(normalized);
|
|
129
365
|
if (run.exitCode === 1 && failedTarget && assertionLike) return { kind: "assertionFailure" };
|
|
130
366
|
return { kind: "otherError", reason: "pytest failure was not a trusted assertion failure" };
|
|
131
367
|
}
|
|
132
368
|
|
|
133
|
-
|
|
369
|
+
/** Only direct literal equality to the named call can constrain an unannotated type. */
|
|
370
|
+
function observedOracleType(repoRoot, nodeid, func) {
|
|
371
|
+
const testPath = path.resolve(repoRoot, nodeid.split("::", 1)[0]);
|
|
372
|
+
let source;
|
|
373
|
+
try { source = readFileSync(testPath, "utf8"); } catch { return null; }
|
|
374
|
+
const escaped = func.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
375
|
+
const classify = (literal) => {
|
|
376
|
+
const value = literal.trim();
|
|
377
|
+
if (/^(True|False)$/.test(value)) return "bool";
|
|
378
|
+
if (/^(['\"]).*\1$/.test(value)) return "str";
|
|
379
|
+
if (/^(\[\]|\{\}|\(\)|set\(\))$/.test(value)) return "collection";
|
|
380
|
+
if (value === "None") return "optional";
|
|
381
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return "scalar";
|
|
382
|
+
return null;
|
|
383
|
+
};
|
|
384
|
+
const direct = new RegExp(`\\bassert\\s+(?:${escaped}\\s*\\([^\\n]*\\)\\s*==\\s*([^#\\n]+)|([^#\\n]+)\\s*==\\s*${escaped}\\s*\\([^\\n]*\\))`, "g");
|
|
385
|
+
const types = [];
|
|
386
|
+
for (const match of source.matchAll(direct)) {
|
|
387
|
+
const type = classify(match[1] ?? match[2] ?? "");
|
|
388
|
+
if (type) types.push(type);
|
|
389
|
+
}
|
|
390
|
+
return types.length > 0 && types.every((type) => type === types[0]) ? types[0] : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function mutate(repoRoot, targetRel, func, mode, timeoutMs, oracleType) {
|
|
134
394
|
const helper = path.join(here, "python-mutate.py");
|
|
135
|
-
const
|
|
136
|
-
|
|
395
|
+
const args = [helper, "--file", path.join(repoRoot, targetRel), "--func", func, "--mode", mode];
|
|
396
|
+
if (oracleType) args.push("--oracle-type", oracleType);
|
|
397
|
+
const result = spawnSync("python3", args, {
|
|
137
398
|
cwd: repoRoot,
|
|
138
|
-
env: cleanEnv(),
|
|
399
|
+
env: cleanEnv(repoRoot),
|
|
139
400
|
encoding: "utf8",
|
|
140
401
|
stdio: ["ignore", "pipe", "pipe"],
|
|
141
402
|
timeout: timeoutMs
|
|
142
403
|
});
|
|
143
|
-
if (result.status !== 0 || result.error) {
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
try {
|
|
147
|
-
return JSON.parse(result.stdout || "{}");
|
|
148
|
-
} catch {
|
|
149
|
-
return { ok: false, reason: "mutator produced invalid json" };
|
|
150
|
-
}
|
|
404
|
+
if (result.status !== 0 || result.error) return { ok: false, reason: result.error?.message || result.stderr || "mutator failed" };
|
|
405
|
+
try { return JSON.parse(result.stdout || "{}"); } catch { return { ok: false, reason: "mutator produced invalid json" }; }
|
|
151
406
|
}
|
|
152
407
|
|
|
153
408
|
function summarize(run) {
|
|
154
|
-
const line = run.output
|
|
155
|
-
.split(/\r?\n/)
|
|
156
|
-
.map((s) => s.trim())
|
|
157
|
-
.find(Boolean);
|
|
409
|
+
const line = run.output.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
158
410
|
return line ? line.slice(0, 240) : "";
|
|
159
411
|
}
|
|
160
412
|
|
|
413
|
+
function emit(verdict, pretty) { process.stdout.write(JSON.stringify(verdict, null, pretty ? 2 : 0)); }
|
|
414
|
+
|
|
161
415
|
function main() {
|
|
162
416
|
let args;
|
|
163
|
-
try {
|
|
164
|
-
args = parseArgs(process.argv.slice(2));
|
|
165
|
-
} catch (error) {
|
|
166
|
-
console.error(error.message);
|
|
167
|
-
console.error(usage());
|
|
168
|
-
process.exit(2);
|
|
169
|
-
}
|
|
417
|
+
try { args = parseArgs(process.argv.slice(2)); } catch (error) { console.error(error.message); console.error(usage()); process.exit(2); }
|
|
170
418
|
|
|
171
419
|
const root = path.resolve(args.root);
|
|
172
|
-
const target = contained(root, args.target);
|
|
173
420
|
const testPath = args.test.split("::", 1)[0];
|
|
174
421
|
const test = contained(root, testPath);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
test: args.test,
|
|
182
|
-
target: target.rel,
|
|
183
|
-
func: args.func,
|
|
184
|
-
mutant: { assertionFailure: false }
|
|
185
|
-
};
|
|
186
|
-
process.stdout.write(JSON.stringify(verdict, null, args.json ? 2 : 0));
|
|
422
|
+
const projectRoot = pythonProjectRoot(root, args.test);
|
|
423
|
+
const projectRootRel = path.relative(root, projectRoot).split(path.sep).join("/") || ".";
|
|
424
|
+
const relativeTest = path.relative(projectRoot, test.abs).split(path.sep).join("/") + args.test.slice(testPath.length);
|
|
425
|
+
const target = args.preflight ? null : contained(root, args.target);
|
|
426
|
+
if (!args.preflight && !isExactPytestNodeId(args.test)) {
|
|
427
|
+
emit({ status: "unrunnable", proven: false, category: "mutation_unsupported", reason: "pytest selector must identify exactly one test function", runner: "pytest", project_root: projectRootRel, test: args.test, target: target.rel, func: args.func, mutant: { assertionFailure: false } }, args.json);
|
|
187
428
|
return;
|
|
188
429
|
}
|
|
430
|
+
|
|
189
431
|
const tmp = mkdtempSync(path.join(tmpdir(), "opro-python-proof-"));
|
|
190
432
|
const repoRoot = path.join(tmp, "repo");
|
|
191
|
-
|
|
192
433
|
try {
|
|
193
434
|
copyRoot(root, repoRoot);
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
435
|
+
const copiedProjectRoot = path.resolve(repoRoot, projectRootRel);
|
|
436
|
+
let plan = pythonRunnerPlan(repoRoot, copiedProjectRoot, root, projectRoot, args.pythonRunner.trim());
|
|
437
|
+
// An isolated copy needs to create its own environment before the first test.
|
|
438
|
+
// Cold dependency installation is not the test's 30-second execution limit.
|
|
439
|
+
// An explicit --timeout-ms still caps every phase as the caller requested.
|
|
440
|
+
const preflightTimeoutMs = args.explicitTimeout ? args.timeoutMs : 300000;
|
|
441
|
+
if (plan.setupArgs) {
|
|
442
|
+
const setup = runCommand(plan, plan.setupArgs, preflightTimeoutMs);
|
|
443
|
+
if (setup.exitCode !== 0 || setup.spawnError) {
|
|
444
|
+
const failureClass = setup.timedOut ? "timeout" : "env_unavailable";
|
|
445
|
+
emit({ status: args.preflight ? "environment_unavailable" : "unrunnable", proven: false, category: "environment_unavailable", reason: `environment_unavailable: Python project dependencies could not be prepared (${failureClass})`, runner: "pytest", project_root: projectRootRel, diagnostics: diagnostic(setup, failureClass), mutant: { assertionFailure: false } }, args.json);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
let preflight = args.skipPreflight ? null : runPytest(plan, relativeTest, preflightTimeoutMs, true);
|
|
450
|
+
// A nested project may have a runtime-only venv, while pytest lives in the
|
|
451
|
+
// workspace dev environment. Retry collection with the root interpreter.
|
|
452
|
+
if (preflight && projectRoot !== root && args.pythonRunner === "auto" && /No module named pytest/.test(preflight.output)) {
|
|
453
|
+
const rootPython = path.join(root, ".venv", "bin", "python");
|
|
454
|
+
if (existsSync(rootPython) && rootPython !== plan.command) {
|
|
455
|
+
plan = { ...plan, command: rootPython, args: ["-m", "pytest"], fallback: [...plan.fallback, "subproject pytest unavailable; using workspace interpreter"] };
|
|
456
|
+
preflight = runPytest(plan, relativeTest, preflightTimeoutMs, true);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const preflightClass = preflight ? classifyPytest(preflight, relativeTest) : { kind: "passed" };
|
|
460
|
+
if (args.preflight) {
|
|
461
|
+
const available = preflightClass.kind === "passed";
|
|
462
|
+
const preflightFailure = diagnosticFailureClass(preflight, preflightClass, "preflight");
|
|
463
|
+
emit({
|
|
464
|
+
status: available ? "preflight_ok" : "environment_unavailable",
|
|
199
465
|
proven: false,
|
|
200
|
-
|
|
466
|
+
...(available ? {} : { category: preflightFailure === "collection_error" ? "collection_error" : "environment_unavailable" }),
|
|
467
|
+
reason: available ? "Python pytest collection succeeded." : `environment_unavailable: pytest collection failed (${preflightClass.reason ?? "unknown"})`,
|
|
201
468
|
runner: "pytest",
|
|
202
|
-
|
|
469
|
+
project_root: projectRootRel,
|
|
470
|
+
diagnostics: diagnostic(preflight, preflightFailure),
|
|
203
471
|
mutant: { assertionFailure: false }
|
|
204
|
-
};
|
|
205
|
-
|
|
472
|
+
}, args.json);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (preflightClass.kind !== "passed") {
|
|
476
|
+
const preflightFailure = diagnosticFailureClass(preflight, preflightClass, "preflight");
|
|
477
|
+
const category = preflightFailure === "collection_error" ? "collection_error" : "environment_unavailable";
|
|
478
|
+
emit({ status: "unrunnable", proven: false, category, reason: `${category}: pytest collection failed before proof could run`, runner: "pytest", project_root: projectRootRel, baseline: { exitCode: preflight.exitCode, timedOut: preflight.timedOut, failureSummary: summarize(preflight) }, diagnostics: diagnostic(preflight, preflightFailure), mutant: { assertionFailure: false } }, args.json);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const copiedTarget = path.join(repoRoot, target.rel);
|
|
483
|
+
const moduleName = targetModule(repoRoot, copiedProjectRoot, copiedTarget);
|
|
484
|
+
if (!moduleName) {
|
|
485
|
+
emit({ status: "unrunnable", proven: false, category: "environment_unavailable", reason: "target_not_loaded_from_sandbox: ambiguous or unsupported Python module path", runner: "pytest", project_root: projectRootRel, diagnostics: { command: "module resolution", cwd: projectRoot, exit_code: null, duration_ms: 0, failure_class: "target_not_loaded_from_sandbox", stdout_tail: [], stderr_tail: [] }, mutant: { assertionFailure: false } }, args.json);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const imported = checkTargetImport(plan, moduleName, copiedTarget, args.timeoutMs);
|
|
489
|
+
if (!imported.matches) {
|
|
490
|
+
emit({ status: "unrunnable", proven: false, category: "environment_unavailable", reason: "target_not_loaded_from_sandbox: target import did not resolve to the copied source", runner: "pytest", project_root: projectRootRel, diagnostics: diagnostic(imported.probe, "target_not_loaded_from_sandbox"), mutant: { assertionFailure: false } }, args.json);
|
|
206
491
|
return;
|
|
207
492
|
}
|
|
493
|
+
const targetImport = { verified: true, module: moduleName };
|
|
208
494
|
|
|
209
|
-
|
|
495
|
+
// Baseline and mutant use the exact same command/cwd/env plan after collection.
|
|
496
|
+
const baseline = runPytest(plan, relativeTest, args.timeoutMs);
|
|
497
|
+
const baselineClass = classifyPytest(baseline, relativeTest);
|
|
498
|
+
if (baselineClass.kind !== "passed") {
|
|
499
|
+
emit({ status: "unrunnable", proven: false, reason: "baseline test did not pass", runner: "pytest", project_root: projectRootRel, baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut, failureSummary: summarize(baseline) }, diagnostics: diagnostic(baseline, diagnosticFailureClass(baseline, baselineClass, "baseline")), mutant: { assertionFailure: false } }, args.json);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const mutation = mutate(repoRoot, target.rel, args.func, args.mode, args.timeoutMs, observedOracleType(copiedProjectRoot, relativeTest, args.func));
|
|
210
504
|
if (!mutation.ok) {
|
|
211
|
-
|
|
212
|
-
status: "unrunnable",
|
|
213
|
-
proven: false,
|
|
214
|
-
reason: `mutation refused: ${mutation.reason}`,
|
|
215
|
-
runner: "pytest",
|
|
216
|
-
baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut },
|
|
217
|
-
mutant: { assertionFailure: false }
|
|
218
|
-
};
|
|
219
|
-
process.stdout.write(JSON.stringify(verdict, null, args.json ? 2 : 0));
|
|
505
|
+
emit({ status: "unrunnable", proven: false, category: "mutation_unsupported", reason: `mutation_unsupported: ${mutation.reason}`, runner: "pytest", project_root: projectRootRel, baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut }, diagnostics: diagnostic(baseline), mutant: { assertionFailure: false } }, args.json);
|
|
220
506
|
return;
|
|
221
507
|
}
|
|
222
508
|
|
|
223
|
-
const mutant = runPytest(
|
|
224
|
-
const mutantClass = classifyPytest(mutant,
|
|
509
|
+
const mutant = runPytest(plan, relativeTest, args.timeoutMs);
|
|
510
|
+
const mutantClass = classifyPytest(mutant, relativeTest);
|
|
225
511
|
const proven = mutantClass.kind === "assertionFailure";
|
|
226
512
|
const survived = mutantClass.kind === "passed";
|
|
227
|
-
|
|
513
|
+
emit({
|
|
228
514
|
status: proven ? "proven" : survived ? "associated_survived" : "unrunnable",
|
|
229
515
|
proven,
|
|
230
516
|
reason: proven ? "mutant failed at a trusted pytest assertion" : survived ? "mutant survived" : mutantClass.reason,
|
|
231
517
|
runner: "pytest",
|
|
232
518
|
replacementMode: args.mode,
|
|
519
|
+
project_root: projectRootRel,
|
|
233
520
|
test: args.test,
|
|
234
521
|
target: target.rel,
|
|
235
522
|
func: args.func,
|
|
523
|
+
target_import: targetImport,
|
|
236
524
|
baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut },
|
|
237
|
-
mutant: { exitCode: mutant.exitCode, timedOut: mutant.timedOut, assertionFailure: proven, failureSummary: proven ? "" : summarize(mutant) }
|
|
238
|
-
|
|
239
|
-
|
|
525
|
+
mutant: { exitCode: mutant.exitCode, timedOut: mutant.timedOut, assertionFailure: proven, failureSummary: proven ? "" : summarize(mutant) },
|
|
526
|
+
mutation: { sentinel: mutation.sentinel, source: mutation.sentinel_source },
|
|
527
|
+
diagnostics: diagnostic(mutant, diagnosticFailureClass(mutant, mutantClass, "mutant"))
|
|
528
|
+
}, args.json);
|
|
240
529
|
} finally {
|
|
241
|
-
|
|
530
|
+
try {
|
|
531
|
+
rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
532
|
+
} catch {
|
|
533
|
+
// Keep the JSON verdict available, but ensure the caller cannot promote
|
|
534
|
+
// proof if the private scratch copy was not safely removed.
|
|
535
|
+
console.error("Python proof scratch cleanup failed; proof is not valid.");
|
|
536
|
+
process.exitCode = 1;
|
|
537
|
+
}
|
|
242
538
|
}
|
|
243
539
|
}
|
|
244
540
|
|