graph-indexer 1.2.0 → 2.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.
- package/CHANGELOG.md +854 -0
- package/README.md +258 -595
- package/SECURITY.md +85 -0
- package/TRADEMARK.md +31 -0
- package/cli-ui.mjs +78 -0
- package/colbert.mjs +168 -0
- package/config.mjs +408 -32
- package/daemon-ctl.mjs +175 -0
- package/daemon-lock.mjs +69 -0
- package/embedders/python/mlx_embed_server.py +61 -0
- package/embedders/python/requirements-mlx.txt +5 -0
- package/embedders/setup-mlx.mjs +110 -0
- package/embeddings.mjs +571 -0
- package/engine/binary.mjs +437 -0
- package/{core-engine.mjs → engine/memory.mjs} +321 -441
- package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
- package/enrichment.mjs +206 -32
- package/git-signals.mjs +248 -0
- package/indexer.mjs +399 -45
- package/init.mjs +1626 -171
- package/layout.mjs +123 -0
- package/mcp/centrality.mjs +117 -0
- package/mcp/format.mjs +61 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +1582 -0
- package/mcp/topology.mjs +676 -0
- package/mcp-server.mjs +80 -25
- package/package.json +66 -27
- package/parse/extractor.mjs +628 -0
- package/parse/imports.mjs +365 -0
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +253 -0
- package/parse/metadata.mjs +701 -0
- package/parse/routes.mjs +327 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +91 -0
- package/prompts/DOMAIN_TEMPLATE.md +46 -0
- package/prompts/INTEGRATION.md +123 -0
- package/prompts/frameworks/ANDROID.md +17 -0
- package/prompts/frameworks/ASPNET_CORE.md +19 -0
- package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
- package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
- package/prompts/frameworks/RAILS.md +20 -0
- package/prompts/frameworks/REACT.md +20 -0
- package/prompts/frameworks/SPRING_BOOT.md +20 -0
- package/prompts/languages/BASH.md +22 -0
- package/prompts/languages/C.md +21 -0
- package/prompts/languages/CSHARP.md +24 -0
- package/prompts/languages/CSS_SCSS.md +22 -0
- package/prompts/languages/GO.md +24 -0
- package/prompts/languages/JAVA.md +23 -0
- package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
- package/prompts/languages/KOTLIN.md +24 -0
- package/prompts/languages/PHP.md +23 -0
- package/prompts/languages/PYTHON.md +23 -0
- package/prompts/languages/RUBY.md +23 -0
- package/prompts/languages/RUST.md +21 -0
- package/prompts/languages/SWIFT.md +21 -0
- package/seal.mjs +400 -0
- package/search-core.mjs +405 -44
- package/search-sparse.mjs +183 -0
- package/storage.mjs +33 -3
- package/watch-daemon.mjs +75 -24
- package/PROMPT.md +0 -182
- package/mcp-tools.mjs +0 -545
- package/parser-utils.mjs +0 -1170
package/indexer.mjs
CHANGED
|
@@ -12,20 +12,109 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from './
|
|
19
|
-
import { readEmbeddingBinary, writeEmbeddingBinary } from './
|
|
20
|
-
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX } from './search-core.mjs';
|
|
21
|
-
import {
|
|
15
|
+
import { MAX_FILE_SIZE_BYTES, buildIgnoreFilter, extractImportsFromAST, extractSemanticChunks } from './parse/extractor.mjs';
|
|
16
|
+
import { EXTENSIONS, getParserForFile, ensureLanguagesReady } from './parse/languages.mjs';
|
|
17
|
+
import { extractRoutes } from './parse/routes.mjs';
|
|
18
|
+
import { resolveLocalImports, buildEmbeddingPayload, fullBodyForEmbedding } from './parse/imports.mjs';
|
|
19
|
+
import { readEmbeddingBinary, writeEmbeddingBinary } from './engine/binary.mjs';
|
|
20
|
+
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows } from './search-core.mjs';
|
|
21
|
+
import { createEmbedder, describeEmbedder, readEmbedMeta, writeEmbedMeta, embedModelChanged, _resetSubprocesses } from './embeddings.mjs';
|
|
22
|
+
import { resolveConfig, describeConfig, configNotices } from './config.mjs';
|
|
23
|
+
import { AUTO_SQLITE_CHUNK_THRESHOLD } from './storage.mjs';
|
|
24
|
+
import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
|
|
22
25
|
import { enrichCoreChunks } from './enrichment.mjs';
|
|
26
|
+
import { collectGitSignals, writeGitSignals } from './git-signals.mjs';
|
|
27
|
+
import {
|
|
28
|
+
installEgressGuard, sealManifest, signManifest, verifySignedManifest,
|
|
29
|
+
generateAttestationKeyPair, canonicalJson, SealViolation,
|
|
30
|
+
} from './seal.mjs';
|
|
31
|
+
|
|
32
|
+
/** Value following `--flag` in argv, or undefined. */
|
|
33
|
+
function argVal(flag) {
|
|
34
|
+
const i = process.argv.indexOf(flag);
|
|
35
|
+
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// resolveConfig fail-closes on a sealed-incompatible config (throws SealViolation); surface it
|
|
39
|
+
// as a clean exit rather than an unhandled rejection.
|
|
40
|
+
let config;
|
|
41
|
+
try {
|
|
42
|
+
config = resolveConfig();
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err && err.name === 'SealViolation') { console.error(`🔒 ${err.message}`); process.exit(2); }
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `idx-index --gen-attestation-key <prefix>`: write an Ed25519 keypair (<prefix>.key / <prefix>.pub)
|
|
49
|
+
// and exit. The private key is the signing key (keep safe); the public key is shared for verification.
|
|
50
|
+
{
|
|
51
|
+
const prefix = argVal('--gen-attestation-key');
|
|
52
|
+
if (prefix) {
|
|
53
|
+
const { publicKey, privateKey } = generateAttestationKeyPair();
|
|
54
|
+
fs.writeFileSync(`${prefix}.key`, privateKey, { mode: 0o600 });
|
|
55
|
+
fs.writeFileSync(`${prefix}.pub`, publicKey);
|
|
56
|
+
console.log(`🔑 Attestation keypair written: ${prefix}.key (private — keep safe) + ${prefix}.pub (share to verify).`);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `idx-index --verify-attestation <file> --pub-key <path>`: verify a signed manifest's signature and,
|
|
62
|
+
// if authentic, report whether it MATCHES the current effective config. Exit 0 = valid + matching.
|
|
63
|
+
{
|
|
64
|
+
const verifyFile = argVal('--verify-attestation');
|
|
65
|
+
if (verifyFile) {
|
|
66
|
+
let envelope;
|
|
67
|
+
try { envelope = JSON.parse(fs.readFileSync(verifyFile, 'utf8')); }
|
|
68
|
+
catch (e) { console.error(`❌ cannot read attestation ${verifyFile}: ${e.message}`); process.exit(3); }
|
|
69
|
+
const pubPath = argVal('--pub-key');
|
|
70
|
+
if (!pubPath) { console.error('❌ --verify-attestation requires --pub-key <path>'); process.exit(3); }
|
|
71
|
+
let pubPem;
|
|
72
|
+
try { pubPem = fs.readFileSync(pubPath, 'utf8'); }
|
|
73
|
+
catch (e) { console.error(`❌ cannot read public key ${pubPath}: ${e.message}`); process.exit(3); }
|
|
74
|
+
const res = verifySignedManifest(envelope, pubPem);
|
|
75
|
+
if (!res.valid) { console.error(`❌ attestation INVALID: ${res.reason}`); process.exit(3); }
|
|
76
|
+
console.log(`✅ attestation signature VALID (key ${res.publicKeyFingerprint}).`);
|
|
77
|
+
const matches = canonicalJson(sealManifest(config)) === canonicalJson(envelope.manifest);
|
|
78
|
+
if (matches) { console.log('✅ attested manifest MATCHES the current effective config.'); process.exit(0); }
|
|
79
|
+
console.log('⚠️ attested manifest DIFFERS from the current effective config (policy drift):');
|
|
80
|
+
console.log(` attested: ${canonicalJson(envelope.manifest)}`);
|
|
81
|
+
console.log(` current : ${canonicalJson(sealManifest(config))}`);
|
|
82
|
+
process.exit(3);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `idx-index --attest [--sign-key <path>]`: print the deterministic seal manifest (signed if a key
|
|
87
|
+
// is given) and exit (no indexing).
|
|
88
|
+
if (process.argv.includes('--attest')) {
|
|
89
|
+
const manifest = sealManifest(config);
|
|
90
|
+
const signKey = argVal('--sign-key');
|
|
91
|
+
if (signKey) {
|
|
92
|
+
let keyPem;
|
|
93
|
+
try { keyPem = fs.readFileSync(signKey, 'utf8'); }
|
|
94
|
+
catch (e) { console.error(`❌ cannot read signing key ${signKey}: ${e.message}`); process.exit(2); }
|
|
95
|
+
try { console.log(JSON.stringify(signManifest(manifest, keyPem), null, 2)); }
|
|
96
|
+
catch (e) { console.error(`❌ signing failed: ${e.message}`); process.exit(2); }
|
|
97
|
+
} else {
|
|
98
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
99
|
+
}
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// F1: install the deny-by-default egress guard BEFORE any provider (embedder/enricher) loads, so
|
|
104
|
+
// even provider initialisation cannot reach the network under a sealed tier.
|
|
105
|
+
if (config.sealed !== 'off') {
|
|
106
|
+
installEgressGuard({ allow: config.sealed === 'local' ? ['loopback'] : [] });
|
|
107
|
+
}
|
|
23
108
|
|
|
24
|
-
const config = resolveConfig();
|
|
25
109
|
const PROJECT_ROOT = config.projectRoot;
|
|
26
110
|
const INDEX_PATH = config.indexPath;
|
|
27
111
|
const EMBEDDINGS_PATH = config.embeddingPath;
|
|
28
112
|
|
|
113
|
+
// Artifacts are written under <root>/.graph-indexer/ — make sure it exists and
|
|
114
|
+
// relocate any artifacts left at the root by a pre-v1.4 install.
|
|
115
|
+
ensureDataDir(PROJECT_ROOT);
|
|
116
|
+
migrateLegacyLayout(PROJECT_ROOT);
|
|
117
|
+
|
|
29
118
|
function walkRepo(dir, root, ig, files = []) {
|
|
30
119
|
for (const entry of fs.readdirSync(dir)) {
|
|
31
120
|
if (entry.startsWith('.')) continue;
|
|
@@ -43,17 +132,70 @@ function walkRepo(dir, root, ig, files = []) {
|
|
|
43
132
|
|
|
44
133
|
async function main() {
|
|
45
134
|
console.log(`\n🚀 Starting Optimized Indexer\n📂 Directory: ${PROJECT_ROOT}`);
|
|
46
|
-
console.log(
|
|
135
|
+
console.log('⚙️ Effective configuration:');
|
|
136
|
+
for (const line of describeConfig(config)) console.log(` ${line}`);
|
|
137
|
+
for (const notice of configNotices(config)) console.log(`⚠️ ${notice}`);
|
|
138
|
+
console.log('');
|
|
139
|
+
|
|
140
|
+
// Grammar packages are installed on demand (not bundled) — resolve/install
|
|
141
|
+
// whatever the configured languages need BEFORE walking the repo. Sealed mode
|
|
142
|
+
// never reaches the network for this: installEgressGuard can't stop a child
|
|
143
|
+
// `npm install` process, so autoInstall is explicitly off rather than relying
|
|
144
|
+
// on the guard — and, matching the fail-closed egress checks elsewhere in
|
|
145
|
+
// sealed mode, an unresolvable grammar refuses the run instead of silently
|
|
146
|
+
// shipping a partial index.
|
|
147
|
+
const { missing: missingGrammars } = await ensureLanguagesReady({
|
|
148
|
+
projectRoot: PROJECT_ROOT,
|
|
149
|
+
enabledLangs: config.languages,
|
|
150
|
+
autoInstall: config.sealed === 'off',
|
|
151
|
+
log: (msg) => console.log(msg),
|
|
152
|
+
});
|
|
153
|
+
if (config.sealed !== 'off' && missingGrammars.length > 0) {
|
|
154
|
+
const detail = missingGrammars.map(m => ` • ${m.key} (${m.pkg}@${m.version})`).join('\n');
|
|
155
|
+
throw new SealViolation(
|
|
156
|
+
`--sealed ${config.sealed} refuses to start — ${missingGrammars.length} language grammar(s) `
|
|
157
|
+
+ `are not installed and sealed mode won't fetch them:\n${detail}\n`
|
|
158
|
+
+ ` remedy: pre-install before sealing, e.g. npm install --prefix ${path.join(PROJECT_ROOT, '.graph-indexer')} `
|
|
159
|
+
+ `${missingGrammars.map(m => `${m.pkg}@${m.version}`).join(' ')}`
|
|
160
|
+
+ ` — or drop the language from your .graph-indexer.json "languages" selection`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
47
163
|
|
|
48
164
|
const ig = buildIgnoreFilter(PROJECT_ROOT);
|
|
49
165
|
const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
|
|
50
166
|
console.log(`Found ${files.length} files to analyse.\n`);
|
|
51
167
|
|
|
52
|
-
|
|
168
|
+
// Resolve the embedding provider for this run (Ollama → in-process local →
|
|
169
|
+
// lexical, unless forced). Index time and query time must share the model.
|
|
170
|
+
const embedder = await createEmbedder(config);
|
|
171
|
+
console.log(`🔎 Embeddings: ${describeEmbedder(embedder)}`);
|
|
172
|
+
// Make the `auto` fallback ladder observable — never silently lexical-only.
|
|
173
|
+
if (config.embedProvider === 'auto') {
|
|
174
|
+
if (embedder.provider === 'local') {
|
|
175
|
+
console.log(` ↪ Ollama not reachable at ${config.ollamaHost}; using the bundled in-process model (no daemon required).`);
|
|
176
|
+
} else if (embedder.provider === 'off') {
|
|
177
|
+
console.log(` ↪ No Ollama and no in-process model installed (\`npm run embed:setup:local\`) — indexing lexical-only.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
console.log('');
|
|
181
|
+
|
|
182
|
+
let existingCache = readEmbeddingBinary(EMBEDDINGS_PATH);
|
|
183
|
+
// A model switch invalidates the cached vectors — vectors of different models
|
|
184
|
+
// (and dims) must never be mixed in one bin. Re-embed from scratch. (e.g. switching
|
|
185
|
+
// `--embed-provider local` → `code-local`: 384-dim MiniLM → 768-dim jina-code.)
|
|
186
|
+
const prevMeta = readEmbedMeta(EMBEDDINGS_PATH);
|
|
187
|
+
if (embedModelChanged(prevMeta, embedder.model)) {
|
|
188
|
+
console.log(`⚠️ Embedding model changed (${prevMeta.model} → ${embedder.model}); re-embedding from scratch.`);
|
|
189
|
+
existingCache = new Map();
|
|
190
|
+
}
|
|
53
191
|
console.log(`📦 Loaded ${existingCache.size} cached embeddings from previous runs.\n`);
|
|
54
192
|
|
|
55
|
-
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {} }, embeddingCache: {} };
|
|
193
|
+
const indexData = { chunks: [], graph: { dependencies: {}, importedBy: {}, routes: [] }, embeddingCache: {} };
|
|
56
194
|
const pendingChunks = [];
|
|
195
|
+
// Full bodies of oversized chunks (snippet was truncated by the 3000-char cap),
|
|
196
|
+
// captured here while we still hold the source so the dense channel can window the
|
|
197
|
+
// WHOLE definition. Kept out of the chunk/JSON — transient, dropped after embedding.
|
|
198
|
+
const fullBodies = new Map();
|
|
57
199
|
let totalCheckedFiles = 0;
|
|
58
200
|
|
|
59
201
|
for (const absolutePath of files) {
|
|
@@ -79,75 +221,112 @@ async function main() {
|
|
|
79
221
|
const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
|
|
80
222
|
indexData.graph.dependencies[relPath] = imports;
|
|
81
223
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
224
|
+
const fileChunks = extractSemanticChunks(tree.rootNode, relPath, content, ext, { interprocedural: config.interprocedural });
|
|
225
|
+
for (const chunk of fileChunks) {
|
|
226
|
+
const fullBody = fullBodyForEmbedding(chunk, content);
|
|
227
|
+
if (fullBody) fullBodies.set(chunk.id, fullBody);
|
|
85
228
|
pendingChunks.push(chunk);
|
|
86
229
|
}
|
|
230
|
+
|
|
231
|
+
for (const route of extractRoutes(tree.rootNode, relPath, fileChunks, ext)) {
|
|
232
|
+
indexData.graph.routes.push(route);
|
|
233
|
+
}
|
|
87
234
|
} catch (err) {
|
|
88
235
|
console.error(`\n💥 Error in ${relPath}: ${err.message}`);
|
|
89
236
|
}
|
|
90
237
|
}
|
|
91
238
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
239
|
+
// Opt-in inter-procedural receiver-type fixpoint (--interprocedural): propagate return
|
|
240
|
+
// types along factory call chains so multi-hop receivers resolve. Runs ONCE over the whole
|
|
241
|
+
// program here, while every chunk's return_via / call_sites are known, and writes
|
|
242
|
+
// recv_resolved_type into call_sites (serialized identically by both backends). Strips the
|
|
243
|
+
// transient _return_via. Off by default → chunks/index byte-identical.
|
|
244
|
+
if (config.interprocedural) {
|
|
245
|
+
const { applyInterprocedural } = await import('./parse/interprocedural.mjs');
|
|
246
|
+
applyInterprocedural(pendingChunks);
|
|
247
|
+
console.log('🔗 Inter-procedural receiver types resolved (factory return-type propagation).');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Enrichment runs before embedding so hypothetical questions share the same vector space.
|
|
94
251
|
if (config.enrichment.enabled) {
|
|
95
252
|
await enrichCoreChunks(pendingChunks, indexData.graph, config);
|
|
96
253
|
}
|
|
97
254
|
|
|
98
255
|
// ── Embedding generation (cache-aware) ────────────────────────────────────────
|
|
99
|
-
// Vectors are keyed by embeddingKeyFor(chunk)
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// this cache on re-runs — previously every enriched chunk was re-embedded on
|
|
103
|
-
// every single index run.
|
|
256
|
+
// Vectors are keyed by embeddingKeyFor(chunk), which includes an enrichment
|
|
257
|
+
// digest so enriched chunks hit the cache on re-runs (previously they were
|
|
258
|
+
// re-embedded on every run).
|
|
104
259
|
const toEmbed = [];
|
|
105
260
|
for (const chunk of pendingChunks) {
|
|
106
261
|
const vecKey = embeddingKeyFor(chunk);
|
|
107
262
|
const sKey = vecKey + SUMMARY_VEC_SUFFIX;
|
|
108
|
-
//
|
|
109
|
-
|
|
263
|
+
// Compute the payload once here and reuse it in the worker to avoid a second build.
|
|
264
|
+
const payload = buildEmbeddingPayload(chunk, indexData.graph.dependencies[chunk.file_path] || [], fullBodies.get(chunk.id) || null);
|
|
265
|
+
const windows = embeddingWindows(payload);
|
|
266
|
+
// Indexes built before dual/window vectors only have the base key, so we must
|
|
267
|
+
// check that summary and window keys are also cached before skipping embedding.
|
|
110
268
|
const summaryMissing = summaryEmbeddingText(chunk) && !existingCache.has(sKey);
|
|
111
|
-
|
|
269
|
+
let windowsMissing = false;
|
|
270
|
+
for (let i = 1; i < windows.length; i++) if (!existingCache.has(vecKey + WINDOW_VEC_SUFFIX + i)) { windowsMissing = true; break; }
|
|
271
|
+
if (existingCache.has(vecKey) && !summaryMissing && !windowsMissing) {
|
|
112
272
|
indexData.embeddingCache[vecKey] = Array.from(existingCache.get(vecKey));
|
|
113
273
|
if (existingCache.has(sKey)) indexData.embeddingCache[sKey] = Array.from(existingCache.get(sKey));
|
|
274
|
+
for (let i = 1; i < windows.length; i++) {
|
|
275
|
+
const wk = vecKey + WINDOW_VEC_SUFFIX + i;
|
|
276
|
+
if (existingCache.has(wk)) indexData.embeddingCache[wk] = Array.from(existingCache.get(wk));
|
|
277
|
+
}
|
|
114
278
|
indexData.chunks.push(chunk);
|
|
115
279
|
} else {
|
|
116
|
-
toEmbed.push(chunk);
|
|
280
|
+
toEmbed.push({ chunk, payload, windows });
|
|
117
281
|
}
|
|
118
282
|
}
|
|
119
283
|
|
|
120
|
-
console.log(`\n\n🧠 Embedding Generation (
|
|
284
|
+
console.log(`\n\n🧠 Embedding Generation — ${describeEmbedder(embedder)}`);
|
|
121
285
|
console.log(`Chunks reused from cache: ${indexData.chunks.length}`);
|
|
122
286
|
console.log(`New chunks to process: ${toEmbed.length}`);
|
|
123
287
|
|
|
124
288
|
if (toEmbed.length > 0) {
|
|
125
289
|
const BATCH_SIZE = 64;
|
|
126
|
-
|
|
290
|
+
// Embedding concurrency. A single local Ollama model serves requests
|
|
291
|
+
// serially, so a high fan-out just queues batches and risks per-request
|
|
292
|
+
// timeouts (a large model like qwen3-embedding:4b made every queued batch
|
|
293
|
+
// breach the old cap). Default 4 (fast on small models / when
|
|
294
|
+
// OLLAMA_NUM_PARALLEL is set); lower it (e.g. 1) for big models on modest
|
|
295
|
+
// hardware via INDEXER_EMBED_CONCURRENCY.
|
|
296
|
+
const CONCURRENCY = Number(process.env.INDEXER_EMBED_CONCURRENCY) > 0
|
|
297
|
+
? Number(process.env.INDEXER_EMBED_CONCURRENCY) : 4;
|
|
127
298
|
const batches = [];
|
|
128
299
|
for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) batches.push(toEmbed.slice(i, i + BATCH_SIZE));
|
|
129
300
|
|
|
130
|
-
let completed = 0;
|
|
301
|
+
let completed = 0, vectorBatches = 0, failedBatches = 0;
|
|
131
302
|
console.time('Embedding Generation Duration');
|
|
132
303
|
const worker = async (batch) => {
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
304
|
+
// Oversized chunks need window vectors so semantic search can reach
|
|
305
|
+
// their tail (the embedder truncates the base text to window 0 only).
|
|
306
|
+
// Enriched chunks also get a compact summary vector whose vocabulary
|
|
307
|
+
// matches natural-language queries.
|
|
136
308
|
const entries = [];
|
|
137
|
-
for (const c of batch) {
|
|
138
|
-
|
|
309
|
+
for (const { chunk: c, payload, windows } of batch) {
|
|
310
|
+
const baseKey = embeddingKeyFor(c);
|
|
311
|
+
entries.push({ key: baseKey, text: payload });
|
|
312
|
+
for (let i = 1; i < windows.length; i++) entries.push({ key: baseKey + WINDOW_VEC_SUFFIX + i, text: windows[i] });
|
|
139
313
|
const sText = summaryEmbeddingText(c);
|
|
140
|
-
if (sText) entries.push({ key:
|
|
314
|
+
if (sText) entries.push({ key: baseKey + SUMMARY_VEC_SUFFIX, text: sText });
|
|
141
315
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
316
|
+
// Per-batch graceful degradation: a slow/failed embedding batch (e.g. a
|
|
317
|
+
// timeout on a big model) must NOT abort the whole index. Those chunks
|
|
318
|
+
// are still indexed lexically — they just miss their vector until the
|
|
319
|
+
// next run re-tries them (the cache only stores what succeeded).
|
|
320
|
+
let matrix = null;
|
|
321
|
+
try { matrix = await embedder.embedDocuments(entries.map(e => e.text)); }
|
|
322
|
+
catch (e) { failedBatches++; process.stderr.write(`\n⚠️ embedding batch failed (${e.message}); indexing ${batch.length} chunks lexical-only this run.\n`); }
|
|
145
323
|
if (matrix && matrix.length === entries.length) {
|
|
146
324
|
for (let j = 0; j < entries.length; j++) {
|
|
147
325
|
indexData.embeddingCache[entries[j].key] = matrix[j];
|
|
148
326
|
}
|
|
327
|
+
vectorBatches++;
|
|
149
328
|
}
|
|
150
|
-
for (const chunk of batch) indexData.chunks.push(chunk);
|
|
329
|
+
for (const { chunk } of batch) indexData.chunks.push(chunk);
|
|
151
330
|
completed += batch.length;
|
|
152
331
|
process.stdout.write(`\r🤖 Embedding Progress: [${completed}/${toEmbed.length}] Chunks processed...`);
|
|
153
332
|
};
|
|
@@ -155,9 +334,12 @@ async function main() {
|
|
|
155
334
|
await Promise.all(batches.slice(i, i + CONCURRENCY).map(worker));
|
|
156
335
|
}
|
|
157
336
|
console.timeEnd('Embedding Generation Duration');
|
|
337
|
+
if (failedBatches > 0) {
|
|
338
|
+
console.log(`⚠️ ${failedBatches}/${batches.length} embedding batches failed (lexical-only for those chunks); re-run to fill them in. `
|
|
339
|
+
+ `Tip: a large embed model on modest hardware wants INDEXER_EMBED_CONCURRENCY=1 and/or a higher INDEXER_EMBED_TIMEOUT_MS.`);
|
|
340
|
+
}
|
|
158
341
|
}
|
|
159
342
|
|
|
160
|
-
// ── Reverse topology edges ────────────────────────────────────────────────────
|
|
161
343
|
for (const [filePath, imports] of Object.entries(indexData.graph.dependencies)) {
|
|
162
344
|
for (const dep of imports) {
|
|
163
345
|
if (!indexData.graph.importedBy[dep]) indexData.graph.importedBy[dep] = [];
|
|
@@ -165,28 +347,200 @@ async function main() {
|
|
|
165
347
|
}
|
|
166
348
|
}
|
|
167
349
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
350
|
+
// Opt-in resolved symbol graph (--symbol-graph): build the chunk→chunk edge set ONCE
|
|
351
|
+
// over the finalized chunks (reusing the query-time resolvers, so confidence matches
|
|
352
|
+
// get_call_graph). Serialized into the index for both backends → getEdges. Built via a
|
|
353
|
+
// throwaway in-memory store so it sees the same resolution the MCP server will.
|
|
354
|
+
let symbolEdges = null;
|
|
355
|
+
let centrality = null;
|
|
356
|
+
let scipReferers = null; // A2 v2: SCIP precise cross-file references for find_references
|
|
357
|
+
if (config.symbolGraph) {
|
|
358
|
+
const tmpSg = path.join(config.dataDir, '_symbolgraph-build.json');
|
|
359
|
+
try {
|
|
360
|
+
fs.writeFileSync(tmpSg, JSON.stringify({ chunks: indexData.chunks, graph: indexData.graph }));
|
|
361
|
+
const { MemoryGraphIndex } = await import('./engine/memory.mjs');
|
|
362
|
+
const { buildSymbolGraph } = await import('./mcp/symbolgraph.mjs');
|
|
363
|
+
const { getResolver, createScipResolver } = await import('./mcp/resolver.mjs');
|
|
364
|
+
const { computeSymbolCentrality } = await import('./mcp/centrality.mjs');
|
|
365
|
+
const sgIdx = new MemoryGraphIndex(tmpSg, { cacheEmbeddings: false });
|
|
366
|
+
sgIdx.load();
|
|
367
|
+
// A2: a 'scip' resolver ingests a locally-generated SCIP index and resolves edges by
|
|
368
|
+
// real cross-file bindings (promote confirmed → `resolved`, suppress wrong-target
|
|
369
|
+
// fan-out). Missing/unreadable index → fall back to heuristic (never worse, loud).
|
|
370
|
+
let resolver = getResolver(config.resolver);
|
|
371
|
+
let scipStats = null;
|
|
372
|
+
if (config.resolver === 'scip') {
|
|
373
|
+
if (!config.scipIndex) {
|
|
374
|
+
console.warn('⚠️ --resolver scip without --scip-index — no SCIP index to read; '
|
|
375
|
+
+ 'falling back to heuristic resolver.');
|
|
376
|
+
resolver = getResolver('heuristic');
|
|
377
|
+
} else {
|
|
378
|
+
try {
|
|
379
|
+
const { loadScip, buildScipBindings, buildScipReferers } = await import('./parse/scip.mjs');
|
|
380
|
+
const aligned = buildScipBindings(sgIdx, loadScip(config.scipIndex));
|
|
381
|
+
scipStats = aligned.stats;
|
|
382
|
+
resolver = createScipResolver(aligned.bindings, aligned.definedChunks);
|
|
383
|
+
// A2 v2: invert the same bindings into the precise cross-file "referenced-by"
|
|
384
|
+
// map find_references consumes (kind-agnostic, sound, isolated artifact).
|
|
385
|
+
const refs = buildScipReferers(aligned.bindings);
|
|
386
|
+
if (Object.keys(refs).length) scipReferers = refs;
|
|
387
|
+
if (scipStats.matchedDocs === 0) {
|
|
388
|
+
console.warn(`⚠️ SCIP index matched 0 of ${scipStats.docs} document(s) to indexed `
|
|
389
|
+
+ `files (${config.scipIndex}) — check the path / project_root. Resolver is `
|
|
390
|
+
+ 'effectively inert (edges stay heuristic { high, name_only }).');
|
|
391
|
+
}
|
|
392
|
+
} catch (e) {
|
|
393
|
+
console.warn(`⚠️ SCIP load failed (${e.message}); falling back to heuristic resolver.`);
|
|
394
|
+
resolver = getResolver('heuristic');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const res = buildSymbolGraph(sgIdx, { resolver });
|
|
399
|
+
symbolEdges = res.edges;
|
|
400
|
+
sgIdx.close?.();
|
|
401
|
+
const isPreciseTier = config.resolver === 'precise' || (config.resolver === 'scip' && scipStats);
|
|
402
|
+
const resolvedCount = isPreciseTier
|
|
403
|
+
? symbolEdges.filter(e => e.confidence === 'resolved').length : 0;
|
|
404
|
+
console.log(`🕸 Symbol graph: ${symbolEdges.length} resolved edges`
|
|
405
|
+
+ `${config.resolver === 'precise' ? ` · resolver=precise (${resolvedCount} resolved-tier)` : ''}`
|
|
406
|
+
+ `${(config.resolver === 'scip' && scipStats) ? ` · resolver=scip (${resolvedCount} resolved-tier; `
|
|
407
|
+
+ `${scipStats.matchedDocs}/${scipStats.docs} docs matched, ${scipStats.bindingPairs} bindings)` : ''}`
|
|
408
|
+
+ `${res.cappedNames.length ? ` (⚠️ ${res.cappedNames.length} high-degree name(s) capped: ${res.cappedNames.slice(0, 5).join(', ')})` : ''}.`);
|
|
409
|
+
// A5: confidence-weighted PageRank over those edges (computed once → serialized →
|
|
410
|
+
// parity-free). Surfaced by explain_symbol / get_repo_map; does not touch ranking.
|
|
411
|
+
const cen = computeSymbolCentrality(symbolEdges);
|
|
412
|
+
centrality = cen.total ? cen.centrality : null;
|
|
413
|
+
if (centrality) console.log(`📊 Centrality: ranked ${cen.total} connected symbols (PageRank, ${cen.iters} iters).`);
|
|
414
|
+
// A2 v2: precise cross-file references (find_references `resolved` tier), serialized
|
|
415
|
+
// as an isolated artifact alongside the graph.
|
|
416
|
+
if (scipReferers) {
|
|
417
|
+
const pairs = Object.values(scipReferers).reduce((s, a) => s + a.length, 0);
|
|
418
|
+
console.log(`🎯 SCIP references: ${Object.keys(scipReferers).length} definitions referenced (${pairs} precise referer pairs).`);
|
|
419
|
+
}
|
|
420
|
+
} finally {
|
|
421
|
+
try { fs.unlinkSync(tmpSg); } catch { /* none */ }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// C2: opt-in index-time taint serialization. Compute the full flow set once — against the
|
|
426
|
+
// symbol-graph edges when present (cross-function precision), else chunk.calls — and serialize
|
|
427
|
+
// it so trace_taint / find_tainted_sinks answer instantly. Like edges/centrality: computed once
|
|
428
|
+
// → both backends serve byte-identical flows (parity-free).
|
|
429
|
+
let taintPayload = null;
|
|
430
|
+
if (config.taint) {
|
|
431
|
+
const tmpT = path.join(config.dataDir, '_taint-build.json');
|
|
432
|
+
try {
|
|
433
|
+
fs.writeFileSync(tmpT, JSON.stringify({
|
|
434
|
+
chunks: indexData.chunks, graph: indexData.graph,
|
|
435
|
+
...(symbolEdges ? { edges: symbolEdges } : {}),
|
|
436
|
+
}));
|
|
437
|
+
const { MemoryGraphIndex } = await import('./engine/memory.mjs');
|
|
438
|
+
const { computeTaintCache } = await import('./mcp/taint.mjs');
|
|
439
|
+
const tIdx = new MemoryGraphIndex(tmpT, { cacheEmbeddings: false });
|
|
440
|
+
tIdx.load();
|
|
441
|
+
taintPayload = computeTaintCache(tIdx, { maxDepth: 4 });
|
|
442
|
+
tIdx.close?.();
|
|
443
|
+
console.log(`🧪 Taint: ${taintPayload.flows.length} flows precomputed `
|
|
444
|
+
+ `(${taintPayload.meta.sources} sources, ${taintPayload.meta.sinks} sinks, depth ${taintPayload.meta.maxDepth}).`);
|
|
445
|
+
} finally {
|
|
446
|
+
try { fs.unlinkSync(tmpT); } catch { /* none */ }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// B3: opt-in learned-sparse model. Learn the corpus co-occurrence (PMI) association model once
|
|
451
|
+
// and serialize it (isolated artifact, like centrality/taint); searchHybrid then adds an NL-only
|
|
452
|
+
// vocabulary-expansion channel. Zero new dependency, fully air-gapped; default index
|
|
453
|
+
// byte-identical (no model unless --learned-sparse).
|
|
454
|
+
let sparseModel = null;
|
|
455
|
+
if (config.learnedSparse) {
|
|
456
|
+
const { buildSparseModel } = await import('./search-sparse.mjs');
|
|
457
|
+
sparseModel = buildSparseModel(indexData.chunks);
|
|
458
|
+
if (sparseModel) {
|
|
459
|
+
console.log(`🧩 Learned-sparse: ${sparseModel.meta.terms} terms · ${sparseModel.meta.pairs} associations `
|
|
460
|
+
+ `(PMI over ${sparseModel.meta.docs} chunks).`);
|
|
461
|
+
} else {
|
|
462
|
+
console.log('🧩 Learned-sparse: no association signal in this corpus — model omitted (channel inert).');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Resolve 'auto' now that the true chunk count is known — the threshold is only
|
|
467
|
+
// meaningful after all chunks have been extracted.
|
|
468
|
+
const backend = config.storage === 'auto'
|
|
469
|
+
? (indexData.chunks.length >= AUTO_SQLITE_CHUNK_THRESHOLD ? 'sqlite' : 'memory')
|
|
470
|
+
: config.storage;
|
|
471
|
+
if (config.storage === 'auto') {
|
|
472
|
+
console.log(`🗄 Storage: auto → ${backend} (${indexData.chunks.length} chunks, threshold ${AUTO_SQLITE_CHUNK_THRESHOLD}).`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (backend === 'sqlite') {
|
|
476
|
+
const { SqliteGraphStore } = await import('./engine/sqlite.mjs');
|
|
171
477
|
const store = new SqliteGraphStore(config.sqlitePath, { embeddingPath: EMBEDDINGS_PATH });
|
|
172
478
|
const res = store.buildFrom({
|
|
173
479
|
chunks: indexData.chunks, graph: indexData.graph, embeddingCache: indexData.embeddingCache,
|
|
480
|
+
edges: symbolEdges, centrality, taint: taintPayload, scipRefs: scipReferers,
|
|
481
|
+
sparseModel,
|
|
174
482
|
});
|
|
483
|
+
store.close?.();
|
|
484
|
+
for (const p of [INDEX_PATH, `${INDEX_PATH}.tmp`]) { try { fs.unlinkSync(p); } catch { /* none */ } }
|
|
175
485
|
console.log(`\n🎉 SQLite index built: ${res.chunks} chunks · ${res.terms} terms · dim ${res.dim}`);
|
|
176
486
|
console.log(` → ${config.sqlitePath}\n`);
|
|
177
487
|
} else {
|
|
178
488
|
const tmpPath = `${INDEX_PATH}.tmp`;
|
|
179
489
|
const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
|
|
180
490
|
await Promise.all([
|
|
181
|
-
fs.promises.writeFile(tmpPath, JSON.stringify({
|
|
491
|
+
fs.promises.writeFile(tmpPath, JSON.stringify({
|
|
492
|
+
chunks: indexData.chunks, graph: indexData.graph,
|
|
493
|
+
...(symbolEdges ? { edges: symbolEdges } : {}),
|
|
494
|
+
...(centrality ? { centrality } : {}),
|
|
495
|
+
...(taintPayload ? { taint: taintPayload } : {}),
|
|
496
|
+
...(scipReferers ? { scip_refs: scipReferers } : {}),
|
|
497
|
+
...(sparseModel ? { sparse_model: sparseModel } : {}),
|
|
498
|
+
})),
|
|
182
499
|
fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
|
|
183
500
|
]);
|
|
184
501
|
await Promise.all([
|
|
185
502
|
fs.promises.rename(tmpPath, INDEX_PATH),
|
|
186
503
|
fs.promises.rename(tmpBinPath, EMBEDDINGS_PATH),
|
|
187
504
|
]);
|
|
188
|
-
|
|
505
|
+
for (const p of [config.sqlitePath, `${config.sqlitePath}-wal`, `${config.sqlitePath}-shm`]) {
|
|
506
|
+
try { fs.unlinkSync(p); } catch { /* none */ }
|
|
507
|
+
}
|
|
508
|
+
if (indexData.chunks.length === 0) {
|
|
509
|
+
console.log(`\n⚠️ Indexing finished but produced 0 chunks — no indexable definitions found under ${PROJECT_ROOT}.`);
|
|
510
|
+
console.log(` Check the --repo path is correct and contains supported languages (see README), and note that trivial one-line definitions are intentionally skipped.\n`);
|
|
511
|
+
} else {
|
|
512
|
+
console.log(`\n🎉 Indexing complete. Total fragments: ${indexData.chunks.length}\n`);
|
|
513
|
+
}
|
|
189
514
|
}
|
|
515
|
+
|
|
516
|
+
// Stamp which model produced the shared embeddings bin, so the server queries
|
|
517
|
+
// with the same provider and the next index run detects a model switch.
|
|
518
|
+
writeEmbedMeta(EMBEDDINGS_PATH, {
|
|
519
|
+
provider: embedder.provider,
|
|
520
|
+
model: embedder.model,
|
|
521
|
+
dim: embedder.dim ?? prevMeta?.dim ?? null,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Git signals are a sidecar kept out of the main index so they don't affect
|
|
525
|
+
// measured retrieval ranking — only opt-in tools consume them.
|
|
526
|
+
if (config.gitSignals) {
|
|
527
|
+
const signals = collectGitSignals(PROJECT_ROOT);
|
|
528
|
+
if (signals) {
|
|
529
|
+
writeGitSignals(config.gitSignalsPath, signals);
|
|
530
|
+
console.log(`🔄 Git signals: ${signals.commits} commits · ${Object.keys(signals.churn).length} files (churn/recency/co-change).\n`);
|
|
531
|
+
} else {
|
|
532
|
+
// Drop any stale sidecar so signals from a previous run don't linger.
|
|
533
|
+
try { fs.unlinkSync(config.gitSignalsPath); } catch { /* none to remove */ }
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Kill any subprocess-based embedder (MLX) so the event loop can drain.
|
|
538
|
+
// Without this the readline interface on proc.stdout keeps Node alive indefinitely.
|
|
539
|
+
_resetSubprocesses();
|
|
190
540
|
}
|
|
191
541
|
|
|
192
|
-
main().catch(
|
|
542
|
+
main().catch((err) => {
|
|
543
|
+
if (err && err.name === 'SealViolation') { console.error(`🔒 ${err.message}`); process.exit(2); }
|
|
544
|
+
console.error(err);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
});
|