@orangepro/orangepro-mcp 0.2.35 → 0.2.36

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.
@@ -475,6 +475,18 @@ export function analyzeRepo(root, opts = {}) {
475
475
  // Emitted CodeSymbol names per file — the call graph resolves callers/callees
476
476
  // ONLY to symbols that actually became nodes (the "known symbol" invariant).
477
477
  const symbolsByFile = new Map();
478
+ // Retained callee NAMES (no node, no edge) for calls the resolver cannot anchor —
479
+ // shared by the TS/JS loop and the tree-sitter loop; persisted as external_callees.
480
+ const externalCalleesByCaller = new Map();
481
+ const recordExternalCallee = (callerId, name) => {
482
+ let set = externalCalleesByCaller.get(callerId);
483
+ if (!set) {
484
+ set = new Set();
485
+ externalCalleesByCaller.set(callerId, set);
486
+ }
487
+ if (set.size < 32)
488
+ set.add(name);
489
+ };
478
490
  // Raw (caller, callee) call pairs per TS/JS code file, resolved after the
479
491
  // import graph is built (cross-file calls need its bindings + targets).
480
492
  const rawCallsByFile = new Map();
@@ -1312,6 +1324,8 @@ export function analyzeRepo(root, opts = {}) {
1312
1324
  }
1313
1325
  }
1314
1326
  // A non-imported (local/param) qualifier is NOT anchored — no edge.
1327
+ if (!ns && !qImport)
1328
+ recordExternalCallee(callerId, `${c.qualifier}.${c.callee}`); // round two: retain by name
1315
1329
  // Retain the callee NAME as a fact on the caller (Fix B): `t.adminClient.
1316
1330
  // DeleteWorkflowExecution` is invisible as an edge (external interface) but
1317
1331
  // is exactly the kind of sink risk scoring must be able to see. Names only —
@@ -1319,6 +1333,10 @@ export function analyzeRepo(root, opts = {}) {
1319
1333
  }
1320
1334
  }
1321
1335
  else if (c.via === "injected" && c.injectedType) {
1336
+ // Retain `this.<field>.<callee>` by name regardless of resolution: names are
1337
+ // facts, not evidence, and consequence signals need the callee even when the
1338
+ // injected type never resolves (round two — TS parity with Go).
1339
+ recordExternalCallee(callerId, `this.${c.qualifier ?? c.injectedType}.${c.callee}`);
1322
1340
  const typeBinding = typeImports?.get(c.injectedType) ?? imports?.get(c.injectedType);
1323
1341
  if (typeBinding) {
1324
1342
  const targetMember = resolveInjectedMember(typeBinding, c.callee);
@@ -1859,16 +1877,6 @@ export function analyzeRepo(root, opts = {}) {
1859
1877
  const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
1860
1878
  return goPkgMethods.get(dir)?.get(member);
1861
1879
  };
1862
- const externalCalleesByCaller = new Map();
1863
- const recordExternalCallee = (callerId, name) => {
1864
- let set = externalCalleesByCaller.get(callerId);
1865
- if (!set) {
1866
- set = new Set();
1867
- externalCalleesByCaller.set(callerId, set);
1868
- }
1869
- if (set.size < 32)
1870
- set.add(name);
1871
- };
1872
1880
  for (const [rel, { language, structure }] of nonTsStructureByFile) {
1873
1881
  const localSyms = symbolsByFile.get(rel);
1874
1882
  if (!localSyms)
@@ -315,8 +315,15 @@ const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
315
315
  /** Calls that make a defect irreversible: deletes, drops, purges. Matched on the
316
316
  * LAST segment of a call name, so `t.adminClient.DeleteWorkflowExecution` and a
317
317
  * local `purgeAll` both count; `deleteButtonLabel` (no call) does not. */
318
- const DESTRUCTIVE_CALL_RE = /^(delete|drop|purge|truncate|remove|forcedelete|destroy)[A-Za-z0-9_]*$/i;
319
- const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(Run|Execute|Handle|Process|Tick|Scan)$/;
318
+ // `truncate` dropped (time.Truncate is common; DB truncation is rare in app code);
319
+ // `remove` kept but not for listener/handler/attribute/child tails — those detach,
320
+ // they don't destroy data.
321
+ const DESTRUCTIVE_CALL_RE = /^(?:delete(?!d)|purge|drop|forcedelete|destroy)[A-Za-z0-9_]*$/i;
322
+ // `remove*` is only a data sink through a persistence-shaped field; in-memory
323
+ // removals (`pollers.Remove`, `RemoveSpeculativeWorkflowTaskTimeout`) are not.
324
+ 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;
325
+ const PERSISTENCE_FIELD_RE = /(store|client|db|repo|repository|persistence|manager|queue|bucket|index|table|storage|dao)/i;
326
+ const SCHEDULED_ENTRY_NAME_RE = /(^|\.)(run|execute|handle|process|tick|scan)$/i;
320
327
  const SCHEDULED_ENTRY_PATH_RE = /(^|\/)(jobs?|workers?|scanners?|scavengers?|cron|schedulers?|processors?|consumers?|reconcil\w*)(\/|$)/i;
321
328
  /** A time- or queue-triggered entry: nothing calls it synchronously, nobody waits
322
329
  * for a response, so a failure is far less likely to be NOTICED. Graph facts only. */
@@ -378,7 +385,9 @@ export function rankRiskGaps(graph, opts = {}) {
378
385
  // Fix D: ranking hygiene. Test-support code, bare constants/variables, and trivial
379
386
  // accessors stay in the behavior DENOMINATOR but never compete for a risk slot.
380
387
  const TEST_SUPPORT_PATH_RE = /(^|\/)(testing|testutils?|testhelpers?|fixtures?|mocks?|fakes?)(\/|$)/i;
381
- const extraTestSupportRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification.test_support_paths.map(globToRegExp);
388
+ const cfgEarly = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.classification;
389
+ const extraTestSupportRef = cfgEarly.test_support_paths.map(globToRegExp);
390
+ const rankExcludeRef = cfgEarly.rank_exclude_paths.map(globToRegExp);
382
391
  const ACCESSOR_RE = /(^|\.)(Get|Set|Is|Has)[A-Z][A-Za-z0-9]*$/;
383
392
  const rankEligible = (n) => {
384
393
  const props = (n.properties ?? {});
@@ -395,6 +404,8 @@ export function rankRiskGaps(graph, opts = {}) {
395
404
  const span = (props.end_line ?? 0) - (props.start_line ?? 0);
396
405
  if (ACCESSOR_RE.test(n.title || "") && span <= 3)
397
406
  return false;
407
+ if (rankExcludeRef.some((re) => re.test(symbolFile(n))))
408
+ return false;
398
409
  return true;
399
410
  };
400
411
  const suppressedRef = loadRiskConfig(opts.repoRoot ?? graph.workspace.root).config.overrides.filter((o) => o.action === "suppress");
@@ -483,29 +494,42 @@ export function rankRiskGaps(graph, opts = {}) {
483
494
  // A sink is a destructive call on an EXTERNAL surface — a persistence store, an
484
495
  // admin/service client, a database handle. In-repo methods named `delete` are
485
496
  // not sinks by name alone (CHASM's `Node.delete` is a tree op, not a data loss).
486
- const SINK_QUALIFIER_RE = /(client|store|manager|persistence|db|admin|repo|repository|dao|storage|bucket|index)/i;
487
- const isSink = (id) => {
497
+ // A retained callee is by construction a call through a receiver FIELD to code
498
+ // the graph could not resolve — i.e. an external surface. That structural fact is
499
+ // the test; the qualifier's NAME is not (round one's store/client/db vocabulary was
500
+ // Temporal-shaped and hid inngest's `w.q.DeleteOldQueueSnapshots`).
501
+ const sinkCallee = (id) => {
488
502
  const n = nodeById.get(id);
489
503
  if (!n)
490
- return false;
504
+ return undefined;
491
505
  const ext = n.properties?.external_callees ?? [];
492
- return ext.some((c) => (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) && SINK_QUALIFIER_RE.test(c.slice(0, c.lastIndexOf(".")))) || extraSinks.some((re) => re.test(lastSeg(c))));
506
+ // A receiver FIELD path is `x.field.Method` no call parentheses before the last
507
+ // segment. `q.Clock().Now().Truncate` is a chain of return values, not a surface.
508
+ const viaField = (c) => !c.slice(0, c.lastIndexOf(".")).includes("(");
509
+ const fieldOf = (c) => c.slice(0, c.lastIndexOf("."));
510
+ return ext.find((c) => viaField(c) && (DESTRUCTIVE_CALL_RE.test(lastSeg(c)) ||
511
+ (REMOVE_CALL_RE.test(lastSeg(c)) && PERSISTENCE_FIELD_RE.test(fieldOf(c))) ||
512
+ extraSinks.some((re) => re.test(lastSeg(c)))));
493
513
  };
494
- const reachesSinkFrom = (id, depth, seen) => {
495
- if (isSink(id))
496
- return true;
514
+ const isSink = (id) => sinkCallee(id) !== undefined;
515
+ const reachedSinkFrom = (id, depth, seen) => {
516
+ const own = sinkCallee(id);
517
+ if (own)
518
+ return own;
497
519
  if (depth === 0)
498
- return false;
520
+ return undefined;
499
521
  for (const t of fanOutTargets.get(id) ?? []) {
500
522
  if (seen.has(t))
501
523
  continue;
502
524
  seen.add(t);
503
- if (reachesSinkFrom(t, depth - 1, seen))
504
- return true;
525
+ const hit = reachedSinkFrom(t, depth - 1, seen);
526
+ if (hit)
527
+ return hit;
505
528
  }
506
- return false;
529
+ return undefined;
507
530
  };
508
- const reachesSink = new Map(symbols.map((s) => [s.external_id, reachesSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
531
+ const sinkReached = new Map(symbols.map((s) => [s.external_id, reachedSinkFrom(s.external_id, 2, new Set([s.external_id]))]));
532
+ const reachesSink = new Map([...sinkReached].map(([k, v]) => [k, v !== undefined]));
509
533
  const depthCtx = buildFlowDepthContext(graph);
510
534
  const staticLinked = staticTestLinkedIds(graph, symbolIds);
511
535
  const candidateLinked = candidateSignalIds(graph, symbolIds);
@@ -605,6 +629,9 @@ export function rankRiskGaps(graph, opts = {}) {
605
629
  reasons.push(`config override (${override.action}): ${override.reason}`);
606
630
  return {
607
631
  ...(override && override.action !== "suppress" ? { override: { action: override.action, reason: override.reason } } : {}),
632
+ ...(sinkReached.get(s.external_id) ? { sink_callee: sinkReached.get(s.external_id) } : {}),
633
+ ...(rawScores[idx].scheduledEntry ? { scheduled_entry: true } : {}),
634
+ detection_tier: detectionTier,
608
635
  id: s.external_id,
609
636
  title: s.title || s.external_id,
610
637
  file,
@@ -9,7 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  export const DEFAULT_RISK_CONFIG = {
12
- classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [] },
12
+ classification: { test_support_paths: [], scheduled_entry_paths: [], destructive_sinks: [], sensitivity_ignore: [], rank_exclude_paths: [] },
13
13
  tuning: { irreversibility_floor: true, silence_multiplier: true },
14
14
  overrides: []
15
15
  };
@@ -33,6 +33,7 @@ export function loadRiskConfig(repoRoot) {
33
33
  cfg.classification.scheduled_entry_paths = asStringArray(cls.scheduled_entry_paths);
34
34
  cfg.classification.destructive_sinks = asStringArray(cls.destructive_sinks);
35
35
  cfg.classification.sensitivity_ignore = asStringArray(cls.sensitivity_ignore);
36
+ cfg.classification.rank_exclude_paths = asStringArray(cls.rank_exclude_paths);
36
37
  const tun = (raw.tuning ?? {});
37
38
  if (typeof tun.irreversibility_floor === "boolean")
38
39
  cfg.tuning.irreversibility_floor = tun.irreversibility_floor;
@@ -2,7 +2,7 @@ import { loadRiskConfig } from "../score/riskConfig.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { buildRtm } from "../rtm.js";
5
- import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps } from "../score/risk.js";
5
+ import { inspectRiskInputHealth, isEntryPoint, rankPriorityGaps, rankRiskGaps } from "../score/risk.js";
6
6
  import { ORANGEPRO_VERSION } from "../version.js";
7
7
  import { PROOF_BLOCKER_GUIDE } from "../proofDoctor.js";
8
8
  import { classifyGeneratedDraftBlocker } from "../generate/draftGuidance.js";
@@ -451,21 +451,49 @@ function riskContext(risk) {
451
451
  : (risk.data_sensitivity ?? 1) >= 3 ? "notification/webhook"
452
452
  : "";
453
453
  const pos = (risk.flow_position ?? 0) >= 5
454
- ? "an entry point"
454
+ ? "entry point"
455
455
  : (risk.flow_position ?? 0) >= 3
456
- ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from the nearest entry point`
456
+ ? `${5 - (risk.flow_position ?? 0)} call${5 - (risk.flow_position ?? 0) === 1 ? "" : "s"} from an entry point`
457
457
  : "deep in the call graph";
458
- const churn = risk.churn_available !== false
459
- ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days`
460
- : "Git churn unavailable (provisional static-only ranking)";
461
- // Consequence signals and config overrides are stated on the row, so a reader can
462
- // disagree with a weight without doubting the fact and a tuned report is visible.
463
- const flagged = (risk.reasons ?? []).filter((r) => r.startsWith("reaches a destructive") || r.startsWith("scheduled/queue-triggered") || r.startsWith("config override"));
464
- const parts = [
465
- `Sits at ${pos}${sens ? ` on ${sens} paths` : ""}.`,
466
- ...flagged.map((r) => `${r[0].toUpperCase()}${r.slice(1)}.`),
467
- `ORS ${risk.risk_score} (P${risk.probability ?? "?"} × I${risk.impact ?? "?"} × D${risk.detection_difficulty ?? "?"}) reflects flow position, change activity, complexity, impact, and test evidence; ${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}, ${churn} — and no test proves this flow.`
458
+ const sink = risk.sink_callee;
459
+ const scheduled = risk.scheduled_entry === true;
460
+ const churnKnown = risk.churn_available !== false;
461
+ const churn = churnKnown
462
+ ? (risk.git_churn > 0 ? `${risk.git_churn} line${risk.git_churn === 1 ? "" : "s"} changed in 180 days` : "unchanged in 180 days")
463
+ : "change history unavailable";
464
+ const tier = risk.detection_tier ?? "";
465
+ const evidence = tier === "candidate"
466
+ ? "no test links here (a similarly-named test exists but never calls it)"
467
+ : tier === "associated" ? "a test calls it but nothing proves it fails when broken" : "no test links here";
468
+ const sinkShort = sink ? sink.split(".").pop() ?? sink : "";
469
+ // Line 1 — the consequence, in plain English, from the signals only.
470
+ const lead = sink && scheduled
471
+ ? `Runs on a schedule and can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data — nothing proves it does the right thing.`
472
+ : sink
473
+ ? `Can ${sinkShort.toLowerCase().startsWith("purge") ? "purge" : "delete"} data and nothing proves it works.`
474
+ : scheduled
475
+ ? "Runs on a schedule with no proof — a failure here surfaces nowhere."
476
+ : sens
477
+ ? `Sits on ${sens} paths, changes, and nothing proves it.`
478
+ : "Reachable and changing, with nothing proving it.";
479
+ // Line 2 — what the graph saw. Facts only: names, counts, tiers.
480
+ const seen = [
481
+ pos + (sens ? ` on ${sens} paths` : ""),
482
+ ...(sink ? [`reaches \`${sink}\` within two calls`] : []),
483
+ ...(scheduled ? ["scheduled / queue-triggered"] : []),
484
+ `${risk.fan_out ?? 0} downstream call${(risk.fan_out ?? 0) === 1 ? "" : "s"}`,
485
+ churn,
486
+ evidence
468
487
  ];
488
+ // Line 3 — what would close it: a test SHAPE, never a claim that one exists.
489
+ const close = sink
490
+ ? `one test that drives the path to \`${sinkShort}\` and fails when the guard before it is broken.`
491
+ : scheduled
492
+ ? "one test that runs this entry against a mutated dependency and fails."
493
+ : "one test that exercises this behavior and fails when it is mutated.";
494
+ const scoreWords = `ORS ${risk.risk_score} — ${(risk.probability ?? 0) >= 6 ? "changes often" : (risk.probability ?? 0) >= 3 ? "changes some" : "stable"} (${risk.probability ?? "?"}) × ${sink ? "irreversible" : (risk.impact ?? 0) >= 6 ? "high blast radius" : "moderate impact"} (${risk.impact ?? "?"}) × ${(risk.detection_difficulty ?? 0) >= 9 ? "unproven and silent" : "unproven"} (${risk.detection_difficulty ?? "?"})`;
495
+ const overrides = (risk.reasons ?? []).filter((r) => r.startsWith("config override"));
496
+ const parts = [lead, `Seen: ${seen.join(" · ")}.`, `Would close it: ${close}`, ...overrides.map((o) => `${o[0].toUpperCase()}${o.slice(1)}.`), scoreWords + "."];
469
497
  return parts.join(" ");
470
498
  }
471
499
  /** Pure delta between a persisted baseline and the current report data.
@@ -898,6 +926,18 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
898
926
  .filter((row) => row.evidence_tier === "proven" && Boolean(row.code_symbol))
899
927
  .map((row) => row.code_symbol));
900
928
  const riskGaps = rankPriorityGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, provenIds });
929
+ // Two worklists from ONE ranking, no new weights. A multiplicative P×I×D top-N
930
+ // cannot hold "changing fast, unproven" and "irreversible, stable, unproven" on
931
+ // the same page: with P=1 for stable code, one family always erases the other
932
+ // (verified three ways on Temporal). Same rows, two questions.
933
+ const wide = rankRiskGaps(graph, { repoRoot, limit: 200, provenIds });
934
+ const changeFrontier = [...wide]
935
+ .filter((r) => !r.sink_callee)
936
+ .sort((a, b) => (b.probability ?? 0) - (a.probability ?? 0) || b.risk_score - a.risk_score || a.id.localeCompare(b.id))
937
+ .slice(0, 20)
938
+ .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
+ .map((r) => ({ path: r.title, file: r.file, score: r.risk_score, sink: r.sink_callee ?? "" }));
901
941
  const riskHealth = inspectRiskInputHealth(repoRoot);
902
942
  const churnAvailable = riskHealth.churnAvailable && riskGaps.every((risk) => risk.churn_available !== false);
903
943
  const provenance = {
@@ -935,6 +975,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
935
975
  flows: flowRows,
936
976
  candidateFlows: candidateFlows(graph),
937
977
  risks,
978
+ worklists: { changeFrontier, irreversible },
938
979
  zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null,
939
980
  mapModel: buildSystemMapModel({ flows: flowRows, risks, behaviors: sortedBehaviors }),
940
981
  viewMeta: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
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",