@esankhan3/code-search-mcp 1.1.0 → 1.1.1

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.
@@ -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
+ });
@@ -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
- function makeNeighborGetter(store, direction, types) {
21
- const cache = /* @__PURE__ */ new Map();
22
- return (key) => {
23
- let v = cache.get(key);
24
- if (v === void 0) {
25
- v = store.neighborsOf(key, direction, types);
26
- cache.set(key, v);
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
- return v;
29
- };
45
+ }
46
+ return false;
30
47
  }
31
- function shortestPath(getNeighbors, fromKeys, toKeys, maxDepth) {
32
- const visited = new Set(fromKeys);
33
- let frontier = fromKeys.map((k) => [k]);
34
- for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
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
- for (const path of frontier) {
37
- for (const nb of getNeighbors(path[path.length - 1])) {
38
- if (toKeys.has(nb) && nb !== path[0]) return [...path, nb];
39
- if (visited.has(nb)) continue;
40
- visited.add(nb);
41
- next.push([...path, nb]);
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
- frontier = next;
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 null;
86
+ return { path, depth };
47
87
  }
48
- function reachable(getNeighbors, fromKeys, maxDepth, cap = 50) {
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
- for (const k of frontier) {
55
- for (const nb of getNeighbors(k)) {
56
- if (visited.has(nb)) continue;
57
- visited.add(nb);
58
- out.push({ key: nb, depth });
59
- next.push(nb);
60
- if (out.length >= cap) return out;
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
- const d = store.dependents(frontier, HOP_EDGE_CAP);
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, unweighted 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.',
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 { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel);
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 getNeighbors = makeNeighborGetter(store, direction, edgeTypes(args));
518
+ const types = edgeTypes(args);
519
+ const budget = new TraversalBudget();
468
520
  if (to) {
469
- const toKeys = new Set(store.resolveNodes(to, repo, fuzzy).map((m) => m.key));
470
- if (toKeys.size === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
471
- const path = shortestPath(getNeighbors, fromKeys, toKeys, maxDepth);
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(getNeighbors, fromKeys, maxDepth);
483
- if (nodes.length === 0) return text(`No ${direction} reachable from "${from}" within ${maxDepth} hops.`);
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
- ({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel));
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.0",
3
+ "version": "1.1.1",
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.0",
27
+ "@esankhan3/anvil-knowledge-core": "^0.4.7",
28
28
  "@modelcontextprotocol/node": "^2.0.0",
29
29
  "@modelcontextprotocol/server": "^2.0.0"
30
30
  },