@esankhan3/code-search-mcp 1.0.6 → 1.1.0

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 (41) 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__/http-auth.test.js +180 -0
  10. package/dist/__tests__/http-index.test.js +125 -0
  11. package/dist/__tests__/metrics-collect.test.js +48 -0
  12. package/dist/__tests__/observability.test.js +3 -4
  13. package/dist/__tests__/remote-proxy.test.js +105 -0
  14. package/dist/__tests__/server-cli-args.test.js +146 -0
  15. package/dist/__tests__/server-lifecycle.test.js +108 -0
  16. package/dist/__tests__/tool-inputs.test.js +178 -0
  17. package/dist/backends/in-process.js +5 -2
  18. package/dist/backends/index.js +2 -2
  19. package/dist/cli/index.js +143 -76
  20. package/dist/core/config.js +106 -35
  21. package/dist/core/env-config.js +3 -5
  22. package/dist/core/project-name.js +12 -0
  23. package/dist/core/version.js +5 -0
  24. package/dist/daemon/index.js +106 -65
  25. package/dist/daemon/rpc-server.js +38 -18
  26. package/dist/index.js +113 -89
  27. package/dist/middleware/auth.js +34 -66
  28. package/dist/middleware/rate-limit.js +37 -0
  29. package/dist/observability/logger.js +10 -2
  30. package/dist/observability/metrics.js +7 -6
  31. package/dist/projects/registry.js +13 -15
  32. package/dist/resources/resources.js +17 -0
  33. package/dist/server.js +216 -129
  34. package/dist/tools/graph.js +353 -133
  35. package/dist/tools/index-tools.js +3 -2
  36. package/dist/tools/profile.js +14 -8
  37. package/dist/tools/search.js +61 -51
  38. package/dist/tools/validate.js +48 -0
  39. package/dist/transports/http-transport.js +94 -55
  40. package/dist/transports/remote-proxy.js +58 -97
  41. package/package.json +1 -1
@@ -1,49 +1,42 @@
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) {
19
+ const fileOf = (key) => key.split("::")[1] ?? "";
20
+ function makeNeighborGetter(store, direction, types) {
26
21
  const cache = /* @__PURE__ */ new Map();
27
22
  return (key) => {
28
23
  let v = cache.get(key);
29
24
  if (v === void 0) {
30
- v = store.neighborsOf(key, direction);
25
+ v = store.neighborsOf(key, direction, types);
31
26
  cache.set(key, v);
32
27
  }
33
28
  return v;
34
29
  };
35
30
  }
36
31
  function shortestPath(getNeighbors, fromKeys, toKeys, maxDepth) {
37
- const seed = fromKeys.find((k) => toKeys.has(k));
38
- if (seed) return [seed];
39
32
  const visited = new Set(fromKeys);
40
33
  let frontier = fromKeys.map((k) => [k]);
41
34
  for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
42
35
  const next = [];
43
36
  for (const path of frontier) {
44
37
  for (const nb of getNeighbors(path[path.length - 1])) {
38
+ if (toKeys.has(nb) && nb !== path[0]) return [...path, nb];
45
39
  if (visited.has(nb)) continue;
46
- if (toKeys.has(nb)) return [...path, nb];
47
40
  visited.add(nb);
48
41
  next.push([...path, nb]);
49
42
  }
@@ -71,88 +64,210 @@ function reachable(getNeighbors, fromKeys, maxDepth, cap = 50) {
71
64
  }
72
65
  return out;
73
66
  }
67
+ function endpointsIn(store, keys) {
68
+ const types = store.nodeTypes(keys);
69
+ return keys.filter((k) => types.get(k) === "external");
70
+ }
71
+ const HOP_EDGE_CAP = 1e3;
72
+ function dependentHops(store, scope, depth, fileLevel) {
73
+ const visited = new Set(scope);
74
+ const repos = /* @__PURE__ */ new Set();
75
+ const hops = [];
76
+ let frontier = [...scope];
77
+ for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
78
+ const d = store.dependents(frontier, HOP_EDGE_CAP);
79
+ const edges = d.edges.filter((e) => !visited.has(e.source));
80
+ const direct = /* @__PURE__ */ new Set();
81
+ const next = [];
82
+ for (const e of edges) {
83
+ repos.add(repoOf(e.source));
84
+ if (!fileLevel.has(e.target)) direct.add(e.source);
85
+ if (!visited.has(e.source)) {
86
+ visited.add(e.source);
87
+ next.push(e.source);
88
+ }
89
+ }
90
+ 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) });
92
+ frontier = next;
93
+ }
94
+ return { hops, repos };
95
+ }
96
+ function edgeLine(e, fileLevel) {
97
+ const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
98
+ return `- \`${e.source}\` ${arrow} \`${e.target}\` (${e.type ?? "edge"})${e.evidence ? ` \u2014 ${e.evidence}` : ""}${fileLevel.has(e.target) ? " [file-level]" : ""}`;
99
+ }
100
+ async function indexedSources(project, repo, files) {
101
+ const cfg = resolvedKnowledgeConfig();
102
+ const parts = /* @__PURE__ */ new Map();
103
+ try {
104
+ const vs = (await getRetriever(project, cfg)).vectorStore;
105
+ await Promise.all(files.map(async (f) => {
106
+ const chunks = await vs.getChunksByFile(repo, f);
107
+ if (chunks.length > 0) parts.set(f, chunks.map((c) => c.content));
108
+ }));
109
+ } catch {
110
+ }
111
+ const missing = new Set(files.filter((f) => !parts.has(f)));
112
+ if (missing.size > 0 && await getBlobStore(project, cfg).exists("chunks.json")) {
113
+ const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg);
114
+ for (const c of found) {
115
+ if (!parts.has(c.filePath)) parts.set(c.filePath, []);
116
+ parts.get(c.filePath).push(c.content);
117
+ }
118
+ }
119
+ return new Map([...parts].map(([f, cs]) => [f, cs.join("\n")]));
120
+ }
121
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
122
+ function identifierCount(src, name) {
123
+ return src.match(new RegExp(`(?<![\\w$])${escapeRegex(name)}(?![\\w$])`, "g"))?.length ?? 0;
124
+ }
125
+ const runGit = (repoPath, args) => execFileSync("git", args, {
126
+ cwd: repoPath,
127
+ encoding: "utf-8",
128
+ maxBuffer: 64 * 1024 * 1024,
129
+ stdio: ["pipe", "pipe", "pipe"]
130
+ });
131
+ function diffHunks(repoPath, baseSha, files) {
132
+ const out = /* @__PURE__ */ new Map();
133
+ if (files.length === 0) return out;
134
+ let file = "";
135
+ for (const line of runGit(repoPath, ["diff", "-U0", "--no-color", "--no-ext-diff", baseSha, "--", ...files]).split("\n")) {
136
+ if (line.startsWith("+++ ")) {
137
+ file = line.slice(4).replace(/^b\//, "");
138
+ continue;
139
+ }
140
+ const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
141
+ if (m && file) {
142
+ if (!out.has(file)) out.set(file, []);
143
+ out.get(file).push([Number(m[1]), m[2] === void 0 ? 1 : Number(m[2])]);
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+ function declarationRanges(src, names) {
149
+ const lines = src.split("\n");
150
+ const starts = [];
151
+ for (const name of names) {
152
+ const n = escapeRegex(name);
153
+ const keyword = new RegExp(`\\b(?:function\\*?|class|interface|type|enum|struct|trait|impl|def|func|fn|const|let|var)\\s+(?:\\([^)]*\\)\\s*)?${n}(?![\\w$])`);
154
+ const method = new RegExp(`^\\s*(?:(?:public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|export|default)\\s+)*(?:[\\w$<>\\[\\],.?]+\\s+)?${n}\\s*(?:<[^>]*>)?\\(`);
155
+ let found = lines.flatMap((l, i) => keyword.test(l) ? [i + 1] : []);
156
+ if (found.length === 0) found = lines.flatMap((l, i) => method.test(l) && !l.trimEnd().endsWith(";") ? [i + 1] : []);
157
+ for (const line of found) starts.push({ name, line });
158
+ }
159
+ starts.sort((a, b) => a.line - b.line);
160
+ return starts.map((s, i) => ({ name: s.name, start: s.line, end: (starts[i + 1]?.line ?? lines.length + 1) - 1 }));
161
+ }
162
+ function touchedEntities(ranges, hunks) {
163
+ const names = /* @__PURE__ */ new Set();
164
+ let fileLevel = false;
165
+ for (const [start, count] of hunks) {
166
+ const hit = ranges.filter((r) => count > 0 ? r.start <= start + count - 1 && r.end >= start : r.start <= start && r.end >= start + 1);
167
+ if (hit.length === 0) fileLevel = true;
168
+ for (const r of hit) names.add(r.name);
169
+ }
170
+ return { names, fileLevel };
171
+ }
172
+ const RELATIONS = ["calls", "imports", "uses", "type-ref", "inherits", "all"];
173
+ const relationsProp = (what) => ({
174
+ type: "array",
175
+ items: { type: "string", enum: RELATIONS },
176
+ 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)`
177
+ });
178
+ function edgeTypes(args) {
179
+ const r = args.relations ?? ["calls"];
180
+ return r.includes("all") ? void 0 : r;
181
+ }
74
182
  function registerGraphTools() {
75
183
  return [
76
184
  {
77
185
  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).",
186
+ 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
187
  inputSchema: {
80
188
  type: "object",
81
- properties: { repo: { type: "string", description: "Repository name" } },
189
+ properties: {
190
+ repo: { type: "string", minLength: 1, description: "Repository name" },
191
+ limit: { type: "integer", minimum: 1, description: "Max entities and max edges listed (default 50)" }
192
+ },
82
193
  required: ["repo"]
83
194
  }
84
195
  },
85
196
  {
86
197
  name: "get_cross_repo_edges",
87
- description: "Get connections between repos \u2014 shared deps, Kafka topics, HTTP routes, database tables, gRPC services, etc.",
198
+ 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
199
  inputSchema: {
89
200
  type: "object",
90
- properties: { repo: { type: "string", description: "Filter to edges involving this repo (optional)" } }
201
+ properties: { repo: { type: "string", minLength: 1, description: "Only edges involving this repo (optional)" } }
91
202
  }
92
203
  },
93
204
  {
94
205
  name: "find_callers",
95
- description: "Find all functions/methods that call a given function (exact name match by default). Uses the AST graph.",
206
+ 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
207
  inputSchema: {
97
208
  type: "object",
98
209
  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)" }
210
+ function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
211
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
212
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
213
+ relations: relationsProp("callers")
102
214
  },
103
215
  required: ["function"]
104
216
  }
105
217
  },
106
218
  {
107
219
  name: "find_dependencies",
108
- description: "Find what a function depends on \u2014 calls, imports, type references (exact name match by default).",
220
+ 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
221
  inputSchema: {
110
222
  type: "object",
111
223
  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)" }
224
+ function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
225
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
226
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
227
+ relations: relationsProp("dependencies")
115
228
  },
116
229
  required: ["function"]
117
230
  }
118
231
  },
119
232
  {
120
233
  name: "impact_analysis",
121
- description: "Analyze what would be affected if a file or entity is changed. Traces callers, dependents, and cross-repo connections.",
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.",
122
235
  inputSchema: {
123
236
  type: "object",
124
237
  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" }
238
+ file: { type: "string", minLength: 1, description: "File path (relative to repo root)" },
239
+ entity: { type: "string", minLength: 1, description: 'Entity name or full "repo::file::entity" id (optional \u2014 if omitted, analyzes everything in the file)' },
240
+ repo: { type: "string", minLength: 1, description: "Repository name" },
241
+ depth: { type: "integer", minimum: 1, maximum: 5, description: "Dependency hops to follow (default 2, max 5)" }
128
242
  },
129
243
  required: ["file", "repo"]
130
244
  }
131
245
  },
132
246
  {
133
247
  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.',
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.',
135
249
  inputSchema: {
136
250
  type: "object",
137
251
  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)" },
252
+ from: { type: "string", minLength: 1, description: "Starting symbol name or qualified id (repo::file::entity)" },
253
+ to: { type: "string", minLength: 1, description: "Target symbol name or qualified id (optional \u2014 shortest path)" },
254
+ repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
141
255
  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)" }
256
+ maxDepth: { type: "integer", minimum: 1, maximum: 10, description: "Max hops to traverse (default 4, max 10)" },
257
+ fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
258
+ relations: relationsProp("the traversal")
144
259
  },
145
260
  required: ["from"]
146
261
  }
147
262
  },
148
263
  {
149
264
  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.",
265
+ 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
266
  inputSchema: {
152
267
  type: "object",
153
268
  properties: {
154
- repo: { type: "string", description: "Repository name" },
155
- limit: { type: "number", description: "Max results (default 50)" }
269
+ repo: { type: "string", minLength: 1, description: "Repository name" },
270
+ limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
156
271
  },
157
272
  required: ["repo"]
158
273
  }
@@ -162,59 +277,74 @@ function registerGraphTools() {
162
277
  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
278
  inputSchema: {
164
279
  type: "object",
165
- properties: { repo: { type: "string", description: "Drill into one repo profile (optional)" } }
280
+ properties: { repo: { type: "string", minLength: 1, description: "Drill into one repo profile (optional)" } }
166
281
  }
167
282
  },
168
283
  {
169
284
  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.`,
285
+ 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
286
  inputSchema: {
172
287
  type: "object",
173
288
  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)" }
289
+ 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." },
290
+ 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." },
291
+ file: { type: "string", minLength: 1, description: "Case-insensitive file-path substring filter (optional)" },
292
+ repo: { type: "string", minLength: 1, description: "Limit to this repo (optional)" },
293
+ minDegree: { type: "integer", minimum: 0, description: "Only nodes with at least this many connections, `contains` excluded (default 0)" },
294
+ limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
180
295
  }
181
296
  }
182
297
  },
183
298
  {
184
299
  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).",
300
+ 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
301
  inputSchema: {
187
302
  type: "object",
188
303
  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)" }
304
+ repo: { type: "string", minLength: 1, description: "Repository name" },
305
+ baseSha: { type: "string", minLength: 1, description: "Git base commit to diff against (default: last-indexed SHA)" },
306
+ limit: { type: "integer", minimum: 1, description: "Max dependent edges to list (default 50)" }
192
307
  },
193
308
  required: ["repo"]
194
309
  }
195
310
  }
196
311
  ];
197
312
  }
313
+ const SCHEMAS = new Map(registerGraphTools().map((t) => [t.name, t.inputSchema]));
198
314
  async function handleGraphTool(name, args, ctx) {
199
- if (!GRAPH_TOOL_NAMES.includes(name)) return null;
200
- const text = (t) => ({ content: [{ type: "text", text: t }] });
315
+ const schema = SCHEMAS.get(name);
316
+ if (!schema) return null;
317
+ const invalid = validateArgs(schema, args);
318
+ if (invalid) return toolError(`${name}: ${invalid}`);
319
+ const text = toolText;
201
320
  const NO_GRAPH = "No system graph found. Build KB first.";
202
321
  try {
203
322
  const blobs = getBlobStore(ctx.projectName, resolvedKnowledgeConfig());
204
323
  if (name === "get_repo_graph") {
205
324
  const repo = args.repo;
325
+ const limit = args.limit ?? 50;
206
326
  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);
327
+ if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
328
+ const nodes = graph.nodes ?? [];
329
+ const links = graph.links ?? [];
330
+ const byType = /* @__PURE__ */ new Map();
331
+ for (const l of links) byType.set(l.type ?? "edge", (byType.get(l.type ?? "edge") ?? 0) + 1);
332
+ const listed = links.filter((l) => l.type !== "contains");
333
+ const more = (n) => n > limit ? [`... and ${n - limit} more`] : [];
334
+ return text([
335
+ `# ${repo} AST Graph`,
336
+ "",
337
+ `- **Nodes:** ${nodes.length}`,
338
+ `- **Edges:** ${links.length}${byType.size ? ` (${[...byType].map(([t, n]) => `${t} ${n}`).join(", ")})` : ""}`,
339
+ "",
340
+ "## Entities",
341
+ ...nodes.slice(0, limit).map((n) => `- \`${n.id}\` (${n.type})`),
342
+ ...more(nodes.length),
343
+ "",
344
+ `## Edges (${listed.length}, \`contains\` omitted)`,
345
+ ...listed.slice(0, limit).map((l) => `- \`${l.source}\` \u2192 \`${l.target}\` (${l.type ?? "edge"})`),
346
+ ...more(listed.length)
347
+ ].join("\n"));
218
348
  }
219
349
  if (name === "get_cross_repo_edges") {
220
350
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -222,14 +352,28 @@ ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
222
352
  const repo = args.repo;
223
353
  const { edges, total } = store.crossRepoEdges(repo, 50);
224
354
  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` : ""}`);
355
+ const between = edges.filter((e) => repoOf(e.source) !== repoOf(e.target));
356
+ const within = edges.filter((e) => repoOf(e.source) === repoOf(e.target));
357
+ const ev = (e) => e.evidence ? ` \u2014 ${e.evidence}` : "";
358
+ const labels = store.labelsOf(within.flatMap((e) => [e.source, e.target]));
359
+ const body = [
360
+ `# Cross-Repo Edges${repo ? ` for ${repo}` : ""}`,
361
+ "",
362
+ `${total} edges found:`
363
+ ];
364
+ if (between.length) {
365
+ body.push("", `## Between repos (${between.length} listed)`);
366
+ for (const e of between) {
367
+ const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
368
+ body.push(`- ${repoOf(e.source)} ${arrow} ${repoOf(e.target)} (${e.type ?? "edge"})${ev(e)} \`${fileOf(e.source)}\` ${arrow} \`${fileOf(e.target)}\``);
369
+ }
370
+ }
371
+ if (within.length) {
372
+ body.push("", `## Within a repo \u2014 workspace packages (${within.length} listed)`);
373
+ for (const e of within) body.push(`- ${repoOf(e.source)}: ${labels.get(e.source)} \u2192 ${labels.get(e.target)} (${e.type ?? "edge"})${ev(e)}`);
374
+ }
375
+ if (total > edges.length) body.push("", `... and ${total - edges.length} more`);
376
+ return text(body.join("\n"));
233
377
  }
234
378
  if (name === "find_callers" || name === "find_dependencies") {
235
379
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -237,45 +381,77 @@ ${body}${total > edges.length ? `
237
381
  const funcName = args.function;
238
382
  const repoFilter = args.repo;
239
383
  const fuzzy = args.fuzzy === true;
384
+ const types = edgeTypes(args);
240
385
  const matched = store.resolveNodes(funcName, repoFilter, fuzzy);
241
386
  if (matched.length === 0) {
242
- return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}. Try fuzzy:true for substring matching.`);
387
+ return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}`);
243
388
  }
244
389
  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}"
390
+ const fetch = (t) => name === "find_callers" ? store.callers(keys, 30, t) : store.dependencies(keys, 30, t);
391
+ const found = fetch(types);
392
+ const noun = name === "find_callers" ? "callers" : "dependencies";
393
+ const title = `# ${name === "find_callers" ? "Callers of" : "Dependencies of"} "${funcName}"`;
394
+ const relLabel = types ? types.join(", ") : "any relation";
395
+ if (found.length === 0) {
396
+ const other = /* @__PURE__ */ new Map();
397
+ if (types) for (const o of fetch(void 0)) other.set(o.type, (other.get(o.type) ?? 0) + 1);
398
+ const hint = other.size ? `
249
399
 
250
- Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${name === "find_callers" ? "callers" : "dependencies"} found.`);
400
+ Other relations: ${[...other].map(([t, n]) => `${t} ${n}`).join(", ")} \u2014 pass relations: ${JSON.stringify([...other.keys()])} to list them.` : "";
401
+ return text(`${title}
402
+
403
+ Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}`);
404
+ }
405
+ const byType = /* @__PURE__ */ new Map();
406
+ for (const f of found) {
407
+ if (!byType.has(f.type)) byType.set(f.type, []);
408
+ byType.get(f.type).push(f.key);
251
409
  }
252
- return text(`# ${direction} "${funcName}"
410
+ const sections = [...byType].map(([t, ks]) => `## ${t} (${ks.length})
411
+ ${ks.map((k) => `- \`${k}\``).join("\n")}`).join("\n\n");
412
+ return text(`${title}
413
+
414
+ ${found.length} found (${relLabel}):
253
415
 
254
- ${unique.length} found:
255
- ${unique.map((r) => `- \`${r}\``).join("\n")}`);
416
+ ${sections}`);
256
417
  }
257
418
  if (name === "impact_analysis") {
258
419
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
259
420
  if (!store) return text("No system graph found.");
260
421
  const file = args.file;
261
422
  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);
423
+ const depth = args.depth ?? 2;
424
+ let entity = args.entity;
425
+ if (entity?.includes("::")) {
426
+ const prefix = `${repo}::${file}::`;
427
+ 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}".`);
428
+ entity = entity.slice(prefix.length);
429
+ }
430
+ const fileKeys = store.nodesInFiles(repo, [file]);
431
+ 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.`);
432
+ const scope = entity ? store.nodesInFiles(repo, [file], entity) : fileKeys;
433
+ if (scope.length === 0) return toolError(`No entity "${entity}" in ${repo}/${file}.`);
434
+ const endpoints = entity ? endpointsIn(store, fileKeys).filter((k) => !scope.includes(k)) : [];
435
+ const fileLevel = new Set(endpoints);
436
+ const { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel);
437
+ const total = hops.reduce((n, h) => n + h.total, 0);
265
438
  const body = [
266
- `# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""}`,
267
- "",
268
- `## Entities in scope: ${fileKeys.length}`,
269
- ...fileKeys.slice(0, 20).map((k) => `- \`${k}\``),
270
- "",
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` : "",
439
+ `# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""} (depth ${depth})`,
275
440
  "",
276
- `## Affected repos: ${repos.join(", ") || "none"}`
277
- ].join("\n");
278
- return text(body);
441
+ `## Entities in scope: ${scope.length}`,
442
+ ...scope.slice(0, 20).map((k) => `- \`${k}\``)
443
+ ];
444
+ if (endpoints.length) {
445
+ body.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in ${file}, not tied to ${entity})`, ...endpoints.slice(0, 20).map((k) => `- \`${k}\``));
446
+ }
447
+ body.push("", `## Dependents: ${total} edges from ${repos.size} repos`);
448
+ for (const h of hops) {
449
+ 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`);
452
+ }
453
+ body.push("", `## Affected repos: ${[...repos].join(", ") || "none"}`);
454
+ return text(body.join("\n"));
279
455
  }
280
456
  if (name === "trace_path") {
281
457
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -285,13 +461,13 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}`);
285
461
  const repo = args.repo;
286
462
  const fuzzy = args.fuzzy === true;
287
463
  const direction = args.direction ?? "callees";
288
- const maxDepth = Math.max(1, Math.min(10, args.maxDepth || 4));
464
+ const maxDepth = args.maxDepth ?? 4;
289
465
  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);
466
+ if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
467
+ const getNeighbors = makeNeighborGetter(store, direction, edgeTypes(args));
292
468
  if (to) {
293
469
  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.`);
470
+ if (toKeys.size === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
295
471
  const path = shortestPath(getNeighbors, fromKeys, toKeys, maxDepth);
296
472
  if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).`);
297
473
  const labelOf2 = store.labelsOf(path);
@@ -321,16 +497,21 @@ ${sections}`);
321
497
  }
322
498
  if (name === "find_dead_code") {
323
499
  const repo = args.repo;
324
- const limit = args.limit || 50;
500
+ const limit = args.limit ?? 50;
325
501
  const graph = await blobs.getJson(`${repo}/graph.json`);
326
- if (!graph) return text(`No graph found for repo "${repo}"`);
502
+ if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
327
503
  const ENTITY_TYPES = /* @__PURE__ */ new Set(["function", "method", "class", "struct", "enum", "trait"]);
328
504
  const inDegree = /* @__PURE__ */ new Map();
329
505
  for (const l of graph.links ?? []) {
330
506
  if (l.type === "contains") continue;
331
507
  inDegree.set(l.target, (inDegree.get(l.target) ?? 0) + 1);
332
508
  }
333
- const dead = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
509
+ const candidates = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
510
+ const source = await indexedSources(ctx.projectName, repo, [...new Set(candidates.map((n) => n.file).filter(Boolean))]);
511
+ const dead = candidates.filter((n) => {
512
+ const src = source.get(n.file);
513
+ return src === void 0 || identifierCount(src, n.label ?? n.id) <= 1;
514
+ });
334
515
  if (dead.length === 0) return text(`# Dead code in ${repo}
335
516
 
336
517
  No zero-caller entities found.`);
@@ -349,7 +530,7 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
349
530
  const repo = args.repo;
350
531
  if (repo) {
351
532
  const profile = await loadProfile(ctx.projectName, repo, resolvedKnowledgeConfig());
352
- if (!profile) return text(`No profile for "${repo}". Run profiling (requires LLM) to generate one.`);
533
+ if (!profile) return text(missingProfileText(repo));
353
534
  const ep = (xs) => xs?.length ? xs.map((e) => ` - ${e.type}: ${e.identifier} \u2014 ${e.description}`).join("\n") : " - (none)";
354
535
  const body = [
355
536
  `# ${profile.name} \u2014 ${profile.role} (${profile.domain})`,
@@ -397,9 +578,9 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
397
578
 
398
579
  ${profiles.map((p) => `- **${p.name}** \u2014 ${p.role} (${p.domain}): ${p.description}`).join("\n")}
399
580
 
400
- _Run project-graph generation (requires LLM) for a full architecture view._`);
581
+ _A full architecture view needs the LLM project graph (\`anvil graph build\`)._`);
401
582
  }
402
- return text("No architecture data yet. Index the project, then run profiling / project-graph generation (requires an LLM) for an architecture overview.");
583
+ 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
584
  }
404
585
  if (name === "search_graph") {
405
586
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
@@ -410,7 +591,7 @@ _Run project-graph generation (requires LLM) for a full architecture view._`);
410
591
  file: args.file,
411
592
  repo: args.repo,
412
593
  minDegree: args.minDegree ?? 0
413
- }, args.limit || 50);
594
+ }, args.limit ?? 50);
414
595
  if (total === 0) return text("No entities match the given filters.");
415
596
  const body = rows.map((m) => `- \`${m.label}\` (${m.type}, ${m.file}) \u2014 degree ${m.degree} \`${m.key}\``).join("\n");
416
597
  return text(`# search_graph \u2014 ${total} match${total === 1 ? "" : "es"}
@@ -421,13 +602,13 @@ ${body}${total > rows.length ? `
421
602
  }
422
603
  if (name === "detect_changes") {
423
604
  const repo = args.repo;
424
- const limit = args.limit || 50;
605
+ const limit = args.limit ?? 50;
425
606
  if (!ctx.directoryPath) {
426
- return text("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
607
+ return toolError("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
427
608
  }
428
609
  const candidate = join(ctx.directoryPath, repo);
429
610
  const repoPath = existsSync(join(candidate, ".git")) ? candidate : ctx.directoryPath;
430
- if (!existsSync(repoPath)) return text(`Repo path not found: ${repoPath}`);
611
+ if (!existsSync(repoPath)) return toolError(`Repo path not found: ${repoPath}`);
431
612
  let baseSha = args.baseSha;
432
613
  if (!baseSha) {
433
614
  try {
@@ -436,43 +617,82 @@ ${body}${total > rows.length ? `
436
617
  } catch {
437
618
  }
438
619
  }
439
- if (!baseSha) return text("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
620
+ if (!baseSha) return toolError("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
440
621
  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.`);
622
+ 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
623
  const changedFiles = getChangedFilesList(diff);
443
624
  if (changedFiles.length === 0 && diff.deleted.length === 0) return text(`No source changes since ${baseSha.slice(0, 7)}.`);
444
625
  const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
445
- let changedKeys = [];
446
- let dependents = [];
447
- let depTotal = 0;
448
- let depRepos = [];
626
+ const changedKeys = [];
627
+ const endpoints = [];
628
+ const wholeFile = [];
629
+ const unlocated = /* @__PURE__ */ new Set();
630
+ let hops = [];
631
+ let depRepos = /* @__PURE__ */ new Set();
632
+ const fileLevel = /* @__PURE__ */ new Set();
449
633
  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;
634
+ changedKeys.push(...store.nodesInFiles(repo, [...diff.added, ...diff.deleted]));
635
+ const hunks = diffHunks(repoPath, baseSha, diff.modified);
636
+ for (const file of diff.modified) {
637
+ const fileKeys = store.nodesInFiles(repo, [file]);
638
+ const types = store.nodeTypes(fileKeys);
639
+ const modKey = `${repo}::${file}`;
640
+ 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]));
641
+ endpoints.push(...fileKeys.filter((k) => types.get(k) === "external"));
642
+ let base;
643
+ try {
644
+ base = runGit(repoPath, ["show", `${baseSha}:${file}`]);
645
+ } catch {
646
+ wholeFile.push(file);
647
+ changedKeys.push(...fileKeys.filter((k) => types.get(k) !== "external"));
648
+ continue;
649
+ }
650
+ const ranges = declarationRanges(base, [...entities.keys()]);
651
+ const { names, fileLevel: touchesFile } = touchedEntities(ranges, hunks.get(file) ?? []);
652
+ if (touchesFile && fileKeys.includes(modKey)) changedKeys.push(modKey);
653
+ const located = new Set(ranges.map((r) => r.name));
654
+ for (const [name2, key] of entities) {
655
+ if (names.has(name2)) changedKeys.push(key);
656
+ else if (!located.has(name2)) {
657
+ changedKeys.push(key);
658
+ unlocated.add(key);
659
+ }
660
+ }
661
+ }
662
+ for (const k of endpoints) fileLevel.add(k);
663
+ ({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel));
455
664
  }
665
+ const dependents = hops[0]?.edges ?? [];
666
+ const depTotal = hops[0]?.total ?? 0;
456
667
  const lines = [
457
668
  `# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
458
669
  "",
459
- `## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length}), deleted ${diff.deleted.length}`,
460
- ...changedFiles.slice(0, 30).map((f) => `- ${f}`),
670
+ `## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})`,
671
+ ...changedFiles.slice(0, 30).map((f) => `- ${f}${wholeFile.includes(f) ? " (not readable at the base \u2014 every entity in the file)" : ""}`),
672
+ "",
673
+ `## Deleted files: ${diff.deleted.length}`,
674
+ ...diff.deleted.slice(0, 30).map((f) => `- ${f}`),
461
675
  "",
462
676
  `## Affected entities: ${changedKeys.length}`,
463
- ...changedKeys.slice(0, 30).map((k) => `- \`${k}\``),
677
+ ...changedKeys.slice(0, 30).map((k) => `- \`${k}\`${unlocated.has(k) ? " (declaration not found in the file \u2014 included to be safe)" : ""}`)
678
+ ];
679
+ if (endpoints.length) {
680
+ lines.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in modified files)`, ...endpoints.slice(0, 30).map((k) => `- \`${k}\``));
681
+ }
682
+ lines.push(
464
683
  "",
465
- `## Dependents: ${depTotal} edges from ${depRepos.length} repo(s)`,
466
- ...dependents.map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${e.type ?? "edge"})`),
684
+ `## Dependents: ${depTotal} edges from ${depRepos.size} repo(s)`,
685
+ ...dependents.slice(0, limit).map((e) => edgeLine(e, fileLevel)),
686
+ ...depTotal > Math.min(dependents.length, limit) ? [`... and ${depTotal - Math.min(dependents.length, limit)} more`] : [],
467
687
  "",
468
- `## Affected repos: ${depRepos.join(", ") || "none"}`
469
- ];
688
+ `## Affected repos: ${[...depRepos].join(", ") || "none"}`
689
+ );
470
690
  return text(lines.join("\n"));
471
691
  }
472
692
  return null;
473
693
  } catch (err) {
474
694
  const msg = err instanceof Error ? err.message : String(err);
475
- return { content: [{ type: "text", text: `Graph tool error: ${msg}` }] };
695
+ return toolError(`Graph tool error: ${msg}`);
476
696
  }
477
697
  }
478
698
  export {