@orangepro/orangepro-mcp 0.2.4 → 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.
package/dist/local/rtm.js CHANGED
@@ -3,11 +3,12 @@ import { languageOf } from "./analyze/classify.js";
3
3
  import { targetFingerprint } from "./ledger.js";
4
4
  const STATUS_ORDER = {
5
5
  "No integration signal": 0,
6
- "Associated signal": 1,
7
- "Generated-unverifiable": 2,
8
- "Runtime-covered": 3,
9
- "Proven": 4,
10
- "Reproven (this run)": 5
6
+ "Candidate signal (unconfirmed)": 1,
7
+ "Associated signal": 2,
8
+ "Generated-unverifiable": 3,
9
+ "Runtime-covered": 4,
10
+ "Proven": 5,
11
+ "Reproven (this run)": 6
11
12
  };
12
13
  const GENERIC_TEST_CATEGORIES = [
13
14
  "happy-path",
@@ -72,6 +73,7 @@ export function renderRtmMarkdown(result) {
72
73
  `| Dynamically Proven | ${provenValue} |`,
73
74
  `| Runtime-covered | ${s.runtime_covered} |`,
74
75
  `| Associated signal | ${s.associated} |`,
76
+ `| Candidate signal (unconfirmed) | ${s.candidate} |`,
75
77
  `| No integration signal | ${s.no_link} |`,
76
78
  `| Reproven this run | ${s.reproven_this_run} |`,
77
79
  `| Generated unverifiable | ${s.generated_unverifiable} |`,
@@ -164,8 +166,13 @@ function evidenceTierFor(indexes, node, file, dynamicProof) {
164
166
  return "proven";
165
167
  if (node.kind === "CodeSymbol" && node.properties.runtime_covered === true)
166
168
  return "runtime";
167
- if (hasAssociatedSignal(indexes, node.external_id, file))
169
+ // Epistemic tiers (mirrors the platform's Proof/Candidate/Weak model):
170
+ // associated = a hard static test link (COVERS/TESTED_BY via real import) exists;
171
+ // candidate = only a lexical/Jaccard candidate edge exists — a lead, not evidence.
172
+ if (indexes.staticSignalsById.has(node.external_id))
168
173
  return "associated";
174
+ if (indexes.candidateSignalsById.has(node.external_id) || (file !== "" && indexes.candidateSignalsByFile.has(file)))
175
+ return "candidate";
169
176
  return "none";
170
177
  }
171
178
  function statusFor(evidence, ledgerRecord, dynamicProof) {
@@ -177,6 +184,8 @@ function statusFor(evidence, ledgerRecord, dynamicProof) {
177
184
  return "Generated-unverifiable";
178
185
  if (evidence === "associated")
179
186
  return "Associated signal";
187
+ if (evidence === "candidate")
188
+ return "Candidate signal (unconfirmed)";
180
189
  return "No integration signal";
181
190
  }
182
191
  /**
@@ -196,6 +205,7 @@ function summarizeRows(rows, unionRows = []) {
196
205
  proven,
197
206
  runtime_covered: rows.filter((row) => row.evidence_tier === "runtime").length,
198
207
  associated: rows.filter((row) => row.evidence_tier === "associated").length,
208
+ candidate: rows.filter((row) => row.evidence_tier === "candidate").length,
199
209
  no_link: rows.filter((row) => row.evidence_tier === "none").length,
200
210
  reproven_this_run: reproven,
201
211
  generated_unverifiable: rows.filter((row) => row.status === "Generated-unverifiable").length,
@@ -252,8 +262,8 @@ function compareLedgerRecords(a, aIndex, b, bIndex) {
252
262
  function buildRtmIndexes(graph) {
253
263
  const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
254
264
  const staticSignalsById = new Map();
255
- const associatedSignalsById = new Map();
256
- const associatedSignalsByFile = new Map();
265
+ const candidateSignalsById = new Map();
266
+ const candidateSignalsByFile = new Map();
257
267
  const add = (map, key, value) => {
258
268
  const set = map.get(key);
259
269
  if (set)
@@ -261,21 +271,16 @@ function buildRtmIndexes(graph) {
261
271
  else
262
272
  map.set(key, new Set([value]));
263
273
  };
264
- const importTargetsByFile = new Map();
274
+ // Candidate (lexical/Jaccard) signals attach ONLY to the matched file itself.
275
+ // They must never propagate through imports: one lexical match on a barrel file
276
+ // previously marked its whole import subtree as "associated" — that inflated
277
+ // 93% of Twenty behaviors into the test-signal tier with zero real evidence.
265
278
  const addFileAssociation = (file, value) => {
266
- add(associatedSignalsByFile, file, value);
267
- const imports = importTargetsByFile.get(file) ?? [];
268
- if (imports.length > ASSOCIATED_IMPORT_PROPAGATION_LIMIT)
269
- return;
270
- for (const importedFile of imports)
271
- add(associatedSignalsByFile, importedFile, value);
279
+ add(candidateSignalsByFile, file, value);
272
280
  };
273
281
  for (const e of graph.edges) {
274
282
  if (e.evidence_strength !== "hard")
275
283
  continue;
276
- if (e.relationship_type === "IMPORTS" && isFileSignalKey(e.from_external_id) && isFileSignalKey(e.to_external_id)) {
277
- importTargetsByFile.set(e.from_external_id, [...(importTargetsByFile.get(e.from_external_id) ?? []), e.to_external_id]);
278
- }
279
284
  if (e.relationship_type !== "COVERS" && e.relationship_type !== "TESTED_BY")
280
285
  continue;
281
286
  const from = nodeById.get(e.from_external_id);
@@ -290,8 +295,8 @@ function buildRtmIndexes(graph) {
290
295
  continue;
291
296
  if (e.relationship_type !== "MAY_RELATE_TO" && e.relationship_type !== "MAY_BE_TESTED_BY" && e.relationship_type !== "MAY_COVER")
292
297
  continue;
293
- add(associatedSignalsById, e.from_external_id, e.to_external_id);
294
- add(associatedSignalsById, e.to_external_id, e.from_external_id);
298
+ add(candidateSignalsById, e.from_external_id, e.to_external_id);
299
+ add(candidateSignalsById, e.to_external_id, e.from_external_id);
295
300
  for (const [key, value] of [[e.from_external_id, e.to_external_id], [e.to_external_id, e.from_external_id]]) {
296
301
  if (isFileSignalKey(key))
297
302
  addFileAssociation(key, value);
@@ -300,21 +305,18 @@ function buildRtmIndexes(graph) {
300
305
  const freeze = (map) => new Map([...map.entries()].map(([key, values]) => [key, [...values].sort()]));
301
306
  return {
302
307
  staticSignalsById: freeze(staticSignalsById),
303
- associatedSignalsById: freeze(associatedSignalsById),
304
- associatedSignalsByFile: freeze(associatedSignalsByFile)
308
+ candidateSignalsById: freeze(candidateSignalsById),
309
+ candidateSignalsByFile: freeze(candidateSignalsByFile)
305
310
  };
306
311
  }
307
312
  function isFileSignalKey(id) {
308
313
  return !id.startsWith("sym:") && !id.startsWith("test:") && !id.startsWith("flow:");
309
314
  }
310
- function hasAssociatedSignal(indexes, externalId, file) {
311
- return indexes.staticSignalsById.has(externalId) || indexes.associatedSignalsById.has(externalId) || (file !== "" && indexes.associatedSignalsByFile.has(file));
312
- }
313
315
  function testSignalFor(indexes, externalId, file) {
314
316
  const staticSignals = indexes.staticSignalsById.get(externalId) ?? [];
315
317
  if (staticSignals.length > 0)
316
318
  return `static candidate: ${staticSignals.slice(0, 3).join("; ")}`;
317
- const associated = [...(indexes.associatedSignalsById.get(externalId) ?? []), ...(file ? indexes.associatedSignalsByFile.get(file) ?? [] : [])]
319
+ const associated = [...(indexes.candidateSignalsById.get(externalId) ?? []), ...(file ? indexes.candidateSignalsByFile.get(file) ?? [] : [])]
318
320
  .filter((id) => id !== externalId && id !== file)
319
321
  .sort();
320
322
  if (associated.length > 0)
@@ -366,6 +368,8 @@ function normalizeStatusFilter(statuses) {
366
368
  out.add("Runtime-covered");
367
369
  else if (s === "associated")
368
370
  out.add("Associated signal");
371
+ else if (s === "candidate" || s === "candidate-signal")
372
+ out.add("Candidate signal (unconfirmed)");
369
373
  else if (s === "no-link" || s === "nolink" || s === "none")
370
374
  out.add("No integration signal");
371
375
  else if (s === "reproven" || s === "reproven-this-run")
@@ -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
  }