graph-indexer 3.0.1 → 3.1.1
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 +61 -8
- package/SECURITY.md +35 -20
- 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 +15 -4
- package/integrations/opencode/graph-indexer.js +10 -3
- package/package.json +1 -1
- package/src/cli/helper.mjs +15 -1
- package/src/cli/hook.mjs +124 -40
- package/src/cli/init.mjs +32 -5
- package/src/cli/resident.mjs +19 -4
- package/src/index/discover.mjs +52 -30
- package/src/index/indexer.mjs +78 -42
- package/src/index/modules.mjs +75 -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 +43 -13
- package/src/mcp/tools.mjs +418 -26
- 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 +126 -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 +406 -0
- package/src/query/injection.mjs +174 -0
- package/src/query/intel.mjs +85 -15
- package/src/query/issue.mjs +156 -0
- package/src/query/orm.mjs +288 -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/textsearch.mjs +30 -9
- 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 +33 -4
- package/src/util/git.mjs +60 -11
- package/src/util/paths.mjs +10 -3
- 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
|
@@ -32,7 +32,8 @@ import path from 'node:path';
|
|
|
32
32
|
import { spawn } 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 {
|
|
35
|
+
import { gitSync } from '../util/git.mjs';
|
|
36
|
+
import { codeSpecForPath as specForPath } from '../parse/languages.mjs'; // hooks report on code files only
|
|
36
37
|
|
|
37
38
|
const MAX_CONTEXT = 1500;
|
|
38
39
|
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write', 'apply_patch', 'replace', 'write_file']);
|
|
@@ -103,15 +104,63 @@ function sessionState(input) {
|
|
|
103
104
|
};
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The commands of a shell line, split at `;`, `&&`, `||` and newlines outside quotes, with heredoc
|
|
109
|
+
* bodies dropped and `cd DIR` steps left out (agents write `cd repo; sed -n …; grep …`).
|
|
110
|
+
*/
|
|
111
|
+
export function shellSegments(command) {
|
|
112
|
+
return splitShell(command).filter(x => !/^cd\s+\S+$/.test(x));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function splitShell(command) {
|
|
116
|
+
const src = String(command ?? '');
|
|
117
|
+
const out = [];
|
|
118
|
+
let cur = '', q = null;
|
|
119
|
+
const lines = src.split('\n');
|
|
120
|
+
for (let li = 0; li < lines.length; li++) {
|
|
121
|
+
const line = lines[li];
|
|
122
|
+
for (let i = 0; i < line.length; i++) {
|
|
123
|
+
const ch = line[i];
|
|
124
|
+
if (q) { cur += ch; if (ch === q) q = null; else if (ch === '\\' && q === '"' && i + 1 < line.length) cur += line[++i]; continue; }
|
|
125
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
126
|
+
if (ch === '\\' && i + 1 < line.length) { cur += ch + line[++i]; continue; }
|
|
127
|
+
if (ch === ';' || (ch === '&' && line[i + 1] === '&') || (ch === '|' && line[i + 1] === '|')) {
|
|
128
|
+
out.push(cur); cur = '';
|
|
129
|
+
if (ch !== ';') i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
cur += ch;
|
|
133
|
+
}
|
|
134
|
+
if (q) { cur += '\n'; continue; } // a quoted string spanning lines
|
|
135
|
+
// a heredoc (`<<'EOF'`): skip its body up to the delimiter
|
|
136
|
+
const hd = /<<-?\s*['"]?(\w+)['"]?/.exec(cur);
|
|
137
|
+
out.push(cur); cur = '';
|
|
138
|
+
if (hd) while (li + 1 < lines.length && lines[li + 1].trim() !== hd[1]) li++;
|
|
139
|
+
if (hd) li++;
|
|
140
|
+
}
|
|
141
|
+
if (cur) out.push(cur);
|
|
142
|
+
return out.map(x => x.trim()).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
/** 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
146
|
export function shellRead(command) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
147
|
+
return shellReads(command)[0] ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 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. */
|
|
151
|
+
export function shellReads(command) {
|
|
152
|
+
const out = [];
|
|
153
|
+
let dir = null;
|
|
154
|
+
const at = (f) => (dir && !path.isAbsolute(f) ? path.join(dir, f) : f);
|
|
155
|
+
for (const c of splitShell(command)) {
|
|
156
|
+
let m;
|
|
157
|
+
if ((m = /^cd\s+(['"]?)(\S+)\1$/.exec(c))) { dir = path.isAbsolute(m[2]) ? m[2] : path.join(dir ?? '', m[2]); continue; }
|
|
158
|
+
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]]);
|
|
159
|
+
else if ((m = /^sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(\S+)\s*$/.exec(c))) out.push([at(m[3]), +m[1], +m[2]]);
|
|
160
|
+
else if ((m = /^head\s+-(?:n\s*)?(\d+)\s+(\S+)\s*$/.exec(c))) out.push([at(m[2]), 1, +m[1]]);
|
|
161
|
+
else if ((m = /^cat\s+(?:-n\s+)?(\S+\.\w+)\s*$/.exec(c))) out.push([at(m[1]), 1, 2000]);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
115
164
|
}
|
|
116
165
|
|
|
117
166
|
/** Minimal shell-words split (quotes and escapes) for extracting a grep pattern. */
|
|
@@ -158,17 +207,20 @@ function localIndex(root) {
|
|
|
158
207
|
};
|
|
159
208
|
}
|
|
160
209
|
|
|
161
|
-
async function afterEdit(root, input, acquire) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
210
|
+
async function afterEdit(root, input, acquire, files = null) {
|
|
211
|
+
if (!files) {
|
|
212
|
+
const file = input.tool_input?.file_path ?? input.tool_input?.notebook_path ?? input.tool_input?.path ?? null;
|
|
213
|
+
if (!file) return null;
|
|
214
|
+
files = [path.isAbsolute(file) ? path.relative(root, file).split(path.sep).join('/') : file];
|
|
215
|
+
}
|
|
216
|
+
files = files.filter(rel => !rel.startsWith('..') && specForPath(rel)).slice(0, 6);
|
|
217
|
+
if (!files.length) return null;
|
|
166
218
|
const ix = await acquire();
|
|
167
219
|
if (!ix) return null;
|
|
168
220
|
const { intel } = ix;
|
|
169
221
|
try {
|
|
170
222
|
const { checkChanges } = await import('../query/check.mjs');
|
|
171
|
-
const r = await checkChanges(intel, { files
|
|
223
|
+
const r = await checkChanges(intel, { files, tests: false });
|
|
172
224
|
if (r.error) return null;
|
|
173
225
|
const lines = [];
|
|
174
226
|
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 +287,69 @@ async function afterGrep(root, input, crawling, acquire) {
|
|
|
235
287
|
* silent when there is none; and while the agent is crawling, where the names the lines use are defined.
|
|
236
288
|
*/
|
|
237
289
|
async function afterRead(root, input, state, crawling, acquire) {
|
|
238
|
-
let
|
|
290
|
+
let reads = [];
|
|
239
291
|
if (READ_TOOLS.has(input.tool_name)) {
|
|
240
|
-
file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
|
|
292
|
+
const file = input.tool_input?.file_path ?? input.tool_input?.path ?? input.tool_input?.absolute_path ?? null;
|
|
293
|
+
let from = 1, to = 2000;
|
|
241
294
|
const off = Number(input.tool_input?.offset ?? 0), lim = Number(input.tool_input?.limit ?? 0);
|
|
242
295
|
if (off > 0) from = off;
|
|
243
296
|
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;
|
|
297
|
+
if (file) reads = [[file, from, to]];
|
|
298
|
+
} else reads = shellReads(input.tool_input?.command);
|
|
250
299
|
const cwd = input.cwd || root;
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
300
|
+
const ranges = reads.map(([file, from, to]) => {
|
|
301
|
+
const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
302
|
+
return { rel: path.relative(root, abs).split(path.sep).join('/'), from, to };
|
|
303
|
+
}).filter(r => !r.rel.startsWith('..') && specForPath(r.rel)).slice(0, 4);
|
|
304
|
+
if (!ranges.length) return null;
|
|
254
305
|
const ix = await acquire();
|
|
255
306
|
if (!ix) return null;
|
|
256
307
|
const { intel } = ix;
|
|
257
308
|
try {
|
|
258
|
-
await intel.revalidate([rel]); // a long-lived index may not have seen the file change yet
|
|
259
|
-
const
|
|
309
|
+
await intel.revalidate([...new Set(ranges.map(r => r.rel))]); // a long-lived index may not have seen the file change yet
|
|
310
|
+
const facts = [], defs = [];
|
|
260
311
|
const { factsForRange, renderFacts } = await import('../query/facts.mjs');
|
|
312
|
+
const { definitionsUsed } = await import('../query/read.mjs');
|
|
261
313
|
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')}`);
|
|
314
|
+
for (const { rel, from, to } of ranges) {
|
|
315
|
+
const fs1 = factsForRange(intel, rel, from, to, { max: Math.max(1, 4 - facts.length), shown: new Set(state.st.facts) });
|
|
316
|
+
for (const f of fs1) { state.st.facts.push(f.key); facts.push(f); }
|
|
317
|
+
if (crawling) {
|
|
318
|
+
const n = Math.max(1, to - from + 1);
|
|
319
|
+
const { rows } = definitionsUsed(intel, rel, from, to, { max: Math.min(12, Math.max(4, Math.round(n / 15))), skipIds: new Set(state.st.shown) });
|
|
320
|
+
for (const r of rows) { state.st.shown.push(r.id); if (defs.length < 12) defs.push(r); }
|
|
321
|
+
}
|
|
273
322
|
}
|
|
323
|
+
const out = [];
|
|
324
|
+
if (facts.length) out.push(`graph-indexer, resolved for these lines:\n${renderFacts(facts).map(l => ` ${l}`).join('\n')}`);
|
|
325
|
+
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
326
|
return out.length ? out.join('\n') : null;
|
|
275
327
|
} finally { ix.release(); }
|
|
276
328
|
}
|
|
277
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Source files a shell command changed (`sed -i`, a Python heredoc that rewrites a file, a patch):
|
|
332
|
+
* the working tree's modified and new files whose modification time moved since the hook last
|
|
333
|
+
* looked, so the edit checker also follows edits made without the edit tool.
|
|
334
|
+
*/
|
|
335
|
+
function shellEdits(root, state) {
|
|
336
|
+
let r;
|
|
337
|
+
// hardened (gitSync): a repository's core.fsmonitor or filters would otherwise run
|
|
338
|
+
try { r = gitSync(root, ['status', '--porcelain', '--untracked-files=all', '-z'], { timeout: 1500 }); } catch { return []; }
|
|
339
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return [];
|
|
340
|
+
const seen = state.st.mtimes ??= {};
|
|
341
|
+
const changed = [];
|
|
342
|
+
for (const e of r.stdout.split('\0')) {
|
|
343
|
+
if (e.length < 4 || e.startsWith('D') || e[1] === 'D') continue;
|
|
344
|
+
const rel = e.slice(3);
|
|
345
|
+
if (!specForPath(rel) || rel.startsWith(DATA_DIR_NAME + '/')) continue;
|
|
346
|
+
let m;
|
|
347
|
+
try { m = fs.statSync(path.join(root, rel)).mtimeMs; } catch { continue; }
|
|
348
|
+
if (seen[rel] !== m) { seen[rel] = m; changed.push(rel); }
|
|
349
|
+
}
|
|
350
|
+
return changed;
|
|
351
|
+
}
|
|
352
|
+
|
|
278
353
|
// the lookup rules, for sessions and subagents whose instruction files do not carry them
|
|
279
354
|
const RULES = `How to look code up in this repository (graph-indexer):
|
|
280
355
|
- Several things at once: several tool calls in one message, or one search with alternatives — not one search per turn.
|
|
@@ -288,7 +363,7 @@ async function sessionLine(root, { rules = false, acquire }) {
|
|
|
288
363
|
const { intel } = ix;
|
|
289
364
|
try {
|
|
290
365
|
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.`;
|
|
366
|
+
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
367
|
// the managed block of `init` already carries the rules; otherwise add them
|
|
293
368
|
const carried = ['CLAUDE.md', 'AGENTS.md'].some(f => (readRepoFile(root, f) ?? '').includes('<!-- graph-indexer:start -->'));
|
|
294
369
|
return rules || !carried ? `${line}\n${RULES}` : line;
|
|
@@ -308,6 +383,7 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
308
383
|
const state = sessionState(input);
|
|
309
384
|
if (EDIT_TOOLS.has(tool)) {
|
|
310
385
|
state.st.nav = 0;
|
|
386
|
+
shellEdits(root, state); // so a later shell command does not report this edit again
|
|
311
387
|
state.save();
|
|
312
388
|
text = await afterEdit(root, input, acquire);
|
|
313
389
|
} else if (GREP_TOOLS.has(tool) || (SHELL_TOOLS.has(tool) && grepPattern(command))) {
|
|
@@ -318,6 +394,12 @@ export async function handleHook(event, input, { root, acquire }) {
|
|
|
318
394
|
state.st.nav++;
|
|
319
395
|
text = await afterRead(root, input, state, state.st.nav >= CRAWL, acquire);
|
|
320
396
|
state.save();
|
|
397
|
+
} else if (SHELL_TOOLS.has(tool)) {
|
|
398
|
+
// any other command may have edited files (`sed -i`, a script): check what it changed
|
|
399
|
+
const changed = shellEdits(root, state);
|
|
400
|
+
if (changed.length) state.st.nav = 0;
|
|
401
|
+
state.save();
|
|
402
|
+
if (changed.length) text = await afterEdit(root, input, acquire, changed);
|
|
321
403
|
}
|
|
322
404
|
} else if (event === 'session-start' || event === 'subagent-start') {
|
|
323
405
|
name ??= event === 'session-start' ? 'SessionStart' : 'SubagentStart';
|
|
@@ -351,7 +433,9 @@ export async function runHook(event, { repo = null, version = null } = {}) {
|
|
|
351
433
|
const timer = setTimeout(() => process.exit(0), deadlineMs); // fail open: never hold the agent up
|
|
352
434
|
try {
|
|
353
435
|
const input = await readStdin();
|
|
354
|
-
|
|
436
|
+
// an explicit --repo wins over the session's directory (a session started elsewhere, with
|
|
437
|
+
// the repository added to it)
|
|
438
|
+
const root = findRepoRoot(repo || input.cwd || process.cwd());
|
|
355
439
|
const { askResident, residentRequired } = await import('./resident.mjs');
|
|
356
440
|
const reply = await askResident(root, { op: 'hook', event, input }, { version, totalMs: 2000 });
|
|
357
441
|
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}`;
|
|
@@ -56,13 +58,23 @@ function readJson(p) {
|
|
|
56
58
|
// (code the repository ships) instead of the published package
|
|
57
59
|
const NPX_ARGS = ['-y', '--prefix', '/', 'graph-indexer@3'];
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* npx, by absolute path on Windows: there a bare `npx` is looked up in the working directory (the
|
|
63
|
+
* repository) before PATH, so an `npx.cmd`/`npx.exe` the repository ships would run instead.
|
|
64
|
+
*/
|
|
65
|
+
function npxCommand() {
|
|
66
|
+
if (process.platform !== 'win32') return 'npx';
|
|
67
|
+
const f = path.join(path.dirname(process.execPath), 'npx.cmd');
|
|
68
|
+
return fs.existsSync(f) ? f : 'npx';
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
function serverEntry(agent, { local }) {
|
|
60
72
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
61
73
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
62
74
|
const repoArgs = agent.workspaceVar ? ['--repo', agent.workspaceVar] : [];
|
|
63
75
|
const base = local
|
|
64
76
|
? { command: process.execPath, args: [bin, 'serve', ...repoArgs] }
|
|
65
|
-
: { command:
|
|
77
|
+
: { command: npxCommand(), args: [...NPX_ARGS, 'serve', ...repoArgs] };
|
|
66
78
|
if (agent.opencode) return { type: 'local', command: [base.command, ...base.args], enabled: true };
|
|
67
79
|
if (agent.zed) return { ...base, env: {} };
|
|
68
80
|
return agent.vscode ? { type: 'stdio', ...base } : base;
|
|
@@ -108,7 +120,9 @@ function upsertBlock(repo, file, dryRun) {
|
|
|
108
120
|
/** An installed `graph-indexer` executable on PATH, if any (hooks start in ~0.1 s instead of npx's ~1 s). */
|
|
109
121
|
function installedBin() {
|
|
110
122
|
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
111
|
-
|
|
123
|
+
// only absolute entries, never a project's own bin directory: a PATH with `.` or
|
|
124
|
+
// `node_modules/.bin` would otherwise find a graph-indexer the repository ships
|
|
125
|
+
if (!dir || !path.isAbsolute(dir) || /(^|[\\/])node_modules[\\/]\.bin[\\/]?$/.test(dir)) continue;
|
|
112
126
|
const f = path.join(dir, process.platform === 'win32' ? 'graph-indexer.cmd' : 'graph-indexer');
|
|
113
127
|
try { if (fs.statSync(f).isFile()) return f; } catch { /* not here */ }
|
|
114
128
|
}
|
|
@@ -121,7 +135,7 @@ function cliCommand(local) {
|
|
|
121
135
|
const bin = path.resolve(here, '../../bin/graph-indexer.mjs');
|
|
122
136
|
if (local) return `${JSON.stringify(process.execPath)} ${JSON.stringify(bin)}`;
|
|
123
137
|
const installed = installedBin();
|
|
124
|
-
return installed ? JSON.stringify(installed) : ['npx', ...NPX_ARGS].join(' ');
|
|
138
|
+
return installed ? JSON.stringify(installed) : [npxCommand() === 'npx' ? 'npx' : JSON.stringify(npxCommand()), ...NPX_ARGS].join(' ');
|
|
125
139
|
}
|
|
126
140
|
|
|
127
141
|
/** The command that starts graph-indexer, as an argv array (for the OpenCode/Kilo Code plugin). */
|
|
@@ -129,7 +143,7 @@ function cliArgv(local) {
|
|
|
129
143
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
130
144
|
if (local) return [process.execPath, path.resolve(here, '../../bin/graph-indexer.mjs')];
|
|
131
145
|
const installed = installedBin();
|
|
132
|
-
return installed ? [installed] : [
|
|
146
|
+
return installed ? [installed] : [npxCommand(), ...NPX_ARGS];
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
/**
|
|
@@ -209,6 +223,7 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
209
223
|
const hooks = flag('--hooks');
|
|
210
224
|
const noInstructions = flag('--no-instructions');
|
|
211
225
|
const noHelper = flag('--no-helper');
|
|
226
|
+
const noDemo = flag('--no-demo');
|
|
212
227
|
const all = flag('--all');
|
|
213
228
|
const agentList = opt('--agents');
|
|
214
229
|
let chosen;
|
|
@@ -267,6 +282,18 @@ export async function runInit({ opt, flag, repo: repoArg = null }) {
|
|
|
267
282
|
}
|
|
268
283
|
process.stdout.write(`graph-indexer init ${dryRun ? '(dry run) ' : ''}in ${repo}\n ${report.join('\n ')}\n\n` +
|
|
269
284
|
`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` +
|
|
285
|
+
(dryRun || noDemo ? `Build the index now with: npx ${NPX_ARGS.join(' ')} index (the server also builds it on first start)\n` : '') +
|
|
271
286
|
(hooks ? '' : `Optional: graph-indexer init --hooks adds Claude Code hooks (edit check after each edit, grep disambiguation).\n`));
|
|
287
|
+
if (dryRun || noDemo) return;
|
|
288
|
+
// build the index now (the server would on first start) and show what it answers here
|
|
289
|
+
const { CodeIntel } = await import('../query/intel.mjs');
|
|
290
|
+
const { showcaseText } = await import('../query/showcase.mjs');
|
|
291
|
+
const intel = new CodeIntel({ root: repo });
|
|
292
|
+
try {
|
|
293
|
+
let last = 0;
|
|
294
|
+
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'); } } });
|
|
295
|
+
process.stdout.write(`\n${await showcaseText(intel)}\n`);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
process.stdout.write(`\nThe index could not be built now (${e.message}); the server builds it on first start.\n`);
|
|
298
|
+
} finally { intel.close(); }
|
|
272
299
|
}
|
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
|
|
@@ -37,7 +37,13 @@ const WIN = process.platform === 'win32';
|
|
|
37
37
|
export function socketPath(root) {
|
|
38
38
|
const abs = path.resolve(root);
|
|
39
39
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
|
|
40
|
-
|
|
40
|
+
// a pipe name anyone could compute would let another user of the machine create it first and
|
|
41
|
+
// answer as the resident: the name carries a random id kept in the private data directory
|
|
42
|
+
if (WIN) {
|
|
43
|
+
let id = '';
|
|
44
|
+
try { id = fs.readFileSync(path.join(abs, '.graph-indexer', 'resident.pipe-id'), 'utf8').trim(); } catch { /* no resident yet */ }
|
|
45
|
+
return /^[0-9a-f]{32}$/.test(id) ? `\\\\.\\pipe\\graph-indexer-${hash}-${id}` : null;
|
|
46
|
+
}
|
|
41
47
|
const inRepo = path.join(abs, DATA_DIR_NAME, 'resident.sock');
|
|
42
48
|
// Unix socket paths are limited to 104–108 bytes
|
|
43
49
|
if (Buffer.byteLength(inRepo) <= 100) return inRepo;
|
|
@@ -74,7 +80,7 @@ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 're
|
|
|
74
80
|
export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
|
|
75
81
|
if (disabled()) return Promise.resolve(null);
|
|
76
82
|
const file = socketPath(root);
|
|
77
|
-
if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
|
|
83
|
+
if (!file || (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root)))) return Promise.resolve(null);
|
|
78
84
|
return new Promise((resolve) => {
|
|
79
85
|
let done = false, connected = false, buf = '';
|
|
80
86
|
const sock = net.createConnection(file);
|
|
@@ -114,7 +120,7 @@ export function askResident(root, request, { version, connectMs = 250, totalMs =
|
|
|
114
120
|
*/
|
|
115
121
|
export async function startResident({ root, version, handle, log = () => {}, idleMs = 0, onIdle = null, wanted = () => true }) {
|
|
116
122
|
if (disabled()) return null;
|
|
117
|
-
|
|
123
|
+
let file = socketPath(root);
|
|
118
124
|
if (!WIN) {
|
|
119
125
|
const dir = path.dirname(file);
|
|
120
126
|
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* checked below */ }
|
|
@@ -123,6 +129,15 @@ export async function startResident({ root, version, handle, log = () => {}, idl
|
|
|
123
129
|
// another resident may serve this repository already; a dead one leaves its socket file behind
|
|
124
130
|
if (await askResident(root, { op: 'ping' }, { version, connectMs: 300, totalMs: 1500 })) return null;
|
|
125
131
|
if (!WIN) { try { fs.unlinkSync(file); } catch { /* none */ } }
|
|
132
|
+
else {
|
|
133
|
+
// a fresh pipe id for this resident (see socketPath)
|
|
134
|
+
const idFile = path.join(path.resolve(root), DATA_DIR_NAME, 'resident.pipe-id');
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.dirname(idFile), { recursive: true });
|
|
137
|
+
fs.writeFileSync(idFile, crypto.randomBytes(16).toString('hex'), { mode: 0o600 });
|
|
138
|
+
} catch (e) { log(`resident: cannot write ${idFile} (${e.message}); not listening`); return null; }
|
|
139
|
+
file = socketPath(root);
|
|
140
|
+
}
|
|
126
141
|
|
|
127
142
|
let last = Date.now();
|
|
128
143
|
const server = net.createServer((sock) => {
|
package/src/index/discover.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { gitSync } from '../util/git.mjs';
|
|
10
|
+
import { readRepoFile, withinRoot } from '../util/paths.mjs';
|
|
10
11
|
import { specForPath } from '../parse/languages.mjs';
|
|
11
12
|
|
|
12
13
|
export const MAX_FILE_BYTES = 1_500_000;
|
|
@@ -34,26 +35,56 @@ function gitListFiles(root) {
|
|
|
34
35
|
return r.stdout.split('\0').filter(Boolean);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A .gitignore pattern as a matcher that cannot backtrack: `*` (within a name), `?`, `**` (across
|
|
40
|
+
* names), anything else literal. A regular expression of many stars (`*a*a*a*a*a*b`) takes seconds
|
|
41
|
+
* per long path; this keeps the set of positions reachable after each token, so a match costs
|
|
42
|
+
* pattern length × path length. Unanchored, it may start at any name; it must end at the end of
|
|
43
|
+
* the path or at a `/` (a directory and everything under it).
|
|
44
|
+
*/
|
|
45
|
+
function globMatcher(glob, anchored) {
|
|
46
|
+
const tokens = [];
|
|
47
|
+
for (let i = 0; i < glob.length; i++) {
|
|
48
|
+
if (glob[i] === '*') {
|
|
49
|
+
let j = i; while (glob[j + 1] === '*') j++;
|
|
50
|
+
tokens.push(j > i ? '**' : '*'); i = j;
|
|
51
|
+
} else tokens.push(glob[i] === '?' ? '?' : { c: glob[i] });
|
|
52
|
+
}
|
|
53
|
+
return (s) => {
|
|
54
|
+
const n = s.length;
|
|
55
|
+
let cur = new Uint8Array(n + 1);
|
|
56
|
+
cur[0] = 1;
|
|
57
|
+
if (!anchored) for (let k = 0; k < n; k++) if (s[k] === '/') cur[k + 1] = 1;
|
|
58
|
+
for (const t of tokens) {
|
|
59
|
+
const next = new Uint8Array(n + 1);
|
|
60
|
+
let any = false;
|
|
61
|
+
if (t === '**') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; } }
|
|
62
|
+
else if (t === '*') { let on = 0; for (let p = 0; p <= n; p++) { on |= cur[p]; next[p] = on; any ||= !!on; if (s[p] === '/') on = 0; } }
|
|
63
|
+
else if (t === '?') { for (let p = 0; p < n; p++) if (cur[p] && s[p] !== '/') { next[p + 1] = 1; any = true; } }
|
|
64
|
+
else { for (let p = 0; p < n; p++) if (cur[p] && s[p] === t.c) { next[p + 1] = 1; any = true; } }
|
|
65
|
+
if (!any) return false;
|
|
66
|
+
cur = next;
|
|
67
|
+
}
|
|
68
|
+
for (let p = 0; p <= n; p++) if (cur[p] && (p === n || s[p] === '/')) return true;
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
/** Minimal .gitignore → predicate (used only outside git repos). */
|
|
38
74
|
function loadGitignore(root) {
|
|
39
75
|
const rules = [];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
line = line.replace(/^\//, '');
|
|
50
|
-
const re = line.split('**').map(seg => seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')).join('.*');
|
|
51
|
-
rules.push({ neg, re: new RegExp(anchored ? `^${re}(/|$)` : `(^|/)${re}(/|$)`) });
|
|
52
|
-
}
|
|
53
|
-
} catch { /* no .gitignore */ }
|
|
76
|
+
for (let line of (readRepoFile(root, '.gitignore') ?? '').split(/\r?\n/)) {
|
|
77
|
+
line = line.trim();
|
|
78
|
+
if (!line || line.startsWith('#')) continue;
|
|
79
|
+
const neg = line.startsWith('!');
|
|
80
|
+
if (neg) line = line.slice(1);
|
|
81
|
+
if (line.endsWith('/')) line = line.slice(0, -1);
|
|
82
|
+
const anchored = line.startsWith('/') || line.includes('/');
|
|
83
|
+
rules.push({ neg, test: globMatcher(line.replace(/^\//, ''), anchored) });
|
|
84
|
+
}
|
|
54
85
|
return (rel) => {
|
|
55
86
|
let ignored = false;
|
|
56
|
-
for (const r of rules) if (r.
|
|
87
|
+
for (const r of rules) if (r.test(rel)) ignored = !r.neg;
|
|
57
88
|
return ignored;
|
|
58
89
|
};
|
|
59
90
|
}
|
|
@@ -95,28 +126,19 @@ export function discoverFiles(root, opts = {}) {
|
|
|
95
126
|
viaGit = rels !== null;
|
|
96
127
|
}
|
|
97
128
|
if (!rels) rels = walk(root, loadGitignore(root));
|
|
98
|
-
const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) &&
|
|
129
|
+
const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && insideRoot(root, r));
|
|
99
130
|
files.sort();
|
|
100
131
|
return { files, viaGit };
|
|
101
132
|
}
|
|
102
133
|
|
|
103
|
-
const realRoots = new Map();
|
|
104
|
-
|
|
105
134
|
/**
|
|
106
|
-
* A tracked file must resolve inside the repository:
|
|
107
|
-
*
|
|
135
|
+
* A tracked file must resolve inside the repository: every component of its path is resolved, so
|
|
136
|
+
* neither a link to /etc/… or ~/.ssh/… nor a file under a linked directory (`link/secret.ts` with
|
|
137
|
+
* `link` → elsewhere, which git lists from its index without looking) is read, indexed or served.
|
|
108
138
|
*/
|
|
109
139
|
export function insideRoot(root, rel) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
try { st = fs.lstatSync(abs); } catch { return true; } // vanished: the caller handles deletion
|
|
113
|
-
if (!st.isSymbolicLink()) return true;
|
|
114
|
-
let realRoot = realRoots.get(root);
|
|
115
|
-
if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = root; } realRoots.set(root, realRoot); }
|
|
116
|
-
try {
|
|
117
|
-
const target = fs.realpathSync(abs);
|
|
118
|
-
return target === realRoot || target.startsWith(realRoot + path.sep);
|
|
119
|
-
} catch { return false; } // dangling link
|
|
140
|
+
try { fs.lstatSync(path.join(root, rel)); } catch { return true; } // vanished: the caller handles deletion
|
|
141
|
+
return withinRoot(root, rel);
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
/** Heuristic: minified / generated content that slipped past the name filters. */
|