@orangepro/orangepro-mcp 0.2.36 → 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
+ }
@@ -8,6 +8,7 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
+ import { homedir } from "node:os";
11
12
  export const DEFAULT_RISK_CONFIG = {
12
13
  classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [], rank_exclude_paths: [] },
13
14
  tuning: { irreversibility_floor: true, silence_multiplier: true },
@@ -21,41 +22,50 @@ export function globToRegExp(glob) {
21
22
  const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
22
23
  return new RegExp(`^${esc}$`);
23
24
  }
24
- export function loadRiskConfig(repoRoot) {
25
- const warnings = [];
26
- const cfg = JSON.parse(JSON.stringify(DEFAULT_RISK_CONFIG));
27
- const file = join(repoRoot, ".orangepro", "config.json");
28
- if (repoRoot && existsSync(file)) {
29
- try {
30
- const raw = JSON.parse(readFileSync(file, "utf8"));
31
- const cls = (raw.classification ?? {});
32
- cfg.classification.test_support_paths = asStringArray(cls.test_support_paths);
33
- cfg.classification.scheduled_entry_paths = asStringArray(cls.scheduled_entry_paths);
34
- cfg.classification.destructive_sinks = asStringArray(cls.destructive_sinks);
35
- cfg.classification.sensitivity_ignore = asStringArray(cls.sensitivity_ignore);
36
- cfg.classification.rank_exclude_paths = asStringArray(cls.rank_exclude_paths);
37
- const tun = (raw.tuning ?? {});
38
- if (typeof tun.irreversibility_floor === "boolean")
39
- cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
40
- if (typeof tun.silence_multiplier === "boolean")
41
- cfg.tuning.silence_multiplier = tun.silence_multiplier;
42
- for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
43
- const ov = o;
44
- if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
45
- warnings.push(`config: override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
46
- continue;
47
- }
48
- if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
49
- warnings.push(`config: override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
50
- continue;
51
- }
52
- cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
53
- }
25
+ /** User-level defaults: ~/.orangepro/config.json (override with ORANGEPRO_USER_CONFIG for tests/CI).
26
+ * Applied FIRST; the analyzed repo's .orangepro/config.json wins on every key it sets.
27
+ * The hash covers the merged result, so provenance still tells the truth. */
28
+ export function userConfigPath() {
29
+ return process.env.ORANGEPRO_USER_CONFIG ?? join(homedir(), ".orangepro", "config.json");
30
+ }
31
+ function applyFile(cfg, file, warnings, label) {
32
+ if (!existsSync(file))
33
+ return;
34
+ try {
35
+ const raw = JSON.parse(readFileSync(file, "utf8"));
36
+ const cls = (raw.classification ?? {});
37
+ for (const k of ["test_support_paths", "scheduled_entry_paths", "destructive_sinks", "sensitivity_ignore", "rank_exclude_paths"]) {
38
+ if (Array.isArray(cls[k]))
39
+ cfg.classification[k] = asStringArray(cls[k]);
54
40
  }
55
- catch (err) {
56
- warnings.push(`config: .orangepro/config.json unreadable for risk settings (${err.message}); defaults used.`);
41
+ const tun = (raw.tuning ?? {});
42
+ if (typeof tun.irreversibility_floor === "boolean")
43
+ cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
44
+ if (typeof tun.silence_multiplier === "boolean")
45
+ cfg.tuning.silence_multiplier = tun.silence_multiplier;
46
+ for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
47
+ const ov = o;
48
+ if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
49
+ warnings.push(`config (${label}): override ignored (needs symbol + action): ${JSON.stringify(o).slice(0, 80)}`);
50
+ continue;
51
+ }
52
+ if (typeof ov.reason !== "string" || ov.reason.trim().length < 8) {
53
+ warnings.push(`config (${label}): override for ${ov.symbol} ignored — a reason (≥8 chars) is required so it can be shown on the report.`);
54
+ continue;
55
+ }
56
+ cfg.overrides.push({ symbol: ov.symbol, action: ov.action, sensitivity: ov.sensitivity, reason: ov.reason.trim() });
57
57
  }
58
58
  }
59
+ catch (err) {
60
+ warnings.push(`config (${label}): unreadable for risk settings (${err.message}); skipped.`);
61
+ }
62
+ }
63
+ export function loadRiskConfig(repoRoot) {
64
+ const warnings = [];
65
+ const cfg = JSON.parse(JSON.stringify(DEFAULT_RISK_CONFIG));
66
+ applyFile(cfg, userConfigPath(), warnings, "user defaults");
67
+ if (repoRoot)
68
+ applyFile(cfg, join(repoRoot, ".orangepro", "config.json"), warnings, "repo");
59
69
  const canonical = JSON.stringify(cfg, Object.keys(cfg).sort());
60
70
  const hash = createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 12);
61
71
  void canonical;
@@ -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: {
@@ -169,6 +169,24 @@ nav.tabs{display:flex;gap:2px;margin:18px 0 0;border-bottom:1px solid var(--bd)}
169
169
  /* RISKS */
170
170
  .risk-card{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:14px 16px;margin-bottom:10px}
171
171
  .risk-tools{display:flex;align-items:center;gap:8px;margin:0 0 12px;flex-wrap:wrap}
172
+ /* WORKLISTS — two views of one ranking */
173
+ .wl-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 0 14px}
174
+ .wl-card{background:var(--s1);border:1px solid var(--bd);border-radius:9px;padding:12px 14px}
175
+ .wl-title{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;margin:0 0 2px}
176
+ .wl-title.wl-change{color:var(--amber)} .wl-title.wl-irrev{color:var(--red)}
177
+ .wl-sub{font-size:11px;color:var(--muted);margin:0 0 8px;line-height:1.45}
178
+ .wl-row{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--bd);font-family:var(--mono);font-size:11px;cursor:pointer}
179
+ .wl-row:last-child{border-bottom:0}
180
+ .wl-row:hover .wl-path{color:var(--ink)}
181
+ .wl-path{color:var(--ink2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
182
+ .wl-meta{color:var(--faint);flex-shrink:0;max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
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}
189
+ @media(max-width:800px){.wl-grid{grid-template-columns:1fr}}
172
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}
173
191
  .risk-filter:hover{border-color:var(--bd2);color:var(--ink2)}
174
192
  .risk-filter[aria-pressed="true"]{border-color:var(--orange);background:var(--obg);color:var(--orange)}
@@ -423,6 +441,8 @@ body[data-mode="expert"] .simple-only{display:none!important}
423
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>
424
442
  <p class="bridge" id="risk-cap-note" style="font-size:12px;opacity:.75"></p>
425
443
  <div class="risk-tools" id="risk-tools"></div>
444
+ <div id="config-disclosure"></div>
445
+ <div id="worklists"></div>
426
446
  <div id="risk-list"></div>
427
447
  </section>
428
448
 
@@ -1008,6 +1028,41 @@ riskTools.addEventListener("click",e=>{
1008
1028
  });
1009
1029
  renderRiskFilters();
1010
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
+ })();
1048
+ // ── Worklists: two views of ONE ranking. A single P×I×D top-20 cannot hold
1049
+ // "changing fast" and "irreversible but stable" together; both are shown here.
1050
+ (function(){
1051
+ const W=D.worklists, host=$("#worklists");
1052
+ if(!W||!host)return;
1053
+ const topPaths=new Set(D.risks.map(r=>r.path));
1054
+ const row=(path,meta)=>{
1055
+ const inTop=topPaths.has(path);
1056
+ 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>\`;
1057
+ };
1058
+ 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>';
1059
+ 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>';
1060
+ host.innerHTML=\`<div class="wl-grid">
1061
+ <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>
1062
+ <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>
1063
+ </div>\`;
1064
+ host.addEventListener("click",e=>{const r=e.target.closest("[data-path]");if(r&&topPaths.has(r.getAttribute("data-path")))scrollToRisk(r.getAttribute("data-path"));});
1065
+ })();
1011
1066
  // toggle test expand
1012
1067
  document.addEventListener('click',e=>{
1013
1068
  const h=e.target.closest('.gen-test-head');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.36",
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",