@orangepro/orangepro-mcp 0.2.46 → 0.2.47

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.
@@ -17,6 +17,7 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
17
17
  import { generateTests, readDeclaredDeps, unresolvedLocalImports } from "./generate/generator.js";
18
18
  import { GENERATED_DIR, runHintsFor } from "./generate/runHints.js";
19
19
  import { rankRiskGaps } from "./score/risk.js";
20
+ import { globToRegExp, loadRiskConfig } from "./score/riskConfig.js";
20
21
  import { resolveProviderConfig } from "./localConfig.js";
21
22
  import { buildProvider, DeterministicProvider } from "./generate/providers.js";
22
23
  import { resolveContained } from "./reprove/paths.js";
@@ -371,6 +372,14 @@ const GEN_WINDOW = 5;
371
372
  // file (every eligible symbol × every importing test) cannot starve the shared budget before
372
373
  // a provable hard-edge symbol is tried. Small K; promote to a flag if a repo needs a wider sweep.
373
374
  const EXISTING_LANE_MAX_WEAK_PER_SYMBOL = 3;
375
+ // A runner that crashes without returning a result (no JSON) fails for the whole
376
+ // project root, not for one test: test-specific problems come back as a classified
377
+ // result. Three crashes in a row in one root stop further calls there instead of
378
+ // walking every remaining linked target (litellm-scale repos queue 1,000+).
379
+ const RUNNER_CRASH_LIMIT = 3;
380
+ const RUNNER_CRASH_RE = /did not return JSON/;
381
+ // Out of disk space is machine-wide: every later sandbox copy fails the same way.
382
+ const DISK_FULL_RE = /\bENOSPC\b|no space left on device/i;
374
383
  const RUNNER_ROOT_MARKERS = {
375
384
  typescript: ["package.json"],
376
385
  javascript: ["package.json"],
@@ -867,6 +876,13 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
867
876
  let nonPythonCountedAttempts = 0;
868
877
  const pythonPreflightGreen = new Set();
869
878
  const pythonBlockedCounts = new Map();
879
+ // classification.rank_exclude_paths removes paths from ranking; proof follows the
880
+ // same scope so excluded areas never cost runner calls or sandbox copies.
881
+ const proofExcludeRe = loadRiskConfig(sourceRoot).config.classification.rank_exclude_paths.map(globToRegExp);
882
+ const excludedTargets = new Set();
883
+ const runnerCrashes = new Map();
884
+ const crashBlocked = new Map();
885
+ let diskFull;
870
886
  const changed = opts.changedFiles && opts.changedFiles.length > 0 ? new Set(opts.changedFiles) : null;
871
887
  const reader = fileReaderFor(sourceRoot); // R-2: source scan for the node:sqlite env profile
872
888
  // Fix 3: hard TESTED_BY/COVERS pairs first, weak MAY_* pairs after and capped per symbol.
@@ -884,6 +900,12 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
884
900
  continue;
885
901
  if (changed && !changed.has(symbolFileOf(node)))
886
902
  continue;
903
+ if (diskFull)
904
+ break;
905
+ if (proofExcludeRe.length > 0 && proofExcludeRe.some((re) => re.test(symbolFileOf(node)))) {
906
+ excludedTargets.add(symId);
907
+ continue;
908
+ }
887
909
  // Fix 1: already Proven for the CURRENT code (fingerprint match under #162) → skip. Re-closing
888
910
  // it would overstate newly-proven and append a redundant closed cert every run.
889
911
  if (alreadyProven.has(symId))
@@ -894,6 +916,12 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
894
916
  const targetFileRel = symbolFileOf(node);
895
917
  const targetLanguageName = language ?? targetLanguage(symId);
896
918
  const runnerRoot = projectRoot ?? proofRunnerRoot(sourceRoot, targetFileRel, testRel);
919
+ const crashKey = `${targetLanguageName}\u0000${runnerRoot}`;
920
+ const crashBlock = crashBlocked.get(crashKey);
921
+ if (crashBlock) {
922
+ crashBlock.skipped++;
923
+ continue;
924
+ }
897
925
  // A prior candidate in this exact runner project hit a classified project-wide
898
926
  // toolchain/environment failure. Preserve the budget and explain the skip.
899
927
  const isPython = isPythonFile(targetFileRel);
@@ -980,9 +1008,22 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
980
1008
  attempted--;
981
1009
  if (!isPython)
982
1010
  nonPythonCountedAttempts--;
983
- reportProgress(`proof skipped: ${symId}: ${redactSecrets(errMsg(e))}`);
1011
+ const message = redactSecrets(errMsg(e));
1012
+ reportProgress(`proof skipped: ${symId}: ${message}`);
1013
+ if (DISK_FULL_RE.test(message)) {
1014
+ diskFull = message;
1015
+ break;
1016
+ }
1017
+ if (RUNNER_CRASH_RE.test(message)) {
1018
+ const crashes = (runnerCrashes.get(crashKey) ?? 0) + 1;
1019
+ runnerCrashes.set(crashKey, crashes);
1020
+ if (crashes >= RUNNER_CRASH_LIMIT) {
1021
+ crashBlocked.set(crashKey, { language: targetLanguageName, root: runnerRoot, skipped: 0, lastError: message.slice(0, 200) });
1022
+ }
1023
+ }
984
1024
  continue;
985
1025
  }
1026
+ runnerCrashes.delete(crashKey);
986
1027
  const { classification, reason, category } = classifyProof(result, { sourceRoot, targetFileRel });
987
1028
  const baselineGreen = baselineGreenOf(result);
988
1029
  if (isPython) {
@@ -1036,7 +1077,28 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
1036
1077
  project_root: root,
1037
1078
  blocked_by: block.blockedBy
1038
1079
  }));
1039
- return { attempts, needsSetup, skipped, proven, attempted, nonPythonAttempted: nonPythonCountedAttempts, provenSymbols };
1080
+ for (const block of crashBlocked.values()) {
1081
+ skipped.push({
1082
+ title: `${block.language} project ${block.root}`,
1083
+ reason: `the proof runner crashed ${RUNNER_CRASH_LIMIT} times in a row without returning a result; ${block.skipped} remaining linked target${block.skipped === 1 ? " was" : "s were"} not attempted in this root. Last error: ${block.lastError}`,
1084
+ language: block.language,
1085
+ project_root: block.root
1086
+ });
1087
+ }
1088
+ if (excludedTargets.size > 0) {
1089
+ const n = excludedTargets.size;
1090
+ skipped.push({
1091
+ title: "Excluded paths",
1092
+ reason: `${n} linked target${n === 1 ? " is" : "s are"} under classification.rank_exclude_paths and ${n === 1 ? "was" : "were"} not attempted.`
1093
+ });
1094
+ }
1095
+ if (diskFull) {
1096
+ skipped.push({
1097
+ title: "Disk full",
1098
+ reason: `the disk ran out of space while preparing a proof sandbox; all remaining proof attempts were stopped. Free disk space and rerun. (${diskFull.slice(0, 200)})`
1099
+ });
1100
+ }
1101
+ return { attempts, needsSetup, skipped, proven, attempted, nonPythonAttempted: nonPythonCountedAttempts, provenSymbols, ...(diskFull ? { diskFull } : {}) };
1040
1102
  }
1041
1103
  /**
1042
1104
  * Drive prove for the top provable TS/JS targets. Two lanes: (1) prove the repo's OWN
@@ -1083,12 +1145,14 @@ export async function autoProve(root, opts, deps) {
1083
1145
  const genLimit = Math.max(1, Math.min(MAX_AUTO_LIMIT, Math.floor(opts.proof_gen_limit ?? DEFAULT_PROOF_GEN_LIMIT)));
1084
1146
  // ── Lane 1: existing associated tests — NO key required, runs FIRST (PR 1.5). ──
1085
1147
  const ex = proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, projectBlocked, existingLimit, genLimit, autoLimit);
1086
- if (opts.existingOnly) {
1148
+ if (opts.existingOnly || ex.diskFull) {
1087
1149
  const status = ex.proven > 0 ? "proven-run" : ex.attempted > 0 ? "ran-no-proof" : "no-targets";
1088
1150
  return {
1089
1151
  ran: ex.attempted > 0,
1090
1152
  status,
1091
- reason: "existing-tests-only: generation disabled.",
1153
+ reason: ex.diskFull
1154
+ ? "Proof stopped: the disk ran out of space while preparing a proof sandbox. Free disk space and rerun."
1155
+ : "existing-tests-only: generation disabled.",
1092
1156
  attempted: ex.attempted,
1093
1157
  proven: ex.proven,
1094
1158
  needs_setup: ex.needsSetup,
@@ -26,7 +26,7 @@ const MAX_RELATED_FILES = 4;
26
26
  const SYMBOL_EXCERPT_CONTEXT_LINES = 3;
27
27
  const MAX_EXCERPT_CHARS = 8000;
28
28
  const MAX_TARGET_TYPE_EXCERPT_CHARS = 5000;
29
- const STATIC_CHECK_TIMEOUT_MS = 3000;
29
+ const STATIC_CHECK_TIMEOUT_MS = 10000;
30
30
  // Large Go monorepos can spend tens of seconds populating a cold module cache
31
31
  // before the target package is compiled. Keep the check authoritative instead
32
32
  // of downgrading valid generated tests while dependencies are still downloading.
@@ -71,6 +71,11 @@ function testFilesForSourceFile(graph, sourceFile) {
71
71
  }
72
72
  return [...out];
73
73
  }
74
+ /** `Model provider HTTP 429: {...body...}` → `Model provider HTTP 429 (response body omitted)`. */
75
+ export function persistedProviderError(message) {
76
+ const match = /^(Model provider HTTP \d+):/.exec(message);
77
+ return match ? `${match[1]} (response body omitted)` : message;
78
+ }
74
79
  /**
75
80
  * Strip a Markdown code fence (and any prose around it) from a model body. Real
76
81
  * BYOK models frequently wrap the test in ```ts … ``` with a prose preamble/suffix;
@@ -2389,14 +2394,18 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2389
2394
  }
2390
2395
  catch (callErr) {
2391
2396
  const msg = redactSecrets(callErr instanceof Error ? callErr.message : String(callErr));
2397
+ // The terminal warning keeps the provider's response for diagnosis. Persisted
2398
+ // drafts and missing-evidence rows end up in shareable reports, so they carry
2399
+ // only the status line, never the provider's response body (account/billing text).
2400
+ const persisted = persistedProviderError(msg);
2392
2401
  warnings.push(`V5 planning call failed for "${gc.ctx.behavior_title}": ${msg} — no test emitted.`);
2393
2402
  missing.push({
2394
2403
  external_id: behavior.external_id,
2395
2404
  title: gc.ctx.behavior_title,
2396
- reason: `V5 planning call failed: ${msg}`,
2405
+ reason: `V5 planning call failed: ${persisted}`,
2397
2406
  needed: ["a reachable model provider"]
2398
2407
  });
2399
- emitManualPlanningFallback(`V5 planning call failed: ${msg}.`);
2408
+ emitManualPlanningFallback(`V5 planning call failed: ${persisted}.`);
2400
2409
  continue;
2401
2410
  }
2402
2411
  try {
@@ -1687,7 +1687,9 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1687
1687
  // Proof is minted only by opProveLoop's UNCHANGED oracle; no key ⇒ no files, no proof.
1688
1688
  reportProgress(opts.noAuto
1689
1689
  ? "auto-prove: disabled (--no-auto)"
1690
- : "auto-prove: driving generate → prove on the top provable targets", { current: 6, total: 8 });
1690
+ : opts.ai === false
1691
+ ? "auto-prove: proving with existing tests only (--no-ai: no test generation)"
1692
+ : "auto-prove: driving generate → prove on the top provable targets", { current: 6, total: 8 });
1691
1693
  let autoProveResult;
1692
1694
  try {
1693
1695
  const scopedFiles = autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef);
@@ -1699,7 +1701,12 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1699
1701
  provider: providerOpts.provider,
1700
1702
  model: providerOpts.model,
1701
1703
  prompt_version: opts.promptVersion,
1702
- changedFiles
1704
+ changedFiles,
1705
+ // --no-ai means no model calls anywhere in the run. The generation lane is gated
1706
+ // only by a key in the environment, so with a real provider configured it must
1707
+ // honor the flag explicitly. Without a key (or with the offline deterministic
1708
+ // provider) no model is called anyway and the released no-key path is unchanged.
1709
+ existingOnly: opts.ai === false && generationProviderConfigured && !deterministicGeneration
1703
1710
  }, { ...providerDeps, proveLoop: opProveLoop });
1704
1711
  autoProveResult = await runAutoProve(scopedFiles);
1705
1712
  if (scopedFiles && autoProveResult.attempted === 0 && !opts.baseRef && !opts.noAuto) {
@@ -516,7 +516,10 @@ export function riskContext(risk, churnWindowDays, churnMeta, sameSinkAs) {
516
516
  : "change history unavailable";
517
517
  const tier = risk.detection_tier ?? "";
518
518
  const evidence = tier === "candidate"
519
- ? "no test links here (a similarly-named test exists but never calls it)"
519
+ // Candidate = a name-level signal only. The analyzer did not credit an asserted
520
+ // call; it cannot claim the test never calls the code (stored-result asserts and
521
+ // pytest.raises are not yet credited for Python).
522
+ ? "no confirmed test link (a similarly-named test exists, but no asserted call to it was recognized)"
520
523
  : tier === "associated" ? "a test calls it but nothing proves it fails when broken" : "no test links here";
521
524
  const sinkShort = sink ? sink.split(".").pop() ?? sink : "";
522
525
  // Line 1 — the consequence, in plain English, from the signals only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
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",
@@ -646,8 +646,33 @@ function copyRuntimeAliasTarget(absTarget, dest, isFile) {
646
646
  });
647
647
  }
648
648
 
649
- function copyFixtureRoot(root, label, { linkNodeModules, workspaceRoot }) {
649
+ // A failed copy (e.g. ENOSPC) must not leave a partial multi-GB sandbox behind:
650
+ // remove this copy's temp dir before rethrowing.
651
+ function copyFixtureRoot(root, label, options) {
650
652
  const tmpRoot = mkdtempSync(path.join(tmpdir(), `opro-dynamic-proof-${label}-`));
653
+ try {
654
+ return populateFixtureRoot(tmpRoot, root, options);
655
+ } catch (error) {
656
+ rmSync(tmpRoot, { recursive: true, force: true });
657
+ throw error;
658
+ }
659
+ }
660
+
661
+ // Directories that are never JS test inputs but can be very large when the analyzed
662
+ // root is a polyglot repo: Python environments and caches, and Rust build output
663
+ // (`target/` next to a Cargo.toml). Copying them made each sandbox several GB.
664
+ const NON_JS_HEAVY_DIRS = new Set([".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox"]);
665
+ function isNonJsHeavyDir(source, name) {
666
+ if (!NON_JS_HEAVY_DIRS.has(name) && name !== "target") {
667
+ return false;
668
+ }
669
+ if (!lstatSync(source).isDirectory()) {
670
+ return false;
671
+ }
672
+ return name !== "target" || existsSync(path.join(path.dirname(source), "Cargo.toml"));
673
+ }
674
+
675
+ function populateFixtureRoot(tmpRoot, root, { linkNodeModules, workspaceRoot }) {
651
676
  const monoRoot = path.join(tmpRoot, "mono");
652
677
  // On any policy violation, mirror nothing → package lands alone at tmpRoot/mono with its
653
678
  // dangling extends / unresolved alias → baseline fails to transform/resolve → honest
@@ -730,6 +755,9 @@ function copyFixtureRoot(root, label, { linkNodeModules, workspaceRoot }) {
730
755
  if (source !== root && lstatSync(source).isSymbolicLink()) {
731
756
  return false;
732
757
  }
758
+ if (source !== root && isNonJsHeavyDir(source, name)) {
759
+ return false;
760
+ }
733
761
  return name !== "node_modules" && name !== ".git" && name !== ".orangepro";
734
762
  }
735
763
  });
@@ -2283,7 +2311,13 @@ function main() {
2283
2311
  const mochaBin = args.mochaBin ? path.resolve(args.mochaBin) : defaultMochaBin(root, workspaceRoot);
2284
2312
 
2285
2313
  const baselineCopy = copyFixtureRoot(root, "baseline", { linkNodeModules: args.linkNodeModules, workspaceRoot });
2286
- const mutantCopy = copyFixtureRoot(root, "mutant", { linkNodeModules: args.linkNodeModules, workspaceRoot });
2314
+ let mutantCopy;
2315
+ try {
2316
+ mutantCopy = copyFixtureRoot(root, "mutant", { linkNodeModules: args.linkNodeModules, workspaceRoot });
2317
+ } catch (error) {
2318
+ rmSync(baselineCopy.tmpRoot, { recursive: true, force: true });
2319
+ throw error;
2320
+ }
2287
2321
  try {
2288
2322
  const baseline = runTest({ runner, repoRoot: baselineCopy.repoRoot, monoRoot: baselineCopy.monoRoot, testRel, vitestBin, jestBin, mochaBin, vitestConfigRel, jestConfigRel, timeoutMs, testEnv, aliases: baselineCopy.aliases });
2289
2323
  mutateMethod(path.join(mutantCopy.repoRoot, targetRel), args.method, replacementBody);
@@ -126,16 +126,22 @@ function resolveInside(root, relOrAbs) {
126
126
  function copyModuleRoot(root, label) {
127
127
  const tmpRoot = mkdtempSync(path.join(tmpdir(), `opro-go-proof-${label}-`));
128
128
  const repoRoot = path.join(tmpRoot, "module");
129
- cpSync(root, repoRoot, {
130
- recursive: true,
131
- filter(source) {
132
- const name = path.basename(source);
133
- if (source !== root && lstatSync(source).isSymbolicLink()) {
134
- return false;
129
+ try {
130
+ cpSync(root, repoRoot, {
131
+ recursive: true,
132
+ filter(source) {
133
+ const name = path.basename(source);
134
+ if (source !== root && lstatSync(source).isSymbolicLink()) {
135
+ return false;
136
+ }
137
+ return name !== "node_modules" && name !== ".git" && name !== ".orangepro";
135
138
  }
136
- return name !== "node_modules" && name !== ".git" && name !== ".orangepro";
137
- }
138
- });
139
+ });
140
+ } catch (error) {
141
+ // A failed copy (e.g. ENOSPC) must not leave a partial sandbox behind.
142
+ rmSync(tmpRoot, { recursive: true, force: true });
143
+ throw error;
144
+ }
139
145
  return { tmpRoot, repoRoot };
140
146
  }
141
147
 
@@ -558,7 +564,13 @@ function main() {
558
564
  const pkgPath = pkgDirRel === "." ? "./" : `./${pkgDirRel.split(path.sep).join("/")}`;
559
565
 
560
566
  const baselineCopy = copyModuleRoot(root, "baseline");
561
- const mutantCopy = copyModuleRoot(root, "mutant");
567
+ let mutantCopy;
568
+ try {
569
+ mutantCopy = copyModuleRoot(root, "mutant");
570
+ } catch (error) {
571
+ rmSync(baselineCopy.tmpRoot, { recursive: true, force: true });
572
+ throw error;
573
+ }
562
574
  // The mutant sandbox's package directory: Go prints failure-frame files as
563
575
  // basenames relative to it, so FIX 2 resolves the test source there.
564
576
  const pkgDirAbs = path.join(mutantCopy.repoRoot, path.dirname(targetRel));
@@ -106,16 +106,22 @@ function resolveInside(root, relOrAbs) {
106
106
  function copyModuleRoot(root, label) {
107
107
  const tmpRoot = mkdtempSync(path.join(tmpdir(), `opro-java-proof-${label}-`));
108
108
  const repoRoot = path.join(tmpRoot, "module");
109
- cpSync(root, repoRoot, {
110
- recursive: true,
111
- filter(source) {
112
- const name = path.basename(source);
113
- if (source !== root && lstatSync(source).isSymbolicLink()) {
114
- return false;
109
+ try {
110
+ cpSync(root, repoRoot, {
111
+ recursive: true,
112
+ filter(source) {
113
+ const name = path.basename(source);
114
+ if (source !== root && lstatSync(source).isSymbolicLink()) {
115
+ return false;
116
+ }
117
+ return name !== "node_modules" && name !== ".git" && name !== ".orangepro" && name !== "target";
115
118
  }
116
- return name !== "node_modules" && name !== ".git" && name !== ".orangepro" && name !== "target";
117
- }
118
- });
119
+ });
120
+ } catch (error) {
121
+ // A failed copy (e.g. ENOSPC) must not leave a partial sandbox behind.
122
+ rmSync(tmpRoot, { recursive: true, force: true });
123
+ throw error;
124
+ }
119
125
  return { tmpRoot, repoRoot };
120
126
  }
121
127
 
@@ -506,7 +512,13 @@ function main() {
506
512
  const timeoutMs = parseTimeoutMs(args.timeoutMs);
507
513
 
508
514
  const baselineCopy = copyModuleRoot(root, "baseline");
509
- const mutantCopy = copyModuleRoot(root, "mutant");
515
+ let mutantCopy;
516
+ try {
517
+ mutantCopy = copyModuleRoot(root, "mutant");
518
+ } catch (error) {
519
+ rmSync(baselineCopy.tmpRoot, { recursive: true, force: true });
520
+ throw error;
521
+ }
510
522
  try {
511
523
  const baseline = runSurefire({
512
524
  repoRoot: baselineCopy.repoRoot,