@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.
@@ -1,54 +1,61 @@
1
1
  #!/usr/bin/env node
2
- // python-dynamic-proof-spike.mjs — Python dynamic-proof mechanism (P-1).
3
- // Runs one pytest node in an isolated copy, applies a minimal AST sentinel
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
- "P-1 supports a single safe shape: one Python def/async def with a block suite. Ambiguous or unsupported shapes fail closed."
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
- out.json = true;
28
- continue;
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 (!["sentinel", "equivalent"].includes(out.mode)) throw new Error("--mode must be sentinel or equivalent");
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.PYTEST_DISABLE_PLUGIN_AUTOLOAD = "1";
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
- function runPytest(repoRoot, nodeid, timeoutMs) {
81
- const result = spawnSync("python3", ["-m", "pytest", "-q", nodeid, "--tb=short"], {
82
- cwd: repoRoot,
83
- env: cleanEnv(repoRoot),
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
- if (parts.length < 2) return false;
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 output = run.output;
117
- const normalized = output.replace(/\r/g, "");
118
- const hasErrorSummary =
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
- function mutate(repoRoot, targetRel, func, mode, timeoutMs) {
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 targetAbs = path.join(repoRoot, targetRel);
136
- const result = spawnSync("python3", [helper, "--file", targetAbs, "--func", func, "--mode", mode], {
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
- return { ok: false, reason: result.error?.message || result.stderr || "mutator failed" };
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
- if (!isExactPytestNodeId(args.test)) {
176
- const verdict = {
177
- status: "unrunnable",
178
- proven: false,
179
- reason: "pytest selector must identify exactly one test function",
180
- runner: "pytest",
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 baseline = runPytest(repoRoot, args.test, args.timeoutMs);
195
- const baselineClass = classifyPytest(baseline, args.test);
196
- if (baselineClass.kind !== "passed") {
197
- const verdict = {
198
- status: "unrunnable",
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
- reason: "baseline test did not pass",
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
- baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut, failureSummary: summarize(baseline) },
469
+ project_root: projectRootRel,
470
+ diagnostics: diagnostic(preflight, preflightFailure),
203
471
  mutant: { assertionFailure: false }
204
- };
205
- process.stdout.write(JSON.stringify(verdict, null, args.json ? 2 : 0));
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
- const mutation = mutate(repoRoot, target.rel, args.func, args.mode, args.timeoutMs);
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
- const verdict = {
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(repoRoot, args.test, args.timeoutMs);
224
- const mutantClass = classifyPytest(mutant, args.test);
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
- const verdict = {
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
- process.stdout.write(JSON.stringify(verdict, null, args.json ? 2 : 0));
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
- rmSync(tmp, { recursive: true, force: true });
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