graph-indexer 2.1.0 → 3.0.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 (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +31 -105
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
package/SECURITY.md CHANGED
@@ -10,7 +10,7 @@ Please include:
10
10
 
11
11
  - A description of the vulnerability and its impact.
12
12
  - Steps to reproduce (a minimal proof of concept if possible).
13
- - The affected version (`graph-indexer --version` or the `version` field in `package.json`).
13
+ - The affected version (`graph-indexer status` prints it, or see the `version` field in `package.json`).
14
14
 
15
15
  You can expect an initial acknowledgement within **5 business days**. Coordinated
16
16
  disclosure is appreciated — we will work with you on a fix and credit before any
@@ -18,68 +18,46 @@ public discussion.
18
18
 
19
19
  ## Supported versions
20
20
 
21
- Security fixes are applied to the latest published `2.x` release. Older versions
21
+ Security fixes are applied to the latest published `3.x` release. Older versions
22
22
  are not maintained; please upgrade to the latest version.
23
23
 
24
24
  ## Security posture
25
25
 
26
- graph-indexer is designed to run locally as a developer tool. Its threat model is
27
- shaped by a few deliberate choices:
28
-
29
- - **Air-gapped by default.** In normal operation the only outbound network call is to
30
- a *local* Ollama endpoint (`OLLAMA_HOST`, default `http://localhost:11434`) for
31
- embeddings. With `INDEXER_EMBEDDINGS=off`, even that call is skipped and the tool is
32
- fully offline. There are no analytics, telemetry, crash reporters, or third-party API
33
- calls. **One exception:** the optional in-process embedding provider
34
- (`embedProvider: "local"`, or `"auto"` when no Ollama is found) downloads its model
35
- weights from the Hugging Face CDN **once** on first index, then runs fully offline
36
- from the local cache. Your source code is never sent anywhere — only model weights
37
- are fetched. For strictly air-gapped installs, set `embedProvider` to `"ollama"` or
38
- `"off"`, or pre-populate the model cache on a connected machine.
39
- - **Sealed mode (opt-in, fail-closed enforcement).** For regulated or strictly
40
- air-gapped installs, `--sealed strict` (`INDEXER_SEALED`) enforces **zero** network
41
- egress (lexical-only), and `--sealed local` permits **loopback only** (a local
42
- Ollama/MLX on this box is allowed, nothing leaves it). It is *fail-closed*: the build
43
- refuses to start if any enabled feature (embeddings/enrichment/rerank/HyDE) would
44
- egress beyond the tier, and it installs a **deny-by-default runtime egress guard** on
45
- the in-process network paths (`net`/`tls` sockets, `http(s)`, `fetch`) so even an
46
- accidental call is blocked — turning "we don't call out" into "we *can't*."
47
- `idx-index --attest` prints a deterministic attestation manifest (optionally
48
- Ed25519/RSA-**signed** and CI-verifiable) of the egress posture. See `seal.mjs`.
49
- - **No code execution.** Source files are parsed into ASTs with Tree-sitter. The
50
- indexer never imports, evaluates, or runs the code it indexes. The one subprocess
51
- it spawns is a local, read-only `git log` (see "git signals" below) — never the
52
- indexed code.
53
- - **Local git signals.** At index time the indexer optionally reads the repository's
54
- *local* commit log (`git log` on `MCP_PROJECT_ROOT`) to derive churn / recency /
55
- co-change ranking hints. No remote is contacted and no source content leaves the
56
- machine — only commit metadata (paths, timestamps) is read locally. Disable it
57
- entirely with `INDEXER_GIT_SIGNALS=off`, `--no-git-signals`, or
58
- `"gitSignals": false`.
59
- - **Path-traversal guard.** The `get_file_skeleton` MCP tool resolves and normalizes
60
- the requested path and rejects anything outside `MCP_PROJECT_ROOT`. Beyond the
61
- textual check it also resolves symlinks with `realpath` on both the root and the
62
- target and re-verifies containment, so a symlink *inside* the project that points
63
- outside it cannot be used to read arbitrary files on the host.
64
- - **Local artifacts only.** The index (`code-index.json`) and embeddings
65
- (`code-index.embeddings.bin`) are written inside the project and are git-ignored by
66
- `init`. They contain snippets of your source code — treat them with the same
67
- sensitivity as the repository itself and do not commit or share them.
68
- - **MCP transport.** The server communicates over stdio with the local MCP client
69
- (your IDE/agent). It does not open a network socket.
70
- - **Taint analysis is advisory, not a guarantee.** The opt-in `trace_taint` /
71
- `find_tainted_sinks` MCP tools statically trace untrusted sources to dangerous sinks
72
- over the call graph using pure regex catalogs + graph traversal (no model, no network,
73
- read-only). They are a **finder, not a verifier**: they favour precision and miss flows
74
- through dynamic dispatch, reflection, ORM/query-builder indirection, and untyped
75
- collections — **"0 findings" is not proof of safety.** Use them for orientation, not as
76
- a security gate.
26
+ graph-indexer is a local developer tool. Its threat model follows from a few deliberate choices:
27
+
28
+ - **No network.** graph-indexer makes no outbound connections: no telemetry, no model
29
+ downloads, no grammar installs. The tree-sitter runtime and all grammars ship inside the
30
+ package as WebAssembly (versions and SHA-256 checksums in `vendor/grammars/manifest.json`),
31
+ so there is no install-time or first-run download and no native build step.
32
+ - **No dependencies.** The package has no npm dependencies; it uses only Node.js built-ins
33
+ (`node:sqlite`, `node:fs`, `node:child_process`, …) and the vendored WebAssembly runtime.
34
+ - **No code execution.** Source files are parsed into syntax trees; the indexed code is never
35
+ imported, evaluated or run. The only subprocesses are read-only git commands
36
+ (`git rev-parse`, `git ls-files`, `git diff`, `git log`) run in the indexed repository with
37
+ fixed arguments; no tool input is passed to them.
38
+ - **Stays inside the repository.** Only files listed by git (or found by a `.gitignore`-aware
39
+ walk) under the repository root are indexed. Symlinks are followed only when their target
40
+ resolves inside the repository, so a link to a file elsewhere on the machine is never read,
41
+ indexed or served. Paths given to the tools (`read_code "file.ts:42"`, `outline`,
42
+ `change_impact`) are looked up in the index, never opened directly from disk.
43
+ - **Read-only tools.** All MCP tools are read-only (`readOnlyHint: true`) and never modify
44
+ the repository.
45
+ - **stdio transport.** The MCP server talks to the local client over stdin/stdout. It does not
46
+ open a network socket.
47
+ - **Local socket for hooks and the CLI.** The MCP server, or `graph-indexer daemon` started by the
48
+ hooks, also answers hook and CLI queries over a Unix domain socket (a named pipe on Windows),
49
+ never TCP. The socket lives in `<repo>/.graph-indexer/` with mode 0600; when that path is too
50
+ long for a socket, in a per-user directory in the temp dir with mode 0700 whose owner and mode
51
+ both sides check before using it. It answers the same read-only queries as the CLI.
52
+ `GRAPH_INDEXER_RESIDENT=0` disables it.
53
+ - **Local artifacts.** The index is a SQLite database in `<repo>/.graph-indexer/` (git-ignored by
54
+ `init`). It contains excerpts of your source code — treat it with the same sensitivity as the
55
+ repository itself and do not commit or share it. Deleting the directory is always safe; it is
56
+ rebuilt on the next run.
77
57
 
78
58
  ## Operational guidance
79
59
 
80
- - Point `MCP_PROJECT_ROOT` only at repositories you trust and intend to index.
81
- - If you set `OLLAMA_HOST` to a remote host, that host will receive code snippets as
82
- embedding input — only do so on a network and host you control.
83
- - The background watch daemon respects `.gitignore` and skips `node_modules`, build
84
- output, and dot-directories, so secrets in ignored paths are not indexed. Keep
85
- sensitive files git-ignored.
60
+ - Run the server only on repositories you trust and intend to index.
61
+ - Keep secrets out of indexed source files (graph-indexer indexes what git tracks or what a
62
+ `.gitignore`-aware walk finds; ignored paths, `node_modules`, build output and dot-directories
63
+ are skipped).
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * graph-indexer CLI.
4
+ *
5
+ * graph-indexer serve [--repo DIR] MCP server on stdio (what agents launch)
6
+ * graph-indexer index [--repo DIR] build / update the index and print stats
7
+ * graph-indexer init [--repo DIR] [--agents …] wire the MCP server into your coding agents
8
+ * graph-indexer status [--repo DIR]
9
+ * graph-indexer search <query> [--path P] [--kind K] [--limit N]
10
+ * graph-indexer read <target>… [--full] symbols, file:START-END ranges or files, with the
11
+ * definitions each one uses
12
+ * graph-indexer symbol <name>… [--no-code]
13
+ * graph-indexer refs <name> [--kind K] [--path P] [--no-tests] [--limit N]
14
+ * graph-indexer callgraph <name> [--direction callers|callees|both] [--depth N] [--no-tests]
15
+ * graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
16
+ * graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
17
+ * graph-indexer files <text|glob> [--path P]
18
+ *
19
+ * The query commands print exactly what the MCP tools return, so humans (and agents limited to a
20
+ * shell) get the same answers.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
28
+
29
+ const argv = process.argv.slice(2);
30
+ const aliasOf = { 'idx-mcp': 'serve', 'idx-index': 'index', mcp: 'serve', references: 'refs', 'call-graph': 'callgraph', map: 'outline' };
31
+ const invokedAs = path.basename(process.argv[1] || '').replace(/\.m?js$/, '');
32
+ let cmd = aliasOf[invokedAs] ?? null;
33
+ if (!cmd && argv.length && !argv[0].startsWith('-')) { const c = argv.shift(); cmd = aliasOf[c] ?? c; }
34
+
35
+ function flag(name) { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; }
36
+ function opt(name, dflt = null) {
37
+ const i = argv.indexOf(name);
38
+ if (i < 0) return dflt;
39
+ const v = argv[i + 1];
40
+ argv.splice(i, 2);
41
+ return v;
42
+ }
43
+
44
+ const log = (m) => process.stderr.write(`[graph-indexer] ${m}\n`);
45
+ // global options are consumed before any command joins the remaining words into a query
46
+ const repoArg = opt('--repo') ?? process.env.GRAPH_INDEXER_REPO ?? process.env.MCP_PROJECT_ROOT ?? null;
47
+
48
+ async function openIntel({ watch = false, background = false, quiet = false } = {}) {
49
+ const { CodeIntel } = await import('../src/query/intel.mjs');
50
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
51
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
52
+ const intel = new CodeIntel({ root, log, watch });
53
+ let last = 0;
54
+ const onProgress = quiet ? null : (d, n) => {
55
+ const now = Date.now();
56
+ if (now - last > 1000 || d === n) { last = now; process.stderr.write(`\r[graph-indexer] indexing ${d}/${n} files`); if (d === n) process.stderr.write('\n'); }
57
+ };
58
+ await intel.open({ onProgress, background });
59
+ return intel;
60
+ }
61
+
62
+ /** Options left over after a command took its own are typos, not part of the query. */
63
+ function rejectUnknownOptions() {
64
+ const bad = argv.filter(a => /^--?[A-Za-z][\w-]*$/.test(a));
65
+ if (!bad.length) return false;
66
+ process.stderr.write(`Unknown option${bad.length > 1 ? 's' : ''} for "${cmd}": ${bad.join(' ')}\n\n${HELP}\n`);
67
+ process.exitCode = 2;
68
+ return true;
69
+ }
70
+
71
+ async function runTool(name, args) {
72
+ return runTools([[name, args]]);
73
+ }
74
+
75
+ /** Several tool calls against one open index (e.g. `symbol A B C`), outputs separated by a blank line. */
76
+ async function runTools(calls) {
77
+ if (rejectUnknownOptions()) return;
78
+ // a resident process (MCP server or daemon) answers without opening and syncing the index again
79
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
80
+ const { askResident, residentRequired } = await import('../src/cli/resident.mjs');
81
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
82
+ const reply = await askResident(root, { op: 'tool', calls }, { version: pkg.version, totalMs: 60_000 });
83
+ if (reply && typeof reply.out === 'string') { process.stdout.write(reply.out + '\n'); return; }
84
+ if (residentRequired()) { process.stderr.write('graph-indexer: no resident process answers for this repository\n'); process.exitCode = 3; return; }
85
+ const intel = await openIntel({ quiet: true });
86
+ const { callTool } = await import('../src/mcp/tools.mjs');
87
+ try {
88
+ const outs = [];
89
+ for (const [name, args] of calls) outs.push(await callTool(intel, name, args, { cli: true }));
90
+ process.stdout.write(outs.join('\n\n') + '\n');
91
+ } finally { intel.close(); }
92
+ }
93
+
94
+ /**
95
+ * Answer hooks and CLI queries for this index over the repository's local socket (see
96
+ * src/cli/resident.mjs). Resolves to null when another process already does.
97
+ */
98
+ async function serveResident(intel, { idleMs = 0, onIdle = null } = {}) {
99
+ const { startResident } = await import('../src/cli/resident.mjs');
100
+ const hold = async () => ({ intel, release() {} });
101
+ return startResident({
102
+ root: intel.root, version: pkg.version, log, idleMs, onIdle,
103
+ wanted: () => fs.existsSync(intel.dbPath),
104
+ handle: async (req) => {
105
+ if (req.op === 'hook') {
106
+ const { handleHook } = await import('../src/cli/hook.mjs');
107
+ return handleHook(String(req.event ?? ''), req.input ?? {}, { root: intel.root, acquire: hold });
108
+ }
109
+ if (req.op === 'tool' && Array.isArray(req.calls)) {
110
+ const { callTool } = await import('../src/mcp/tools.mjs');
111
+ const outs = [];
112
+ for (const [name, args] of req.calls) outs.push(await callTool(intel, name, args ?? {}, { cli: true }));
113
+ return outs.join('\n\n');
114
+ }
115
+ return null;
116
+ },
117
+ });
118
+ }
119
+
120
+ const HELP = `graph-indexer ${pkg.version} — live code graph & search for AI coding agents (MCP)
121
+
122
+ Usage:
123
+ graph-indexer init [--repo DIR] [--agents claude,cursor,vscode,gemini,codex,opencode,junie,zed,devin] [--all] [--hooks] [--local] [--dry-run] [--no-instructions] [--no-helper]
124
+ graph-indexer serve [--repo DIR] start the MCP server on stdio
125
+ graph-indexer index [--repo DIR] build or update the index
126
+ graph-indexer status [--repo DIR]
127
+ graph-indexer search <query> [--path P] [--kind K] [--limit N]
128
+ graph-indexer grep <regex> [--path P] [--literal|-F] [-i] [--limit N]
129
+ graph-indexer files <text|glob> [--path P] [--limit N] find files by path or name
130
+ graph-indexer read <target>… [--full] [--max-lines N] symbols (Class.method), ranges (file:10-80) or files,
131
+ with where each name they use is defined
132
+ graph-indexer symbol <name>… [--no-code] one or several definitions
133
+ graph-indexer refs <name> [--kind call|type|inherit|new|value|decorator] [--path P] [--no-tests]
134
+ graph-indexer callgraph <name> [--direction callers|callees|both] [--depth N] [--no-tests]
135
+ graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
136
+ graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
137
+ graph-indexer check [--files x,y] [--base REV] verify uncommitted edits
138
+ graph-indexer hook post-tool|session-start|subagent-start agent hook (JSON on stdin)
139
+ graph-indexer daemon [--idle-minutes N] keep the index open for hooks and CLI queries
140
+ (the hooks start it; the MCP server does the same)
141
+
142
+ The index lives in <repo>/.graph-indexer/ and is kept in sync automatically.`;
143
+
144
+ async function main() {
145
+ if (!cmd || cmd === 'help' || flag('--help') || flag('-h')) { process.stdout.write(HELP + '\n'); return; }
146
+ if (cmd === 'version' || flag('--version')) { process.stdout.write(pkg.version + '\n'); return; }
147
+ switch (cmd) {
148
+ case 'serve': {
149
+ const intel = await openIntel({ watch: true, background: true, quiet: true });
150
+ const { McpServer } = await import('../src/mcp/server.mjs');
151
+ const server = new McpServer({ intel, version: pkg.version, log, ready: intel.ready });
152
+ intel.ready.then((r) => log(`index ready: ${r.total} files (${r.added} added, ${r.changed} changed, ${r.removed} removed) in ${r.ms} ms`)).catch((e) => log(`indexing failed: ${e.stack || e.message}`));
153
+ server.onClose = () => { intel.close(); process.exit(0); };
154
+ server.listen();
155
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { intel.close(); process.exit(0); });
156
+ serveResident(intel).catch((e) => log(`resident: ${e.message}`));
157
+ return;
158
+ }
159
+ case 'daemon': {
160
+ // the resident for hooks and CLI queries when no MCP server runs; the hooks start it
161
+ const idle = Math.max(1, Number(opt('--idle-minutes', 30)));
162
+ const { findRepoRoot } = await import('../src/util/paths.mjs');
163
+ const { askResident } = await import('../src/cli/resident.mjs');
164
+ const root = repoArg ? path.resolve(repoArg) : findRepoRoot(process.cwd());
165
+ if (await askResident(root, { op: 'ping' }, { version: pkg.version })) return; // already served
166
+ const intel = await openIntel({ watch: true, quiet: true });
167
+ const stop = () => { intel.close(); process.exit(0); };
168
+ const res = await serveResident(intel, { idleMs: idle * 60_000, onIdle: stop });
169
+ if (!res) { intel.close(); return; }
170
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, stop);
171
+ return;
172
+ }
173
+ case 'index': {
174
+ const t0 = Date.now();
175
+ const intel = await openIntel();
176
+ const st = intel.stats();
177
+ const r = intel.lastSyncStats;
178
+ process.stdout.write(`indexed ${st.root}\n ${st.files} files · ${st.symbols} symbols · ${st.refs} references (${st.resolved} resolved, ${st.exact} exact)\n this run: ${r.added} added, ${r.changed} changed, ${r.removed} removed in ${Date.now() - t0} ms\n languages: ${st.langs.map(l => `${l.lang} ${l.n}`).join(', ')}\n`);
179
+ intel.close();
180
+ return;
181
+ }
182
+ case 'status': {
183
+ const intel = await openIntel({ quiet: true });
184
+ const st = intel.stats();
185
+ process.stdout.write(JSON.stringify({ version: pkg.version, ...st, db: intel.dbPath }, null, 2) + '\n');
186
+ intel.close();
187
+ return;
188
+ }
189
+ case 'init': {
190
+ const { runInit } = await import('../src/cli/init.mjs');
191
+ await runInit({ argv, opt, flag, log, version: pkg.version, repo: repoArg });
192
+ return;
193
+ }
194
+ case 'search': {
195
+ const pathF = opt('--path'); const kind = opt('--kind'); const limit = Number(opt('--limit', 8));
196
+ return runTool('search_code', { query: argv.join(' '), path: pathF, kind, limit });
197
+ }
198
+ case 'grep': {
199
+ const pathF = opt('--path'); const literal = flag('--literal') || flag('-F'); const ic = flag('-i') || flag('--ignore-case'); const limit = Number(opt('--limit', 60));
200
+ const pattern = argv.shift();
201
+ return runTool('search_text', { pattern, path: pathF, literal, ignore_case: ic, limit });
202
+ }
203
+ case 'read': {
204
+ const full = flag('--full'); const maxLines = Number(opt('--max-lines', 200));
205
+ const targets = argv.filter(a => !a.startsWith('--'));
206
+ argv.length = 0;
207
+ return runTool('read_code', { targets, full, max_lines: maxLines });
208
+ }
209
+ case 'symbol': {
210
+ const noCode = flag('--no-code'); const maxLines = Number(opt('--max-lines', 200));
211
+ // `symbol A B C` reads several definitions in one call
212
+ const targets = argv.filter(a => !a.startsWith('--'));
213
+ return runTools((targets.length ? targets : ['']).map(t => ['get_symbol', { symbol: t, include_code: !noCode, max_lines: maxLines }]));
214
+ }
215
+ case 'refs': {
216
+ const kind = opt('--kind', 'all'); const noTests = flag('--no-tests'); const limit = Number(opt('--limit', 80)); const pathF = opt('--path');
217
+ return runTool('find_references', { symbol: argv.join(' '), kind, include_tests: !noTests, limit, ...(pathF ? { path: pathF } : {}) });
218
+ }
219
+ case 'callgraph': {
220
+ const direction = opt('--direction', 'both'); const depth = Number(opt('--depth', 2)); const limit = Number(opt('--limit', 40)); const noTests = flag('--no-tests');
221
+ return runTool('call_graph', { symbol: argv.join(' '), direction, depth, limit, include_tests: !noTests });
222
+ }
223
+ case 'impact': {
224
+ const symbols = (opt('--symbols') ?? '').split(',').filter(Boolean);
225
+ const files = (opt('--files') ?? '').split(',').filter(Boolean);
226
+ const diff = flag('--diff'); const depth = Number(opt('--depth', 3));
227
+ return runTool('change_impact', { symbols, files, diff, depth });
228
+ }
229
+ case 'hook': {
230
+ const { runHook } = await import('../src/cli/hook.mjs');
231
+ return runHook(argv.shift() ?? '', { repo: repoArg, version: pkg.version });
232
+ }
233
+ case 'check': {
234
+ const files = (opt('--files') ?? '').split(',').filter(Boolean); const base = opt('--base', 'HEAD');
235
+ return runTool('check_changes', { files, base });
236
+ }
237
+ case 'files': {
238
+ const pathF = opt('--path'); const limit = Number(opt('--limit', 100));
239
+ return runTool('find_files', { pattern: argv.join(' '), path: pathF, limit });
240
+ }
241
+ case 'outline': {
242
+ const focus = opt('--focus'); const maxTokens = Number(opt('--max-tokens', 1500));
243
+ return runTool('outline', { path: argv[0] ?? '', focus, max_tokens: maxTokens });
244
+ }
245
+ default:
246
+ process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}\n`);
247
+ process.exitCode = 2;
248
+ }
249
+ }
250
+
251
+ main().catch((e) => { process.stderr.write(`graph-indexer: ${e.stack || e.message}\n`); process.exit(1); });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "graph-indexer",
3
+ "version": "3.0.0",
4
+ "description": "Live code graph for Claude Code: exact references, structural grep, change impact and a post-edit check, from a local index with no model or network.",
5
+ "author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
6
+ "homepage": "https://github.com/MaquinaTech/graph-indexer",
7
+ "license": "MIT"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "graph-indexer": {
4
+ "command": "npx",
5
+ "args": ["-y", "graph-indexer@3", "serve"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: code-structure
3
+ description: "Exact answers to structural questions about this repository's code, from graph-indexer's index, on a small fast model: every call site of a function or method (told apart from same-name methods of other classes), callers of callers, classes that extend or implement a type, and what a change would affect with the tests that cover it. Give it the symbol (Class.method, with its file when you know it) and the scope (directories, whether tests count, how many levels); it replies with path:line lists and says whether they are complete. Delegate these instead of grepping for names and reading files yourself."
4
+ model: haiku
5
+ disallowedTools: Edit, Write, MultiEdit, NotebookEdit
6
+ omitClaudeMd: true
7
+ ---
8
+ You answer structural questions about the code in this repository — where something is used, who calls it, what extends or implements it, what a change would affect — exactly and briefly, from graph-indexer's index (the graph-indexer tools). The index binds every reference through scopes, imports and receiver types, so methods that only share a name are kept apart, and it re-syncs with the files before every answer.
9
+
10
+ How to answer
11
+ - Call sites or other uses of a function, method or class: `find_references` on it (`Class.method`, or `path:Name` when the name is ambiguous), with kind `call` for calls only, `include_tests: false` when tests are excluded and `path` for one directory. It says when the list is complete.
12
+ - Callers of callers: `call_graph` with direction `callers` and the depth asked for (two levels up is depth 2), with `include_tests: false` when tests are excluded.
13
+ - Classes that extend a class or implement an interface, directly or through other classes: `find_references` with kind `inherit`.
14
+ - What a change affects (call sites to update, overrides, subclasses that inherit it, tests to run): `change_impact` with the symbols.
15
+ - A definition or a signature: `read_code` with its name.
16
+ - Answer from these results. Read code or search text only to settle what they report as unresolved or incomplete (a call through a name built at run time, a receiver of unknown type), not to redo a list they give as complete.
17
+ - Keep to the scope asked: the directories, whether tests count, how many levels.
18
+
19
+ Reply
20
+ - When the request gives an answer format, follow it exactly.
21
+ - Otherwise one item per line as `path:LINE`, relative to the repository root, with a few words after it when they help (the enclosing function, the kind of use), then one line saying whether the list is complete or what could not be resolved.
22
+ - Nothing else: no account of how you found it. You do not change files.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The plugin's hook command: hands the hook payload to the graph-indexer resident process of the
4
+ * repository (the plugin's MCP server keeps one running) and prints its answer.
5
+ *
6
+ * Starting graph-indexer through `npx` costs about a second on every call; this client only
7
+ * connects to a local socket (tens of milliseconds). When no resident answers, it runs the full
8
+ * hook through npx once, which also starts a resident for the next calls.
9
+ *
10
+ * node gi-hook.mjs post-tool|session-start|subagent-start (hook payload on stdin)
11
+ *
12
+ * The socket location must match socketPath() in graph-indexer's src/cli/resident.mjs.
13
+ */
14
+ import fs from 'node:fs';
15
+ import net from 'node:net';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { spawn } from 'node:child_process';
20
+ import { pathToFileURL } from 'node:url';
21
+
22
+ const PROTOCOL = 1;
23
+ const WIN = process.platform === 'win32';
24
+
25
+ export function findRepoRoot(start) {
26
+ let dir = path.resolve(start);
27
+ for (;;) {
28
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
29
+ const parent = path.dirname(dir);
30
+ if (parent === dir) return path.resolve(start);
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ export function socketPath(root) {
36
+ const abs = path.resolve(root);
37
+ const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
38
+ if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
39
+ const inRepo = path.join(abs, '.graph-indexer', 'resident.sock');
40
+ if (Buffer.byteLength(inRepo) <= 100) return inRepo;
41
+ return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
42
+ }
43
+
44
+ function trusted(file, root) {
45
+ if (WIN) return true;
46
+ const dir = path.dirname(file);
47
+ if (dir === path.join(path.resolve(root), '.graph-indexer')) return fs.existsSync(file);
48
+ try {
49
+ const st = fs.lstatSync(dir);
50
+ return fs.existsSync(file) && st.isDirectory() && (!process.getuid || st.uid === process.getuid()) && (st.mode & 0o077) === 0;
51
+ } catch { return false; }
52
+ }
53
+
54
+ /** The resident's answer ({ out }) or null. `any: true`: this client has no logic of its own to match. */
55
+ function ask(root, request, totalMs) {
56
+ const file = socketPath(root);
57
+ if (!trusted(file, root)) return Promise.resolve(null);
58
+ return new Promise((resolve) => {
59
+ let done = false, buf = '';
60
+ const sock = net.createConnection(file);
61
+ const finish = (v) => { if (!done) { done = true; clearTimeout(timer); sock.destroy(); resolve(v); } };
62
+ const timer = setTimeout(() => finish(null), totalMs);
63
+ sock.setEncoding('utf8');
64
+ sock.on('connect', () => sock.write(JSON.stringify({ v: PROTOCOL, any: true, ...request }) + '\n'));
65
+ sock.on('data', (d) => {
66
+ buf += d;
67
+ const i = buf.indexOf('\n');
68
+ if (i < 0) return;
69
+ let r = null;
70
+ try { r = JSON.parse(buf.slice(0, i)); } catch { /* garbled */ }
71
+ finish(r?.ok ? r : null);
72
+ });
73
+ sock.on('error', () => finish(null));
74
+ sock.on('close', () => finish(null));
75
+ });
76
+ }
77
+
78
+ async function main() {
79
+ const event = process.argv[2] ?? '';
80
+ const chunks = [];
81
+ if (!process.stdin.isTTY) for await (const c of process.stdin) chunks.push(c);
82
+ const raw = Buffer.concat(chunks).toString('utf8');
83
+ let input = {};
84
+ try { input = JSON.parse(raw || '{}'); } catch { return; }
85
+ const root = findRepoRoot(input.cwd || process.cwd());
86
+ const reply = await ask(root, { op: 'hook', event, input }, 2500);
87
+ if (reply) { if (reply.out) process.stdout.write(reply.out + '\n'); return; }
88
+ // no resident yet: the full hook through npx (it starts one for the next calls)
89
+ const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', 'graph-indexer@3', 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
90
+ child.on('error', () => { /* fail open */ });
91
+ child.stdin.on('error', () => { /* fail open */ });
92
+ child.stdin.end(raw);
93
+ await new Promise((resolve) => child.on('close', resolve).on('error', resolve));
94
+ }
95
+
96
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
97
+ const timer = setTimeout(() => process.exit(0), 9000); // fail open before the host's 10 s timeout
98
+ main().catch(() => { /* fail open */ }).finally(() => clearTimeout(timer));
99
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|MultiEdit|Grep|Bash|Read",
6
+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" post-tool", "timeout": 10 }]
7
+ }
8
+ ],
9
+ "SessionStart": [
10
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" session-start", "timeout": 10 }] }
11
+ ],
12
+ "SubagentStart": [
13
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gi-hook.mjs\" subagent-start", "timeout": 10 }] }
14
+ ]
15
+ }
16
+ }