graph-indexer 2.1.0 → 3.0.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 (129) hide show
  1. package/CHANGELOG.md +260 -1
  2. package/README.md +232 -312
  3. package/SECURITY.md +37 -59
  4. package/bin/graph-indexer.mjs +251 -0
  5. package/integrations/claude-code/.claude-plugin/plugin.json +8 -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 +99 -0
  9. package/integrations/claude-code/hooks/hooks.json +16 -0
  10. package/integrations/opencode/graph-indexer.js +124 -0
  11. package/package.json +31 -105
  12. package/src/cli/helper.mjs +67 -0
  13. package/src/cli/hook.mjs +356 -0
  14. package/src/cli/init.mjs +255 -0
  15. package/src/cli/resident.mjs +175 -0
  16. package/src/index/discover.mjs +130 -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 +331 -0
  21. package/src/index/resolver.mjs +954 -0
  22. package/src/mcp/render.mjs +60 -0
  23. package/src/mcp/server.mjs +147 -0
  24. package/src/mcp/tools.mjs +973 -0
  25. package/src/parse/arity.mjs +152 -0
  26. package/src/parse/extract.mjs +1031 -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 +343 -0
  40. package/src/query/facts.mjs +323 -0
  41. package/src/query/intel.mjs +680 -0
  42. package/src/query/read.mjs +81 -0
  43. package/src/query/testcmd.mjs +119 -0
  44. package/src/query/textsearch.mjs +160 -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/paths.mjs +24 -0
  49. package/vendor/grammars/bash.wasm.gz +0 -0
  50. package/vendor/grammars/c.wasm.gz +0 -0
  51. package/vendor/grammars/c_sharp.wasm.gz +0 -0
  52. package/vendor/grammars/cpp.wasm.gz +0 -0
  53. package/vendor/grammars/css.wasm.gz +0 -0
  54. package/vendor/grammars/go.wasm.gz +0 -0
  55. package/vendor/grammars/java.wasm.gz +0 -0
  56. package/vendor/grammars/javascript.wasm.gz +0 -0
  57. package/vendor/grammars/kotlin.wasm.gz +0 -0
  58. package/vendor/grammars/manifest.json +148 -0
  59. package/vendor/grammars/php.wasm.gz +0 -0
  60. package/vendor/grammars/python.wasm.gz +0 -0
  61. package/vendor/grammars/ruby.wasm.gz +0 -0
  62. package/vendor/grammars/rust.wasm.gz +0 -0
  63. package/vendor/grammars/scala.wasm.gz +0 -0
  64. package/vendor/grammars/tsx.wasm.gz +0 -0
  65. package/vendor/grammars/typescript.wasm.gz +0 -0
  66. package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
  67. package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  68. package/cli-ui.mjs +0 -78
  69. package/colbert.mjs +0 -168
  70. package/config.mjs +0 -494
  71. package/daemon-ctl.mjs +0 -175
  72. package/daemon-lock.mjs +0 -69
  73. package/embedders/python/mlx_embed_server.py +0 -61
  74. package/embedders/python/requirements-mlx.txt +0 -5
  75. package/embedders/setup-mlx.mjs +0 -110
  76. package/embeddings.mjs +0 -571
  77. package/engine/binary.mjs +0 -437
  78. package/engine/memory.mjs +0 -1003
  79. package/engine/sqlite.mjs +0 -1136
  80. package/enrichment.mjs +0 -521
  81. package/git-signals.mjs +0 -248
  82. package/indexer.mjs +0 -546
  83. package/init.mjs +0 -1809
  84. package/layout.mjs +0 -123
  85. package/mcp/centrality.mjs +0 -117
  86. package/mcp/format.mjs +0 -61
  87. package/mcp/resolver.mjs +0 -116
  88. package/mcp/symbolgraph.mjs +0 -137
  89. package/mcp/taint.mjs +0 -225
  90. package/mcp/tools.mjs +0 -1582
  91. package/mcp/topology.mjs +0 -676
  92. package/mcp-server.mjs +0 -197
  93. package/parse/extractor.mjs +0 -628
  94. package/parse/imports.mjs +0 -365
  95. package/parse/interprocedural.mjs +0 -134
  96. package/parse/languages.mjs +0 -253
  97. package/parse/metadata.mjs +0 -701
  98. package/parse/routes.mjs +0 -327
  99. package/parse/scip.mjs +0 -391
  100. package/parse/taint-patterns.mjs +0 -119
  101. package/prompts/CORE.md +0 -91
  102. package/prompts/DOMAIN_TEMPLATE.md +0 -46
  103. package/prompts/INTEGRATION.md +0 -123
  104. package/prompts/frameworks/ANDROID.md +0 -17
  105. package/prompts/frameworks/ASPNET_CORE.md +0 -19
  106. package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
  107. package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
  108. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
  109. package/prompts/frameworks/RAILS.md +0 -20
  110. package/prompts/frameworks/REACT.md +0 -20
  111. package/prompts/frameworks/SPRING_BOOT.md +0 -20
  112. package/prompts/languages/BASH.md +0 -22
  113. package/prompts/languages/C.md +0 -21
  114. package/prompts/languages/CSHARP.md +0 -24
  115. package/prompts/languages/CSS_SCSS.md +0 -22
  116. package/prompts/languages/GO.md +0 -24
  117. package/prompts/languages/JAVA.md +0 -23
  118. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
  119. package/prompts/languages/KOTLIN.md +0 -24
  120. package/prompts/languages/PHP.md +0 -23
  121. package/prompts/languages/PYTHON.md +0 -23
  122. package/prompts/languages/RUBY.md +0 -23
  123. package/prompts/languages/RUST.md +0 -21
  124. package/prompts/languages/SWIFT.md +0 -21
  125. package/seal.mjs +0 -400
  126. package/search-core.mjs +0 -777
  127. package/search-sparse.mjs +0 -183
  128. package/storage.mjs +0 -73
  129. package/watch-daemon.mjs +0 -289
@@ -0,0 +1,175 @@
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
+ /** A per-user directory in the temp dir must belong to us and be closed to everyone else. */
48
+ function privateDir(dir, root) {
49
+ if (WIN || dir === path.join(path.resolve(root), DATA_DIR_NAME)) return true;
50
+ try {
51
+ const st = fs.lstatSync(dir);
52
+ return st.isDirectory() && (process.getuid ? st.uid === process.getuid() : true) && (st.mode & 0o077) === 0;
53
+ } catch { return false; }
54
+ }
55
+
56
+ const disabled = () => process.env.GRAPH_INDEXER_RESIDENT === '0';
57
+ /** GRAPH_INDEXER_RESIDENT=require: answer only through a resident, never by opening the index (tests, debugging). */
58
+ export const residentRequired = () => process.env.GRAPH_INDEXER_RESIDENT === 'require';
59
+
60
+ /**
61
+ * Ask the resident serving `root`. Resolves to its reply ({ ok: true, … }) or null when there is
62
+ * none, it is another version, it fails, or it is slower than `totalMs`.
63
+ */
64
+ export function askResident(root, request, { version, connectMs = 250, totalMs = 5000 } = {}) {
65
+ if (disabled()) return Promise.resolve(null);
66
+ const file = socketPath(root);
67
+ if (!WIN && (!fs.existsSync(file) || !privateDir(path.dirname(file), root))) return Promise.resolve(null);
68
+ return new Promise((resolve) => {
69
+ let done = false, connected = false, buf = '';
70
+ const sock = net.createConnection(file);
71
+ const finish = (v) => {
72
+ if (done) return;
73
+ done = true;
74
+ clearTimeout(connectTimer);
75
+ clearTimeout(totalTimer);
76
+ sock.destroy();
77
+ resolve(v);
78
+ };
79
+ const connectTimer = setTimeout(() => { if (!connected) finish(null); }, connectMs);
80
+ const totalTimer = setTimeout(() => finish(null), totalMs);
81
+ sock.setEncoding('utf8');
82
+ sock.on('connect', () => {
83
+ connected = true;
84
+ sock.write(JSON.stringify({ v: PROTOCOL, version, ...request }) + '\n');
85
+ });
86
+ sock.on('data', (d) => {
87
+ buf += d;
88
+ const i = buf.indexOf('\n');
89
+ if (i < 0) return;
90
+ let reply = null;
91
+ try { reply = JSON.parse(buf.slice(0, i)); } catch { /* garbled */ }
92
+ finish(reply?.ok ? reply : null);
93
+ });
94
+ sock.on('error', () => finish(null));
95
+ sock.on('close', () => finish(null));
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Serve `root` on its socket. `handle(request)` answers hook and tool requests. Returns null when
101
+ * another resident already serves the repository (or the socket cannot be made private), else
102
+ * { file, close }. `idleMs` > 0 calls `onIdle` after that long without a request, or as soon as
103
+ * `wanted()` turns false.
104
+ */
105
+ export async function startResident({ root, version, handle, log = () => {}, idleMs = 0, onIdle = null, wanted = () => true }) {
106
+ if (disabled()) return null;
107
+ const file = socketPath(root);
108
+ if (!WIN) {
109
+ const dir = path.dirname(file);
110
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* checked below */ }
111
+ if (!privateDir(dir, root)) { log(`resident: ${dir} is not private; not listening`); return null; }
112
+ }
113
+ // another resident may serve this repository already; a dead one leaves its socket file behind
114
+ if (await askResident(root, { op: 'ping' }, { version, connectMs: 300, totalMs: 1500 })) return null;
115
+ if (!WIN) { try { fs.unlinkSync(file); } catch { /* none */ } }
116
+
117
+ let last = Date.now();
118
+ const server = net.createServer((sock) => {
119
+ let buf = '';
120
+ sock.setEncoding('utf8');
121
+ sock.on('error', () => { /* client went away */ });
122
+ sock.on('data', async (d) => {
123
+ buf += d;
124
+ if (buf.length > MAX_REQUEST) { sock.destroy(); return; }
125
+ const i = buf.indexOf('\n');
126
+ if (i < 0) return;
127
+ const line = buf.slice(0, i);
128
+ buf = '';
129
+ last = Date.now();
130
+ let reply;
131
+ try {
132
+ const req = JSON.parse(line);
133
+ // `any`: a thin client with no logic of its own (the Claude Code plugin's hook command)
134
+ if (req.v !== PROTOCOL || (req.version !== version && req.any !== true)) reply = { ok: false, reason: 'version' };
135
+ else if (req.op === 'ping') reply = { ok: true, root, pid: process.pid };
136
+ else reply = { ok: true, out: (await handle(req)) ?? null };
137
+ } catch (e) { reply = { ok: false, reason: String(e?.message ?? e).slice(0, 200) }; }
138
+ last = Date.now();
139
+ if (!sock.destroyed) sock.end(JSON.stringify(reply) + '\n');
140
+ });
141
+ });
142
+ try {
143
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(file, resolve); });
144
+ } catch (e) {
145
+ log(`resident: cannot listen on ${file} (${e.code ?? e.message})`);
146
+ return null;
147
+ }
148
+ let ino = null;
149
+ if (!WIN) {
150
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
151
+ try { ino = fs.statSync(file).ino; } catch { /* checked on close */ }
152
+ }
153
+ server.unref(); // the caller's own work (stdio, watcher, idle timer) decides the process lifetime
154
+
155
+ let closed = false;
156
+ const close = () => {
157
+ if (closed) return;
158
+ closed = true;
159
+ try { server.close(); } catch { /* closed */ }
160
+ // only our own socket file: a resident started after us may have replaced it
161
+ if (!WIN) { try { if (fs.statSync(file).ino === ino) fs.unlinkSync(file); } catch { /* gone */ } }
162
+ };
163
+ process.once('exit', close);
164
+ if (idleMs > 0) {
165
+ // also stop when the repository or its index goes away
166
+ const timer = setInterval(() => {
167
+ if (Date.now() - last < idleMs && wanted()) return;
168
+ clearInterval(timer);
169
+ close();
170
+ onIdle?.();
171
+ }, Math.min(idleMs, 30_000));
172
+ }
173
+ log(`resident: answering hooks and CLI queries on ${file}`);
174
+ return { file, close };
175
+ }
@@ -0,0 +1,130 @@
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 { spawnSync } from 'node:child_process';
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 = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, encoding: 'utf8' });
28
+ return r.status === 0 && r.stdout.trim() === 'true';
29
+ }
30
+
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
+ });
35
+ if (r.status !== 0) return null;
36
+ return r.stdout.split('\0').filter(Boolean);
37
+ }
38
+
39
+ /** Minimal .gitignore → predicate (used only outside git repos). */
40
+ function loadGitignore(root) {
41
+ const rules = [];
42
+ try {
43
+ for (let line of fs.readFileSync(path.join(root, '.gitignore'), 'utf8').split(/\r?\n/)) {
44
+ line = line.trim();
45
+ if (!line || line.startsWith('#')) continue;
46
+ const neg = line.startsWith('!');
47
+ if (neg) line = line.slice(1);
48
+ const dirOnly = line.endsWith('/');
49
+ if (dirOnly) line = line.slice(0, -1);
50
+ const anchored = line.startsWith('/') || line.includes('/');
51
+ line = line.replace(/^\//, '');
52
+ const re = line.split('**').map(seg => seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')).join('.*');
53
+ rules.push({ neg, re: new RegExp(anchored ? `^${re}(/|$)` : `(^|/)${re}(/|$)`) });
54
+ }
55
+ } catch { /* no .gitignore */ }
56
+ return (rel) => {
57
+ let ignored = false;
58
+ for (const r of rules) if (r.re.test(rel)) ignored = !r.neg;
59
+ return ignored;
60
+ };
61
+ }
62
+
63
+ function walk(root, ignored) {
64
+ const out = [];
65
+ const stack = [''];
66
+ while (stack.length) {
67
+ const rel = stack.pop();
68
+ let entries;
69
+ try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
70
+ for (const e of entries) {
71
+ const r = rel ? rel + '/' + e.name : e.name;
72
+ if (e.isDirectory()) {
73
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') || ignored(r)) continue;
74
+ stack.push(r);
75
+ } else if (e.isFile() && !ignored(r)) out.push(r);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export function isIndexablePath(rel, { include = null } = {}) {
82
+ if (!specForPath(rel)) return false;
83
+ const parts = rel.split('/');
84
+ if (include && include.some(p => rel.startsWith(p))) return !GENERATED_FILE.test(rel);
85
+ for (let i = 0; i < parts.length - 1; i++) if (SKIP_DIRS.has(parts[i])) return false;
86
+ return !GENERATED_FILE.test(rel);
87
+ }
88
+
89
+ /**
90
+ * List indexable files (repo-relative, forward slashes), sorted for determinism.
91
+ * @returns {{ files: string[], viaGit: boolean }}
92
+ */
93
+ export function discoverFiles(root, opts = {}) {
94
+ let rels = null, viaGit = false;
95
+ if (!opts.noGit && isGitRepo(root)) {
96
+ rels = gitListFiles(root);
97
+ viaGit = rels !== null;
98
+ }
99
+ if (!rels) rels = walk(root, loadGitignore(root));
100
+ const files = rels.map(r => r.replace(/\\/g, '/')).filter(r => isIndexablePath(r, opts) && (!viaGit || insideRoot(root, r)));
101
+ files.sort();
102
+ return { files, viaGit };
103
+ }
104
+
105
+ const realRoots = new Map();
106
+
107
+ /**
108
+ * A tracked file must resolve inside the repository: symlinks are followed only when their target
109
+ * stays under the root, so a link to /etc/… or ~/.ssh/… is never read, indexed or served.
110
+ */
111
+ export function insideRoot(root, rel) {
112
+ const abs = path.join(root, rel);
113
+ let st;
114
+ try { st = fs.lstatSync(abs); } catch { return true; } // vanished: the caller handles deletion
115
+ if (!st.isSymbolicLink()) return true;
116
+ let realRoot = realRoots.get(root);
117
+ if (!realRoot) { try { realRoot = fs.realpathSync(root); } catch { realRoot = root; } realRoots.set(root, realRoot); }
118
+ try {
119
+ const target = fs.realpathSync(abs);
120
+ return target === realRoot || target.startsWith(realRoot + path.sep);
121
+ } catch { return false; } // dangling link
122
+ }
123
+
124
+ /** Heuristic: minified / generated content that slipped past the name filters. */
125
+ export function looksMinified(source) {
126
+ if (source.length < 2000) return false;
127
+ const lines = source.split('\n', 50);
128
+ const long = lines.filter(l => l.length > 500).length;
129
+ return long >= 3 || source.length / Math.max(1, source.split('\n').length) > 300;
130
+ }
@@ -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
+ }