@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/README.md +10 -3
- package/dist/local/analyze/behaviorContracts.js +78 -5
- package/dist/local/analyze/parseCache.js +16 -2
- package/dist/local/cli.js +7 -3
- package/dist/local/enrich/markdown.js +11 -0
- package/dist/local/flows/flowWalker.js +11 -6
- package/dist/local/generate/generator.js +54 -3
- package/dist/local/generate/promptV5.js +12 -1
- package/dist/local/operations.js +25 -5
- package/dist/local/proofDoctor.js +28 -3
- package/dist/local/rtm.js +30 -26
- package/dist/local/score/risk.js +128 -37
- package/dist/local/viz/behaviorReportData.js +393 -23
- package/dist/local/viz/behaviorReportHtml.js +225 -10
- package/dist/local/viz/payload.js +12 -5
- package/package.json +7 -2
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
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
|
-
|
|
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
|
|
256
|
-
const
|
|
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
|
-
|
|
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(
|
|
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(
|
|
294
|
-
add(
|
|
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
|
-
|
|
304
|
-
|
|
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.
|
|
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")
|
package/dist/local/score/risk.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
325
|
-
|
|
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 =
|
|
328
|
-
const score =
|
|
391
|
+
const detectionTier = detectionFor(s.external_id);
|
|
392
|
+
const score = Math.round(pExact * iExact * d * 10) / 10;
|
|
329
393
|
const reasons = [
|
|
330
|
-
`ORS ${score}
|
|
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
|
}
|