@orangepro/orangepro-mcp 0.2.45 → 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.
@@ -982,7 +982,11 @@ export function analyzeRepo(root, opts = {}) {
982
982
  ...(notEntryPointAdjacent ? { denominator_reason_code: "not_entry_point_adjacent" } : {}),
983
983
  ...(pythonRankingExclusion
984
984
  ? {
985
- ranking_exclusion_code: pythonRankingExclusion.code === "python_trivial_thread_local_initializer" ? "trivial_constructor" : "enum_declaration",
985
+ ranking_exclusion_code: pythonRankingExclusion.code === "python_trivial_constructor" || pythonRankingExclusion.code === "python_trivial_thread_local_initializer"
986
+ ? "trivial_constructor"
987
+ : pythonRankingExclusion.code === "python_data_shape_declaration"
988
+ ? "data_shape_declaration"
989
+ : "enum_declaration",
986
990
  ranking_exclusion_reason_code: pythonRankingExclusion.code,
987
991
  ranking_exclusion_reason: pythonRankingExclusion.reason
988
992
  }
@@ -2687,17 +2687,18 @@ function pythonImportsBefore(root, stopAt) {
2687
2687
  return bindings;
2688
2688
  }
2689
2689
  function pythonBaseIsStdlib(base, bindings, module, allowed) {
2690
+ const modules = typeof module === "string" ? [module] : module;
2690
2691
  const dotted = pythonDottedName(base);
2691
2692
  if (!dotted)
2692
2693
  return false;
2693
2694
  const parts = dotted.split(".");
2694
2695
  if (parts.length === 1) {
2695
2696
  const binding = bindings.get(parts[0]);
2696
- return Boolean(binding?.kind === "named" && binding.module === module && binding.imported && allowed.has(binding.imported));
2697
+ return Boolean(binding?.kind === "named" && modules.includes(binding.module) && binding.imported && allowed.has(binding.imported));
2697
2698
  }
2698
2699
  if (parts.length === 2 && allowed.has(parts[1])) {
2699
2700
  const binding = bindings.get(parts[0]);
2700
- return Boolean(binding?.kind === "module" && binding.module === module);
2701
+ return Boolean(binding?.kind === "module" && modules.includes(binding.module));
2701
2702
  }
2702
2703
  return false;
2703
2704
  }
@@ -2741,6 +2742,9 @@ function pythonLiteralOrParameter(node, params) {
2741
2742
  return false;
2742
2743
  if (node.type === "identifier")
2743
2744
  return params.has(node.text);
2745
+ // An f-string with {interpolation} computes a value; only plain literals qualify.
2746
+ if ((node.type === "string" || node.type === "concatenated_string") && node.descendantsOfType("interpolation").length > 0)
2747
+ return false;
2744
2748
  return new Set(["none", "true", "false", "integer", "float", "string", "concatenated_string"]).has(node.type);
2745
2749
  }
2746
2750
  function pythonDocstringStatement(statement) {
@@ -2749,11 +2753,18 @@ function pythonDocstringStatement(statement) {
2749
2753
  const expression = namedChildren(statement)[0];
2750
2754
  return expression?.type === "string" || expression?.type === "concatenated_string";
2751
2755
  }
2752
- function pythonSuperInitStatement(statement) {
2756
+ function pythonSuperInitStatement(statement, params = new Set()) {
2753
2757
  if (statement.type !== "expression_statement")
2754
2758
  return false;
2755
2759
  const call = namedChildren(statement)[0];
2756
- if (call?.type !== "call" || pythonCallArguments(call).length !== 0)
2760
+ if (call?.type !== "call")
2761
+ return false;
2762
+ // super().__init__(...) may only FORWARD this constructor's own parameters or
2763
+ // literals (positionally or by keyword). Any computed argument is behavior.
2764
+ const forwardsOnly = pythonCallArguments(call).every((arg) => arg.type === "keyword_argument"
2765
+ ? pythonLiteralOrParameter(arg.childForFieldName("value"), params)
2766
+ : pythonLiteralOrParameter(arg, params));
2767
+ if (!forwardsOnly)
2757
2768
  return false;
2758
2769
  const fn = call.childForFieldName("function");
2759
2770
  if (fn?.type !== "attribute" || fn.childForFieldName("attribute")?.text !== "__init__")
@@ -2774,7 +2785,7 @@ function pythonTrivialSelfAssignment(statement, params) {
2774
2785
  return false;
2775
2786
  return pythonLiteralOrParameter(assignment.childForFieldName("right"), params);
2776
2787
  }
2777
- function pythonTrivialThreadLocalInitializer(classNode) {
2788
+ function pythonTrivialInitializer(classNode) {
2778
2789
  const body = classNode.childForFieldName("body");
2779
2790
  if (!body)
2780
2791
  return null;
@@ -2800,40 +2811,97 @@ function pythonTrivialThreadLocalInitializer(classNode) {
2800
2811
  const statement = statements[i];
2801
2812
  if (i === 0 && pythonDocstringStatement(statement))
2802
2813
  continue;
2803
- if (statement.type === "pass_statement" || pythonSuperInitStatement(statement) || pythonTrivialSelfAssignment(statement, params))
2814
+ if (statement.type === "pass_statement" || pythonSuperInitStatement(statement, params) || pythonTrivialSelfAssignment(statement, params))
2804
2815
  continue;
2805
2816
  return null;
2806
2817
  }
2807
2818
  return pythonQualifiedFunctionName(fn) ?? null;
2808
2819
  }
2820
+ const PYTHON_TYPING_SHAPES = new Set(["TypedDict", "NamedTuple", "Protocol"]);
2821
+ const PYTHON_TYPING_MODULES = ["typing", "typing_extensions"];
2822
+ const PYTHON_PYDANTIC_SHAPES = new Set(["BaseModel"]);
2823
+ const PYTHON_PYDANTIC_MODULES = ["pydantic", "pydantic.main", "pydantic.v1"];
2824
+ const PYTHON_DATACLASS_MODULES = ["dataclasses", "pydantic.dataclasses"];
2825
+ /** `@dataclass`, `@dataclass(...)`, `@dataclasses.dataclass(...)` (stdlib or pydantic's drop-in). */
2826
+ function pythonIsStdlibDataclassDecorator(decorator, bindings) {
2827
+ let expression = namedChildren(decorator)[0] ?? null;
2828
+ if (expression?.type === "call")
2829
+ expression = expression.childForFieldName("function");
2830
+ return Boolean(expression && pythonBaseIsStdlib(expression, bindings, PYTHON_DATACLASS_MODULES, new Set(["dataclass"])));
2831
+ }
2832
+ /** Class body holds only field declarations: annotations (with or without a default),
2833
+ * plain attribute assignments, a docstring, or `pass`. No methods, no nested classes,
2834
+ * no statements that run logic. */
2835
+ function pythonFieldOnlyBody(classNode) {
2836
+ const body = classNode.childForFieldName("body");
2837
+ if (!body)
2838
+ return false;
2839
+ const statements = blockStatements(body);
2840
+ if (statements.length === 0)
2841
+ return false;
2842
+ return statements.every((statement, index) => {
2843
+ if (statement.type === "pass_statement")
2844
+ return true;
2845
+ if (index === 0 && pythonDocstringStatement(statement))
2846
+ return true;
2847
+ if (statement.type !== "expression_statement")
2848
+ return false;
2849
+ const inner = namedChildren(statement);
2850
+ // A lambda in a field value (e.g. custom JSON encoders) is behavior, not data.
2851
+ return inner.length === 1 && inner[0].type === "assignment" && inner[0].childForFieldName("left")?.type === "identifier"
2852
+ && inner[0].descendantsOfType("lambda").length === 0;
2853
+ });
2854
+ }
2809
2855
  function extractPythonDeclarationRankingExclusions(root) {
2810
2856
  const out = [];
2811
2857
  for (const classNode of root.descendantsOfType("class_definition")) {
2812
- if (!classNode || hasPythonFunctionAncestor(classNode) || classNode.parent?.type !== "module")
2858
+ if (!classNode || hasPythonFunctionAncestor(classNode))
2859
+ continue;
2860
+ // Module-level classes only (decorated or not): nested-class symbol names are
2861
+ // not guaranteed to line up with the analyzer's symbol table.
2862
+ const decorated = classNode.parent?.type === "decorated_definition" ? classNode.parent : null;
2863
+ if ((decorated ?? classNode).parent?.type !== "module")
2813
2864
  continue;
2814
2865
  const name = classNode.childForFieldName("name")?.text;
2815
- const bases = classNode.childForFieldName("superclasses");
2816
- if (!name || !bases)
2866
+ if (!name)
2817
2867
  continue;
2818
- const bindings = pythonImportsBefore(root, classNode.startIndex);
2819
- const baseNodes = namedChildren(bases);
2820
- if (baseNodes.some((base) => pythonBaseIsStdlib(base, bindings, "enum", PYTHON_ENUM_TYPES)) && !pythonClassHasMethod(classNode)) {
2868
+ const bases = classNode.childForFieldName("superclasses");
2869
+ const bindings = pythonImportsBefore(root, (decorated ?? classNode).startIndex);
2870
+ const baseNodes = bases ? namedChildren(bases) : [];
2871
+ const hasMethod = pythonClassHasMethod(classNode);
2872
+ if (baseNodes.some((base) => pythonBaseIsStdlib(base, bindings, "enum", PYTHON_ENUM_TYPES)) && !hasMethod) {
2821
2873
  out.push({
2822
2874
  symbol: name,
2823
2875
  code: "python_stdlib_enum_declaration",
2824
2876
  reason: "Standard-library enum declaration without methods — retained in the graph but excluded from priority ranking."
2825
2877
  });
2878
+ continue;
2826
2879
  }
2827
- if (baseNodes.some((base) => pythonBaseIsStdlib(base, bindings, "threading", new Set(["local"])))) {
2828
- const initializer = pythonTrivialThreadLocalInitializer(classNode);
2829
- if (initializer) {
2880
+ // Data-shape declarations: a TypedDict/NamedTuple/Protocol, a pydantic model, or a
2881
+ // stdlib @dataclass whose body only declares fields. No method ⇒ no behavior to test.
2882
+ if (!hasMethod && pythonFieldOnlyBody(classNode)) {
2883
+ const typedShape = baseNodes.some((base) => pythonBaseIsStdlib(base, bindings, PYTHON_TYPING_MODULES, PYTHON_TYPING_SHAPES) ||
2884
+ pythonBaseIsStdlib(base, bindings, PYTHON_PYDANTIC_MODULES, PYTHON_PYDANTIC_SHAPES));
2885
+ const dataclassShape = Boolean(decorated) && namedChildren(decorated).some((child) => child.type === "decorator" && pythonIsStdlibDataclassDecorator(child, bindings));
2886
+ if (typedShape || dataclassShape) {
2830
2887
  out.push({
2831
- symbol: initializer,
2832
- code: "python_trivial_thread_local_initializer",
2833
- reason: "Trivial threading.local initializer — retained in the graph but excluded from priority ranking."
2888
+ symbol: name,
2889
+ code: "python_data_shape_declaration",
2890
+ reason: "Data-shape declaration (fields only, no methods) — retained in the graph but excluded from priority ranking."
2834
2891
  });
2892
+ continue;
2835
2893
  }
2836
2894
  }
2895
+ // Trivial constructor on ANY class: the body only stores parameters/literals on
2896
+ // self, forwards parameters/literals to super().__init__, or passes.
2897
+ const initializer = pythonTrivialInitializer(classNode);
2898
+ if (initializer) {
2899
+ out.push({
2900
+ symbol: initializer,
2901
+ code: "python_trivial_constructor",
2902
+ reason: "Trivial constructor (stores or forwards its parameters only) — retained in the graph but excluded from priority ranking."
2903
+ });
2904
+ }
2837
2905
  }
2838
2906
  return out;
2839
2907
  }
@@ -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,10 +1687,13 @@ 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
- autoProveResult = await autoProve(root, {
1695
+ const scopedFiles = autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef);
1696
+ const runAutoProve = (changedFiles) => autoProve(root, {
1694
1697
  autoLimit: opts.proofLimit ?? opts.autoLimit,
1695
1698
  proof_existing_limit: opts.proofLimit ?? opts.autoLimit ?? loadedRiskConfig.config.proof.attempt_limit,
1696
1699
  proof_gen_limit: loadedRiskConfig.config.proof.baseline_green_target,
@@ -1698,8 +1701,24 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1698
1701
  provider: providerOpts.provider,
1699
1702
  model: providerOpts.model,
1700
1703
  prompt_version: opts.promptVersion,
1701
- changedFiles: autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef)
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
1702
1710
  }, { ...providerDeps, proveLoop: opProveLoop });
1711
+ autoProveResult = await runAutoProve(scopedFiles);
1712
+ if (scopedFiles && autoProveResult.attempted === 0 && !opts.baseRef && !opts.noAuto) {
1713
+ // An uncommitted working tree implicitly scoped the pass, but nothing in that
1714
+ // scope was provable. Fall back to the global top targets instead of reporting
1715
+ // "not attempted" for a reason the user cannot see. --base keeps strict scoping.
1716
+ warnings.push(`auto-prove: none of the ${scopedFiles.length} uncommitted changed file(s) had a provable target with a linked test; ran the global top targets instead.`);
1717
+ autoProveResult = await runAutoProve(undefined);
1718
+ }
1719
+ else if (scopedFiles) {
1720
+ autoProveResult = { ...autoProveResult, scoped_changed_files: scopedFiles.length };
1721
+ }
1703
1722
  for (const skip of autoProveResult.skipped)
1704
1723
  warnings.push(`auto-prove skipped ${skip.target_symbol ?? skip.title}: ${skip.reason}`);
1705
1724
  }
@@ -1944,6 +1963,9 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1944
1963
  const dynForReport = {
1945
1964
  attempted: autoProveResult.attempted,
1946
1965
  proven: autoProveResult.proven,
1966
+ status: autoProveResult.status,
1967
+ reason: autoProveResult.reason,
1968
+ ...(autoProveResult.scoped_changed_files !== undefined ? { scopedChangedFiles: autoProveResult.scoped_changed_files } : {}),
1947
1969
  needsSetup: autoProveResult.needs_setup
1948
1970
  .filter((a) => !a.deduped)
1949
1971
  .map((a) => ({ category: a.category, reason: a.reason }))
@@ -107,6 +107,9 @@ export function distillProofAttempts(auto, meta) {
107
107
  artifact_identity: meta.graph.artifact_identity,
108
108
  attempted: auto.attempted,
109
109
  proven: auto.proven,
110
+ ...(auto.status ? { status: auto.status } : {}),
111
+ ...(auto.reason ? { reason: redactSecrets(auto.reason) } : {}),
112
+ ...(auto.scoped_changed_files !== undefined ? { scoped_changed_files: auto.scoped_changed_files } : {}),
110
113
  attempts: auto.attempts.filter((a) => !a.deduped).map((a) => ({
111
114
  target_symbol: a.target_symbol,
112
115
  test_path: a.test_path || undefined,
@@ -3,7 +3,7 @@ import { ORS_VERSION } from "./score/risk.js";
3
3
  import { ORANGEPRO_VERSION } from "./version.js";
4
4
  import { hashString } from "./util/hash.js";
5
5
  export const ARTIFACT_IDENTITY_VERSION = "orangepro.artifact_identity.v1";
6
- export const ANALYZER_VERSION = "orangepro.analyzer.v5";
6
+ export const ANALYZER_VERSION = "orangepro.analyzer.v6";
7
7
  export const PROOF_ORACLE_VERSION = "orangepro.targeted_mutation_oracle.v3";
8
8
  function stable(value) {
9
9
  if (value === null || typeof value !== "object")
@@ -78,15 +78,18 @@ function confirmedBehaviorIds(graph, provenIds) {
78
78
  }
79
79
  return ids;
80
80
  }
81
- /** Bounded repository-wide history acquisition. Deliberately constants, not hidden timeouts. */
82
- export const GIT_CHURN_MAX_COMMITS = 5_000;
83
- export const GIT_CHURN_TIMEOUT_MS = 60_000;
81
+ /** Default bounds for repository-wide history acquisition (overridable via
82
+ * tuning.churn_max_commits / tuning.churn_timeout_seconds, always disclosed).
83
+ * Sized so an active monorepo's full 180-day window completes (a 5,000-commit /
84
+ * 60 s bound truncated windows that 0.2.44 scanned completely). */
85
+ export const GIT_CHURN_MAX_COMMITS = 20_000;
86
+ export const GIT_CHURN_TIMEOUT_MS = 300_000;
84
87
  const GIT_FIRST_COMMIT_BATCH = 200;
85
88
  /**
86
89
  * This worker intentionally uses spawn, not execFileSync: stdout is parsed as it
87
- * arrives, so a 60s bound returns the values already observed instead of erasing
88
- * completed chunks. It retains only requested paths, bounding the worker map and
89
- * handoff payload by ranking input rather than repository history. The scoring API
90
+ * arrives, so a time bound returns the values already observed instead of erasing
91
+ * completed chunks. It keeps one counter per path the window touched (bounded by
92
+ * the window, not by total history) so a single scan serves every caller. The scoring API
90
93
  * is synchronous, therefore the worker signals only after writing its result file.
91
94
  */
92
95
  const GIT_CHURN_WORKER_SOURCE = String.raw `
@@ -95,7 +98,7 @@ const { writeFileSync } = require("node:fs");
95
98
  const { workerData } = require("node:worker_threads");
96
99
  const state = new Int32Array(workerData.signal);
97
100
  const values = new Map();
98
- const trackedFiles = new Set(workerData.files);
101
+ const trackedFiles = Array.isArray(workerData.files) ? new Set(workerData.files) : null;
99
102
  let commitsScanned = 0;
100
103
  let commitLimitReached = false;
101
104
  let expectingTimestamp = false;
@@ -110,7 +113,7 @@ let child;
110
113
  let childClosed = false;
111
114
  const firstLine = (text) => String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
112
115
  const add = (path, adds, dels) => {
113
- if (!path || commitLimitReached || !trackedFiles.has(path)) return;
116
+ if (!path || commitLimitReached || (trackedFiles && !trackedFiles.has(path))) return;
114
117
  values.set(path, (values.get(path) || 0) + adds + dels);
115
118
  };
116
119
  const consume = (token) => {
@@ -260,12 +263,17 @@ function gitChurn(root, files, window, health = inspectRiskInputHealth(root, win
260
263
  // Values are retained only for the requested paths, so that set and clone
261
264
  // state are part of cache identity. A checkout can be marked partial at an
262
265
  // unchanged HEAD and must then remain truthfully provisional.
263
- const requestedFiles = [...new Set(files)].sort();
264
- const requested = new Set(requestedFiles);
265
- const key = `${health.gitRoot ?? root}\0${health.commit ?? ""}\0${health.history}\0${window}\0${hashString(JSON.stringify(requestedFiles))}`;
266
+ const requested = new Set(files);
267
+ const tuning = loadRiskConfig(root).config.tuning;
268
+ const maxCommits = tuning.churn_max_commits ?? GIT_CHURN_MAX_COMMITS;
269
+ const timeoutMs = (tuning.churn_timeout_seconds ?? GIT_CHURN_TIMEOUT_MS / 1000) * 1000;
270
+ // ONE bounded scan per repository state: values are kept for every path the
271
+ // window touched and filtered per caller, so the ranking, the report header and
272
+ // the static snapshot never each pay for (and each time out on) the same scan.
273
+ const key = `${health.gitRoot ?? root}\0${health.commit ?? ""}\0${health.history}\0${window}\0${maxCommits}\0${timeoutMs}`;
266
274
  const cached = historyCache.get(key);
267
275
  if (cached)
268
- return { ...cached, values: new Map(cached.values) };
276
+ return { ...cached, values: new Map([...cached.values].filter(([file]) => requested.has(file))) };
269
277
  const dir = mkdtempSync(join(tmpdir(), "orangepro-churn-"));
270
278
  const resultPath = join(dir, "result.json");
271
279
  const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
@@ -277,9 +285,9 @@ function gitChurn(root, files, window, health = inspectRiskInputHealth(root, win
277
285
  workerData: {
278
286
  root,
279
287
  window,
280
- files: requestedFiles,
281
- maxCommits: GIT_CHURN_MAX_COMMITS,
282
- timeoutMs: GIT_CHURN_TIMEOUT_MS,
288
+ files: null,
289
+ maxCommits,
290
+ timeoutMs,
283
291
  cloneState: health.history,
284
292
  resultPath,
285
293
  signal
@@ -287,7 +295,7 @@ function gitChurn(root, files, window, health = inspectRiskInputHealth(root, win
287
295
  });
288
296
  // The worker publishes a partial result 100ms after its child timeout; leave
289
297
  // explicit scheduling room before the synchronous API forces it down.
290
- Atomics.wait(state, 0, 0, GIT_CHURN_TIMEOUT_MS + 2_000);
298
+ Atomics.wait(state, 0, 0, timeoutMs + 2_000);
291
299
  if (Atomics.load(state, 0) === 1)
292
300
  parsed = JSON.parse(readFileSync(resultPath, "utf8"));
293
301
  void worker.terminate().catch(() => undefined);
@@ -519,6 +527,10 @@ const IMPORTED_REPLACEMENT_CALL_RE = /^replace[A-Za-z0-9_]*$/i;
519
527
  // removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
520
528
  const REMOVE_CALL_RE = /^remove(?![A-Za-z0-9_]*(?:listener|handler|observer|callback|attribute|attr|class|child|style|hook|timeout|timer)$)[A-Za-z0-9_]*$/i;
521
529
  const PERSISTENCE_FIELD_RE = /(store|client|db|repo|repository|persistence|manager|queue|bucket|index|table|storage|dao)/i;
530
+ // The browser Web Storage API (`sessionStorage` / `localStorage`, optionally via
531
+ // `window.`) is a per-tab/per-device client cache, not a durable data store. Its
532
+ // `removeItem` matches the persistence vocabulary ("storage") by name only.
533
+ const BROWSER_WEB_STORAGE_FIELD_RE = /(^|\.)(sessionStorage|localStorage)$/;
522
534
  const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(run|execute|handle|process|tick|scan)$/i;
523
535
  const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
524
536
  /** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
@@ -646,28 +658,48 @@ export function rankRiskGaps(graph, opts = {}) {
646
658
  // attributed; CALLS already has an exact symbol target. Older graphs without
647
659
  // these metadata facts fail closed instead of redistributing imports.
648
660
  const incoming = new Map();
661
+ // Display-only breakdown of the same total, so a reader can reconcile the
662
+ // number against the graph: CALLS edges + named imports + module.attr references.
663
+ const incomingParts = new Map();
664
+ const addIncoming = (id, kind, n) => {
665
+ incoming.set(id, (incoming.get(id) ?? 0) + n);
666
+ const parts = incomingParts.get(id) ?? { calls: 0, named: 0, attrs: 0 };
667
+ parts[kind] += n;
668
+ incomingParts.set(id, parts);
669
+ };
649
670
  for (const e of graph.edges) {
650
671
  if (e.relationship_type === "CALLS" && symbolIds.has(e.to_external_id)) {
651
- incoming.set(e.to_external_id, (incoming.get(e.to_external_id) ?? 0) + 1);
672
+ addIncoming(e.to_external_id, "calls", 1);
652
673
  }
653
674
  else if (e.relationship_type === "IMPORTS") {
654
675
  const named = e.properties?.symbol_imports;
655
676
  if (Array.isArray(named))
656
677
  for (const id of new Set(named)) {
657
678
  if (typeof id === "string" && symbolIds.has(id) && fileBySymbolId.get(id) === e.to_external_id) {
658
- incoming.set(id, (incoming.get(id) ?? 0) + 1);
679
+ addIncoming(id, "named", 1);
659
680
  }
660
681
  }
661
682
  const refs = e.properties?.symbol_references;
662
683
  if (refs && typeof refs === "object" && !Array.isArray(refs)) {
663
684
  for (const [id, count] of Object.entries(refs)) {
664
685
  if (symbolIds.has(id) && typeof count === "number" && Number.isSafeInteger(count) && count > 0 && fileBySymbolId.get(id) === e.to_external_id) {
665
- incoming.set(id, (incoming.get(id) ?? 0) + count);
686
+ addIncoming(id, "attrs", count);
666
687
  }
667
688
  }
668
689
  }
669
690
  }
670
691
  }
692
+ const incomingBreakdown = (id) => {
693
+ const parts = incomingParts.get(id);
694
+ if (!parts || (parts.named === 0 && parts.attrs === 0))
695
+ return "";
696
+ const bits = [`${parts.calls} call${parts.calls === 1 ? "" : "s"}`];
697
+ if (parts.named > 0)
698
+ bits.push(`${parts.named} named import${parts.named === 1 ? "" : "s"}`);
699
+ if (parts.attrs > 0)
700
+ bits.push(`${parts.attrs} module.attribute reference${parts.attrs === 1 ? "" : "s"}`);
701
+ return ` (${bits.join(" + ")})`;
702
+ };
671
703
  // Per-symbol churn share: file churn weighted by the symbol's line span so one
672
704
  // hot file no longer awards its full churn to every method it contains.
673
705
  const fileComplexityTotals = new Map();
@@ -726,20 +758,21 @@ export function rankRiskGaps(graph, opts = {}) {
726
758
  // segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
727
759
  const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
728
760
  const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
761
+ const persistentRemove = (c) => REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c)) && !BROWSER_WEB_STORAGE_FIELD_RE.test(fieldOf(c));
729
762
  const destructive = ext.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
730
- (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
763
+ persistentRemove(c) ||
731
764
  extraSinks.some((re) => re.test(lastSeg(c)))));
732
765
  if (destructive)
733
766
  return destructive;
734
767
  return importedStatic.find((c) => viaField(c) && (isDestructiveTerminal(lastSeg(c)) ||
735
768
  IMPORTED_REPLACEMENT_CALL_RE.test(lastSeg(c)) ||
736
- (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
769
+ persistentRemove(c) ||
737
770
  extraSinks.some((re) => re.test(lastSeg(c)))));
738
771
  };
739
772
  const reachedSinkFrom = (id, depth, seen) => {
740
773
  const own = sinkCallee(id);
741
774
  if (own)
742
- return own;
775
+ return { callee: own, owner: id };
743
776
  if (depth === 0)
744
777
  return undefined;
745
778
  for (const t of fanOutTargets.get(id) ?? []) {
@@ -752,7 +785,12 @@ export function rankRiskGaps(graph, opts = {}) {
752
785
  }
753
786
  return undefined;
754
787
  };
755
- const sinkReached = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
788
+ const endpointHandlers = new Set(graph.edges
789
+ .filter((e) => e.relationship_type === "IMPLEMENTED_IN" && nodeById.get(e.from_external_id)?.kind === "Endpoint")
790
+ .map((e) => e.to_external_id));
791
+ const frameworkInvoked = (node) => node.properties.cli_entrypoint === true || endpointHandlers.has(node.external_id);
792
+ const sinkHits = new Map(normalizationSymbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
793
+ const sinkReached = new Map([...sinkHits].map(([k, v]) => [k, v?.callee]));
756
794
  const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
757
795
  const depthCtx = buildFlowDepthContext(graph);
758
796
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
@@ -766,7 +804,13 @@ export function rankRiskGaps(graph, opts = {}) {
766
804
  if (node.properties.ranking_exclusion_code === "python_class_with_methods" || node.properties.ranking_exclusion_code === "python_structural_container" || node.properties.ranking_exclusion_reason === "python_structural_container")
767
805
  return true;
768
806
  const code = node.properties.ranking_exclusion_reason_code;
769
- if (node.properties.ranking_exclusion_code === "trivial_constructor" || node.properties.ranking_exclusion_code === "enum_declaration" || code === "python_trivial_thread_local_initializer" || code === "python_stdlib_enum_declaration")
807
+ if (node.properties.ranking_exclusion_code === "trivial_constructor" ||
808
+ node.properties.ranking_exclusion_code === "enum_declaration" ||
809
+ node.properties.ranking_exclusion_code === "data_shape_declaration" ||
810
+ code === "python_trivial_thread_local_initializer" ||
811
+ code === "python_trivial_constructor" ||
812
+ code === "python_stdlib_enum_declaration" ||
813
+ code === "python_data_shape_declaration")
770
814
  return true;
771
815
  return (node.properties.ranking_exclusion_code === "thin_external_delegate" || code === "python_thin_external_delegate")
772
816
  && deriveDataSensitivity(node) <= 1
@@ -848,12 +892,16 @@ export function rankRiskGaps(graph, opts = {}) {
848
892
  const d = raw.d;
849
893
  const detectionTier = detectionFor(s.external_id);
850
894
  let score = Math.round(pExact * iExact * d * 10) / 10;
851
- const disconnected = incoming_refs === 0 && fan_out === 0;
895
+ // A framework-invoked entry (a registered CLI command, or a handler bound to an
896
+ // Endpoint) has no static caller BY CONSTRUCTION; "disconnected" means dead-code
897
+ // shaped, which an entry the framework calls is not. Before per-symbol fan-in,
898
+ // such entries escaped this dampener only through file-split import counts.
899
+ const disconnected = incoming_refs === 0 && fan_out === 0 && !frameworkInvoked(s);
852
900
  if (disconnected)
853
901
  score = Math.round(score * 0.25 * 10) / 10;
854
902
  const reasons = [
855
903
  `ORS ${score} ≈ P${p} × I${i} × D${d}`,
856
- `${incoming_refs} incoming symbol-specific structural reference${incoming_refs === 1 ? "" : "s"}`,
904
+ `${incoming_refs} incoming symbol-specific structural reference${incoming_refs === 1 ? "" : "s"}${incomingBreakdown(s.external_id)}`,
857
905
  churnAvailable
858
906
  ? `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in recent history`
859
907
  : churnResult.state === "partial"
@@ -880,7 +928,7 @@ export function rankRiskGaps(graph, opts = {}) {
880
928
  reasons.push(`config override (${override.action}): ${override.reason}`);
881
929
  return {
882
930
  ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
883
- ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
931
+ ...(sinkHits.get(s.external_id) ? { sink_callee: sinkHits.get(s.external_id).callee, sink_owner: sinkHits.get(s.external_id).owner } : {}),
884
932
  ...(raw.scheduledEntry ? { scheduled_entry: true } : {}),
885
933
  detection_tier: detectionTier,
886
934
  id: s.external_id,
@@ -1000,7 +1048,7 @@ export function configDisclosureFor(graph, repoRoot) {
1000
1048
  rankExcludePaths: cfg.classification.rank_exclude_paths,
1001
1049
  floor: cfg.tuning.irreversibility_floor,
1002
1050
  silence: cfg.tuning.silence_multiplier,
1003
- tuning: { churn_window_days: cfg.tuning.churn_window_days },
1051
+ tuning: { churn_window_days: cfg.tuning.churn_window_days, churn_max_commits: cfg.tuning.churn_max_commits, churn_timeout_seconds: cfg.tuning.churn_timeout_seconds },
1004
1052
  proof: cfg.proof,
1005
1053
  classification: {
1006
1054
  test_support_paths: cfg.classification.test_support_paths,
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  export const DEFAULT_RISK_CONFIG = {
13
13
  classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [], rank_exclude_paths: [] },
14
- tuning: { irreversibility_floor: true, silence_multiplier: true, churn_window_days: 180 },
14
+ tuning: { irreversibility_floor: true, silence_multiplier: true, churn_window_days: 180, churn_max_commits: 20_000, churn_timeout_seconds: 300 },
15
15
  proof: { python_runner: "auto", attempt_limit: 20, baseline_green_target: 5 },
16
16
  overrides: []
17
17
  };
@@ -51,6 +51,10 @@ function applyFile(cfg, file, warnings, label) {
51
51
  cfg.tuning.silence_multiplier = tun.silence_multiplier;
52
52
  if (tun.churn_window_days !== undefined)
53
53
  cfg.tuning.churn_window_days = boundedInt(tun.churn_window_days, cfg.tuning.churn_window_days, 1, 3_650);
54
+ if (tun.churn_max_commits !== undefined)
55
+ cfg.tuning.churn_max_commits = boundedInt(tun.churn_max_commits, cfg.tuning.churn_max_commits, 100, 200_000);
56
+ if (tun.churn_timeout_seconds !== undefined)
57
+ cfg.tuning.churn_timeout_seconds = boundedInt(tun.churn_timeout_seconds, cfg.tuning.churn_timeout_seconds, 10, 1_800);
54
58
  const proof = (raw.proof ?? {});
55
59
  if (typeof proof.python_runner === "string" && proof.python_runner.trim() !== "") {
56
60
  cfg.proof.python_runner = proof.python_runner.trim();
@@ -178,10 +178,19 @@ function proofGuidance(ledger, summary, dyn) {
178
178
  // — behaviors, flows, Statically Linked signals — is unaffected; only the dynamic pass is small.
179
179
  if (dyn) {
180
180
  if (dyn.attempted === 0) {
181
+ const why = dyn.status === "disabled"
182
+ ? " Auto-prove was disabled for this run (--no-auto)."
183
+ : dyn.scopedChangedFiles !== undefined
184
+ ? ` The pass was scoped to ${dyn.scopedChangedFiles.toLocaleString()} changed file${dyn.scopedChangedFiles === 1 ? "" : "s"} in the working tree, and none had a provable target with a linked test.`
185
+ : dyn.status === "no-targets"
186
+ ? " No provable target with a linked existing test was found."
187
+ : dyn.reason
188
+ ? ` ${dyn.reason}`
189
+ : "";
181
190
  return {
182
191
  state: "not_started",
183
192
  title: "0 Dynamically Proven — dynamic proof was not attempted",
184
- body: "Dynamic proof was not attempted in this run. Static behavior mapping and Statically Linked signals are still available.",
193
+ body: `Dynamic proof was not attempted in this run.${why} Static behavior mapping and Statically Linked signals are still available.`,
185
194
  action: HANDOFF_ACTION
186
195
  };
187
196
  }
@@ -486,7 +495,7 @@ function displayTitle(title, file) {
486
495
  }
487
496
  /** Deterministic 1–2 line behavior context from graph facts only — no LLM.
488
497
  * Sensitivity label mirrors deriveDataSensitivity's tiers. */
489
- export function riskContext(risk, churnWindowDays, churnMeta) {
498
+ export function riskContext(risk, churnWindowDays, churnMeta, sameSinkAs) {
490
499
  const sens = (risk.data_sensitivity ?? 1) >= 10 ? "payment/billing-sensitive"
491
500
  : (risk.data_sensitivity ?? 1) >= 9 ? "auth/session-sensitive"
492
501
  : (risk.data_sensitivity ?? 1) >= 7 ? "order/transaction"
@@ -507,7 +516,10 @@ export function riskContext(risk, churnWindowDays, churnMeta) {
507
516
  : "change history unavailable";
508
517
  const tier = risk.detection_tier ?? "";
509
518
  const evidence = tier === "candidate"
510
- ? "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)"
511
523
  : tier === "associated" ? "a test calls it but nothing proves it fails when broken" : "no test links here";
512
524
  const sinkShort = sink ? sink.split(".").pop() ?? sink : "";
513
525
  // Line 1 — the consequence, in plain English, from the signals only.
@@ -524,6 +536,7 @@ export function riskContext(risk, churnWindowDays, churnMeta) {
524
536
  const seen = [
525
537
  pos + (sens ? ` on ${sens} paths` : ""),
526
538
  ...(sink ? [`reaches \`${sink}\` within two calls`] : []),
539
+ ...(sink && sameSinkAs ? [`same delete call as \`${sameSinkAs}\` (ranked higher)`] : []),
527
540
  ...(scheduled ? ["scheduled / queue-triggered"] : []),
528
541
  `${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
529
542
  churn,
@@ -941,6 +954,12 @@ function riskRows(risks, graph, churnWindowDays, churnMeta) {
941
954
  for (const r of risks)
942
955
  if (!firstRowForFile.has(r.file))
943
956
  firstRowForFile.set(r.file, r.id);
957
+ // R8.10 (display only): rows that reach the SAME destructive call site through
958
+ // forwarding wrappers name the higher-ranked row. Order and membership unchanged.
959
+ const firstRowForSink = new Map();
960
+ for (const r of risks)
961
+ if (r.sink_owner && !firstRowForSink.has(r.sink_owner))
962
+ firstRowForSink.set(r.sink_owner, { id: r.id, title: r.title });
944
963
  // Ambiguity-qualified display: identical titles from DIFFERENT files
945
964
  // (multi-program repos: 76 x main) get their top-level dir as a prefix.
946
965
  // Purely display; single-file titles render unchanged everywhere else.
@@ -990,7 +1009,9 @@ function riskRows(risks, graph, churnWindowDays, churnMeta) {
990
1009
  todo: riskTodo(risk, verb, path, generatedTests)
991
1010
  };
992
1011
  })(),
993
- context: riskContext(risk, churnWindowDays, churnMeta),
1012
+ context: riskContext(risk, churnWindowDays, churnMeta, risk.sink_owner && firstRowForSink.get(risk.sink_owner) && firstRowForSink.get(risk.sink_owner).id !== risk.id
1013
+ ? firstRowForSink.get(risk.sink_owner).title
1014
+ : undefined),
994
1015
  desc: risk.reasons.join(" · "),
995
1016
  tags
996
1017
  };
@@ -1040,7 +1061,16 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
1040
1061
  provenIds,
1041
1062
  includeAssociated: true
1042
1063
  }).filter((r) => r.sink_callee).slice(0, 20)
1043
- .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
1064
+ .map((r, _index, rows) => {
1065
+ const first = rows.find((x) => x.sink_owner && x.sink_owner === r.sink_owner);
1066
+ return {
1067
+ path: r.title,
1068
+ file: r.file,
1069
+ score: r.risk_score,
1070
+ sink: r.sink_callee ?? "",
1071
+ ...(first && first.id !== r.id ? { sameSinkAs: first.title } : {})
1072
+ };
1073
+ });
1044
1074
  const riskHealth = inspectRiskInputHealth(repoRoot);
1045
1075
  const codeFiles = Object.entries(graph.manifest.files).filter(([, file]) => file.kind === "code").map(([file]) => file);
1046
1076
  const churnMeta = inspectRiskChurn(repoRoot, codeFiles, riskHealth.churnWindow);
@@ -1060,13 +1060,13 @@ renderRisks();
1060
1060
  const P=C.proof||{};
1061
1061
  const T=C.tuning||{};
1062
1062
  const clsSet=["test_support_paths","scheduled_entry_paths","destructive_sinks","sensitivity_ignore"].filter(k=>(K[k]||[]).length>0);
1063
- const tuned=C.overridesActive>0||(C.rankExcludePaths||[]).length>0||!C.floor||!C.silence||clsSet.length>0||(T.churn_window_days??180)!==180||P.python_runner!=="auto"||P.attempt_limit!==20||P.baseline_green_target!==5;
1063
+ const tuned=C.overridesActive>0||(C.rankExcludePaths||[]).length>0||!C.floor||!C.silence||clsSet.length>0||(T.churn_window_days??180)!==180||(T.churn_max_commits??20000)!==20000||(T.churn_timeout_seconds??300)!==300||P.python_runner!=="auto"||P.attempt_limit!==20||P.baseline_green_target!==5;
1064
1064
  const bits=["config <b>"+esc(C.hash)+"</b>","overrides active <b>"+C.overridesActive+"</b>","suppressed <b>"+(C.suppressed||[]).length+"</b>"];
1065
1065
  if((C.rankExcludePaths||[]).length)bits.push("ranking excludes <b>"+esc(C.rankExcludePaths.join(", "))+"</b>");
1066
1066
  if(!C.floor)bits.push("<b>irreversibility floor OFF</b>");
1067
1067
  if(!C.silence)bits.push("<b>silence multiplier OFF</b>");
1068
1068
  let html="<div>"+bits.join(" · ")+"</div>";
1069
- html+='<span class="cfg-row">Churn window: <b>'+esc(String(T.churn_window_days??180))+' days</b></span>';
1069
+ html+='<span class="cfg-row">Churn window: <b>'+esc(String(T.churn_window_days??180))+' days</b> · history bound <b>'+esc(String(T.churn_max_commits??20000))+' commits / '+esc(String(T.churn_timeout_seconds??300))+' s</b></span>';
1070
1070
  html+='<span class="cfg-row">Python proof: runner <b>'+esc(P.python_runner||"auto")+'</b> · attempt limit <b>'+esc(String(P.attempt_limit??20))+'</b> · green baseline target <b>'+esc(String(P.baseline_green_target??5))+'</b></span>';
1071
1071
  clsSet.forEach(k=>{html+='<span class="cfg-row">'+esc(k)+': <b>'+esc(K[k].join(", "))+'</b></span>';});
1072
1072
  if((C.sensitivityIgnored||[]).length)html+='<span class="cfg-row">sensitivity ignored by config (score changed): <b>'+esc(C.sensitivityIgnored.join(", "))+'</b></span>';
@@ -1086,7 +1086,7 @@ renderRisks();
1086
1086
  return \`<div class="wl-row" data-path="\${esc(path).replace(/"/g,'&quot;')}" title="\${inTop?'in the ranked list below — click to jump':'ranked, but below the top-20 cut'}"><span class="wl-path">\${esc(path)}</span><span class="wl-meta">\${esc(meta)}</span></div>\`;
1087
1087
  };
1088
1088
  const cf=(W.changeFrontier||[]).map(r=>row(r.path,"changes "+(r.probability>=7?"a lot":r.probability>=4?"often":"some"))).join("")||'<div class="wl-empty">nothing changing fast and unproven</div>';
1089
- const ir=(W.irreversible||[]).map(r=>row(r.path,"→ "+(r.sink||"").split(".").pop())).join("")||'<div class="wl-empty">no unproven path reaches a delete</div>';
1089
+ const ir=(W.irreversible||[]).map(r=>row(r.path,"→ "+(r.sink||"").split(".").pop()+(r.sameSinkAs?" · same delete as "+r.sameSinkAs:""))).join("")||'<div class="wl-empty">no unproven path reaches a delete</div>';
1090
1090
  host.innerHTML=\`<div class="wl-grid">
1091
1091
  <div class="wl-card"><p class="wl-title wl-change">Changing fast · unproven</p><p class="wl-sub">Where the code moves most with nothing proving it. The place a bug is most likely to have just arrived.</p>\${cf}</div>
1092
1092
  <div class="wl-card"><p class="wl-title wl-irrev">Can destroy data · unproven</p><p class="wl-sub">Paths that reach a delete or purge with nothing proving they do the right thing. Rarely changing — which is why nobody looks.</p>\${ir}</div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.45",
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,