graph-indexer 2.1.1 → 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.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +32 -109
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- 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
|
|
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 `
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- **Local
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
-
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
|
|
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,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
|
+
}
|