@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.
- package/LICENSE +1 -1
- package/README.md +195 -251
- package/dist/local/analyze/analyzer.js +62 -17
- package/dist/local/analyze/treeSitter/engine.js +185 -22
- package/dist/local/autoProve.js +433 -38
- package/dist/local/cli.js +93 -10
- package/dist/local/cliArgs.js +1 -0
- package/dist/local/generate/runHints.js +9 -4
- package/dist/local/graph/factories.js +5 -2
- package/dist/local/ledger.js +1 -1
- package/dist/local/mcp.js +26 -13
- package/dist/local/operations.js +454 -52
- package/dist/local/pack/coverageReport.js +3 -3
- package/dist/local/proofDoctor.js +312 -0
- package/dist/local/rtm.js +40 -10
- package/dist/local/viz/behaviorReportData.js +79 -7
- package/dist/local/viz/behaviorReportHtml.js +526 -615
- package/dist/local/viz/html.js +1 -1
- package/docs/agent-workflow.md +10 -38
- package/docs/agents/claude-code.md +3 -9
- package/docs/agents/codex.md +3 -20
- package/docs/agents/cursor.md +2 -2
- package/docs/agents/opencode.md +2 -2
- package/docs/agents/vscode.md +2 -2
- package/docs/local-proof-kit.md +52 -19
- package/package.json +39 -6
- package/scripts/spikes/go-dynamic-proof-spike.mjs +637 -0
- package/scripts/spikes/go-mutate.go +182 -0
- package/scripts/spikes/java-dynamic-proof-spike.mjs +571 -0
- package/scripts/spikes/java-mutate.mjs +264 -0
- package/scripts/spikes/python-dynamic-proof-spike.mjs +245 -0
- package/scripts/spikes/python-mutate.py +89 -0
|
@@ -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()
|