@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dorkian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,280 @@
1
+ # code-graph-mcp
2
+
3
+ Local code-intelligence MCP server for mono-repos. It indexes your repo once
4
+ into a knowledge graph — packages, components, frontend routes, HTTP calls,
5
+ backend routes, handlers — and lets any MCP client (Claude Code, Cursor, Codex
6
+ CLI, OpenCode…) query structure directly instead of grepping and re-reading
7
+ files on every question.
8
+
9
+ Ask "where does `LoginPage` land in the backend?" and get
10
+ `LoginPage → POST /api/auth/login → auth.login` in one tool call, from a graph
11
+ that's already in memory. No file reads, no grep round-trips.
12
+
13
+ ## Why
14
+
15
+ Agents burn most of their tokens re-discovering structure: listing directories,
16
+ grepping for usages, opening five files to trace one request path. This server
17
+ front-loads that discovery into a single index pass and then answers structural
18
+ questions from memory, with output engineered to be cheap:
19
+
20
+ - **Compact line-oriented output** — no pretty-printed JSON, no prose padding.
21
+ - **Hard token budgets per response** (default ~2k tokens, tunable per call)
22
+ with `…+N more (refine query)` truncation instead of overflow.
23
+ - **Path compression** — repo-relative paths, common prefixes stripped in lists.
24
+ - **Incremental re-index** — content-hash manifest; unchanged files are never
25
+ reparsed.
26
+ - **Savings tracking** — `repo_summary` reports how many tokens of raw file
27
+ reads the session avoided.
28
+ - **Zero query-time I/O** — every tool answers from in-memory indexes; only
29
+ `reindex` touches disk.
30
+
31
+ ## What it detects
32
+
33
+ | Layer | Detected |
34
+ |---|---|
35
+ | Packages | npm workspaces, any `package.json`, `pyproject.toml`, `requirements.txt` |
36
+ | Frontend | React Router routes (`<Route>`, `createBrowserRouter`), exported components, imports, rendered children, custom hooks/stores |
37
+ | HTTP calls | `fetch()`, `axios.<method>()`, `axios({...})`, generic `client.get/post/...()`, template-literal URLs normalized (`` `${API}/users/${id}` `` → `/users/:param`) |
38
+ | Backend | FastAPI (`@app.get`, `APIRouter(prefix=…)`, `include_router(…, prefix=…)` incl. import aliases), Flask (`@app.route`), Express (`app.get`, `router.post`, `app.use('/prefix', router)`) |
39
+ | Cross-layer | frontend call ↔ backend route matching by method + normalized path, `:param`/`{id}`/`${id}` all treated as wildcards |
40
+
41
+ Parsing is regex/heuristic (deliberately: fast, zero native deps). It won't
42
+ catch every dynamic pattern — that's the tradeoff for indexing thousands of
43
+ files in milliseconds with two pure-JS dependencies.
44
+
45
+ ## Install
46
+
47
+ ### Fastest: let the CLI configure your client
48
+
49
+ ```bash
50
+ # Claude Code — writes .mcp.json AND installs the companion skill
51
+ npx -y @ashkand/code-graph-mcp install claude-code --repo .
52
+
53
+ # Cursor — writes .cursor/mcp.json
54
+ npx -y @ashkand/code-graph-mcp install cursor --repo .
55
+
56
+ # anything else — prints ready-to-paste config for Claude Desktop, Codex CLI, …
57
+ npx -y @ashkand/code-graph-mcp install
58
+ ```
59
+
60
+ No npm? Every command also works straight from the repo by swapping
61
+ `@ashkand/code-graph-mcp` for `github:dorkian/code-graph-mcp`.
62
+
63
+ That's it — restart your client and ask *"where does LoginPage land in the backend?"*
64
+
65
+ ### Or: let your AI install it
66
+
67
+ Paste this into Claude Code, Cursor, Codex, or any coding agent with shell access, from inside the repo you want indexed:
68
+
69
+ ```text
70
+ Install the code-graph-mcp server in this repo:
71
+ 1. Run: npx -y @ashkand/code-graph-mcp install claude-code --repo .
72
+ (if this isn't Claude Code, run `npx -y @ashkand/code-graph-mcp install`
73
+ and apply the printed config for the client you are instead)
74
+ 2. Add `.codegraph/` to .gitignore if it isn't there.
75
+ 3. Run: npx -y @ashkand/code-graph-mcp --repo . --index
76
+ and show me the node/edge counts it reports.
77
+ 4. From now on, prefer the code-graph MCP tools (repo_summary,
78
+ find_component_relations, map_frontend_to_backend, search_symbols,
79
+ impact_of_change, visualize_graph) over grep/read_file for questions
80
+ about repo structure, and call reindex after you edit files.
81
+ ```
82
+
83
+ Step 4 is a summary of the bundled skill — Claude Code users get the full version automatically at `.claude/skills/code-graph/SKILL.md`.
84
+
85
+ ### Manual
86
+
87
+ ```bash
88
+ git clone https://github.com/dorkian/code-graph-mcp.git
89
+ cd code-graph-mcp && npm install
90
+ node bin/code-graph-mcp.js --repo /path/to/your/repo
91
+ ```
92
+
93
+ The graph lives at `<repo>/.codegraph/` — add it to your repo's `.gitignore`.
94
+ Pre-warm the cache (e.g. in CI or a git hook):
95
+
96
+ ```bash
97
+ code-graph-mcp --repo . --index # incremental
98
+ code-graph-mcp --repo . --index --full # rebuild from scratch
99
+ ```
100
+
101
+ ## Client setup (manual configs)
102
+
103
+ <details>
104
+ <summary>Claude Code (.mcp.json)</summary>
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "code-graph": {
110
+ "command": "npx",
111
+ "args": ["-y", "@ashkand/code-graph-mcp", "--repo", "."]
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ Then copy `skills/code-graph/` into `.claude/skills/` (the `install claude-code` command does both).
118
+ </details>
119
+
120
+ <details>
121
+ <summary>Cursor (.cursor/mcp.json)</summary>
122
+
123
+ ```json
124
+ {
125
+ "mcpServers": {
126
+ "code-graph": {
127
+ "command": "npx",
128
+ "args": ["-y", "@ashkand/code-graph-mcp", "--repo", "${workspaceFolder}"]
129
+ }
130
+ }
131
+ }
132
+ ```
133
+ </details>
134
+
135
+ <details>
136
+ <summary>Claude Desktop (claude_desktop_config.json)</summary>
137
+
138
+ ```json
139
+ {
140
+ "mcpServers": {
141
+ "code-graph": {
142
+ "command": "npx",
143
+ "args": ["-y", "@ashkand/code-graph-mcp", "--repo", "/abs/path/to/repo"]
144
+ }
145
+ }
146
+ }
147
+ ```
148
+ </details>
149
+
150
+ <details>
151
+ <summary>Codex CLI (~/.codex/config.toml)</summary>
152
+
153
+ ```toml
154
+ [mcp_servers.code-graph]
155
+ command = "npx"
156
+ args = ["-y", "@ashkand/code-graph-mcp", "--repo", "/abs/path/to/repo"]
157
+ ```
158
+ </details>
159
+
160
+ Any other MCP client: it's a plain stdio server — run
161
+ `node bin/code-graph-mcp.js --repo <path>` and speak MCP over stdin/stdout.
162
+
163
+ ## Use it as a Claude custom connector (claude.ai / Desktop / mobile)
164
+
165
+ Claude's custom connectors only accept **remote** MCP servers: Claude connects
166
+ to your server URL from Anthropic's cloud, not from your machine, so a local
167
+ stdio process can't be added directly — the server must be reachable on the
168
+ public internet over Streamable HTTP. That's what `--http` mode is for:
169
+
170
+ ```bash
171
+ # on your VPS, next to a checkout of the repo you want indexed
172
+ npx -y @ashkand/code-graph-mcp --repo /srv/my-monorepo \
173
+ --http --port 3333 --auth-token "$(openssl rand -hex 24)"
174
+ ```
175
+
176
+ Put it behind your reverse proxy with TLS (Caddy example):
177
+
178
+ ```caddy
179
+ graph.yourdomain.com {
180
+ reverse_proxy 127.0.0.1:3333
181
+ }
182
+ ```
183
+
184
+ Then in Claude: **Settings → Connectors → Add custom connector** and paste
185
+
186
+ ```
187
+ https://graph.yourdomain.com/mcp/<your-token>
188
+ ```
189
+
190
+ The token-in-URL form exists exactly for this: the connector UI takes a plain
191
+ URL (or OAuth), so the secret rides in the path; API/CLI clients can send
192
+ `Authorization: Bearer <token>` instead. Keep in mind what you're exposing —
193
+ the graph reveals your repo's structure (routes, endpoints, file names), so
194
+ treat the URL like a password, rotate the token if it leaks, and never run
195
+ `--http` without a token on anything internet-facing. Run it as a systemd
196
+ service and add a cron/git-hook `--index` call to keep the graph fresh after
197
+ pushes.
198
+
199
+ **Who can use your hosted URL — and what they get.** Anyone you share
200
+ `https://graph.yourdomain.com/mcp/<token>` with can add it as a custom
201
+ connector in their own Claude (Settings → Connectors → Add custom connector;
202
+ available on all plans — Free accounts can add one custom connector). But be
203
+ clear about the model: one running instance indexes **one repo on your
204
+ server**, and every connected user queries **that same shared graph**. It is
205
+ not multi-tenant — users can't point your instance at their own codebases.
206
+ Host it to demo the tool or to give a team a shared brain for one codebase;
207
+ for their own repos, users run their own instance (stdio locally via the
208
+ install command, or `--http` on their own box).
209
+
210
+ ## Tools
211
+
212
+ | Tool | Purpose | Key params |
213
+ |---|---|---|
214
+ | `repo_summary` | Packages, deps, FE/BE routes, counts, session token savings | `detail`, `max_tokens` |
215
+ | `find_component_relations` | Everything related to a component/route: children, parents, hooks, endpoints, imports | `name`, `detail` |
216
+ | `map_frontend_to_backend` | Full chain `Component → HTTP call → backend route → handler`; works from either end | `name` (component, route, or `/path`) |
217
+ | `visualize_graph` | Mermaid/DOT slice: `component-tree` or `fe-to-be` | `name`, `kind`, `format`, `max_nodes` |
218
+ | `search_symbols` | Fuzzy find any node by name/path — replaces grep for "where is X" | `query`, `types`, `limit` |
219
+ | `impact_of_change` | Blast radius: transitive dependents of a node or file | `name`, `depth` |
220
+ | `reindex` | Refresh graph; incremental by default | `full` |
221
+
222
+ Every tool accepts `max_tokens` (response budget) and, where lists appear,
223
+ `detail: "compact" | "full"`.
224
+
225
+ Example output (`map_frontend_to_backend`, `name: "LoginPage"`):
226
+
227
+ ```
228
+ LoginPage -> POST /api/auth/login -> POST /api/auth/login (services/api/app/auth.py) -> auth.login
229
+ ```
230
+
231
+ Example diagram (`visualize_graph`, `kind: "fe-to-be"`):
232
+
233
+ ```mermaid
234
+ graph LR
235
+ n1(("route /login")) --> n2["LoginPage"]
236
+ n2 -- calls --> n3["POST /api/auth/login"]
237
+ n3 -- hits --> n4[/"POST /api/auth/login"/]
238
+ n4 -- handled by --> n5[["fn login"]]
239
+ ```
240
+
241
+ ## Storage format
242
+
243
+ `.codegraph/graph.json` — versioned node/edge lists, loaded into in-memory
244
+ Maps (by id, name, file, endpoint path) at startup; a ~1k-file repo loads in
245
+ well under a second. `.codegraph/manifest.json` — sha1 per indexed file,
246
+ drives incremental re-index and deleted-file pruning. Corrupted or missing
247
+ graph files trigger a clean full re-index, never a crash.
248
+
249
+ ## Development
250
+
251
+ ```bash
252
+ npm install
253
+ npm test # 57 unit assertions against a bundled fixture mono-repo,
254
+ # an MCP smoke test speaking real JSON-RPC over stdio,
255
+ # and an HTTP smoke test covering --http mode + token auth
256
+ ```
257
+
258
+ The fixture under `test/fixture/` is a miniature mono-repo (React app, FastAPI
259
+ service, Express service) exercising every detection path including
260
+ `include_router` alias resolution and template-literal endpoints.
261
+
262
+ ## Publishing this repo to GitHub
263
+
264
+ Credentials never leave your machine, so push it yourself:
265
+
266
+ ```bash
267
+ cd code-graph-mcp
268
+ git init && git add -A && git commit -m "code-graph-mcp v0.2.0"
269
+ git branch -M main
270
+ git remote add origin https://github.com/dorkian/code-graph-mcp.git
271
+ git push -u origin main
272
+
273
+ # optional: publish to npm so npx works
274
+ npm login
275
+ npm publish --access public
276
+ ```
277
+
278
+ ## License
279
+
280
+ MIT © dorkian
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // code-graph-mcp CLI
3
+ // code-graph-mcp --repo <path> MCP server (stdio)
4
+ // code-graph-mcp --repo <path> --http [--port N] [--auth-token T]
5
+ // MCP server (Streamable HTTP)
6
+ // code-graph-mcp --repo <path> --index [--full] index only and exit
7
+ // code-graph-mcp install claude-code|cursor [--repo p] configure a client
8
+ import path from "node:path";
9
+ import fs from "node:fs";
10
+ import { fullIndex, incrementalIndex } from "../src/indexer.js";
11
+ import { Graph } from "../src/graph.js";
12
+ import { startServer, startHttpServer } from "../src/server.js";
13
+ import { install } from "../src/install.js";
14
+
15
+ const args = process.argv.slice(2);
16
+ function flag(name) { return args.includes(name); }
17
+ function opt(name, dflt) {
18
+ const i = args.indexOf(name);
19
+ return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
20
+ }
21
+
22
+ if (flag("--help") || flag("-h")) {
23
+ console.log(`code-graph-mcp — local code-intelligence MCP server
24
+
25
+ Usage:
26
+ code-graph-mcp --repo <path> start MCP server (stdio)
27
+ code-graph-mcp --repo <path> --http start MCP server (Streamable HTTP)
28
+ [--port 3333] [--host 127.0.0.1] [--auth-token <secret>]
29
+ code-graph-mcp --repo <path> --index [--full] build/refresh index and exit
30
+ code-graph-mcp install claude-code [--repo <path>] write .mcp.json + copy skill
31
+ code-graph-mcp install cursor [--repo <path>] write .cursor/mcp.json
32
+ code-graph-mcp install print setup for other clients
33
+
34
+ The graph is stored at <repo>/.codegraph/ (add it to .gitignore).`);
35
+ process.exit(0);
36
+ }
37
+
38
+ const repo = path.resolve(opt("--repo", process.env.CODE_GRAPH_REPO ?? process.cwd()));
39
+
40
+ if (args[0] === "install") {
41
+ try { console.log(install(args[1], repo)); process.exit(0); }
42
+ catch (e) { console.error(`install failed: ${e.message}`); process.exit(1); }
43
+ }
44
+
45
+ if (!fs.existsSync(repo) || !fs.statSync(repo).isDirectory()) {
46
+ console.error(`[code-graph-mcp] not a directory: ${repo}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ if (flag("--index")) {
51
+ const existing = flag("--full") ? null : Graph.load(repo);
52
+ const res = existing ? incrementalIndex(repo, existing) : fullIndex(repo);
53
+ const s = res.graph.stats();
54
+ console.log(`indexed (${res.mode}): ${s.nodes} nodes, ${s.edges} edges in ${res.ms}ms; warnings: ${res.warnings.length}`);
55
+ for (const w of res.warnings.slice(0, 10)) console.log(` warn: ${w}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ if (flag("--http")) {
60
+ startHttpServer(repo, {
61
+ port: Number(opt("--port", 3333)),
62
+ host: opt("--host", "127.0.0.1"),
63
+ token: opt("--auth-token", null),
64
+ }).catch((e) => { console.error("[code-graph-mcp] fatal:", e); process.exit(1); });
65
+ } else {
66
+ startServer(repo).catch((e) => { console.error("[code-graph-mcp] fatal:", e); process.exit(1); });
67
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@ashkand/code-graph-mcp",
3
+ "version": "0.2.2",
4
+ "description": "Local code-intelligence MCP server: index a mono-repo into a knowledge graph (routes, components, API calls, backend handlers) and answer structural questions in a fraction of the tokens.",
5
+ "type": "module",
6
+ "bin": {
7
+ "code-graph-mcp": "bin/code-graph-mcp.js"
8
+ },
9
+ "main": "src/server.js",
10
+ "scripts": {
11
+ "start": "node bin/code-graph-mcp.js",
12
+ "test": "node test/run-tests.js && node test/mcp-smoke.js && node test/http-smoke.js"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "code-intelligence",
18
+ "knowledge-graph",
19
+ "claude-code",
20
+ "cursor",
21
+ "monorepo",
22
+ "token-optimization"
23
+ ],
24
+ "author": "dorkian",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/dorkian/code-graph-mcp.git"
29
+ },
30
+ "homepage": "https://github.com/dorkian/code-graph-mcp#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/dorkian/code-graph-mcp/issues"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "files": [
38
+ "bin/",
39
+ "src/",
40
+ "skills/",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.29.0",
46
+ "zod": "^3.25.76"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: code-graph
3
+ description: >-
4
+ Answer questions about repo structure, architecture, and frontend-to-backend
5
+ flows by querying the code-graph-mcp tools instead of grepping and reading
6
+ files. Use this skill whenever the user asks where something is defined, what
7
+ uses or depends on a component, which backend endpoint or handler a page/route
8
+ hits, what a repo or package contains, what breaks if a file changes, or wants
9
+ a diagram of a component tree or FE→BE flow. Also use it BEFORE editing code
10
+ when adding a feature, to pick the right files with repo_summary and
11
+ find_component_relations first. Do NOT fall back to read_file/grep for these
12
+ structural questions while the code-graph MCP server is connected — read files
13
+ only after the graph has named the exact files worth reading.
14
+ ---
15
+
16
+ # Code Graph
17
+
18
+ The `code-graph-mcp` server holds a pre-built knowledge graph of this repo:
19
+ packages, files, components, frontend routes, HTTP calls, backend routes, and
20
+ handler functions, with the edges between them. One tool call against the graph
21
+ replaces a grep plus several file reads — use it first, read files second.
22
+
23
+ ## Question → tool mapping
24
+
25
+ | The user asks… | Call |
26
+ |---|---|
27
+ | "Where does login land in the backend?" / "Which handler serves /users/:id?" | `map_frontend_to_backend` with the component, route, or path |
28
+ | "What uses `Button`?" / "What does `Dashboard` depend on?" | `find_component_relations` |
29
+ | "What's in this repo?" / "Which packages/services exist?" | `repo_summary` |
30
+ | "Where is X defined?" | `search_symbols` (never grep for this) |
31
+ | "What breaks if I change X?" / before a refactor | `impact_of_change` |
32
+ | "Show me the component tree / the flow" | `visualize_graph` |
33
+ | Results look stale after edits | `reindex` (incremental, cheap) |
34
+
35
+ ## Workflow: answering a structural question
36
+
37
+ 1. Call the single most specific tool from the table — not `repo_summary`
38
+ first "for context" unless the question is actually repo-wide.
39
+ 2. Answer from the tool output. Only `read_file` the specific files the graph
40
+ named, and only if the user needs code-level detail beyond structure.
41
+ 3. If a name isn't found, the tool returns closest matches — pick the right
42
+ one and retry rather than switching to grep.
43
+
44
+ ## Workflow: adding a feature or fixing a bug
45
+
46
+ 1. `repo_summary` — identify the right package/service.
47
+ 2. `find_component_relations` on the nearest existing component/route — see
48
+ its hooks, children, and endpoints so the new code matches local patterns.
49
+ 3. `map_frontend_to_backend` if the change crosses the FE/BE boundary — know
50
+ the handler before touching the client call (and vice versa).
51
+ 4. `impact_of_change` on anything you're about to modify — list the blast
52
+ radius and check the dependents after editing.
53
+ 5. Only now read/edit the specific files identified. After substantial edits,
54
+ call `reindex` so later questions in the session stay accurate.
55
+
56
+ ## Token discipline
57
+
58
+ - Default `detail: "compact"` is almost always enough; only pass
59
+ `detail: "full"` when the compact output was truncated on the part you need.
60
+ - Outputs end with `…+N more (refine query…)` when capped — refine (add
61
+ `types`, a more specific name) rather than immediately raising `max_tokens`.
62
+ - Don't re-call a tool you already called with the same arguments in this
63
+ conversation; the graph doesn't change unless files change.
64
+
65
+ ## Diagrams
66
+
67
+ `visualize_graph` returns a ready `graph LR` Mermaid block (or DOT with
68
+ `format: "dot"`). Paste the Mermaid block into the reply inside a
69
+ ```mermaid fenced code block so UIs that support it render the diagram; when
70
+ the interface can't render Mermaid, summarize the same slice as an indented
71
+ ASCII tree instead. Use `kind: "fe-to-be"` for request-flow questions and
72
+ `kind: "component-tree"` for UI hierarchy questions.
package/src/graph.js ADDED
@@ -0,0 +1,193 @@
1
+ // Graph store for code-graph-mcp.
2
+ // Nodes: { id, type, name, file?, extra? }
3
+ // Edges: { from, to, type }
4
+ // Persisted as JSON (.codegraph/graph.json); indexed in memory for O(1) lookups.
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ export const NODE_TYPES = [
10
+ "package", "file", "component", "route", "endpoint-call", "api-route", "handler",
11
+ ];
12
+
13
+ export class Graph {
14
+ constructor(repoRoot) {
15
+ this.repoRoot = repoRoot;
16
+ this.nodes = new Map(); // id -> node
17
+ this.edges = []; // { from, to, type }
18
+ this.byName = new Map(); // lowercased name -> Set<id>
19
+ this.byFile = new Map(); // relPath -> Set<id> (nodes defined in file)
20
+ this.out = new Map(); // id -> [{to,type}]
21
+ this.in = new Map(); // id -> [{from,type}]
22
+ this.byEndpointPath = new Map(); // normalized path -> Set<id> (api-route + endpoint-call)
23
+ }
24
+
25
+ // ---------- mutation ----------
26
+ addNode(node) {
27
+ this.nodes.set(node.id, node);
28
+ if (node.name) {
29
+ const key = node.name.toLowerCase();
30
+ if (!this.byName.has(key)) this.byName.set(key, new Set());
31
+ this.byName.get(key).add(node.id);
32
+ }
33
+ if (node.file) {
34
+ if (!this.byFile.has(node.file)) this.byFile.set(node.file, new Set());
35
+ this.byFile.get(node.file).add(node.id);
36
+ }
37
+ if ((node.type === "api-route" || node.type === "endpoint-call") && node.extra?.normPath) {
38
+ const k = node.extra.normPath;
39
+ if (!this.byEndpointPath.has(k)) this.byEndpointPath.set(k, new Set());
40
+ this.byEndpointPath.get(k).add(node.id);
41
+ }
42
+ return node;
43
+ }
44
+
45
+ addEdge(from, to, type) {
46
+ if (!this.nodes.has(from) || !this.nodes.has(to)) return;
47
+ this.edges.push({ from, to, type });
48
+ if (!this.out.has(from)) this.out.set(from, []);
49
+ this.out.get(from).push({ to, type });
50
+ if (!this.in.has(to)) this.in.set(to, []);
51
+ this.in.get(to).push({ from, type });
52
+ }
53
+
54
+ // Remove every node defined in `relPath` (and its edges). Used by incremental reindex.
55
+ removeFile(relPath) {
56
+ const ids = this.byFile.get(relPath);
57
+ if (!ids) return 0;
58
+ const dead = new Set(ids);
59
+ // file node itself uses id `file:<rel>`
60
+ dead.add(`file:${relPath}`);
61
+ for (const id of dead) {
62
+ const n = this.nodes.get(id);
63
+ if (!n) continue;
64
+ this.nodes.delete(id);
65
+ if (n.name) this.byName.get(n.name.toLowerCase())?.delete(id);
66
+ if (n.extra?.normPath) this.byEndpointPath.get(n.extra.normPath)?.delete(id);
67
+ }
68
+ this.byFile.delete(relPath);
69
+ const before = this.edges.length;
70
+ this.edges = this.edges.filter((e) => !dead.has(e.from) && !dead.has(e.to));
71
+ this.rebuildAdjacency();
72
+ return before - this.edges.length;
73
+ }
74
+
75
+ rebuildAdjacency() {
76
+ this.out = new Map();
77
+ this.in = new Map();
78
+ for (const e of this.edges) {
79
+ if (!this.out.has(e.from)) this.out.set(e.from, []);
80
+ this.out.get(e.from).push({ to: e.to, type: e.type });
81
+ if (!this.in.has(e.to)) this.in.set(e.to, []);
82
+ this.in.get(e.to).push({ from: e.from, type: e.type });
83
+ }
84
+ }
85
+
86
+ // ---------- queries ----------
87
+ findByName(name) {
88
+ return [...(this.byName.get(name.toLowerCase()) ?? [])].map((id) => this.nodes.get(id));
89
+ }
90
+
91
+ // Fuzzy: substring + subsequence scoring across node names and file paths.
92
+ fuzzy(query, limit = 8, types = null) {
93
+ const q = query.toLowerCase();
94
+ const scored = [];
95
+ for (const n of this.nodes.values()) {
96
+ if (types && !types.includes(n.type)) continue;
97
+ const hay = `${n.name ?? ""} ${n.file ?? ""}`.toLowerCase();
98
+ let score = 0;
99
+ const nameLc = (n.name ?? "").toLowerCase();
100
+ if (nameLc === q) score = 100;
101
+ else if (nameLc.startsWith(q)) score = 80;
102
+ else if (nameLc.includes(q)) score = 60;
103
+ else if (hay.includes(q)) score = 40;
104
+ else if (isSubsequence(q, nameLc)) score = 20;
105
+ if (score > 0) scored.push([score, n]);
106
+ }
107
+ scored.sort((a, b) => b[0] - a[0] || (a[1].name ?? "").length - (b[1].name ?? "").length);
108
+ return scored.slice(0, limit).map(([, n]) => n);
109
+ }
110
+
111
+ neighbors(id, dir = "out", edgeTypes = null) {
112
+ const list = (dir === "out" ? this.out : this.in).get(id) ?? [];
113
+ return list
114
+ .filter((e) => !edgeTypes || edgeTypes.includes(e.type))
115
+ .map((e) => ({ node: this.nodes.get(dir === "out" ? e.to : e.from), type: e.type }))
116
+ .filter((e) => e.node);
117
+ }
118
+
119
+ // BFS over reverse edges: who depends (directly or transitively) on `id`.
120
+ dependents(id, maxDepth = 3, edgeTypes = ["imports", "renders", "calls", "hits-endpoint", "handled-by"]) {
121
+ const seen = new Map(); // id -> depth
122
+ let frontier = [id];
123
+ for (let d = 1; d <= maxDepth && frontier.length; d++) {
124
+ const next = [];
125
+ for (const cur of frontier) {
126
+ for (const { node } of this.neighbors(cur, "in", edgeTypes)) {
127
+ if (node.id === id || seen.has(node.id)) continue;
128
+ seen.set(node.id, d);
129
+ next.push(node.id);
130
+ }
131
+ }
132
+ frontier = next;
133
+ }
134
+ return [...seen.entries()].map(([nid, depth]) => ({ node: this.nodes.get(nid), depth }));
135
+ }
136
+
137
+ stats() {
138
+ const byType = {};
139
+ for (const n of this.nodes.values()) byType[n.type] = (byType[n.type] ?? 0) + 1;
140
+ return { nodes: this.nodes.size, edges: this.edges.length, byType };
141
+ }
142
+
143
+ // ---------- persistence ----------
144
+ static storageDir(repoRoot) { return path.join(repoRoot, ".codegraph"); }
145
+ static graphPath(repoRoot) { return path.join(Graph.storageDir(repoRoot), "graph.json"); }
146
+ static manifestPath(repoRoot) { return path.join(Graph.storageDir(repoRoot), "manifest.json"); }
147
+
148
+ save() {
149
+ const dir = Graph.storageDir(this.repoRoot);
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ const payload = { version: 1, nodes: [...this.nodes.values()], edges: this.edges };
152
+ fs.writeFileSync(Graph.graphPath(this.repoRoot), JSON.stringify(payload));
153
+ }
154
+
155
+ /** @returns {Graph|null} null when missing or corrupted (caller falls back to full index — spec E6) */
156
+ static load(repoRoot) {
157
+ try {
158
+ const raw = fs.readFileSync(Graph.graphPath(repoRoot), "utf8");
159
+ const data = JSON.parse(raw);
160
+ if (data.version !== 1 || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) return null;
161
+ const g = new Graph(repoRoot);
162
+ for (const n of data.nodes) g.addNode(n);
163
+ for (const e of data.edges) g.addEdge(e.from, e.to, e.type);
164
+ return g;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ }
170
+
171
+ function isSubsequence(needle, hay) {
172
+ let i = 0;
173
+ for (const c of hay) if (c === needle[i]) i++;
174
+ return i === needle.length;
175
+ }
176
+
177
+ // Shorten a list of repo-relative paths by stripping their longest common dir prefix.
178
+ export function shortenPaths(paths) {
179
+ if (paths.length < 2) return { prefix: "", paths };
180
+ const split = paths.map((p) => p.split("/"));
181
+ let common = 0;
182
+ outer: while (true) {
183
+ const seg = split[0][common];
184
+ if (seg === undefined) break;
185
+ for (const s of split) {
186
+ if (s.length - 1 <= common || s[common] !== seg) break outer;
187
+ }
188
+ common++;
189
+ }
190
+ if (common === 0) return { prefix: "", paths };
191
+ const prefix = split[0].slice(0, common).join("/") + "/";
192
+ return { prefix, paths: paths.map((p) => p.slice(prefix.length)) };
193
+ }