@orangepro/orangepro-mcp 0.2.1 → 0.2.3

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
@@ -41,7 +41,38 @@ cd orangepro-mcp && npm ci && npm run build && npm link
41
41
 
42
42
  OrangePro runs as an MCP server. Any MCP-compatible agent (Cursor, Claude Code, Codex, Copilot, OpenCode) can drive it.
43
43
 
44
- ### Setup
44
+ ### Quick agent setup
45
+
46
+ If you already have `opro` on your PATH, print the exact config for your client:
47
+
48
+ ```bash
49
+ opro agent --client codex
50
+ opro agent --client claude-code
51
+ opro agent --client cursor
52
+ opro agent --client opencode
53
+ opro agent --client generic
54
+ ```
55
+
56
+ No global install is required. These commands use the published package:
57
+
58
+ ```bash
59
+ # Codex
60
+ npx -y @orangepro/mcp-server@latest agent --client codex
61
+
62
+ # Claude Code
63
+ npx -y @orangepro/mcp-server@latest agent --client claude-code
64
+
65
+ # Cursor
66
+ npx -y @orangepro/mcp-server@latest agent --client cursor
67
+
68
+ # OpenCode
69
+ npx -y @orangepro/mcp-server@latest agent --client opencode
70
+
71
+ # Generic MCP clients, including VS Code/Copilot-style MCP settings
72
+ npx -y @orangepro/mcp-server@latest agent --client generic
73
+ ```
74
+
75
+ ### Manual MCP config
45
76
 
46
77
  Add to your client's MCP config:
47
78
 
@@ -57,11 +88,13 @@ Add to your client's MCP config:
57
88
  ```
58
89
 
59
90
  | Client | Config location |
91
+ | --- | --- |
60
92
  |--------|----------------|
61
93
  | Claude Code | `.mcp.json` or `~/.claude.json` |
62
94
  | Cursor | `~/.cursor/mcp.json` or Settings → MCP |
63
- | Codex | MCP config printed by `opro agent --client codex`; plugin install after OrangePro is listed in a configured marketplace |
64
- | VS Code / Copilot | MCP settings |
95
+ | Codex | Config printed by `opro agent --client codex` or `npx -y @orangepro/mcp-server@latest agent --client codex` |
96
+ | VS Code / Copilot | MCP settings; use the `generic` config if your client accepts raw MCP server JSON |
97
+ | OpenCode | Config printed by `opro agent --client opencode` |
65
98
 
66
99
  ### The workflow
67
100
 
@@ -766,6 +766,17 @@ function goAssertionCalls(node, assertLocals, dotAssertMethods, shadowed) {
766
766
  return Boolean(fn?.type === "identifier" && !shadowed.has(fn.text) && dotAssertMethods.has(fn.text));
767
767
  });
768
768
  }
769
+ function goAssertionIdentifierArgs(assertion, testingParams) {
770
+ const sel = callFunctionSelector(assertion);
771
+ const name = sel?.name ?? assertion.childForFieldName("function")?.text;
772
+ const args = namedChildren(assertion.childForFieldName("arguments") ?? assertion).filter((n) => n.type !== "comment");
773
+ if (!name || args.length < 2)
774
+ return [];
775
+ if (!testingParams.has(args[0]?.text ?? ""))
776
+ return [];
777
+ const slots = name === "Equal" || name === "NotEqual" ? [args[1], args[2]] : [args[1]];
778
+ return slots.filter((n) => n?.type === "identifier").map((n) => n.text);
779
+ }
769
780
  function goAssertionSubject(assertion, testingParams) {
770
781
  const sel = callFunctionSelector(assertion);
771
782
  const name = sel?.name ?? assertion.childForFieldName("function")?.text;
@@ -862,9 +873,36 @@ function extractGoProofCalls(root, imports) {
862
873
  }
863
874
  };
864
875
  const processBlock = (block, testName, testingParams, shadowed) => {
876
+ // ONE-HOP block-local dataflow: a standalone `x, err := F(...)` statement is
877
+ // credited when a LATER statement in the SAME block checks a declared name
878
+ // (if-fail condition or assert subject). This covers the dominant real-Go
879
+ // idiom the if-initializer shape misses. Discipline kept: single product
880
+ // call per statement (goShortVarCalls), last write wins, plain reassignment
881
+ // invalidates, never crosses block boundaries. Metadata only — the dynamic
882
+ // oracle still re-verifies every edge before anything is Proven.
883
+ const pending = new Map();
884
+ // Deferred pending credits: witness lines are collected per call and flushed
885
+ // after the block — one witness keeps its exact line (subtest binding),
886
+ // several witnesses drop the line (the oracle's frame-line gate must never
887
+ // refuse a real kill firing at a sibling check).
888
+ const pendingHits = new Map();
889
+ const hitPending = (assertion, calls, line) => {
890
+ for (const c of calls) {
891
+ const key = `${assertion}|${c.qualifier ?? ""}|${c.callee}`;
892
+ const hit = pendingHits.get(key) ?? { assertion, calls: [c], lines: new Set() };
893
+ if (line !== undefined)
894
+ hit.lines.add(line);
895
+ pendingHits.set(key, hit);
896
+ }
897
+ };
865
898
  for (const stmt of blockStatements(block)) {
866
899
  for (const assertion of goAssertionCalls(stmt, assertLocals, dotAssertMethods, shadowed)) {
867
- add(testName, shadowed, "assert_helper", singleGoProductCallIn(goAssertionSubject(assertion, testingParams)), assertion.startPosition.row + 1);
900
+ const subject = goAssertionSubject(assertion, testingParams);
901
+ add(testName, shadowed, "assert_helper", singleGoProductCallIn(subject), assertion.startPosition.row + 1);
902
+ for (const argName of goAssertionIdentifierArgs(assertion, testingParams)) {
903
+ if (pending.has(argName))
904
+ hitPending("assert_helper", pending.get(argName), assertion.startPosition.row + 1);
905
+ }
868
906
  }
869
907
  if (stmt.type === "if_statement" && hasGoTestingFailure(stmt.childForFieldName("consequence"), testingParams)) {
870
908
  const condition = stmt.childForFieldName("condition");
@@ -876,6 +914,10 @@ function extractGoProofCalls(root, imports) {
876
914
  const initCalls = init ? goShortVarCalls(init) : null;
877
915
  if (initCalls && containsIdentifier(condition, initCalls.names))
878
916
  add(testName, shadowed, "testing_fail", initCalls.calls, failLine);
917
+ for (const [name, calls] of pending) {
918
+ if (containsIdentifier(condition, new Set([name])))
919
+ hitPending("testing_fail", calls, failLine);
920
+ }
879
921
  }
880
922
  for (const child of namedChildren(stmt)) {
881
923
  if (child.type === "block")
@@ -886,6 +928,23 @@ function extractGoProofCalls(root, imports) {
886
928
  const subTestName = subtest.subName ? `${testName}/${subtest.subName}` : testName;
887
929
  processBlock(subtest.body, subTestName, subtest.testingParams, shadowed);
888
930
  }
931
+ // Record declarations AFTER uses: a declaration is never its own check.
932
+ const sv = stmt.type === "short_var_declaration" ? goShortVarCalls(stmt) : null;
933
+ if (sv) {
934
+ for (const name of sv.names)
935
+ pending.set(name, sv.calls);
936
+ }
937
+ else if (stmt.type === "assignment_statement") {
938
+ // Plain reassignment kills the binding — the checked value is no longer F's.
939
+ const reassigned = new Set();
940
+ collectNames(stmt.childForFieldName("left") ?? stmt.namedChild(0), reassigned);
941
+ for (const name of reassigned)
942
+ pending.delete(name);
943
+ }
944
+ }
945
+ for (const hit of pendingHits.values()) {
946
+ const line = hit.lines.size === 1 ? [...hit.lines][0] : undefined;
947
+ add(testName, shadowed, hit.assertion, hit.calls, line);
889
948
  }
890
949
  };
891
950
  const processSuiteBlock = (block, testName, receiver, shadowed) => {
package/dist/local/cli.js CHANGED
@@ -106,7 +106,7 @@ Usage:
106
106
  opro ai-links [--all] [--apply] [--provider openai|anthropic|ollama] [--model <name>] [--max-behaviors <n>] [--symbols-per-behavior <n>] [--max-prompt-tokens <n>] [--json]
107
107
  # opt-in AI lane: stage weak candidate behavior↔code links in .orangepro/ai/links.json; --apply merges them into candidate_edges only
108
108
  opro ai-flows [--apply] [--provider openai|anthropic|ollama] [--model <name>] [--json]
109
- # opt-in AI lane: stage candidate behavior flows (closed anchor set) in .orangepro/ai/flows.json; --apply stores them under analysis.candidate_flows only — a verify-these worklist, never evidence
109
+ # opt-in AI lane: stage candidate behavior flows (closed anchor set) in .orangepro/flows.json; --apply stores them under analysis.candidate_flows only — a verify-these worklist, never evidence
110
110
  opro generate [--target REQ-001] [--base <ref>] [--pr <n> [--yes]] [--changed] [--framework playwright] [--limit 3] [--prompt-version v2|v5] [--provider openai|anthropic|ollama|deterministic] [--model <name>] [--single [--raw]] [--background] [--json]
111
111
  # default: A/B both arms (prompt-only vs Local KG, same model) scored side by side + writes a fresh report; --single generates one arm only
112
112
  # --base <ref>: NON-MUTATING default for PR/branch review — generate only for the behaviors the diff vs <ref> touches (e.g. --base main); read-only \`git diff\`, no checkout
@@ -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 = readArtifact(path);
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 = aiFlowsPath(root);
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/ai/flows.json",
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,
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/ai/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.",
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."),
@@ -1560,6 +1560,36 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1560
1560
  };
1561
1561
  warnings.push(`auto-prove skipped: ${reason}`);
1562
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
+ }
1563
1593
  // G1: persist the distilled, already-redacted attempt classifications so
1564
1594
  // `opro doctor --proof` and standalone report regens can explain blockers
1565
1595
  // after this process exits. Sidecar only — never read by the oracle, RTM,
@@ -1680,6 +1710,8 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1680
1710
  }
1681
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.';
1682
1712
  const START_RTM_LIMIT = 500;
1713
+ const START_GENERATE_RISK_LIMIT = 20;
1714
+ const START_GENERATE_BATCH_LIMIT = 5;
1683
1715
  const EMPTY_EVIDENCE_SUMMARY = {
1684
1716
  tests: 0,
1685
1717
  tests_with_proof: 0,
@@ -1711,7 +1743,7 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1711
1743
  // deterministic stand-in is opt-in only; otherwise return setup guidance
1712
1744
  // instead of silently degrading.
1713
1745
  const providerEnv = loadProviderEnv([root], deps.env);
1714
- const provider = resolveGenerationProvider(providerEnv, opts);
1746
+ const provider = deps.aiProvider ?? resolveGenerationProvider(providerEnv, opts);
1715
1747
  if (!provider) {
1716
1748
  return {
1717
1749
  run_id: null,
@@ -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() ?? (risk.entry_point ? "ENTRY" : "CODE"),
398
- path: methodMatch?.[2] ?? (pathMatch ? `/${pathMatch[1]}` : risk.file),
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."
@@ -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
 
@@ -471,8 +470,9 @@ if(cf&&cf.flows.length){
471
470
  }
472
471
 
473
472
  // risks + generated test samples
474
- let activeRiskFilter="all";
475
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";
476
476
  function riskMatchesFilter(r){
477
477
  const hasGenerated=Boolean(r.generatedTests&&r.generatedTests.length);
478
478
  if(activeRiskFilter==="generated")return hasGenerated;
@@ -482,7 +482,7 @@ function riskMatchesFilter(r){
482
482
  function renderRiskFilters(){
483
483
  const options=[
484
484
  ["all","All",D.risks.length],
485
- ["generated","Generated tests",D.risks.filter(r=>r.generatedTests&&r.generatedTests.length).length],
485
+ ["generated","Flows with tests",generatedRiskCount],
486
486
  ["missing","No generated tests",D.risks.filter(r=>!(r.generatedTests&&r.generatedTests.length)).length]
487
487
  ];
488
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("");
@@ -513,7 +513,6 @@ function riskCardHtml(r){
513
513
  }
514
514
  return \`<div class="risk-rank">#\${r.rank}</div>
515
515
  <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
516
  <div class="risk-tags">\${tags}</div>
518
517
  <div class="todo">\${esc(r.todo)}</div>\${testsHtml}\${catHtml}\`;
519
518
  }
@@ -521,16 +520,16 @@ function renderRisks(){
521
520
  riskList.innerHTML="";
522
521
  D.risks.filter(riskMatchesFilter).forEach(r=>riskList.append(el("div","risk-card",riskCardHtml(r))));
523
522
  if(activeRiskFilter==="all"&&D.generatedTotal){
524
- const hiddenGenerated=Math.max(0,D.generatedTotal-D.shownCount);
525
- const remainingRiskFlows=Math.max(0,D.risks.length-D.generatedTotal);
523
+ const hiddenGeneratedFlows=Math.max(0,generatedRiskCount-D.risks.filter(riskMatchesFilter).length);
524
+ const remainingRiskFlows=Math.max(0,D.risks.length-generatedRiskCount);
526
525
  riskList.append(el("div","paywall",
527
- hiddenGenerated
528
- ? \`<div class="paywall-num">\${hiddenGenerated} more tests generated</div>
529
- <div class="paywall-txt">OrangePro generated tests for \${D.generatedTotal} behaviors across your highest-risk flows. You're seeing \${D.shownCount}.</div>
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>
530
529
  <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">View all on OrangePro Platform &rarr;</a>\`
531
530
  : remainingRiskFlows
532
531
  ? \`<div class="paywall-num">\${remainingRiskFlows} high-risk flows left</div>
533
- <div class="paywall-txt">The local MCP accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} for this report. Generate the remaining high-risk flow tests on OrangePro Platform.</div>
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>
534
533
  <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">Generate remaining tests on Platform &rarr;</a>\`
535
534
  : \`<div class="paywall-num">All generated tests are shown</div>
536
535
  <div class="paywall-txt">OrangePro generated tests for every high-risk flow in this report, and every generated test is visible here.</div>\`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": "vitest run tests/local/autoProveJava.test.ts --pool=forks --poolOptions.forks.maxForks=1",
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",
@@ -35,6 +35,7 @@ import (
35
35
  "go/printer"
36
36
  "go/token"
37
37
  "os"
38
+ "strings"
38
39
  )
39
40
 
40
41
  // fail prints a stable, machine-readable marker plus a human message and exits
@@ -104,6 +105,15 @@ func main() {
104
105
 
105
106
  if *mode == "sentinel" {
106
107
  target.Body = sentinelBody(results)
108
+ // The sentinel body can orphan imports the original body used (Go
109
+ // rejects unused imports, so the mutant would fail to BUILD and the
110
+ // oracle would refuse — an honest but useless verdict for most real
111
+ // functions). Rewrite now-unused imports to blank imports: package
112
+ // init side effects are preserved, no behavior is added, and the
113
+ // repair is fail-safe in both directions — over-blanking a used
114
+ // import still fails the build (refusal, never proof), and a missed
115
+ // unused import is exactly today's behavior.
116
+ blankUnusedImports(astFile)
107
117
  }
108
118
  // equivalent mode: leave target.Body untouched (semantically identical).
109
119
 
@@ -117,6 +127,39 @@ func main() {
117
127
  }
118
128
  }
119
129
 
130
+ // blankUnusedImports renames imports whose package qualifier is no longer
131
+ // referenced anywhere in the file to blank imports (`_ "path"`). Dot imports
132
+ // and existing blank imports are left untouched. Qualifier detection is
133
+ // syntactic (selector bases), which over-approximates "used" — the safe
134
+ // direction: we only ever blank an import nothing references.
135
+ func blankUnusedImports(f *ast.File) {
136
+ used := map[string]bool{}
137
+ ast.Inspect(f, func(n ast.Node) bool {
138
+ if sel, ok := n.(*ast.SelectorExpr); ok {
139
+ if id, ok := sel.X.(*ast.Ident); ok {
140
+ used[id.Name] = true
141
+ }
142
+ }
143
+ return true
144
+ })
145
+ for _, imp := range f.Imports {
146
+ if imp.Name != nil {
147
+ if imp.Name.Name == "_" || imp.Name.Name == "." {
148
+ continue
149
+ }
150
+ if !used[imp.Name.Name] {
151
+ imp.Name = ast.NewIdent("_")
152
+ }
153
+ continue
154
+ }
155
+ path := strings.Trim(imp.Path.Value, "\"")
156
+ base := path[strings.LastIndex(path, "/")+1:]
157
+ if !used[base] {
158
+ imp.Name = ast.NewIdent("_")
159
+ }
160
+ }
161
+ }
162
+
120
163
  // sentinelBody builds `{ return <zero>, <zero>, ... }` matching the function's
121
164
  // result signature so the mutant COMPILES. Zero values are type-derived and
122
165
  // deliberately wrong for any function whose real return is non-zero; when a