@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.
- package/README.md +39 -12
- package/dist/local/analyze/analyzer.js +61 -2
- package/dist/local/analyze/behaviorContracts.js +78 -5
- package/dist/local/analyze/parseCache.js +17 -3
- package/dist/local/analyze/treeSitter/engine.js +81 -4
- package/dist/local/autoProve.js +39 -21
- package/dist/local/cli.js +13 -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/graph/ontology.js +3 -1
- package/dist/local/operations.js +61 -10
- 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/util/walk.js +5 -4
- package/dist/local/viz/behaviorReportData.js +393 -23
- package/dist/local/viz/behaviorReportHtml.js +225 -10
- package/dist/local/viz/coverageReveal.js +29 -0
- package/dist/local/viz/payload.js +12 -5
- package/dist/local/workspace.js +25 -2
- package/package.json +7 -2
- package/scripts/spikes/go-dynamic-proof-spike.mjs +17 -13
- package/scripts/spikes/go-mutate.go +60 -21
- package/scripts/spikes/java-dynamic-proof-spike.mjs +6 -4
- package/scripts/spikes/java-mutate.mjs +3 -2
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
|
}
|
package/dist/local/util/walk.js
CHANGED
|
@@ -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(
|
|
94
|
+
return new RegExp(`${rootAnchored ? "^" : "(^|/)"}${escaped}(/|$)`);
|
|
94
95
|
}
|
|
95
96
|
function isIgnored(relPath, baseName, rules) {
|
|
96
97
|
if (rules.names.has(baseName))
|