@orangepro/orangepro-mcp 0.2.2 → 0.2.4
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 +65 -12
- package/dist/local/analyze/analyzer.js +61 -2
- package/dist/local/analyze/parseCache.js +1 -1
- package/dist/local/analyze/treeSitter/engine.js +141 -5
- package/dist/local/autoProve.js +39 -21
- package/dist/local/cli.js +7 -1
- package/dist/local/flows/llmFlowDiscovery.js +16 -5
- package/dist/local/graph/ontology.js +3 -1
- package/dist/local/mcp.js +1 -1
- package/dist/local/operations.js +36 -5
- package/dist/local/util/walk.js +5 -4
- package/dist/local/viz/behaviorReportData.js +2 -3
- package/dist/local/viz/behaviorReportHtml.js +9 -8
- package/dist/local/viz/coverageReveal.js +29 -0
- package/dist/local/workspace.js +25 -2
- package/package.json +6 -2
- package/scripts/spikes/go-dynamic-proof-spike.mjs +17 -13
- package/scripts/spikes/go-mutate.go +103 -21
- package/scripts/spikes/java-dynamic-proof-spike.mjs +6 -4
- package/scripts/spikes/java-mutate.mjs +3 -2
|
@@ -19,8 +19,20 @@ const MAX_PROMPT_ENTRIES = 60;
|
|
|
19
19
|
const MAX_PROMPT_SYMBOLS = 300;
|
|
20
20
|
const MAX_COMPLETION_TOKENS = 3000;
|
|
21
21
|
export function aiFlowsPath(root) {
|
|
22
|
+
return join(workspacePaths(root).dir, "flows.json");
|
|
23
|
+
}
|
|
24
|
+
function legacyAiFlowsPath(root) {
|
|
22
25
|
return join(workspacePaths(root).dir, "ai", "flows.json");
|
|
23
26
|
}
|
|
27
|
+
function readAiFlowsArtifact(root) {
|
|
28
|
+
const path = aiFlowsPath(root);
|
|
29
|
+
const current = readArtifact(path);
|
|
30
|
+
if (current.artifact || current.invalid)
|
|
31
|
+
return { ...current, path };
|
|
32
|
+
const legacy = legacyAiFlowsPath(root);
|
|
33
|
+
const fallback = readArtifact(legacy);
|
|
34
|
+
return { ...fallback, path: fallback.artifact || fallback.invalid ? legacy : path };
|
|
35
|
+
}
|
|
24
36
|
function buildAnchorContext(graph) {
|
|
25
37
|
const entries = dedupeEntries([
|
|
26
38
|
...endpointEntries(graph.nodes, graph.edges),
|
|
@@ -210,12 +222,12 @@ export async function generateAiFlows(root, graph, provider, clock) {
|
|
|
210
222
|
node_set_hash: nodeSetHash
|
|
211
223
|
}));
|
|
212
224
|
const path = aiFlowsPath(root);
|
|
213
|
-
const cached =
|
|
225
|
+
const cached = readAiFlowsArtifact(root);
|
|
214
226
|
if (cached.artifact?.cache_key === cacheKey) {
|
|
215
227
|
const hit = cached.artifact;
|
|
216
228
|
return {
|
|
217
229
|
mode: "generate",
|
|
218
|
-
ai_flows_path: path,
|
|
230
|
+
ai_flows_path: cached.path,
|
|
219
231
|
cache_hit: true,
|
|
220
232
|
model_provider: hit.model_provider,
|
|
221
233
|
model_name: hit.model_name,
|
|
@@ -306,8 +318,7 @@ export async function generateAiFlows(root, graph, provider, clock) {
|
|
|
306
318
|
* edges/candidate_edges/nodes/analysis.flows.
|
|
307
319
|
*/
|
|
308
320
|
export function applyAiFlows(root, graph) {
|
|
309
|
-
const path =
|
|
310
|
-
const { artifact, invalid } = readArtifact(path);
|
|
321
|
+
const { path, artifact, invalid } = readAiFlowsArtifact(root);
|
|
311
322
|
if (invalid) {
|
|
312
323
|
throw new Error(`AI flows artifact at ${path} is invalid or corrupted; re-run \`opro ai-flows\` to regenerate it.`);
|
|
313
324
|
}
|
|
@@ -413,7 +424,7 @@ function toCandidateFlow(flow, entriesById, knownEdges, provenance) {
|
|
|
413
424
|
...(flow.rationale ? { rationale: flow.rationale } : {}),
|
|
414
425
|
provenance: {
|
|
415
426
|
source_scope_id: `ai:${provenance.cache_key}`,
|
|
416
|
-
source_ref: ".orangepro/
|
|
427
|
+
source_ref: ".orangepro/flows.json",
|
|
417
428
|
detector: "ai_flows",
|
|
418
429
|
model_provider: provenance.model_provider,
|
|
419
430
|
model_name: provenance.model_name,
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* The graph is built directly by OrangePro; it does not depend on any
|
|
10
10
|
* third-party graph product or format.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
// v2: Go method symbol ids are receiver-qualified (`sym:file.go#Recv.M`) — old
|
|
13
|
+
// graphs hold bare-name method ids and must force-rebuild (loadGraph hard-fails).
|
|
14
|
+
export const LOCAL_GRAPH_SCHEMA_VERSION = "orangepro.local_graph.v2";
|
|
13
15
|
/** Node kinds that map to behaviors/requirements for scoring + gaps + generation. */
|
|
14
16
|
export const BEHAVIOR_KINDS = new Set([
|
|
15
17
|
"Requirement",
|
package/dist/local/mcp.js
CHANGED
|
@@ -407,7 +407,7 @@ export function createLocalServer() {
|
|
|
407
407
|
});
|
|
408
408
|
server.registerTool("orangepro_ai_flows", {
|
|
409
409
|
title: "Stage/apply AI candidate flows",
|
|
410
|
-
description: "Opt-in AI lane: propose candidate behavior-flow chains over existing deterministic entry and CodeSymbol ids. Generate writes .orangepro/
|
|
410
|
+
description: "Opt-in AI lane: propose candidate behavior-flow chains over existing deterministic entry and CodeSymbol ids. Generate writes .orangepro/flows.json only; apply=true stores survivors under analysis.candidate_flows. Candidate flows are a verify-these worklist and never affect Proven, deterministic flow counts, tiers, or coverage.",
|
|
411
411
|
inputSchema: {
|
|
412
412
|
...Workspace,
|
|
413
413
|
apply: z.boolean().optional().describe("Apply staged flows into analysis.candidate_flows. Default false stages flows only."),
|
package/dist/local/operations.js
CHANGED
|
@@ -273,11 +273,14 @@ function symbolTargetParts(symExtId) {
|
|
|
273
273
|
throw new Error(`Cannot derive dynamic proof target from symbol id: ${symExtId}`);
|
|
274
274
|
}
|
|
275
275
|
const [, file, symbolName] = match;
|
|
276
|
-
const
|
|
276
|
+
const segments = symbolName.split(".").filter(Boolean);
|
|
277
|
+
const method = segments.pop();
|
|
277
278
|
if (!file || !method) {
|
|
278
279
|
throw new Error(`Cannot derive dynamic proof target from symbol id: ${symExtId}`);
|
|
279
280
|
}
|
|
280
|
-
|
|
281
|
+
// The owner qualifier of a member id (TS `Class.method`, Go `Recv.M`). The Go
|
|
282
|
+
// lane passes it as --recv so the mutator matches the exact receiver.
|
|
283
|
+
return { file, method, ...(segments.length ? { memberQualifier: segments.join(".") } : {}) };
|
|
281
284
|
}
|
|
282
285
|
function assertProofTargetMatchesSymbol(opts, symbolTarget) {
|
|
283
286
|
if (opts.target_path !== undefined && opts.target_path !== "") {
|
|
@@ -560,8 +563,9 @@ export function opAnalyze(root, opts = {}, deps = defaultDeps()) {
|
|
|
560
563
|
? (!opts.suppressProgress && reportProgress("coverage: generating local runtime coverage before graph build", { current: 2, total: 4 }),
|
|
561
564
|
prepareRuntimeCoverage(scanRoot, { generate: true, timeoutMs: opts.coverageTimeoutMs, runner: deps.coverageRunner }))
|
|
562
565
|
: undefined;
|
|
563
|
-
|
|
564
|
-
|
|
566
|
+
// Idempotent for existing workspaces and also applies conservative migrations
|
|
567
|
+
// to untouched generated workspace files (for example .orangeproignore).
|
|
568
|
+
initWorkspace(root, now);
|
|
565
569
|
if (!opts.suppressProgress) {
|
|
566
570
|
reportProgress("analyze: parsing source and building deterministic graph", {
|
|
567
571
|
current: opts.generateCoverage ? 3 : 2,
|
|
@@ -1007,6 +1011,10 @@ export function opDynamicProof(root, opts, deps = defaultDeps()) {
|
|
|
1007
1011
|
// Slice 2: bind a runtime-named subtest's mutant failure to the exact assertion line.
|
|
1008
1012
|
if (opts.go_assertion_line !== undefined)
|
|
1009
1013
|
args.push("--go-assertion-line", String(opts.go_assertion_line));
|
|
1014
|
+
// Receiver-qualified method target (`sym:file.go#Recv.M`) → the mutator must
|
|
1015
|
+
// match the exact receiver, never a same-named decl on another type.
|
|
1016
|
+
if (symbolTarget.memberQualifier)
|
|
1017
|
+
args.push("--recv", symbolTarget.memberQualifier);
|
|
1010
1018
|
const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, {
|
|
1011
1019
|
cwd: goRoot,
|
|
1012
1020
|
scriptPath: dynamicProofSpikePathFor("go")
|
|
@@ -1458,6 +1466,28 @@ export function autoProveChangedScope(graph, changed, baseRef) {
|
|
|
1458
1466
|
});
|
|
1459
1467
|
return hasEligibleTarget ? meaningful : undefined; // no eligible provable target in scope → global top-5
|
|
1460
1468
|
}
|
|
1469
|
+
function writeStartStaticSnapshot(root, baseRef, warnings) {
|
|
1470
|
+
let behaviorCoveragePath;
|
|
1471
|
+
try {
|
|
1472
|
+
reportProgress("artifacts: writing static behavior view (proof still running)", { current: 4, total: 8 });
|
|
1473
|
+
behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, {
|
|
1474
|
+
attempted: 0,
|
|
1475
|
+
proven: 0,
|
|
1476
|
+
needsSetup: []
|
|
1477
|
+
}).behavior_coverage_path;
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
warnings.push(`static behavior view not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
reportProgress("artifacts: writing static RTM (proof still running)", { current: 4, total: 8 });
|
|
1484
|
+
opRtm(root, { format: "md", baseRef, limit: START_RTM_LIMIT });
|
|
1485
|
+
}
|
|
1486
|
+
catch (error) {
|
|
1487
|
+
warnings.push(`static RTM not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1488
|
+
}
|
|
1489
|
+
return behaviorCoveragePath ? { behaviorCoveragePath } : {};
|
|
1490
|
+
}
|
|
1461
1491
|
export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
1462
1492
|
const providerOpts = startProviderOverride(root, opts);
|
|
1463
1493
|
const scanRoot = opts.source ? resolve(opts.source) : resolve(root);
|
|
@@ -1475,6 +1505,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
|
1475
1505
|
}, deps);
|
|
1476
1506
|
const warnings = [...analyze.warnings];
|
|
1477
1507
|
reportProgress("start: deterministic graph is ready", { current: 4, total: 8 });
|
|
1508
|
+
const staticSnapshot = writeStartStaticSnapshot(root, opts.baseRef, warnings);
|
|
1478
1509
|
const providerConfigured = deps.aiProvider !== undefined || resolveProviderConfig(providerEnv, providerOpts) !== null;
|
|
1479
1510
|
let aiLinks = { status: "skipped", reason: "AI candidate links disabled for this run." };
|
|
1480
1511
|
if (opts.ai !== false) {
|
|
@@ -1611,7 +1642,7 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
|
1611
1642
|
catch (error) {
|
|
1612
1643
|
warnings.push(`coverage report not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1613
1644
|
}
|
|
1614
|
-
let coverageHtml;
|
|
1645
|
+
let coverageHtml = staticSnapshot.behaviorCoveragePath;
|
|
1615
1646
|
try {
|
|
1616
1647
|
reportProgress("artifacts: writing behavior coverage view", { current: 7, total: 8 });
|
|
1617
1648
|
// Forward THIS-RUN dynamic-proof outcome so the report can name the dominant setup/runnability
|
package/dist/local/util/walk.js
CHANGED
|
@@ -66,20 +66,21 @@ export function loadIgnore(root) {
|
|
|
66
66
|
const line = raw.trim();
|
|
67
67
|
if (!line || line.startsWith("#") || line.startsWith("!"))
|
|
68
68
|
continue;
|
|
69
|
+
const rootAnchored = line.startsWith("/");
|
|
69
70
|
const cleaned = line.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
70
71
|
if (!cleaned)
|
|
71
72
|
continue;
|
|
72
|
-
if (!cleaned.includes("/") && !cleaned.includes("*")) {
|
|
73
|
+
if (!rootAnchored && !cleaned.includes("/") && !cleaned.includes("*")) {
|
|
73
74
|
names.add(cleaned);
|
|
74
75
|
}
|
|
75
76
|
else {
|
|
76
|
-
matchers.push(globToRegExp(cleaned));
|
|
77
|
+
matchers.push(globToRegExp(cleaned, rootAnchored));
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
return { names, matchers };
|
|
81
82
|
}
|
|
82
|
-
function globToRegExp(glob) {
|
|
83
|
+
function globToRegExp(glob, rootAnchored = false) {
|
|
83
84
|
// Use a plain-ASCII sentinel for `**` so the file stays text (no NUL bytes)
|
|
84
85
|
// and `*` substitution does not re-match the globstar.
|
|
85
86
|
const GLOBSTAR = "__ORANGEPRO_GLOBSTAR__";
|
|
@@ -90,7 +91,7 @@ function globToRegExp(glob) {
|
|
|
90
91
|
.split(GLOBSTAR)
|
|
91
92
|
.join(".*")
|
|
92
93
|
.replace(/\?/g, "[^/]");
|
|
93
|
-
return new RegExp(
|
|
94
|
+
return new RegExp(`${rootAnchored ? "^" : "(^|/)"}${escaped}(/|$)`);
|
|
94
95
|
}
|
|
95
96
|
function isIgnored(relPath, baseName, rules) {
|
|
96
97
|
if (rules.names.has(baseName))
|
|
@@ -375,7 +375,6 @@ function riskRows(risks, graph) {
|
|
|
375
375
|
firstRowForFile.set(r.file, r.id);
|
|
376
376
|
return risks.map((risk, idx) => {
|
|
377
377
|
const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
|
|
378
|
-
const pathMatch = risk.file.match(/\/api\/(.+)$/);
|
|
379
378
|
const tags = [];
|
|
380
379
|
const bucket = riskBucket(risk.risk_score);
|
|
381
380
|
if (bucket)
|
|
@@ -394,8 +393,8 @@ function riskRows(risks, graph) {
|
|
|
394
393
|
applicableCategories: [...new Set(generatedTests.map((t) => t.concern).filter((c) => Boolean(c)))]
|
|
395
394
|
};
|
|
396
395
|
})(),
|
|
397
|
-
verb: methodMatch?.[1]?.toUpperCase() ??
|
|
398
|
-
path: methodMatch?.[2] ??
|
|
396
|
+
verb: methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR",
|
|
397
|
+
path: methodMatch?.[2] ?? risk.title,
|
|
399
398
|
desc: risk.reasons.join(" · "),
|
|
400
399
|
tags,
|
|
401
400
|
todo: "Write an integration or behavior test that calls this behavior and asserts the observable outcome."
|
|
@@ -470,8 +470,9 @@ if(cf&&cf.flows.length){
|
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
// risks + generated test samples
|
|
473
|
-
let activeRiskFilter="all";
|
|
474
473
|
const riskList=$("#risk-list"),riskTools=$("#risk-tools");
|
|
474
|
+
const generatedRiskCount=D.risks.filter(r=>r.generatedTests&&r.generatedTests.length).length;
|
|
475
|
+
let activeRiskFilter=generatedRiskCount?"generated":"all";
|
|
475
476
|
function riskMatchesFilter(r){
|
|
476
477
|
const hasGenerated=Boolean(r.generatedTests&&r.generatedTests.length);
|
|
477
478
|
if(activeRiskFilter==="generated")return hasGenerated;
|
|
@@ -481,7 +482,7 @@ function riskMatchesFilter(r){
|
|
|
481
482
|
function renderRiskFilters(){
|
|
482
483
|
const options=[
|
|
483
484
|
["all","All",D.risks.length],
|
|
484
|
-
["generated","
|
|
485
|
+
["generated","Flows with tests",generatedRiskCount],
|
|
485
486
|
["missing","No generated tests",D.risks.filter(r=>!(r.generatedTests&&r.generatedTests.length)).length]
|
|
486
487
|
];
|
|
487
488
|
riskTools.innerHTML=options.map(([key,label,count])=>\`<button class="risk-filter" type="button" data-risk-filter="\${key}" aria-pressed="\${key===activeRiskFilter}">\${label} <span class="gc">\${count}</span></button>\`).join("");
|
|
@@ -519,16 +520,16 @@ function renderRisks(){
|
|
|
519
520
|
riskList.innerHTML="";
|
|
520
521
|
D.risks.filter(riskMatchesFilter).forEach(r=>riskList.append(el("div","risk-card",riskCardHtml(r))));
|
|
521
522
|
if(activeRiskFilter==="all"&&D.generatedTotal){
|
|
522
|
-
const
|
|
523
|
-
const remainingRiskFlows=Math.max(0,D.risks.length-
|
|
523
|
+
const hiddenGeneratedFlows=Math.max(0,generatedRiskCount-D.risks.filter(riskMatchesFilter).length);
|
|
524
|
+
const remainingRiskFlows=Math.max(0,D.risks.length-generatedRiskCount);
|
|
524
525
|
riskList.append(el("div","paywall",
|
|
525
|
-
|
|
526
|
-
? \`<div class="paywall-num">\${
|
|
527
|
-
<div class="paywall-txt">OrangePro
|
|
526
|
+
hiddenGeneratedFlows
|
|
527
|
+
? \`<div class="paywall-num">\${hiddenGeneratedFlows} more flows with tests</div>
|
|
528
|
+
<div class="paywall-txt">OrangePro accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Use the “Flows with tests” filter to review them first.</div>
|
|
528
529
|
<a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">View all on OrangePro Platform →</a>\`
|
|
529
530
|
: remainingRiskFlows
|
|
530
531
|
? \`<div class="paywall-num">\${remainingRiskFlows} high-risk flows left</div>
|
|
531
|
-
<div class="paywall-txt">The local MCP accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"}
|
|
532
|
+
<div class="paywall-txt">The local MCP accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Generate the remaining high-risk flow tests on OrangePro Platform.</div>
|
|
532
533
|
<a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">Generate remaining tests on Platform →</a>\`
|
|
533
534
|
: \`<div class="paywall-num">All generated tests are shown</div>
|
|
534
535
|
<div class="paywall-txt">OrangePro generated tests for every high-risk flow in this report, and every generated test is visible here.</div>\`));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G6 — the coverage-vs-proof reveal line for the start summary.
|
|
3
|
+
*
|
|
4
|
+
* Compares ONLY same-scope numbers: both sides are RTM counts over the SAME
|
|
5
|
+
* denominator (`summary.total`). The proven side is `coverage_confirmed` — the
|
|
6
|
+
* IN-DENOMINATOR proven count — never `summary.proven`, which also counts
|
|
7
|
+
* off-denominator proofs (relaxed hard-edge targets below the entry-point bar)
|
|
8
|
+
* and can exceed `total` (a 5/3 = 167% render). The signature omits `proven`
|
|
9
|
+
* entirely so the off-denominator number cannot be passed by mistake.
|
|
10
|
+
*
|
|
11
|
+
* `runtime_covered` is the DISJOINT runtime tier — behaviors a repo coverage
|
|
12
|
+
* report executed that are NOT dynamically proven. Runtime coverage only proves
|
|
13
|
+
* EXECUTION, and the unproven side may simply be unattempted or unrunnable —
|
|
14
|
+
* the line says so and never blames the tests outright. Source-report
|
|
15
|
+
* line-coverage totals (lcov LF/LH etc.) are NOT retained by ingestion and are
|
|
16
|
+
* never invented here (spec G6 guardrail). Never implies proven SHOULD equal
|
|
17
|
+
* coverage — they measure different things.
|
|
18
|
+
*
|
|
19
|
+
* Null when no runtime coverage was ingested (nothing to reveal) or the
|
|
20
|
+
* denominator is empty.
|
|
21
|
+
*/
|
|
22
|
+
export function coverageRevealLine(summary) {
|
|
23
|
+
if (summary.runtime_covered <= 0 || summary.total <= 0)
|
|
24
|
+
return null;
|
|
25
|
+
const pct = (n) => Math.round((n / summary.total) * 100);
|
|
26
|
+
return (`Coverage vs proof: ${summary.runtime_covered}/${summary.total} behaviors are runtime-covered but not Dynamically Proven ` +
|
|
27
|
+
`(${pct(summary.runtime_covered)}%) vs ${summary.coverage_confirmed}/${summary.total} Dynamically Proven (${pct(summary.coverage_confirmed)}%) — ` +
|
|
28
|
+
`coverage only proves execution; the unproven side may be unattempted, blocked, or covered by tests that never assert these behaviors.`);
|
|
29
|
+
}
|
package/dist/local/workspace.js
CHANGED
|
@@ -21,7 +21,7 @@ export function workspaceInitialized(root) {
|
|
|
21
21
|
export function graphExists(root) {
|
|
22
22
|
return existsSync(workspacePaths(root).graphPath);
|
|
23
23
|
}
|
|
24
|
-
const
|
|
24
|
+
const ORANGEPROIGNORE_PREAMBLE = `# .orangeproignore — paths the OrangePro local proof kit should never read.
|
|
25
25
|
# Same spirit as .gitignore. Secrets and large assets are excluded by default.
|
|
26
26
|
*.env
|
|
27
27
|
*.pem
|
|
@@ -32,7 +32,8 @@ secrets/
|
|
|
32
32
|
|
|
33
33
|
# Product-denominator defaults: example/demo apps are useful references, but
|
|
34
34
|
# they usually should not count as product behavior coverage.
|
|
35
|
-
|
|
35
|
+
`;
|
|
36
|
+
export const LEGACY_ORANGEPROIGNORE_TEMPLATE = `${ORANGEPROIGNORE_PREAMBLE}examples/
|
|
36
37
|
example/
|
|
37
38
|
demos/
|
|
38
39
|
demo/
|
|
@@ -42,6 +43,16 @@ docs/examples/
|
|
|
42
43
|
docs/demo/
|
|
43
44
|
docs/demos/
|
|
44
45
|
`;
|
|
46
|
+
export const ORANGEPROIGNORE_TEMPLATE = `${ORANGEPROIGNORE_PREAMBLE}/examples/
|
|
47
|
+
/example/
|
|
48
|
+
/demos/
|
|
49
|
+
/demo/
|
|
50
|
+
/samples/
|
|
51
|
+
/sample/
|
|
52
|
+
/docs/examples/
|
|
53
|
+
/docs/demo/
|
|
54
|
+
/docs/demos/
|
|
55
|
+
`;
|
|
45
56
|
export function initWorkspace(root, now) {
|
|
46
57
|
const paths = workspacePaths(root);
|
|
47
58
|
mkdirSync(paths.dir, { recursive: true });
|
|
@@ -58,6 +69,18 @@ export function initWorkspace(root, now) {
|
|
|
58
69
|
if (!existsSync(ignorePath)) {
|
|
59
70
|
writeFileSync(ignorePath, ORANGEPROIGNORE_TEMPLATE, "utf8");
|
|
60
71
|
}
|
|
72
|
+
else {
|
|
73
|
+
// Upgrade only the untouched generated template. A user-edited ignore file
|
|
74
|
+
// is configuration and must never be rewritten implicitly.
|
|
75
|
+
try {
|
|
76
|
+
if (readFileSync(ignorePath, "utf8") === LEGACY_ORANGEPROIGNORE_TEMPLATE) {
|
|
77
|
+
writeFileSync(ignorePath, ORANGEPROIGNORE_TEMPLATE, "utf8");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A read-only or transiently unavailable ignore file must not fail init.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
61
84
|
return { paths, config: loadConfig(paths) };
|
|
62
85
|
}
|
|
63
86
|
export function loadConfig(paths) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangepro/orangepro-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -85,7 +85,11 @@
|
|
|
85
85
|
"test:integration:go:auto:slow": "OPRO_SLOW_GO_AUTO=1 vitest run tests/local/autoProveGo.test.ts --pool=forks --poolOptions.forks.maxForks=1",
|
|
86
86
|
"test:integration:java:1": "vitest run tests/local/javaProveIntegration.test.ts -t \"^(?!.*(no-false-Proven|mints the same Proven)).*\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
87
87
|
"test:integration:java:2": "vitest run tests/local/javaProveIntegration.test.ts -t \"no-false-Proven|mints the same Proven\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
88
|
-
"test:integration:java:3": "
|
|
88
|
+
"test:integration:java:3": "npm run test:integration:java:3:unit && npm run test:integration:java:3:prove && npm run test:integration:java:3:survivor && npm run test:integration:java:3:refused",
|
|
89
|
+
"test:integration:java:3:unit": "vitest run tests/local/autoProveJava.test.ts -t \"javaTestForTarget|isEligibleProvableTarget\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
90
|
+
"test:integration:java:3:prove": "vitest run tests/local/autoProveJava.test.ts -t \"mints DP\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
91
|
+
"test:integration:java:3:survivor": "vitest run tests/local/autoProveJava.test.ts -t \"equivalent survivor\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
92
|
+
"test:integration:java:3:refused": "vitest run tests/local/autoProveJava.test.ts -t \"refused-shape\" --pool=forks --poolOptions.forks.maxForks=1",
|
|
89
93
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
90
94
|
"smoke:gap-fill": "npm run build && node scripts/smoke-gap-fill-loop.mjs",
|
|
91
95
|
"smoke:generate-prove": "npm run build && node scripts/smoke-generate-prove-e2e.mjs",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// go-dynamic-proof-spike.mjs — Go dynamic-proof MECHANISM (G-1).
|
|
3
3
|
//
|
|
4
|
-
// Proves ONE free function 0->1 on a single Go module by
|
|
4
|
+
// Proves ONE free function or receiver method 0->1 on a single Go module by
|
|
5
|
+
// mutation: it byte-copies
|
|
5
6
|
// the module into a hermetic sandbox, runs `go test -json` baseline, replaces the
|
|
6
7
|
// target function body with a signature-derived sentinel (via go-mutate.go), reruns
|
|
7
8
|
// the SAME test, and classifies. It emits a JSON verdict mirroring the TS/JS spike's
|
|
@@ -21,12 +22,13 @@
|
|
|
21
22
|
// assertion call (`t.Error`/`t.Errorf`, or testify `assert.`/`require.`), NOT a
|
|
22
23
|
// `t.Fatal`/`t.Fatalf`/`t.FailNow`/`t.SkipNow` hard-stop or a helper call. A build
|
|
23
24
|
// error, a panic, a t.Fatal precondition, a setup/helper failure, an unbindable
|
|
24
|
-
// failure, and an ambiguous
|
|
25
|
+
// failure, and an ambiguous or no-return name all classify as `unrunnable`,
|
|
25
26
|
// never `proven`. An equivalent-value mutation survives -> `associated_survived`.
|
|
26
27
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// PRODUCT-WIRED: `opro` routes Go dynamic proof through this script (operations.ts
|
|
29
|
+
// dynamicProofSpikePathFor("go")). The script itself writes no graph edges or product
|
|
30
|
+
// artifacts — it emits a JSON verdict; the orchestrator is the sole interpreter and
|
|
31
|
+
// the only place proof is minted.
|
|
30
32
|
import { spawnSync } from "node:child_process";
|
|
31
33
|
import { cpSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync } from "node:fs";
|
|
32
34
|
import { tmpdir } from "node:os";
|
|
@@ -39,11 +41,12 @@ function usage() {
|
|
|
39
41
|
return [
|
|
40
42
|
"Usage: node scripts/spikes/go-dynamic-proof-spike.mjs --root <module> --test-run <^TestName$> --target <rel.go> --func <name> [--json]",
|
|
41
43
|
"",
|
|
42
|
-
"Runs a Go baseline test, mutates the target free function body in an isolated byte-copy, reruns the same test, and classifies the result.",
|
|
44
|
+
"Runs a Go baseline test, mutates the target free function or method body in an isolated byte-copy, reruns the same test, and classifies the result.",
|
|
43
45
|
"--test-run is passed verbatim to `go test -run` and should anchor a single test, e.g. '^TestCompute$'.",
|
|
46
|
+
"--recv <T> (optional): receiver base type — mutate only `func (x T) <name>` / `func (x *T) <name>`, so a same-named method on another receiver (or a free function) can never be the mutation target.",
|
|
44
47
|
"--go-assertion-line <n> (optional): 1-based test-source line of the target's assertion. When set, the mutant's failure must bind to a frame at EXACTLY that line and subtest frames are considered — so a runtime-named subtest can prove while a sibling asserting elsewhere is refused.",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
48
|
+
"Scope: free functions and receiver methods. Without --recv the name must resolve to exactly ONE declaration in the target file; with --recv exactly one declaration on the selected base receiver must match. Ambiguity within the filtered receiver and generic receivers still fail closed. Equivalent-value mutations survive (associated_survived).",
|
|
49
|
+
"Product wiring: opro prove/auto-prove invokes this script for Go targets; it writes no graph edges or product artifacts itself — the caller interprets the JSON verdict."
|
|
47
50
|
].join("\n");
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -460,9 +463,9 @@ function redactSecrets(text) {
|
|
|
460
463
|
// Run the AST mutator (go run go-mutate.go). Because `go run` collapses any
|
|
461
464
|
// non-zero child status to 1, we classify on the MUTATE_ERROR:<code> marker the
|
|
462
465
|
// helper prints to stderr, not the exit code.
|
|
463
|
-
function mutateFunc({ targetAbs, func, mode, cacheRoot, timeoutMs }) {
|
|
466
|
+
function mutateFunc({ targetAbs, func, recv, mode, cacheRoot, timeoutMs }) {
|
|
464
467
|
const helper = path.join(path.dirname(fileURLToPath(import.meta.url)), "go-mutate.go");
|
|
465
|
-
const result = spawnSync(goBin(), ["run", helper, "--file", targetAbs, "--func", func, "--mode", mode], {
|
|
468
|
+
const result = spawnSync(goBin(), ["run", helper, "--file", targetAbs, "--func", func, ...(recv ? ["--recv", recv] : []), "--mode", mode], {
|
|
466
469
|
encoding: "utf8",
|
|
467
470
|
timeout: timeoutMs,
|
|
468
471
|
env: hermeticEnv(cacheRoot),
|
|
@@ -481,9 +484,9 @@ function mutateFunc({ targetAbs, func, mode, cacheRoot, timeoutMs }) {
|
|
|
481
484
|
|
|
482
485
|
function mutateErrorReason(code) {
|
|
483
486
|
switch (code) {
|
|
484
|
-
case 3: return "target
|
|
485
|
-
case 4: return "target free function was not found";
|
|
486
|
-
|
|
487
|
+
case 3: return "target name is ambiguous (more than one free function or method)";
|
|
488
|
+
case 4: return "target free function or method was not found";
|
|
489
|
+
// code 5 (method out of scope) is retired — methods are mutable now, never emitted.
|
|
487
490
|
case 6: return "target function has no return value (not mutable)";
|
|
488
491
|
default: return "mutation could not be applied";
|
|
489
492
|
}
|
|
@@ -565,6 +568,7 @@ function main() {
|
|
|
565
568
|
const mutation = mutateFunc({
|
|
566
569
|
targetAbs: path.join(mutantCopy.repoRoot, targetRel),
|
|
567
570
|
func: args.func,
|
|
571
|
+
recv: args.recv,
|
|
568
572
|
mode: args.mode,
|
|
569
573
|
cacheRoot: mutantCopy.tmpRoot,
|
|
570
574
|
timeoutMs
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// go-mutate.go — AST-based body replacer for the Go dynamic-proof spike (G-1).
|
|
4
4
|
//
|
|
5
|
-
// Locates ONE free function `func Name(
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Locates ONE function declaration by exact name — a free function `func Name(...)`
|
|
6
|
+
// or a receiver method `func (r Recv) Name(...)` — and replaces its BODY with a
|
|
7
|
+
// signature-derived sentinel, then writes the mutated file. Invoked by
|
|
8
|
+
// go-dynamic-proof-spike.mjs (the product Go proof path); it writes ONLY the
|
|
9
|
+
// mutated file inside the sandbox copy — no graph or product artifacts. The
|
|
10
|
+
// name must resolve to exactly ONE declaration — in the whole file without
|
|
11
|
+
// --recv, or on the selected base receiver with --recv <T> (receiver-exact
|
|
12
|
+
// selection for receiver-qualified `Recv.M` targets — never the wrong decl,
|
|
13
|
+
// so A.M and B.M can coexist and still be individually mutable). Ambiguity
|
|
14
|
+
// within that filter fails(3); generic receivers are refused (not found).
|
|
10
15
|
//
|
|
11
16
|
// Modes:
|
|
12
17
|
// sentinel — replace body with a type-compatible, deliberately-wrong value
|
|
@@ -19,9 +24,10 @@
|
|
|
19
24
|
// Exit codes (distinct, so the Node orchestrator can classify precisely):
|
|
20
25
|
// 0 ok, mutated file written
|
|
21
26
|
// 2 usage / IO / parse error
|
|
22
|
-
// 3 ambiguous: more than one free
|
|
23
|
-
// 4 not found: no free function with that name
|
|
24
|
-
// 5
|
|
27
|
+
// 3 ambiguous: more than one declaration (free and/or method) with that name
|
|
28
|
+
// 4 not found: no free function or method with that name
|
|
29
|
+
// 5 RETIRED — was "method out of scope (G-2)" before methods became mutable;
|
|
30
|
+
// no longer emitted (kept so codes 3/4/6 stay stable for the orchestrator)
|
|
25
31
|
// 6 not mutable: the function has no return values (no signature-derived
|
|
26
32
|
// sentinel is possible) -> fail closed, never mutated
|
|
27
33
|
package main
|
|
@@ -35,6 +41,7 @@ import (
|
|
|
35
41
|
"go/printer"
|
|
36
42
|
"go/token"
|
|
37
43
|
"os"
|
|
44
|
+
"strings"
|
|
38
45
|
)
|
|
39
46
|
|
|
40
47
|
// fail prints a stable, machine-readable marker plus a human message and exits
|
|
@@ -50,7 +57,8 @@ func fail(code int, format string, args ...any) {
|
|
|
50
57
|
|
|
51
58
|
func main() {
|
|
52
59
|
file := flag.String("file", "", "path to the Go source file to mutate")
|
|
53
|
-
fn := flag.String("func", "", "exact name of the free function to mutate")
|
|
60
|
+
fn := flag.String("func", "", "exact name of the free function or method to mutate")
|
|
61
|
+
recv := flag.String("recv", "", "receiver base type name; when set, match only methods on this receiver")
|
|
54
62
|
out := flag.String("out", "", "path to write the mutated file (defaults to --file)")
|
|
55
63
|
mode := flag.String("mode", "sentinel", "sentinel | equivalent")
|
|
56
64
|
flag.Parse()
|
|
@@ -72,31 +80,47 @@ func main() {
|
|
|
72
80
|
fail(2, "parse error: %v", err)
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
// Collect BOTH free functions and methods named *fn. The proof lane only ever
|
|
84
|
+
// targets a method whose name is UNIQUE in its package (the analyzer's
|
|
85
|
+
// uniqueGoPackageSymbol refuses cross-file collisions before an edge is minted);
|
|
86
|
+
// this file-scoped count is the in-file backstop, so free+method or two-method
|
|
87
|
+
// collisions fail(3) as ambiguous — never a mislabeled mutation. A lone decl
|
|
88
|
+
// (free OR method) is mutated identically via the receiver-agnostic sentinel.
|
|
89
|
+
var matches []*ast.FuncDecl
|
|
77
90
|
for _, decl := range astFile.Decls {
|
|
78
91
|
fd, ok := decl.(*ast.FuncDecl)
|
|
79
92
|
if !ok || fd.Name == nil || fd.Name.Name != *fn {
|
|
80
93
|
continue
|
|
81
94
|
}
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
// Refuse generic receivers (r T[U]) — receiver base type is not a bare Ident.
|
|
96
|
+
if fd.Recv != nil && recvBaseIdent(fd) == nil {
|
|
84
97
|
continue
|
|
85
98
|
}
|
|
86
|
-
|
|
99
|
+
// Receiver-exact selection: when --recv is set, only a method on that base
|
|
100
|
+
// receiver type matches — a free function or another receiver never can, so
|
|
101
|
+
// a receiver-qualified target can never mutate the wrong declaration.
|
|
102
|
+
if *recv != "" {
|
|
103
|
+
if fd.Recv == nil {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if id := recvBaseIdent(fd); id == nil || id.Name != *recv {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
matches = append(matches, fd)
|
|
87
111
|
}
|
|
88
112
|
|
|
89
|
-
if len(
|
|
90
|
-
fail(3, "ambiguous: %d
|
|
113
|
+
if len(matches) > 1 {
|
|
114
|
+
fail(3, "ambiguous: %d declarations named %q (free and/or methods)", len(matches), *fn)
|
|
91
115
|
}
|
|
92
|
-
if len(
|
|
93
|
-
if
|
|
94
|
-
fail(
|
|
116
|
+
if len(matches) == 0 {
|
|
117
|
+
if *recv != "" {
|
|
118
|
+
fail(4, "not found: no method named %q on receiver %q", *fn, *recv)
|
|
95
119
|
}
|
|
96
|
-
fail(4, "not found: no free function named %q", *fn)
|
|
120
|
+
fail(4, "not found: no free function or method named %q", *fn)
|
|
97
121
|
}
|
|
98
122
|
|
|
99
|
-
target :=
|
|
123
|
+
target := matches[0]
|
|
100
124
|
results := target.Type.Results
|
|
101
125
|
if results == nil || len(results.List) == 0 {
|
|
102
126
|
fail(6, "not mutable: %q has no return values; no signature-derived sentinel is possible", *fn)
|
|
@@ -104,6 +128,15 @@ func main() {
|
|
|
104
128
|
|
|
105
129
|
if *mode == "sentinel" {
|
|
106
130
|
target.Body = sentinelBody(results)
|
|
131
|
+
// The sentinel body can orphan imports the original body used (Go
|
|
132
|
+
// rejects unused imports, so the mutant would fail to BUILD and the
|
|
133
|
+
// oracle would refuse — an honest but useless verdict for most real
|
|
134
|
+
// functions). Rewrite now-unused imports to blank imports: package
|
|
135
|
+
// init side effects are preserved, no behavior is added, and the
|
|
136
|
+
// repair is fail-safe in both directions — over-blanking a used
|
|
137
|
+
// import still fails the build (refusal, never proof), and a missed
|
|
138
|
+
// unused import is exactly today's behavior.
|
|
139
|
+
blankUnusedImports(astFile)
|
|
107
140
|
}
|
|
108
141
|
// equivalent mode: leave target.Body untouched (semantically identical).
|
|
109
142
|
|
|
@@ -117,6 +150,55 @@ func main() {
|
|
|
117
150
|
}
|
|
118
151
|
}
|
|
119
152
|
|
|
153
|
+
// blankUnusedImports renames imports whose package qualifier is no longer
|
|
154
|
+
// referenced anywhere in the file to blank imports (`_ "path"`). Dot imports
|
|
155
|
+
// and existing blank imports are left untouched. Qualifier detection is
|
|
156
|
+
// syntactic (selector bases), which over-approximates "used" — the safe
|
|
157
|
+
// direction: we only ever blank an import nothing references.
|
|
158
|
+
func blankUnusedImports(f *ast.File) {
|
|
159
|
+
used := map[string]bool{}
|
|
160
|
+
ast.Inspect(f, func(n ast.Node) bool {
|
|
161
|
+
if sel, ok := n.(*ast.SelectorExpr); ok {
|
|
162
|
+
if id, ok := sel.X.(*ast.Ident); ok {
|
|
163
|
+
used[id.Name] = true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return true
|
|
167
|
+
})
|
|
168
|
+
for _, imp := range f.Imports {
|
|
169
|
+
if imp.Name != nil {
|
|
170
|
+
if imp.Name.Name == "_" || imp.Name.Name == "." {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if !used[imp.Name.Name] {
|
|
174
|
+
imp.Name = ast.NewIdent("_")
|
|
175
|
+
}
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
path := strings.Trim(imp.Path.Value, "\"")
|
|
179
|
+
base := path[strings.LastIndex(path, "/")+1:]
|
|
180
|
+
if !used[base] {
|
|
181
|
+
imp.Name = ast.NewIdent("_")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// recvBaseIdent returns the receiver type's base identifier for a method decl
|
|
187
|
+
// (unwrapping a pointer receiver `*T` to `T`), or nil for a generic/unsupported
|
|
188
|
+
// receiver. Used only to refuse generic receivers; value-vs-pointer is irrelevant
|
|
189
|
+
// to body mutation (Go auto-(de)refs at the call site).
|
|
190
|
+
func recvBaseIdent(fd *ast.FuncDecl) *ast.Ident {
|
|
191
|
+
if fd.Recv == nil || len(fd.Recv.List) != 1 {
|
|
192
|
+
return nil
|
|
193
|
+
}
|
|
194
|
+
t := fd.Recv.List[0].Type
|
|
195
|
+
if star, ok := t.(*ast.StarExpr); ok {
|
|
196
|
+
t = star.X
|
|
197
|
+
}
|
|
198
|
+
id, _ := t.(*ast.Ident)
|
|
199
|
+
return id
|
|
200
|
+
}
|
|
201
|
+
|
|
120
202
|
// sentinelBody builds `{ return <zero>, <zero>, ... }` matching the function's
|
|
121
203
|
// result signature so the mutant COMPILES. Zero values are type-derived and
|
|
122
204
|
// deliberately wrong for any function whose real return is non-zero; when a
|