@orangepro/orangepro-mcp 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  `opro` builds a knowledge graph from your local checkout, maps every behavior in your code, shows which ones are tested and which aren't, and generates integration-level tests grounded in real symbols — not hallucinated imports. Runs as a CLI and an MCP server.
6
6
 
7
7
  ```bash
8
- npx @orangepro/orangepro-mcp
8
+ npx @orangepro/mcp-server
9
9
  cd /path/to/your/repo
10
10
  opro
11
11
  ```
@@ -25,7 +25,7 @@ That's it. You get:
25
25
 
26
26
  ```bash
27
27
  # No install needed (npx)
28
- npx @orangepro/orangepro-mcp
28
+ npx @orangepro/mcp-server
29
29
 
30
30
  # Or global install
31
31
  npm install -g @orangepro/orangepro-mcp
@@ -50,7 +50,7 @@ Add to your client's MCP config:
50
50
  "mcpServers": {
51
51
  "orangepro-local": {
52
52
  "command": "npx",
53
- "args": ["-y", "-p", "@orangepro/orangepro-mcp@latest", "opro", "mcp"]
53
+ "args": ["-y", "@orangepro/mcp-server@latest", "mcp"]
54
54
  }
55
55
  }
56
56
  }
@@ -1592,9 +1592,7 @@ export function analyzeRepo(root, opts = {}) {
1592
1592
  };
1593
1593
  const resolvePythonProofTarget = (testRel, structure, qualifier, callee, shadowed) => {
1594
1594
  const conv = conventionSibling(testRel, "python", codeFileSet);
1595
- const targetRel = conv?.relPath ?? null;
1596
- if (!targetRel || isNonProductFile(targetRel))
1597
- return null;
1595
+ const conventionTargetRel = conv?.relPath && !isNonProductFile(conv.relPath) ? conv.relPath : null;
1598
1596
  const hasWildcardImport = structure.imports.some((i) => i.imported === "*");
1599
1597
  if (!qualifier) {
1600
1598
  if (shadowed.has(callee))
@@ -1603,13 +1601,15 @@ export function analyzeRepo(root, opts = {}) {
1603
1601
  if (binding === null)
1604
1602
  return null;
1605
1603
  if (binding) {
1606
- if (binding.kind !== "named" || binding.targetRel !== targetRel || binding.imported !== callee)
1604
+ if (binding.kind !== "named" || !binding.targetRel || isNonProductFile(binding.targetRel) || binding.imported !== callee)
1607
1605
  return null;
1608
- return eligiblePythonSymbol(targetRel, callee);
1606
+ return eligiblePythonSymbol(binding.targetRel, callee);
1609
1607
  }
1608
+ if (!conventionTargetRel)
1609
+ return null;
1610
1610
  if (hasWildcardImport)
1611
1611
  return null;
1612
- return eligiblePythonSymbol(targetRel, callee);
1612
+ return eligiblePythonSymbol(conventionTargetRel, callee);
1613
1613
  }
1614
1614
  const rootQualifier = pythonQualifierRoot(qualifier);
1615
1615
  if (shadowed.has(rootQualifier))
@@ -1618,13 +1618,14 @@ export function analyzeRepo(root, opts = {}) {
1618
1618
  if (binding === null)
1619
1619
  return null;
1620
1620
  if (binding?.kind === "module") {
1621
- return binding.targetRel === targetRel ? eligiblePythonSymbol(targetRel, callee) : null;
1621
+ return binding.targetRel && !isNonProductFile(binding.targetRel) ? eligiblePythonSymbol(binding.targetRel, callee) : null;
1622
1622
  }
1623
- if (binding?.kind === "named" && (binding.targetRel !== targetRel || binding.imported !== rootQualifier))
1623
+ const classTargetRel = binding?.kind === "named" ? binding.targetRel : conventionTargetRel;
1624
+ if (binding?.kind === "named" && (!classTargetRel || isNonProductFile(classTargetRel) || binding.imported !== rootQualifier))
1624
1625
  return null;
1625
- if (symbolKind(targetRel, rootQualifier) !== "class")
1626
+ if (!classTargetRel || symbolKind(classTargetRel, rootQualifier) !== "class")
1626
1627
  return null;
1627
- return eligiblePythonSymbol(targetRel, callee);
1628
+ return eligiblePythonSymbol(classTargetRel, callee);
1628
1629
  };
1629
1630
  for (const [rel, { language, structure }] of nonTsStructureByFile) {
1630
1631
  for (const i of structure.imports) {
@@ -1814,11 +1815,15 @@ export function analyzeRepo(root, opts = {}) {
1814
1815
  continue;
1815
1816
  seenPythonProof.add(edgeKey);
1816
1817
  pythonProofConfirmedPairs++;
1818
+ const pythonEdgeProps = /^(?:Test[A-Za-z0-9_]*::)?test_[A-Za-z0-9_]+$/.test(proof.testName)
1819
+ ? { test_name: proof.testName }
1820
+ : undefined;
1817
1821
  edges.push(...makeProofEdges({
1818
1822
  testRel,
1819
1823
  symId,
1820
1824
  provenance: prov(testRel, hashString(`${symId}:${proof.assertion}`)),
1821
- lastVerified: proofVerifiedAt
1825
+ lastVerified: proofVerifiedAt,
1826
+ ...(pythonEdgeProps ? { properties: pythonEdgeProps } : {})
1822
1827
  }));
1823
1828
  }
1824
1829
  }
@@ -1333,19 +1333,27 @@ function extractPythonProofCalls(root) {
1333
1333
  }
1334
1334
  }
1335
1335
  };
1336
- const walk = (node, insideFunction) => {
1336
+ const walk = (node, insideFunction, testClass) => {
1337
+ if (!insideFunction && node.type === "class_definition") {
1338
+ const name = node.childForFieldName("name")?.text;
1339
+ const nextTestClass = name && /^Test[A-Za-z0-9_]*$/.test(name) ? name : null;
1340
+ for (const child of namedChildren(node))
1341
+ walk(child, false, nextTestClass);
1342
+ return;
1343
+ }
1337
1344
  if (node.type === "function_definition") {
1338
1345
  const name = functionName(node, "python");
1339
1346
  const body = node.childForFieldName("body");
1340
- if (name && /^test_/.test(name) && body)
1341
- processBlock(body, name, localBindings(node, "python"));
1347
+ if (name && /^test_[A-Za-z0-9_]*$/.test(name) && body) {
1348
+ processBlock(body, testClass ? `${testClass}::${name}` : name, localBindings(node, "python"));
1349
+ }
1342
1350
  if (insideFunction)
1343
1351
  return;
1344
1352
  }
1345
1353
  for (const child of namedChildren(node))
1346
- walk(child, insideFunction || node.type === "function_definition");
1354
+ walk(child, insideFunction || node.type === "function_definition", testClass);
1347
1355
  };
1348
- walk(root, false);
1356
+ walk(root, false, null);
1349
1357
  return out;
1350
1358
  }
1351
1359
  export function extractTreeSitterStructure(content, language) {
@@ -22,6 +22,7 @@ import { buildProvider } from "./generate/providers.js";
22
22
  import { resolveContained } from "./reprove/paths.js";
23
23
  import { buildRtm } from "./rtm.js";
24
24
  import { loadLedger } from "./ledger.js";
25
+ import { reportProgress } from "./util/progress.js";
25
26
  import { loadGraph, workspacePaths } from "./workspace.js";
26
27
  import { systemClock } from "./util/time.js";
27
28
  import { redactSecrets } from "./util/redact.js";
@@ -49,6 +50,7 @@ function isRunnablePythonTestPath(testRel) {
49
50
  const file = testRel.split("::", 1)[0] ?? testRel;
50
51
  return /(^|\/)(test_[^/]+|[^/]+_test)\.py$/i.test(file);
51
52
  }
53
+ const PYTHON_TEST_NODEID_SUFFIX_RE = /^(?:Test[A-Za-z0-9_]*::)?test_[A-Za-z0-9_]+$/;
52
54
  function isRunnableTestForTarget(node, testRel) {
53
55
  const file = codeSymbolFile(node);
54
56
  if (isPythonFile(file))
@@ -91,6 +93,12 @@ function pytestNodeidsForFile(sourceRoot, testRel) {
91
93
  }
92
94
  return out.length ? out.slice(0, 25) : [testRel];
93
95
  }
96
+ function pytestNodeidsForTarget(sourceRoot, testRel, testName) {
97
+ if (testName && PYTHON_TEST_NODEID_SUFFIX_RE.test(testName) && !testRel.includes("::")) {
98
+ return [`${testRel}::${testName}`];
99
+ }
100
+ return pytestNodeidsForFile(sourceRoot, testRel);
101
+ }
94
102
  /**
95
103
  * SOLE trust barrier for auto-prove target selection. `opDynamicProof` has NO
96
104
  * eligibility guard — `resolveTargetSymbol` resolves ANY CodeSymbol and the prove
@@ -512,7 +520,7 @@ export function existingAssociatedTests(graph, nodeById) {
512
520
  // `hard` = TESTED_BY/COVERS (the confirmer's structural links); weak = MAY_* candidate
513
521
  // edges. Hard is recorded before weak (graph.edges scanned first), so a test already
514
522
  // linked hard is never downgraded; a later weak dup only upgrades an existing weak to hard.
515
- const add = (symId, testRel, hard) => {
523
+ const add = (symId, testRel, hard, testName) => {
516
524
  // A HARD TESTED_BY/COVERS edge is a real derivable test; admit it even when the symbol
517
525
  // is not_entry_point_adjacent (relaxed shape-only guard). Weak MAY_* fan-out stays strict.
518
526
  const node = nodeById.get(symId);
@@ -520,26 +528,26 @@ export function existingAssociatedTests(graph, nodeById) {
520
528
  return;
521
529
  const list = out.get(symId);
522
530
  if (!list) {
523
- out.set(symId, [{ test: testRel, hard }]);
531
+ out.set(symId, [{ test: testRel, hard, ...(testName ? { testName } : {}) }]);
524
532
  return;
525
533
  }
526
- const existing = list.find((t) => t.test === testRel);
534
+ const existing = list.find((t) => t.test === testRel && t.testName === testName);
527
535
  if (existing) {
528
536
  if (hard)
529
537
  existing.hard = true;
530
538
  return;
531
539
  }
532
- list.push({ test: testRel, hard });
540
+ list.push({ test: testRel, hard, ...(testName ? { testName } : {}) });
533
541
  };
534
542
  // A sym↔test edge joins one TestCase endpoint to one CodeSymbol endpoint; resolve
535
543
  // whichever side is the symbol so both edge directions are handled uniformly.
536
- const link = (a, b, hard) => {
544
+ const link = (a, b, hard, testName) => {
537
545
  const tb = testFileOf(b);
538
546
  if (tb && nodeById.get(a)?.kind === "CodeSymbol")
539
- return add(a, tb, hard);
547
+ return add(a, tb, hard, testName);
540
548
  const ta = testFileOf(a);
541
549
  if (ta && nodeById.get(b)?.kind === "CodeSymbol")
542
- add(b, ta, hard);
550
+ add(b, ta, hard, testName);
543
551
  };
544
552
  // Eligible symbols grouped by source file, for the file-level MAY_RELATE_TO expansion.
545
553
  const eligibleByFile = new Map();
@@ -560,8 +568,10 @@ export function existingAssociatedTests(graph, nodeById) {
560
568
  return n && n.kind === "TestCase" ? relPath : null;
561
569
  };
562
570
  for (const e of graph.edges) {
563
- if (e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS")
564
- link(e.from_external_id, e.to_external_id, true);
571
+ if (e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS") {
572
+ const testName = typeof e.properties?.test_name === "string" ? e.properties.test_name : undefined;
573
+ link(e.from_external_id, e.to_external_id, true, testName);
574
+ }
565
575
  }
566
576
  for (const e of graph.candidate_edges ?? []) {
567
577
  if (e.review_status === "ai_suggested")
@@ -595,7 +605,7 @@ export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING
595
605
  let weakCount = 0;
596
606
  for (const t of tests) {
597
607
  if (t.hard)
598
- hard.push({ symId, testRel: t.test, hard: true });
608
+ hard.push({ symId, testRel: t.test, hard: true, ...(t.testName ? { testName: t.testName } : {}) });
599
609
  else if (weakCount++ < maxWeakPerSymbol)
600
610
  weak.push({ symId, testRel: t.test, hard: false });
601
611
  }
@@ -624,7 +634,7 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
624
634
  const reader = fileReaderFor(sourceRoot); // R-2: source scan for the node:sqlite env profile
625
635
  // Fix 3: hard TESTED_BY/COVERS pairs first, weak MAY_* pairs after and capped per symbol.
626
636
  const queue = orderExistingAttempts(existingAssociatedTests(graph, nodeById));
627
- for (const { symId, testRel, hard } of queue) {
637
+ for (const { symId, testRel, hard, testName } of queue) {
628
638
  if (attempted >= budget)
629
639
  break;
630
640
  const node = nodeById.get(symId);
@@ -649,7 +659,7 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
649
659
  // needs_setup WITHOUT re-running (and WITHOUT consuming the attempt budget).
650
660
  const isPython = isPythonFile(targetFileRel);
651
661
  const candidateTestRels = isPython
652
- ? pytestNodeidsForFile(sourceRoot, testRel).filter((candidate) => isRunnableTestForTarget(node, candidate))
662
+ ? pytestNodeidsForTarget(sourceRoot, testRel, testName).filter((candidate) => isRunnableTestForTarget(node, candidate))
653
663
  : [testRel];
654
664
  if (candidateTestRels.length === 0)
655
665
  continue;
@@ -686,6 +696,15 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
686
696
  // target references node:sqlite. Makes the baseline runnable only; never mints Proven.
687
697
  const testEnv = isGo || isJava || isPython ? undefined : experimentalSqliteTestEnv(reader, targetFileRel);
688
698
  const displayTest = nativeTestRun ?? proofTestRel;
699
+ // G5: name the plan before the attempt — detected runner/selector + target —
700
+ // so a detection miss is a visible, named thing instead of a mysterious 0.
701
+ reportProgress(`proof plan: ${symId} → ${isGo
702
+ ? `go test -run '${nativeTestRun}'`
703
+ : isJava
704
+ ? `mvn test -Dtest=${nativeTestRun}`
705
+ : isPython
706
+ ? `pytest ${displayTest}`
707
+ : `js test file ${displayTest}`}`);
689
708
  let result;
690
709
  try {
691
710
  result = proveLoop(root, nativeTestRun
@@ -923,6 +942,8 @@ export async function autoProve(root, opts, deps) {
923
942
  attempted++;
924
943
  // R-2: inject the node:sqlite env profile when the TARGET source references the builtin.
925
944
  const testEnv = experimentalSqliteTestEnv(reader, targetFileRel);
945
+ // G5: plan line for the generated-test attempt (runner from the run hint).
946
+ reportProgress(`proof plan: ${hint.prove_run.args.target_symbol} → ${hint.prove_run.args.runner ?? "auto"} on ${writeRel}`);
926
947
  let result;
927
948
  try {
928
949
  result = proveLoop(root, {
@@ -64,9 +64,23 @@ function dynamicProofSpikePath() {
64
64
  * never sandboxes a directory outside the trusted checkout.
65
65
  * ponytail: nearest-ancestor go.mod; multi-module edge cases (nested/replace) resolve to G-INT-3.
66
66
  */
67
- function goModuleRoot(sourceRoot, targetRel) {
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) {
68
82
  let dir = dirname(resolve(sourceRoot, targetRel));
69
- const stop = resolve(sourceRoot);
83
+ const stop = moduleRootBound(sourceRoot, workspaceRoot);
70
84
  for (;;) {
71
85
  if (existsSync(join(dir, "go.mod")))
72
86
  return dir;
@@ -77,7 +91,7 @@ function goModuleRoot(sourceRoot, targetRel) {
77
91
  break;
78
92
  dir = parent;
79
93
  }
80
- throw new Error(`No go.mod found for Go target ${targetRel} under ${sourceRoot}.`);
94
+ throw new Error(`No go.mod found for Go target ${targetRel} under ${stop}.`);
81
95
  }
82
96
  /**
83
97
  * Resolve the Java MODULE root: the nearest `pom.xml` (or `build.gradle` /
@@ -87,9 +101,9 @@ function goModuleRoot(sourceRoot, targetRel) {
87
101
  * spike never sandboxes a directory outside the trusted checkout.
88
102
  * ponytail: nearest-ancestor build file; multi-module reactor edge cases resolve to J-INT-3.
89
103
  */
90
- function javaModuleRoot(sourceRoot, targetRel) {
104
+ function javaModuleRoot(sourceRoot, targetRel, workspaceRoot) {
91
105
  let dir = dirname(resolve(sourceRoot, targetRel));
92
- const stop = resolve(sourceRoot);
106
+ const stop = moduleRootBound(sourceRoot, workspaceRoot);
93
107
  for (;;) {
94
108
  if (existsSync(join(dir, "pom.xml")) || existsSync(join(dir, "build.gradle")) || existsSync(join(dir, "build.gradle.kts"))) {
95
109
  return dir;
@@ -101,7 +115,7 @@ function javaModuleRoot(sourceRoot, targetRel) {
101
115
  break;
102
116
  dir = parent;
103
117
  }
104
- throw new Error(`No pom.xml or build.gradle found for Java target ${targetRel} under ${sourceRoot}.`);
118
+ throw new Error(`No pom.xml or build.gradle found for Java target ${targetRel} under ${stop}.`);
105
119
  }
106
120
  /** Route to the per-language spike. TS/JS keeps the original path; native profiles use their own mechanisms. */
107
121
  function dynamicProofSpikePathFor(language) {
@@ -908,6 +922,33 @@ export function opRecordRun(root, opts, deps = defaultDeps()) {
908
922
  ts: deps.clock()
909
923
  });
910
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
+ }
911
952
  export function opDynamicProof(root, opts, deps = defaultDeps()) {
912
953
  const paths = workspacePaths(root);
913
954
  const graph = loadGraph(paths.graphPath);
@@ -932,6 +973,7 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
932
973
  // DynamicProofOracleSummary shape. Everything from `closed` onward (the trust gate,
933
974
  // the cert, appendLedgerRecord, the return summary) is language-agnostic and unchanged.
934
975
  let oracle;
976
+ let proofModuleRoot;
935
977
  let testRel;
936
978
  let replacementMode;
937
979
  let runner;
@@ -944,7 +986,11 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
944
986
  if (!/^\^.+\$$/.test(opts.test_run)) {
945
987
  throw new Error("prove --test-run must be a fully-anchored test name, e.g. '^TestName$'.");
946
988
  }
947
- const goRoot = goModuleRoot(sourceRoot, targetRel);
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
+ }
948
994
  const args = [
949
995
  "--root",
950
996
  goRoot,
@@ -984,7 +1030,11 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
984
1030
  }
985
1031
  const testClass = opts.test_run.slice(0, hash);
986
1032
  const testMethod = opts.test_run.slice(hash + 1);
987
- const javaRoot = javaModuleRoot(sourceRoot, targetRel);
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
+ }
988
1038
  const args = [
989
1039
  "--root",
990
1040
  javaRoot,
@@ -1026,13 +1076,20 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
1026
1076
  if ((opts.test_env?.length ?? 0) > 0) {
1027
1077
  throw new Error("prove --test-env is not supported for Python targets yet.");
1028
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
+ }
1029
1086
  const args = [
1030
1087
  "--root",
1031
- sourceRoot,
1088
+ pyRoot,
1032
1089
  "--test",
1033
- testRel,
1090
+ relative(pyRoot, resolve(sourceRoot, testRel)).split(sep).join("/"),
1034
1091
  "--target",
1035
- targetRel,
1092
+ relative(pyRoot, resolve(sourceRoot, targetRel)).split(sep).join("/"),
1036
1093
  "--func",
1037
1094
  method,
1038
1095
  "--mode",
@@ -1042,7 +1099,7 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
1042
1099
  if (opts.timeout_ms !== undefined)
1043
1100
  args.push("--timeout-ms", String(opts.timeout_ms));
1044
1101
  const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, {
1045
- cwd: sourceRoot,
1102
+ cwd: pyRoot,
1046
1103
  scriptPath: dynamicProofSpikePathFor("python")
1047
1104
  });
1048
1105
  oracle = parseDynamicProofJson(run.stdout, run.stderr);
@@ -1126,6 +1183,7 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
1126
1183
  });
1127
1184
  return {
1128
1185
  ...result,
1186
+ ...(proofModuleRoot ? { module_root: proofModuleRoot } : {}),
1129
1187
  oracle: {
1130
1188
  status: oracle.status,
1131
1189
  proven: oracle.proven,
@@ -1502,6 +1560,36 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1502
1560
  };
1503
1561
  warnings.push(`auto-prove skipped: ${reason}`);
1504
1562
  }
1563
+ if (!opts.noAuto && providerConfigured && opts.ai !== false) {
1564
+ try {
1565
+ const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
1566
+ const generatedTargets = new Set((graphForGeneration.generated_tests ?? []).map((t) => t.target_symbol_external_id).filter((id) => Boolean(id)));
1567
+ const targetIds = rankRiskGaps(graphForGeneration, { repoRoot: root, limit: START_GENERATE_RISK_LIMIT })
1568
+ .map((gap) => gap.id)
1569
+ .filter((id) => !generatedTargets.has(id));
1570
+ if (targetIds.length) {
1571
+ reportProgress(`generate: drafting tests for top ${targetIds.length} risk target(s)`, { current: 6, total: 8 });
1572
+ let accepted = 0;
1573
+ for (let i = 0; i < targetIds.length; i += START_GENERATE_BATCH_LIMIT) {
1574
+ const batch = targetIds.slice(i, i + START_GENERATE_BATCH_LIMIT);
1575
+ const generated = await opGenerate(root, {
1576
+ ...providerOpts,
1577
+ target_ids: batch,
1578
+ limit: batch.length,
1579
+ prompt_version: opts.promptVersion ?? "v5"
1580
+ }, providerDeps);
1581
+ accepted += generated.generated_tests.length;
1582
+ warnings.push(...generated.warnings.map((w) => `generate: ${w}`));
1583
+ }
1584
+ if (accepted === 0)
1585
+ warnings.push("generate: provider returned no accepted tests for the top risk targets.");
1586
+ }
1587
+ }
1588
+ catch (err) {
1589
+ const reason = err instanceof Error ? err.message : String(err);
1590
+ warnings.push(`generate skipped: ${reason}`);
1591
+ }
1592
+ }
1505
1593
  // G1: persist the distilled, already-redacted attempt classifications so
1506
1594
  // `opro doctor --proof` and standalone report regens can explain blockers
1507
1595
  // after this process exits. Sidecar only — never read by the oracle, RTM,
@@ -1622,6 +1710,8 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1622
1710
  }
1623
1711
  const NO_PROVIDER_MESSAGE = 'No model provider configured. Set OPENAI_API_KEY (or OLLAMA_BASE_URL / ANTHROPIC_API_KEY) in your shell environment or a .env.provider.local file to generate with your own model, or pass provider="deterministic" (or set ORANGEPRO_ALLOW_DETERMINISTIC=1) to use the offline deterministic stand-in. No tests were generated.';
1624
1712
  const START_RTM_LIMIT = 500;
1713
+ const START_GENERATE_RISK_LIMIT = 20;
1714
+ const START_GENERATE_BATCH_LIMIT = 5;
1625
1715
  const EMPTY_EVIDENCE_SUMMARY = {
1626
1716
  tests: 0,
1627
1717
  tests_with_proof: 0,
@@ -1653,7 +1743,7 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1653
1743
  // deterministic stand-in is opt-in only; otherwise return setup guidance
1654
1744
  // instead of silently degrading.
1655
1745
  const providerEnv = loadProviderEnv([root], deps.env);
1656
- const provider = resolveGenerationProvider(providerEnv, opts);
1746
+ const provider = deps.aiProvider ?? resolveGenerationProvider(providerEnv, opts);
1657
1747
  if (!provider) {
1658
1748
  return {
1659
1749
  run_id: null,
@@ -167,7 +167,6 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
167
167
  .risk-rank{font-size:10px;color:var(--orange);font-weight:700;margin-bottom:3px}
168
168
  .risk-ep{font-family:var(--mono);font-size:13px;margin:0 0 6px}
169
169
  .risk-ep .v{color:var(--green);font-weight:700}
170
- .risk-desc{font-size:12px;color:var(--muted);margin:0 0 8px;max-width:72ch}
171
170
  .risk-tags{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px}
172
171
  .todo{background:var(--gbg);border:1px solid var(--gbd);border-radius:6px;padding:8px 11px;font-size:11.5px;color:var(--ink2)}
173
172
 
@@ -365,7 +364,7 @@ function tierOf(b){
365
364
  }
366
365
  function ctaOf(b){
367
366
  if(b.tier==="proven")return"This method is Dynamically Proven. A test breaks if you mutate it. Keep it green.";
368
- if(b.tier==="assoc")return"A test calls this method, but doesn't prove breakage. Run <code>npx -y @orangepro/orangepro-mcp opro start</code> to attempt dynamic proof.";
367
+ if(b.tier==="assoc")return"A test calls this method, but doesn't prove breakage. Run <code>npx -y @orangepro/orangepro-mcp start</code> to attempt dynamic proof.";
369
368
  if(b.tier==="none"&&b.reachable)return"This method is reachable from a tested path but has no direct test. Write one.";
370
369
  return"Nothing in your test suite touches this method. Write a test that calls it and asserts the output.";
371
370
  }
@@ -513,7 +512,6 @@ function riskCardHtml(r){
513
512
  }
514
513
  return \`<div class="risk-rank">#\${r.rank}</div>
515
514
  <div class="risk-ep"><span class="v">\${esc(r.verb)}</span> \${esc(r.path)}</div>
516
- <div class="risk-desc">\${esc(r.desc)}</div>
517
515
  <div class="risk-tags">\${tags}</div>
518
516
  <div class="todo">\${esc(r.todo)}</div>\${testsHtml}\${catHtml}\`;
519
517
  }
@@ -18,7 +18,7 @@ Install OrangePro from the Cursor Marketplace when it is listed. Until then, use
18
18
  plugins/orangepro
19
19
  ```
20
20
 
21
- The plugin starts the `orangepro-local` MCP server through `npx -y -p @orangepro/orangepro-mcp@latest opro mcp` and applies OrangePro rules for gap/test workflows. It does not pin a model; provider keys and model defaults come from your environment, `.env.provider.local`, `.env.local`, `.env`, or `opro setup`.
21
+ The plugin starts the `orangepro-local` MCP server through `npx -y @orangepro/mcp-server@latest mcp` and applies OrangePro rules for gap/test workflows. It does not pin a model; provider keys and model defaults come from your environment, `.env.provider.local`, `.env.local`, `.env`, or `opro setup`.
22
22
 
23
23
  ## MCP Setup
24
24
 
@@ -28,7 +28,7 @@ For a direct OpenCode config, add this server under `mcp`:
28
28
  "orangepro-local": {
29
29
  "enabled": true,
30
30
  "command": "npx",
31
- "args": ["-y", "-p", "@orangepro/orangepro-mcp@latest", "opro", "mcp"]
31
+ "args": ["-y", "@orangepro/mcp-server@latest", "mcp"]
32
32
  }
33
33
  }
34
34
  }
@@ -19,7 +19,7 @@ Add OrangePro as an MCP server in your user or workspace MCP config:
19
19
  "servers": {
20
20
  "orangepro-local": {
21
21
  "command": "npx",
22
- "args": ["-y", "-p", "@orangepro/orangepro-mcp@latest", "opro", "mcp"]
22
+ "args": ["-y", "@orangepro/mcp-server@latest", "mcp"]
23
23
  }
24
24
  }
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "description": "OrangePro (`opro`) — a local-first, BYOK CLI + MCP server that builds an evidence graph from a local checkout, ingests runtime coverage, and generates grounded tests. Metadata-only exports; no source upload; generated tests stay local.",
6
6
  "license": "MIT",
@@ -99,7 +99,8 @@ function runPytest(repoRoot, nodeid, timeoutMs) {
99
99
 
100
100
  function exactNodeIdPattern(nodeid) {
101
101
  const escaped = nodeid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\//g, "[/\\\\]");
102
- return new RegExp(`FAILED\\s+${escaped}(?:\\s|$)`);
102
+ const selectedParamSet = /\[[^\]]+\]$/.test(nodeid);
103
+ return new RegExp(`FAILED\\s+${escaped}${selectedParamSet ? "" : "(?:\\[[^\\]\\n]+\\])?"}(?:\\s|$)`);
103
104
  }
104
105
 
105
106
  function isExactPytestNodeId(nodeid) {