@esankhan3/code-search-mcp 1.0.6 → 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.
Files changed (42) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/graph-traversal.test.js +262 -0
  10. package/dist/__tests__/http-auth.test.js +180 -0
  11. package/dist/__tests__/http-index.test.js +125 -0
  12. package/dist/__tests__/metrics-collect.test.js +48 -0
  13. package/dist/__tests__/observability.test.js +3 -4
  14. package/dist/__tests__/remote-proxy.test.js +105 -0
  15. package/dist/__tests__/server-cli-args.test.js +146 -0
  16. package/dist/__tests__/server-lifecycle.test.js +108 -0
  17. package/dist/__tests__/tool-inputs.test.js +178 -0
  18. package/dist/backends/in-process.js +5 -2
  19. package/dist/backends/index.js +2 -2
  20. package/dist/cli/index.js +143 -76
  21. package/dist/core/config.js +106 -35
  22. package/dist/core/env-config.js +3 -5
  23. package/dist/core/project-name.js +12 -0
  24. package/dist/core/version.js +5 -0
  25. package/dist/daemon/index.js +106 -65
  26. package/dist/daemon/rpc-server.js +38 -18
  27. package/dist/index.js +113 -89
  28. package/dist/middleware/auth.js +34 -66
  29. package/dist/middleware/rate-limit.js +37 -0
  30. package/dist/observability/logger.js +10 -2
  31. package/dist/observability/metrics.js +7 -6
  32. package/dist/projects/registry.js +13 -15
  33. package/dist/resources/resources.js +17 -0
  34. package/dist/server.js +216 -129
  35. package/dist/tools/graph.js +448 -165
  36. package/dist/tools/index-tools.js +6 -2
  37. package/dist/tools/profile.js +14 -8
  38. package/dist/tools/search.js +61 -51
  39. package/dist/tools/validate.js +48 -0
  40. package/dist/transports/http-transport.js +94 -55
  41. package/dist/transports/remote-proxy.js +58 -97
  42. package/package.json +2 -2
@@ -1,158 +1,318 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { join } from "node:path";
3
4
  import {
4
5
  getBlobStore,
5
6
  getGraphStore,
7
+ getRetriever,
8
+ findChunksInProject,
6
9
  loadAllProfiles,
7
10
  loadProfile,
8
11
  getAllChanges,
9
- getChangedFilesList
12
+ getChangedFilesList,
13
+ SYMMETRIC_EDGE_TYPES
10
14
  } from "@esankhan3/anvil-knowledge-core";
11
15
  import { resolvedKnowledgeConfig } from "../core/env-config.js";
12
- const GRAPH_TOOL_NAMES = [
13
- "get_repo_graph",
14
- "get_cross_repo_edges",
15
- "find_callers",
16
- "find_dependencies",
17
- "impact_analysis",
18
- "trace_path",
19
- "find_dead_code",
20
- "get_architecture",
21
- "search_graph",
22
- "detect_changes"
23
- ];
16
+ import { missingProfileText } from "./profile.js";
17
+ import { toolError, toolText, validateArgs } from "./validate.js";
24
18
  const repoOf = (key) => key.split("::")[0] ?? "";
25
- function makeNeighborGetter(store, direction) {
26
- const cache = /* @__PURE__ */ new Map();
27
- return (key) => {
28
- let v = cache.get(key);
29
- if (v === void 0) {
30
- v = store.neighborsOf(key, direction);
31
- cache.set(key, v);
19
+ const fileOf = (key) => key.split("::")[1] ?? "";
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;
32
44
  }
33
- return v;
34
- };
45
+ }
46
+ return false;
35
47
  }
36
- function shortestPath(getNeighbors, fromKeys, toKeys, maxDepth) {
37
- const seed = fromKeys.find((k) => toKeys.has(k));
38
- if (seed) return [seed];
39
- const visited = new Set(fromKeys);
40
- let frontier = fromKeys.map((k) => [k]);
41
- 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];
42
65
  const next = [];
43
- for (const path of frontier) {
44
- for (const nb of getNeighbors(path[path.length - 1])) {
45
- if (visited.has(nb)) continue;
46
- if (toKeys.has(nb)) return [...path, nb];
47
- visited.add(nb);
48
- 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
+ }
49
73
  }
50
- }
51
- 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++;
52
85
  }
53
- return null;
86
+ return { path, depth };
54
87
  }
55
- function reachable(getNeighbors, fromKeys, maxDepth, cap = 50) {
88
+ function reachable(store, fromKeys, maxDepth, direction, types, budget, cap = 50) {
56
89
  const visited = new Set(fromKeys);
57
90
  const out = [];
91
+ budget.explored = visited.size;
58
92
  let frontier = [...fromKeys];
59
93
  for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
60
94
  const next = [];
61
- for (const k of frontier) {
62
- for (const nb of getNeighbors(k)) {
63
- if (visited.has(nb)) continue;
64
- visited.add(nb);
65
- out.push({ key: nb, depth });
66
- next.push(nb);
67
- if (out.length >= cap) return out;
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;
104
+ frontier = next;
105
+ }
106
+ return out;
107
+ }
108
+ function endpointsIn(store, keys) {
109
+ const types = store.nodeTypes(keys);
110
+ return keys.filter((k) => types.get(k) === "external");
111
+ }
112
+ const HOP_EDGE_CAP = 1e3;
113
+ function dependentHops(store, scope, depth, fileLevel, budget) {
114
+ const visited = new Set(scope);
115
+ const repos = /* @__PURE__ */ new Set();
116
+ const hops = [];
117
+ let frontier = [...scope];
118
+ for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
119
+ budget.explored = visited.size;
120
+ if (!budget.hasRoom()) break;
121
+ const d = store.dependents(frontier, HOP_EDGE_CAP, TRAVERSAL_MAX_NODES);
122
+ const edges = d.edges.filter((e) => !visited.has(e.source));
123
+ const direct = /* @__PURE__ */ new Set();
124
+ const next = [];
125
+ for (const e of edges) {
126
+ repos.add(repoOf(e.source));
127
+ if (!fileLevel.has(e.target)) direct.add(e.source);
128
+ if (!visited.has(e.source)) {
129
+ visited.add(e.source);
130
+ next.push(e.source);
68
131
  }
69
132
  }
133
+ for (const k of next) if (!direct.has(k)) fileLevel.add(k);
134
+ hops.push({ hop, edges, total: d.total - (d.edges.length - edges.length), truncated: d.truncated });
70
135
  frontier = next;
71
136
  }
137
+ return { hops, repos };
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._`;
141
+ function edgeLine(e, fileLevel) {
142
+ const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
143
+ return `- \`${e.source}\` ${arrow} \`${e.target}\` (${e.type ?? "edge"})${e.evidence ? ` \u2014 ${e.evidence}` : ""}${fileLevel.has(e.target) ? " [file-level]" : ""}`;
144
+ }
145
+ async function indexedSources(project, repo, files) {
146
+ const cfg = resolvedKnowledgeConfig();
147
+ const parts = /* @__PURE__ */ new Map();
148
+ try {
149
+ const vs = (await getRetriever(project, cfg)).vectorStore;
150
+ await Promise.all(files.map(async (f) => {
151
+ const chunks = await vs.getChunksByFile(repo, f);
152
+ if (chunks.length > 0) parts.set(f, chunks.map((c) => c.content));
153
+ }));
154
+ } catch {
155
+ }
156
+ const missing = new Set(files.filter((f) => !parts.has(f)));
157
+ if (missing.size > 0 && await getBlobStore(project, cfg).exists("chunks.json")) {
158
+ const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg);
159
+ for (const c of found) {
160
+ if (!parts.has(c.filePath)) parts.set(c.filePath, []);
161
+ parts.get(c.filePath).push(c.content);
162
+ }
163
+ }
164
+ return new Map([...parts].map(([f, cs]) => [f, cs.join("\n")]));
165
+ }
166
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
167
+ function identifierCount(src, name) {
168
+ return src.match(new RegExp(`(?<![\\w$])${escapeRegex(name)}(?![\\w$])`, "g"))?.length ?? 0;
169
+ }
170
+ const runGit = (repoPath, args) => execFileSync("git", args, {
171
+ cwd: repoPath,
172
+ encoding: "utf-8",
173
+ maxBuffer: 64 * 1024 * 1024,
174
+ stdio: ["pipe", "pipe", "pipe"]
175
+ });
176
+ function diffHunks(repoPath, baseSha, files) {
177
+ const out = /* @__PURE__ */ new Map();
178
+ if (files.length === 0) return out;
179
+ let file = "";
180
+ for (const line of runGit(repoPath, ["diff", "-U0", "--no-color", "--no-ext-diff", baseSha, "--", ...files]).split("\n")) {
181
+ if (line.startsWith("+++ ")) {
182
+ file = line.slice(4).replace(/^b\//, "");
183
+ continue;
184
+ }
185
+ const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
186
+ if (m && file) {
187
+ if (!out.has(file)) out.set(file, []);
188
+ out.get(file).push([Number(m[1]), m[2] === void 0 ? 1 : Number(m[2])]);
189
+ }
190
+ }
72
191
  return out;
73
192
  }
193
+ function declarationRanges(src, names) {
194
+ const lines = src.split("\n");
195
+ const starts = [];
196
+ for (const name of names) {
197
+ const n = escapeRegex(name);
198
+ const keyword = new RegExp(`\\b(?:function\\*?|class|interface|type|enum|struct|trait|impl|def|func|fn|const|let|var)\\s+(?:\\([^)]*\\)\\s*)?${n}(?![\\w$])`);
199
+ const method = new RegExp(`^\\s*(?:(?:public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|export|default)\\s+)*(?:[\\w$<>\\[\\],.?]+\\s+)?${n}\\s*(?:<[^>]*>)?\\(`);
200
+ let found = lines.flatMap((l, i) => keyword.test(l) ? [i + 1] : []);
201
+ if (found.length === 0) found = lines.flatMap((l, i) => method.test(l) && !l.trimEnd().endsWith(";") ? [i + 1] : []);
202
+ for (const line of found) starts.push({ name, line });
203
+ }
204
+ starts.sort((a, b) => a.line - b.line);
205
+ return starts.map((s, i) => ({ name: s.name, start: s.line, end: (starts[i + 1]?.line ?? lines.length + 1) - 1 }));
206
+ }
207
+ function touchedEntities(ranges, hunks) {
208
+ const names = /* @__PURE__ */ new Set();
209
+ let fileLevel = false;
210
+ for (const [start, count] of hunks) {
211
+ const hit = ranges.filter((r) => count > 0 ? r.start <= start + count - 1 && r.end >= start : r.start <= start && r.end >= start + 1);
212
+ if (hit.length === 0) fileLevel = true;
213
+ for (const r of hit) names.add(r.name);
214
+ }
215
+ return { names, fileLevel };
216
+ }
217
+ const RELATIONS = ["calls", "imports", "uses", "type-ref", "inherits", "all"];
218
+ const relationsProp = (what) => ({
219
+ type: "array",
220
+ items: { type: "string", enum: RELATIONS },
221
+ description: `Edge types to follow for ${what} (default ["calls"]): calls; imports (a file imports a file); uses (a file imports this symbol by name); type-ref; inherits; or "all" (every edge type except contains, cross-repo edges included)`
222
+ });
223
+ function edgeTypes(args) {
224
+ const r = args.relations ?? ["calls"];
225
+ return r.includes("all") ? void 0 : r;
226
+ }
74
227
  function registerGraphTools() {
75
228
  return [
76
229
  {
77
230
  name: "get_repo_graph",
78
- description: "Get the AST knowledge graph for a repo \u2014 entities (functions, classes, types) and their relationships (calls, imports, inheritance).",
231
+ description: "List a repo's AST graph: node and edge counts, entities (id + type) and edges (source \u2192 target + relation; `contains` omitted), each list capped by `limit`.",
79
232
  inputSchema: {
80
233
  type: "object",
81
- properties: { repo: { type: "string", description: "Repository name" } },
234
+ properties: {
235
+ repo: { type: "string", minLength: 1, description: "Repository name" },
236
+ limit: { type: "integer", minimum: 1, description: "Max entities and max edges listed (default 50)" }
237
+ },
82
238
  required: ["repo"]
83
239
  }
84
240
  },
85
241
  {
86
242
  name: "get_cross_repo_edges",
87
- description: "Get connections between repos \u2014 shared deps, Kafka topics, HTTP routes, database tables, gRPC services, etc.",
243
+ description: "Connections between repos \u2014 HTTP routes, gRPC services, event/Kafka topics, database tables, Redis keys, S3 buckets, OpenAPI contracts, shared types / constants / env vars / npm deps \u2014 each with the evidence that produced it (route, topic, table, package). Same-repo workspace-package dependencies are listed in their own section. Up to 50 edges.",
88
244
  inputSchema: {
89
245
  type: "object",
90
- properties: { repo: { type: "string", description: "Filter to edges involving this repo (optional)" } }
246
+ properties: { repo: { type: "string", minLength: 1, description: "Only edges involving this repo (optional)" } }
91
247
  }
92
248
  },
93
249
  {
94
250
  name: "find_callers",
95
- description: "Find all functions/methods that call a given function (exact name match by default). Uses the AST graph.",
251
+ description: 'Find the callers of a symbol \u2014 entities with a `calls` edge into it. The name is matched exactly (case-sensitive; a full "repo::file::entity" id works too) unless fuzzy:true. Imports, type references and inheritance are not calls: they are listed, by relation, only when requested via `relations`.',
96
252
  inputSchema: {
97
253
  type: "object",
98
254
  properties: {
99
- function: { type: "string", description: "Function name to find callers of" },
100
- repo: { type: "string", description: "Limit search to this repo (optional)" },
101
- fuzzy: { type: "boolean", description: "Substring-match the name instead of exact (default false)" }
255
+ function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
256
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
257
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
258
+ relations: relationsProp("callers")
102
259
  },
103
260
  required: ["function"]
104
261
  }
105
262
  },
106
263
  {
107
264
  name: "find_dependencies",
108
- description: "Find what a function depends on \u2014 calls, imports, type references (exact name match by default).",
265
+ description: 'Find what a symbol calls \u2014 targets of its `calls` edges. The name is matched exactly (case-sensitive; a full "repo::file::entity" id works too) unless fuzzy:true. Type references, inheritance and imports are listed, by relation, only when requested via `relations`.',
109
266
  inputSchema: {
110
267
  type: "object",
111
268
  properties: {
112
- function: { type: "string", description: "Function name" },
113
- repo: { type: "string", description: "Limit to this repo (optional)" },
114
- fuzzy: { type: "boolean", description: "Substring-match the name instead of exact (default false)" }
269
+ function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
270
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
271
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
272
+ relations: relationsProp("dependencies")
115
273
  },
116
274
  required: ["function"]
117
275
  }
118
276
  },
119
277
  {
120
278
  name: "impact_analysis",
121
- description: "Analyze what would be affected if a file or entity is changed. Traces callers, dependents, and cross-repo connections.",
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.",
122
280
  inputSchema: {
123
281
  type: "object",
124
282
  properties: {
125
- file: { type: "string", description: "File path (relative to repo root)" },
126
- entity: { type: "string", description: "Specific entity name (optional \u2014 if omitted, analyzes all entities in the file)" },
127
- repo: { type: "string", description: "Repository name" }
283
+ file: { type: "string", minLength: 1, description: "File path (relative to repo root)" },
284
+ entity: { type: "string", minLength: 1, description: 'Entity name or full "repo::file::entity" id (optional \u2014 if omitted, analyzes everything in the file)' },
285
+ repo: { type: "string", minLength: 1, description: "Repository name" },
286
+ depth: { type: "integer", minimum: 1, maximum: 5, description: "Dependency hops to follow (default 2, max 5)" }
128
287
  },
129
288
  required: ["file", "repo"]
130
289
  }
131
290
  },
132
291
  {
133
292
  name: "trace_path",
134
- description: 'Trace the call chain from a function across multiple hops. With "to", returns the shortest path between two functions; otherwise returns the reachable call tree. Follows confidence-weighted call/import/use edges.',
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.',
135
294
  inputSchema: {
136
295
  type: "object",
137
296
  properties: {
138
- from: { type: "string", description: "Starting function name or qualified id (repo::file::entity)" },
139
- to: { type: "string", description: "Target function name or qualified id (optional \u2014 shortest path)" },
140
- repo: { type: "string", description: "Limit resolution to this repo (optional)" },
297
+ from: { type: "string", minLength: 1, description: "Starting symbol name or qualified id (repo::file::entity)" },
298
+ to: { type: "string", minLength: 1, description: "Target symbol name or qualified id (optional \u2014 shortest path)" },
299
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
141
300
  direction: { type: "string", enum: ["callees", "callers", "both"], description: "Traverse callees (what it calls), callers (who calls it), or both. Default callees." },
142
- maxDepth: { type: "number", description: "Max hops to traverse (default 4)" },
143
- fuzzy: { type: "boolean", description: "Substring-match names instead of exact (default false)" }
301
+ maxDepth: { type: "integer", minimum: 1, maximum: 10, description: "Max hops to traverse (default 4, max 10)" },
302
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
303
+ relations: relationsProp("the traversal")
144
304
  },
145
305
  required: ["from"]
146
306
  }
147
307
  },
148
308
  {
149
309
  name: "find_dead_code",
150
- description: "List entities (functions, methods, classes) with no callers/importers in a repo \u2014 likely dead code. Heuristic: exported APIs, reflection, and dynamic dispatch produce false positives.",
310
+ description: "List entities (functions, methods, classes, structs, enums, traits) with no incoming edge in the repo graph and no other mention of their name in their own file \u2014 likely dead code. Heuristic: exported APIs, reflection, and dynamic dispatch produce false positives.",
151
311
  inputSchema: {
152
312
  type: "object",
153
313
  properties: {
154
- repo: { type: "string", description: "Repository name" },
155
- limit: { type: "number", description: "Max results (default 50)" }
314
+ repo: { type: "string", minLength: 1, description: "Repository name" },
315
+ limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
156
316
  },
157
317
  required: ["repo"]
158
318
  }
@@ -162,59 +322,74 @@ function registerGraphTools() {
162
322
  description: "High-level architecture overview of the project \u2014 repo roles, key flows, and how services connect. Built from the LLM-generated project graph and repo profiles.",
163
323
  inputSchema: {
164
324
  type: "object",
165
- properties: { repo: { type: "string", description: "Drill into one repo profile (optional)" } }
325
+ properties: { repo: { type: "string", minLength: 1, description: "Drill into one repo profile (optional)" } }
166
326
  }
167
327
  },
168
328
  {
169
329
  name: "search_graph",
170
- description: `Structural search over the knowledge graph: filter entities by name (regex/substring), type, file, and repo; rank by connectivity (degree). Answers "which functions matter most in this module" \u2014 a structural question search/grep can't.`,
330
+ description: `Structural search over the knowledge graph: filter nodes by name (case-insensitive regex over symbol names), type, file, and repo; rank by connectivity (degree). Answers "which functions matter most in this module" \u2014 a structural question search/grep can't.`,
171
331
  inputSchema: {
172
332
  type: "object",
173
333
  properties: {
174
- name: { type: "string", description: "Name regex or substring to match (optional)" },
175
- type: { type: "string", description: "Entity type filter: function | class | interface | method | type | \u2026 (optional)" },
176
- file: { type: "string", description: "File-path substring filter (optional)" },
177
- repo: { type: "string", description: "Limit to this repo (optional)" },
178
- minDegree: { type: "number", description: "Only entities with at least this many connections (default 0)" },
179
- limit: { type: "number", description: "Max results (default 50)" }
334
+ name: { type: "string", minLength: 1, description: "Case-insensitive regex over symbol names (a plain substring works too). Files and cross-repo endpoints are skipped unless `type` is module / external. Optional." },
335
+ type: { type: "string", minLength: 1, description: "Node type: function | method | class | interface | type | const | struct | enum | trait | impl | package | module (a file) | external (a cross-repo endpoint). Optional." },
336
+ file: { type: "string", minLength: 1, description: "Case-insensitive file-path substring filter (optional)" },
337
+ repo: { type: "string", minLength: 1, description: "Limit to this repo (optional)" },
338
+ minDegree: { type: "integer", minimum: 0, description: "Only nodes with at least this many connections, `contains` excluded (default 0)" },
339
+ limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
180
340
  }
181
341
  }
182
342
  },
183
343
  {
184
344
  name: "detect_changes",
185
- description: "Map a git diff to affected symbols and their dependents. Compares the working tree against a base commit (or the last-indexed SHA) and reports which entities changed and what depends on them \u2014 a blast-radius / code-review tool. Requires a local repo (local/serve mode).",
345
+ description: "Map a git diff to affected symbols and their dependents. Diffs the working tree against a base commit (default: the last-indexed SHA) and reports the entities whose line range at the base (from their declaration line to the next declaration) overlaps a changed hunk (every entity of an added or deleted file), the cross-repo endpoints of modified files (file-level), and what directly depends on them \u2014 a blast-radius / code-review tool. Requires a local repo (local/serve mode).",
186
346
  inputSchema: {
187
347
  type: "object",
188
348
  properties: {
189
- repo: { type: "string", description: "Repository name" },
190
- baseSha: { type: "string", description: "Git base commit to diff against (default: last-indexed SHA)" },
191
- limit: { type: "number", description: "Max dependent edges to list (default 50)" }
349
+ repo: { type: "string", minLength: 1, description: "Repository name" },
350
+ baseSha: { type: "string", minLength: 1, description: "Git base commit to diff against (default: last-indexed SHA)" },
351
+ limit: { type: "integer", minimum: 1, description: "Max dependent edges to list (default 50)" }
192
352
  },
193
353
  required: ["repo"]
194
354
  }
195
355
  }
196
356
  ];
197
357
  }
358
+ const SCHEMAS = new Map(registerGraphTools().map((t) => [t.name, t.inputSchema]));
198
359
  async function handleGraphTool(name, args, ctx) {
199
- if (!GRAPH_TOOL_NAMES.includes(name)) return null;
200
- const text = (t) => ({ content: [{ type: "text", text: t }] });
360
+ const schema = SCHEMAS.get(name);
361
+ if (!schema) return null;
362
+ const invalid = validateArgs(schema, args);
363
+ if (invalid) return toolError(`${name}: ${invalid}`);
364
+ const text = toolText;
201
365
  const NO_GRAPH = "No system graph found. Build KB first.";
202
366
  try {
203
367
  const blobs = getBlobStore(ctx.projectName, resolvedKnowledgeConfig());
204
368
  if (name === "get_repo_graph") {
205
369
  const repo = args.repo;
370
+ const limit = args.limit ?? 50;
206
371
  const graph = await blobs.getJson(`${repo}/graph.json`);
207
- if (!graph) return text(`No graph found for repo "${repo}"`);
208
- const summary = `# ${repo} AST Graph
209
-
210
- - **Nodes:** ${graph.nodes?.length ?? 0}
211
- - **Edges:** ${graph.links?.length ?? 0}
212
-
213
- ## Entities
214
- ${(graph.nodes ?? []).slice(0, 50).map((n) => `- \`${n.id}\` (${n.type})`).join("\n")}
215
-
216
- ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
217
- return text(summary);
372
+ if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
373
+ const nodes = graph.nodes ?? [];
374
+ const links = graph.links ?? [];
375
+ const byType = /* @__PURE__ */ new Map();
376
+ for (const l of links) byType.set(l.type ?? "edge", (byType.get(l.type ?? "edge") ?? 0) + 1);
377
+ const listed = links.filter((l) => l.type !== "contains");
378
+ const more = (n) => n > limit ? [`... and ${n - limit} more`] : [];
379
+ return text([
380
+ `# ${repo} AST Graph`,
381
+ "",
382
+ `- **Nodes:** ${nodes.length}`,
383
+ `- **Edges:** ${links.length}${byType.size ? ` (${[...byType].map(([t, n]) => `${t} ${n}`).join(", ")})` : ""}`,
384
+ "",
385
+ "## Entities",
386
+ ...nodes.slice(0, limit).map((n) => `- \`${n.id}\` (${n.type})`),
387
+ ...more(nodes.length),
388
+ "",
389
+ `## Edges (${listed.length}, \`contains\` omitted)`,
390
+ ...listed.slice(0, limit).map((l) => `- \`${l.source}\` \u2192 \`${l.target}\` (${l.type ?? "edge"})`),
391
+ ...more(listed.length)
392
+ ].join("\n"));
218
393
  }
219
394
  if (name === "get_cross_repo_edges") {
220
395
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -222,14 +397,28 @@ ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
222
397
  const repo = args.repo;
223
398
  const { edges, total } = store.crossRepoEdges(repo, 50);
224
399
  if (total === 0) return text(repo ? `No cross-repo edges found for "${repo}"` : "No cross-repo edges found");
225
- const body = edges.map((e) => `- ${repoOf(e.source)} \u2192 ${repoOf(e.target)} (${e.type ?? "edge"})`).join("\n");
226
- return text(`# Cross-Repo Edges${repo ? ` for ${repo}` : ""}
227
-
228
- ${total} edges found:
229
-
230
- ${body}${total > edges.length ? `
231
-
232
- ... and ${total - edges.length} more` : ""}`);
400
+ const between = edges.filter((e) => repoOf(e.source) !== repoOf(e.target));
401
+ const within = edges.filter((e) => repoOf(e.source) === repoOf(e.target));
402
+ const ev = (e) => e.evidence ? ` \u2014 ${e.evidence}` : "";
403
+ const labels = store.labelsOf(within.flatMap((e) => [e.source, e.target]));
404
+ const body = [
405
+ `# Cross-Repo Edges${repo ? ` for ${repo}` : ""}`,
406
+ "",
407
+ `${total} edges found:`
408
+ ];
409
+ if (between.length) {
410
+ body.push("", `## Between repos (${between.length} listed)`);
411
+ for (const e of between) {
412
+ const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
413
+ body.push(`- ${repoOf(e.source)} ${arrow} ${repoOf(e.target)} (${e.type ?? "edge"})${ev(e)} \`${fileOf(e.source)}\` ${arrow} \`${fileOf(e.target)}\``);
414
+ }
415
+ }
416
+ if (within.length) {
417
+ body.push("", `## Within a repo \u2014 workspace packages (${within.length} listed)`);
418
+ for (const e of within) body.push(`- ${repoOf(e.source)}: ${labels.get(e.source)} \u2192 ${labels.get(e.target)} (${e.type ?? "edge"})${ev(e)}`);
419
+ }
420
+ if (total > edges.length) body.push("", `... and ${total - edges.length} more`);
421
+ return text(body.join("\n"));
233
422
  }
234
423
  if (name === "find_callers" || name === "find_dependencies") {
235
424
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -237,45 +426,83 @@ ${body}${total > edges.length ? `
237
426
  const funcName = args.function;
238
427
  const repoFilter = args.repo;
239
428
  const fuzzy = args.fuzzy === true;
429
+ const types = edgeTypes(args);
240
430
  const matched = store.resolveNodes(funcName, repoFilter, fuzzy);
241
431
  if (matched.length === 0) {
242
- return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}. Try fuzzy:true for substring matching.`);
432
+ return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}`);
243
433
  }
244
434
  const keys = matched.map((m) => m.key);
245
- const unique = name === "find_callers" ? store.callers(keys, 30) : store.dependencies(keys, 30);
246
- const direction = name === "find_callers" ? "Callers of" : "Dependencies of";
247
- if (unique.length === 0) {
248
- return text(`# ${direction} "${funcName}"
435
+ const fetch = (t) => name === "find_callers" ? store.callers(keys, 30, t) : store.dependencies(keys, 30, t);
436
+ const found = fetch(types);
437
+ const noun = name === "find_callers" ? "callers" : "dependencies";
438
+ const title = `# ${name === "find_callers" ? "Callers of" : "Dependencies of"} "${funcName}"`;
439
+ const relLabel = types ? types.join(", ") : "any relation";
440
+ if (found.length === 0) {
441
+ const other = /* @__PURE__ */ new Map();
442
+ if (types) for (const o of fetch(void 0)) other.set(o.type, (other.get(o.type) ?? 0) + 1);
443
+ const hint = other.size ? `
444
+
445
+ Other relations: ${[...other].map(([t, n]) => `${t} ${n}`).join(", ")} \u2014 pass relations: ${JSON.stringify([...other.keys()])} to list them.` : "";
446
+ return text(`${title}
249
447
 
250
- Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${name === "find_callers" ? "callers" : "dependencies"} found.`);
448
+ Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}`);
251
449
  }
252
- return text(`# ${direction} "${funcName}"
450
+ const byType = /* @__PURE__ */ new Map();
451
+ for (const f of found) {
452
+ if (!byType.has(f.type)) byType.set(f.type, []);
453
+ byType.get(f.type).push(f.key);
454
+ }
455
+ const sections = [...byType].map(([t, ks]) => `## ${t} (${ks.length})
456
+ ${ks.map((k) => `- \`${k}\``).join("\n")}`).join("\n\n");
457
+ return text(`${title}
458
+
459
+ ${found.length} found (${relLabel}):
253
460
 
254
- ${unique.length} found:
255
- ${unique.map((r) => `- \`${r}\``).join("\n")}`);
461
+ ${sections}`);
256
462
  }
257
463
  if (name === "impact_analysis") {
258
464
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
259
465
  if (!store) return text("No system graph found.");
260
466
  const file = args.file;
261
467
  const repo = args.repo;
262
- const entity = args.entity;
263
- const fileKeys = store.nodesInFiles(repo, [file], entity);
264
- const { edges: dependents, total, repos } = store.dependents(fileKeys, 30);
468
+ const depth = args.depth ?? 2;
469
+ let entity = args.entity;
470
+ if (entity?.includes("::")) {
471
+ const prefix = `${repo}::${file}::`;
472
+ if (!entity.startsWith(prefix)) return toolError(`impact_analysis: entity id "${entity}" is not in ${repo}/${file} \u2014 pass a bare name or an id starting with "${prefix}".`);
473
+ entity = entity.slice(prefix.length);
474
+ }
475
+ const fileKeys = store.nodesInFiles(repo, [file]);
476
+ if (fileKeys.length === 0) return toolError(`No graph nodes for ${repo}/${file} \u2014 check the repo name and that the path is repo-relative and indexed.`);
477
+ const scope = entity ? store.nodesInFiles(repo, [file], entity) : fileKeys;
478
+ if (scope.length === 0) return toolError(`No entity "${entity}" in ${repo}/${file}.`);
479
+ const endpoints = entity ? endpointsIn(store, fileKeys).filter((k) => !scope.includes(k)) : [];
480
+ const fileLevel = new Set(endpoints);
481
+ const budget = new TraversalBudget();
482
+ const { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel, budget);
483
+ const total = hops.reduce((n, h) => n + h.total, 0);
484
+ const truncated = hops.some((h) => h.truncated);
265
485
  const body = [
266
- `# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""}`,
267
- "",
268
- `## Entities in scope: ${fileKeys.length}`,
269
- ...fileKeys.slice(0, 20).map((k) => `- \`${k}\``),
486
+ `# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""} (depth ${depth})`,
270
487
  "",
271
- `## Dependents: ${total} edges from ${repos.length} repos`,
272
- ...dependents.map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${e.type ?? "edge"})`),
273
- total > dependents.length ? `
274
- ... and ${total - dependents.length} more` : "",
275
- "",
276
- `## Affected repos: ${repos.join(", ") || "none"}`
277
- ].join("\n");
278
- return text(body);
488
+ `## Entities in scope: ${scope.length}`,
489
+ ...scope.slice(0, 20).map((k) => `- \`${k}\``)
490
+ ];
491
+ if (endpoints.length) {
492
+ body.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in ${file}, not tied to ${entity})`, ...endpoints.slice(0, 20).map((k) => `- \`${k}\``));
493
+ }
494
+ body.push("", `## Dependents: ${total}${atLeast(truncated)} edges from ${repos.size} repos`);
495
+ for (const h of hops) {
496
+ if (h.edges.length === 0) continue;
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._`);
503
+ }
504
+ body.push("", `## Affected repos: ${[...repos].join(", ") || "none"}`);
505
+ return text(body.join("\n"));
279
506
  }
280
507
  if (name === "trace_path") {
281
508
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -285,14 +512,18 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}`);
285
512
  const repo = args.repo;
286
513
  const fuzzy = args.fuzzy === true;
287
514
  const direction = args.direction ?? "callees";
288
- const maxDepth = Math.max(1, Math.min(10, args.maxDepth || 4));
515
+ const maxDepth = args.maxDepth ?? 4;
289
516
  const fromKeys = store.resolveNodes(from, repo, fuzzy).map((m) => m.key);
290
- if (fromKeys.length === 0) return text(`No entity found matching "${from}". Try fuzzy:true.`);
291
- const getNeighbors = makeNeighborGetter(store, direction);
517
+ if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
518
+ const types = edgeTypes(args);
519
+ const budget = new TraversalBudget();
292
520
  if (to) {
293
- const toKeys = new Set(store.resolveNodes(to, repo, fuzzy).map((m) => m.key));
294
- if (toKeys.size === 0) return text(`No entity found matching target "${to}". Try fuzzy:true.`);
295
- 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
+ }
296
527
  if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).`);
297
528
  const labelOf2 = store.labelsOf(path);
298
529
  const arrow = direction === "callers" ? " \u2190 " : " \u2192 ";
@@ -303,8 +534,11 @@ ${rendered}
303
534
 
304
535
  ${path.map((k) => `- \`${k}\``).join("\n")}`);
305
536
  }
306
- const nodes = reachable(getNeighbors, fromKeys, maxDepth);
307
- 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}`);
308
542
  const labelOf = store.labelsOf(nodes.map((n) => n.key));
309
543
  const byDepth = /* @__PURE__ */ new Map();
310
544
  for (const { key, depth } of nodes) {
@@ -317,20 +551,25 @@ ${ks.map((k) => `- ${k}`).join("\n")}`).join("\n\n");
317
551
 
318
552
  ${nodes.length} nodes:
319
553
 
320
- ${sections}`);
554
+ ${sections}${stopped}`);
321
555
  }
322
556
  if (name === "find_dead_code") {
323
557
  const repo = args.repo;
324
- const limit = args.limit || 50;
558
+ const limit = args.limit ?? 50;
325
559
  const graph = await blobs.getJson(`${repo}/graph.json`);
326
- if (!graph) return text(`No graph found for repo "${repo}"`);
560
+ if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
327
561
  const ENTITY_TYPES = /* @__PURE__ */ new Set(["function", "method", "class", "struct", "enum", "trait"]);
328
562
  const inDegree = /* @__PURE__ */ new Map();
329
563
  for (const l of graph.links ?? []) {
330
564
  if (l.type === "contains") continue;
331
565
  inDegree.set(l.target, (inDegree.get(l.target) ?? 0) + 1);
332
566
  }
333
- const dead = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
567
+ const candidates = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
568
+ const source = await indexedSources(ctx.projectName, repo, [...new Set(candidates.map((n) => n.file).filter(Boolean))]);
569
+ const dead = candidates.filter((n) => {
570
+ const src = source.get(n.file);
571
+ return src === void 0 || identifierCount(src, n.label ?? n.id) <= 1;
572
+ });
334
573
  if (dead.length === 0) return text(`# Dead code in ${repo}
335
574
 
336
575
  No zero-caller entities found.`);
@@ -349,7 +588,7 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
349
588
  const repo = args.repo;
350
589
  if (repo) {
351
590
  const profile = await loadProfile(ctx.projectName, repo, resolvedKnowledgeConfig());
352
- if (!profile) return text(`No profile for "${repo}". Run profiling (requires LLM) to generate one.`);
591
+ if (!profile) return text(missingProfileText(repo));
353
592
  const ep = (xs) => xs?.length ? xs.map((e) => ` - ${e.type}: ${e.identifier} \u2014 ${e.description}`).join("\n") : " - (none)";
354
593
  const body = [
355
594
  `# ${profile.name} \u2014 ${profile.role} (${profile.domain})`,
@@ -397,9 +636,9 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
397
636
 
398
637
  ${profiles.map((p) => `- **${p.name}** \u2014 ${p.role} (${p.domain}): ${p.description}`).join("\n")}
399
638
 
400
- _Run project-graph generation (requires LLM) for a full architecture view._`);
639
+ _A full architecture view needs the LLM project graph (\`anvil graph build\`)._`);
401
640
  }
402
- return text("No architecture data yet. Index the project, then run profiling / project-graph generation (requires an LLM) for an architecture overview.");
641
+ return text("No architecture data yet: it needs repo profiles (generated during indexing when an LLM is configured) or the LLM project graph (`anvil graph build`).");
403
642
  }
404
643
  if (name === "search_graph") {
405
644
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -410,7 +649,7 @@ _Run project-graph generation (requires LLM) for a full architecture view._`);
410
649
  file: args.file,
411
650
  repo: args.repo,
412
651
  minDegree: args.minDegree ?? 0
413
- }, args.limit || 50);
652
+ }, args.limit ?? 50);
414
653
  if (total === 0) return text("No entities match the given filters.");
415
654
  const body = rows.map((m) => `- \`${m.label}\` (${m.type}, ${m.file}) \u2014 degree ${m.degree} \`${m.key}\``).join("\n");
416
655
  return text(`# search_graph \u2014 ${total} match${total === 1 ? "" : "es"}
@@ -421,13 +660,13 @@ ${body}${total > rows.length ? `
421
660
  }
422
661
  if (name === "detect_changes") {
423
662
  const repo = args.repo;
424
- const limit = args.limit || 50;
663
+ const limit = args.limit ?? 50;
425
664
  if (!ctx.directoryPath) {
426
- return text("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
665
+ return toolError("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
427
666
  }
428
667
  const candidate = join(ctx.directoryPath, repo);
429
668
  const repoPath = existsSync(join(candidate, ".git")) ? candidate : ctx.directoryPath;
430
- if (!existsSync(repoPath)) return text(`Repo path not found: ${repoPath}`);
669
+ if (!existsSync(repoPath)) return toolError(`Repo path not found: ${repoPath}`);
431
670
  let baseSha = args.baseSha;
432
671
  if (!baseSha) {
433
672
  try {
@@ -436,43 +675,87 @@ ${body}${total > rows.length ? `
436
675
  } catch {
437
676
  }
438
677
  }
439
- if (!baseSha) return text("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
678
+ if (!baseSha) return toolError("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
440
679
  const diff = getAllChanges(repoPath, baseSha);
441
- if (diff.fallbackToFull) return text(`git diff against ${baseSha.slice(0, 7)} failed or is too large to map incrementally. Check the base SHA.`);
680
+ if (diff.fallbackToFull) return toolError(`git diff against ${baseSha.slice(0, 7)} failed or is too large to map incrementally. Check the base SHA.`);
442
681
  const changedFiles = getChangedFilesList(diff);
443
682
  if (changedFiles.length === 0 && diff.deleted.length === 0) return text(`No source changes since ${baseSha.slice(0, 7)}.`);
444
683
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
445
- let changedKeys = [];
446
- let dependents = [];
447
- let depTotal = 0;
448
- let depRepos = [];
684
+ const changedKeys = [];
685
+ const endpoints = [];
686
+ const wholeFile = [];
687
+ const unlocated = /* @__PURE__ */ new Set();
688
+ let hops = [];
689
+ let depRepos = /* @__PURE__ */ new Set();
690
+ const fileLevel = /* @__PURE__ */ new Set();
691
+ let budget = null;
449
692
  if (store) {
450
- changedKeys = store.nodesInFiles(repo, changedFiles);
451
- const d = store.dependents(changedKeys, limit);
452
- dependents = d.edges;
453
- depTotal = d.total;
454
- depRepos = d.repos;
693
+ changedKeys.push(...store.nodesInFiles(repo, [...diff.added, ...diff.deleted]));
694
+ const hunks = diffHunks(repoPath, baseSha, diff.modified);
695
+ for (const file of diff.modified) {
696
+ const fileKeys = store.nodesInFiles(repo, [file]);
697
+ const types = store.nodeTypes(fileKeys);
698
+ const modKey = `${repo}::${file}`;
699
+ const entities = new Map(fileKeys.filter((k) => k.startsWith(`${modKey}::`) && !["module", "external", "package"].includes(types.get(k) ?? "")).map((k) => [k.slice(modKey.length + 2), k]));
700
+ endpoints.push(...fileKeys.filter((k) => types.get(k) === "external"));
701
+ let base;
702
+ try {
703
+ base = runGit(repoPath, ["show", `${baseSha}:${file}`]);
704
+ } catch {
705
+ wholeFile.push(file);
706
+ changedKeys.push(...fileKeys.filter((k) => types.get(k) !== "external"));
707
+ continue;
708
+ }
709
+ const ranges = declarationRanges(base, [...entities.keys()]);
710
+ const { names, fileLevel: touchesFile } = touchedEntities(ranges, hunks.get(file) ?? []);
711
+ if (touchesFile && fileKeys.includes(modKey)) changedKeys.push(modKey);
712
+ const located = new Set(ranges.map((r) => r.name));
713
+ for (const [name2, key] of entities) {
714
+ if (names.has(name2)) changedKeys.push(key);
715
+ else if (!located.has(name2)) {
716
+ changedKeys.push(key);
717
+ unlocated.add(key);
718
+ }
719
+ }
720
+ }
721
+ for (const k of endpoints) fileLevel.add(k);
722
+ budget = new TraversalBudget();
723
+ ({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel, budget));
455
724
  }
725
+ const dependents = hops[0]?.edges ?? [];
726
+ const depTotal = hops[0]?.total ?? 0;
727
+ const plus = atLeast(hops[0]?.truncated ?? false);
456
728
  const lines = [
457
729
  `# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
458
730
  "",
459
- `## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length}), deleted ${diff.deleted.length}`,
460
- ...changedFiles.slice(0, 30).map((f) => `- ${f}`),
731
+ `## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})`,
732
+ ...changedFiles.slice(0, 30).map((f) => `- ${f}${wholeFile.includes(f) ? " (not readable at the base \u2014 every entity in the file)" : ""}`),
733
+ "",
734
+ `## Deleted files: ${diff.deleted.length}`,
735
+ ...diff.deleted.slice(0, 30).map((f) => `- ${f}`),
461
736
  "",
462
737
  `## Affected entities: ${changedKeys.length}`,
463
- ...changedKeys.slice(0, 30).map((k) => `- \`${k}\``),
738
+ ...changedKeys.slice(0, 30).map((k) => `- \`${k}\`${unlocated.has(k) ? " (declaration not found in the file \u2014 included to be safe)" : ""}`)
739
+ ];
740
+ if (endpoints.length) {
741
+ lines.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in modified files)`, ...endpoints.slice(0, 30).map((k) => `- \`${k}\``));
742
+ }
743
+ lines.push(
464
744
  "",
465
- `## Dependents: ${depTotal} edges from ${depRepos.length} repo(s)`,
466
- ...dependents.map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${e.type ?? "edge"})`),
745
+ `## Dependents: ${depTotal}${plus} edges from ${depRepos.size} repo(s)`,
746
+ ...dependents.slice(0, limit).map((e) => edgeLine(e, fileLevel)),
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._`] : [],
467
750
  "",
468
- `## Affected repos: ${depRepos.join(", ") || "none"}`
469
- ];
751
+ `## Affected repos: ${[...depRepos].join(", ") || "none"}`
752
+ );
470
753
  return text(lines.join("\n"));
471
754
  }
472
755
  return null;
473
756
  } catch (err) {
474
757
  const msg = err instanceof Error ? err.message : String(err);
475
- return { content: [{ type: "text", text: `Graph tool error: ${msg}` }] };
758
+ return toolError(`Graph tool error: ${msg}`);
476
759
  }
477
760
  }
478
761
  export {