@orangepro/orangepro-mcp 0.2.44 → 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.
@@ -11,12 +11,18 @@ 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 },
14
+ tuning: { irreversibility_floor: true, silence_multiplier: true, churn_window_days: 180, churn_max_commits: 20_000, churn_timeout_seconds: 300 },
15
+ proof: { python_runner: "auto", attempt_limit: 20, baseline_green_target: 5 },
15
16
  overrides: []
16
17
  };
17
18
  function asStringArray(v) {
18
19
  return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
19
20
  }
21
+ function boundedInt(value, fallback, min, max) {
22
+ if (typeof value !== "number" || !Number.isFinite(value))
23
+ return fallback;
24
+ return Math.max(min, Math.min(max, Math.floor(value)));
25
+ }
20
26
  /** Glob → RegExp: `*` matches within a path segment, `**` matches across segments. */
21
27
  export function globToRegExp(glob) {
22
28
  const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
@@ -43,6 +49,20 @@ function applyFile(cfg, file, warnings, label) {
43
49
  cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
44
50
  if (typeof tun.silence_multiplier === "boolean")
45
51
  cfg.tuning.silence_multiplier = tun.silence_multiplier;
52
+ if (tun.churn_window_days !== undefined)
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);
58
+ const proof = (raw.proof ?? {});
59
+ if (typeof proof.python_runner === "string" && proof.python_runner.trim() !== "") {
60
+ cfg.proof.python_runner = proof.python_runner.trim();
61
+ }
62
+ if (proof.attempt_limit !== undefined)
63
+ cfg.proof.attempt_limit = boundedInt(proof.attempt_limit, cfg.proof.attempt_limit, 1, 100);
64
+ if (proof.baseline_green_target !== undefined)
65
+ cfg.proof.baseline_green_target = boundedInt(proof.baseline_green_target, cfg.proof.baseline_green_target, 1, 100);
46
66
  for (const o of Array.isArray(raw.overrides) ? raw.overrides : []) {
47
67
  const ov = o;
48
68
  if (typeof ov.symbol !== "string" || !["suppress", "pin", "reclassify"].includes(ov.action ?? "")) {
@@ -1,13 +1,16 @@
1
- import { loadRiskConfig } from "../score/riskConfig.js";
2
1
  import { configDisclosureFor } from "../score/risk.js";
3
2
  import path from "node:path";
4
3
  import { buildRtm } from "../rtm.js";
5
- import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps, riskHistoryFingerprint } from "../score/risk.js";
4
+ import { inspectRiskChurn, inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps, riskHistoryFingerprint } from "../score/risk.js";
6
5
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
7
6
  import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
8
7
  import { buildArtifactIdentity, comparisonCompatibility } from "../provenance.js";
9
8
  /** Short human phrase per R-1 needs_setup category, for the "blocked because: …" panel copy. */
10
9
  const BLOCK_CATEGORY_LABEL = {
10
+ environment_unavailable: "a Python test environment that could not start",
11
+ collection_error: "a Python test collection or import error",
12
+ go_package_build_failure: "a Go package that could not build for proof",
13
+ runner_missing: "a missing test runner in the selected package",
11
14
  module_not_found: "a missing module or dependency in the sandbox",
12
15
  tsconfig_missing: "a monorepo tsconfig the sandbox can't resolve (a parent config the package extends)",
13
16
  experimental_builtin: "an experimental Node builtin that needs a runtime flag",
@@ -137,7 +140,7 @@ const ZERO_PROOF_EXPLAINER = {
137
140
  body: [
138
141
  "OrangePro mapped behaviors, flows, and static test links without running your app. Dynamic proof requires executing tests in a sandbox and checking whether a test fails when the target behavior is mutated.",
139
142
  "The statically linked behaviors have test evidence, but they are not verified yet. Treat them as likely covered, not proven.",
140
- "OrangePro tries to dynamically prove the top 5 highest-risk behaviors by default. If setup is missing, the report shows the reason, such as missing dependencies, database setup, environment variables, or unsupported test runner configuration."
143
+ "OrangePro uses a bounded proof pass. For Python, it can examine up to the configured attempt limit to find the configured number of green baselines; environment or collection failures are recorded without being mistaken for failed product tests."
141
144
  ]
142
145
  };
143
146
  function pipeline(graph, ledger, summary) {
@@ -175,21 +178,42 @@ function proofGuidance(ledger, summary, dyn) {
175
178
  // — behaviors, flows, Statically Linked signals — is unaffected; only the dynamic pass is small.
176
179
  if (dyn) {
177
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
+ : "";
178
190
  return {
179
191
  state: "not_started",
180
192
  title: "0 Dynamically Proven — dynamic proof was not attempted",
181
- 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.`,
182
194
  action: HANDOFF_ACTION
183
195
  };
184
196
  }
185
197
  const dom = dominantBlockReason(dyn.needsSetup);
198
+ const environmentClasses = new Set(["environment_unavailable", "collection_error"]);
199
+ const allEnvironmentUnavailable = dyn.attempted > 0
200
+ && dyn.needsSetup.length >= dyn.attempted
201
+ && dyn.needsSetup.every((attempt) => environmentClasses.has(attempt.category ?? ""));
202
+ if (allEnvironmentUnavailable) {
203
+ return {
204
+ state: "attempted",
205
+ title: "Proof not run: test environment could not be started (see ledger)",
206
+ body: "Proof not run: test environment could not be started (see ledger)",
207
+ action: HANDOFF_ACTION
208
+ };
209
+ }
186
210
  const allBlocked = dyn.needsSetup.length > 0 && dyn.needsSetup.length >= dyn.attempted;
187
211
  const plural = dyn.attempted === 1 ? "" : "s";
188
212
  if (allBlocked && dom) {
189
213
  return {
190
214
  state: "attempted",
191
215
  title: `0 Dynamically Proven — top ${dyn.attempted} attempted, all setup-blocked`,
192
- body: `Dynamic proof attempted ${dyn.attempted} target${plural}; all were blocked by ${dom.label} (${dom.count}/${dom.total}). This is a sandbox setup gap, not a static-test failure — the Statically Linked signals are still shown.`,
216
+ body: `Dynamic proof attempted ${dyn.attempted} target${plural}; none reached a green baseline. Most common block: ${dom.label} (${dom.count}/${dom.total} actual attempts). This is a test-environment/setup gap, not a static-test failure — the Statically Linked signals are still shown.`,
193
217
  action: nextStepFor(dom) ?? HANDOFF_ACTION
194
218
  };
195
219
  }
@@ -204,6 +228,15 @@ function proofGuidance(ledger, summary, dyn) {
204
228
  // Standalone report regen (no THIS-RUN data) → derive from the ledger only.
205
229
  const dynamicAttempts = ledger.records.filter((r) => r.dynamic_proof?.proof_kind === "dynamic_targeted");
206
230
  if (dynamicAttempts.length > 0) {
231
+ const unavailableClasses = new Set(["env_unavailable", "collection_error"]);
232
+ if (dynamicAttempts.every((attempt) => unavailableClasses.has(attempt.dynamic_proof?.failure_class ?? ""))) {
233
+ return {
234
+ state: "attempted",
235
+ title: "Proof not run: test environment could not be started (see ledger)",
236
+ body: "Proof not run: test environment could not be started (see ledger)",
237
+ action: HANDOFF_ACTION
238
+ };
239
+ }
207
240
  return {
208
241
  state: "attempted",
209
242
  title: "0 Dynamically Proven — dynamic proof ran, none closed yet",
@@ -242,6 +275,12 @@ function scanBlock(graph, rows) {
242
275
  const unit = tests.filter((n) => n.properties.test_layer === "unit" || n.properties.test_layer === "component").length;
243
276
  const denominator = graph.analysis?.denominator;
244
277
  const runtime = graph.analysis?.runtime_coverage;
278
+ const rankingExclusions = new Map();
279
+ for (const node of graph.nodes) {
280
+ const code = node.kind === "CodeSymbol" && node.denominator_eligible === true ? node.properties.ranking_exclusion_code : undefined;
281
+ if (typeof code === "string" && code)
282
+ rankingExclusions.set(code, (rankingExclusions.get(code) ?? 0) + 1);
283
+ }
245
284
  const excludedCount = (denominator?.excluded_boilerplate ?? 0) +
246
285
  (denominator?.excluded_infra ?? 0) +
247
286
  (denominator?.excluded_generated ?? 0) +
@@ -269,7 +308,8 @@ function scanBlock(graph, rows) {
269
308
  count: excludedCount > 0 ? String(excludedCount) : "0",
270
309
  text: "non-behavior symbols were excluded from the behavior count — generated code, framework internals, test-inferred flows, and infrastructure plumbing."
271
310
  },
272
- excludedCliCommands: excludedCliCommands(graph)
311
+ excludedCliCommands: excludedCliCommands(graph),
312
+ rankingExclusions: [...rankingExclusions.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([code, count]) => ({ code, count }))
273
313
  };
274
314
  }
275
315
  function behaviorLists(rows, flowIds) {
@@ -455,7 +495,7 @@ function displayTitle(title, file) {
455
495
  }
456
496
  /** Deterministic 1–2 line behavior context from graph facts only — no LLM.
457
497
  * Sensitivity label mirrors deriveDataSensitivity's tiers. */
458
- function riskContext(risk) {
498
+ export function riskContext(risk, churnWindowDays, churnMeta, sameSinkAs) {
459
499
  const sens = (risk.data_sensitivity ?? 1) >= 10 ? "payment/billing-sensitive"
460
500
  : (risk.data_sensitivity ?? 1) >= 9 ? "auth/session-sensitive"
461
501
  : (risk.data_sensitivity ?? 1) >= 7 ? "order/transaction"
@@ -469,10 +509,11 @@ function riskContext(risk) {
469
509
  : "deep in the call graph";
470
510
  const sink = risk.sink_callee;
471
511
  const scheduled = risk.scheduled_entry === true;
472
- const churnKnown = risk.churn_available !== false;
473
- const churn = churnKnown
474
- ? (risk.git_churn > 0 ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days` : "unchanged in 180 days")
475
- : "change history unavailable";
512
+ const churn = churnMeta.state === "partial"
513
+ ? `${risk.git_churn} churn line${risk.git_churn === 1 ? "" : "s"} in file over partial change history (${churnMeta.commitsScanned} commits)`
514
+ : churnMeta.state === "complete" && risk.churn_available !== false
515
+ ? `${risk.git_churn} churn line${risk.git_churn === 1 ? "" : "s"} in file over ${churnWindowDays} days`
516
+ : "change history unavailable";
476
517
  const tier = risk.detection_tier ?? "";
477
518
  const evidence = tier === "candidate"
478
519
  ? "no test links here (a similarly-named test exists but never calls it)"
@@ -492,6 +533,7 @@ function riskContext(risk) {
492
533
  const seen = [
493
534
  pos + (sens ? ` on ${sens} paths` : ""),
494
535
  ...(sink ? [`reaches \`${sink}\` within two calls`] : []),
536
+ ...(sink && sameSinkAs ? [`same delete call as \`${sameSinkAs}\` (ranked higher)`] : []),
495
537
  ...(scheduled ? ["scheduled / queue-triggered"] : []),
496
538
  `${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
497
539
  churn,
@@ -902,13 +944,19 @@ function riskTodo(risk, verb, path, generatedTests) {
902
944
  }
903
945
  return `No test signal exists. Start with one integration test that ${call} and asserts the observable outcome.${sens}`;
904
946
  }
905
- function riskRows(risks, graph) {
947
+ function riskRows(risks, graph, churnWindowDays, churnMeta) {
906
948
  const maxRiskScore = risks.reduce((m, r) => Math.max(m, r.risk_score), 0);
907
949
  const riskIds = new Set(risks.map((r) => r.id));
908
950
  const firstRowForFile = new Map();
909
951
  for (const r of risks)
910
952
  if (!firstRowForFile.has(r.file))
911
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 });
912
960
  // Ambiguity-qualified display: identical titles from DIFFERENT files
913
961
  // (multi-program repos: 76 x main) get their top-level dir as a prefix.
914
962
  // Purely display; single-file titles render unchanged everywhere else.
@@ -958,7 +1006,9 @@ function riskRows(risks, graph) {
958
1006
  todo: riskTodo(risk, verb, path, generatedTests)
959
1007
  };
960
1008
  })(),
961
- context: riskContext(risk),
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),
962
1012
  desc: risk.reasons.join(" · "),
963
1013
  tags
964
1014
  };
@@ -1008,11 +1058,22 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
1008
1058
  provenIds,
1009
1059
  includeAssociated: true
1010
1060
  }).filter((r) => r.sink_callee).slice(0, 20)
1011
- .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
+ });
1012
1071
  const riskHealth = inspectRiskInputHealth(repoRoot);
1013
- const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
1014
- const configHash = loadRiskConfig(repoRoot).hash;
1015
1072
  const codeFiles = Object.entries(graph.manifest.files).filter(([, file]) => file.kind === "code").map(([file]) => file);
1073
+ const churnMeta = inspectRiskChurn(repoRoot, codeFiles, riskHealth.churnWindow);
1074
+ const churnAvailable = churnMeta.state === "complete" && riskGaps.every((risk) => risk.churn_available !== false);
1075
+ const configDisclosure = configDisclosureFor(graph, repoRoot);
1076
+ const configHash = configDisclosure.hash;
1016
1077
  const identity = buildArtifactIdentity(graph, {
1017
1078
  configHash,
1018
1079
  history: {
@@ -1028,16 +1089,18 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
1028
1089
  gitRoot: riskHealth.gitRoot ? path.basename(riskHealth.gitRoot) : null,
1029
1090
  commit: riskHealth.commit,
1030
1091
  history: riskHealth.history,
1031
- churn: churnAvailable ? "available" : "unavailable",
1092
+ churn: churnMeta.state === "complete" ? "available" : churnMeta.state,
1093
+ churnState: churnMeta.state,
1094
+ commitsScanned: churnMeta.commitsScanned,
1032
1095
  churnWindow: riskHealth.churnWindow,
1033
1096
  toolVersion: identity.tool_version,
1034
1097
  configHash,
1035
1098
  identity,
1036
1099
  inputFingerprint: identity.run_fingerprint.replace(/^sha256:/, "").slice(0, 16),
1037
- reason: churnAvailable ? undefined : (riskHealth.reason ?? "Git churn scan did not complete")
1100
+ reason: churnMeta.state === "complete" ? undefined : (churnMeta.reason ?? riskHealth.reason ?? "error: churn acquisition returned no reason")
1038
1101
  };
1039
1102
  const lists = behaviorLists(rows, flowIds);
1040
- const risks = riskRows(riskGaps, graph);
1103
+ const risks = riskRows(riskGaps, graph, configDisclosure.tuning.churn_window_days, churnMeta);
1041
1104
  const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
1042
1105
  const flowRows = flows(graph, rows, riskGaps);
1043
1106
  return {
@@ -1057,7 +1120,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
1057
1120
  candidateFlows: candidateFlows(graph),
1058
1121
  risks,
1059
1122
  worklists: { changeFrontier, irreversible },
1060
- configDisclosure: configDisclosureFor(graph, repoRoot),
1123
+ configDisclosure,
1061
1124
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
1062
1125
  mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
1063
1126
  delta: firstRunDelta(),
@@ -345,6 +345,7 @@ body[data-mode="expert"] .simple-only{display:none!important}
345
345
  </header>
346
346
  <div class="provenance" id="provenance"></div>
347
347
  <div class="scope-note" id="cli-exclusions" hidden></div>
348
+ <div class="scope-note" id="ranking-exclusions" hidden></div>
348
349
 
349
350
  <section class="kpis" id="kpis"></section>
350
351
  <p class="metric-scope" id="metric-scope"></p>
@@ -694,6 +695,12 @@ if(excludedCli.length){
694
695
  note.hidden=false;
695
696
  note.textContent="CLI command entries excluded from the behavior count: "+excludedCli.map(x=>x.path+" ("+x.reason+")").join(" · ");
696
697
  }
698
+ const rankExclusions=D.scan.rankingExclusions||[];
699
+ if(rankExclusions.length){
700
+ const note=$("#ranking-exclusions");
701
+ note.hidden=false;
702
+ note.textContent="Excluded from ranking only (still counted as behaviors): "+rankExclusions.map(x=>x.code+" "+x.count).join(" · ");
703
+ }
697
704
  $("#fw-pill").textContent=D.framework;
698
705
 
699
706
  // bridge text in codebase tab
@@ -1050,13 +1057,17 @@ renderRisks();
1050
1057
  if(!C||!host)return;
1051
1058
  const warn=(C.warnings||[]).length>0;
1052
1059
  const K=C.classification||{};
1060
+ const P=C.proof||{};
1061
+ const T=C.tuning||{};
1053
1062
  const clsSet=["test_support_paths","scheduled_entry_paths","destructive_sinks","sensitivity_ignore"].filter(k=>(K[k]||[]).length>0);
1054
- const tuned=C.overridesActive>0||(C.rankExcludePaths||[]).length>0||!C.floor||!C.silence||clsSet.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||(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;
1055
1064
  const bits=["config <b>"+esc(C.hash)+"</b>","overrides active <b>"+C.overridesActive+"</b>","suppressed <b>"+(C.suppressed||[]).length+"</b>"];
1056
1065
  if((C.rankExcludePaths||[]).length)bits.push("ranking excludes <b>"+esc(C.rankExcludePaths.join(", "))+"</b>");
1057
1066
  if(!C.floor)bits.push("<b>irreversibility floor OFF</b>");
1058
1067
  if(!C.silence)bits.push("<b>silence multiplier OFF</b>");
1059
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> · history bound <b>'+esc(String(T.churn_max_commits??20000))+' commits / '+esc(String(T.churn_timeout_seconds??300))+' s</b></span>';
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>';
1060
1071
  clsSet.forEach(k=>{html+='<span class="cfg-row">'+esc(k)+': <b>'+esc(K[k].join(", "))+'</b></span>';});
1061
1072
  if((C.sensitivityIgnored||[]).length)html+='<span class="cfg-row">sensitivity ignored by config (score changed): <b>'+esc(C.sensitivityIgnored.join(", "))+'</b></span>';
1062
1073
  (C.suppressed||[]).forEach(s=>{html+='<span class="cfg-row">suppressed by config: <b>'+esc(s.symbol)+'</b> — '+esc(s.reason)+'</span>';});
@@ -1075,7 +1086,7 @@ renderRisks();
1075
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>\`;
1076
1087
  };
1077
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>';
1078
- 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>';
1079
1090
  host.innerHTML=\`<div class="wl-grid">
1080
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>
1081
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.44",
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",