@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.
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { LOCAL_GRAPH_SCHEMA_VERSION } from "./graph/ontology.js";
6
6
  import { systemClock } from "./util/time.js";
@@ -42,6 +42,7 @@ import { confirmedCoverageByLayer } from "./score/coverage.js";
42
42
  import { prepareRuntimeCoverage } from "./analyze/coverageArtifacts.js";
43
43
  import { appendLedgerRecord, canReproveLanguage, loadLedger, ledgerStats, proofEdgesFor, reproveTarget, resolveTargetSymbol, targetFingerprint, targetLanguage } from "./ledger.js";
44
44
  import { buildRtm, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
45
+ import { buildProofDoctor, distillProofAttempts, loadProofAttempts, proofAttemptsFresh, writeProofAttempts } from "./proofDoctor.js";
45
46
  import { tryScopedReprove } from "./reprove/scoped.js";
46
47
  import { resolveContained, toWorkspaceRel } from "./reprove/paths.js";
47
48
  import { hashBuffer } from "./util/hash.js";
@@ -56,8 +57,81 @@ function dynamicProofSpikePath() {
56
57
  const here = dirname(fileURLToPath(import.meta.url));
57
58
  return resolve(here, "..", "..", "scripts", "spikes", "dynamic-proof-spike.mjs");
58
59
  }
60
+ /**
61
+ * Resolve the Go MODULE root: the nearest `go.mod` ancestor of the target file,
62
+ * searched from the target's directory up to (and including) sourceRoot. For a
63
+ * single-module repo this is sourceRoot itself. Confined to sourceRoot so the spike
64
+ * never sandboxes a directory outside the trusted checkout.
65
+ * ponytail: nearest-ancestor go.mod; multi-module edge cases (nested/replace) resolve to G-INT-3.
66
+ */
67
+ /**
68
+ * G2 — confinement bound for module-root walk-up: the walk may pass ABOVE the
69
+ * analyzed source path (fixing `opro start ./subpkg` inside a bigger module)
70
+ * but never escapes the INVOCATION root the user ran opro from. If the analyzed
71
+ * path lies outside the invocation root, keep the old sourceRoot confinement.
72
+ * Discovery/scoping only — the proof gate is untouched, and when the chosen
73
+ * module root differs from the analyzed path it is named in progress output
74
+ * and on the result (module_root).
75
+ */
76
+ function moduleRootBound(sourceRoot, workspaceRoot) {
77
+ const src = resolve(sourceRoot);
78
+ const ws = resolve(workspaceRoot);
79
+ return src === ws || src.startsWith(ws + sep) ? ws : src;
80
+ }
81
+ function goModuleRoot(sourceRoot, targetRel, workspaceRoot) {
82
+ let dir = dirname(resolve(sourceRoot, targetRel));
83
+ const stop = moduleRootBound(sourceRoot, workspaceRoot);
84
+ for (;;) {
85
+ if (existsSync(join(dir, "go.mod")))
86
+ return dir;
87
+ if (dir === stop)
88
+ break;
89
+ const parent = dirname(dir);
90
+ if (parent === dir)
91
+ break;
92
+ dir = parent;
93
+ }
94
+ throw new Error(`No go.mod found for Go target ${targetRel} under ${stop}.`);
95
+ }
96
+ /**
97
+ * Resolve the Java MODULE root: the nearest `pom.xml` (or `build.gradle` /
98
+ * `build.gradle.kts`) ancestor of the target file, searched from the target's
99
+ * directory up to (and including) sourceRoot. Mirrors goModuleRoot — for a
100
+ * single-module project this is sourceRoot itself. Confined to sourceRoot so the
101
+ * spike never sandboxes a directory outside the trusted checkout.
102
+ * ponytail: nearest-ancestor build file; multi-module reactor edge cases resolve to J-INT-3.
103
+ */
104
+ function javaModuleRoot(sourceRoot, targetRel, workspaceRoot) {
105
+ let dir = dirname(resolve(sourceRoot, targetRel));
106
+ const stop = moduleRootBound(sourceRoot, workspaceRoot);
107
+ for (;;) {
108
+ if (existsSync(join(dir, "pom.xml")) || existsSync(join(dir, "build.gradle")) || existsSync(join(dir, "build.gradle.kts"))) {
109
+ return dir;
110
+ }
111
+ if (dir === stop)
112
+ break;
113
+ const parent = dirname(dir);
114
+ if (parent === dir)
115
+ break;
116
+ dir = parent;
117
+ }
118
+ throw new Error(`No pom.xml or build.gradle found for Java target ${targetRel} under ${stop}.`);
119
+ }
120
+ /** Route to the per-language spike. TS/JS keeps the original path; native profiles use their own mechanisms. */
121
+ function dynamicProofSpikePathFor(language) {
122
+ if (language === "go" || language === "java" || language === "python") {
123
+ const here = dirname(fileURLToPath(import.meta.url));
124
+ const script = language === "go"
125
+ ? "go-dynamic-proof-spike.mjs"
126
+ : language === "java"
127
+ ? "java-dynamic-proof-spike.mjs"
128
+ : "python-dynamic-proof-spike.mjs";
129
+ return resolve(here, "..", "..", "scripts", "spikes", script);
130
+ }
131
+ return dynamicProofSpikePath();
132
+ }
59
133
  function defaultDynamicProofRunner(args, opts = {}) {
60
- const script = dynamicProofSpikePath();
134
+ const script = opts.scriptPath ?? dynamicProofSpikePath();
61
135
  if (!existsSync(script)) {
62
136
  throw new Error(`Dynamic proof spike runner not found at ${script}. Run this command from an OrangePro source checkout with scripts/spikes available.`);
63
137
  }
@@ -83,6 +157,107 @@ function parseDynamicProofJson(stdout, stderr) {
83
157
  throw new Error(`Dynamic proof runner did not return JSON.${detail ? ` stderr/stdout: ${detail}` : ""}`);
84
158
  }
85
159
  }
160
+ /** The label recorded as the cert `sentinel` for a Go proof (Go derives a zero-value return). */
161
+ const GO_SENTINEL_LABEL = "go-zero-return";
162
+ /**
163
+ * Map the Go spike's JSON onto the SAME language-agnostic DynamicProofOracleSummary the
164
+ * unchanged `dynamicProofSucceeded` and cert block read. This is the ONE trust-adjacent
165
+ * seam, so mapping is STRICT: `assertionFailure` is true ONLY when the Go spike genuinely
166
+ * returned `mutant.trustedAssertion === true`. A skipped/refused mutation, a survived
167
+ * mutation, or any absent field maps to `assertionFailure = false` — never a false Proven.
168
+ */
169
+ function mapGoOracle(go) {
170
+ const mutant = go.mutant;
171
+ // A refused/skipped mutation co-occurs with status "unrunnable"; it must never close.
172
+ const skipped = Boolean(mutant && "skipped" in mutant && mutant.skipped === true);
173
+ const runMutant = !skipped && mutant ? mutant : undefined;
174
+ return {
175
+ status: go.status,
176
+ proven: go.proven === true,
177
+ reason: go.reason,
178
+ runner: "go",
179
+ replacementMode: GO_SENTINEL_LABEL,
180
+ test: go.testRun,
181
+ target: go.target,
182
+ method: go.func,
183
+ baseline: go.baseline
184
+ ? { exitCode: go.baseline.exitCode, timedOut: go.baseline.timedOut, failureSummary: go.baseline.failureSummary ?? null }
185
+ : undefined,
186
+ // STRICT: trustedAssertion must be exactly true AND the mutant must carry a NUMERIC non-zero
187
+ // exit code. A missing/undefined/zero exitCode ⇒ assertionFailure=false ⇒ non-close, so the
188
+ // shared gate (which asserts `exitCode !== 0`, where `undefined !== 0` is truthy) can never
189
+ // false-close on an absent exit code. skipped/absent/false ⇒ false ⇒ non-close.
190
+ mutant: runMutant
191
+ ? {
192
+ exitCode: runMutant.exitCode,
193
+ timedOut: runMutant.timedOut,
194
+ assertionFailure: runMutant.trustedAssertion === true &&
195
+ typeof runMutant.exitCode === "number" &&
196
+ runMutant.exitCode !== 0
197
+ }
198
+ : { assertionFailure: false },
199
+ medianProofMs: go.medianProofMs
200
+ };
201
+ }
202
+ /** Test-only handle on the Go→oracle mapper (the one trust-adjacent seam). */
203
+ export function __mapGoOracleForTest(go) {
204
+ return mapGoOracle(go);
205
+ }
206
+ /** The label recorded as the cert `sentinel` for a Java proof (Java derives a typed sentinel from the return type). */
207
+ const JAVA_SENTINEL_LABEL = "java-typed-sentinel";
208
+ /**
209
+ * Map the Java spike's JSON onto the SAME language-agnostic DynamicProofOracleSummary the
210
+ * unchanged `dynamicProofSucceeded` and cert block read. This is the ONE trust-adjacent
211
+ * seam, so mapping is STRICT: `assertionFailure` is true ONLY when the Java spike genuinely
212
+ * signalled a trusted JUnit assertion failure — the mutant ran (not skipped), the SAME
213
+ * target test FAILED, AND surefire classified it as a trusted assertion (`isAssertion`).
214
+ * A skipped/refused mutation, a survived (associated_survived) mutation, a compile failure,
215
+ * a non-assertion error, or any absent field maps to `assertionFailure = false` — never a
216
+ * false Proven. The spike itself already gates `status: "proven"` on all of this; the mapper
217
+ * re-derives the assertion signal independently so the cert's `mutant_failed_assertion` never
218
+ * trusts the spike's verdict alone.
219
+ */
220
+ function mapJavaOracle(java) {
221
+ const mutant = java.mutant;
222
+ // A refused/skipped mutation co-occurs with status "unrunnable"; it must never close.
223
+ const skipped = Boolean(mutant && "skipped" in mutant && mutant.skipped === true);
224
+ const runMutant = !skipped && mutant
225
+ ? mutant
226
+ : undefined;
227
+ return {
228
+ status: java.status,
229
+ proven: java.proven === true,
230
+ reason: java.reason,
231
+ runner: "junit",
232
+ replacementMode: JAVA_SENTINEL_LABEL,
233
+ test: java.testClass && java.testMethod ? `${java.testClass}#${java.testMethod}` : java.testMethod,
234
+ target: java.target,
235
+ method: java.method,
236
+ baseline: java.baseline
237
+ ? { exitCode: java.baseline.exitCode, timedOut: java.baseline.timedOut, failureSummary: java.baseline.failureSummary ?? null }
238
+ : undefined,
239
+ // STRICT: the mutant must have RUN, FAILED the same target test, the failure must be a trusted
240
+ // JUnit assertion, AND the mutant must carry a NUMERIC non-zero exit code. A missing/undefined/
241
+ // zero exitCode ⇒ assertionFailure=false ⇒ non-close, so the shared gate (which asserts
242
+ // `exitCode !== 0`, where `undefined !== 0` is truthy) can never false-close on an absent exit
243
+ // code. skipped/survived/compile-fail/non-assertion/absent ⇒ false ⇒ non-close.
244
+ mutant: runMutant
245
+ ? {
246
+ exitCode: runMutant.exitCode,
247
+ timedOut: runMutant.timedOut,
248
+ assertionFailure: runMutant.targetTestFailed === true &&
249
+ runMutant.isAssertion === true &&
250
+ typeof runMutant.exitCode === "number" &&
251
+ runMutant.exitCode !== 0
252
+ }
253
+ : { assertionFailure: false },
254
+ medianProofMs: java.medianProofMs
255
+ };
256
+ }
257
+ /** Test-only handle on the Java→oracle mapper (the one trust-adjacent seam). */
258
+ export function __mapJavaOracleForTest(java) {
259
+ return mapJavaOracle(java);
260
+ }
86
261
  function dynamicProofSucceeded(oracle) {
87
262
  return (oracle.status === "proven" &&
88
263
  oracle.proven === true &&
@@ -617,6 +792,17 @@ export function opDoctor(root) {
617
792
  const graph = loadGraph(workspacePaths(root).graphPath);
618
793
  return doctorGraph(graph, scoreGraph(graph));
619
794
  }
795
+ /**
796
+ * G1 — proof-focused doctor: why are top targets not Dynamically Proven?
797
+ * Read-only: consumes the canonical RTM judgment (buildRtm) plus the last run's
798
+ * redacted proof-attempts sidecar. Mints nothing, mutates no ledger, writes no
799
+ * files — under staleness or ambiguity it fails closed to "re-run".
800
+ */
801
+ export function opProofDoctor(root) {
802
+ const graph = loadGraph(workspacePaths(root).graphPath);
803
+ const rtm = buildRtm(graph, loadLedger(root));
804
+ return buildProofDoctor(graph, rtm, loadProofAttempts(root));
805
+ }
620
806
  export function opGaps(root, opts = {}) {
621
807
  const graph = loadGraph(workspacePaths(root).graphPath);
622
808
  const gaps = findGaps(graph, opts);
@@ -736,6 +922,33 @@ export function opRecordRun(root, opts, deps = defaultDeps()) {
736
922
  ts: deps.clock()
737
923
  });
738
924
  }
925
+ /**
926
+ * G2 — Python sandbox NARROWING: the nearest pyproject.toml/setup.py/setup.cfg
927
+ * ancestor of the target, confined to sourceRoot, used only when the test also
928
+ * lives inside it. Falls back to sourceRoot (today's behavior) otherwise —
929
+ * this only ever shrinks the copied sandbox, never widens or breaks it.
930
+ */
931
+ function pythonModuleRoot(sourceRoot, targetRel, testRel) {
932
+ const stop = resolve(sourceRoot);
933
+ let dir = dirname(resolve(sourceRoot, targetRel));
934
+ let found = null;
935
+ for (;;) {
936
+ if (existsSync(join(dir, "pyproject.toml")) || existsSync(join(dir, "setup.py")) || existsSync(join(dir, "setup.cfg"))) {
937
+ found = dir;
938
+ break;
939
+ }
940
+ if (dir === stop)
941
+ break;
942
+ const parent = dirname(dir);
943
+ if (parent === dir)
944
+ break;
945
+ dir = parent;
946
+ }
947
+ if (!found || found === stop)
948
+ return stop;
949
+ const testAbs = resolve(sourceRoot, testRel);
950
+ return testAbs === found || testAbs.startsWith(found + sep) ? found : stop;
951
+ }
739
952
  export function opDynamicProof(root, opts, deps = defaultDeps()) {
740
953
  const paths = workspacePaths(root);
741
954
  const graph = loadGraph(paths.graphPath);
@@ -745,59 +958,199 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
745
958
  throw new Error("prove requires --target-symbol sym:<file>#<Symbol>, or a target id that resolves to exactly one CodeSymbol.");
746
959
  }
747
960
  const language = targetLanguage(target);
748
- if (language !== "typescript") {
749
- throw new Error(`prove currently supports JavaScript/TypeScript targets only; found ${language}.`);
961
+ if (language !== "typescript" && language !== "go" && language !== "java" && language !== "python") {
962
+ throw new Error(`prove currently supports JavaScript/TypeScript, Go, Java, and Python targets only; found ${language}.`);
750
963
  }
751
- if (!opts.test_path)
752
- throw new Error("prove requires --test <path>.");
753
- if (opts.replacement === undefined)
754
- throw new Error("prove requires --replacement <sentinel>.");
755
964
  const sourceRoot = resolve(opts.source ?? root);
756
965
  assertProofSourceMatchesGraph(sourceRoot, graph);
757
966
  const symbolTarget = symbolTargetParts(target);
758
967
  const providedTargetRel = opts.target_path ? toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.target_path)) : undefined;
759
968
  assertProofTargetMatchesSymbol({ ...opts, target_path: providedTargetRel }, symbolTarget);
760
- const testRel = toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.test_path));
761
969
  const targetRel = symbolTarget.file;
762
970
  const method = symbolTarget.method;
763
971
  assertTargetFileFresh(sourceRoot, targetRel, graph);
764
- const replacementMode = opts.replacement_mode ?? "return-json";
765
- const runner = opts.runner ?? "auto";
766
- if (replacementMode !== "return-json" && replacementMode !== "promise-json") {
767
- throw new Error("prove --replacement-mode must be one of: return-json, promise-json.");
768
- }
769
- if (runner !== "auto" && runner !== "vitest" && runner !== "jest" && runner !== "mocha") {
770
- throw new Error("prove --runner must be one of: auto, vitest, jest, mocha.");
771
- }
772
- const args = [
773
- "--root",
774
- sourceRoot,
775
- "--test",
776
- testRel,
777
- "--target",
778
- targetRel,
779
- "--method",
780
- method,
781
- "--replacement",
782
- opts.replacement,
783
- "--replacement-mode",
784
- replacementMode,
785
- "--runner",
786
- runner,
787
- "--json"
788
- ];
789
- if (opts.timeout_ms !== undefined)
790
- args.push("--timeout-ms", String(opts.timeout_ms));
791
- if (opts.link_node_modules)
792
- args.push("--link-node-modules");
793
- if (opts.vitest_config)
794
- args.push("--vitest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.vitest_config)));
795
- if (opts.jest_config)
796
- args.push("--jest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.jest_config)));
797
- for (const entry of opts.test_env ?? [])
798
- args.push("--test-env", entry);
799
- const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, { cwd: sourceRoot });
800
- const oracle = parseDynamicProofJson(run.stdout, run.stderr);
972
+ // Per-language routing produces the spike args + a mapped oracle in the SAME
973
+ // DynamicProofOracleSummary shape. Everything from `closed` onward (the trust gate,
974
+ // the cert, appendLedgerRecord, the return summary) is language-agnostic and unchanged.
975
+ let oracle;
976
+ let proofModuleRoot;
977
+ let testRel;
978
+ let replacementMode;
979
+ let runner;
980
+ if (language === "go") {
981
+ // Go selects the target test by NAME (`go test -run ^TestX$`), derives its own
982
+ // zero-value sentinel, and always uses `go test`. No --replacement/--runner/
983
+ // --vitest-config/--jest-config/--test-env/--link-node-modules apply.
984
+ if (!opts.test_run)
985
+ throw new Error("prove requires --test-run '^TestName$' for Go targets.");
986
+ if (!/^\^.+\$$/.test(opts.test_run)) {
987
+ throw new Error("prove --test-run must be a fully-anchored test name, e.g. '^TestName$'.");
988
+ }
989
+ const goRoot = goModuleRoot(sourceRoot, targetRel, root);
990
+ if (goRoot !== resolve(sourceRoot)) {
991
+ proofModuleRoot = goRoot;
992
+ reportProgress(`proof scope: Go module root ${goRoot} (above the analyzed path)`);
993
+ }
994
+ const args = [
995
+ "--root",
996
+ goRoot,
997
+ "--target",
998
+ relative(goRoot, resolve(sourceRoot, targetRel)).split(sep).join("/"),
999
+ "--func",
1000
+ method,
1001
+ "--test-run",
1002
+ opts.test_run,
1003
+ "--json"
1004
+ ];
1005
+ if (opts.timeout_ms !== undefined)
1006
+ args.push("--timeout-ms", String(opts.timeout_ms));
1007
+ // Slice 2: bind a runtime-named subtest's mutant failure to the exact assertion line.
1008
+ if (opts.go_assertion_line !== undefined)
1009
+ args.push("--go-assertion-line", String(opts.go_assertion_line));
1010
+ const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, {
1011
+ cwd: goRoot,
1012
+ scriptPath: dynamicProofSpikePathFor("go")
1013
+ });
1014
+ oracle = mapGoOracle(parseDynamicProofJson(run.stdout, run.stderr));
1015
+ testRel = opts.test_run;
1016
+ replacementMode = GO_SENTINEL_LABEL;
1017
+ runner = "go";
1018
+ }
1019
+ else if (language === "java") {
1020
+ // Java selects the target test by class#method (`mvn test -Dtest=Class#method`),
1021
+ // derives its own type-compatible sentinel from the return type, and always uses
1022
+ // Surefire. The single `test_run` field carries `Class#method` (FQCN or simple
1023
+ // class both accepted by the spike). No --replacement/--runner/--vitest-config/
1024
+ // --jest-config/--test-env/--link-node-modules apply.
1025
+ if (!opts.test_run)
1026
+ throw new Error("prove requires --test-run 'TestClass#testMethod' for Java targets.");
1027
+ const hash = opts.test_run.lastIndexOf("#");
1028
+ if (hash <= 0 || hash === opts.test_run.length - 1) {
1029
+ throw new Error("prove --test-run must be 'TestClass#testMethod' for Java targets, e.g. 'CalculatorTest#addsTwoNumbers'.");
1030
+ }
1031
+ const testClass = opts.test_run.slice(0, hash);
1032
+ const testMethod = opts.test_run.slice(hash + 1);
1033
+ const javaRoot = javaModuleRoot(sourceRoot, targetRel, root);
1034
+ if (javaRoot !== resolve(sourceRoot)) {
1035
+ proofModuleRoot = javaRoot;
1036
+ reportProgress(`proof scope: Java module root ${javaRoot} (above the analyzed path)`);
1037
+ }
1038
+ const args = [
1039
+ "--root",
1040
+ javaRoot,
1041
+ "--test-class",
1042
+ testClass,
1043
+ "--test-method",
1044
+ testMethod,
1045
+ "--target",
1046
+ relative(javaRoot, resolve(sourceRoot, targetRel)).split(sep).join("/"),
1047
+ "--method",
1048
+ method,
1049
+ "--json"
1050
+ ];
1051
+ if (opts.timeout_ms !== undefined)
1052
+ args.push("--timeout-ms", String(opts.timeout_ms));
1053
+ const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, {
1054
+ cwd: javaRoot,
1055
+ scriptPath: dynamicProofSpikePathFor("java")
1056
+ });
1057
+ oracle = mapJavaOracle(parseDynamicProofJson(run.stdout, run.stderr));
1058
+ testRel = opts.test_run;
1059
+ replacementMode = JAVA_SENTINEL_LABEL;
1060
+ runner = "junit";
1061
+ }
1062
+ else if (language === "python") {
1063
+ if (!opts.test_path)
1064
+ throw new Error("prove requires --test <path>.");
1065
+ if (opts.replacement === undefined)
1066
+ throw new Error("prove requires --replacement <sentinel>.");
1067
+ testRel = toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.test_path));
1068
+ replacementMode = opts.replacement_mode ?? "return-json";
1069
+ runner = opts.runner ?? "auto";
1070
+ if (replacementMode !== "return-json") {
1071
+ throw new Error("prove --replacement-mode promise-json is not supported for Python targets.");
1072
+ }
1073
+ if (runner !== "auto" && runner !== "pytest") {
1074
+ throw new Error("prove Python targets require --runner auto or --runner pytest.");
1075
+ }
1076
+ if ((opts.test_env?.length ?? 0) > 0) {
1077
+ throw new Error("prove --test-env is not supported for Python targets yet.");
1078
+ }
1079
+ // G2: narrow the copied sandbox to the owning Python project when both the
1080
+ // target and the test live inside it (bounded copy — no full-repo OOM).
1081
+ const pyRoot = pythonModuleRoot(sourceRoot, targetRel, testRel);
1082
+ if (pyRoot !== resolve(sourceRoot)) {
1083
+ proofModuleRoot = pyRoot;
1084
+ reportProgress(`proof scope: Python project root ${pyRoot} (narrowed from the analyzed path)`);
1085
+ }
1086
+ const args = [
1087
+ "--root",
1088
+ pyRoot,
1089
+ "--test",
1090
+ relative(pyRoot, resolve(sourceRoot, testRel)).split(sep).join("/"),
1091
+ "--target",
1092
+ relative(pyRoot, resolve(sourceRoot, targetRel)).split(sep).join("/"),
1093
+ "--func",
1094
+ method,
1095
+ "--mode",
1096
+ "sentinel",
1097
+ "--json"
1098
+ ];
1099
+ if (opts.timeout_ms !== undefined)
1100
+ args.push("--timeout-ms", String(opts.timeout_ms));
1101
+ const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, {
1102
+ cwd: pyRoot,
1103
+ scriptPath: dynamicProofSpikePathFor("python")
1104
+ });
1105
+ oracle = parseDynamicProofJson(run.stdout, run.stderr);
1106
+ }
1107
+ else {
1108
+ if (!opts.test_path)
1109
+ throw new Error("prove requires --test <path>.");
1110
+ if (opts.replacement === undefined)
1111
+ throw new Error("prove requires --replacement <sentinel>.");
1112
+ testRel = toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.test_path));
1113
+ replacementMode = opts.replacement_mode ?? "return-json";
1114
+ runner = opts.runner ?? "auto";
1115
+ if (replacementMode !== "return-json" && replacementMode !== "promise-json") {
1116
+ throw new Error("prove --replacement-mode must be one of: return-json, promise-json.");
1117
+ }
1118
+ if (runner === "pytest") {
1119
+ throw new Error("prove --runner pytest requires a Python target.");
1120
+ }
1121
+ if (runner !== "auto" && runner !== "vitest" && runner !== "jest" && runner !== "mocha") {
1122
+ throw new Error("prove --runner must be one of: auto, vitest, jest, mocha.");
1123
+ }
1124
+ const args = [
1125
+ "--root",
1126
+ sourceRoot,
1127
+ "--test",
1128
+ testRel,
1129
+ "--target",
1130
+ targetRel,
1131
+ "--method",
1132
+ method,
1133
+ "--replacement",
1134
+ opts.replacement,
1135
+ "--replacement-mode",
1136
+ replacementMode,
1137
+ "--runner",
1138
+ runner,
1139
+ "--json"
1140
+ ];
1141
+ if (opts.timeout_ms !== undefined)
1142
+ args.push("--timeout-ms", String(opts.timeout_ms));
1143
+ if (opts.link_node_modules)
1144
+ args.push("--link-node-modules");
1145
+ if (opts.vitest_config)
1146
+ args.push("--vitest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.vitest_config)));
1147
+ if (opts.jest_config)
1148
+ args.push("--jest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.jest_config)));
1149
+ for (const entry of opts.test_env ?? [])
1150
+ args.push("--test-env", entry);
1151
+ const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, { cwd: sourceRoot });
1152
+ oracle = parseDynamicProofJson(run.stdout, run.stderr);
1153
+ }
801
1154
  const closed = dynamicProofSucceeded(oracle);
802
1155
  const baselineGreen = oracle.baseline?.exitCode === 0 && oracle.baseline?.timedOut !== true;
803
1156
  const mutantFailedAssertion = oracle.mutant?.assertionFailure === true && oracle.mutant?.timedOut !== true;
@@ -830,6 +1183,7 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
830
1183
  });
831
1184
  return {
832
1185
  ...result,
1186
+ ...(proofModuleRoot ? { module_root: proofModuleRoot } : {}),
833
1187
  oracle: {
834
1188
  status: oracle.status,
835
1189
  proven: oracle.proven,
@@ -927,8 +1281,8 @@ export function opProveLoop(root, opts, deps = defaultDeps()) {
927
1281
  throw new Error("prove requires --target-symbol sym:<file>#<Symbol>, or a target id that resolves to exactly one CodeSymbol.");
928
1282
  }
929
1283
  const language = targetLanguage(target);
930
- if (language !== "typescript") {
931
- throw new Error(`prove currently supports JavaScript/TypeScript targets only; found ${language}.`);
1284
+ if (language !== "typescript" && language !== "go" && language !== "java" && language !== "python") {
1285
+ throw new Error(`prove currently supports JavaScript/TypeScript, Go, Java, and Python targets only; found ${language}.`);
932
1286
  }
933
1287
  const setupCommands = (opts.setup_commands ?? []).map((command, index) => validateSetupCommand(command, index));
934
1288
  const sourceRoot = resolve(opts.source ?? root);
@@ -947,6 +1301,8 @@ export function opProveLoop(root, opts, deps = defaultDeps()) {
947
1301
  target_id: opts.target_id,
948
1302
  source: opts.source,
949
1303
  test_path: opts.test_path,
1304
+ test_run: opts.test_run,
1305
+ go_assertion_line: opts.go_assertion_line,
950
1306
  target_path: opts.target_path,
951
1307
  method: opts.method,
952
1308
  replacement: opts.replacement,
@@ -1183,6 +1539,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1183
1539
  noAuto: opts.noAuto,
1184
1540
  provider: providerOpts.provider,
1185
1541
  model: providerOpts.model,
1542
+ prompt_version: opts.promptVersion,
1186
1543
  changedFiles: autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef)
1187
1544
  }, { ...providerDeps, proveLoop: opProveLoop });
1188
1545
  for (const skip of autoProveResult.skipped)
@@ -1203,6 +1560,19 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1203
1560
  };
1204
1561
  warnings.push(`auto-prove skipped: ${reason}`);
1205
1562
  }
1563
+ // G1: persist the distilled, already-redacted attempt classifications so
1564
+ // `opro doctor --proof` and standalone report regens can explain blockers
1565
+ // after this process exits. Sidecar only — never read by the oracle, RTM,
1566
+ // or ledger paths; a write failure must never fail start.
1567
+ try {
1568
+ writeProofAttempts(root, distillProofAttempts(autoProveResult, {
1569
+ generatedAt: deps.clock(),
1570
+ graph: loadGraph(workspacePaths(root).graphPath)
1571
+ }));
1572
+ }
1573
+ catch (error) {
1574
+ warnings.push(`proof-attempts sidecar not written: ${error instanceof Error ? error.message : String(error)}`);
1575
+ }
1206
1576
  let coverageReport;
1207
1577
  try {
1208
1578
  reportProgress("artifacts: writing coverage report", { current: 6, total: 8 });
@@ -1274,10 +1644,10 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1274
1644
  if (rtm.rows.length < rtm.summary.total)
1275
1645
  nextActions.push("For full machine-readable RTM, run `opro rtm --format json --out .orangepro/rtm-full.json`; avoid opening full Markdown on very large repos.");
1276
1646
  if (changed.status === "ok" && changed.affected_behaviors.length > 0) {
1277
- nextActions.push(`In your coding agent, call orangepro_generate_tests with base_ref=${changed.base_ref}; write runnable tests, then follow each returned handoff: use prove_run when present for public Proven, otherwise record_run for static diagnostics.`);
1647
+ nextActions.push(`In your coding agent, call orangepro_generate_tests with base_ref=${changed.base_ref}; write runnable tests, then follow each returned handoff: call orangepro_prove with returned prove_run args when present for public Proven, otherwise record_run is static diagnostics only.`);
1278
1648
  }
1279
1649
  else if (gaps.gaps.length > 0) {
1280
- nextActions.push(`In your coding agent, call orangepro_generate_tests for ${gaps.gaps[0].external_id}; write runnable tests, then follow each returned handoff: use prove_run when present for public Proven, otherwise record_run for static diagnostics.`);
1650
+ nextActions.push(`In your coding agent, call orangepro_generate_tests for ${gaps.gaps[0].external_id}; write runnable tests, then follow each returned handoff: call orangepro_prove with returned prove_run args when present for public Proven, otherwise record_run is static diagnostics only.`);
1281
1651
  }
1282
1652
  else {
1283
1653
  nextActions.push("No deterministic gap target was found; inspect the RTM and graph before generating tests.");
@@ -1365,6 +1735,16 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1365
1735
  updated_at: deps.clock()
1366
1736
  };
1367
1737
  saveGraph(paths.graphPath, next);
1738
+ // Keep the behavior report in sync with the freshly persisted generated
1739
+ // tests — analyze/start would REBUILD the graph and drop them, so this is
1740
+ // the only command that can surface them. Display-only refresh; a render
1741
+ // failure must never fail generate.
1742
+ try {
1743
+ opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`);
1744
+ }
1745
+ catch {
1746
+ /* best-effort report refresh */
1747
+ }
1368
1748
  }
1369
1749
  // Validate each test's grounding citations against the graph the tests cite.
1370
1750
  // This is the keyless grounding contract: provenance must be verifiable, and a
@@ -1553,11 +1933,33 @@ export function opGraphHtml(root, outputPath = "orangepro-graph.html") {
1553
1933
  /** Write the self-contained offline behavior-coverage view (deterministic, metadata only). */
1554
1934
  export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-coverage.html", dynamicProof) {
1555
1935
  const graph = loadGraph(workspacePaths(root).graphPath);
1556
- const html = renderBehaviorReport(buildBehaviorReportData(graph, loadLedger(root), { repoRoot: root, dynamicProof }));
1936
+ // Standalone regens have no this-run outcome: fall back to the persisted
1937
+ // proof-attempts sidecar ONLY when it anchors to the current graph+commit
1938
+ // (stale evidence is dropped — fail closed; display copy only, no tier math).
1939
+ const dyn = dynamicProof ?? sidecarDynamicProof(root, graph);
1940
+ const html = renderBehaviorReport(buildBehaviorReportData(graph, loadLedger(root), { repoRoot: root, dynamicProof: dyn }));
1557
1941
  const htmlPath = resolve(root, outputPath);
1558
1942
  writeFileSync(htmlPath, html, "utf8");
1559
1943
  return { behavior_coverage_path: htmlPath };
1560
1944
  }
1945
+ /** Fresh-only sidecar view for report regens; unreadable or stale ⇒ undefined. */
1946
+ function sidecarDynamicProof(root, graph) {
1947
+ try {
1948
+ const attempts = loadProofAttempts(root);
1949
+ if (!attempts || !proofAttemptsFresh(attempts, graph))
1950
+ return undefined;
1951
+ return {
1952
+ attempted: attempts.attempted,
1953
+ proven: attempts.proven,
1954
+ needsSetup: attempts.attempts
1955
+ .filter((a) => a.classification === "needs_setup")
1956
+ .map((a) => ({ category: a.category, reason: a.reason }))
1957
+ };
1958
+ }
1959
+ catch {
1960
+ return undefined;
1961
+ }
1962
+ }
1561
1963
  /** Phase 5.2 — write the human-readable COVERAGE_REPORT.md (3-file contract). */
1562
1964
  export function opCoverageReport(root, outputPath = "COVERAGE_REPORT.md") {
1563
1965
  const graph = loadGraph(workspacePaths(root).graphPath);
@@ -1663,7 +2065,7 @@ const NO_CODE_CHANGES_GUIDANCE = "The diff vs the base ref only touched docs (.m
1663
2065
  * `base_ref` defaults to `main`. The diff runs from `git merge-base <base> HEAD`
1664
2066
  * to the working tree: the branch's own commits AND uncommitted edits, never
1665
2067
  * upstream churn on the base (falls back to the base tip when no merge-base
1666
- * exists). Read-only: no repo writes, no source upload.
2068
+ * exists). This diff resolver is read-only: it does not write repo files or upload source.
1667
2069
  */
1668
2070
  export function resolveDiffContext(graph, baseRefInput) {
1669
2071
  const scanRoot = graph.workspace.root;
@@ -3,8 +3,8 @@ import { resolveCoverage } from "../score/coverage.js";
3
3
  import { rankRiskGaps } from "../score/risk.js";
4
4
  import { buildRtm } from "../rtm.js";
5
5
  /**
6
- * Phase 5.2 — `COVERAGE_REPORT.md`, the human-readable third file of the
7
- * 3-file contract (graph.html / COVERAGE_REPORT.md / graph.json). It states,
6
+ * Phase 5.2 — `COVERAGE_REPORT.md`, the human-readable summary beside
7
+ * `behavior-coverage.html`, `graph.json`, and optional graph export. It states,
8
8
  * auditably and in one place:
9
9
  * - dynamic Proven % over the DENOMINATOR behaviors;
10
10
  * - what the denominator is made of (Gate 3 composition line);
@@ -36,7 +36,7 @@ export function renderCoverageReport(graph, ledger = emptyLedger()) {
36
36
  lines.push(`_Graph schema: ${graph.schema_version}_`);
37
37
  lines.push(`_Generated: ${graph.updated_at || graph.created_at || "(unknown)"}_`);
38
38
  lines.push("");
39
- // ---- Public Proven + denominator: ONE atomic pair (shared with graph.html) ----
39
+ // ---- Public Proven + denominator: ONE atomic pair (shared with report views) ----
40
40
  const { coverage: staticCov, denominator: comp } = resolveCoverage(graph);
41
41
  const rtm = buildRtm(graph, ledger);
42
42
  const s = rtm.summary;