@ashkand/code-graph-mcp 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,149 @@
1
+ // MCP server exposing the code graph tools (spec R3), over stdio or
2
+ // Streamable HTTP (spec R8, for Claude custom connectors).
3
+ import http from "node:http";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { z } from "zod";
8
+ import { Graph } from "./graph.js";
9
+ import { fullIndex } from "./indexer.js";
10
+ import * as T from "./tools.js";
11
+
12
+ const detailParam = z.enum(["compact", "full"]).optional().describe("compact (default) = terse line output; full = expanded lists");
13
+ const maxTokensParam = z.number().int().min(100).max(20000).optional().describe("response token budget (default 2000)");
14
+
15
+ function loadOrIndex(repoRoot) {
16
+ let graph = Graph.load(repoRoot);
17
+ if (!graph) {
18
+ const res = fullIndex(repoRoot);
19
+ graph = res.graph;
20
+ console.error(`[code-graph-mcp] full index: ${graph.stats().nodes} nodes in ${res.ms}ms (${res.warnings.length} warnings)`);
21
+ } else {
22
+ console.error(`[code-graph-mcp] loaded graph: ${graph.stats().nodes} nodes / ${graph.stats().edges} edges`);
23
+ }
24
+ return { graph };
25
+ }
26
+
27
+ export function buildServer(ref) {
28
+ const server = new McpServer({ name: "code-graph-mcp", version: "0.2.2" });
29
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
30
+
31
+ server.registerTool("repo_summary", {
32
+ title: "Repo summary",
33
+ description: "High-level overview of the indexed repo: packages, dependency highlights, frontend routes, backend routes, node/edge counts, and session token-savings stats. Call this FIRST for any structural question instead of listing directories.",
34
+ inputSchema: { detail: detailParam, max_tokens: maxTokensParam },
35
+ }, async (a) => text(T.repoSummary(ref.graph, a)));
36
+
37
+ server.registerTool("find_component_relations", {
38
+ title: "Find component relations",
39
+ description: "Given a component or route name, return everything related: child components it renders, parents that render it, hooks/stores used, endpoints it calls, and file import relationships. Use instead of grepping for usages.",
40
+ inputSchema: { name: z.string().describe("component or route name, e.g. LoginPage or /login"), detail: detailParam, max_tokens: maxTokensParam },
41
+ }, async (a) => text(T.findComponentRelations(ref.graph, a)));
42
+
43
+ server.registerTool("map_frontend_to_backend", {
44
+ title: "Map frontend to backend",
45
+ description: "Trace the full chain Component/Route -> HTTP call -> backend route -> handler function. Accepts a frontend name (LoginPage), a route (/login), or an endpoint path (/api/login) and works from either end. Use for 'where does X land in the backend?' questions.",
46
+ inputSchema: { name: z.string().describe("component name, frontend route, or endpoint path"), max_tokens: maxTokensParam },
47
+ }, async (a) => text(T.mapFrontendToBackend(ref.graph, a)));
48
+
49
+ server.registerTool("visualize_graph", {
50
+ title: "Visualize graph slice",
51
+ description: "Return a Mermaid (default) or Graphviz DOT diagram for a slice of the graph: kind 'component-tree' (render hierarchy under a component) or 'fe-to-be' (frontend-to-backend flow). Paste the Mermaid block directly into chat to render it.",
52
+ inputSchema: {
53
+ name: z.string().describe("root component/route/endpoint for the slice"),
54
+ kind: z.enum(["component-tree", "fe-to-be"]).optional(),
55
+ format: z.enum(["mermaid", "dot"]).optional(),
56
+ max_nodes: z.number().int().min(3).max(200).optional(),
57
+ max_tokens: maxTokensParam,
58
+ },
59
+ }, async (a) => text(T.visualizeGraph(ref.graph, a)));
60
+
61
+ server.registerTool("search_symbols", {
62
+ title: "Search symbols",
63
+ description: "Fuzzy search any indexed node (components, routes, endpoints, handlers, files, packages) by name or path. Use instead of grep/find for 'where is X defined?'.",
64
+ inputSchema: {
65
+ query: z.string(),
66
+ types: z.array(z.enum(["package", "file", "component", "route", "endpoint-call", "api-route", "handler"])).optional(),
67
+ limit: z.number().int().min(1).max(50).optional(),
68
+ max_tokens: maxTokensParam,
69
+ },
70
+ }, async (a) => text(T.searchSymbols(ref.graph, a)));
71
+
72
+ server.registerTool("impact_of_change", {
73
+ title: "Impact of change (blast radius)",
74
+ description: "Given a component, handler, endpoint, or file path, list everything that depends on it (directly and transitively, up to depth N). Use before refactors and when debugging 'what breaks if I change this?'.",
75
+ inputSchema: { name: z.string().describe("node name or repo-relative file path"), depth: z.number().int().min(1).max(6).optional(), max_tokens: maxTokensParam },
76
+ }, async (a) => text(T.impactOfChange(ref.graph, a)));
77
+
78
+ server.registerTool("reindex", {
79
+ title: "Reindex repo",
80
+ description: "Refresh the graph. Incremental by default (only changed files reparsed); pass full:true to rebuild from scratch. Call after significant code edits.",
81
+ inputSchema: { full: z.boolean().optional() },
82
+ }, async (a) => text(T.reindex(ref, a)));
83
+
84
+ return server;
85
+ }
86
+
87
+ // ---------- stdio (Claude Code, Cursor, Codex CLI, local clients) ----------
88
+ export async function startServer(repoRoot) {
89
+ const ref = loadOrIndex(repoRoot); // mutable ref so reindex can swap the instance
90
+ const server = buildServer(ref);
91
+ const transport = new StdioServerTransport();
92
+ await server.connect(transport);
93
+ console.error("[code-graph-mcp] ready (stdio)");
94
+ return server;
95
+ }
96
+
97
+ // ---------- Streamable HTTP (Claude custom connectors, MCP connector API) ----------
98
+ // Stateless: a fresh McpServer+transport per request; the graph ref is shared
99
+ // so the index is loaded once and reused across requests (spec R8.2).
100
+ export async function startHttpServer(repoRoot, { port = 3333, host = "127.0.0.1", token = null } = {}) {
101
+ const ref = loadOrIndex(repoRoot);
102
+
103
+ const httpServer = http.createServer(async (req, res) => {
104
+ const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
105
+ const segs = url.pathname.split("/").filter(Boolean);
106
+ const isMcp = segs[0] === "mcp";
107
+ if (!isMcp) {
108
+ res.writeHead(404, { "content-type": "application/json" });
109
+ res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
110
+ return;
111
+ }
112
+ // auth (spec R8.3): Authorization: Bearer <token> OR /mcp/<token>
113
+ if (token) {
114
+ const header = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
115
+ const pathToken = segs[1] ?? "";
116
+ if (header !== token && pathToken !== token) {
117
+ res.writeHead(401, { "content-type": "application/json" });
118
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "unauthorized" }, id: null }));
119
+ return;
120
+ }
121
+ }
122
+ if (req.method === "GET" || req.method === "DELETE") {
123
+ // stateless mode: no SSE stream / session teardown to serve
124
+ res.writeHead(405, { "content-type": "application/json" });
125
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "stateless server: POST only" }, id: null }));
126
+ return;
127
+ }
128
+ try {
129
+ const chunks = [];
130
+ for await (const c of req) chunks.push(c);
131
+ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
132
+ const server = buildServer(ref);
133
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
134
+ res.on("close", () => { transport.close(); server.close(); });
135
+ await server.connect(transport);
136
+ await transport.handleRequest(req, res, body);
137
+ } catch (e) {
138
+ if (!res.headersSent) {
139
+ res.writeHead(500, { "content-type": "application/json" });
140
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: `internal error: ${e.message}` }, id: null }));
141
+ }
142
+ }
143
+ });
144
+
145
+ await new Promise((resolve) => httpServer.listen(port, host, resolve));
146
+ const shownPath = token ? `/mcp/<token>` : "/mcp";
147
+ console.error(`[code-graph-mcp] ready (streamable http) on http://${host}:${port}${shownPath}${token ? " (token required)" : " (no auth — keep this off the public internet)"}`);
148
+ return httpServer;
149
+ }
package/src/tools.js ADDED
@@ -0,0 +1,357 @@
1
+ // Tool implementations. Everything answers from the in-memory graph (spec
2
+ // constraint), formats compact line-oriented text (R4.1), and enforces a
3
+ // token budget with "…+N more" truncation (R4.2).
4
+
5
+
6
+ import path from "node:path";
7
+ import { shortenPaths } from "./graph.js";
8
+ import { incrementalIndex, fullIndex, pathsMatch } from "./indexer.js";
9
+
10
+ const CHARS_PER_TOKEN = 4;
11
+ const DEFAULT_MAX_TOKENS = 2000;
12
+
13
+ // Session stats (R4.4)
14
+ export const stats = { calls: 0, charsReturned: 0, rawCharsAvoided: 0 };
15
+
16
+ export function budget(lines, maxTokens = DEFAULT_MAX_TOKENS) {
17
+ const cap = maxTokens * CHARS_PER_TOKEN;
18
+ let used = 0;
19
+ const out = [];
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const l = lines[i];
22
+ if (used + l.length + 1 > cap) {
23
+ out.push(`…+${lines.length - i} more (refine query or raise max_tokens)`);
24
+ return out.join("\n");
25
+ }
26
+ out.push(l);
27
+ used += l.length + 1;
28
+ }
29
+ return out.join("\n");
30
+ }
31
+
32
+ function record(text, filesTouched, graph) {
33
+ stats.calls++;
34
+ stats.charsReturned += text.length;
35
+ // Estimate what an agent would have read instead: sizes captured at index
36
+ // time on file nodes — no filesystem I/O at query time (spec constraint).
37
+ let raw = 0;
38
+ for (const rel of new Set(filesTouched)) {
39
+ raw += graph.nodes.get(`file:${rel}`)?.extra?.bytes ?? 0;
40
+ }
41
+ stats.rawCharsAvoided += Math.max(0, raw - text.length);
42
+ return text;
43
+ }
44
+
45
+ function notFound(graph, name, types) {
46
+ const close = graph.fuzzy(name, 5, types);
47
+ if (!close.length) return `not found: "${name}" (no close matches in graph — try search_symbols or reindex)`;
48
+ const lines = [`not found: "${name}". closest matches:`];
49
+ for (const n of close) lines.push(` ${n.type} ${n.name} ${n.file ?? ""}`);
50
+ return lines.join("\n");
51
+ }
52
+
53
+ // resolve a user-supplied name to node(s); prefers exact, disambiguates duplicates (E4)
54
+ function resolve(graph, name, types) {
55
+ let hits = graph.findByName(name).filter((n) => !types || types.includes(n.type));
56
+ if (!hits.length) hits = graph.fuzzy(name, 3, types).filter((n) => n.name?.toLowerCase() === name.toLowerCase());
57
+ return hits;
58
+ }
59
+
60
+ // ---------- repo_summary ----------
61
+ export function repoSummary(graph, { detail = "compact", max_tokens = DEFAULT_MAX_TOKENS } = {}) {
62
+ const s = graph.stats();
63
+ const lines = [`repo: ${path.basename(graph.repoRoot)} nodes:${s.nodes} edges:${s.edges}`];
64
+ lines.push(`counts: ${Object.entries(s.byType).map(([t, c]) => `${t}=${c}`).join(" ")}`);
65
+ const pkgs = [...graph.nodes.values()].filter((n) => n.type === "package");
66
+ lines.push(`packages (${pkgs.length}):`);
67
+ for (const p of pkgs) {
68
+ const deps = (p.extra?.deps ?? []).slice(0, detail === "full" ? 20 : 6).join(",");
69
+ lines.push(` ${p.file === "." ? "(root)" : p.file} ${p.name} [${p.extra?.kind}] deps: ${deps || "-"}`);
70
+ }
71
+ const routes = [...graph.nodes.values()].filter((n) => n.type === "route");
72
+ if (routes.length) {
73
+ lines.push(`frontend routes (${routes.length}):`);
74
+ for (const r of routes) lines.push(` ${r.name} -> ${r.extra?.component ?? "?"}`);
75
+ }
76
+ const api = [...graph.nodes.values()].filter((n) => n.type === "api-route");
77
+ if (api.length) {
78
+ lines.push(`backend routes (${api.length}):`);
79
+ for (const a of api) lines.push(` ${a.name} (${a.file})`);
80
+ }
81
+ if (stats.calls > 0) {
82
+ const saved = stats.rawCharsAvoided / CHARS_PER_TOKEN;
83
+ lines.push(`session savings: ${stats.calls} calls served, ~${Math.round(saved).toLocaleString()} tokens of raw file reads avoided`);
84
+ }
85
+ const text = budget(lines, max_tokens);
86
+ return record(text, [], graph);
87
+ }
88
+
89
+ // ---------- find_component_relations ----------
90
+ export function findComponentRelations(graph, { name, detail = "compact", max_tokens = DEFAULT_MAX_TOKENS }) {
91
+ const hits = resolve(graph, name, ["component", "route"]);
92
+ if (!hits.length) return record(notFound(graph, name, ["component", "route"]), [], graph);
93
+ const lines = [];
94
+ const files = [];
95
+ for (const node of hits) {
96
+ files.push(node.file);
97
+ lines.push(`${node.type}: ${node.name} (${node.file ?? "-"})`);
98
+ const fileId = node.file ? `file:${node.file}` : null;
99
+ const sections = [
100
+ ["renders", graph.neighbors(node.id, "out", ["renders"])],
101
+ ["rendered by", graph.neighbors(node.id, "in", ["renders"])],
102
+ ["hooks/stores used", graph.neighbors(node.id, "out", ["uses"])],
103
+ ["endpoints called", graph.neighbors(node.id, "out", ["calls"])],
104
+ ];
105
+ if (fileId) {
106
+ sections.push(["file imports", graph.neighbors(fileId, "out", ["imports"])]);
107
+ sections.push(["file imported by", graph.neighbors(fileId, "in", ["imports"])]);
108
+ }
109
+ for (const [label, items] of sections) {
110
+ if (!items.length) continue;
111
+ const uniq = dedupe(items.map(({ node: n }) => n));
112
+ lines.push(` ${label}:`);
113
+ const shown = detail === "full" ? uniq : uniq.slice(0, 12);
114
+ for (const n of shown) {
115
+ lines.push(` ${n.name}${n.file && n.type !== "file" ? ` (${n.file})` : n.type === "file" ? ` (${n.file})` : ""}`);
116
+ if (n.file) files.push(n.file);
117
+ }
118
+ if (shown.length < uniq.length) lines.push(` …+${uniq.length - shown.length} more (detail:"full")`);
119
+ }
120
+ if (hits.length > 1) lines.push("");
121
+ }
122
+ if (hits.length > 1) lines.unshift(`note: ${hits.length} matches for "${name}" — listed all:`);
123
+ return record(budget(lines, max_tokens), files, graph);
124
+ }
125
+
126
+ // ---------- map_frontend_to_backend ----------
127
+ export function mapFrontendToBackend(graph, { name, max_tokens = DEFAULT_MAX_TOKENS }) {
128
+ const lines = [];
129
+ const files = [];
130
+
131
+ // Case A: name is an endpoint path -> walk backwards to FE and forwards to handler
132
+ if (name.startsWith("/")) {
133
+ const target = name.toLowerCase();
134
+ const apiRoutes = [...graph.nodes.values()].filter((n) => n.type === "api-route" && pathsMatch(normalizeForQuery(target), n.extra.normPath));
135
+ const calls = [...graph.nodes.values()].filter((n) => n.type === "endpoint-call" && pathsMatch(normalizeForQuery(target), n.extra.normPath));
136
+ if (!apiRoutes.length && !calls.length) return record(notFound(graph, name, ["api-route", "endpoint-call"]), [], graph);
137
+ for (const ar of apiRoutes) lines.push(...chainFromApiRoute(graph, ar, files));
138
+ for (const c of calls) if (!graph.neighbors(c.id, "out", ["hits-endpoint"]).length) {
139
+ lines.push(`${callerLabel(graph, c)} -> ${c.name} -> (no backend match found)`);
140
+ }
141
+ return record(budget(dedupeLines(lines), max_tokens), files, graph);
142
+ }
143
+
144
+ // Case B: component or route name -> its calls -> backend
145
+ const hits = resolve(graph, name, ["component", "route"]);
146
+ if (!hits.length) return record(notFound(graph, name, ["component", "route"]), [], graph);
147
+ for (const node of hits) {
148
+ files.push(node.file);
149
+ const calls = collectCalls(graph, node);
150
+ if (!calls.length) { lines.push(`${node.name}: no HTTP calls found (checked component + rendered children)`); continue; }
151
+ for (const c of calls) {
152
+ const hitEdges = graph.neighbors(c.node.id, "out", ["hits-endpoint"]);
153
+ if (!hitEdges.length) {
154
+ lines.push(`${node.name}${c.via ? ` -> ${c.via}` : ""} -> ${c.node.name} -> (no backend match)`);
155
+ continue;
156
+ }
157
+ for (const { node: ar } of hitEdges) {
158
+ const handlers = graph.neighbors(ar.id, "out", ["handled-by"]);
159
+ const h = handlers[0]?.node;
160
+ files.push(ar.file); if (h) files.push(h.file);
161
+ lines.push(`${node.name}${c.via ? ` -> ${c.via}` : ""} -> ${c.node.name} -> ${ar.name} (${ar.file})${h ? ` -> ${handlerName(h)}` : ""}`);
162
+ }
163
+ }
164
+ }
165
+ return record(budget(dedupeLines(lines), max_tokens), files, graph);
166
+ }
167
+
168
+ function normalizeForQuery(p) {
169
+ return p.replace(/\{[^}]*\}/g, ":param").replace(/:[A-Za-z_]\w*/g, ":param");
170
+ }
171
+ function handlerName(h) {
172
+ const mod = h.file ? h.file.replace(/\.\w+$/, "").split("/").pop() : "";
173
+ return mod ? `${mod}.${h.name}` : h.name;
174
+ }
175
+ function callerLabel(graph, call) {
176
+ const owners = graph.neighbors(call.id, "in", ["calls"]).map((e) => e.node.name);
177
+ return owners[0] ?? call.file ?? "?";
178
+ }
179
+ function chainFromApiRoute(graph, ar, files) {
180
+ const lines = [];
181
+ files.push(ar.file);
182
+ const handlers = graph.neighbors(ar.id, "out", ["handled-by"]);
183
+ const h = handlers[0]?.node;
184
+ if (h) files.push(h.file);
185
+ const callers = graph.neighbors(ar.id, "in", ["hits-endpoint"]);
186
+ if (!callers.length) {
187
+ lines.push(`(no frontend caller found) -> ${ar.name} (${ar.file})${h ? ` -> ${handlerName(h)}` : ""}`);
188
+ }
189
+ for (const { node: call } of callers) {
190
+ files.push(call.file);
191
+ lines.push(`${callerLabel(graph, call)} -> ${call.name} -> ${ar.name} (${ar.file})${h ? ` -> ${handlerName(h)}` : ""}`);
192
+ }
193
+ return lines;
194
+ }
195
+ // calls made by the node itself, or by components it renders (1 level), so
196
+ // route -> page component -> call chains resolve.
197
+ function collectCalls(graph, node) {
198
+ const direct = graph.neighbors(node.id, "out", ["calls"]).map(({ node: n }) => ({ node: n, via: null }));
199
+ const rendered = graph.neighbors(node.id, "out", ["renders"]);
200
+ const viaChildren = [];
201
+ for (const { node: child } of rendered) {
202
+ for (const { node: c } of graph.neighbors(child.id, "out", ["calls"])) {
203
+ viaChildren.push({ node: c, via: child.name });
204
+ }
205
+ }
206
+ return dedupe([...direct, ...viaChildren].map((x) => x), (x) => x.node.id + (x.via ?? ""));
207
+ }
208
+
209
+ // ---------- visualize_graph ----------
210
+ export function visualizeGraph(graph, { name, kind = "component-tree", format = "mermaid", max_nodes = 30, max_tokens = DEFAULT_MAX_TOKENS }) {
211
+ const hits = resolve(graph, name, ["component", "route", "api-route"]);
212
+ if (!hits.length) return record(notFound(graph, name, ["component", "route", "api-route"]), [], graph);
213
+ const root = hits[0];
214
+ const nodes = new Map([[root.id, root]]);
215
+ const edges = [];
216
+ if (kind === "fe-to-be") {
217
+ // route/component -> calls -> api-route -> handler
218
+ const frontier = [root];
219
+ for (const start of frontier) {
220
+ for (const c of collectCalls(graph, start)) {
221
+ addViz(nodes, edges, start, c.node, "calls", max_nodes);
222
+ for (const { node: ar } of graph.neighbors(c.node.id, "out", ["hits-endpoint"])) {
223
+ addViz(nodes, edges, c.node, ar, "hits", max_nodes);
224
+ for (const { node: h } of graph.neighbors(ar.id, "out", ["handled-by"])) {
225
+ addViz(nodes, edges, ar, h, "handled by", max_nodes);
226
+ }
227
+ }
228
+ }
229
+ for (const { node: child } of graph.neighbors(start.id, "out", ["renders"])) {
230
+ addViz(nodes, edges, start, child, "renders", max_nodes);
231
+ }
232
+ }
233
+ } else {
234
+ // component tree: BFS over renders
235
+ let frontier = [root];
236
+ while (frontier.length && nodes.size < max_nodes) {
237
+ const next = [];
238
+ for (const cur of frontier) {
239
+ for (const { node: child } of graph.neighbors(cur.id, "out", ["renders"])) {
240
+ if (nodes.size >= max_nodes) break;
241
+ const isNew = !nodes.has(child.id);
242
+ addViz(nodes, edges, cur, child, "", max_nodes);
243
+ if (isNew) next.push(child);
244
+ }
245
+ }
246
+ frontier = next;
247
+ }
248
+ }
249
+ const text = format === "dot" ? renderDot(nodes, edges) : renderMermaid(nodes, edges);
250
+ const capped = nodes.size >= max_nodes ? `${text}\n%% capped at ${max_nodes} nodes (raise max_nodes for more)` : text;
251
+ return record(budget(capped.split("\n"), max_tokens), [...nodes.values()].map((n) => n.file).filter(Boolean), graph);
252
+ }
253
+
254
+ function addViz(nodes, edges, from, to, label, cap) {
255
+ if (nodes.size >= cap && !nodes.has(to.id)) return;
256
+ nodes.set(from.id, from);
257
+ nodes.set(to.id, to);
258
+ edges.push({ from: from.id, to: to.id, label });
259
+ }
260
+ function vizId(id) { return "n" + Math.abs(hashCode(id)).toString(36); }
261
+ function hashCode(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = (h << 5) - h + s.charCodeAt(i); h |= 0; } return h; }
262
+ function vizLabel(n) {
263
+ const t = { component: "", route: "route ", "api-route": "", "endpoint-call": "", handler: "fn " }[n.type] ?? "";
264
+ return `${t}${n.name}`.replace(/["\[\]{}|]/g, "");
265
+ }
266
+ function renderMermaid(nodes, edges) {
267
+ const lines = ["graph LR"];
268
+ const shape = (n) => {
269
+ const l = vizLabel(n);
270
+ if (n.type === "api-route") return `${vizId(n.id)}[/"${l}"/]`;
271
+ if (n.type === "handler") return `${vizId(n.id)}[["${l}"]]`;
272
+ if (n.type === "route") return `${vizId(n.id)}(("${l}"))`;
273
+ return `${vizId(n.id)}["${l}"]`;
274
+ };
275
+ for (const n of nodes.values()) lines.push(` ${shape(n)}`);
276
+ const seen = new Set();
277
+ for (const e of edges) {
278
+ const key = `${e.from}>${e.to}`;
279
+ if (seen.has(key)) continue;
280
+ seen.add(key);
281
+ lines.push(` ${vizId(e.from)} ${e.label ? `-- ${e.label} -->` : "-->"} ${vizId(e.to)}`);
282
+ }
283
+ return lines.join("\n");
284
+ }
285
+ function renderDot(nodes, edges) {
286
+ const lines = ["digraph G {", " rankdir=LR;"];
287
+ for (const n of nodes.values()) lines.push(` ${vizId(n.id)} [label="${vizLabel(n)}"];`);
288
+ const seen = new Set();
289
+ for (const e of edges) {
290
+ const key = `${e.from}>${e.to}`;
291
+ if (seen.has(key)) continue;
292
+ seen.add(key);
293
+ lines.push(` ${vizId(e.from)} -> ${vizId(e.to)}${e.label ? ` [label="${e.label}"]` : ""};`);
294
+ }
295
+ lines.push("}");
296
+ return lines.join("\n");
297
+ }
298
+
299
+ // ---------- search_symbols ----------
300
+ export function searchSymbols(graph, { query, types = null, limit = 10, max_tokens = DEFAULT_MAX_TOKENS }) {
301
+ const hits = graph.fuzzy(query, limit, types);
302
+ if (!hits.length) return record(`no matches for "${query}"`, [], graph);
303
+ const paths = hits.map((n) => n.file ?? "");
304
+ const { prefix, paths: short } = shortenPaths(paths);
305
+ const lines = prefix ? [`(paths relative to ${prefix})`] : [];
306
+ hits.forEach((n, i) => lines.push(`${n.type} ${n.name} ${short[i] || "-"}`));
307
+ return record(budget(lines, max_tokens), paths.filter(Boolean), graph);
308
+ }
309
+
310
+ // ---------- impact_of_change ----------
311
+ export function impactOfChange(graph, { name, depth = 3, max_tokens = DEFAULT_MAX_TOKENS }) {
312
+ let hits = resolve(graph, name, null);
313
+ if (!hits.length && (name.includes("/") || name.includes("."))) {
314
+ const f = graph.nodes.get(`file:${name}`);
315
+ if (f) hits = [f];
316
+ }
317
+ if (!hits.length) return record(notFound(graph, name, null), [], graph);
318
+ const lines = [];
319
+ const files = [];
320
+ for (const node of hits) {
321
+ const deps = graph.dependents(node.id, depth);
322
+ // for files, also include dependents of nodes defined in the file
323
+ if (node.type === "file") {
324
+ for (const id of graph.byFile.get(node.file) ?? []) {
325
+ if (id === node.id) continue;
326
+ for (const d of graph.dependents(id, depth)) {
327
+ if (!deps.some((x) => x.node.id === d.node.id)) deps.push(d);
328
+ }
329
+ }
330
+ }
331
+ lines.push(`${node.type}: ${node.name} (${node.file ?? "-"}) — ${deps.length} dependent(s) within depth ${depth}:`);
332
+ deps.sort((a, b) => a.depth - b.depth);
333
+ for (const { node: n, depth: d } of deps) {
334
+ lines.push(` [d${d}] ${n.type} ${n.name} ${n.file ?? ""}`);
335
+ if (n.file) files.push(n.file);
336
+ }
337
+ if (!deps.length) lines.push(" (nothing depends on this — safe to change in isolation)");
338
+ }
339
+ return record(budget(lines, max_tokens), files, graph);
340
+ }
341
+
342
+ // ---------- reindex ----------
343
+ export function reindex(graphRef, { full = false } = {}) {
344
+ const root = graphRef.graph.repoRoot;
345
+ const res = full ? fullIndex(root) : incrementalIndex(root, graphRef.graph);
346
+ graphRef.graph = res.graph;
347
+ const s = res.graph.stats();
348
+ const warn = res.warnings.length ? ` warnings:${res.warnings.length}` : "";
349
+ const text = `reindex ${res.mode}: +${res.added ?? "?"} changed:${res.changed} removed:${res.removed} in ${res.ms}ms — graph now ${s.nodes} nodes / ${s.edges} edges${warn}`;
350
+ return record(text, [], res.graph);
351
+ }
352
+
353
+ function dedupe(arr, keyFn = (x) => x.id ?? x) {
354
+ const seen = new Set();
355
+ return arr.filter((x) => { const k = keyFn(x); if (seen.has(k)) return false; seen.add(k); return true; });
356
+ }
357
+ function dedupeLines(lines) { return [...new Set(lines)]; }