graph-indexer 3.0.1 → 3.1.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 +404 -0
- package/README.md +61 -8
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +1 -1
- package/integrations/claude-code/agents/code-structure.md +0 -3
- package/integrations/claude-code/skills/setup/SKILL.md +26 -0
- package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
- package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
- package/integrations/claude-code-local/README.md +10 -0
- package/integrations/claude-code-local/agents/code-structure.md +22 -0
- package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +3 -1
- package/package.json +1 -1
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +123 -41
- package/src/cli/init.mjs +16 -1
- package/src/cli/resident.mjs +1 -1
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +76 -2
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +362 -23
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +417 -25
- package/src/parse/arity.mjs +39 -2
- package/src/parse/extract.mjs +269 -45
- package/src/parse/lang/ecma.mjs +2 -0
- package/src/parse/lang/go.mjs +2 -0
- package/src/parse/lang/jvm.mjs +33 -0
- package/src/parse/lang/markdown.mjs +92 -0
- package/src/parse/lang/python.mjs +43 -0
- package/src/parse/lang/rust.mjs +215 -15
- package/src/parse/lang/sfc.mjs +125 -0
- package/src/parse/languages.mjs +9 -2
- package/src/query/check.mjs +49 -13
- package/src/query/facts.mjs +3 -2
- package/src/query/injection-spring.mjs +405 -0
- package/src/query/injection.mjs +173 -0
- package/src/query/intel.mjs +76 -14
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +286 -0
- package/src/query/read.mjs +6 -1
- package/src/query/report.mjs +53 -0
- package/src/query/routes.mjs +142 -0
- package/src/query/showcase.mjs +59 -0
- package/src/search/search.mjs +85 -17
- package/src/search/semantic.mjs +201 -0
- package/src/search/vectors.mjs +122 -0
- package/src/store/db.mjs +22 -4
- package/vendor/grammars/README.md +47 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +4 -4
- /package/integrations/{claude-code → claude-code-local}/hooks/hooks.json +0 -0
package/bin/graph-indexer.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* graph-indexer index [--repo DIR] build / update the index and print stats
|
|
7
7
|
* graph-indexer init [--repo DIR] [--agents …] wire the MCP server into your coding agents
|
|
8
8
|
* graph-indexer status [--repo DIR]
|
|
9
|
-
* graph-indexer
|
|
9
|
+
* graph-indexer explore <task in words> [--symbols a,b] [--path P] [--max-tokens N]
|
|
10
|
+
* graph-indexer search <query> [--or QUERY]… [--path P] [--kind K] [--limit N]
|
|
10
11
|
* graph-indexer read <target>… [--full] symbols, file:START-END ranges or files, with the
|
|
11
12
|
* definitions each one uses
|
|
12
13
|
* graph-indexer symbol <name>… [--no-code]
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
* graph-indexer impact [--symbols a,b] [--files x,y] [--diff] [--depth N]
|
|
16
17
|
* graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
|
|
17
18
|
* graph-indexer files <text|glob> [--path P]
|
|
19
|
+
* graph-indexer demo grep versus exact uses, on this repository
|
|
18
20
|
*
|
|
19
21
|
* The query commands print exactly what the MCP tools return, so humans (and agents limited to a
|
|
20
22
|
* shell) get the same answers.
|
|
@@ -33,6 +35,8 @@ let cmd = aliasOf[invokedAs] ?? null;
|
|
|
33
35
|
if (!cmd && argv.length && !argv[0].startsWith('-')) { const c = argv.shift(); cmd = aliasOf[c] ?? c; }
|
|
34
36
|
|
|
35
37
|
function flag(name) { const i = argv.indexOf(name); if (i < 0) return false; argv.splice(i, 1); return true; }
|
|
38
|
+
/** Every value of a repeatable option (`--or a --or b`). */
|
|
39
|
+
function opts(name) { const out = []; let v; while ((v = opt(name)) != null) out.push(v); return out; }
|
|
36
40
|
function opt(name, dflt = null) {
|
|
37
41
|
const i = argv.indexOf(name);
|
|
38
42
|
if (i < 0) return dflt;
|
|
@@ -120,13 +124,21 @@ async function serveResident(intel, { idleMs = 0, onIdle = null } = {}) {
|
|
|
120
124
|
const HELP = `graph-indexer ${pkg.version} — live code graph & search for AI coding agents (MCP)
|
|
121
125
|
|
|
122
126
|
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]
|
|
127
|
+
graph-indexer init [--repo DIR] [--agents claude,cursor,vscode,gemini,codex,opencode,junie,zed,devin] [--all] [--hooks] [--local] [--dry-run] [--no-instructions] [--no-helper] [--no-demo]
|
|
124
128
|
graph-indexer serve [--repo DIR] start the MCP server on stdio
|
|
125
129
|
graph-indexer index [--repo DIR] build or update the index
|
|
126
130
|
graph-indexer status [--repo DIR]
|
|
127
|
-
graph-indexer
|
|
131
|
+
graph-indexer explore <task in words> [--or QUERY]… [--symbols a,b] [--path P] [--max-tokens N]
|
|
132
|
+
relevant code, callers, definitions used and tests
|
|
133
|
+
graph-indexer search <query> [--or QUERY]… [--path P] [--kind K] [--limit N]
|
|
134
|
+
--or: other phrasings of the same need, fused
|
|
128
135
|
graph-indexer grep <regex> [--path P] [--literal|-F] [-i] [--limit N]
|
|
129
136
|
graph-indexer files <text|glob> [--path P] [--limit N] find files by path or name
|
|
137
|
+
graph-indexer demo a method that shares its name: grep versus its exact uses
|
|
138
|
+
graph-indexer report summary to paste into an issue (no paths, names or code)
|
|
139
|
+
graph-indexer routes [path | METHOD /path] HTTP routes and their handlers
|
|
140
|
+
graph-indexer inject [token | class] dependency injection: tokens, providers, injection points
|
|
141
|
+
graph-indexer models [name | table] ORM models: table, migrations, Prisma client calls
|
|
130
142
|
graph-indexer read <target>… [--full] [--max-lines N] symbols (Class.method), ranges (file:10-80) or files,
|
|
131
143
|
with where each name they use is defined
|
|
132
144
|
graph-indexer symbol <name>… [--no-code] one or several definitions
|
|
@@ -136,6 +148,8 @@ Usage:
|
|
|
136
148
|
graph-indexer outline [path] [--focus TEXT] [--max-tokens N]
|
|
137
149
|
graph-indexer check [--files x,y] [--base REV] verify uncommitted edits
|
|
138
150
|
graph-indexer hook post-tool|session-start|subagent-start agent hook (JSON on stdin)
|
|
151
|
+
graph-indexer semantic install|status optional static code-embedding model (32 MB, MIT) for
|
|
152
|
+
search in words; used with GRAPH_INDEXER_SEMANTIC=1
|
|
139
153
|
graph-indexer daemon [--idle-minutes N] keep the index open for hooks and CLI queries
|
|
140
154
|
(the hooks start it; the MCP server does the same)
|
|
141
155
|
|
|
@@ -170,6 +184,17 @@ async function main() {
|
|
|
170
184
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, stop);
|
|
171
185
|
return;
|
|
172
186
|
}
|
|
187
|
+
case 'semantic': {
|
|
188
|
+
const { installModel, modelDir, semanticEnabled, MODEL } = await import('../src/search/semantic.mjs');
|
|
189
|
+
const sub = argv.shift() ?? 'status';
|
|
190
|
+
if (sub === 'install') {
|
|
191
|
+
const dir = await installModel({ log });
|
|
192
|
+
process.stdout.write(`${MODEL.repo}@${MODEL.revision.slice(0, 12)} (${MODEL.license}) installed in ${dir}\nTurn the semantic channel on with GRAPH_INDEXER_SEMANTIC=1.\n`);
|
|
193
|
+
} else {
|
|
194
|
+
process.stdout.write(JSON.stringify({ model: MODEL.repo, revision: MODEL.revision, dir: modelDir(), installed: fs.existsSync(path.join(modelDir(), 'model.safetensors')), enabled: semanticEnabled() }, null, 2) + '\n');
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
173
198
|
case 'index': {
|
|
174
199
|
const t0 = Date.now();
|
|
175
200
|
const intel = await openIntel();
|
|
@@ -191,9 +216,13 @@ async function main() {
|
|
|
191
216
|
await runInit({ argv, opt, flag, log, version: pkg.version, repo: repoArg });
|
|
192
217
|
return;
|
|
193
218
|
}
|
|
219
|
+
case 'explore': {
|
|
220
|
+
const symbols = (opt('--symbols') ?? '').split(',').filter(Boolean); const pathF = opt('--path'); const maxTokens = Number(opt('--max-tokens', 2000)); const queries = opts('--or');
|
|
221
|
+
return runTool('explore', { query: argv.join(' '), queries, symbols, path: pathF, max_tokens: maxTokens });
|
|
222
|
+
}
|
|
194
223
|
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 });
|
|
224
|
+
const pathF = opt('--path'); const kind = opt('--kind'); const limit = Number(opt('--limit', 8)); const queries = opts('--or');
|
|
225
|
+
return runTool('search_code', { query: argv.join(' '), queries, path: pathF, kind, limit });
|
|
197
226
|
}
|
|
198
227
|
case 'grep': {
|
|
199
228
|
const pathF = opt('--path'); const literal = flag('--literal') || flag('-F'); const ic = flag('-i') || flag('--ignore-case'); const limit = Number(opt('--limit', 60));
|
|
@@ -234,6 +263,18 @@ async function main() {
|
|
|
234
263
|
const files = (opt('--files') ?? '').split(',').filter(Boolean); const base = opt('--base', 'HEAD');
|
|
235
264
|
return runTool('check_changes', { files, base });
|
|
236
265
|
}
|
|
266
|
+
case 'demo': return runTool('demo', {});
|
|
267
|
+
case 'routes': return runTool('routes', { filter: argv.join(' ') });
|
|
268
|
+
case 'inject': return runTool('inject', { filter: argv.join(' ') });
|
|
269
|
+
case 'models': return runTool('models', { filter: argv.join(' ') });
|
|
270
|
+
case 'report': {
|
|
271
|
+
// for bug reports: sizes, languages, binding rates and latencies — no paths, names or code
|
|
272
|
+
const intel = await openIntel({ quiet: true });
|
|
273
|
+
const { reportText } = await import('../src/query/report.mjs');
|
|
274
|
+
process.stdout.write(await reportText(intel, { version: pkg.version }) + '\n');
|
|
275
|
+
intel.close();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
237
278
|
case 'files': {
|
|
238
279
|
const pathF = opt('--path'); const limit = Number(opt('--limit', 100));
|
|
239
280
|
return runTool('find_files', { pattern: argv.join(' '), path: pathF, limit });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
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
5
|
"author": { "name": "MaquinaTech", "url": "https://github.com/MaquinaTech" },
|
|
6
6
|
"homepage": "https://github.com/MaquinaTech/graph-indexer",
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: code-structure
|
|
3
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
4
|
---
|
|
8
5
|
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
6
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup
|
|
3
|
+
description: Connect graph-indexer's MCP server to this repository so Claude Code can use exact references, call graphs and change impact. Use when the user installs this plugin, or when the graph-indexer tools (find_references, call_graph, change_impact) are not available.
|
|
4
|
+
---
|
|
5
|
+
# Connect graph-indexer
|
|
6
|
+
|
|
7
|
+
This plugin brings the `code-structure` sub-agent and these instructions. The graph-indexer tools
|
|
8
|
+
come from a local MCP server that the user adds to the repository once. The plugin directory does not
|
|
9
|
+
allow plugins to start local programs, so the user must approve this step.
|
|
10
|
+
|
|
11
|
+
1. Check that Node.js 22.23.4+, 24.22.1+ or 26.12.1+ is installed (`node --version`).
|
|
12
|
+
2. Ask the user to confirm, then run from the repository root:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add graph-indexer -- npx -y --prefix / graph-indexer@3.1.0 serve
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`--prefix /` makes npx run only the published package, never a copy shipped inside the repository.
|
|
19
|
+
The version is pinned on purpose: update it by hand when a newer graph-indexer is released.
|
|
20
|
+
|
|
21
|
+
3. Optional, with the user's consent: `npx -y --prefix / graph-indexer@3.1.0 init --hooks` also writes the
|
|
22
|
+
Claude Code hooks (an edit check after each edit, grep disambiguation) to `.claude/settings.json`.
|
|
23
|
+
4. Restart Claude Code, or run `/mcp`, and confirm `graph-indexer` is connected.
|
|
24
|
+
|
|
25
|
+
Then use `find_references`, `call_graph`, `change_impact`, `read_code` and `check_changes`, or delegate
|
|
26
|
+
structural questions to the `code-structure` sub-agent.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "graph-indexer",
|
|
3
|
+
"version": "3.1.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
|
+
"icon": "https://raw.githubusercontent.com/MaquinaTech/graph-indexer/main/assets/logo.jpg"
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# graph-indexer: full Claude Code plugin (manual install)
|
|
2
|
+
|
|
3
|
+
This folder is the plugin with everything wired in: the MCP server (`.mcp.json`), the hooks
|
|
4
|
+
(`hooks/`) and the `code-structure` sub-agent. The plugin directory lists only plugins made of
|
|
5
|
+
Markdown and JSON (see `../claude-code`), so this one is not listed there. To use it, copy the
|
|
6
|
+
folder or point a local marketplace at it. `npx -y --prefix / graph-indexer@3.1.0 init --hooks`
|
|
7
|
+
gives the same result without a plugin.
|
|
8
|
+
|
|
9
|
+
Each release must update the version in `.mcp.json`, `hooks/gi-hook.mjs` and
|
|
10
|
+
`.claude-plugin/plugin.json`; `test/plugin-pins.test.mjs` checks that they equal `package.json`.
|
|
@@ -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.
|
|
@@ -20,6 +20,8 @@ import { spawn } from 'node:child_process';
|
|
|
20
20
|
import { pathToFileURL } from 'node:url';
|
|
21
21
|
|
|
22
22
|
const PROTOCOL = 1;
|
|
23
|
+
// the exact release this plugin version runs through npx (never a range): keep it equal to package.json's version
|
|
24
|
+
export const VERSION = '3.1.0';
|
|
23
25
|
const WIN = process.platform === 'win32';
|
|
24
26
|
|
|
25
27
|
export function findRepoRoot(start) {
|
|
@@ -90,7 +92,7 @@ async function main() {
|
|
|
90
92
|
// npx from running a graph-indexer that the repository ships in its own node_modules
|
|
91
93
|
// (on Windows npx.cmd needs a shell, so the home directory is quoted for cmd.exe)
|
|
92
94
|
const home = WIN ? `"${os.homedir()}"` : os.homedir();
|
|
93
|
-
const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home,
|
|
95
|
+
const child = spawn(WIN ? 'npx.cmd' : 'npx', ['-y', '--prefix', home, `graph-indexer@${VERSION}`, 'hook', event], { stdio: ['pipe', 'inherit', 'ignore'], shell: WIN });
|
|
94
96
|
child.on('error', () => { /* fail open */ });
|
|
95
97
|
child.stdin.on('error', () => { /* fail open */ });
|
|
96
98
|
child.stdin.end(raw);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Live code graph and search for AI coding agents over MCP: tree-sitter (WASM) symbols, scope/import/type-aware references, call graphs, change impact and hybrid search — local, zero-dependency, no native builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/query/intel.mjs",
|
package/src/cli/helper.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* index, and replies with the answer alone. The main agent delegates the question and gets back a
|
|
5
5
|
* path:line list instead of the searches and reads behind it, and the lookup runs on a model that
|
|
6
6
|
* costs a fraction per token. `init` writes it for Claude Code (.claude/agents/) and the Claude Code
|
|
7
|
-
* plugin ships
|
|
7
|
+
* plugin ships it too (integrations/claude-code/agents/, with only the frontmatter keys the plugin directory accepts).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export const HELPER_NAME = 'code-structure';
|
|
@@ -65,3 +65,17 @@ omitClaudeMd: true
|
|
|
65
65
|
${helperInstructions()}
|
|
66
66
|
`;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The same sub-agent for the Claude Code plugin listed in the plugin directory, which accepts only
|
|
71
|
+
* a short list of frontmatter keys (name and description): the model and the tool limits are left
|
|
72
|
+
* to the session, and the instructions already say it does not change files.
|
|
73
|
+
*/
|
|
74
|
+
export function pluginAgentFile() {
|
|
75
|
+
return `---
|
|
76
|
+
name: ${HELPER_NAME}
|
|
77
|
+
description: ${JSON.stringify(DESCRIPTION)}
|
|
78
|
+
---
|
|
79
|
+
${helperInstructions()}
|
|
80
|
+
`;
|
|
81
|
+
}
|
package/src/cli/hook.mjs
CHANGED
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
-
import { spawn } from 'node:child_process';
|
|
32
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.mjs';
|
|
35
|
-
import { specForPath } from '../parse/languages.mjs';
|
|
35
|
+
import { codeSpecForPath as specForPath } from '../parse/languages.mjs'; // hooks report on code files only
|
|
36
36
|
|
|
37
37
|
const MAX_CONTEXT = 1500;
|
|
38
38
|
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write', 'apply_patch', 'replace', 'write_file']);
|
|
@@ -103,15 +103,63 @@ function sessionState(input) {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The commands of a shell line, split at `;`, `&&`, `||` and newlines outside quotes, with heredoc
|
|
108
|
+
* bodies dropped and `cd DIR` steps left out (agents write `cd repo; sed -n …; grep …`).
|
|
109
|
+
*/
|
|
110
|
+
export function shellSegments(command) {
|
|
111
|
+
return splitShell(command).filter(x => !/^cd\s+\S+$/.test(x));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function splitShell(command) {
|
|
115
|
+
const src = String(command ?? '');
|
|
116
|
+
const out = [];
|
|
117
|
+
let cur = '', q = null;
|
|
118
|
+
const lines = src.split('\n');
|
|
119
|
+
for (let li = 0; li < lines.length; li++) {
|
|
120
|
+
const line = lines[li];
|
|
121
|
+
for (let i = 0; i < line.length; i++) {
|
|
122
|
+
const ch = line[i];
|
|
123
|
+
if (q) { cur += ch; if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < line.length) cur += line[++i]; continue; }
|
|
124
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
125
|
+
if (ch === '\\' && i + 1 < line.length) { cur += ch + line[++i]; continue; }
|
|
126
|
+
if (ch === ';' || (ch === '&' && line[i + 1] === '&') || (ch === '|' && line[i + 1] === '|')) {
|
|
127
|
+
out.push(cur); cur = '';
|
|
128
|
+
if (ch !== ';') i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
cur += ch;
|
|
132
|
+
}
|
|
133
|
+
if (q) { cur += '\n'; continue; } // a quoted string spanning lines
|
|
134
|
+
// a heredoc (`<<'EOF'`): skip its body up to the delimiter
|
|
135
|
+
const hd = /<<-?\s*['"]?(\w+)['"]?/.exec(cur);
|
|
136
|
+
out.push(cur); cur = '';
|
|
137
|
+
if (hd) while (li + 1 < lines.length && lines[li + 1].trim() !== hd[1]) li++;
|
|
138
|
+
if (hd) li++;
|
|
139
|
+
}
|
|
140
|
+
if (cur) out.push(cur);
|
|
141
|
+
return out.map(x => x.trim()).filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
106
144
|
/** A file read through the shell: [file, from, to] for `sed -n 'a,bp' f`, `head -n N f`, `cat f`, `nl -ba f | sed -n …`. */
|
|
107
145
|
export function shellRead(command) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
146
|
+
return shellReads(command)[0] ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every file read in a shell line (`cd repo; sed -n 10,40p a.py; sed -n 5,9p b.py`), relative paths resolved against a `cd` before them. */
|
|
150
|
+
export function shellReads(command) {
|
|
151
|
+
const out = [];
|
|
152
|
+
let dir = null;
|
|
153
|
+
const at = (f) => (dir && !path.isAbsolute(f) ? path.join(dir, f) : f);
|
|
154
|
+
for (const c of splitShell(command)) {
|
|
155
|
+
let m;
|
|
156
|
+
if ((m = /^cd\s+(['"]?)(\S+)\1$/.exec(c))) { dir = path.isAbsolute(m[2]) ? m[2] : path.join(dir ?? '', m[2]); continue; }
|
|
157
|
+
if ((m = /^(?:nl\s+-ba\s+|cat\s+-n\s+)(\S+)\s*\|\s*sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s*$/.exec(c))) out.push([at(m[1]), +m[2], +m[3]]);
|
|
158
|
+
else if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) out.push([at(m[3]), +m[1], +m[2]]);
|
|
159
|
+
else if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) out.push([at(m[2]), 1, +m[1]]);
|
|
160
|
+
else if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) out.push([at(m[1]), 1, 2000]);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
/** Minimal shell-words split (quotes and escapes) for extracting a grep pattern. */
|
|
@@ -158,17 +206,20 @@ function localIndex(root) {
|
|
|
158
206
|
};
|
|
159
207
|
}
|
|
160
208
|
|
|
161
|
-
async function afterEdit(root, input, acquire) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
209
|
+
async function afterEdit(root, input, acquire, files = null) {
|
|
210
|
+
if (!files) {
|
|
211
|
+
const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
|
|
212
|
+
if (!file) return null;
|
|
213
|
+
files = [path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file];
|
|
214
|
+
}
|
|
215
|
+
files = files.filter(rel => !rel.startsWith('..') && specForPath(rel)).slice(0, 6);
|
|
216
|
+
if (!files.length) return null;
|
|
166
217
|
const ix = await acquire();
|
|
167
218
|
if (!ix) return null;
|
|
168
219
|
const { intel } = ix;
|
|
169
220
|
try {
|
|
170
221
|
const { checkChanges } = await import('../query/check.mjs');
|
|
171
|
-
const r = await checkChanges(intel, { files
|
|
222
|
+
const r = await checkChanges(intel, { files, tests: false });
|
|
172
223
|
if (r.error) return null;
|
|
173
224
|
const lines = [];
|
|
174
225
|
for (const e of r.syntax.slice(0, 3)) lines.push(`${e.path}:${e.line} has a syntax error introduced since the last commit (${e.missing ? `missing ${e.missing}` : 'unexpected code'}): ${e.text.slice(0, 100)}`);
|
|
@@ -235,46 +286,68 @@ async function afterGrep(root, input, crawling, acquire) {
|
|
|
235
286
|
* silent when there is none; and while the agent is crawling, where the names the lines use are defined.
|
|
236
287
|
*/
|
|
237
288
|
async function afterRead(root, input, state, crawling, acquire) {
|
|
238
|
-
let
|
|
289
|
+
let reads = [];
|
|
239
290
|
if (READ_TOOLS.has(input.tool_name)) {
|
|
240
|
-
file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
|
|
291
|
+
const file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
|
|
292
|
+
let from = 1, to = 2000;
|
|
241
293
|
const off = Number(input.tool_input?.offset ?? 0), lim = Number(input.tool_input?.limit ?? 0);
|
|
242
294
|
if (off > 0) from = off;
|
|
243
295
|
if (lim > 0) to = from + lim - 1;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!r) return null;
|
|
247
|
-
[file, from, to] = r;
|
|
248
|
-
}
|
|
249
|
-
if (!file) return null;
|
|
296
|
+
if (file) reads = [[file, from, to]];
|
|
297
|
+
} else reads = shellReads(input.tool_input?.command);
|
|
250
298
|
const cwd = input.cwd || root;
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
299
|
+
const ranges = reads.map(([file, from, to]) => {
|
|
300
|
+
const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
301
|
+
return { rel: path.relative(root, abs).split(path.sep).join('/'), from, to };
|
|
302
|
+
}).filter(r => !r.rel.startsWith('..') && specForPath(r.rel)).slice(0, 4);
|
|
303
|
+
if (!ranges.length) return null;
|
|
254
304
|
const ix = await acquire();
|
|
255
305
|
if (!ix) return null;
|
|
256
306
|
const { intel } = ix;
|
|
257
307
|
try {
|
|
258
|
-
await intel.revalidate([rel]); // a long-lived index may not have seen the file change yet
|
|
259
|
-
const
|
|
308
|
+
await intel.revalidate([...new Set(ranges.map(r => r.rel))]); // a long-lived index may not have seen the file change yet
|
|
309
|
+
const facts = [], defs = [];
|
|
260
310
|
const { factsForRange, renderFacts } = await import('../query/facts.mjs');
|
|
311
|
+
const { definitionsUsed } = await import('../query/read.mjs');
|
|
261
312
|
state.st.facts ??= [];
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
for (const f of
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
|
|
271
|
-
for (const r of rows) state.st.shown.push(r.id);
|
|
272
|
-
if (rows.length) out.push(`graph-indexer: where names used in these lines are defined (read them directly instead of searching):\n${rows.map(r => ` ${r.qname} → ${r.path}:${r.start_line} ${String(r.sig || r.kind).replace(/\s+/g, ' ').replace(/\s+#.*$/, '').slice(0, 100)}`).join('\n')}`);
|
|
313
|
+
for (const { rel, from, to } of ranges) {
|
|
314
|
+
const fs1 = factsForRange(intel, rel, from, to, { max: Math.max(1, 4 - facts.length), shown: new Set(state.st.facts) });
|
|
315
|
+
for (const f of fs1) { state.st.facts.push(f.key); facts.push(f); }
|
|
316
|
+
if (crawling) {
|
|
317
|
+
const n = Math.max(1, to - from + 1);
|
|
318
|
+
const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
|
|
319
|
+
for (const r of rows) { state.st.shown.push(r.id); if (defs.length < 12) defs.push(r); }
|
|
320
|
+
}
|
|
273
321
|
}
|
|
322
|
+
const out = [];
|
|
323
|
+
if (facts.length) out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
|
|
324
|
+
if (defs.length) out.push(`graph-indexer: where names used in these lines are defined (read them directly instead of searching):\n${defs.map(r => ` ${r.qname} → ${r.path}:${r.start_line} ${String(r.sig || r.kind).replace(/\s+/g, ' ').replace(/\s+#.*$/, '').slice(0, 100)}`).join('\n')}`);
|
|
274
325
|
return out.length ? out.join('\n') : null;
|
|
275
326
|
} finally { ix.release(); }
|
|
276
327
|
}
|
|
277
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Source files a shell command changed (`sed -i`, a Python heredoc that rewrites a file, a patch):
|
|
331
|
+
* the working tree's modified and new files whose modification time moved since the hook last
|
|
332
|
+
* looked, so the edit checker also follows edits made without the edit tool.
|
|
333
|
+
*/
|
|
334
|
+
function shellEdits(root, state) {
|
|
335
|
+
let r;
|
|
336
|
+
try { r = spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all', '-z'], { encoding: 'utf8', timeout: 1500 }); } catch { return []; }
|
|
337
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return [];
|
|
338
|
+
const seen = state.st.mtimes ??= {};
|
|
339
|
+
const changed = [];
|
|
340
|
+
for (const e of r.stdout.split('\0')) {
|
|
341
|
+
if (e.length < 4 || e.startsWith('D') || e[1] === 'D') continue;
|
|
342
|
+
const rel = e.slice(3);
|
|
343
|
+
if (!specForPath(rel) || rel.startsWith(DATA_DIR_NAME + '/')) continue;
|
|
344
|
+
let m;
|
|
345
|
+
try { m = fs.statSync(path.join(root, rel)).mtimeMs; } catch { continue; }
|
|
346
|
+
if (seen[rel] !== m) { seen[rel] = m; changed.push(rel); }
|
|
347
|
+
}
|
|
348
|
+
return changed;
|
|
349
|
+
}
|
|
350
|
+
|
|
278
351
|
// the lookup rules, for sessions and subagents whose instruction files do not carry them
|
|
279
352
|
const RULES = `How to look code up in this repository (graph-indexer):
|
|
280
353
|
- Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
|
|
@@ -288,7 +361,7 @@ async function sessionLine(root, { rules = false, acquire }) {
|
|
|
288
361
|
const { intel } = ix;
|
|
289
362
|
try {
|
|
290
363
|
const st = intel.stats();
|
|
291
|
-
const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: find_references (exact uses, even when other code shares the name), change_impact (what a change affects and which tests to run), check_changes (what an edit broke, and the closest tests), call_graph, read_code (symbols or ranges by name, with where each name they use is defined), search_text, search_code and outline.`;
|
|
364
|
+
const line = `graph-indexer has a live index of this repository (${st.files} files, ${st.symbols} symbols). Its MCP tools answer with exact locations: explore (the code relevant to a task, its callers, the definitions it uses and its tests, in one call), find_references (exact uses, even when other code shares the name), change_impact (what a change affects and which tests to run), check_changes (what an edit broke, and the closest tests), call_graph, read_code (symbols or ranges by name, with where each name they use is defined), search_text, search_code and outline.`;
|
|
292
365
|
// the managed block of `init` already carries the rules; otherwise add them
|
|
293
366
|
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
|
|
294
367
|
return rules || !carried ? `${line}\n${RULES}` : line;
|
|
@@ -308,6 +381,7 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
308
381
|
const state = sessionState(input);
|
|
309
382
|
if (EDIT_TOOLS.has(tool)) {
|
|
310
383
|
state.st.nav = 0;
|
|
384
|
+
shellEdits(root, state); // so a later shell command does not report this edit again
|
|
311
385
|
state.save();
|
|
312
386
|
text = await afterEdit(root, input, acquire);
|
|
313
387
|
} else if (GREP_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && grepPattern(command))) {
|
|
@@ -318,6 +392,12 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
318
392
|
state.st.nav++;
|
|
319
393
|
text = await afterRead(root, input, state, state.st.nav >= CRAWL, acquire);
|
|
320
394
|
state.save();
|
|
395
|
+
} else if (SHELL_TOOLS.has(tool)) {
|
|
396
|
+
// any other command may have edited files (`sed -i`, a script): check what it changed
|
|
397
|
+
const changed = shellEdits(root, state);
|
|
398
|
+
if (changed.length) state.st.nav = 0;
|
|
399
|
+
state.save();
|
|
400
|
+
if (changed.length) text = await afterEdit(root, input, acquire, changed);
|
|
321
401
|
}
|
|
322
402
|
} else if (event === 'session-start' || event === 'subagent-start') {
|
|
323
403
|
name ??= event === 'session-start' ? 'SessionStart' : 'SubagentStart';
|
|
@@ -351,7 +431,9 @@ export async function runHook(event, { repo = null, version = null } = {}) {
|
|
|
351
431
|
const timer = setTimeout(() => process.exit(0), deadlineMs); // fail open: never hold the agent up
|
|
352
432
|
try {
|
|
353
433
|
const input = await readStdin();
|
|
354
|
-
|
|
434
|
+
// an explicit --repo wins over the session's directory (a session started elsewhere, with
|
|
435
|
+
// the repository added to it)
|
|
436
|
+
const root = findRepoRoot(repo || input.cwd || process.cwd());
|
|
355
437
|
const { askResident, residentRequired } = await import('./resident.mjs');
|
|
356
438
|
const reply = await askResident(root, { op: 'hook', event, input }, { version, totalMs: 2000 });
|
|
357
439
|
let out = null;
|
package/src/cli/init.mjs
CHANGED
|
@@ -26,6 +26,8 @@ const SNIPPET = `${BLOCK_START}
|
|
|
26
26
|
- Check once: run the tests that cover your change when you are done; do not re-run a check nothing has changed.
|
|
27
27
|
|
|
28
28
|
The \`graph-indexer\` MCP server answers what a text search cannot answer exactly:
|
|
29
|
+
- Fixing an issue or bug report: make your first call \`explore\` with the names and terms the issue uses (or its text as written). It ranks the code where the names the issue uses meet (usually what you will change) and shows it with its callers, the definitions it uses and the tests to run; read further only what it leaves out.
|
|
30
|
+
- Starting on any other task in code you have not read: \`explore\` with the task in words.
|
|
29
31
|
- Uses of a function, method or class that other code shares a name with: \`find_references\` (exact call sites, says when the list is complete; kind \`inherit\` for subclasses and implementations). Callers of callers: \`call_graph\`.
|
|
30
32
|
- Before changing a signature other code relies on: \`change_impact\`; after: \`check_changes\`.
|
|
31
33
|
${BLOCK_END}`;
|
|
@@ -209,6 +211,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
209
211
|
const hooks = flag('--hooks');
|
|
210
212
|
const noInstructions = flag('--no-instructions');
|
|
211
213
|
const noHelper = flag('--no-helper');
|
|
214
|
+
const noDemo = flag('--no-demo');
|
|
212
215
|
const all = flag('--all');
|
|
213
216
|
const agentList = opt('--agents');
|
|
214
217
|
let chosen;
|
|
@@ -267,6 +270,18 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
267
270
|
}
|
|
268
271
|
process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
|
|
269
272
|
`Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args ${JSON.stringify([...NPX_ARGS, 'serve', '--repo', repo])}.\n` +
|
|
270
|
-
`Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` +
|
|
273
|
+
(dryRun || noDemo ? `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` : '') +
|
|
271
274
|
(hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
|
|
275
|
+
if (dryRun || noDemo) return;
|
|
276
|
+
// build the index now (the server would on first start) and show what it answers here
|
|
277
|
+
const { CodeIntel } = await import('../query/intel.mjs');
|
|
278
|
+
const { showcaseText } = await import('../query/showcase.mjs');
|
|
279
|
+
const intel = new CodeIntel({ root: repo });
|
|
280
|
+
try {
|
|
281
|
+
let last = 0;
|
|
282
|
+
await intel.open({ onProgress: (d, n) => { const now = Date.now(); 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'); } } });
|
|
283
|
+
process.stdout.write(`\n${await showcaseText(intel)}\n`);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
process.stdout.write(`\nThe index could not be built now (${e.message}); the server builds it on first start.\n`);
|
|
286
|
+
} finally { intel.close(); }
|
|
272
287
|
}
|
package/src/cli/resident.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* { v, version, op: 'hook', event, input } → { ok, out } out: what the hook prints, or null
|
|
17
17
|
* { v, version, op: 'tool', calls } → { ok, out } out: the CLI's output text
|
|
18
18
|
* A request from another graph-indexer version gets { ok: false } and the client falls back, except
|
|
19
|
-
* from thin clients that only relay hooks (`any: true`, integrations/claude-code/hooks/gi-hook.mjs).
|
|
19
|
+
* from thin clients that only relay hooks (`any: true`, integrations/claude-code-local/hooks/gi-hook.mjs).
|
|
20
20
|
*
|
|
21
21
|
* The socket is only reachable by its owner: inside the repository's .graph-indexer/ directory,
|
|
22
22
|
* or, when that path is too long for a Unix socket, in a per-user directory with mode 0700 that
|