@esankhan3/code-search-mcp 1.1.0 → 1.1.2
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/__tests__/graph-traversal.test.js +262 -0
- package/dist/tools/graph.js +114 -51
- package/dist/tools/index-tools.js +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createSystemGraphSqliteWriter, getGraphStore, invalidateRetriever } from "@esankhan3/anvil-knowledge-core";
|
|
7
|
+
import { handleGraphTool } from "../tools/graph.js";
|
|
8
|
+
const MAX_NODES = 5e4;
|
|
9
|
+
let tmp = "";
|
|
10
|
+
let stashDataDir;
|
|
11
|
+
function ctx(projectName) {
|
|
12
|
+
return {
|
|
13
|
+
projectName,
|
|
14
|
+
directoryPath: null,
|
|
15
|
+
indexReady: true,
|
|
16
|
+
startedAt: 0,
|
|
17
|
+
indexing: {
|
|
18
|
+
status: "idle",
|
|
19
|
+
phase: null,
|
|
20
|
+
message: null,
|
|
21
|
+
percent: 0,
|
|
22
|
+
startedAt: null,
|
|
23
|
+
error: null,
|
|
24
|
+
lastSuccess: null,
|
|
25
|
+
lastDurationMs: 0,
|
|
26
|
+
history: []
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const text = (r) => r?.content?.[0]?.text ?? "";
|
|
31
|
+
const call = async (project, name, args) => text(await handleGraphTool(name, args, ctx(project)));
|
|
32
|
+
const key = (name, file = "f.ts") => `r::${file}::${name}`;
|
|
33
|
+
async function writeGraph(project, nodes, edges, fileOf = (_n) => "f.ts") {
|
|
34
|
+
const kb = join(tmp, project);
|
|
35
|
+
mkdirSync(kb, { recursive: true });
|
|
36
|
+
const w = await createSystemGraphSqliteWriter(kb);
|
|
37
|
+
assert.ok(w, "no sqlite driver");
|
|
38
|
+
w.addRepoGraph("r", {
|
|
39
|
+
nodes: nodes.map((n) => ({ id: `${n.file ?? "f.ts"}::${n.name}`, label: n.name, type: "function", file: n.file ?? "f.ts" })),
|
|
40
|
+
links: edges.map((e) => ({ source: `${fileOf(e.s)}::${e.s}`, target: `${fileOf(e.t)}::${e.t}`, type: e.type, confidence: e.confidence }))
|
|
41
|
+
});
|
|
42
|
+
w.finalize();
|
|
43
|
+
}
|
|
44
|
+
async function instrument(project, methods, onRead = () => {
|
|
45
|
+
}) {
|
|
46
|
+
const store = await getGraphStore(project);
|
|
47
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const m of methods) {
|
|
49
|
+
const orig = store[m];
|
|
50
|
+
if (typeof orig !== "function") continue;
|
|
51
|
+
store[m] = (...a) => {
|
|
52
|
+
const r = orig.apply(store, a);
|
|
53
|
+
if (Array.isArray(r)) for (const x of r) for (const k of Array.isArray(x) ? x : [x]) nodes.add(k);
|
|
54
|
+
onRead();
|
|
55
|
+
return r;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { nodes, restore: () => {
|
|
59
|
+
for (const m of methods) delete store[m];
|
|
60
|
+
} };
|
|
61
|
+
}
|
|
62
|
+
function expireClock() {
|
|
63
|
+
const real = Date.now;
|
|
64
|
+
Date.now = () => real() + 6e4;
|
|
65
|
+
return () => {
|
|
66
|
+
Date.now = real;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
before(() => {
|
|
70
|
+
stashDataDir = process.env.CODE_SEARCH_DATA_DIR;
|
|
71
|
+
tmp = mkdtempSync(join(tmpdir(), "cs-trav-"));
|
|
72
|
+
process.env.CODE_SEARCH_DATA_DIR = tmp;
|
|
73
|
+
});
|
|
74
|
+
after(async () => {
|
|
75
|
+
await invalidateRetriever();
|
|
76
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
77
|
+
if (stashDataDir === void 0) delete process.env.CODE_SEARCH_DATA_DIR;
|
|
78
|
+
else process.env.CODE_SEARCH_DATA_DIR = stashDataDir;
|
|
79
|
+
});
|
|
80
|
+
function rng(seed) {
|
|
81
|
+
return () => {
|
|
82
|
+
seed = seed + 1831565813 | 0;
|
|
83
|
+
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
|
84
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
85
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function steps(edges, direction, types) {
|
|
89
|
+
const adj = /* @__PURE__ */ new Map();
|
|
90
|
+
const add = (a, b) => {
|
|
91
|
+
if (!adj.has(a)) adj.set(a, /* @__PURE__ */ new Set());
|
|
92
|
+
adj.get(a).add(b);
|
|
93
|
+
};
|
|
94
|
+
for (const e of edges) {
|
|
95
|
+
if (e.confidence < 0.7 || types && !types.includes(e.type)) continue;
|
|
96
|
+
if (direction !== "callers") add(e.s, e.t);
|
|
97
|
+
if (direction !== "callees") add(e.t, e.s);
|
|
98
|
+
}
|
|
99
|
+
return adj;
|
|
100
|
+
}
|
|
101
|
+
function bfsHops(adj, from, to) {
|
|
102
|
+
if (from === to) return null;
|
|
103
|
+
const dist = /* @__PURE__ */ new Map([[from, 0]]);
|
|
104
|
+
let frontier = [from];
|
|
105
|
+
while (frontier.length > 0) {
|
|
106
|
+
const next = [];
|
|
107
|
+
for (const k of frontier) {
|
|
108
|
+
for (const n of adj.get(k) ?? []) {
|
|
109
|
+
if (dist.has(n)) continue;
|
|
110
|
+
dist.set(n, dist.get(k) + 1);
|
|
111
|
+
if (n === to) return dist.get(n);
|
|
112
|
+
next.push(n);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
frontier = next;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const pathKeys = (t) => [...t.matchAll(/^- `([^`]+)`$/gm)].map((m) => m[1]);
|
|
120
|
+
describe("trace_path \u2014 shortest path", () => {
|
|
121
|
+
it("matches a plain BFS on random graphs: hop count, a real path, maxDepth", async () => {
|
|
122
|
+
const rand = rng(7);
|
|
123
|
+
const pick = (xs) => xs[Math.floor(rand() * xs.length)];
|
|
124
|
+
let paths = 0;
|
|
125
|
+
let none = 0;
|
|
126
|
+
for (let g = 0; g < 25; g++) {
|
|
127
|
+
const n = 8 + Math.floor(rand() * 20);
|
|
128
|
+
const names = Array.from({ length: n }, (_, i) => `n${i}`);
|
|
129
|
+
const edges = Array.from({ length: Math.floor(n * (1 + rand() * 1.5)) }, () => ({
|
|
130
|
+
s: pick(names),
|
|
131
|
+
t: pick(names),
|
|
132
|
+
type: rand() < 0.75 ? "calls" : "imports",
|
|
133
|
+
confidence: rand() < 0.85 ? 0.9 : 0.5
|
|
134
|
+
}));
|
|
135
|
+
const project = `rnd${g}`;
|
|
136
|
+
await writeGraph(project, names.map((name) => ({ name })), edges);
|
|
137
|
+
for (let q = 0; q < 16; q++) {
|
|
138
|
+
const from = pick(names);
|
|
139
|
+
const to = pick(names);
|
|
140
|
+
const direction = pick(["callees", "callers", "both"]);
|
|
141
|
+
const relations = pick([void 0, ["imports"], ["all"]]);
|
|
142
|
+
const maxDepth = 1 + Math.floor(rand() * 6);
|
|
143
|
+
const adj = steps(edges, direction, relations === void 0 ? ["calls"] : relations[0] === "all" ? null : relations);
|
|
144
|
+
const want = bfsHops(adj, from, to);
|
|
145
|
+
const t = await call(project, "trace_path", { from: key(from), to: key(to), direction, maxDepth, ...relations ? { relations } : {} });
|
|
146
|
+
const label = `${project}: ${from} \u2192 ${to} (${direction}, ${relations ?? "calls"}, maxDepth ${maxDepth})
|
|
147
|
+
${t}`;
|
|
148
|
+
if (want === null || want > maxDepth) {
|
|
149
|
+
assert.match(t, new RegExp(`^No path from .* within ${maxDepth} hops`), label);
|
|
150
|
+
none++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
assert.equal(Number(/^# Path \((\d+) hops\)/.exec(t)?.[1]), want, label);
|
|
154
|
+
const keys = pathKeys(t);
|
|
155
|
+
assert.equal(keys.length, want + 1, label);
|
|
156
|
+
assert.equal(keys[0], key(from), label);
|
|
157
|
+
assert.equal(keys[keys.length - 1], key(to), label);
|
|
158
|
+
for (let i = 1; i < keys.length; i++) {
|
|
159
|
+
assert.ok(adj.get(keys[i - 1].split("::")[2])?.has(keys[i].split("::")[2]), `no step ${keys[i - 1]} \u2192 ${keys[i]} in ${label}`);
|
|
160
|
+
}
|
|
161
|
+
paths++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
assert.ok(paths >= 60 && none >= 60, `sample covers both outcomes (${paths} paths, ${none} none)`);
|
|
165
|
+
});
|
|
166
|
+
it("picks the shorter of two routes and honours maxDepth exactly", async () => {
|
|
167
|
+
const e = (s, t2) => ({ s, t: t2, type: "calls", confidence: 0.9 });
|
|
168
|
+
await writeGraph("routes", ["a", "b", "c", "d", "e", "x", "y"].map((name) => ({ name })), [
|
|
169
|
+
e("a", "b"),
|
|
170
|
+
e("b", "c"),
|
|
171
|
+
e("c", "d"),
|
|
172
|
+
e("d", "e"),
|
|
173
|
+
// 4 hops
|
|
174
|
+
e("a", "x"),
|
|
175
|
+
e("x", "y"),
|
|
176
|
+
e("y", "e")
|
|
177
|
+
// 3 hops
|
|
178
|
+
]);
|
|
179
|
+
const t = await call("routes", "trace_path", { from: "a", to: "e" });
|
|
180
|
+
assert.match(t, /# Path \(3 hops\)\n\na → x → y → e/);
|
|
181
|
+
assert.match(await call("routes", "trace_path", { from: "a", to: "e", maxDepth: 3 }), /3 hops/);
|
|
182
|
+
assert.match(await call("routes", "trace_path", { from: "a", to: "e", maxDepth: 2 }), /^No path from "a" to "e" within 2 hops/);
|
|
183
|
+
assert.match(await call("routes", "trace_path", { from: "e", to: "a", direction: "callers" }), /# Path \(3 hops\)\n\ne ← y ← x ← a/);
|
|
184
|
+
assert.match(await call("routes", "trace_path", { from: "e", to: "a" }), /^No path from "e" to "a"/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe("traversal budget on a high-fan-out graph", () => {
|
|
188
|
+
const FAN = 250;
|
|
189
|
+
before(async () => {
|
|
190
|
+
const nodes = [{ name: "src" }, { name: "dst" }, { name: "sink", file: "sink.ts" }];
|
|
191
|
+
const edges = [];
|
|
192
|
+
const calls = (s, t) => edges.push({ s, t, type: "calls", confidence: 0.9 });
|
|
193
|
+
for (let i = 0; i < FAN; i++) {
|
|
194
|
+
nodes.push({ name: `c${i}` }, { name: `k${i}` });
|
|
195
|
+
calls("src", `c${i}`);
|
|
196
|
+
calls(`k${i}`, "dst");
|
|
197
|
+
for (let j = 0; j < FAN; j++) {
|
|
198
|
+
nodes.push({ name: `g${i}_${j}` });
|
|
199
|
+
calls(`c${i}`, `g${i}_${j}`);
|
|
200
|
+
calls(`g${i}_${j}`, "sink");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await writeGraph("fan", nodes, edges, (n) => n === "sink" ? "sink.ts" : "f.ts");
|
|
204
|
+
});
|
|
205
|
+
it('trace_path stops at the node cap and says so instead of "No path"', async () => {
|
|
206
|
+
const reads = await instrument("fan", ["neighborsOf", "neighborEdges"]);
|
|
207
|
+
try {
|
|
208
|
+
const t = await call("fan", "trace_path", { from: "src", to: "dst", maxDepth: 10 });
|
|
209
|
+
assert.match(t, /^No path from "src" to "dst" found within the traversal budget \(explored \d+ nodes, depth \d+ of 10/);
|
|
210
|
+
assert.match(t, /narrow the search .* or lower maxDepth/);
|
|
211
|
+
assert.ok(Number(/explored (\d+) nodes/.exec(t)?.[1]) <= MAX_NODES, t);
|
|
212
|
+
assert.ok(reads.nodes.size <= MAX_NODES, `read ${reads.nodes.size} distinct nodes`);
|
|
213
|
+
} finally {
|
|
214
|
+
reads.restore();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
it("trace_path stops when the wall-clock budget runs out", async () => {
|
|
218
|
+
let restoreClock = () => {
|
|
219
|
+
};
|
|
220
|
+
let queries = 0;
|
|
221
|
+
const reads = await instrument("fan", ["neighborsOf", "neighborEdges"], () => {
|
|
222
|
+
if (++queries === 1) restoreClock = expireClock();
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
const t = await call("fan", "trace_path", { from: "src", to: "dst", maxDepth: 10 });
|
|
226
|
+
assert.match(t, /found within the traversal budget \(explored \d+ nodes, depth 1 of 10/);
|
|
227
|
+
assert.equal(queries, 1);
|
|
228
|
+
} finally {
|
|
229
|
+
restoreClock();
|
|
230
|
+
reads.restore();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
it("trace_path still finds a path into a hub within the budget", async () => {
|
|
234
|
+
const t = await call("fan", "trace_path", { from: "c0", to: "sink", maxDepth: 10 });
|
|
235
|
+
assert.match(t, /# Path \(2 hops\)\n\nc0 → g0_\d+ → sink/);
|
|
236
|
+
});
|
|
237
|
+
it("impact_analysis caps the dependents scanned per hop and marks the count as a lower bound", async () => {
|
|
238
|
+
const t = await call("fan", "impact_analysis", { repo: "r", file: "sink.ts", depth: 1 });
|
|
239
|
+
assert.match(t, new RegExp(`## Dependents: ${MAX_NODES}\\+ edges`));
|
|
240
|
+
assert.match(t, new RegExp(`### Hop 1 \\(${MAX_NODES}\\+\\)`));
|
|
241
|
+
});
|
|
242
|
+
it("impact_analysis stops between hops when the wall-clock budget runs out", async () => {
|
|
243
|
+
let restoreClock = () => {
|
|
244
|
+
};
|
|
245
|
+
let expired = false;
|
|
246
|
+
const reads = await instrument("fan", ["dependents"], () => {
|
|
247
|
+
if (!expired) {
|
|
248
|
+
expired = true;
|
|
249
|
+
restoreClock = expireClock();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
try {
|
|
253
|
+
const t = await call("fan", "impact_analysis", { repo: "r", file: "sink.ts", depth: 3 });
|
|
254
|
+
assert.match(t, /### Hop 1/);
|
|
255
|
+
assert.doesNotMatch(t, /### Hop 2/);
|
|
256
|
+
assert.match(t, /traversal budget/);
|
|
257
|
+
} finally {
|
|
258
|
+
restoreClock();
|
|
259
|
+
reads.restore();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
package/dist/tools/graph.js
CHANGED
|
@@ -17,49 +17,90 @@ import { missingProfileText } from "./profile.js";
|
|
|
17
17
|
import { toolError, toolText, validateArgs } from "./validate.js";
|
|
18
18
|
const repoOf = (key) => key.split("::")[0] ?? "";
|
|
19
19
|
const fileOf = (key) => key.split("::")[1] ?? "";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
const TRAVERSAL_MAX_NODES = 5e4;
|
|
21
|
+
const TRAVERSAL_BUDGET_MS = 1e3;
|
|
22
|
+
const NEIGHBOR_BATCH = 400;
|
|
23
|
+
class TraversalBudget {
|
|
24
|
+
deadline = Date.now() + TRAVERSAL_BUDGET_MS;
|
|
25
|
+
/** Distinct nodes discovered so far. */
|
|
26
|
+
explored = 0;
|
|
27
|
+
/** Set once a limit stopped the traversal. */
|
|
28
|
+
exhausted = false;
|
|
29
|
+
/** Room for another query? Marks the budget exhausted when there isn't. */
|
|
30
|
+
hasRoom() {
|
|
31
|
+
if (this.explored >= TRAVERSAL_MAX_NODES || Date.now() > this.deadline) this.exhausted = true;
|
|
32
|
+
return !this.exhausted;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function expandLevel(store, frontier, direction, types, budget, visit) {
|
|
36
|
+
for (let i = 0; i < frontier.length; i += NEIGHBOR_BATCH) {
|
|
37
|
+
if (!budget.hasRoom()) return false;
|
|
38
|
+
const limit = TRAVERSAL_MAX_NODES - budget.explored;
|
|
39
|
+
const rows = store.neighborEdges(frontier.slice(i, i + NEIGHBOR_BATCH), direction, types, limit);
|
|
40
|
+
for (const [key, nb] of rows) if (visit(key, nb)) return true;
|
|
41
|
+
if (rows.length >= limit) {
|
|
42
|
+
budget.exhausted = true;
|
|
43
|
+
return false;
|
|
27
44
|
}
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
30
47
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
const REVERSE = { callees: "callers", callers: "callees", both: "both" };
|
|
49
|
+
function shortestPath(store, fromKeys, toKeys, maxDepth, direction, types, budget) {
|
|
50
|
+
const fwd = new Map(fromKeys.map((k) => [k, null]));
|
|
51
|
+
const bwd = new Map(toKeys.map((k) => [k, null]));
|
|
52
|
+
const chain = (m, k) => {
|
|
53
|
+
const out = [k];
|
|
54
|
+
for (let p = m.get(k); p != null; p = m.get(p)) out.push(p);
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
budget.explored = fwd.size + bwd.size;
|
|
58
|
+
let fFront = [...fwd.keys()];
|
|
59
|
+
let bFront = [...bwd.keys()];
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let path = null;
|
|
62
|
+
while (depth < maxDepth && fFront.length > 0 && bFront.length > 0) {
|
|
63
|
+
const forward = fFront.length <= bFront.length;
|
|
64
|
+
const [mine, other] = forward ? [fwd, bwd] : [bwd, fwd];
|
|
35
65
|
const next = [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
expandLevel(store, forward ? fFront : bFront, forward ? direction : REVERSE[direction], types, budget, (k, nb) => {
|
|
67
|
+
if (other.has(nb)) {
|
|
68
|
+
const p = forward ? [...chain(fwd, k).reverse(), ...chain(bwd, nb)] : [...chain(fwd, nb).reverse(), ...chain(bwd, k)];
|
|
69
|
+
if (p[0] !== p[p.length - 1]) {
|
|
70
|
+
path = p;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
42
73
|
}
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
if (!mine.has(nb)) {
|
|
75
|
+
mine.set(nb, k);
|
|
76
|
+
next.push(nb);
|
|
77
|
+
budget.explored++;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
if (path || budget.exhausted) break;
|
|
82
|
+
if (forward) fFront = next;
|
|
83
|
+
else bFront = next;
|
|
84
|
+
depth++;
|
|
45
85
|
}
|
|
46
|
-
return
|
|
86
|
+
return { path, depth };
|
|
47
87
|
}
|
|
48
|
-
function reachable(
|
|
88
|
+
function reachable(store, fromKeys, maxDepth, direction, types, budget, cap = 50) {
|
|
49
89
|
const visited = new Set(fromKeys);
|
|
50
90
|
const out = [];
|
|
91
|
+
budget.explored = visited.size;
|
|
51
92
|
let frontier = [...fromKeys];
|
|
52
93
|
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
53
94
|
const next = [];
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
const full = expandLevel(store, frontier, direction, types, budget, (_k, nb) => {
|
|
96
|
+
if (visited.has(nb)) return false;
|
|
97
|
+
visited.add(nb);
|
|
98
|
+
budget.explored++;
|
|
99
|
+
out.push({ key: nb, depth });
|
|
100
|
+
next.push(nb);
|
|
101
|
+
return out.length >= cap;
|
|
102
|
+
});
|
|
103
|
+
if (full || budget.exhausted) break;
|
|
63
104
|
frontier = next;
|
|
64
105
|
}
|
|
65
106
|
return out;
|
|
@@ -69,13 +110,15 @@ function endpointsIn(store, keys) {
|
|
|
69
110
|
return keys.filter((k) => types.get(k) === "external");
|
|
70
111
|
}
|
|
71
112
|
const HOP_EDGE_CAP = 1e3;
|
|
72
|
-
function dependentHops(store, scope, depth, fileLevel) {
|
|
113
|
+
function dependentHops(store, scope, depth, fileLevel, budget) {
|
|
73
114
|
const visited = new Set(scope);
|
|
74
115
|
const repos = /* @__PURE__ */ new Set();
|
|
75
116
|
const hops = [];
|
|
76
117
|
let frontier = [...scope];
|
|
77
118
|
for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
|
|
78
|
-
|
|
119
|
+
budget.explored = visited.size;
|
|
120
|
+
if (!budget.hasRoom()) break;
|
|
121
|
+
const d = store.dependents(frontier, HOP_EDGE_CAP, TRAVERSAL_MAX_NODES);
|
|
79
122
|
const edges = d.edges.filter((e) => !visited.has(e.source));
|
|
80
123
|
const direct = /* @__PURE__ */ new Set();
|
|
81
124
|
const next = [];
|
|
@@ -88,11 +131,13 @@ function dependentHops(store, scope, depth, fileLevel) {
|
|
|
88
131
|
}
|
|
89
132
|
}
|
|
90
133
|
for (const k of next) if (!direct.has(k)) fileLevel.add(k);
|
|
91
|
-
hops.push({ hop, edges, total: d.total - (d.edges.length - edges.length) });
|
|
134
|
+
hops.push({ hop, edges, total: d.total - (d.edges.length - edges.length), truncated: d.truncated });
|
|
92
135
|
frontier = next;
|
|
93
136
|
}
|
|
94
137
|
return { hops, repos };
|
|
95
138
|
}
|
|
139
|
+
const atLeast = (truncated) => truncated ? "+" : "";
|
|
140
|
+
const AT_LEAST_NOTE = `_N+ = at least N: a hop counts at most ${TRAVERSAL_MAX_NODES} dependent edges._`;
|
|
96
141
|
function edgeLine(e, fileLevel) {
|
|
97
142
|
const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
|
|
98
143
|
return `- \`${e.source}\` ${arrow} \`${e.target}\` (${e.type ?? "edge"})${e.evidence ? ` \u2014 ${e.evidence}` : ""}${fileLevel.has(e.target) ? " [file-level]" : ""}`;
|
|
@@ -231,7 +276,7 @@ function registerGraphTools() {
|
|
|
231
276
|
},
|
|
232
277
|
{
|
|
233
278
|
name: "impact_analysis",
|
|
234
|
-
description: "What is affected if a file or entity changes: everything that depends on it, transitively up to `depth` hops \u2014 over every relation (calls, imports, uses, type-refs, inheritance, cross-repo edges); shared-resource edges (topics, tables, Redis keys, buckets, shared types / constants / env vars / npm deps) count both ways. With `entity`, the cross-repo endpoints declared in the same file (routes, topics, tables \u2026) are included and their dependents marked [file-level], since the graph can't tie them to one entity.",
|
|
279
|
+
description: "What is affected if a file or entity changes: everything that depends on it, transitively up to `depth` hops \u2014 over every relation (calls, imports, uses, type-refs, inheritance, cross-repo edges); shared-resource edges (topics, tables, Redis keys, buckets, shared types / constants / env vars / npm deps) count both ways. With `entity`, the cross-repo endpoints declared in the same file (routes, topics, tables \u2026) are included and their dependents marked [file-level], since the graph can't tie them to one entity. A hop counts at most 50,000 edges (shown as 50000+); hops stop after 1 s, with a note.",
|
|
235
280
|
inputSchema: {
|
|
236
281
|
type: "object",
|
|
237
282
|
properties: {
|
|
@@ -245,7 +290,7 @@ function registerGraphTools() {
|
|
|
245
290
|
},
|
|
246
291
|
{
|
|
247
292
|
name: "trace_path",
|
|
248
|
-
description: 'Trace a call chain across multiple hops. With "to", returns the shortest path (fewest hops,
|
|
293
|
+
description: 'Trace a call chain across multiple hops. With "to", returns the shortest path (fewest hops, bidirectional BFS) between two symbols; otherwise lists what is reachable, by depth (up to 50 nodes). Follows `calls` edges by default (`relations` for imports, type-refs, inheritance) and skips edges with confidence below 0.7. Names are matched exactly (a full "repo::file::entity" id works too) unless fuzzy:true. A search stops after 50,000 nodes or 1 s and says so \u2014 then narrow it or lower maxDepth.',
|
|
249
294
|
inputSchema: {
|
|
250
295
|
type: "object",
|
|
251
296
|
properties: {
|
|
@@ -433,8 +478,10 @@ ${sections}`);
|
|
|
433
478
|
if (scope.length === 0) return toolError(`No entity "${entity}" in ${repo}/${file}.`);
|
|
434
479
|
const endpoints = entity ? endpointsIn(store, fileKeys).filter((k) => !scope.includes(k)) : [];
|
|
435
480
|
const fileLevel = new Set(endpoints);
|
|
436
|
-
const
|
|
481
|
+
const budget = new TraversalBudget();
|
|
482
|
+
const { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel, budget);
|
|
437
483
|
const total = hops.reduce((n, h) => n + h.total, 0);
|
|
484
|
+
const truncated = hops.some((h) => h.truncated);
|
|
438
485
|
const body = [
|
|
439
486
|
`# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""} (depth ${depth})`,
|
|
440
487
|
"",
|
|
@@ -444,11 +491,15 @@ ${sections}`);
|
|
|
444
491
|
if (endpoints.length) {
|
|
445
492
|
body.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in ${file}, not tied to ${entity})`, ...endpoints.slice(0, 20).map((k) => `- \`${k}\``));
|
|
446
493
|
}
|
|
447
|
-
body.push("", `## Dependents: ${total} edges from ${repos.size} repos`);
|
|
494
|
+
body.push("", `## Dependents: ${total}${atLeast(truncated)} edges from ${repos.size} repos`);
|
|
448
495
|
for (const h of hops) {
|
|
449
496
|
if (h.edges.length === 0) continue;
|
|
450
|
-
body.push(`### Hop ${h.hop} (${h.total})`, ...h.edges.slice(0, 30).map((e) => edgeLine(e, fileLevel)));
|
|
451
|
-
if (h.total > Math.min(h.edges.length, 30)) body.push(`... and ${h.total - Math.min(h.edges.length, 30)} more`);
|
|
497
|
+
body.push(`### Hop ${h.hop} (${h.total}${atLeast(h.truncated)})`, ...h.edges.slice(0, 30).map((e) => edgeLine(e, fileLevel)));
|
|
498
|
+
if (h.total > Math.min(h.edges.length, 30)) body.push(`... and ${h.total - Math.min(h.edges.length, 30)}${atLeast(h.truncated)} more`);
|
|
499
|
+
}
|
|
500
|
+
if (truncated) body.push("", AT_LEAST_NOTE);
|
|
501
|
+
if (budget.exhausted) {
|
|
502
|
+
body.push("", `_Stopped before hop ${hops.length + 1} at the traversal budget (explored ${budget.explored} nodes) \u2014 lower depth or pass an entity._`);
|
|
452
503
|
}
|
|
453
504
|
body.push("", `## Affected repos: ${[...repos].join(", ") || "none"}`);
|
|
454
505
|
return text(body.join("\n"));
|
|
@@ -464,11 +515,15 @@ ${sections}`);
|
|
|
464
515
|
const maxDepth = args.maxDepth ?? 4;
|
|
465
516
|
const fromKeys = store.resolveNodes(from, repo, fuzzy).map((m) => m.key);
|
|
466
517
|
if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
467
|
-
const
|
|
518
|
+
const types = edgeTypes(args);
|
|
519
|
+
const budget = new TraversalBudget();
|
|
468
520
|
if (to) {
|
|
469
|
-
const toKeys =
|
|
470
|
-
if (toKeys.
|
|
471
|
-
const path = shortestPath(
|
|
521
|
+
const toKeys = store.resolveNodes(to, repo, fuzzy).map((m) => m.key);
|
|
522
|
+
if (toKeys.length === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
523
|
+
const { path, depth } = shortestPath(store, fromKeys, toKeys, maxDepth, direction, types, budget);
|
|
524
|
+
if (!path && budget.exhausted) {
|
|
525
|
+
return text(`No path from "${from}" to "${to}" found within the traversal budget (explored ${budget.explored} nodes, depth ${depth} of ${maxDepth}, direction: ${direction}) \u2014 narrow the search (repo, exact names, relations) or lower maxDepth.`);
|
|
526
|
+
}
|
|
472
527
|
if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).`);
|
|
473
528
|
const labelOf2 = store.labelsOf(path);
|
|
474
529
|
const arrow = direction === "callers" ? " \u2190 " : " \u2192 ";
|
|
@@ -479,8 +534,11 @@ ${rendered}
|
|
|
479
534
|
|
|
480
535
|
${path.map((k) => `- \`${k}\``).join("\n")}`);
|
|
481
536
|
}
|
|
482
|
-
const nodes = reachable(
|
|
483
|
-
|
|
537
|
+
const nodes = reachable(store, fromKeys, maxDepth, direction, types, budget);
|
|
538
|
+
const stopped = budget.exhausted ? `
|
|
539
|
+
|
|
540
|
+
_Stopped early at the traversal budget (explored ${budget.explored} nodes) \u2014 narrow the search or lower maxDepth._` : "";
|
|
541
|
+
if (nodes.length === 0) return text(`No ${direction} reachable from "${from}" within ${maxDepth} hops.${stopped}`);
|
|
484
542
|
const labelOf = store.labelsOf(nodes.map((n) => n.key));
|
|
485
543
|
const byDepth = /* @__PURE__ */ new Map();
|
|
486
544
|
for (const { key, depth } of nodes) {
|
|
@@ -493,7 +551,7 @@ ${ks.map((k) => `- ${k}`).join("\n")}`).join("\n\n");
|
|
|
493
551
|
|
|
494
552
|
${nodes.length} nodes:
|
|
495
553
|
|
|
496
|
-
${sections}`);
|
|
554
|
+
${sections}${stopped}`);
|
|
497
555
|
}
|
|
498
556
|
if (name === "find_dead_code") {
|
|
499
557
|
const repo = args.repo;
|
|
@@ -630,6 +688,7 @@ ${body}${total > rows.length ? `
|
|
|
630
688
|
let hops = [];
|
|
631
689
|
let depRepos = /* @__PURE__ */ new Set();
|
|
632
690
|
const fileLevel = /* @__PURE__ */ new Set();
|
|
691
|
+
let budget = null;
|
|
633
692
|
if (store) {
|
|
634
693
|
changedKeys.push(...store.nodesInFiles(repo, [...diff.added, ...diff.deleted]));
|
|
635
694
|
const hunks = diffHunks(repoPath, baseSha, diff.modified);
|
|
@@ -660,10 +719,12 @@ ${body}${total > rows.length ? `
|
|
|
660
719
|
}
|
|
661
720
|
}
|
|
662
721
|
for (const k of endpoints) fileLevel.add(k);
|
|
663
|
-
|
|
722
|
+
budget = new TraversalBudget();
|
|
723
|
+
({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel, budget));
|
|
664
724
|
}
|
|
665
725
|
const dependents = hops[0]?.edges ?? [];
|
|
666
726
|
const depTotal = hops[0]?.total ?? 0;
|
|
727
|
+
const plus = atLeast(hops[0]?.truncated ?? false);
|
|
667
728
|
const lines = [
|
|
668
729
|
`# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
|
|
669
730
|
"",
|
|
@@ -681,9 +742,11 @@ ${body}${total > rows.length ? `
|
|
|
681
742
|
}
|
|
682
743
|
lines.push(
|
|
683
744
|
"",
|
|
684
|
-
`## Dependents: ${depTotal} edges from ${depRepos.size} repo(s)`,
|
|
745
|
+
`## Dependents: ${depTotal}${plus} edges from ${depRepos.size} repo(s)`,
|
|
685
746
|
...dependents.slice(0, limit).map((e) => edgeLine(e, fileLevel)),
|
|
686
|
-
...depTotal > Math.min(dependents.length, limit) ? [`... and ${depTotal - Math.min(dependents.length, limit)} more`] : [],
|
|
747
|
+
...depTotal > Math.min(dependents.length, limit) ? [`... and ${depTotal - Math.min(dependents.length, limit)}${plus} more`] : [],
|
|
748
|
+
...plus ? ["", AT_LEAST_NOTE] : [],
|
|
749
|
+
...budget?.exhausted ? ["", `_Dependents not looked up: ${budget.explored} affected nodes exceed the traversal budget \u2014 diff against a closer baseSha._`] : [],
|
|
687
750
|
"",
|
|
688
751
|
`## Affected repos: ${[...depRepos].join(", ") || "none"}`
|
|
689
752
|
);
|
|
@@ -9,6 +9,9 @@ function describeVectorIndex(v) {
|
|
|
9
9
|
v.trainedRows ? `trained on ${v.trainedRows.toLocaleString()} rows at ${v.trainedAt}, +${v.rowsAddedSinceTrain.toLocaleString()} since (${Math.round(v.rowsAddedSinceTrain / v.trainedRows * 100)}%)` : "training untracked (retrains on the next writer run)"
|
|
10
10
|
);
|
|
11
11
|
parts.push(`${(v.numUnindexedRows ?? 0).toLocaleString()} unindexed rows`);
|
|
12
|
+
if (v.nprobes) {
|
|
13
|
+
parts.push(`${v.nprobes} probes per query (recall@10 ${v.calibratedRecall?.toFixed(3)} on ${v.calibrationQueries} query-like probes)`);
|
|
14
|
+
}
|
|
12
15
|
if (v.retrainStartedAt) parts.push(`retrain started ${v.retrainStartedAt} has not completed`);
|
|
13
16
|
if (v.lastError) parts.push(`last error: ${v.lastError}`);
|
|
14
17
|
return parts.join("; ");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esankhan3/code-search-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "MCP server for multi-repo code search \u2014 GitHub org or local path, zero config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@esankhan3/anvil-agent-core": "^0.5.0",
|
|
27
|
-
"@esankhan3/anvil-knowledge-core": "^0.4.
|
|
27
|
+
"@esankhan3/anvil-knowledge-core": "^0.4.8",
|
|
28
28
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
29
29
|
"@modelcontextprotocol/server": "^2.0.0"
|
|
30
30
|
},
|