@orangepro/orangepro-mcp 0.2.3 → 0.2.5

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.
@@ -93,15 +93,6 @@ function gitFirstCommitBatch(root, files) {
93
93
  }
94
94
  return out;
95
95
  }
96
- function countOutgoingCalls(symbolId, graph) {
97
- const targets = new Set();
98
- for (const e of graph.edges) {
99
- if (e.relationship_type === "CALLS" && e.from_external_id === symbolId) {
100
- targets.add(e.to_external_id);
101
- }
102
- }
103
- return targets.size;
104
- }
105
96
  function isEntryPoint(node) {
106
97
  const file = symbolFile(node);
107
98
  const title = symbolTitle(node);
@@ -160,11 +151,8 @@ function deriveDataSensitivity(node) {
160
151
  }
161
152
  return 1;
162
153
  }
163
- function getFlowDepth(node, graph) {
154
+ export function buildFlowDepthContext(graph) {
164
155
  const entryIds = new Set(graph.nodes.filter((n) => n.kind === "CodeSymbol" && isEntryPoint(n)).map((n) => n.external_id));
165
- if (entryIds.has(node.external_id))
166
- return 0;
167
- // BFS backward over CALLS edges to find nearest entry point.
168
156
  const callers = new Map();
169
157
  for (const e of graph.edges) {
170
158
  if (e.relationship_type === "CALLS") {
@@ -173,14 +161,27 @@ function getFlowDepth(node, graph) {
173
161
  callers.set(e.to_external_id, set);
174
162
  }
175
163
  }
164
+ return { entryIds, callers, cache: new Map() };
165
+ }
166
+ function getFlowDepth(node, ctx) {
167
+ const cached = ctx.cache.get(node.external_id);
168
+ if (cached !== undefined)
169
+ return cached;
170
+ const { entryIds, callers } = ctx;
171
+ if (entryIds.has(node.external_id)) {
172
+ ctx.cache.set(node.external_id, 0);
173
+ return 0;
174
+ }
176
175
  let depth = 0;
177
176
  let frontier = new Set(callers.get(node.external_id) ?? []);
178
177
  const seen = new Set(frontier);
179
178
  while (frontier.size > 0 && depth < 6) {
180
179
  depth++;
181
180
  for (const id of frontier) {
182
- if (entryIds.has(id))
181
+ if (entryIds.has(id)) {
182
+ ctx.cache.set(node.external_id, depth);
183
183
  return depth;
184
+ }
184
185
  }
185
186
  const next = new Set();
186
187
  for (const id of frontier) {
@@ -193,7 +194,9 @@ function getFlowDepth(node, graph) {
193
194
  }
194
195
  frontier = next;
195
196
  }
196
- return depth >= 6 ? 6 : depth;
197
+ const out = depth >= 6 ? 6 : depth;
198
+ ctx.cache.set(node.external_id, out);
199
+ return out;
197
200
  }
198
201
  function complexityProxy(node) {
199
202
  const start = typeof node.properties.start_line === "number" ? node.properties.start_line : 0;
@@ -213,23 +216,40 @@ function normalizeScores(values) {
213
216
  const DETECTION_MAP = {
214
217
  proven: 1,
215
218
  associated: 5,
219
+ // A lexical/Jaccard candidate is a lead, not evidence — detection stays hard.
220
+ candidate: 8,
216
221
  none: 10
217
222
  };
218
223
  const NEW_CODE_DAYS = 30;
219
224
  const NEW_CODE_SECONDS = NEW_CODE_DAYS * 24 * 60 * 60;
220
- function computeRawORS(node, graph, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec) {
225
+ function computeRawORS(node, depthCtx, incomingRefs, gitChurn, fanOut, detectionTier, firstCommitTs, nowSec) {
221
226
  const isNew = firstCommitTs > 0 && nowSec - firstCommitTs < NEW_CODE_SECONDS;
222
227
  const complexity = complexityProxy(node);
223
228
  const rawP = gitChurn * 0.35 + fanOut * 0.3 + (isNew ? 15 : 0) + complexity * 0.2;
224
229
  const routeWeight = deriveRouteWeight(node);
225
- const flowDepth = getFlowDepth(node, graph);
230
+ const flowDepth = getFlowDepth(node, depthCtx);
226
231
  const flowPosition = Math.max(0, 5 - flowDepth);
227
232
  const dataSensitivity = deriveDataSensitivity(node);
228
233
  const rawI = incomingRefs * 0.3 + routeWeight * 0.3 + flowPosition * 0.2 + dataSensitivity * 0.2;
229
234
  const d = DETECTION_MAP[detectionTier];
230
235
  return { p: rawP, i: rawI, d };
231
236
  }
232
- function associatedBehaviorIds(graph, candidateIds) {
237
+ function staticTestLinkedIds(graph, candidateIds) {
238
+ const ids = new Set();
239
+ const kinds = new Map(graph.nodes.map((n) => [n.external_id, n.kind]));
240
+ for (const e of graph.edges) {
241
+ if (e.evidence_strength !== "hard")
242
+ continue;
243
+ if (e.relationship_type !== "TESTED_BY" && e.relationship_type !== "COVERS")
244
+ continue;
245
+ if (kinds.get(e.from_external_id) === "TestCase" && candidateIds.has(e.to_external_id))
246
+ ids.add(e.to_external_id);
247
+ if (kinds.get(e.to_external_id) === "TestCase" && candidateIds.has(e.from_external_id))
248
+ ids.add(e.from_external_id);
249
+ }
250
+ return ids;
251
+ }
252
+ function candidateSignalIds(graph, candidateIds) {
233
253
  const ids = new Set();
234
254
  for (const e of graph.candidate_edges ?? []) {
235
255
  if (e.relationship_type !== "MAY_BE_TESTED_BY" && e.relationship_type !== "MAY_COVER" && e.relationship_type !== "MAY_RELATE_TO") {
@@ -264,19 +284,58 @@ export function rankRiskGaps(graph, opts = {}) {
264
284
  const churn = gitChurn(opts.repoRoot ?? graph.workspace.root, files, opts.churnWindow ?? "180 days ago");
265
285
  const firstCommitTs = gitFirstCommitBatch(opts.repoRoot ?? graph.workspace.root, files);
266
286
  const nowSec = Math.floor(Date.now() / 1000);
287
+ // Method-level attribution. CALLS edges are already symbol-granular and count
288
+ // at full weight. IMPORTS edges are file-granular: previously every symbol in
289
+ // an imported file inherited the file's full import count, which made all 17
290
+ // methods of a hot service tie at the same "incoming refs" and saturated the
291
+ // ranking. Split the file's import count across its eligible symbols instead.
267
292
  const incoming = new Map();
293
+ const fileImports = new Map();
268
294
  for (const e of graph.edges) {
269
295
  if (e.relationship_type === "CALLS" && symbolIds.has(e.to_external_id)) {
270
296
  incoming.set(e.to_external_id, (incoming.get(e.to_external_id) ?? 0) + 1);
271
297
  }
272
- else if (e.relationship_type === "IMPORTS") {
273
- for (const s of symbolsByFile.get(e.to_external_id) ?? [])
274
- incoming.set(s.external_id, (incoming.get(s.external_id) ?? 0) + 1);
298
+ else if (e.relationship_type === "IMPORTS" && symbolsByFile.has(e.to_external_id)) {
299
+ fileImports.set(e.to_external_id, (fileImports.get(e.to_external_id) ?? 0) + 1);
275
300
  }
276
301
  }
302
+ for (const [file, count] of fileImports) {
303
+ const syms = symbolsByFile.get(file) ?? [];
304
+ if (syms.length === 0)
305
+ continue;
306
+ const share = count / syms.length;
307
+ for (const s of syms)
308
+ incoming.set(s.external_id, (incoming.get(s.external_id) ?? 0) + share);
309
+ }
310
+ // Per-symbol churn share: file churn weighted by the symbol's line span so one
311
+ // hot file no longer awards its full churn to every method it contains.
312
+ const fileComplexityTotals = new Map();
313
+ for (const [file, syms] of symbolsByFile) {
314
+ fileComplexityTotals.set(file, syms.reduce((acc, s) => acc + Math.max(complexityProxy(s), 1), 0));
315
+ }
316
+ const symbolChurn = (s) => {
317
+ const file = symbolFile(s);
318
+ const fileChurn = churn.get(file) ?? 0;
319
+ if (fileChurn === 0)
320
+ return 0;
321
+ const total = fileComplexityTotals.get(file) ?? 1;
322
+ return fileChurn * (Math.max(complexityProxy(s), 1) / Math.max(total, 1));
323
+ };
277
324
  const entryPoint = new Map(symbols.map((s) => [s.external_id, isEntryPoint(s)]));
278
- const fanOut = new Map(symbols.map((s) => [s.external_id, countOutgoingCalls(s.external_id, graph)]));
279
- const associated = associatedBehaviorIds(graph, symbolIds);
325
+ // Single pass over edges (was one full edge scan PER symbol).
326
+ const fanOutTargets = new Map();
327
+ for (const e of graph.edges) {
328
+ if (e.relationship_type !== "CALLS" || !symbolIds.has(e.from_external_id))
329
+ continue;
330
+ const set = fanOutTargets.get(e.from_external_id) ?? new Set();
331
+ set.add(e.to_external_id);
332
+ fanOutTargets.set(e.from_external_id, set);
333
+ }
334
+ const fanOut = new Map(symbols.map((s) => [s.external_id, fanOutTargets.get(s.external_id)?.size ?? 0]));
335
+ const depthCtx = buildFlowDepthContext(graph);
336
+ const staticLinked = staticTestLinkedIds(graph, symbolIds);
337
+ const candidateLinked = candidateSignalIds(graph, symbolIds);
338
+ const detectionFor = (id) => staticLinked.has(id) ? "associated" : candidateLinked.has(id) ? "candidate" : "none";
280
339
  if (opts.legacy) {
281
340
  return symbols
282
341
  .map((s) => {
@@ -300,42 +359,49 @@ export function rankRiskGaps(graph, opts = {}) {
300
359
  const rawScores = symbols.map((s) => {
301
360
  const file = symbolFile(s);
302
361
  const incoming_refs = incoming.get(s.external_id) ?? 0;
303
- const git_churn = churn.get(file) ?? 0;
362
+ const git_churn = symbolChurn(s);
304
363
  const fan_out = fanOut.get(s.external_id) ?? 0;
305
364
  const ts = firstCommitTs.get(file) ?? 0;
306
- const detectionTier = associated.has(s.external_id) ? "associated" : "none";
307
- return computeRawORS(s, graph, incoming_refs, git_churn, fan_out, detectionTier, ts, nowSec);
365
+ return computeRawORS(s, depthCtx, incoming_refs, git_churn, fan_out, detectionFor(s.external_id), ts, nowSec);
308
366
  });
309
367
  const pScores = normalizeScores(rawScores.map((r) => r.p));
310
368
  const iScores = normalizeScores(rawScores.map((r) => r.i));
311
- return symbols
369
+ const ranked = symbols
312
370
  .map((s, idx) => {
313
371
  const file = symbolFile(s);
314
- const incoming_refs = incoming.get(s.external_id) ?? 0;
315
- const git_churn = churn.get(file) ?? 0;
372
+ const incoming_refs = Math.round((incoming.get(s.external_id) ?? 0) * 10) / 10;
373
+ const git_churn = Math.round(symbolChurn(s));
316
374
  const fan_out = fanOut.get(s.external_id) ?? 0;
317
375
  const isEntry = entryPoint.get(s.external_id) ?? false;
318
376
  const route_weight = deriveRouteWeight(s);
319
377
  const data_sensitivity = deriveDataSensitivity(s);
320
- const flow_position = Math.max(0, 5 - getFlowDepth(s, graph));
378
+ const flow_position = Math.max(0, 5 - getFlowDepth(s, depthCtx));
321
379
  const complexity_proxy = complexityProxy(s);
322
380
  const firstTs = firstCommitTs.get(file) ?? 0;
323
381
  const is_new_code = firstTs > 0 && nowSec - firstTs < NEW_CODE_SECONDS;
324
- const p = Math.round(pScores[idx]);
325
- const i = Math.round(iScores[idx]);
382
+ // Score on the CONTINUOUS normalized values; rounding P and I to integers
383
+ // before multiplying previously collapsed whole hot files into identical
384
+ // P×I×D ties (seventeen ORS-100 rows from one service). Integers remain
385
+ // display-only in the decomposition string.
386
+ const pExact = pScores[idx];
387
+ const iExact = iScores[idx];
388
+ const p = Math.round(pExact);
389
+ const i = Math.round(iExact);
326
390
  const d = rawScores[idx].d;
327
- const detectionTier = associated.has(s.external_id) ? "associated" : "none";
328
- const score = p * i * d;
391
+ const detectionTier = detectionFor(s.external_id);
392
+ const score = Math.round(pExact * iExact * d * 10) / 10;
329
393
  const reasons = [
330
- `ORS ${score} = P${p} × I${i} × D${d}`,
331
- `${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"}`,
332
- `${git_churn} git churn line${git_churn === 1 ? "" : "s"} in 180 days`,
394
+ `ORS ${score} P${p} × I${i} × D${d}`,
395
+ `${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"} (method-attributed)`,
396
+ `${git_churn} git churn line${git_churn === 1 ? "" : "s"} attributed to this symbol in 180 days`,
333
397
  `route weight ${route_weight}, data sensitivity ${data_sensitivity}, fan-out ${fan_out}`
334
398
  ];
335
399
  if (isEntry)
336
400
  reasons.push("near an API/route/handler entry point");
337
401
  if (is_new_code)
338
402
  reasons.push("new code (< 30 days)");
403
+ if (detectionTier === "candidate")
404
+ reasons.push("lexical candidate test match only — unconfirmed");
339
405
  return {
340
406
  id: s.external_id,
341
407
  title: s.title || s.external_id,
@@ -357,6 +423,31 @@ export function rankRiskGaps(graph, opts = {}) {
357
423
  integration_signal: detectionTier
358
424
  };
359
425
  })
426
+ .sort((a, b) => b.risk_score - a.risk_score || b.incoming_refs - a.incoming_refs || b.git_churn - a.git_churn || a.id.localeCompare(b.id));
427
+ // Portfolio diversity: cap how many gaps a single file contributes to the
428
+ // surfaced list, then backfill with the remaining highest scores if short.
429
+ const maxPerFile = Math.max(1, opts.maxPerFile ?? 3);
430
+ const perFile = new Map();
431
+ const surfaced = [];
432
+ const overflow = [];
433
+ for (const gap of ranked) {
434
+ const used = perFile.get(gap.file) ?? 0;
435
+ if (used < maxPerFile) {
436
+ perFile.set(gap.file, used + 1);
437
+ surfaced.push(gap);
438
+ }
439
+ else {
440
+ overflow.push(gap);
441
+ }
442
+ if (surfaced.length >= limit)
443
+ break;
444
+ }
445
+ if (surfaced.length < limit)
446
+ surfaced.push(...overflow.slice(0, limit - surfaced.length));
447
+ // Guarantee: the surfaced list is ALWAYS highest-risk-first, even when the
448
+ // per-file diversity backfill re-admits overflow items (which otherwise land
449
+ // appended after lower-scored rows).
450
+ return surfaced
360
451
  .sort((a, b) => b.risk_score - a.risk_score || b.incoming_refs - a.incoming_refs || b.git_churn - a.git_churn || a.id.localeCompare(b.id))
361
452
  .slice(0, limit);
362
453
  }
@@ -66,20 +66,21 @@ export function loadIgnore(root) {
66
66
  const line = raw.trim();
67
67
  if (!line || line.startsWith("#") || line.startsWith("!"))
68
68
  continue;
69
+ const rootAnchored = line.startsWith("/");
69
70
  const cleaned = line.replace(/^\/+/, "").replace(/\/+$/, "");
70
71
  if (!cleaned)
71
72
  continue;
72
- if (!cleaned.includes("/") && !cleaned.includes("*")) {
73
+ if (!rootAnchored && !cleaned.includes("/") && !cleaned.includes("*")) {
73
74
  names.add(cleaned);
74
75
  }
75
76
  else {
76
- matchers.push(globToRegExp(cleaned));
77
+ matchers.push(globToRegExp(cleaned, rootAnchored));
77
78
  }
78
79
  }
79
80
  }
80
81
  return { names, matchers };
81
82
  }
82
- function globToRegExp(glob) {
83
+ function globToRegExp(glob, rootAnchored = false) {
83
84
  // Use a plain-ASCII sentinel for `**` so the file stays text (no NUL bytes)
84
85
  // and `*` substitution does not re-match the globstar.
85
86
  const GLOBSTAR = "__ORANGEPRO_GLOBSTAR__";
@@ -90,7 +91,7 @@ function globToRegExp(glob) {
90
91
  .split(GLOBSTAR)
91
92
  .join(".*")
92
93
  .replace(/\?/g, "[^/]");
93
- return new RegExp(`(^|/)${escaped}(/|$)`);
94
+ return new RegExp(`${rootAnchored ? "^" : "(^|/)"}${escaped}(/|$)`);
94
95
  }
95
96
  function isIgnored(relPath, baseName, rules) {
96
97
  if (rules.names.has(baseName))