@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/indexer.js ADDED
@@ -0,0 +1,434 @@
1
+ // Indexer: walks the repo, parses files, builds the Graph, persists manifest
2
+ // for incremental re-index (spec R1, R2).
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { Graph } from "./graph.js";
8
+ import { parseJsTs, parsePython, normalizeEndpoint } from "./parsers.js";
9
+
10
+ const SKIP_DIRS = new Set([
11
+ "node_modules", ".git", "dist", "build", ".next", ".venv", "venv",
12
+ "__pycache__", ".codegraph", "coverage", ".turbo", ".cache", "out",
13
+ ]);
14
+ const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
15
+ const PY_EXT = new Set([".py"]);
16
+ const MAX_FILE_BYTES = 1_500_000;
17
+
18
+ export function walkFiles(root) {
19
+ const files = [];
20
+ (function walk(dir) {
21
+ let entries;
22
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
23
+ for (const e of entries) {
24
+ if (e.name.startsWith(".") && e.name !== ".env.example") {
25
+ if (e.isDirectory()) continue;
26
+ }
27
+ const full = path.join(dir, e.name);
28
+ if (e.isDirectory()) {
29
+ if (!SKIP_DIRS.has(e.name)) walk(full);
30
+ } else if (e.isFile()) {
31
+ files.push(full);
32
+ }
33
+ }
34
+ })(root);
35
+ return files;
36
+ }
37
+
38
+ function hashFile(full) {
39
+ return crypto.createHash("sha1").update(fs.readFileSync(full)).digest("hex");
40
+ }
41
+
42
+ function detectPackages(root, allFiles) {
43
+ const pkgs = [];
44
+ for (const f of allFiles) {
45
+ const base = path.basename(f);
46
+ if (!["package.json", "pyproject.toml", "requirements.txt"].includes(base)) continue;
47
+ const dir = path.dirname(f);
48
+ const rel = path.relative(root, dir) || ".";
49
+ let name = path.basename(dir === root ? root : dir);
50
+ let deps = [];
51
+ let kind = base === "package.json" ? "node" : "python";
52
+ try {
53
+ if (base === "package.json") {
54
+ const pj = JSON.parse(fs.readFileSync(f, "utf8"));
55
+ name = pj.name ?? name;
56
+ deps = Object.keys({ ...pj.dependencies }).slice(0, 20);
57
+ if (pj.workspaces) kind = "node-workspace-root";
58
+ } else if (base === "pyproject.toml") {
59
+ const src = fs.readFileSync(f, "utf8");
60
+ name = src.match(/^name\s*=\s*["']([^"']+)["']/m)?.[1] ?? name;
61
+ deps = [...src.matchAll(/^\s*["']?([A-Za-z][\w.-]+)["']?\s*(?:[><=~^].*)?,?\s*$/gm)]
62
+ .map((m) => m[1]).filter((d) => !["dependencies", "project", "name", "version", "python"].includes(d)).slice(0, 20);
63
+ } else {
64
+ deps = fs.readFileSync(f, "utf8").split("\n")
65
+ .map((l) => l.trim().split(/[><=~;[\s]/)[0]).filter(Boolean).filter((l) => !l.startsWith("#")).slice(0, 20);
66
+ }
67
+ } catch { /* unparseable manifest: keep defaults */ }
68
+ // avoid duplicate package per dir (pyproject + requirements)
69
+ if (!pkgs.some((p) => p.dir === rel)) pkgs.push({ dir: rel, name, kind, deps });
70
+ else pkgs.find((p) => p.dir === rel).deps.push(...deps);
71
+ }
72
+ return pkgs;
73
+ }
74
+
75
+ function pkgFor(pkgs, rel) {
76
+ let best = null;
77
+ for (const p of pkgs) {
78
+ if (p.dir === "." || rel === p.dir || rel.startsWith(p.dir + "/")) {
79
+ if (!best || p.dir.length > best.dir.length) best = p;
80
+ }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ // Resolve a relative import spec against the importing file. Returns rel path or null.
86
+ function resolveImport(root, fromRel, spec) {
87
+ if (!spec.startsWith(".")) return null; // package import
88
+ const baseDir = path.dirname(fromRel);
89
+ const target = path.normalize(path.join(baseDir, spec));
90
+ const candidates = [
91
+ target,
92
+ ...[".ts", ".tsx", ".js", ".jsx", ".mjs", ".py"].map((e) => target + e),
93
+ ...["index.ts", "index.tsx", "index.js", "index.jsx"].map((i) => path.join(target, i)),
94
+ ];
95
+ for (const c of candidates) {
96
+ if (fs.existsSync(path.join(root, c)) && fs.statSync(path.join(root, c)).isFile()) {
97
+ return c.split(path.sep).join("/");
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // ---------- per-file parsing into graph fragments ----------
104
+ function indexFile(graph, root, rel, pkgs, warnings) {
105
+ const full = path.join(root, rel);
106
+ const ext = path.extname(rel).toLowerCase();
107
+ if (!JS_EXT.has(ext) && !PY_EXT.has(ext)) return;
108
+ let src, bytes = 0;
109
+ try {
110
+ const st = fs.statSync(full);
111
+ if (st.size > MAX_FILE_BYTES) { warnings.push(`${rel}: too large, skipped`); return; }
112
+ bytes = st.size;
113
+ src = fs.readFileSync(full, "utf8");
114
+ } catch (e) { warnings.push(`${rel}: unreadable (${e.code ?? e.message})`); return; }
115
+
116
+ const fileId = `file:${rel}`;
117
+ graph.addNode({ id: fileId, type: "file", name: path.basename(rel), file: rel, extra: { bytes } });
118
+ const pkg = pkgFor(pkgs, rel);
119
+ if (pkg) graph.addEdge(`package:${pkg.dir}`, fileId, "contains");
120
+
121
+ try {
122
+ if (JS_EXT.has(ext)) indexJsFile(graph, root, rel, fileId, src);
123
+ else indexPyFile(graph, rel, fileId, src);
124
+ } catch (e) {
125
+ warnings.push(`${rel}: parse error (${e.message})`);
126
+ }
127
+ }
128
+
129
+ function indexJsFile(graph, root, rel, fileId, src) {
130
+ const parsed = parseJsTs(rel, src);
131
+
132
+ for (const spec of parsed.imports) {
133
+ const target = resolveImport(root, rel, spec);
134
+ if (target) {
135
+ const tid = `file:${target}`;
136
+ if (!graph.nodes.has(tid)) graph.addNode({ id: tid, type: "file", name: path.basename(target), file: target });
137
+ graph.addEdge(fileId, tid, "imports");
138
+ }
139
+ }
140
+
141
+ for (const comp of parsed.components) {
142
+ const cid = `component:${rel}#${comp}`;
143
+ graph.addNode({ id: cid, type: "component", name: comp, file: rel });
144
+ graph.addEdge(fileId, cid, "contains");
145
+ }
146
+ // rendered children + hooks: resolved to nodes in the link phase (names may live in other files)
147
+ graph._pendingRenders ??= [];
148
+ for (const comp of parsed.components) {
149
+ for (const child of parsed.rendered ?? []) {
150
+ if (child !== comp) graph._pendingRenders.push({ from: `component:${rel}#${comp}`, name: child, type: "renders" });
151
+ }
152
+ for (const hook of parsed.hooks ?? []) {
153
+ graph._pendingRenders.push({ from: `component:${rel}#${comp}`, name: hook, type: "uses" });
154
+ }
155
+ }
156
+
157
+ for (const r of parsed.routes) {
158
+ const rid = `route:${r.path}`;
159
+ graph.addNode({ id: rid, type: "route", name: r.path, file: rel, extra: { component: r.component } });
160
+ if (r.component) graph._pendingRenders.push({ from: rid, name: r.component, type: "renders" });
161
+ }
162
+
163
+ parsed.calls.forEach((c, i) => {
164
+ const eid = `endpoint-call:${rel}#${i}:${c.method} ${c.normPath}`;
165
+ graph.addNode({ id: eid, type: "endpoint-call", name: `${c.method} ${c.normPath}`, file: rel, extra: { method: c.method, raw: c.raw, normPath: c.normPath } });
166
+ graph.addEdge(fileId, eid, "contains");
167
+ // attribute the call to components in the same file (best-effort)
168
+ for (const comp of parsed.components) graph.addEdge(`component:${rel}#${comp}`, eid, "calls");
169
+ });
170
+
171
+ for (const ar of parsed.apiRoutes) {
172
+ addApiRoute(graph, rel, fileId, ar, ""); // express mounts resolved in link phase
173
+ }
174
+ if (parsed.mounts?.length) {
175
+ graph._mounts ??= [];
176
+ for (const m of parsed.mounts) graph._mounts.push({ file: rel, ...m });
177
+ }
178
+ }
179
+
180
+ function indexPyFile(graph, rel, fileId, src) {
181
+ const parsed = parsePython(rel, src);
182
+ graph._pyIncludes ??= [];
183
+ graph._pyImports ??= {};
184
+ graph._pyImports[rel] = parsed.pyImports;
185
+ for (const inc of parsed.includes) graph._pyIncludes.push({ file: rel, ...inc });
186
+ for (const ar of parsed.apiRoutes) {
187
+ const localPrefix = parsed.routerPrefixes[ar.routerVar] ?? "";
188
+ addApiRoute(graph, rel, fileId, ar, localPrefix);
189
+ }
190
+ }
191
+
192
+ function addApiRoute(graph, rel, fileId, ar, prefix) {
193
+ const fullPath = joinPaths(prefix, ar.path);
194
+ const norm = normalizeEndpoint(fullPath);
195
+ const rid = `api-route:${ar.method} ${norm}@${rel}`;
196
+ graph.addNode({
197
+ id: rid, type: "api-route", name: `${ar.method} ${fullPath}`, file: rel,
198
+ extra: { method: ar.method, path: fullPath, normPath: norm, framework: ar.framework, routerVar: ar.routerVar ?? null, rawPath: ar.path },
199
+ });
200
+ graph.addEdge(fileId, rid, "contains");
201
+ if (ar.handler) {
202
+ const hid = `handler:${rel}#${ar.handler}`;
203
+ if (!graph.nodes.has(hid)) graph.addNode({ id: hid, type: "handler", name: ar.handler, file: rel });
204
+ graph.addEdge(rid, hid, "handled-by");
205
+ }
206
+ }
207
+
208
+ export function joinPaths(a, b) {
209
+ const joined = `${a ?? ""}/${b ?? ""}`.replace(/\/{2,}/g, "/");
210
+ return joined === "" ? "/" : joined;
211
+ }
212
+
213
+ // ---------- link phase: cross-file resolution ----------
214
+ function linkGraph(graph, pkgs) {
215
+ // pending name-based edges (renders/uses)
216
+ for (const p of graph._pendingRenders ?? []) {
217
+ const targets = graph.findByName(p.name).filter((n) => n.type === "component" || p.type === "uses");
218
+ for (const t of targets.slice(0, 3)) graph.addEdge(p.from, t.id, p.type);
219
+ }
220
+ delete graph._pendingRenders;
221
+
222
+ // FastAPI include_router prefixes: re-prefix api-routes of the included router.
223
+ // The included name is often an alias (from .auth import router as auth_router),
224
+ // so resolve via the including file's imports to the defining file.
225
+ for (const inc of graph._pyIncludes ?? []) {
226
+ if (!inc.prefix) continue;
227
+ const imports = graph._pyImports?.[inc.file] ?? [];
228
+ const imp = imports.find((i) => i.alias === inc.router);
229
+ let targetFile = null;
230
+ let targetVar = inc.router;
231
+ if (imp) {
232
+ targetVar = imp.name; // original variable name in the defining module
233
+ targetFile = resolvePyModule(graph, inc.file, imp.module);
234
+ }
235
+ for (const n of [...graph.nodes.values()]) {
236
+ if (n.type !== "api-route" || n.extra?.framework !== "fastapi" || n.extra._mounted) continue;
237
+ const varMatch = n.extra.routerVar === targetVar || n.extra.routerVar === inc.router;
238
+ const fileMatch = !targetFile || n.file === targetFile;
239
+ if (varMatch && fileMatch) applyPrefix(graph, n, inc.prefix);
240
+ }
241
+ }
242
+ delete graph._pyIncludes;
243
+ delete graph._pyImports;
244
+
245
+ // Express mounts: app.use('/api', usersRouter) — prefix routes defined on `router` in imported files
246
+ for (const m of graph._mounts ?? []) {
247
+ const mountFile = graph.nodes.get(`file:${m.file}`);
248
+ if (!mountFile) continue;
249
+ const imported = graph.neighbors(mountFile.id, "out", ["imports"]).map((e) => e.node.file);
250
+ for (const n of [...graph.nodes.values()]) {
251
+ if (n.type !== "api-route" || n.extra?.framework !== "express" || n.extra._mounted) continue;
252
+ if (imported.includes(n.file)) applyPrefix(graph, n, m.prefix);
253
+ }
254
+ }
255
+ delete graph._mounts;
256
+
257
+ // hits-endpoint: endpoint-call -> api-route by method + normalized path (with :param wildcards)
258
+ const apiRoutes = [...graph.nodes.values()].filter((n) => n.type === "api-route");
259
+ for (const call of [...graph.nodes.values()].filter((n) => n.type === "endpoint-call")) {
260
+ for (const ar of apiRoutes) {
261
+ if (ar.extra.method !== call.extra.method) continue;
262
+ if (pathsMatch(call.extra.normPath, ar.extra.normPath)) {
263
+ graph.addEdge(call.id, ar.id, "hits-endpoint");
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // Resolve a python module spec (".auth", "app.users") relative to the importing
270
+ // file into a repo-relative .py path present in the graph.
271
+ function resolvePyModule(graph, fromRel, moduleSpec) {
272
+ const fromDir = fromRel.split("/").slice(0, -1).join("/");
273
+ const candidates = [];
274
+ if (moduleSpec.startsWith(".")) {
275
+ const ups = moduleSpec.match(/^\.+/)[0].length - 1;
276
+ const rest = moduleSpec.replace(/^\.+/, "").split(".").filter(Boolean);
277
+ let base = fromDir.split("/");
278
+ base = base.slice(0, base.length - ups);
279
+ candidates.push([...base, ...rest].join("/") + ".py");
280
+ candidates.push([...base, ...rest, "__init__"].join("/") + ".py");
281
+ } else {
282
+ const rest = moduleSpec.split(".");
283
+ candidates.push(rest.join("/") + ".py");
284
+ candidates.push([fromDir.split("/")[0], ...rest].join("/") + ".py");
285
+ // suffix match fallback: any indexed file ending in the module path
286
+ const suffix = "/" + rest.join("/") + ".py";
287
+ for (const f of graph.byFile.keys()) if (f.endsWith(suffix)) candidates.push(f);
288
+ }
289
+ for (const c of candidates) if (graph.byFile.has(c) || graph.nodes.has(`file:${c}`)) return c;
290
+ return null;
291
+ }
292
+
293
+ function applyPrefix(graph, node, prefix) {
294
+ const newPath = joinPaths(prefix, node.extra.rawPathWithLocal ?? node.extra.path);
295
+ const oldNorm = node.extra.normPath;
296
+ node.extra.path = newPath;
297
+ node.extra.normPath = normalizeEndpoint(newPath);
298
+ node.extra._mounted = true;
299
+ node.name = `${node.extra.method} ${newPath}`;
300
+ graph.byEndpointPath.get(oldNorm)?.delete(node.id);
301
+ if (!graph.byEndpointPath.has(node.extra.normPath)) graph.byEndpointPath.set(node.extra.normPath, new Set());
302
+ graph.byEndpointPath.get(node.extra.normPath).add(node.id);
303
+ }
304
+
305
+ // segment-wise match where :param matches any single segment on either side
306
+ export function pathsMatch(a, b) {
307
+ const sa = a.split("/").filter(Boolean);
308
+ const sb = b.split("/").filter(Boolean);
309
+ if (sa.length !== sb.length) return false;
310
+ for (let i = 0; i < sa.length; i++) {
311
+ if (sa[i] === sb[i]) continue;
312
+ if (sa[i] === ":param" || sb[i] === ":param") continue;
313
+ return false;
314
+ }
315
+ return true;
316
+ }
317
+
318
+ // ---------- top-level API ----------
319
+ export function fullIndex(root) {
320
+ const t0 = Date.now();
321
+ const graph = new Graph(root);
322
+ const warnings = [];
323
+ const allFiles = walkFiles(root);
324
+ const pkgs = detectPackages(root, allFiles);
325
+ for (const p of pkgs) {
326
+ graph.addNode({ id: `package:${p.dir}`, type: "package", name: p.name, file: p.dir, extra: { kind: p.kind, deps: p.deps } });
327
+ }
328
+ const manifest = {};
329
+ const rels = allFiles.map((f) => path.relative(root, f).split(path.sep).join("/"));
330
+ for (const rel of rels) {
331
+ const ext = path.extname(rel).toLowerCase();
332
+ if (!JS_EXT.has(ext) && !PY_EXT.has(ext)) continue;
333
+ manifest[rel] = hashFile(path.join(root, rel));
334
+ indexFile(graph, root, rel, pkgs, warnings);
335
+ }
336
+ linkGraph(graph, pkgs);
337
+ graph.save();
338
+ saveManifest(root, manifest);
339
+ return { graph, warnings, ms: Date.now() - t0, changed: Object.keys(manifest).length, added: Object.keys(manifest).length, removed: 0, mode: "full" };
340
+ }
341
+
342
+ export function incrementalIndex(root, graph) {
343
+ const t0 = Date.now();
344
+ const prev = loadManifest(root);
345
+ if (!prev || !graph) return fullIndex(root);
346
+ const warnings = [];
347
+ const allFiles = walkFiles(root);
348
+ const pkgs = detectPackages(root, allFiles);
349
+ const rels = new Set(
350
+ allFiles.map((f) => path.relative(root, f).split(path.sep).join("/"))
351
+ .filter((rel) => JS_EXT.has(path.extname(rel).toLowerCase()) || PY_EXT.has(path.extname(rel).toLowerCase()))
352
+ );
353
+ const next = {};
354
+ let changed = 0, added = 0, removed = 0;
355
+ for (const rel of rels) {
356
+ next[rel] = hashFile(path.join(root, rel));
357
+ if (!(rel in prev)) added++;
358
+ else if (prev[rel] !== next[rel]) changed++;
359
+ }
360
+ for (const rel of Object.keys(prev)) if (!rels.has(rel)) removed++;
361
+
362
+ if (changed + added + removed === 0) {
363
+ return { graph, warnings, ms: Date.now() - t0, changed, added, removed, mode: "incremental-noop" };
364
+ }
365
+ // Simplest correct incremental strategy: reparse only changed/added files,
366
+ // remove deleted ones, then redo the (cheap, in-memory) link phase.
367
+ for (const rel of Object.keys(prev)) {
368
+ if (!rels.has(rel) || prev[rel] !== next[rel]) graph.removeFile(rel);
369
+ }
370
+ // link-phase edges are name-based; drop and rebuild them to avoid stale links
371
+ graph.edges = graph.edges.filter((e) => !["renders", "uses", "hits-endpoint"].includes(e.type));
372
+ graph.rebuildAdjacency();
373
+ for (const rel of rels) {
374
+ if (!(rel in prev) || prev[rel] !== next[rel]) indexFile(graph, root, rel, pkgs, warnings);
375
+ }
376
+ // re-run pending link resolution against full node set
377
+ // (renders/uses edges from unchanged files were dropped; regenerate them by
378
+ // re-parsing is overkill — instead we re-link from stored component render info.
379
+ // To keep this correct without storing render lists, reparse unchanged JS files' link data only.)
380
+ relinkUnchanged(graph, root, rels, prev, next, pkgs, warnings);
381
+ linkGraph(graph, pkgs);
382
+ graph.save();
383
+ saveManifest(root, next);
384
+ return { graph, warnings, ms: Date.now() - t0, changed, added, removed, mode: "incremental" };
385
+ }
386
+
387
+ // Rebuild name-based pending links for unchanged files (cheap parse of already-hot files).
388
+ function relinkUnchanged(graph, root, rels, prev, next, pkgs, warnings) {
389
+ for (const rel of rels) {
390
+ if (rel in prev && prev[rel] === next[rel]) {
391
+ const ext = path.extname(rel).toLowerCase();
392
+ if (PY_EXT.has(ext)) {
393
+ try {
394
+ const src = fs.readFileSync(path.join(root, rel), "utf8");
395
+ const parsed = parsePython(rel, src);
396
+ graph._pyIncludes ??= [];
397
+ graph._pyImports ??= {};
398
+ graph._pyImports[rel] = parsed.pyImports;
399
+ for (const inc of parsed.includes) graph._pyIncludes.push({ file: rel, ...inc });
400
+ } catch (e) { warnings.push(`${rel}: relink error (${e.message})`); }
401
+ continue;
402
+ }
403
+ if (!JS_EXT.has(ext)) continue;
404
+ try {
405
+ const src = fs.readFileSync(path.join(root, rel), "utf8");
406
+ const parsed = parseJsTs(rel, src);
407
+ if (parsed.mounts?.length) {
408
+ graph._mounts ??= [];
409
+ for (const m of parsed.mounts) graph._mounts.push({ file: rel, ...m });
410
+ }
411
+ graph._pendingRenders ??= [];
412
+ for (const comp of parsed.components) {
413
+ for (const child of parsed.rendered ?? []) {
414
+ if (child !== comp) graph._pendingRenders.push({ from: `component:${rel}#${comp}`, name: child, type: "renders" });
415
+ }
416
+ for (const hook of parsed.hooks ?? []) {
417
+ graph._pendingRenders.push({ from: `component:${rel}#${comp}`, name: hook, type: "uses" });
418
+ }
419
+ }
420
+ for (const r of parsed.routes) {
421
+ if (r.component) graph._pendingRenders.push({ from: `route:${r.path}`, name: r.component, type: "renders" });
422
+ }
423
+ } catch (e) { warnings.push(`${rel}: relink error (${e.message})`); }
424
+ }
425
+ }
426
+ }
427
+
428
+ function saveManifest(root, manifest) {
429
+ fs.mkdirSync(Graph.storageDir(root), { recursive: true });
430
+ fs.writeFileSync(Graph.manifestPath(root), JSON.stringify(manifest));
431
+ }
432
+ function loadManifest(root) {
433
+ try { return JSON.parse(fs.readFileSync(Graph.manifestPath(root), "utf8")); } catch { return null; }
434
+ }
package/src/install.js ADDED
@@ -0,0 +1,73 @@
1
+ // `code-graph-mcp install <target>` — one-command client setup (spec R7).
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const SKILL_SRC = path.join(__dirname, "..", "skills", "code-graph");
8
+
9
+ const SERVER_ENTRY = {
10
+ command: "npx",
11
+ args: ["-y", "@ashkand/code-graph-mcp", "--repo", "."],
12
+ };
13
+
14
+ function mergeMcpJson(file, entryArgs) {
15
+ let config = { mcpServers: {} };
16
+ if (fs.existsSync(file)) {
17
+ try { config = JSON.parse(fs.readFileSync(file, "utf8")); }
18
+ catch { throw new Error(`${file} exists but is not valid JSON — fix it or remove it, then re-run install`); }
19
+ }
20
+ config.mcpServers ??= {};
21
+ const already = JSON.stringify(config.mcpServers["code-graph"]) === JSON.stringify(entryArgs);
22
+ config.mcpServers["code-graph"] = entryArgs; // idempotent overwrite of our own entry only
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
25
+ return already ? "unchanged" : "written";
26
+ }
27
+
28
+ function copySkill(destDir) {
29
+ fs.mkdirSync(destDir, { recursive: true });
30
+ fs.cpSync(SKILL_SRC, destDir, { recursive: true });
31
+ }
32
+
33
+ export function install(target, repo) {
34
+ const lines = [];
35
+ switch (target) {
36
+ case "claude-code": {
37
+ const mcpFile = path.join(repo, ".mcp.json");
38
+ const state = mergeMcpJson(mcpFile, SERVER_ENTRY);
39
+ lines.push(`${state}: ${mcpFile} (mcpServers.code-graph)`);
40
+ const skillDest = path.join(repo, ".claude", "skills", "code-graph");
41
+ copySkill(skillDest);
42
+ lines.push(`copied skill -> ${skillDest}`);
43
+ lines.push(`done. Restart Claude Code in ${repo} and ask: "where does <SomePage> land in the backend?"`);
44
+ break;
45
+ }
46
+ case "cursor": {
47
+ const mcpFile = path.join(repo, ".cursor", "mcp.json");
48
+ const state = mergeMcpJson(mcpFile, { ...SERVER_ENTRY, args: ["-y", "@ashkand/code-graph-mcp", "--repo", "${workspaceFolder}"] });
49
+ lines.push(`${state}: ${mcpFile} (mcpServers.code-graph)`);
50
+ lines.push("done. Reload Cursor; the code-graph tools appear under MCP.");
51
+ break;
52
+ }
53
+ default: {
54
+ lines.push(target ? `unknown target "${target}" — manual setup:` : "manual setup per client:");
55
+ lines.push("");
56
+ lines.push("Claude Code: code-graph-mcp install claude-code --repo <path>");
57
+ lines.push("Cursor: code-graph-mcp install cursor --repo <path>");
58
+ lines.push("");
59
+ lines.push("Claude Desktop (claude_desktop_config.json):");
60
+ lines.push(' "code-graph": { "command": "npx", "args": ["-y", "@ashkand/code-graph-mcp", "--repo", "/abs/path/to/repo"] }');
61
+ lines.push("");
62
+ lines.push("Codex CLI (~/.codex/config.toml):");
63
+ lines.push(" [mcp_servers.code-graph]");
64
+ lines.push(' command = "npx"');
65
+ lines.push(' args = ["-y", "@ashkand/code-graph-mcp", "--repo", "/abs/path/to/repo"]');
66
+ lines.push("");
67
+ lines.push("Claude.ai / Claude Desktop custom connector (remote):");
68
+ lines.push(" host with: npx -y @ashkand/code-graph-mcp --repo <path> --http --port 3333 --auth-token <secret>");
69
+ lines.push(" then add https://your-domain/mcp/<secret> in Settings > Connectors (see README).");
70
+ }
71
+ }
72
+ return lines.join("\n");
73
+ }
package/src/parsers.js ADDED
@@ -0,0 +1,158 @@
1
+ // Lightweight regex/heuristic parsers. Deliberately not a full AST:
2
+ // fast, zero native deps, and "good enough" for structural graphs.
3
+ // Every parser returns { components, routes, calls, imports, apiRoutes } fragments.
4
+
5
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
6
+
7
+ // ---------- shared: endpoint path normalization (spec E3) ----------
8
+ // `${API}/users/${id}` -> /users/:param ; /users/{user_id} -> /users/:param ; /users/:id -> /users/:param
9
+ export function normalizeEndpoint(raw) {
10
+ let p = raw.trim();
11
+ p = p.replace(/\$\{[^}]*\}/g, ":param"); // template literals
12
+ p = p.replace(/\{[^}]*\}/g, ":param"); // FastAPI-style {id}
13
+ p = p.replace(/:[A-Za-z_][\w]*/g, ":param"); // express-style :id
14
+ // strip scheme+host and leading base-var placeholders
15
+ p = p.replace(/^https?:\/\/[^/]+/i, "");
16
+ p = p.replace(/^:param(?=\/)/, ""); // `${API_BASE}/users` -> /users
17
+ if (!p.startsWith("/")) p = "/" + p;
18
+ p = p.replace(/\/{2,}/g, "/");
19
+ if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
20
+ p = p.split("?")[0];
21
+ return p.toLowerCase();
22
+ }
23
+
24
+ // ---------- JS/TS frontend ----------
25
+ export function parseJsTs(rel, src) {
26
+ const out = { components: [], routes: [], calls: [], imports: [], apiRoutes: [] };
27
+
28
+ // imports: import X from './y'; import { a, b } from "../z"; require('./w')
29
+ const importRe = /import\s+(?:[\w${},*\s]+?\s+from\s+)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/g;
30
+ for (const m of src.matchAll(importRe)) {
31
+ const spec = m[1] ?? m[2];
32
+ if (spec) out.imports.push(spec);
33
+ }
34
+
35
+ // components: exported PascalCase function/arrow/class
36
+ const compRes = [
37
+ /export\s+default\s+function\s+([A-Z]\w*)/g,
38
+ /export\s+(?:default\s+)?class\s+([A-Z]\w*)/g,
39
+ /export\s+function\s+([A-Z]\w*)/g,
40
+ /export\s+(?:default\s+)?const\s+([A-Z]\w*)\s*(?::[^=]+)?=\s*(?:React\.)?(?:memo\(|forwardRef\()?\s*(?:async\s*)?(?:function|\()/g,
41
+ /(?:^|\n)\s*(?:const|function)\s+([A-Z]\w*)\s*(?:=\s*)?\((?:[^)]*)\)\s*(?:=>)?\s*\{[\s\S]{0,400}?return\s*\(?\s*</g,
42
+ ];
43
+ const seen = new Set();
44
+ for (const re of compRes) {
45
+ for (const m of src.matchAll(re)) {
46
+ if (!seen.has(m[1])) { seen.add(m[1]); out.components.push(m[1]); }
47
+ }
48
+ }
49
+
50
+ // hooks used (custom useXxx) — recorded as "uses" targets
51
+ out.hooks = [...new Set([...src.matchAll(/\buse[A-Z]\w*/g)].map((m) => m[0]))]
52
+ .filter((h) => !["useState", "useEffect", "useMemo", "useCallback", "useRef", "useContext", "useReducer", "useLayoutEffect", "useId", "useTransition"].includes(h));
53
+
54
+ // JSX children rendered: <PascalCase
55
+ out.rendered = [...new Set([...src.matchAll(/<([A-Z]\w*)[\s/>]/g)].map((m) => m[1]))]
56
+ .filter((c) => !["Fragment", "React", "Suspense", "StrictMode"].includes(c));
57
+
58
+ // React Router: <Route path="/x" element={<Comp/>}> (attrs in either order)
59
+ const routeTagRe = /<Route\b[^>]*>/g;
60
+ for (const m of src.matchAll(routeTagRe)) {
61
+ const tag = m[0];
62
+ const p = tag.match(/path\s*=\s*["']([^"']+)["']/);
63
+ const el = tag.match(/element\s*=\s*\{\s*<([A-Z]\w*)/) ?? tag.match(/component\s*=\s*\{\s*([A-Z]\w*)/);
64
+ if (p) out.routes.push({ path: p[1], component: el?.[1] ?? null });
65
+ }
66
+ // createBrowserRouter([{ path: '/x', element: <Comp/> }, ...])
67
+ const objRouteRe = /path\s*:\s*["']([^"']+)["'][\s\S]{0,200}?(?:element\s*:\s*<([A-Z]\w*)|Component\s*:\s*([A-Z]\w*))/g;
68
+ for (const m of src.matchAll(objRouteRe)) {
69
+ out.routes.push({ path: m[1], component: m[2] ?? m[3] ?? null });
70
+ }
71
+
72
+ // HTTP calls
73
+ // fetch('...', { method: 'POST' }) / fetch(`...`)
74
+ const fetchRe = /fetch\s*\(\s*(["'`])([\s\S]*?)\1\s*(?:,\s*\{([\s\S]{0,300}?)\})?/g;
75
+ for (const m of src.matchAll(fetchRe)) {
76
+ const url = m[2];
77
+ if (!looksLikeUrlPath(url)) continue;
78
+ const method = m[3]?.match(/method\s*:\s*["'`](\w+)["'`]/i)?.[1]?.toUpperCase() ?? "GET";
79
+ out.calls.push({ method, raw: url, normPath: normalizeEndpoint(url) });
80
+ }
81
+ // axios.get('...') / api.post(`...`) / client.delete('...')
82
+ const clientRe = new RegExp(String.raw`\b(\w+)\.(${HTTP_METHODS.join("|")})\s*\(\s*(["'\`])([\s\S]*?)\3`, "g");
83
+ for (const m of src.matchAll(clientRe)) {
84
+ const [, obj, method, , url] = m;
85
+ if (obj === "router" || obj === "app" || obj === "server") continue; // express server-side, handled below
86
+ if (!looksLikeUrlPath(url)) continue;
87
+ out.calls.push({ method: method.toUpperCase(), raw: url, normPath: normalizeEndpoint(url), client: obj });
88
+ }
89
+ // axios({ url: '...', method: 'post' })
90
+ const axiosCfgRe = /axios\s*\(\s*\{([\s\S]{0,400}?)\}\s*\)/g;
91
+ for (const m of src.matchAll(axiosCfgRe)) {
92
+ const url = m[1].match(/url\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
93
+ if (!url || !looksLikeUrlPath(url)) continue;
94
+ const method = m[1].match(/method\s*:\s*["'`](\w+)["'`]/)?.[1]?.toUpperCase() ?? "GET";
95
+ out.calls.push({ method, raw: url, normPath: normalizeEndpoint(url) });
96
+ }
97
+
98
+ // Express backend routes in JS: app.get('/x', handler) / router.post('/y', fn)
99
+ const expressRe = new RegExp(String.raw`\b(app|router|server)\.(${HTTP_METHODS.join("|")})\s*\(\s*(["'\`])([^"'\`]+)\3\s*,\s*([\w.]+|async|\()`, "g");
100
+ for (const m of src.matchAll(expressRe)) {
101
+ const [, , method, , p, handlerTok] = m;
102
+ const handler = /^[\w.]+$/.test(handlerTok) && !["async"].includes(handlerTok) ? handlerTok : null;
103
+ out.apiRoutes.push({ method: method.toUpperCase(), path: p, handler, framework: "express" });
104
+ }
105
+ // app.use('/prefix', someRouter)
106
+ out.mounts = [];
107
+ const useRe = /\b(?:app|server)\.use\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*(\w+)/g;
108
+ for (const m of src.matchAll(useRe)) out.mounts.push({ prefix: m[1], router: m[2] });
109
+
110
+ return out;
111
+ }
112
+
113
+ function looksLikeUrlPath(s) {
114
+ return /^(https?:\/\/|\/|`?\$\{)/.test(s) || s.startsWith("${") || /^[\w-]+\/[\w-]/.test(s) === false && s.includes("/");
115
+ }
116
+
117
+ // ---------- Python backend (FastAPI / Flask) ----------
118
+ export function parsePython(rel, src) {
119
+ const out = { apiRoutes: [], routerPrefixes: {}, includes: [], pyImports: [] };
120
+
121
+ // from .auth import router as auth_router / from app.users import router
122
+ const fromImportRe = /from\s+([\w.]+)\s+import\s+([\w,\s]+?)(?:\n|$)/g;
123
+ for (const m of src.matchAll(fromImportRe)) {
124
+ for (const part of m[2].split(",")) {
125
+ const seg = part.trim().match(/^(\w+)(?:\s+as\s+(\w+))?$/);
126
+ if (seg) out.pyImports.push({ module: m[1], name: seg[1], alias: seg[2] ?? seg[1] });
127
+ }
128
+ }
129
+
130
+ // router = APIRouter(prefix="/auth")
131
+ const apiRouterRe = /(\w+)\s*=\s*APIRouter\s*\(([^)]*)\)/g;
132
+ for (const m of src.matchAll(apiRouterRe)) {
133
+ const prefix = m[2].match(/prefix\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
134
+ out.routerPrefixes[m[1]] = prefix;
135
+ }
136
+ // app.include_router(auth_router, prefix="/api")
137
+ const includeRe = /\.include_router\s*\(\s*([\w.]+)([^)]*)\)/g;
138
+ for (const m of src.matchAll(includeRe)) {
139
+ const prefix = m[2].match(/prefix\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
140
+ out.includes.push({ router: m[1].split(".").pop(), prefix });
141
+ }
142
+ // @app.get("/path") / @router.post("/path") followed by def name(
143
+ const decoRe = new RegExp(String.raw`@(\w+)\.(${HTTP_METHODS.join("|")})\s*\(\s*["']([^"']+)["'][^)]*\)\s*(?:@[\w.()\s"',=]+\s*)*(?:async\s+)?def\s+(\w+)`, "g");
144
+ for (const m of src.matchAll(decoRe)) {
145
+ const [, obj, method, p, fn] = m;
146
+ out.apiRoutes.push({ method: method.toUpperCase(), path: p, handler: fn, routerVar: obj, framework: "fastapi" });
147
+ }
148
+ // Flask: @app.route("/path", methods=["POST"]) def fn
149
+ const flaskRe = /@(\w+)\.route\s*\(\s*["']([^"']+)["']([^)]*)\)\s*(?:async\s+)?def\s+(\w+)/g;
150
+ for (const m of src.matchAll(flaskRe)) {
151
+ const methods = m[3].match(/methods\s*=\s*\[([^\]]+)\]/)?.[1]
152
+ ?.split(",").map((s) => s.replace(/["'\s]/g, "").toUpperCase()) ?? ["GET"];
153
+ for (const method of methods) {
154
+ out.apiRoutes.push({ method, path: m[2], handler: m[4], routerVar: m[1], framework: "flask" });
155
+ }
156
+ }
157
+ return out;
158
+ }