@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 +3 -3
- package/dist/local/analyze/analyzer.js +16 -11
- package/dist/local/analyze/treeSitter/engine.js +13 -5
- package/dist/local/autoProve.js +33 -12
- package/dist/local/operations.js +103 -13
- package/dist/local/viz/behaviorReportHtml.js +1 -3
- package/docs/agents/cursor.md +1 -1
- package/docs/agents/opencode.md +1 -1
- package/docs/agents/vscode.md +1 -1
- package/package.json +1 -1
- package/scripts/spikes/python-dynamic-proof-spike.mjs +2 -1
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/
|
|
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/
|
|
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", "
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1621
|
+
return binding.targetRel && !isNonProductFile(binding.targetRel) ? eligiblePythonSymbol(binding.targetRel, callee) : null;
|
|
1622
1622
|
}
|
|
1623
|
-
|
|
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(
|
|
1626
|
+
if (!classTargetRel || symbolKind(classTargetRel, rootQualifier) !== "class")
|
|
1626
1627
|
return null;
|
|
1627
|
-
return eligiblePythonSymbol(
|
|
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_
|
|
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) {
|
package/dist/local/autoProve.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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, {
|
package/dist/local/operations.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 ${
|
|
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 =
|
|
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 ${
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
}
|
package/docs/agents/cursor.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/agents/opencode.md
CHANGED
|
@@ -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", "
|
|
31
|
+
"args": ["-y", "@orangepro/mcp-server@latest", "mcp"]
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
package/docs/agents/vscode.md
CHANGED
|
@@ -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", "
|
|
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.
|
|
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
|
-
|
|
102
|
+
const selectedParamSet = /\[[^\]]+\]$/.test(nodeid);
|
|
103
|
+
return new RegExp(`FAILED\\s+${escaped}${selectedParamSet ? "" : "(?:\\[[^\\]\\n]+\\])?"}(?:\\s|$)`);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
function isExactPytestNodeId(nodeid) {
|