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.
Files changed (60) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/README.md +72 -15
  3. package/SECURITY.md +21 -4
  4. package/bin/graph-indexer.mjs +46 -5
  5. package/integrations/claude-code/.claude-plugin/plugin.json +3 -2
  6. package/integrations/claude-code/agents/code-structure.md +0 -3
  7. package/integrations/claude-code/skills/setup/SKILL.md +26 -0
  8. package/integrations/claude-code-local/.claude-plugin/plugin.json +9 -0
  9. package/integrations/{claude-code → claude-code-local}/.mcp.json +1 -1
  10. package/integrations/claude-code-local/README.md +10 -0
  11. package/integrations/claude-code-local/agents/code-structure.md +22 -0
  12. package/integrations/{claude-code → claude-code-local}/hooks/gi-hook.mjs +12 -6
  13. package/integrations/opencode/graph-indexer.js +13 -8
  14. package/package.json +2 -2
  15. package/src/cli/helper.mjs +15 -1
  16. package/src/cli/hook.mjs +140 -48
  17. package/src/cli/init.mjs +49 -17
  18. package/src/cli/resident.mjs +15 -5
  19. package/src/index/discover.mjs +3 -5
  20. package/src/index/indexer.mjs +77 -41
  21. package/src/index/modules.mjs +82 -7
  22. package/src/index/parse-worker.mjs +12 -0
  23. package/src/index/parsepool.mjs +73 -0
  24. package/src/index/resolver.mjs +371 -25
  25. package/src/mcp/server.mjs +16 -12
  26. package/src/mcp/tools.mjs +419 -26
  27. package/src/parse/arity.mjs +41 -3
  28. package/src/parse/extract.mjs +277 -46
  29. package/src/parse/lang/ecma.mjs +2 -0
  30. package/src/parse/lang/go.mjs +2 -0
  31. package/src/parse/lang/jvm.mjs +33 -0
  32. package/src/parse/lang/markdown.mjs +92 -0
  33. package/src/parse/lang/python.mjs +43 -0
  34. package/src/parse/lang/rust.mjs +215 -15
  35. package/src/parse/lang/sfc.mjs +125 -0
  36. package/src/parse/languages.mjs +9 -2
  37. package/src/query/check.mjs +55 -17
  38. package/src/query/facts.mjs +3 -2
  39. package/src/query/injection-spring.mjs +405 -0
  40. package/src/query/injection.mjs +173 -0
  41. package/src/query/intel.mjs +87 -22
  42. package/src/query/issue.mjs +156 -0
  43. package/src/query/orm.mjs +286 -0
  44. package/src/query/read.mjs +6 -1
  45. package/src/query/report.mjs +53 -0
  46. package/src/query/routes.mjs +142 -0
  47. package/src/query/showcase.mjs +59 -0
  48. package/src/query/testcmd.mjs +25 -14
  49. package/src/query/textsearch.mjs +29 -11
  50. package/src/search/search.mjs +85 -17
  51. package/src/search/semantic.mjs +201 -0
  52. package/src/search/vectors.mjs +122 -0
  53. package/src/store/db.mjs +22 -4
  54. package/src/util/git.mjs +30 -0
  55. package/src/util/paths.mjs +40 -1
  56. package/src/util/regex.mjs +2 -0
  57. package/vendor/grammars/README.md +47 -0
  58. package/vendor/grammars/kotlin.wasm.gz +0 -0
  59. package/vendor/grammars/manifest.json +4 -4
  60. /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
- const file = path.join(os.tmpdir(), 'graph-indexer-hooks', `${id}.json`);
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(path.dirname(file), { recursive: true });
95
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ if (!privateDir()) return;
89
97
  st.shown = st.shown.slice(-400);
90
- fs.writeFileSync(file, JSON.stringify(st));
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
- const c = String(command ?? '').replace(/^\s*cd\s+\S+\s*&&\s*/, '');
99
- let m;
100
- if ((m = /^(?:nl\s+-ba\s+|cat\s+-n\s+)(\S+)\s*\|\s*sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s*$/.exec(c))) return [m[1], +m[2], +m[3]];
101
- if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) return [m[3], +m[1], +m[2]];
102
- if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) return [m[2], 1, +m[1]];
103
- if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) return [m[1], 1, 2000];
104
- return null;
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
- const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
153
- if (!file) return null;
154
- const rel = path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file;
155
- if (rel.startsWith('..') || !specForPath(rel)) return null;
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: [rel], tests: false });
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 file = null, from = 1, to = 2000;
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
- } else {
235
- const r = shellRead(input.tool_input?.command);
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 abs = path.isAbsolute(file) ? file : path.join(cwd, file);
242
- const rel = path.relative(root, abs).split(path.sep).join('/');
243
- if (rel.startsWith('..') || !specForPath(rel)) return null;
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 out = [];
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 facts = factsForRange(intel, rel, from, to, { max: 4, shown: new Set(state.st.facts) });
253
- if (facts.length) {
254
- for (const f of facts) state.st.facts.push(f.key);
255
- out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
256
- }
257
- if (crawling) {
258
- const { definitionsUsed } = await import('../query/read.mjs');
259
- const n = Math.max(1, to - from + 1);
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 => { try { return fs.readFileSync(path.join(root, f), 'utf8').includes('<!-- graph-indexer:start -->'); } catch { return false; } });
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
- const root = findRepoRoot(input.cwd || repo || process.cwd());
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: ['-y', 'graph-indexer@3', 'serve', ...repoArgs] };
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
- function upsertBlock(file, dryRun) {
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) fs.writeFileSync(file, next);
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 -y graph-indexer@3';
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', '-y', 'graph-indexer@3'];
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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) fs.writeFileSync(gi, (giText.trimEnd() ? giText.trimEnd() + '\n' : '') + '.graph-indexer/\n');
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 ["-y","graph-indexer@3","serve","--repo","${repo}"].\n` +
253
- `Build the index now with: npx graph-indexer index (the server also builds it on first start)\n` +
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
  }
@@ -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
- /** A per-user directory in the temp dir must belong to us and be closed to everyone else. */
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 || dir === path.join(path.resolve(root), DATA_DIR_NAME)) return true;
54
+ if (WIN) return true;
50
55
  try {
51
56
  const st = fs.lstatSync(dir);
52
- return st.isDirectory() && (process.getuid ? st.uid === process.getuid() : true) && (st.mode & 0o077) === 0;
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 && (!fs.existsSync(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
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);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { spawnSync } from 'node:child_process';
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 = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, encoding: 'utf8' });
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 = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
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
  }