graph-indexer 3.0.0 → 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 +456 -0
- package/README.md +72 -15
- package/SECURITY.md +21 -4
- package/bin/graph-indexer.mjs +46 -5
- package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
- 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 +12 -6
- package/integrations/opencode/graph-indexer.js +13 -8
- package/package.json +2 -2
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +140 -48
- package/src/cli/init.mjs +49 -17
- package/src/cli/resident.mjs +15 -5
- package/src/index/discover.mjs +3 -5
- package/src/index/indexer.mjs +77 -41
- package/src/index/modules.mjs +82 -7
- package/src/index/parse-worker.mjs +12 -0
- package/src/index/parsepool.mjs +73 -0
- package/src/index/resolver.mjs +371 -25
- package/src/mcp/server.mjs +16 -12
- package/src/mcp/tools.mjs +419 -26
- package/src/parse/arity.mjs +41 -3
- package/src/parse/extract.mjs +277 -46
- 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 +55 -17
- 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 +87 -22
- 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/query/testcmd.mjs +25 -14
- package/src/query/textsearch.mjs +29 -11
- 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/src/util/git.mjs +30 -0
- package/src/util/paths.mjs +40 -1
- package/src/util/regex.mjs +2 -0
- 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/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
|
-
import { findRepoRoot, dataDir, DATA_DIR_NAME } from '../util/paths.mjs';
|
|
35
|
-
import { specForPath } from '../parse/languages.mjs';
|
|
34
|
+
import { findRepoRoot, dataDir, DATA_DIR_NAME, readRepoFile } from '../util/paths.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']);
|
|
@@ -78,30 +78,88 @@ function responseText(input) {
|
|
|
78
78
|
/** Navigation calls since the last edit and definitions already shown, per session (crawl detector). */
|
|
79
79
|
function sessionState(input) {
|
|
80
80
|
const id = String(input.session_id ?? input.sessionId ?? input.conversation_id ?? 'default').replace(/[^\w.-]/g, '_').slice(0, 80);
|
|
81
|
-
|
|
81
|
+
// a per-user directory closed to others: in a shared temp dir another user could otherwise read
|
|
82
|
+
// the state or plant links that the next save writes through
|
|
83
|
+
const dir = path.join(os.tmpdir(), `graph-indexer-hooks-${process.getuid?.() ?? 'user'}`);
|
|
84
|
+
const file = path.join(dir, `${id}.json`);
|
|
85
|
+
const privateDir = () => {
|
|
86
|
+
if (process.platform === 'win32') return true;
|
|
87
|
+
try { const d = fs.lstatSync(dir); return d.isDirectory() && d.uid === process.getuid() && (d.mode & 0o077) === 0; } catch { return false; }
|
|
88
|
+
};
|
|
82
89
|
let st = { nav: 0, shown: [] };
|
|
83
|
-
try { st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
|
|
90
|
+
try { if (privateDir() && fs.lstatSync(file).isFile()) st = { ...st, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch { /* first call */ }
|
|
84
91
|
return {
|
|
85
92
|
st,
|
|
86
93
|
save() {
|
|
87
94
|
try {
|
|
88
|
-
fs.mkdirSync(
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
if (!privateDir()) return;
|
|
89
97
|
st.shown = st.shown.slice(-400);
|
|
90
|
-
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
fs.writeFileSync(tmp, JSON.stringify(st), { flag: 'wx', mode: 0o600 });
|
|
100
|
+
fs.renameSync(tmp, file);
|
|
91
101
|
} catch { /* optional */ }
|
|
92
102
|
},
|
|
93
103
|
};
|
|
94
104
|
}
|
|
95
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
|
+
|
|
96
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 …`. */
|
|
97
145
|
export function shellRead(command) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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;
|
|
105
163
|
}
|
|
106
164
|
|
|
107
165
|
/** Minimal shell-words split (quotes and escapes) for extracting a grep pattern. */
|
|
@@ -148,17 +206,20 @@ function localIndex(root) {
|
|
|
148
206
|
};
|
|
149
207
|
}
|
|
150
208
|
|
|
151
|
-
async function afterEdit(root, input, acquire) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
156
217
|
const ix = await acquire();
|
|
157
218
|
if (!ix) return null;
|
|
158
219
|
const { intel } = ix;
|
|
159
220
|
try {
|
|
160
221
|
const { checkChanges } = await import('../query/check.mjs');
|
|
161
|
-
const r = await checkChanges(intel, { files
|
|
222
|
+
const r = await checkChanges(intel, { files, tests: false });
|
|
162
223
|
if (r.error) return null;
|
|
163
224
|
const lines = [];
|
|
164
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)}`);
|
|
@@ -225,51 +286,73 @@ async function afterGrep(root, input, crawling, acquire) {
|
|
|
225
286
|
* silent when there is none; and while the agent is crawling, where the names the lines use are defined.
|
|
226
287
|
*/
|
|
227
288
|
async function afterRead(root, input, state, crawling, acquire) {
|
|
228
|
-
let
|
|
289
|
+
let reads = [];
|
|
229
290
|
if (READ_TOOLS.has(input.tool_name)) {
|
|
230
|
-
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;
|
|
231
293
|
const off = Number(input.tool_input?.offset ?? 0), lim = Number(input.tool_input?.limit ?? 0);
|
|
232
294
|
if (off > 0) from = off;
|
|
233
295
|
if (lim > 0) to = from + lim - 1;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!r) return null;
|
|
237
|
-
[file, from, to] = r;
|
|
238
|
-
}
|
|
239
|
-
if (!file) return null;
|
|
296
|
+
if (file) reads = [[file, from, to]];
|
|
297
|
+
} else reads = shellReads(input.tool_input?.command);
|
|
240
298
|
const cwd = input.cwd || root;
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
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;
|
|
244
304
|
const ix = await acquire();
|
|
245
305
|
if (!ix) return null;
|
|
246
306
|
const { intel } = ix;
|
|
247
307
|
try {
|
|
248
|
-
await intel.revalidate([rel]); // a long-lived index may not have seen the file change yet
|
|
249
|
-
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 = [];
|
|
250
310
|
const { factsForRange, renderFacts } = await import('../query/facts.mjs');
|
|
311
|
+
const { definitionsUsed } = await import('../query/read.mjs');
|
|
251
312
|
state.st.facts ??= [];
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
for (const f of
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
|
|
261
|
-
for (const r of rows) state.st.shown.push(r.id);
|
|
262
|
-
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
|
+
}
|
|
263
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')}`);
|
|
264
325
|
return out.length ? out.join('\n') : null;
|
|
265
326
|
} finally { ix.release(); }
|
|
266
327
|
}
|
|
267
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
|
+
|
|
268
351
|
// the lookup rules, for sessions and subagents whose instruction files do not carry them
|
|
269
352
|
const RULES = `How to look code up in this repository (graph-indexer):
|
|
270
353
|
- Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
|
|
271
354
|
- The function or the 50–100 lines you need, not whole files; to find a definition, search for its definition line (\`def name\`, \`class Name\`) across the package in one search.
|
|
272
|
-
- Exact uses of a function or class, even when other code shares its name: find_references (\`npx graph-indexer refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
|
|
355
|
+
- Exact uses of a function or class, even when other code shares its name: find_references (\`npx -y --prefix / graph-indexer@3 refs NAME\` in a shell). Before changing what other code relies on: change_impact; when you are done: check_changes, then run the tests it names, once.`;
|
|
273
356
|
|
|
274
357
|
async function sessionLine(root, { rules = false, acquire }) {
|
|
275
358
|
if (!hasIndex(root)) return null; // being built in the background (spawnResident); say nothing yet
|
|
@@ -278,9 +361,9 @@ async function sessionLine(root, { rules = false, acquire }) {
|
|
|
278
361
|
const { intel } = ix;
|
|
279
362
|
try {
|
|
280
363
|
const st = intel.stats();
|
|
281
|
-
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.`;
|
|
282
365
|
// the managed block of `init` already carries the rules; otherwise add them
|
|
283
|
-
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f =>
|
|
366
|
+
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
|
|
284
367
|
return rules || !carried ? `${line}\n${RULES}` : line;
|
|
285
368
|
} finally { ix.release(); }
|
|
286
369
|
}
|
|
@@ -298,6 +381,7 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
298
381
|
const state = sessionState(input);
|
|
299
382
|
if (EDIT_TOOLS.has(tool)) {
|
|
300
383
|
state.st.nav = 0;
|
|
384
|
+
shellEdits(root, state); // so a later shell command does not report this edit again
|
|
301
385
|
state.save();
|
|
302
386
|
text = await afterEdit(root, input, acquire);
|
|
303
387
|
} else if (GREP_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && grepPattern(command))) {
|
|
@@ -308,6 +392,12 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
308
392
|
state.st.nav++;
|
|
309
393
|
text = await afterRead(root, input, state, state.st.nav >= CRAWL, acquire);
|
|
310
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);
|
|
311
401
|
}
|
|
312
402
|
} else if (event === 'session-start' || event === 'subagent-start') {
|
|
313
403
|
name ??= event === 'session-start' ? 'SessionStart' : 'SubagentStart';
|
|
@@ -341,7 +431,9 @@ export async function runHook(event, { repo = null, version = null } = {}) {
|
|
|
341
431
|
const timer = setTimeout(() => process.exit(0), deadlineMs); // fail open: never hold the agent up
|
|
342
432
|
try {
|
|
343
433
|
const input = await readStdin();
|
|
344
|
-
|
|
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());
|
|
345
437
|
const { askResident, residentRequired } = await import('./resident.mjs');
|
|
346
438
|
const reply = await askResident(root, { op: 'hook', event, input }, { version, totalMs: 2000 });
|
|
347
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}`;
|
|
@@ -52,13 +54,17 @@ function readJson(p) {
|
|
|
52
54
|
try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// --prefix /: npx would otherwise run a graph-indexer 3.x found in the repository's own node_modules
|
|
58
|
+
// (code the repository ships) instead of the published package
|
|
59
|
+
const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
|
|
60
|
+
|
|
55
61
|
function serverEntry(agent, { local }) {
|
|
56
62
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
57
63
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
58
64
|
const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
|
|
59
65
|
const base = local
|
|
60
66
|
? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
|
|
61
|
-
: { command: 'npx', args: [
|
|
67
|
+
: { command: 'npx', args: [...NPX_ARGS, 'serve', ...repoArgs] };
|
|
62
68
|
if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
|
|
63
69
|
if (agent.zed) return { ...base, env: {} };
|
|
64
70
|
return agent.vscode ? { type: 'stdio', ...base } : base;
|
|
@@ -70,7 +76,24 @@ function configFile(agent, repo) {
|
|
|
70
76
|
return agent.file;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Write `file` under `repo` without following a link the repository committed (`CLAUDE.md` →
|
|
81
|
+
* ~/.zshrc, `.claude` → ~/.claude): such a target is refused instead of written through.
|
|
82
|
+
*/
|
|
83
|
+
function writeInRepo(repo, file, content) {
|
|
84
|
+
const rootReal = fs.realpathSync(repo);
|
|
85
|
+
const inside = (p) => { const r = fs.realpathSync(p); return r === rootReal || r.startsWith(rootReal + path.sep); };
|
|
86
|
+
let dir = path.dirname(file);
|
|
87
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir);
|
|
88
|
+
let link = false;
|
|
89
|
+
try { link = fs.lstatSync(file).isSymbolicLink(); } catch { /* a new file */ }
|
|
90
|
+
if (link || !inside(dir)) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
if (!inside(path.dirname(file))) throw new Error(`${path.relative(repo, file)} leads outside the repository (a symbolic link); not written`);
|
|
93
|
+
fs.writeFileSync(file, content);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upsertBlock(repo, file, dryRun) {
|
|
74
97
|
const exists = fs.existsSync(file);
|
|
75
98
|
const text = exists ? fs.readFileSync(file, 'utf8') : '';
|
|
76
99
|
let next;
|
|
@@ -80,7 +103,7 @@ function upsertBlock(file, dryRun) {
|
|
|
80
103
|
next = (text.trimEnd() ? text.trimEnd() + '\n\n' : '') + SNIPPET + '\n';
|
|
81
104
|
}
|
|
82
105
|
if (next === text) return 'unchanged';
|
|
83
|
-
if (!dryRun)
|
|
106
|
+
if (!dryRun) writeInRepo(repo, file, next);
|
|
84
107
|
return exists ? 'updated' : 'created';
|
|
85
108
|
}
|
|
86
109
|
|
|
@@ -100,7 +123,7 @@ function cliCommand(local) {
|
|
|
100
123
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
101
124
|
if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
|
|
102
125
|
const installed = installedBin();
|
|
103
|
-
return installed ? JSON.stringify(installed) : 'npx
|
|
126
|
+
return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
|
|
104
127
|
}
|
|
105
128
|
|
|
106
129
|
/** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
|
|
@@ -108,7 +131,7 @@ function cliArgv(local) {
|
|
|
108
131
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
109
132
|
if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
|
|
110
133
|
const installed = installedBin();
|
|
111
|
-
return installed ? [installed] : ['npx',
|
|
134
|
+
return installed ? [installed] : ['npx', ...NPX_ARGS];
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/**
|
|
@@ -128,8 +151,7 @@ function writeOpenCodePlugin(repo, local, dryRun) {
|
|
|
128
151
|
const file = path.join(repo, rel);
|
|
129
152
|
const same = fs.existsSync(file) && fs.readFileSync(file, 'utf8') === src;
|
|
130
153
|
if (!same && !dryRun) {
|
|
131
|
-
|
|
132
|
-
fs.writeFileSync(file, src);
|
|
154
|
+
writeInRepo(repo, file, src);
|
|
133
155
|
}
|
|
134
156
|
out.push(`${same ? 'already in' : 'written to'} ${rel}`);
|
|
135
157
|
}
|
|
@@ -160,8 +182,7 @@ function upsertClaudeHooks(repo, local, dryRun) {
|
|
|
160
182
|
}
|
|
161
183
|
const changed = before !== JSON.stringify(cfg.hooks);
|
|
162
184
|
if (changed && !dryRun) {
|
|
163
|
-
|
|
164
|
-
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
|
|
185
|
+
writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
|
|
165
186
|
}
|
|
166
187
|
return `Claude Code hooks: ${changed ? 'written to' : 'already in'} .claude/settings.json (post-edit check, grep disambiguation, session line)`;
|
|
167
188
|
}
|
|
@@ -178,8 +199,7 @@ function upsertClaudeHelper(repo, dryRun) {
|
|
|
178
199
|
if (have === want) return `Claude Code: structural helper already in ${rel}`;
|
|
179
200
|
if (have != null && !have.includes("graph-indexer's index")) return `Claude Code: ${rel} exists and is not graph-indexer's, so it was left as is`;
|
|
180
201
|
if (!dryRun) {
|
|
181
|
-
|
|
182
|
-
fs.writeFileSync(file, want);
|
|
202
|
+
writeInRepo(repo, file, want);
|
|
183
203
|
}
|
|
184
204
|
return `Claude Code: structural helper ${have == null ? 'written to' : 'updated in'} ${rel} (a sub-agent on a small model that answers call-site, caller, subclass and impact questions from the index)`;
|
|
185
205
|
}
|
|
@@ -191,6 +211,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
191
211
|
const hooks = flag('--hooks');
|
|
192
212
|
const noInstructions = flag('--no-instructions');
|
|
193
213
|
const noHelper = flag('--no-helper');
|
|
214
|
+
const noDemo = flag('--no-demo');
|
|
194
215
|
const all = flag('--all');
|
|
195
216
|
const agentList = opt('--agents');
|
|
196
217
|
let chosen;
|
|
@@ -221,8 +242,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
221
242
|
cfg[a.key]['graph-indexer'] = entry;
|
|
222
243
|
const changed = before !== JSON.stringify(entry);
|
|
223
244
|
if (changed && !dryRun) {
|
|
224
|
-
|
|
225
|
-
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n');
|
|
245
|
+
writeInRepo(repo, file, JSON.stringify(cfg, null, 2) + '\n');
|
|
226
246
|
}
|
|
227
247
|
report.push(`${a.label}: ${changed ? (before === 'null' ? 'added' : 'updated') : 'already configured'} ${rel}`);
|
|
228
248
|
} else if (key === 'codex') {
|
|
@@ -239,17 +259,29 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
239
259
|
if (hooks && chosen.includes('opencode')) report.push(writeOpenCodePlugin(repo, local, dryRun));
|
|
240
260
|
if (!noInstructions) {
|
|
241
261
|
const targets = [...new Set(chosen.map(k => AGENTS[k].instructions))];
|
|
242
|
-
for (const t of targets) report.push(`${t}: ${upsertBlock(path.join(repo, t), dryRun)} graph-indexer instructions block`);
|
|
262
|
+
for (const t of targets) report.push(`${t}: ${upsertBlock(repo, path.join(repo, t), dryRun)} graph-indexer instructions block`);
|
|
243
263
|
}
|
|
244
264
|
// keep the index out of version control
|
|
245
265
|
const gi = path.join(repo, '.gitignore');
|
|
246
266
|
const giText = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
247
267
|
if (!/^\/?\.graph-indexer\/?$/m.test(giText)) {
|
|
248
|
-
if (!dryRun)
|
|
268
|
+
if (!dryRun) writeInRepo(repo, gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
|
|
249
269
|
report.push('.gitignore: added .graph-indexer/');
|
|
250
270
|
}
|
|
251
271
|
process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
|
|
252
|
-
`Other agents (Claude Desktop, JetBrains AI Assistant, Cline): add an MCP server with command "npx" and args [
|
|
253
|
-
`Build the index now with: npx
|
|
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` +
|
|
273
|
+
(dryRun || noDemo ? `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` : '') +
|
|
254
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(); }
|
|
255
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
|
|
@@ -44,15 +44,25 @@ export function socketPath(root) {
|
|
|
44
44
|
return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
const mine = (st) => (process.getuid ? st.uid === process.getuid() : true);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The socket's directory must be a real directory of ours (not a link the repository committed);
|
|
51
|
+
* a per-user directory in the temp dir must also be closed to everyone else.
|
|
52
|
+
*/
|
|
48
53
|
function privateDir(dir, root) {
|
|
49
|
-
if (WIN
|
|
54
|
+
if (WIN) return true;
|
|
50
55
|
try {
|
|
51
56
|
const st = fs.lstatSync(dir);
|
|
52
|
-
return st.isDirectory() && (
|
|
57
|
+
return st.isDirectory() && mine(st) && (dir === path.join(path.resolve(root), DATA_DIR_NAME) || (st.mode & 0o077) === 0);
|
|
53
58
|
} catch { return false; }
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** A socket of ours, not a file or a listener someone else put there. */
|
|
62
|
+
function ownSocket(file) {
|
|
63
|
+
try { const st = fs.lstatSync(file); return st.isSocket() && mine(st); } catch { return false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
const disabled = () => process.env.GRAPH_INDEXER_RESIDENT === '0';
|
|
57
67
|
/** GRAPH_INDEXER_RESIDENT=require: answer only through a resident, never by opening the index (tests, debugging). */
|
|
58
68
|
export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 'require';
|
|
@@ -64,7 +74,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
|
|
|
64
74
|
export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
|
|
65
75
|
if (disabled()) return Promise.resolve(null);
|
|
66
76
|
const file = socketPath(root);
|
|
67
|
-
if (!WIN && (!
|
|
77
|
+
if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
|
|
68
78
|
return new Promise((resolve) => {
|
|
69
79
|
let done = false, connected = false, buf = '';
|
|
70
80
|
const sock = net.createConnection(file);
|
package/src/index/discover.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { gitSync } from '../util/git.mjs';
|
|
10
10
|
import { specForPath } from '../parse/languages.mjs';
|
|
11
11
|
|
|
12
12
|
export const MAX_FILE_BYTES = 1_500_000;
|
|
@@ -24,14 +24,12 @@ const SKIP_DIRS = new Set([
|
|
|
24
24
|
const GENERATED_FILE = /(\.min\.(js|css|mjs)$|\.bundle\.js$|[.-]bundle\.[cm]?js$|\.chunk\.js$|\.map$|\.pb\.go$|_pb2(_grpc)?\.py$|\.pb\.(cc|h)$|\.generated\.\w+$|\.g\.dart$|\.designer\.cs$|(^|\/)generated\/|\.d\.ts\.map$)/i;
|
|
25
25
|
|
|
26
26
|
function isGitRepo(root) {
|
|
27
|
-
const r =
|
|
27
|
+
const r = gitSync(root, ['rev-parse', '--is-inside-work-tree']);
|
|
28
28
|
return r.status === 0 && r.stdout.trim() === 'true';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function gitListFiles(root) {
|
|
32
|
-
const r =
|
|
33
|
-
cwd: root, encoding: 'utf8', maxBuffer: 1 << 30,
|
|
34
|
-
});
|
|
32
|
+
const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
|
|
35
33
|
if (r.status !== 0) return null;
|
|
36
34
|
return r.stdout.split('\0').filter(Boolean);
|
|
37
35
|
}
|