@orangepro/orangepro-mcp 0.1.0 → 0.2.1

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,264 @@
1
+ #!/usr/bin/env node
2
+ // java-mutate.mjs — AST-based body replacer for the Java dynamic-proof spike (J-1).
3
+ //
4
+ // Locates ONE simplest-shape method `[modifiers] ReturnType name(params) { ... }`
5
+ // by exact name via tree-sitter Java (the SAME grammar the static layer already
6
+ // uses — tree-sitter-wasms + web-tree-sitter, no new dependency) and replaces its
7
+ // BODY with a signature-derived, type-compatible sentinel by splicing the body
8
+ // block's byte range. It is a spike helper only: it writes no product artifacts and
9
+ // is not wired into prove / RTM / mint.
10
+ //
11
+ // J-1 scope is the SIMPLEST SHAPE ONLY: a concrete non-void, non-type-variable
12
+ // return type, EXACTLY one top-level `return <expr>;` (no nested return in an
13
+ // if/loop/try, no second return anywhere), no generics, and no overload ambiguity.
14
+ // Everything else is refused with a distinct reason (deferred to J-2), never
15
+ // silently mutated — the sentinel replaces the whole body, so a method with more
16
+ // than one exit shape or an unknown (type-variable) return is not safe to mutate.
17
+ //
18
+ // Modes:
19
+ // sentinel — replace the body with `{ return <wrong-but-compiling value>; }`.
20
+ // The sentinel is a FIXED value derived from the declared return
21
+ // type, so it can only ever cause a FALSE SURVIVE, never a false
22
+ // Proven — the trust bias is safe by construction.
23
+ // equivalent — leave the body byte-for-byte unchanged (the original source is
24
+ // re-written) so a value-only test still passes -> the orchestrator
25
+ // classifies it associated_survived.
26
+ //
27
+ // Exit codes (distinct, so the Node orchestrator can classify precisely):
28
+ // 0 ok, mutated file written
29
+ // 2 usage / IO / parse error
30
+ // 3 ambiguous: more than one method with that name (overloads)
31
+ // 4 not found: no method with that name
32
+ // 5 out of scope: void, constructor, generic, or no single concrete return
33
+ // (J-2), refused in J-1
34
+ // 6 not mutable: could not derive a type-compatible sentinel for the return type
35
+ //
36
+ // Like the Go helper, it prints a stable MUTATE_ERROR:<code> marker to stderr so a
37
+ // caller that cannot see the process status can still classify.
38
+ import { createRequire } from "node:module";
39
+ import { readFileSync, writeFileSync } from "node:fs";
40
+ import path from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const require = createRequire(import.meta.url);
44
+
45
+ function fail(code, message) {
46
+ process.stderr.write(`MUTATE_ERROR:${code}\n`);
47
+ process.stderr.write(`${message}\n`);
48
+ process.exit(code);
49
+ }
50
+
51
+ function parseArgs(argv) {
52
+ const args = { mode: "sentinel" };
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ if (!arg.startsWith("--")) fail(2, `unexpected positional argument: ${arg}`);
56
+ const key = arg.slice(2);
57
+ const value = argv[i + 1];
58
+ if (value === undefined || value.startsWith("--")) fail(2, `missing value for ${arg}`);
59
+ args[key] = value;
60
+ i += 1;
61
+ }
62
+ if (!args.file || !args.func) {
63
+ fail(2, "usage: node java-mutate.mjs --file <path> --func <name> [--out <path>] [--mode sentinel|equivalent]");
64
+ }
65
+ if (args.mode !== "sentinel" && args.mode !== "equivalent") {
66
+ fail(2, "--mode must be sentinel or equivalent");
67
+ }
68
+ return args;
69
+ }
70
+
71
+ /** Load tree-sitter + the Java grammar exactly as the static analyzer does. */
72
+ async function loadJavaParser() {
73
+ const { Parser, Language } = require("web-tree-sitter");
74
+ await Parser.init();
75
+ const wasm = require.resolve("tree-sitter-wasms/out/tree-sitter-java.wasm");
76
+ const language = await Language.load(wasm);
77
+ const parser = new Parser();
78
+ parser.setLanguage(language);
79
+ return parser;
80
+ }
81
+
82
+ // A method is J-1 SIMPLEST-SHAPE when: it is a real method_declaration (not a
83
+ // constructor — those are a distinct node type and never match here), its return
84
+ // type is a concrete non-void, non-type-variable type, it declares no type
85
+ // parameters (no generics), and its body is EXACTLY one top-level `return <expr>;`
86
+ // — no nested return (inside an if/loop/try/lambda) and no second return anywhere.
87
+ // Anything else is out of scope for J-1: the sentinel replaces the WHOLE body, so a
88
+ // method whose real control flow has more than one exit shape (or a return buried in
89
+ // a branch) is not a value we can safely mutate 0->1.
90
+ function topLevelReturns(body) {
91
+ // Direct named children of the body block that are return statements.
92
+ return body.namedChildren.filter((c) => c.type === "return_statement");
93
+ }
94
+
95
+ function allReturns(body) {
96
+ // Every return anywhere in the body — nested ones included.
97
+ return body.descendantsOfType("return_statement");
98
+ }
99
+
100
+ // The set of type-parameter names in scope for a method: its own `<T>` plus every
101
+ // enclosing class/interface `<T>`. A return type that names one of these is a type
102
+ // VARIABLE — no type-derived sentinel is safe (the runtime type is unknown), so it
103
+ // is refused as out of scope for J-1.
104
+ function typeParamNamesInScope(method) {
105
+ const names = new Set();
106
+ const collect = (node) => {
107
+ if (!node) return;
108
+ for (const tp of node.descendantsOfType("type_parameter")) {
109
+ // A type_parameter's identifier child is the variable name (T, U, …).
110
+ const id = tp.namedChildren.find((c) => c.type === "identifier" || c.type === "type_identifier");
111
+ if (id?.text) names.add(id.text);
112
+ }
113
+ };
114
+ // Method-level type parameters.
115
+ const ownTp = method.childForFieldName("type_parameters");
116
+ if (ownTp) collect(ownTp);
117
+ // Enclosing class/interface/record/enum type parameters.
118
+ for (let n = method.parent; n; n = n.parent) {
119
+ if (/_declaration$/.test(n.type)) {
120
+ const tp = n.childForFieldName("type_parameters");
121
+ if (tp) collect(tp);
122
+ }
123
+ }
124
+ return names;
125
+ }
126
+
127
+ // True when the declared return type is a bare type variable (a type_identifier
128
+ // whose text is a type parameter in scope), e.g. `<T> T foo()` or a method that
129
+ // returns the enclosing class's `T`. tree-sitter reports both as a `type_identifier`
130
+ // with no way to distinguish a class from a type variable except the in-scope set.
131
+ function isTypeVariableReturn(typeNode, method) {
132
+ if (typeNode.type !== "type_identifier") return false;
133
+ return typeParamNamesInScope(method).has(typeNode.text);
134
+ }
135
+
136
+ function main() {
137
+ const args = parseArgs(process.argv.slice(2));
138
+ const dst = args.out || args.file;
139
+ let source;
140
+ try {
141
+ source = readFileSync(args.file, "utf8");
142
+ } catch (error) {
143
+ fail(2, `read error: ${error instanceof Error ? error.message : String(error)}`);
144
+ return;
145
+ }
146
+
147
+ return loadJavaParser().then((parser) => {
148
+ let tree;
149
+ try {
150
+ tree = parser.parse(source);
151
+ } catch (error) {
152
+ fail(2, `parse error: ${error instanceof Error ? error.message : String(error)}`);
153
+ return;
154
+ }
155
+ if (!tree) fail(2, "parse produced no tree");
156
+
157
+ const matches = tree.rootNode
158
+ .descendantsOfType("method_declaration")
159
+ .filter((m) => m.childForFieldName("name")?.text === args.func);
160
+
161
+ if (matches.length > 1) {
162
+ fail(3, `ambiguous: ${matches.length} methods named ${JSON.stringify(args.func)} (overloads)`);
163
+ }
164
+ if (matches.length === 0) {
165
+ // Distinguish a constructor of that name from a genuinely missing method.
166
+ const ctor = tree.rootNode
167
+ .descendantsOfType("constructor_declaration")
168
+ .some((c) => c.childForFieldName("name")?.text === args.func);
169
+ if (ctor) fail(5, `out of scope: ${JSON.stringify(args.func)} is a constructor (J-2)`);
170
+ fail(4, `not found: no method named ${JSON.stringify(args.func)}`);
171
+ }
172
+
173
+ const method = matches[0];
174
+ const typeNode = method.childForFieldName("type");
175
+ const body = method.childForFieldName("body");
176
+ if (!typeNode || !body || body.type !== "block") {
177
+ fail(5, `out of scope: ${JSON.stringify(args.func)} has no concrete body/return type (abstract/interface?)`);
178
+ }
179
+ if (typeNode.type === "void_type") {
180
+ fail(5, `out of scope: ${JSON.stringify(args.func)} returns void (J-2)`);
181
+ }
182
+ if (method.descendantsOfType("type_parameters").length > 0) {
183
+ fail(5, `out of scope: ${JSON.stringify(args.func)} is generic (J-2)`);
184
+ }
185
+ // A return type that is a type variable (a method or enclosing-class `<T>`) has
186
+ // no type-derived sentinel that is safe, so refuse it as out of scope.
187
+ if (isTypeVariableReturn(typeNode, method)) {
188
+ fail(5, `out of scope: ${JSON.stringify(args.func)} returns a type variable ${JSON.stringify(typeNode.text)} (J-2)`);
189
+ }
190
+ // Exactly one TOP-LEVEL return and no other return anywhere in the body. A
191
+ // nested return (inside an if/loop/try) has topLevel === 0; a second return
192
+ // anywhere makes all > 1. Both are refused: the sentinel replaces the whole body.
193
+ const topReturns = topLevelReturns(body);
194
+ const everyReturn = allReturns(body);
195
+ if (topReturns.length !== 1 || everyReturn.length !== 1) {
196
+ fail(5, `out of scope: ${JSON.stringify(args.func)} is not a single top-level return (nested/multiple returns) (J-2)`);
197
+ }
198
+
199
+ if (args.mode === "equivalent") {
200
+ // Re-write the original bytes unchanged: a value-only test still passes.
201
+ try {
202
+ writeFileSync(dst, source);
203
+ } catch (error) {
204
+ fail(2, `write error: ${error instanceof Error ? error.message : String(error)}`);
205
+ }
206
+ return;
207
+ }
208
+
209
+ const sentinel = sentinelReturn(typeNode);
210
+ if (sentinel === null) {
211
+ fail(6, `not mutable: cannot derive a type-compatible sentinel for return type ${JSON.stringify(typeNode.text)}`);
212
+ }
213
+
214
+ // Splice the body block's byte range with a minimal `{ return <sentinel>; }`.
215
+ const before = source.slice(0, body.startIndex);
216
+ const after = source.slice(body.endIndex);
217
+ const mutated = `${before}{ return ${sentinel}; }${after}`;
218
+ try {
219
+ writeFileSync(dst, mutated);
220
+ } catch (error) {
221
+ fail(2, `write error: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ });
224
+ }
225
+
226
+ // A FIXED, deliberately-wrong-but-compiling value derived from the declared return
227
+ // type. A fixed sentinel can only cause a FALSE SURVIVE (associated_survived) when
228
+ // the real return happens to equal it — NEVER a false Proven. Returns null when no
229
+ // type-compatible constant can be named (fail closed -> exit 6, never mutated).
230
+ function sentinelReturn(typeNode) {
231
+ const type = typeNode.type;
232
+ const text = typeNode.text;
233
+ if (type === "integral_type") {
234
+ // int / short / byte / long -> a distinct wrong integer. char is integral in
235
+ // the grammar but -999 is not a char; give it a distinct char literal.
236
+ if (text === "char") return "'\\u0000'";
237
+ if (text === "long") return "-999L";
238
+ return "-999";
239
+ }
240
+ if (type === "floating_point_type") {
241
+ return text === "float" ? "-999.0f" : "-999.0";
242
+ }
243
+ if (type === "boolean_type") {
244
+ // A fixed constant. If the real value is false, the mutant SURVIVES (safe); if
245
+ // it is true, the assertion FAILS -> proven. Exactly the trust bias.
246
+ return "false";
247
+ }
248
+ if (type === "type_identifier" && text === "String") {
249
+ return '"__opro_sentinel__"';
250
+ }
251
+ // Any other reference type (a class, an array, a boxed type, ...) -> null is a
252
+ // valid, always-compiling wrong value for a test asserting a non-null result.
253
+ if (
254
+ type === "type_identifier" ||
255
+ type === "array_type" ||
256
+ type === "scoped_type_identifier" ||
257
+ type === "generic_type"
258
+ ) {
259
+ return "null";
260
+ }
261
+ return null;
262
+ }
263
+
264
+ main();
@@ -0,0 +1,245 @@
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.
5
+
6
+ import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ function usage() {
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]",
17
+ "",
18
+ "P-1 supports a single safe shape: one Python def/async def with a block suite. Ambiguous or unsupported shapes fail closed."
19
+ ].join("\n");
20
+ }
21
+
22
+ function parseArgs(argv) {
23
+ const out = { mode: "sentinel", json: false, timeoutMs: 30000 };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i];
26
+ if (arg === "--json") {
27
+ out.json = true;
28
+ continue;
29
+ }
30
+ if (!arg.startsWith("--")) throw new Error(`Unexpected positional arg: ${arg}`);
31
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
32
+ const value = argv[++i];
33
+ if (!value) throw new Error(`Missing value for ${arg}`);
34
+ out[key] = value;
35
+ }
36
+ for (const required of ["root", "test", "target", "func"]) {
37
+ if (!out[required]) throw new Error(`Missing required --${required.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
38
+ }
39
+ const timeoutMs = Number(out.timeoutMs);
40
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("--timeout-ms must be positive");
41
+ out.timeoutMs = timeoutMs;
42
+ if (!["sentinel", "equivalent"].includes(out.mode)) throw new Error("--mode must be sentinel or equivalent");
43
+ return out;
44
+ }
45
+
46
+ function contained(root, rel) {
47
+ const absRoot = path.resolve(root);
48
+ const abs = path.resolve(absRoot, rel);
49
+ const relative = path.relative(absRoot, abs);
50
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path escapes root: ${rel}`);
51
+ return { absRoot, abs, rel: relative };
52
+ }
53
+
54
+ function copyRoot(root, dest) {
55
+ cpSync(root, dest, {
56
+ recursive: true,
57
+ dereference: false,
58
+ filter(src) {
59
+ const base = path.basename(src);
60
+ return ![".git", ".orangepro", ".pytest_cache", "__pycache__", ".venv", "venv", "node_modules"].includes(base);
61
+ }
62
+ });
63
+ }
64
+
65
+ function cleanEnv(repoRoot) {
66
+ const keep = {};
67
+ for (const key of ["PATH", "HOME", "SystemRoot", "WINDIR"]) {
68
+ if (process.env[key]) keep[key] = process.env[key];
69
+ }
70
+ 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
+ }
77
+ return keep;
78
+ }
79
+
80
+ function runPytest(repoRoot, nodeid, timeoutMs) {
81
+ const result = spawnSync("python3", ["-m", "pytest", "-q", nodeid, "--tb=short"], {
82
+ cwd: repoRoot,
83
+ env: cleanEnv(repoRoot),
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ timeout: timeoutMs
87
+ });
88
+ const stdout = result.stdout ?? "";
89
+ const stderr = result.stderr ?? "";
90
+ return {
91
+ exitCode: result.status ?? null,
92
+ signal: result.signal ?? null,
93
+ timedOut: Boolean(result.error && result.error.code === "ETIMEDOUT"),
94
+ stdout,
95
+ stderr,
96
+ output: `${stdout}\n${stderr}`
97
+ };
98
+ }
99
+
100
+ function exactNodeIdPattern(nodeid) {
101
+ const escaped = nodeid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\//g, "[/\\\\]");
102
+ const selectedParamSet = /\[[^\]]+\]$/.test(nodeid);
103
+ return new RegExp(`FAILED\\s+${escaped}${selectedParamSet ? "" : "(?:\\[[^\\]\\n]+\\])?"}(?:\\s|$)`);
104
+ }
105
+
106
+ function isExactPytestNodeId(nodeid) {
107
+ 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);
111
+ }
112
+
113
+ function classifyPytest(run, nodeid) {
114
+ if (run.timedOut) return { kind: "otherError", reason: "pytest timed out" };
115
+ 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" };
123
+ const failedTarget = exactNodeIdPattern(nodeid).test(normalized);
124
+ 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
+ }
128
+ const assertionLike = /\bAssertionError\b|(^|\n)E\s+assert\s/m.test(normalized);
129
+ if (run.exitCode === 1 && failedTarget && assertionLike) return { kind: "assertionFailure" };
130
+ return { kind: "otherError", reason: "pytest failure was not a trusted assertion failure" };
131
+ }
132
+
133
+ function mutate(repoRoot, targetRel, func, mode, timeoutMs) {
134
+ 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], {
137
+ cwd: repoRoot,
138
+ env: cleanEnv(),
139
+ encoding: "utf8",
140
+ stdio: ["ignore", "pipe", "pipe"],
141
+ timeout: timeoutMs
142
+ });
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
+ }
151
+ }
152
+
153
+ function summarize(run) {
154
+ const line = run.output
155
+ .split(/\r?\n/)
156
+ .map((s) => s.trim())
157
+ .find(Boolean);
158
+ return line ? line.slice(0, 240) : "";
159
+ }
160
+
161
+ function main() {
162
+ 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
+ }
170
+
171
+ const root = path.resolve(args.root);
172
+ const target = contained(root, args.target);
173
+ const testPath = args.test.split("::", 1)[0];
174
+ 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));
187
+ return;
188
+ }
189
+ const tmp = mkdtempSync(path.join(tmpdir(), "opro-python-proof-"));
190
+ const repoRoot = path.join(tmp, "repo");
191
+
192
+ try {
193
+ 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",
199
+ proven: false,
200
+ reason: "baseline test did not pass",
201
+ runner: "pytest",
202
+ baseline: { exitCode: baseline.exitCode, timedOut: baseline.timedOut, failureSummary: summarize(baseline) },
203
+ mutant: { assertionFailure: false }
204
+ };
205
+ process.stdout.write(JSON.stringify(verdict, null, args.json ? 2 : 0));
206
+ return;
207
+ }
208
+
209
+ const mutation = mutate(repoRoot, target.rel, args.func, args.mode, args.timeoutMs);
210
+ 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));
220
+ return;
221
+ }
222
+
223
+ const mutant = runPytest(repoRoot, args.test, args.timeoutMs);
224
+ const mutantClass = classifyPytest(mutant, args.test);
225
+ const proven = mutantClass.kind === "assertionFailure";
226
+ const survived = mutantClass.kind === "passed";
227
+ const verdict = {
228
+ status: proven ? "proven" : survived ? "associated_survived" : "unrunnable",
229
+ proven,
230
+ reason: proven ? "mutant failed at a trusted pytest assertion" : survived ? "mutant survived" : mutantClass.reason,
231
+ runner: "pytest",
232
+ replacementMode: args.mode,
233
+ test: args.test,
234
+ target: target.rel,
235
+ func: args.func,
236
+ 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));
240
+ } finally {
241
+ rmSync(tmp, { recursive: true, force: true });
242
+ }
243
+ }
244
+
245
+ main();
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal Python mutation helper for the P-1 proof spike.
3
+
4
+ This intentionally supports only the first safe shape: exactly one function or
5
+ method named --func in the target file, with a block suite that can be replaced
6
+ by a simple return sentinel. Unsupported or ambiguous shapes fail closed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import ast
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+
17
+
18
+ def fail(reason: str) -> None:
19
+ print(json.dumps({"ok": False, "reason": reason}))
20
+ raise SystemExit(0)
21
+
22
+
23
+ def leading_ws(line: str) -> str:
24
+ return line[: len(line) - len(line.lstrip(" \t"))]
25
+
26
+
27
+ def main() -> None:
28
+ parser = argparse.ArgumentParser()
29
+ parser.add_argument("--file", required=True)
30
+ parser.add_argument("--func", required=True)
31
+ parser.add_argument("--mode", choices=["sentinel", "equivalent"], default="sentinel")
32
+ args = parser.parse_args()
33
+
34
+ if args.mode == "equivalent":
35
+ print(json.dumps({"ok": True, "changed": False}))
36
+ return
37
+
38
+ target = Path(args.file)
39
+ text = target.read_text(encoding="utf8")
40
+ lines = text.splitlines(keepends=True)
41
+
42
+ try:
43
+ tree = ast.parse(text, filename=str(target))
44
+ except SyntaxError as exc:
45
+ fail(f"syntax_error:{exc.lineno}")
46
+
47
+ candidates: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
48
+ for node in ast.walk(tree):
49
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == args.func:
50
+ candidates.append(node)
51
+
52
+ if len(candidates) != 1:
53
+ fail("ambiguous_function" if candidates else "function_not_found")
54
+
55
+ fn = candidates[0]
56
+ if fn.end_lineno is None or not fn.body:
57
+ fail("unsupported_function_shape")
58
+ if any(isinstance(n, (ast.Yield, ast.YieldFrom)) for n in ast.walk(fn)):
59
+ fail("unsupported_generator")
60
+
61
+ first_body = fn.body[0]
62
+ start = first_body.lineno
63
+ end = fn.end_lineno
64
+ if start < 1 or end < start or end > len(lines):
65
+ fail("unsupported_function_range")
66
+
67
+ if start == fn.lineno and end == fn.lineno:
68
+ header_line = lines[fn.lineno - 1]
69
+ match = re.match(r"^(\s*(?:async\s+)?def\s+[A-Za-z_][A-Za-z0-9_]*\([^)]*\)(?:\s*->\s*[^:]+)?):\s*.+$", header_line)
70
+ if not match:
71
+ fail("unsupported_inline_suite")
72
+ body_indent = leading_ws(header_line) + " "
73
+ lines[start - 1 : end] = [f"{match.group(1)}:\n", f"{body_indent}return 0\n"]
74
+ target.write_text("".join(lines), encoding="utf8")
75
+ print(json.dumps({"ok": True, "changed": True, "start_line": start, "end_line": end}))
76
+ return
77
+
78
+ indent = leading_ws(lines[start - 1])
79
+ if not indent or len(indent.replace("\t", " ")) <= len(leading_ws(lines[fn.lineno - 1]).replace("\t", " ")):
80
+ fail("unsupported_suite_indent")
81
+
82
+ replacement = f"{indent}return 0\n"
83
+ lines[start - 1 : end] = [replacement]
84
+ target.write_text("".join(lines), encoding="utf8")
85
+ print(json.dumps({"ok": True, "changed": True, "start_line": start, "end_line": end}))
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()