@orangepro/orangepro-mcp 0.2.37 → 0.2.38

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.
@@ -1,3 +1,4 @@
1
+ import { loadRiskConfig } from "./score/riskConfig.js";
1
2
  import { execFileSync, spawnSync } from "node:child_process";
2
3
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, relative, resolve, sep } from "node:path";
@@ -1553,6 +1554,10 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1553
1554
  }, deps);
1554
1555
  const warnings = [...analyze.warnings];
1555
1556
  reportProgress("start: deterministic graph is ready", { current: 4, total: 8 });
1557
+ // A per-repo/user risk config that fails to parse must announce itself here, not
1558
+ // silently revert to defaults (round-three review finding #1).
1559
+ for (const w of loadRiskConfig(root).warnings)
1560
+ warnings.push(w);
1556
1561
  const staticSnapshot = writeStartStaticSnapshot(root, opts.baseRef, warnings);
1557
1562
  const aiProviderConfigured = deps.aiProvider !== undefined || resolveProviderConfig(providerEnv, providerOpts) !== null;
1558
1563
  const resolvedGenerationProvider = deps.aiProvider ?? resolveGenerationProvider(providerEnv, providerOpts);
@@ -337,7 +337,7 @@ function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detection
337
337
  const routeWeight = deriveRouteWeight(node);
338
338
  const flowDepth = getFlowDepth(node, depthCtx);
339
339
  const flowPosition = Math.max(0, 5 - flowDepth);
340
- const dataSensitivity = signals.sensitivityIgnored ? 0 : deriveDataSensitivity(node);
340
+ const dataSensitivity = signals.sensitivityOverride !== undefined ? signals.sensitivityOverride : signals.sensitivityIgnored ? 0 : deriveDataSensitivity(node);
341
341
  // Irreversibility is impact: a bug on a path that reaches a delete/purge cannot be
342
342
  // rolled back. Bounded, additive, graph-derived (Fix C, signal 1).
343
343
  const rawI = incomingRefs * 0.3 + routeWeight * 0.3 + flowPosition * 0.2 + dataSensitivity * 0.2;
@@ -345,7 +345,7 @@ function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detection
345
345
  // queue fails where no request surfaces it (Fix C, signal 2). Proven stays proven.
346
346
  const silentFactor = signals.scheduledEntry && detectionTier !== "associated" ? 1.25 : 1;
347
347
  const d = DETECTION_MAP[detectionTier] * silentFactor;
348
- return { p: rawP, i: rawI, d, reachesDestructiveSink: signals.reachesDestructiveSink, scheduledEntry: signals.scheduledEntry, sensitivityIgnored: signals.sensitivityIgnored };
348
+ return { p: rawP, i: rawI, d, reachesDestructiveSink: signals.reachesDestructiveSink, scheduledEntry: signals.scheduledEntry, sensitivityIgnored: signals.sensitivityIgnored, sensitivityOverride: signals.sensitivityOverride };
349
349
  }
350
350
  function staticTestLinkedIds(graph, candidateIds) {
351
351
  const ids = new Set();
@@ -565,7 +565,10 @@ export function rankRiskGaps(graph, opts = {}) {
565
565
  return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec, {
566
566
  reachesDestructiveSink: cfg.tuning.irreversibility_floor && (reachesSink.get(s.external_id) ?? false),
567
567
  scheduledEntry: cfg.tuning.silence_multiplier && (isScheduledEntry(s) || (SCHEDULED_ENTRY_NAME_RE.test(s.title || "") && extraScheduled.some((re) => re.test(symbolFile(s))))),
568
- sensitivityIgnored: sensitivityIgnore.some((re) => re.test(s.title || "") || re.test(s.external_id)) || overrideFor(s.external_id)?.action === "reclassify" && overrideFor(s.external_id)?.sensitivity === "none"
568
+ sensitivityIgnored: sensitivityIgnore.some((re) => re.test(s.title || "") || re.test(s.external_id)) || overrideFor(s.external_id)?.action === "reclassify" && overrideFor(s.external_id)?.sensitivity === "none",
569
+ // reclassify to a named class uses the SAME values deriveDataSensitivity assigns (payment 10, auth 9, pii 8)
570
+ sensitivityOverride: (() => { const o = overrideFor(s.external_id); if (!o || o.action !== "reclassify")
571
+ return undefined; return o.sensitivity === "payment" ? 10 : o.sensitivity === "auth" ? 9 : o.sensitivity === "pii" ? 8 : o.sensitivity === "none" ? 0 : undefined; })()
569
572
  });
570
573
  });
571
574
  const pinnedIds = new Set(symbols.filter((s) => overrideFor(s.external_id)?.action === "pin").map((s) => s.external_id));
@@ -583,7 +586,7 @@ export function rankRiskGaps(graph, opts = {}) {
583
586
  const fan_out = fanOut.get(s.external_id) ?? 0;
584
587
  const isEntry = entryPoint.get(s.external_id) ?? false;
585
588
  const route_weight = deriveRouteWeight(s);
586
- const data_sensitivity = rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
589
+ const data_sensitivity = rawScores[idx].sensitivityOverride !== undefined ? rawScores[idx].sensitivityOverride : rawScores[idx].sensitivityIgnored ? 0 : deriveDataSensitivity(s);
587
590
  const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
588
591
  const complexity_proxy = complexityProxy(s);
589
592
  const firstTs = firstCommitTs.get(file) ?? 0;
@@ -719,3 +722,28 @@ export function rankRiskGaps(graph, opts = {}) {
719
722
  export function rankPriorityGaps(graph, opts = {}) {
720
723
  return rankRiskGaps(graph, { ...opts, maxPerFile: 3, maxPerTitle: 1 });
721
724
  }
725
+ /** What the per-repo config DID to this ranking — for visible disclosure in the report.
726
+ * A tuned report must never pass as clean: every suppression is listed with its reason. */
727
+ export function configDisclosureFor(graph, repoRoot) {
728
+ const loaded = loadRiskConfig(repoRoot);
729
+ const cfg = loaded.config;
730
+ const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale);
731
+ const suppressed = [];
732
+ for (const o of cfg.overrides) {
733
+ if (o.action !== "suppress")
734
+ continue;
735
+ const re = globToRegExp(o.symbol);
736
+ for (const n of symbols)
737
+ if (n.external_id === o.symbol || re.test(n.external_id))
738
+ suppressed.push({ symbol: n.title || n.external_id, reason: o.reason });
739
+ }
740
+ return {
741
+ hash: loaded.hash,
742
+ warnings: loaded.warnings,
743
+ overridesActive: cfg.overrides.length,
744
+ suppressed,
745
+ rankExcludePaths: cfg.classification.rank_exclude_paths,
746
+ floor: cfg.tuning.irreversibility_floor,
747
+ silence: cfg.tuning.silence_multiplier
748
+ };
749
+ }
@@ -1,4 +1,5 @@
1
1
  import { loadRiskConfig } from "../score/riskConfig.js";
2
+ import { configDisclosureFor } from "../score/risk.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import path from "node:path";
4
5
  import { buildRtm } from "../rtm.js";
@@ -936,7 +937,9 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
936
937
  .sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0) || b.risk_score - a.risk_score || a.id.localeCompare(b.id))
937
938
  .slice(0, 20)
938
939
  .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, probability: r.probability ?? 0 }));
939
- const irreversible = wide.filter((r) => r.sink_callee).slice(0, 20)
940
+ // Fix: search the FULL ranking for sinks, not the top 200 — a stable destructive path
941
+ // ranked 300th is exactly what this panel exists to recover.
942
+ const irreversible = rankRiskGaps(graph, { repoRoot, limit: Number.MAX_SAFE_INTEGER, provenIds }).filter((r) => r.sink_callee).slice(0, 20)
940
943
  .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
941
944
  const riskHealth = inspectRiskInputHealth(repoRoot);
942
945
  const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
@@ -950,7 +953,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
950
953
  toolVersion: ORANGEPRO_VERSION,
951
954
  configHash: loadRiskConfig(repoRoot).hash,
952
955
  inputFingerprint: createHash("sha256")
953
- .update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION }))
956
+ .update(JSON.stringify({ root: graph.workspace.root_hash, commit: riskHealth.commit, history: riskHealth.history, churn: churnAvailable, window: riskHealth.churnWindow, version: ORANGEPRO_VERSION, config: loadRiskConfig(repoRoot).hash }))
954
957
  .digest("hex")
955
958
  .slice(0, 16),
956
959
  reason: churnAvailable ? undefined : (riskHealth.reason ?? "Git churn scan did not complete")
@@ -976,6 +979,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
976
979
  candidateFlows: candidateFlows(graph),
977
980
  risks,
978
981
  worklists: { changeFrontier, irreversible },
982
+ configDisclosure: configDisclosureFor(graph, repoRoot),
979
983
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
980
984
  mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
981
985
  viewMeta: {
@@ -181,6 +181,11 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
181
181
  .wl-path{color:var(--ink2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
182
182
  .wl-meta{color:var(--faint);flex-shrink:0;max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
183
183
  .wl-empty{font-size:11.5px;color:var(--faint);padding:6px 0}
184
+ .cfg{margin:0 0 12px;padding:8px 12px;border:1px solid var(--bd);border-radius:7px;background:var(--s1);font:11px/1.55 var(--mono);color:var(--muted)}
185
+ .cfg b{color:var(--ink2);font-weight:600}
186
+ .cfg.cfg-warn{border-color:var(--rbd);background:var(--rbg);color:var(--red)}
187
+ .cfg.cfg-tuned{border-color:var(--obd);background:var(--obg);color:var(--ink2)}
188
+ .cfg-row{display:block;margin-top:3px}
184
189
  @media(max-width:800px){.wl-grid{grid-template-columns:1fr}}
185
190
  .risk-filter{appearance:none;border:1px solid var(--bd);background:var(--s1);color:var(--muted);border-radius:20px;padding:5px 10px;font:inherit;font-size:12px;font-weight:600;cursor:pointer}
186
191
  .risk-filter:hover{border-color:var(--bd2);color:var(--ink2)}
@@ -436,6 +441,7 @@ body[data-mode="expert"] .simple-only{display:none!important}
436
441
  <p class="bridge expert-only">This is the <b>priority-gap worklist</b>, ranked by blast radius and test weakness. It is separate from the coverage-status cards above: <b>Reachable · no test signal</b> is one strict coverage bucket, not the number of priority gaps.</p>
437
442
  <p class="bridge" id="risk-cap-note" style="font-size:12px;opacity:.75"></p>
438
443
  <div class="risk-tools" id="risk-tools"></div>
444
+ <div id="config-disclosure"></div>
439
445
  <div id="worklists"></div>
440
446
  <div id="risk-list"></div>
441
447
  </section>
@@ -1022,6 +1028,23 @@ riskTools.addEventListener("click",e=>{
1022
1028
  });
1023
1029
  renderRiskFilters();
1024
1030
  renderRisks();
1031
+ // ── Config disclosure: what the per-repo config did. Always rendered — a tuned
1032
+ // report must never pass as clean, and a broken config must announce itself.
1033
+ (function(){
1034
+ const C=D.configDisclosure, host=$("#config-disclosure");
1035
+ if(!C||!host)return;
1036
+ const warn=(C.warnings||[]).length>0;
1037
+ const tuned=C.overridesActive>0||(C.rankExcludePaths||[]).length>0||!C.floor||!C.silence;
1038
+ const bits=["config <b>"+esc(C.hash)+"</b>","overrides active <b>"+C.overridesActive+"</b>","suppressed <b>"+(C.suppressed||[]).length+"</b>"];
1039
+ if((C.rankExcludePaths||[]).length)bits.push("ranking excludes <b>"+esc(C.rankExcludePaths.join(", "))+"</b>");
1040
+ if(!C.floor)bits.push("<b>irreversibility floor OFF</b>");
1041
+ if(!C.silence)bits.push("<b>silence multiplier OFF</b>");
1042
+ let html="<div>"+bits.join(" · ")+"</div>";
1043
+ (C.suppressed||[]).forEach(s=>{html+='<span class="cfg-row">suppressed by config: <b>'+esc(s.symbol)+'</b> — '+esc(s.reason)+'</span>';});
1044
+ (C.warnings||[]).forEach(w=>{html+='<span class="cfg-row">⚠ '+esc(w)+'</span>';});
1045
+ host.className="cfg"+(warn?" cfg-warn":tuned?" cfg-tuned":"");
1046
+ host.innerHTML=html;
1047
+ })();
1025
1048
  // ── Worklists: two views of ONE ranking. A single P×I×D top-20 cannot hold
1026
1049
  // "changing fast" and "irreversible but stable" together; both are shown here.
1027
1050
  (function(){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.37",
3
+ "version": "0.2.38",
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",