@orangepro/orangepro-mcp 0.2.45 → 0.2.46

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
  }
@@ -1690,7 +1690,8 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1690
1690
  : "auto-prove: driving generate → prove on the top provable targets", { current: 6, total: 8 });
1691
1691
  let autoProveResult;
1692
1692
  try {
1693
- autoProveResult = await autoProve(root, {
1693
+ const scopedFiles = autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef);
1694
+ const runAutoProve = (changedFiles) => autoProve(root, {
1694
1695
  autoLimit: opts.proofLimit ?? opts.autoLimit,
1695
1696
  proof_existing_limit: opts.proofLimit ?? opts.autoLimit ?? loadedRiskConfig.config.proof.attempt_limit,
1696
1697
  proof_gen_limit: loadedRiskConfig.config.proof.baseline_green_target,
@@ -1698,8 +1699,19 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1698
1699
  provider: providerOpts.provider,
1699
1700
  model: providerOpts.model,
1700
1701
  prompt_version: opts.promptVersion,
1701
- changedFiles: autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef)
1702
+ changedFiles
1702
1703
  }, { ...providerDeps, proveLoop: opProveLoop });
1704
+ autoProveResult = await runAutoProve(scopedFiles);
1705
+ if (scopedFiles && autoProveResult.attempted === 0 && !opts.baseRef && !opts.noAuto) {
1706
+ // An uncommitted working tree implicitly scoped the pass, but nothing in that
1707
+ // scope was provable. Fall back to the global top targets instead of reporting
1708
+ // "not attempted" for a reason the user cannot see. --base keeps strict scoping.
1709
+ 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.`);
1710
+ autoProveResult = await runAutoProve(undefined);
1711
+ }
1712
+ else if (scopedFiles) {
1713
+ autoProveResult = { ...autoProveResult, scoped_changed_files: scopedFiles.length };
1714
+ }
1703
1715
  for (const skip of autoProveResult.skipped)
1704
1716
  warnings.push(`auto-prove skipped ${skip.target_symbol ?? skip.title}: ${skip.reason}`);
1705
1717
  }
@@ -1944,6 +1956,9 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1944
1956
  const dynForReport = {
1945
1957
  attempted: autoProveResult.attempted,
1946
1958
  proven: autoProveResult.proven,
1959
+ status: autoProveResult.status,
1960
+ reason: autoProveResult.reason,
1961
+ ...(autoProveResult.scoped_changed_files !== undefined ? { scopedChangedFiles: autoProveResult.scoped_changed_files } : {}),
1947
1962
  needsSetup: autoProveResult.needs_setup
1948
1963
  .filter((a) => !a.deduped)
1949
1964
  .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"
@@ -524,6 +533,7 @@ export function riskContext(risk, churnWindowDays, churnMeta) {
524
533
  const seen = [
525
534
  pos + (sens ? ` on ${sens} paths` : ""),
526
535
  ...(sink ? [`reaches \`${sink}\` within two calls`] : []),
536
+ ...(sink && sameSinkAs ? [`same delete call as \`${sameSinkAs}\` (ranked higher)`] : []),
527
537
  ...(scheduled ? ["scheduled / queue-triggered"] : []),
528
538
  `${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
529
539
  churn,
@@ -941,6 +951,12 @@ function riskRows(risks, graph, churnWindowDays, churnMeta) {
941
951
  for (const r of risks)
942
952
  if (!firstRowForFile.has(r.file))
943
953
  firstRowForFile.set(r.file, r.id);
954
+ // R8.10 (display only): rows that reach the SAME destructive call site through
955
+ // forwarding wrappers name the higher-ranked row. Order and membership unchanged.
956
+ const firstRowForSink = new Map();
957
+ for (const r of risks)
958
+ if (r.sink_owner && !firstRowForSink.has(r.sink_owner))
959
+ firstRowForSink.set(r.sink_owner, { id: r.id, title: r.title });
944
960
  // Ambiguity-qualified display: identical titles from DIFFERENT files
945
961
  // (multi-program repos: 76 x main) get their top-level dir as a prefix.
946
962
  // Purely display; single-file titles render unchanged everywhere else.
@@ -990,7 +1006,9 @@ function riskRows(risks, graph, churnWindowDays, churnMeta) {
990
1006
  todo: riskTodo(risk, verb, path, generatedTests)
991
1007
  };
992
1008
  })(),
993
- context: riskContext(risk, churnWindowDays, churnMeta),
1009
+ context: riskContext(risk, churnWindowDays, churnMeta, risk.sink_owner && firstRowForSink.get(risk.sink_owner) && firstRowForSink.get(risk.sink_owner).id !== risk.id
1010
+ ? firstRowForSink.get(risk.sink_owner).title
1011
+ : undefined),
994
1012
  desc: risk.reasons.join(" · "),
995
1013
  tags
996
1014
  };
@@ -1040,7 +1058,16 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
1040
1058
  provenIds,
1041
1059
  includeAssociated: true
1042
1060
  }).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 ?? "" }));
1061
+ .map((r, _index, rows) => {
1062
+ const first = rows.find((x) => x.sink_owner && x.sink_owner === r.sink_owner);
1063
+ return {
1064
+ path: r.title,
1065
+ file: r.file,
1066
+ score: r.risk_score,
1067
+ sink: r.sink_callee ?? "",
1068
+ ...(first && first.id !== r.id ? { sameSinkAs: first.title } : {})
1069
+ };
1070
+ });
1044
1071
  const riskHealth = inspectRiskInputHealth(repoRoot);
1045
1072
  const codeFiles = Object.entries(graph.manifest.files).filter(([, file]) => file.kind === "code").map(([file]) => file);
1046
1073
  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.46",
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",