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
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Vector index over definitions for the semantic channel (see semantic.mjs).
3
+ *
4
+ * One int8 vector per definition, embedded from its qualified name, signature, doc comment and
5
+ * the head of its body. Vectors are filled lazily, file by file, keyed by the file's content
6
+ * hash, so an edit re-embeds only the files it touched. Search is an exact scan over the matrix
7
+ * held in memory (no ANN structure to go stale): ~1 ms per 10k definitions.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { StaticModel, quantize, semanticEnabled } from './semantic.mjs';
12
+ import { splitIdentifier } from './tokenize.mjs';
13
+
14
+ const EMBED_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'trait', 'type', 'object', 'module', 'impl',
15
+ 'function', 'method', 'constructor', 'macro', 'section']);
16
+ const BODY_LINES = 60;
17
+
18
+ let sharedModel = null;
19
+ function model() { return (sharedModel ??= new StaticModel()); }
20
+
21
+ /** The text a definition is embedded from. */
22
+ export function symbolText(sym, path_, lines) {
23
+ const words = splitIdentifier(sym.name).join(' ');
24
+ const head = `${path_}\n${sym.qname} (${words})\n${sym.sig ?? ''}\n${sym.doc ?? ''}\n`;
25
+ const body = lines.slice(sym.start_line - 1, Math.min(sym.end_line, sym.start_line - 1 + BODY_LINES)).join('\n');
26
+ return head + body;
27
+ }
28
+
29
+ export class VectorIndex {
30
+ constructor(indexer, { m = null } = {}) {
31
+ this.ix = indexer;
32
+ this.store = indexer.store;
33
+ this.m = m;
34
+ this.version = -1;
35
+ this.ids = null; // Int32Array of symbol ids
36
+ this.mat = null; // Int8Array rows × dim
37
+ this.scales = null; // Float32Array
38
+ }
39
+
40
+ static maybe(indexer) {
41
+ try { return semanticEnabled() ? new VectorIndex(indexer) : null; } catch { return null; }
42
+ }
43
+
44
+ get model() { return (this.m ??= model()); }
45
+
46
+ /** Embed the files whose vectors are missing or stale; returns how many files were embedded. */
47
+ fill() {
48
+ const s = this.store;
49
+ const stale = s.all(`SELECT f.id, f.path, f.hash FROM files f LEFT JOIN vec_files v ON v.file_id = f.id
50
+ WHERE v.hash IS NULL OR v.hash <> f.hash`);
51
+ const gone = s.all('SELECT v.file_id AS id FROM vec_files v LEFT JOIN files f ON f.id = v.file_id WHERE f.id IS NULL');
52
+ if (!stale.length && !gone.length) return 0;
53
+ const dim = this.model.dim;
54
+ let n = 0;
55
+ const BATCH = 200;
56
+ for (let i = 0; i < stale.length; i += BATCH) {
57
+ s.tx(() => {
58
+ for (const f of stale.slice(i, i + BATCH)) {
59
+ s.run('DELETE FROM vectors WHERE file_id = ?', f.id);
60
+ let src = null;
61
+ try { src = fs.readFileSync(path.join(this.ix.root, f.path), 'utf8'); } catch { /* deleted since */ }
62
+ if (src != null) {
63
+ const lines = src.split('\n');
64
+ const syms = s.all('SELECT id, name, qname, kind, sig, doc, start_line, end_line FROM symbols WHERE file_id = ?', f.id);
65
+ for (const sym of syms) {
66
+ if (!EMBED_KINDS.has(sym.kind)) continue;
67
+ const v = this.model.embed(symbolText(sym, f.path, lines));
68
+ if (!v || v.length !== dim) continue;
69
+ const { q, scale } = quantize(v);
70
+ s.run('INSERT INTO vectors (symbol_id, file_id, scale, v) VALUES (?, ?, ?, ?)', sym.id, f.id, scale, Buffer.from(q.buffer));
71
+ }
72
+ }
73
+ s.run('INSERT INTO vec_files (file_id, hash) VALUES (?, ?) ON CONFLICT(file_id) DO UPDATE SET hash = excluded.hash', f.id, f.hash);
74
+ n++;
75
+ }
76
+ });
77
+ }
78
+ if (gone.length) s.tx(() => { for (const g of gone) { s.run('DELETE FROM vectors WHERE file_id = ?', g.id); s.run('DELETE FROM vec_files WHERE file_id = ?', g.id); } });
79
+ return n;
80
+ }
81
+
82
+ #load() {
83
+ if (this.version === this.ix.version && this.mat) return;
84
+ this.fill();
85
+ const rows = this.store.all('SELECT symbol_id, scale, v FROM vectors');
86
+ const dim = this.model.dim;
87
+ this.ids = new Int32Array(rows.length);
88
+ this.scales = new Float32Array(rows.length);
89
+ this.mat = new Int8Array(rows.length * dim);
90
+ rows.forEach((r, i) => {
91
+ this.ids[i] = r.symbol_id;
92
+ this.scales[i] = r.scale;
93
+ this.mat.set(new Int8Array(r.v.buffer, r.v.byteOffset, dim), i * dim);
94
+ });
95
+ this.version = this.ix.version;
96
+ }
97
+
98
+ /** Top-k definitions by cosine similarity to the query: Map(symbol id → cosine). */
99
+ search(query, k = 200) {
100
+ const qv = this.model.embed(query);
101
+ if (!qv) return new Map();
102
+ this.#load();
103
+ const dim = this.model.dim, M = this.mat, n = this.ids.length;
104
+ const top = []; // small min-heap would be faster; k ≪ n and n is modest, a threshold scan is enough
105
+ let floor = -Infinity;
106
+ for (let i = 0; i < n; i++) {
107
+ let d = 0;
108
+ const o = i * dim;
109
+ for (let j = 0; j < dim; j++) d += M[o + j] * qv[j];
110
+ d *= this.scales[i];
111
+ if (top.length < k) { top.push([i, d]); if (top.length === k) { top.sort((a, b) => a[1] - b[1]); floor = top[0][1]; } }
112
+ else if (d > floor) {
113
+ top[0] = [i, d];
114
+ // re-establish the minimum at index 0 (insertion into sorted position)
115
+ let p = 0;
116
+ while (p + 1 < top.length && top[p][1] > top[p + 1][1]) { const t = top[p]; top[p] = top[p + 1]; top[p + 1] = t; p++; }
117
+ floor = top[0][1];
118
+ }
119
+ }
120
+ return new Map(top.map(([i, d]) => [this.ids[i], d]));
121
+ }
122
+ }
package/src/store/db.mjs CHANGED
@@ -15,7 +15,7 @@ process.emitWarning = function (warning, ...rest) {
15
15
  };
16
16
  const { DatabaseSync } = await import('node:sqlite');
17
17
 
18
- export const SCHEMA_VERSION = 9;
18
+ export const SCHEMA_VERSION = 11;
19
19
 
20
20
  const SCHEMA = `
21
21
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS files (
29
29
  lines INTEGER NOT NULL,
30
30
  package TEXT,
31
31
  is_test INTEGER NOT NULL DEFAULT 0,
32
- parse_errors INTEGER NOT NULL DEFAULT 0,
32
+ parse_errors INTEGER NOT NULL DEFAULT 0, -- regions the parser could not read (ERROR nodes)
33
+ parse_error_lines TEXT, -- their [start, end] lines as JSON, the first 100
33
34
  indexed_at INTEGER NOT NULL
34
35
  );
35
36
  CREATE TABLE IF NOT EXISTS symbols (
@@ -69,7 +70,8 @@ CREATE TABLE IF NOT EXISTS refs (
69
70
  dst_id INTEGER,
70
71
  conf REAL NOT NULL DEFAULT 0,
71
72
  ncand INTEGER NOT NULL DEFAULT 0,
72
- argc INTEGER -- arguments of a call or construction (-1: a spread makes it a lower bound), else NULL
73
+ argc INTEGER, -- arguments of a call or construction (-1: a spread makes it a lower bound), else NULL
74
+ arg_types TEXT -- their static types where the language overloads by type (JSON: "int", "String", "?" unknown), else NULL
73
75
  );
74
76
  CREATE INDEX IF NOT EXISTS refs_file ON refs(file_id);
75
77
  CREATE INDEX IF NOT EXISTS refs_dst ON refs(dst_id);
@@ -102,6 +104,21 @@ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
102
104
  tokenize = 'porter unicode61 remove_diacritics 2',
103
105
  content = '', contentless_delete = 1
104
106
  );
107
+ -- semantic channel (optional, src/search/vectors.mjs): one int8 vector per definition, filled lazily
108
+ CREATE TABLE IF NOT EXISTS vectors (
109
+ symbol_id INTEGER PRIMARY KEY,
110
+ file_id INTEGER NOT NULL,
111
+ scale REAL NOT NULL,
112
+ v BLOB NOT NULL
113
+ );
114
+ CREATE INDEX IF NOT EXISTS vectors_file ON vectors(file_id);
115
+ CREATE TABLE IF NOT EXISTS vec_files (file_id INTEGER PRIMARY KEY, hash TEXT NOT NULL);
116
+ -- documentation sections (Markdown headings), apart from code so prose never shifts code's BM25 statistics
117
+ CREATE VIRTUAL TABLE IF NOT EXISTS doc_fts USING fts5(
118
+ name, body,
119
+ tokenize = 'porter unicode61 remove_diacritics 2',
120
+ content = '', contentless_delete = 1
121
+ );
105
122
  -- one document per file (rowid = files.id): file-level relevance for hierarchical ranking
106
123
  CREATE VIRTUAL TABLE IF NOT EXISTS file_fts USING fts5(
107
124
  path, body,
@@ -156,9 +173,10 @@ export class Store {
156
173
  /** Drop every indexed fact but keep the database (the index is a cache of the source tree). */
157
174
  reset() {
158
175
  this.tx(() => {
159
- for (const t of ['files', 'symbols', 'refs', 'imports', 'fields']) this.db.exec(`DELETE FROM ${t}`);
176
+ for (const t of ['files', 'symbols', 'refs', 'imports', 'fields', 'vectors', 'vec_files']) this.db.exec(`DELETE FROM ${t}`);
160
177
  this.db.exec("INSERT INTO fts(fts) VALUES ('delete-all')");
161
178
  this.db.exec("INSERT INTO file_fts(file_fts) VALUES ('delete-all')");
179
+ this.db.exec("INSERT INTO doc_fts(doc_fts) VALUES ('delete-all')");
162
180
  });
163
181
  }
164
182
 
@@ -0,0 +1,30 @@
1
+ /**
2
+ * git, run so that an indexed repository's own config cannot make it execute anything: no
3
+ * fsmonitor, no hooks, no signature verification, no external diff or textconv, and every
4
+ * clean/smudge/process filter switched off. A repository copied with its .git directory (an
5
+ * archive, a shared folder) is not trusted.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const COMMAND_KEY = /^(filter\.[^\n]+)\.(clean|smudge|process)$|^diff\.[^\n]+\.(textconv|command)$/;
10
+
11
+ function hardening(root) {
12
+ const args = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external=', '-c', 'log.showSignature=false'];
13
+ const r = spawnSync('git', ['config', '--name-only', '--get-regexp', '^(filter|diff)\\.'], { cwd: root, encoding: 'utf8' });
14
+ for (const key of (r.status === 0 ? r.stdout : '').split('\n')) {
15
+ const m = COMMAND_KEY.exec(key);
16
+ if (!m) continue;
17
+ args.push('-c', `${key}=`);
18
+ // a required filter (Git LFS sets one) would otherwise fail the diff once its command is blank
19
+ if (m[1]) args.push('-c', `${m[1]}.required=false`);
20
+ }
21
+ return args;
22
+ }
23
+
24
+ const NO_TOOLS = { diff: ['--no-ext-diff', '--no-textconv'], log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'] };
25
+
26
+ /** spawnSync('git', args) in `root`, hardened as above; diff and log never run external tools. */
27
+ export function gitSync(root, args, opts = {}) {
28
+ const sub = NO_TOOLS[args[0]] ? [args[0], ...NO_TOOLS[args[0]], ...args.slice(1)] : args;
29
+ return spawnSync('git', [...hardening(root), ...sub], { cwd: root, encoding: 'utf8', ...opts });
30
+ }
@@ -6,7 +6,12 @@ export const DATA_DIR_NAME = '.graph-indexer';
6
6
 
7
7
  export function dataDir(root) {
8
8
  const dir = path.join(root, DATA_DIR_NAME);
9
- fs.mkdirSync(dir, { recursive: true });
9
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
10
+ // a .graph-indexer the repository committed as a link would put the index (excerpts of the
11
+ // source) wherever it points
12
+ const st = fs.lstatSync(dir);
13
+ if (!st.isDirectory()) throw new Error(`${dir} is not a directory (a symbolic link?); remove it and run again`);
14
+ if ((st.mode & 0o077) !== 0) { try { fs.chmodSync(dir, 0o700); } catch { /* read-only */ } }
10
15
  const gi = path.join(dir, '.gitignore');
11
16
  if (!fs.existsSync(gi)) { try { fs.writeFileSync(gi, '*\n'); } catch { /* read-only */ } }
12
17
  return dir;
@@ -22,3 +27,37 @@ export function findRepoRoot(start) {
22
27
  dir = parent;
23
28
  }
24
29
  }
30
+
31
+ const realRoots = new Map();
32
+
33
+ /**
34
+ * `rel` names a file inside the repository once `..` and symlinks are resolved, so a tool target
35
+ * like `../../etc/hosts` or a tracked link to ~/.ssh/… is never read. The index's own data and git's
36
+ * internals are not served either.
37
+ */
38
+ export function withinRoot(root, rel) {
39
+ let realRoot = realRoots.get(root);
40
+ if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); } realRoots.set(root, realRoot); }
41
+ let real;
42
+ try { real = fs.realpathSync(path.join(root, rel)); } catch { return false; }
43
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return false;
44
+ const top = path.relative(realRoot, real).split(path.sep)[0];
45
+ return top !== '.git' && top !== DATA_DIR_NAME;
46
+ }
47
+
48
+ /** A git revision given by a caller: never an option (`--output=…`), whitespace or NUL. */
49
+ export const isRevision = (s) => typeof s === 'string' && s.length > 0 && s.length <= 256 && !s.startsWith('-') && !/[\s\0]/.test(s);
50
+
51
+ /**
52
+ * A small regular file of the repository (a manifest, a config), or null: never a device, a FIFO,
53
+ * a file larger than `maxBytes` or a link out of the root — a `tsconfig.json` linked to /dev/zero
54
+ * would otherwise hang the indexer.
55
+ */
56
+ export function readRepoFile(root, rel, maxBytes = 2_000_000) {
57
+ if (!withinRoot(root, rel)) return null;
58
+ try {
59
+ const abs = path.join(root, rel);
60
+ const st = fs.statSync(abs);
61
+ return st.isFile() && st.size <= maxBytes ? fs.readFileSync(abs, 'utf8') : null;
62
+ } catch { return null; }
63
+ }
@@ -0,0 +1,2 @@
1
+ /** A string as a literal inside a regular expression: every metacharacter escaped. */
2
+ export const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -0,0 +1,47 @@
1
+ # Vendored grammars
2
+
3
+ Each `NAME.wasm.gz` is a tree-sitter grammar compiled to WebAssembly. `manifest.json` records where it
4
+ comes from and the SHA-256 and size of the uncompressed `.wasm`; `test/vendor.test.mjs` checks every
5
+ file against it, and the index fingerprint covers the manifest, so a changed grammar rebuilds indexes.
6
+
7
+ - **`tag` and `asset`**: the `.wasm` published with that release of the grammar's repository,
8
+ downloaded as is.
9
+ - **`commit` and `built`**: the grammar has no release with what graph-indexer needs (Kotlin: the
10
+ latest release, 0.3.8, predates `fun interface` and Kotlin 2.x syntax), so the `.wasm` is built
11
+ from that commit. The build is reproducible: anyone can check the vendored file against its
12
+ source.
13
+
14
+ ## Building a grammar from a commit
15
+
16
+ This is `tree-sitter build --wasm` with tree-sitter-cli 0.25.10, the version of the vendored
17
+ `web-tree-sitter`, run as the `emcc` command that CLI issues (`compile_parser_to_wasm` in
18
+ `cli/loader/src/lib.rs`), in the Emscripten image it pins. The checkout must be under a directory
19
+ the container can mount (with Colima, your home directory).
20
+
21
+ ```sh
22
+ git clone https://github.com/fwcd/tree-sitter-kotlin && cd tree-sitter-kotlin
23
+ git checkout d6af96d2e23ef0a8ff5ab512864378cf08fba311
24
+ docker run --rm --platform linux/amd64 --workdir /src/src -v "$PWD":/src --user "$(id -u)" \
25
+ emscripten/emsdk:4.0.4 emcc -o output.wasm -Os -s WASM=1 -s SIDE_MODULE=2 -s TOTAL_MEMORY=33554432 \
26
+ -s NODEJS_CATCH_EXIT=0 -s 'EXPORTED_FUNCTIONS=["_tree_sitter_kotlin"]' -fno-exceptions -fvisibility=hidden \
27
+ -I . scanner.c parser.c
28
+ shasum -a 256 src/output.wasm # must equal manifest.json's sha256
29
+ gzip -9 -n -c src/output.wasm > kotlin.wasm.gz
30
+ ```
31
+
32
+ For another grammar, replace the repository, the commit and the exported symbol
33
+ (`_tree_sitter_<name>`). The grammar's `src/parser.c` must declare `LANGUAGE_VERSION` 14 or 15,
34
+ the ABI versions web-tree-sitter 0.25 loads.
35
+
36
+ ## Checking a new grammar before vendoring it
37
+
38
+ A grammar change can move or rename nodes the extraction queries in `src/parse/lang/` match. Before
39
+ replacing one:
40
+
41
+ 1. `npm test`, which compiles every query against its grammar.
42
+ 2. Parse a corpus of real code in that language with the old and the new grammar and compare the
43
+ files with ERROR regions, the symbols (by kind and qualified name) and the references each
44
+ extracts. A symbol that disappears needs a reason; one that changes its qualified name usually
45
+ moved back into its class.
46
+ 3. For a language with a compiler oracle (`bench/eval-graph*.mjs`), the references must keep their
47
+ precision and recall.
Binary file
@@ -82,10 +82,10 @@
82
82
  },
83
83
  "kotlin": {
84
84
  "repo": "https://github.com/fwcd/tree-sitter-kotlin",
85
- "tag": "0.3.8",
86
- "asset": "tree-sitter-kotlin.wasm",
87
- "sha256": "c624e7443b371c28adc5d81674e73067564c12555ebe3ed96a6c8db814b7602d",
88
- "bytes": 4052625,
85
+ "commit": "d6af96d2e23ef0a8ff5ab512864378cf08fba311",
86
+ "built": "tree-sitter build --wasm (tree-sitter-cli 0.25.10, emscripten/emsdk:4.0.4): Kotlin 2.x syntax (guard conditions, `..<`, `$$` strings, context parameters) and `fun interface` are in no release yet, the latest being 0.3.8",
87
+ "sha256": "90689092295c4e11d21f2c4f1b9ffce1a3c68f7ce6d4de57c365cd0fc888c7ee",
88
+ "bytes": 6078823,
89
89
  "license": "MIT"
90
90
  },
91
91
  "php": {