graph-indexer 2.1.1 → 3.0.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +236 -312
  3. package/SECURITY.md +53 -58
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +9 -0
  6. package/integrations/claude-code/.mcp.json +8 -0
  7. package/integrations/claude-code/agents/code-structure.md +22 -0
  8. package/integrations/claude-code/hooks/gi-hook.mjs +103 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +129 -0
  11. package/package.json +32 -109
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +366 -0
  14. package/src/cli/init.mjs +272 -0
  15. package/src/cli/resident.mjs +185 -0
  16. package/src/index/discover.mjs +128 -0
  17. package/src/index/fingerprint.mjs +30 -0
  18. package/src/index/graph.mjs +61 -0
  19. package/src/index/indexer.mjs +476 -0
  20. package/src/index/modules.mjs +332 -0
  21. package/src/index/resolver.mjs +961 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +974 -0
  25. package/src/parse/arity.mjs +153 -0
  26. package/src/parse/extract.mjs +1038 -0
  27. package/src/parse/lang/csharp.mjs +106 -0
  28. package/src/parse/lang/ecma.mjs +438 -0
  29. package/src/parse/lang/go.mjs +194 -0
  30. package/src/parse/lang/jvm.mjs +118 -0
  31. package/src/parse/lang/kotlin.mjs +97 -0
  32. package/src/parse/lang/misc.mjs +115 -0
  33. package/src/parse/lang/native.mjs +126 -0
  34. package/src/parse/lang/python.mjs +153 -0
  35. package/src/parse/lang/rust.mjs +157 -0
  36. package/src/parse/lang/scripting.mjs +219 -0
  37. package/src/parse/languages.mjs +48 -0
  38. package/src/parse/runtime.mjs +56 -0
  39. package/src/query/check.mjs +345 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +683 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +130 -0
  44. package/src/query/textsearch.mjs +178 -0
  45. package/src/search/search.mjs +210 -0
  46. package/src/search/tokenize.mjs +188 -0
  47. package/src/store/db.mjs +169 -0
  48. package/src/util/git.mjs +30 -0
  49. package/src/util/paths.mjs +63 -0
  50. package/src/util/regex.mjs +2 -0
  51. package/vendor/grammars/bash.wasm.gz +0 -0
  52. package/vendor/grammars/c.wasm.gz +0 -0
  53. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  54. package/vendor/grammars/cpp.wasm.gz +0 -0
  55. package/vendor/grammars/css.wasm.gz +0 -0
  56. package/vendor/grammars/go.wasm.gz +0 -0
  57. package/vendor/grammars/java.wasm.gz +0 -0
  58. package/vendor/grammars/javascript.wasm.gz +0 -0
  59. package/vendor/grammars/kotlin.wasm.gz +0 -0
  60. package/vendor/grammars/manifest.json +148 -0
  61. package/vendor/grammars/php.wasm.gz +0 -0
  62. package/vendor/grammars/python.wasm.gz +0 -0
  63. package/vendor/grammars/ruby.wasm.gz +0 -0
  64. package/vendor/grammars/rust.wasm.gz +0 -0
  65. package/vendor/grammars/scala.wasm.gz +0 -0
  66. package/vendor/grammars/tsx.wasm.gz +0 -0
  67. package/vendor/grammars/typescript.wasm.gz +0 -0
  68. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  69. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  70. package/cli-ui.mjs +0 -78
  71. package/colbert.mjs +0 -168
  72. package/config.mjs +0 -494
  73. package/daemon-ctl.mjs +0 -175
  74. package/daemon-lock.mjs +0 -69
  75. package/embedders/python/mlx_embed_server.py +0 -61
  76. package/embedders/python/requirements-mlx.txt +0 -5
  77. package/embedders/setup-mlx.mjs +0 -110
  78. package/embeddings.mjs +0 -571
  79. package/engine/binary.mjs +0 -437
  80. package/engine/memory.mjs +0 -1003
  81. package/engine/sqlite.mjs +0 -1136
  82. package/enrichment.mjs +0 -521
  83. package/git-signals.mjs +0 -248
  84. package/indexer.mjs +0 -546
  85. package/init.mjs +0 -1809
  86. package/layout.mjs +0 -123
  87. package/mcp/centrality.mjs +0 -117
  88. package/mcp/format.mjs +0 -61
  89. package/mcp/resolver.mjs +0 -116
  90. package/mcp/symbolgraph.mjs +0 -137
  91. package/mcp/taint.mjs +0 -225
  92. package/mcp/tools.mjs +0 -1582
  93. package/mcp/topology.mjs +0 -676
  94. package/mcp-server.mjs +0 -197
  95. package/parse/extractor.mjs +0 -628
  96. package/parse/imports.mjs +0 -365
  97. package/parse/interprocedural.mjs +0 -134
  98. package/parse/languages.mjs +0 -253
  99. package/parse/metadata.mjs +0 -701
  100. package/parse/routes.mjs +0 -327
  101. package/parse/scip.mjs +0 -391
  102. package/parse/taint-patterns.mjs +0 -119
  103. package/prompts/CORE.md +0 -91
  104. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  105. package/prompts/INTEGRATION.md +0 -123
  106. package/prompts/frameworks/ANDROID.md +0 -17
  107. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  108. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  109. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  110. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  111. package/prompts/frameworks/RAILS.md +0 -20
  112. package/prompts/frameworks/REACT.md +0 -20
  113. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  114. package/prompts/languages/BASH.md +0 -22
  115. package/prompts/languages/C.md +0 -21
  116. package/prompts/languages/CSHARP.md +0 -24
  117. package/prompts/languages/CSS_SCSS.md +0 -22
  118. package/prompts/languages/GO.md +0 -24
  119. package/prompts/languages/JAVA.md +0 -23
  120. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  121. package/prompts/languages/KOTLIN.md +0 -24
  122. package/prompts/languages/PHP.md +0 -23
  123. package/prompts/languages/PYTHON.md +0 -23
  124. package/prompts/languages/RUBY.md +0 -23
  125. package/prompts/languages/RUST.md +0 -21
  126. package/prompts/languages/SWIFT.md +0 -21
  127. package/seal.mjs +0 -400
  128. package/search-core.mjs +0 -777
  129. package/search-sparse.mjs +0 -183
  130. package/storage.mjs +0 -73
  131. package/watch-daemon.mjs +0 -289
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The resident process: a long-lived graph-indexer that keeps one repository's index open and in
3
+ * sync, and answers hooks and CLI queries over a local socket.
4
+ *
5
+ * Opening the index and reconciling it with the working tree costs 200–500 ms on a repository of
6
+ * 1,600 files, on every hook call and every CLI query. Hooks run on each read, search and edit, so
7
+ * that cost lands on every step of the agent. The MCP server (`serve`) is already long-lived and
8
+ * serves as the resident when the agent runs it; `graph-indexer daemon` does the same for agents
9
+ * that only use hooks or the CLI, and exits after a while without requests.
10
+ *
11
+ * Clients ask first and fall back to opening the index themselves when nothing answers, so a
12
+ * missing, dead or different-version resident only costs the old latency.
13
+ *
14
+ * Protocol: one JSON request per connection, one JSON reply, each ended by a newline.
15
+ * { v, version, op: 'ping' } → { ok, root, pid }
16
+ * { v, version, op: 'hook', event, input } → { ok, out } out: what the hook prints, or null
17
+ * { v, version, op: 'tool', calls } → { ok, out } out: the CLI's output text
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).
20
+ *
21
+ * The socket is only reachable by its owner: inside the repository's .graph-indexer/ directory,
22
+ * or, when that path is too long for a Unix socket, in a per-user directory with mode 0700 that
23
+ * both sides check before using it. On Windows it is a named pipe.
24
+ */
25
+ import net from 'node:net';
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import crypto from 'node:crypto';
30
+ import { DATA_DIR_NAME } from '../util/paths.mjs';
31
+
32
+ const PROTOCOL = 1;
33
+ const MAX_REQUEST = 8 * 1024 * 1024;
34
+ const WIN = process.platform === 'win32';
35
+
36
+ /** Where the resident for `root` listens. */
37
+ export function socketPath(root) {
38
+ const abs = path.resolve(root);
39
+ const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 16);
40
+ if (WIN) return `\\\\.\\pipe\\graph-indexer-${hash}`;
41
+ const inRepo = path.join(abs, DATA_DIR_NAME, 'resident.sock');
42
+ // Unix socket paths are limited to 104–108 bytes
43
+ if (Buffer.byteLength(inRepo) <= 100) return inRepo;
44
+ return path.join(os.tmpdir(), `graph-indexer-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
45
+ }
46
+
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
+ */
53
+ function privateDir(dir, root) {
54
+ if (WIN) return true;
55
+ try {
56
+ const st = fs.lstatSync(dir);
57
+ return st.isDirectory() && mine(st) && (dir === path.join(path.resolve(root), DATA_DIR_NAME) || (st.mode & 0o077) === 0);
58
+ } catch { return false; }
59
+ }
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
+
66
+ const disabled = () => process.env.GRAPH_INDEXER_RESIDENT === '0';
67
+ /** GRAPH_INDEXER_RESIDENT=require: answer only through a resident, never by opening the index (tests, debugging). */
68
+ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 'require';
69
+
70
+ /**
71
+ * Ask the resident serving `root`. Resolves to its reply ({ ok: true, … }) or null when there is
72
+ * none, it is another version, it fails, or it is slower than `totalMs`.
73
+ */
74
+ export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
75
+ if (disabled()) return Promise.resolve(null);
76
+ const file = socketPath(root);
77
+ if (!WIN && (!ownSocket(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
78
+ return new Promise((resolve) => {
79
+ let done = false, connected = false, buf = '';
80
+ const sock = net.createConnection(file);
81
+ const finish = (v) => {
82
+ if (done) return;
83
+ done = true;
84
+ clearTimeout(connectTimer);
85
+ clearTimeout(totalTimer);
86
+ sock.destroy();
87
+ resolve(v);
88
+ };
89
+ const connectTimer = setTimeout(() => { if (!connected) finish(null); }, connectMs);
90
+ const totalTimer = setTimeout(() => finish(null), totalMs);
91
+ sock.setEncoding('utf8');
92
+ sock.on('connect', () => {
93
+ connected = true;
94
+ sock.write(JSON.stringify({ v: PROTOCOL, version, ...request }) + '\n');
95
+ });
96
+ sock.on('data', (d) => {
97
+ buf += d;
98
+ const i = buf.indexOf('\n');
99
+ if (i < 0) return;
100
+ let reply = null;
101
+ try { reply = JSON.parse(buf.slice(0, i)); } catch { /* garbled */ }
102
+ finish(reply?.ok ? reply : null);
103
+ });
104
+ sock.on('error', () => finish(null));
105
+ sock.on('close', () => finish(null));
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Serve `root` on its socket. `handle(request)` answers hook and tool requests. Returns null when
111
+ * another resident already serves the repository (or the socket cannot be made private), else
112
+ * { file, close }. `idleMs` > 0 calls `onIdle` after that long without a request, or as soon as
113
+ * `wanted()` turns false.
114
+ */
115
+ export async function startResident({ root, version, handle, log = () => {}, idleMs = 0, onIdle = null, wanted = () => true }) {
116
+ if (disabled()) return null;
117
+ const file = socketPath(root);
118
+ if (!WIN) {
119
+ const dir = path.dirname(file);
120
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* checked below */ }
121
+ if (!privateDir(dir, root)) { log(`resident: ${dir} is not private; not listening`); return null; }
122
+ }
123
+ // another resident may serve this repository already; a dead one leaves its socket file behind
124
+ if (await askResident(root, { op: 'ping' }, { version, connectMs: 300, totalMs: 1500 })) return null;
125
+ if (!WIN) { try { fs.unlinkSync(file); } catch { /* none */ } }
126
+
127
+ let last = Date.now();
128
+ const server = net.createServer((sock) => {
129
+ let buf = '';
130
+ sock.setEncoding('utf8');
131
+ sock.on('error', () => { /* client went away */ });
132
+ sock.on('data', async (d) => {
133
+ buf += d;
134
+ if (buf.length > MAX_REQUEST) { sock.destroy(); return; }
135
+ const i = buf.indexOf('\n');
136
+ if (i < 0) return;
137
+ const line = buf.slice(0, i);
138
+ buf = '';
139
+ last = Date.now();
140
+ let reply;
141
+ try {
142
+ const req = JSON.parse(line);
143
+ // `any`: a thin client with no logic of its own (the Claude Code plugin's hook command)
144
+ if (req.v !== PROTOCOL || (req.version !== version && req.any !== true)) reply = { ok: false, reason: 'version' };
145
+ else if (req.op === 'ping') reply = { ok: true, root, pid: process.pid };
146
+ else reply = { ok: true, out: (await handle(req)) ?? null };
147
+ } catch (e) { reply = { ok: false, reason: String(e?.message ?? e).slice(0, 200) }; }
148
+ last = Date.now();
149
+ if (!sock.destroyed) sock.end(JSON.stringify(reply) + '\n');
150
+ });
151
+ });
152
+ try {
153
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(file, resolve); });
154
+ } catch (e) {
155
+ log(`resident: cannot listen on ${file} (${e.code ?? e.message})`);
156
+ return null;
157
+ }
158
+ let ino = null;
159
+ if (!WIN) {
160
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
161
+ try { ino = fs.statSync(file).ino; } catch { /* checked on close */ }
162
+ }
163
+ server.unref(); // the caller's own work (stdio, watcher, idle timer) decides the process lifetime
164
+
165
+ let closed = false;
166
+ const close = () => {
167
+ if (closed) return;
168
+ closed = true;
169
+ try { server.close(); } catch { /* closed */ }
170
+ // only our own socket file: a resident started after us may have replaced it
171
+ if (!WIN) { try { if (fs.statSync(file).ino === ino) fs.unlinkSync(file); } catch { /* gone */ } }
172
+ };
173
+ process.once('exit', close);
174
+ if (idleMs > 0) {
175
+ // also stop when the repository or its index goes away
176
+ const timer = setInterval(() => {
177
+ if (Date.now() - last < idleMs && wanted()) return;
178
+ clearInterval(timer);
179
+ close();
180
+ onIdle?.();
181
+ }, Math.min(idleMs, 30_000));
182
+ }
183
+ log(`resident: answering hooks and CLI queries on ${file}`);
184
+ return { file, close };
185
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * File discovery. Inside a git work tree we ask git (tracked + untracked-but-not-ignored),
3
+ * which honours every .gitignore/.git/info/exclude rule exactly; elsewhere we walk the tree
4
+ * with a small .gitignore matcher. On top of that we skip what an agent never wants in its
5
+ * index: dependencies, build output, vendored bundles, minified and generated files.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { gitSync } from '../util/git.mjs';
10
+ import { specForPath } from '../parse/languages.mjs';
11
+
12
+ export const MAX_FILE_BYTES = 1_500_000;
13
+
14
+ const SKIP_DIRS = new Set([
15
+ 'node_modules', 'bower_components', 'jspm_packages', '.git', '.hg', '.svn', '.graph-indexer',
16
+ 'dist', 'build', 'out', 'target', '.next', '.nuxt', '.svelte-kit', '.output', '.turbo', '.cache', '.parcel-cache',
17
+ 'coverage', '.nyc_output', '__pycache__', '.venv', 'venv', 'env', '.env', '.tox', '.mypy_cache', '.pytest_cache',
18
+ 'site-packages', '.gradle', '.idea', '.vscode', 'Pods', 'DerivedData', 'vendor', 'third_party', 'thirdparty',
19
+ 'external', 'deps', '_deps', 'obj', 'bin', 'packages-cache', '.yarn', '.pnpm-store', 'elm-stuff', '.terraform',
20
+ ]);
21
+ // `vendor` / `bin` / `obj` are common real source dirs in some ecosystems only when they contain
22
+ // sources the user wrote; the conservative default is to skip them (override via config.include).
23
+
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
+
26
+ function isGitRepo(root) {
27
+ const r = gitSync(root, ['rev-parse', '--is-inside-work-tree']);
28
+ return r.status === 0 && r.stdout.trim() === 'true';
29
+ }
30
+
31
+ function gitListFiles(root) {
32
+ const r = gitSync(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 1 << 30 });
33
+ if (r.status !== 0) return null;
34
+ return r.stdout.split('\0').filter(Boolean);
35
+ }
36
+
37
+ /** Minimal .gitignore → predicate (used only outside git repos). */
38
+ function loadGitignore(root) {
39
+ const rules = [];
40
+ try {
41
+ for (let line of fs.readFileSync(path.join(root, '.gitignore'), 'utf8').split(/\r?\n/)) {
42
+ line = line.trim();
43
+ if (!line || line.startsWith('#')) continue;
44
+ const neg = line.startsWith('!');
45
+ if (neg) line = line.slice(1);
46
+ const dirOnly = line.endsWith('/');
47
+ if (dirOnly) line = line.slice(0, -1);
48
+ const anchored = line.startsWith('/') || line.includes('/');
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 */ }
54
+ return (rel) => {
55
+ let ignored = false;
56
+ for (const r of rules) if (r.re.test(rel)) ignored = !r.neg;
57
+ return ignored;
58
+ };
59
+ }
60
+
61
+ function walk(root, ignored) {
62
+ const out = [];
63
+ const stack = [''];
64
+ while (stack.length) {
65
+ const rel = stack.pop();
66
+ let entries;
67
+ try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
68
+ for (const e of entries) {
69
+ const r = rel ? rel + '/' + e.name : e.name;
70
+ if (e.isDirectory()) {
71
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') || ignored(r)) continue;
72
+ stack.push(r);
73
+ } else if (e.isFile() && !ignored(r)) out.push(r);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ export function isIndexablePath(rel, { include = null } = {}) {
80
+ if (!specForPath(rel)) return false;
81
+ const parts = rel.split('/');
82
+ if (include && include.some(p => rel.startsWith(p))) return !GENERATED_FILE.test(rel);
83
+ for (let i = 0; i < parts.length - 1; i++) if (SKIP_DIRS.has(parts[i])) return false;
84
+ return !GENERATED_FILE.test(rel);
85
+ }
86
+
87
+ /**
88
+ * List indexable files (repo-relative, forward slashes), sorted for determinism.
89
+ * @returns {{ files: string[], viaGit: boolean }}
90
+ */
91
+ export function discoverFiles(root, opts = {}) {
92
+ let rels = null, viaGit = false;
93
+ if (!opts.noGit && isGitRepo(root)) {
94
+ rels = gitListFiles(root);
95
+ viaGit = rels !== null;
96
+ }
97
+ if (!rels) rels = walk(root, loadGitignore(root));
98
+ const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && (!viaGit || insideRoot(root, r)));
99
+ files.sort();
100
+ return { files, viaGit };
101
+ }
102
+
103
+ const realRoots = new Map();
104
+
105
+ /**
106
+ * A tracked file must resolve inside the repository: symlinks are followed only when their target
107
+ * stays under the root, so a link to /etc/… or ~/.ssh/… is never read, indexed or served.
108
+ */
109
+ export function insideRoot(root, rel) {
110
+ const abs = path.join(root, rel);
111
+ let st;
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
120
+ }
121
+
122
+ /** Heuristic: minified / generated content that slipped past the name filters. */
123
+ export function looksMinified(source) {
124
+ if (source.length < 2000) return false;
125
+ const lines = source.split('\n', 50);
126
+ const long = lines.filter(l => l.length > 500).length;
127
+ return long >= 3 || source.length / Math.max(1, source.split('\n').length) > 300;
128
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Fingerprint of everything that determines the stored facts: extraction queries and code,
3
+ * module and reference resolution, FTS tokenization, the schema and the grammar builds. An index
4
+ * written under a different fingerprint is rebuilt, so upgrading graph-indexer never keeps serving
5
+ * facts that an older extractor produced for files that have not changed since.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const INPUTS = ['parse', 'index', 'search/tokenize.mjs', 'store/db.mjs', '../vendor/grammars/manifest.json'];
14
+ const QUERY_TIME_ONLY = new Set(['graph.mjs']);
15
+
16
+ let cached = null;
17
+
18
+ export function indexFingerprint() {
19
+ if (cached) return cached;
20
+ const h = crypto.createHash('sha1');
21
+ const add = (p) => {
22
+ const st = fs.statSync(p, { throwIfNoEntry: false });
23
+ if (!st) return;
24
+ if (st.isDirectory()) { for (const e of fs.readdirSync(p).sort()) add(path.join(p, e)); return; }
25
+ if (!/\.(mjs|json)$/.test(p) || QUERY_TIME_ONLY.has(path.basename(p))) return;
26
+ h.update(path.relative(SRC, p).split(path.sep).join('/')).update('\0').update(fs.readFileSync(p)).update('\0');
27
+ };
28
+ for (const i of INPUTS) add(path.join(SRC, i));
29
+ return (cached = h.digest('hex').slice(0, 16));
30
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Graph analytics over the resolved reference graph: symbol centrality (PageRank, optionally
3
+ * personalized to a focus set — the Aider repo-map idea applied to symbols) and helpers for
4
+ * traversals used by the impact / call-graph tools.
5
+ */
6
+
7
+ /** Load the weighted symbol graph: edges src → dst (references), plus member → container. */
8
+ export function loadGraph(store) {
9
+ const edges = store.all(`SELECT src_id AS s, dst_id AS d, SUM(conf) AS w, COUNT(*) AS n FROM refs
10
+ WHERE dst_id IS NOT NULL AND src_id IS NOT NULL AND src_id != dst_id GROUP BY src_id, dst_id`);
11
+ const contain = store.all('SELECT id AS s, parent_id AS d FROM symbols WHERE parent_id IS NOT NULL');
12
+ const ids = store.all('SELECT id FROM symbols').map(r => r.id);
13
+ return { ids, edges, contain };
14
+ }
15
+
16
+ /**
17
+ * PageRank over symbols. Edge weight = Σconf (√ damped so one hot call site does not dominate).
18
+ * @param {object} store
19
+ * @param {{ personalization?: Map<number, number>, damping?: number, iters?: number }} opts
20
+ * @returns {Map<number, number>} id → score normalized to a [0,1] percentile
21
+ */
22
+ export function computeCentrality(store, { personalization = null, damping = 0.85, iters = 40, graph = null } = {}) {
23
+ const g = graph ?? loadGraph(store);
24
+ const n = g.ids.length;
25
+ if (!n) return new Map();
26
+ const index = new Map(g.ids.map((id, i) => [id, i]));
27
+ const out = new Float64Array(n);
28
+ const src = [], dst = [], wt = [];
29
+ const add = (s, d, w) => {
30
+ const a = index.get(s), b = index.get(d);
31
+ if (a === undefined || b === undefined) return;
32
+ src.push(a); dst.push(b); wt.push(w); out[a] += w;
33
+ };
34
+ for (const e of g.edges) add(e.s, e.d, Math.sqrt(e.w));
35
+ for (const e of g.contain) add(e.s, e.d, 0.3);
36
+ let pers = null;
37
+ if (personalization?.size) {
38
+ pers = new Float64Array(n);
39
+ let tot = 0;
40
+ for (const [id, v] of personalization) { const i = index.get(id); if (i !== undefined) { pers[i] += v; tot += v; } }
41
+ if (tot > 0) for (let i = 0; i < n; i++) pers[i] /= tot; else pers = null;
42
+ }
43
+ let rank = new Float64Array(n).fill(1 / n);
44
+ for (let it = 0; it < iters; it++) {
45
+ const next = new Float64Array(n);
46
+ let dangling = 0;
47
+ for (let i = 0; i < n; i++) if (out[i] === 0) dangling += rank[i];
48
+ for (let k = 0; k < src.length; k++) next[dst[k]] += damping * rank[src[k]] * wt[k] / out[src[k]];
49
+ for (let i = 0; i < n; i++) {
50
+ const teleport = pers ? pers[i] : 1 / n;
51
+ next[i] += (1 - damping + damping * dangling) * teleport;
52
+ }
53
+ rank = next;
54
+ }
55
+ if (personalization) return new Map(g.ids.map((id, i) => [id, rank[i]]));
56
+ // percentile normalization: robust to the heavy tail
57
+ const order = [...rank.keys()].sort((a, b) => rank[a] - rank[b]);
58
+ const pct = new Map();
59
+ for (let r = 0; r < order.length; r++) pct.set(g.ids[order[r]], n > 1 ? r / (n - 1) : 1);
60
+ return pct;
61
+ }