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.
- package/CHANGELOG.md +260 -1
- package/README.md +232 -312
- package/SECURITY.md +37 -59
- package/bin/graph-indexer.mjs +251 -0
- package/integrations/claude-code/.claude-plugin/plugin.json +8 -0
- package/integrations/claude-code/.mcp.json +8 -0
- package/integrations/claude-code/agents/code-structure.md +22 -0
- package/integrations/claude-code/hooks/gi-hook.mjs +99 -0
- package/integrations/claude-code/hooks/hooks.json +16 -0
- package/integrations/opencode/graph-indexer.js +124 -0
- package/package.json +31 -105
- package/src/cli/helper.mjs +67 -0
- package/src/cli/hook.mjs +356 -0
- package/src/cli/init.mjs +255 -0
- package/src/cli/resident.mjs +175 -0
- package/src/index/discover.mjs +130 -0
- package/src/index/fingerprint.mjs +30 -0
- package/src/index/graph.mjs +61 -0
- package/src/index/indexer.mjs +476 -0
- package/src/index/modules.mjs +331 -0
- package/src/index/resolver.mjs +954 -0
- package/src/mcp/render.mjs +60 -0
- package/src/mcp/server.mjs +147 -0
- package/src/mcp/tools.mjs +973 -0
- package/src/parse/arity.mjs +152 -0
- package/src/parse/extract.mjs +1031 -0
- package/src/parse/lang/csharp.mjs +106 -0
- package/src/parse/lang/ecma.mjs +438 -0
- package/src/parse/lang/go.mjs +194 -0
- package/src/parse/lang/jvm.mjs +118 -0
- package/src/parse/lang/kotlin.mjs +97 -0
- package/src/parse/lang/misc.mjs +115 -0
- package/src/parse/lang/native.mjs +126 -0
- package/src/parse/lang/python.mjs +153 -0
- package/src/parse/lang/rust.mjs +157 -0
- package/src/parse/lang/scripting.mjs +219 -0
- package/src/parse/languages.mjs +48 -0
- package/src/parse/runtime.mjs +56 -0
- package/src/query/check.mjs +343 -0
- package/src/query/facts.mjs +323 -0
- package/src/query/intel.mjs +680 -0
- package/src/query/read.mjs +81 -0
- package/src/query/testcmd.mjs +119 -0
- package/src/query/textsearch.mjs +160 -0
- package/src/search/search.mjs +210 -0
- package/src/search/tokenize.mjs +188 -0
- package/src/store/db.mjs +169 -0
- package/src/util/paths.mjs +24 -0
- package/vendor/grammars/bash.wasm.gz +0 -0
- package/vendor/grammars/c.wasm.gz +0 -0
- package/vendor/grammars/c_sharp.wasm.gz +0 -0
- package/vendor/grammars/cpp.wasm.gz +0 -0
- package/vendor/grammars/css.wasm.gz +0 -0
- package/vendor/grammars/go.wasm.gz +0 -0
- package/vendor/grammars/java.wasm.gz +0 -0
- package/vendor/grammars/javascript.wasm.gz +0 -0
- package/vendor/grammars/kotlin.wasm.gz +0 -0
- package/vendor/grammars/manifest.json +148 -0
- package/vendor/grammars/php.wasm.gz +0 -0
- package/vendor/grammars/python.wasm.gz +0 -0
- package/vendor/grammars/ruby.wasm.gz +0 -0
- package/vendor/grammars/rust.wasm.gz +0 -0
- package/vendor/grammars/scala.wasm.gz +0 -0
- package/vendor/grammars/tsx.wasm.gz +0 -0
- package/vendor/grammars/typescript.wasm.gz +0 -0
- package/vendor/web-tree-sitter/web-tree-sitter.js +3980 -0
- package/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/cli-ui.mjs +0 -78
- package/colbert.mjs +0 -168
- package/config.mjs +0 -494
- package/daemon-ctl.mjs +0 -175
- package/daemon-lock.mjs +0 -69
- package/embedders/python/mlx_embed_server.py +0 -61
- package/embedders/python/requirements-mlx.txt +0 -5
- package/embedders/setup-mlx.mjs +0 -110
- package/embeddings.mjs +0 -571
- package/engine/binary.mjs +0 -437
- package/engine/memory.mjs +0 -1003
- package/engine/sqlite.mjs +0 -1136
- package/enrichment.mjs +0 -521
- package/git-signals.mjs +0 -248
- package/indexer.mjs +0 -546
- package/init.mjs +0 -1809
- package/layout.mjs +0 -123
- package/mcp/centrality.mjs +0 -117
- package/mcp/format.mjs +0 -61
- package/mcp/resolver.mjs +0 -116
- package/mcp/symbolgraph.mjs +0 -137
- package/mcp/taint.mjs +0 -225
- package/mcp/tools.mjs +0 -1582
- package/mcp/topology.mjs +0 -676
- package/mcp-server.mjs +0 -197
- package/parse/extractor.mjs +0 -628
- package/parse/imports.mjs +0 -365
- package/parse/interprocedural.mjs +0 -134
- package/parse/languages.mjs +0 -253
- package/parse/metadata.mjs +0 -701
- package/parse/routes.mjs +0 -327
- package/parse/scip.mjs +0 -391
- package/parse/taint-patterns.mjs +0 -119
- package/prompts/CORE.md +0 -91
- package/prompts/DOMAIN_TEMPLATE.md +0 -46
- package/prompts/INTEGRATION.md +0 -123
- package/prompts/frameworks/ANDROID.md +0 -17
- package/prompts/frameworks/ASPNET_CORE.md +0 -19
- package/prompts/frameworks/FASTAPI_DJANGO.md +0 -20
- package/prompts/frameworks/LARAVEL_SYMFONY.md +0 -22
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +0 -19
- package/prompts/frameworks/RAILS.md +0 -20
- package/prompts/frameworks/REACT.md +0 -20
- package/prompts/frameworks/SPRING_BOOT.md +0 -20
- package/prompts/languages/BASH.md +0 -22
- package/prompts/languages/C.md +0 -21
- package/prompts/languages/CSHARP.md +0 -24
- package/prompts/languages/CSS_SCSS.md +0 -22
- package/prompts/languages/GO.md +0 -24
- package/prompts/languages/JAVA.md +0 -23
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +0 -24
- package/prompts/languages/KOTLIN.md +0 -24
- package/prompts/languages/PHP.md +0 -23
- package/prompts/languages/PYTHON.md +0 -23
- package/prompts/languages/RUBY.md +0 -23
- package/prompts/languages/RUST.md +0 -21
- package/prompts/languages/SWIFT.md +0 -21
- package/seal.mjs +0 -400
- package/search-core.mjs +0 -777
- package/search-sparse.mjs +0 -183
- package/storage.mjs +0 -73
- 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
|
+
}
|