graph-indexer 2.0.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 +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
package/enrichment.mjs
CHANGED
|
@@ -280,6 +280,101 @@ export async function rerankResults(queryText, results, { generate, topM = 8 } =
|
|
|
280
280
|
return [...picked, ...rest, ...results.slice(topM)];
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// ─── Cross-encoder reranker (local, air-gapped, deterministic) ───────────────
|
|
284
|
+
// A second, opt-in reranker provider (rerank.provider === 'cross-encoder'). Unlike the
|
|
285
|
+
// generative judge — which prompts an LLM for a permutation — a cross-encoder SCORES each
|
|
286
|
+
// (query, candidate) PAIR with a small sequence-classification model and sorts by score.
|
|
287
|
+
// It is local (runs through the optional @huggingface/transformers package, the same dep as
|
|
288
|
+
// the in-process 'local' embedder), needs no Ollama/daemon, is deterministic, and is fast
|
|
289
|
+
// (~tens of ms for a top-12 pool on CPU). It sees only a candidate's signature/summary line,
|
|
290
|
+
// not full code — so it sharpens lexical-vs-semantic near-ties rather than reasoning about
|
|
291
|
+
// behaviour the way a 7B judge can. The generative judge stays as the higher-reasoning tier.
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One-line textual representation of a chunk to pair against the query. Mirrors the
|
|
295
|
+
* generative judge's candidate line (name + class + summary→docstring→snippet) so both
|
|
296
|
+
* rerankers judge comparable evidence.
|
|
297
|
+
*/
|
|
298
|
+
export function crossEncoderCandidateText(chunk) {
|
|
299
|
+
const ctx = chunk.class_context ? `${chunk.class_context}.` : '';
|
|
300
|
+
const desc = chunk.summary
|
|
301
|
+
|| (chunk.docstring || '').split('\n')[0]
|
|
302
|
+
|| (chunk.code_snippet || '').split('\n').slice(0, 3).join(' ');
|
|
303
|
+
return `${ctx}${chunk.name || ''} — ${(desc || '').slice(0, 300)}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Lazy, module-level singleton model+tokenizer (mirrors embeddings.mjs:_localPipeline). The
|
|
307
|
+
// import is inside the function so the default path never loads @huggingface/transformers.
|
|
308
|
+
let _xenc = null, _xencModel = null;
|
|
309
|
+
async function _crossEncoderPipeline(model) {
|
|
310
|
+
if (_xenc && _xencModel === model) return _xenc;
|
|
311
|
+
let mod;
|
|
312
|
+
try { mod = await import('@huggingface/transformers'); }
|
|
313
|
+
catch {
|
|
314
|
+
throw new Error(
|
|
315
|
+
"Cross-encoder rerank needs the optional '@huggingface/transformers' package. "
|
|
316
|
+
+ "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
|
|
317
|
+
+ "or set rerank.provider to 'generative'."
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const m = await mod.AutoModelForSequenceClassification.from_pretrained(model, { quantized: true });
|
|
321
|
+
const t = await mod.AutoTokenizer.from_pretrained(model);
|
|
322
|
+
_xenc = { model: m, tokenizer: t };
|
|
323
|
+
_xencModel = model;
|
|
324
|
+
return _xenc;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Reset the cached cross-encoder pipeline (tests). */
|
|
328
|
+
export function _resetCrossEncoderPipeline() { _xenc = null; _xencModel = null; }
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Score each candidate text against the query with the cross-encoder. Returns one
|
|
332
|
+
* relevance float per text, in input order (higher = more relevant).
|
|
333
|
+
*
|
|
334
|
+
* @param {string} query
|
|
335
|
+
* @param {string[]} texts
|
|
336
|
+
* @param {object} [opts]
|
|
337
|
+
* @param {string} [opts.model] HF model id (default a MS-MARCO MiniLM cross-encoder).
|
|
338
|
+
* @returns {Promise<number[]>}
|
|
339
|
+
*/
|
|
340
|
+
export async function crossEncoderScore(query, texts, { model = 'Xenova/ms-marco-MiniLM-L-6-v2' } = {}) {
|
|
341
|
+
if (!texts || texts.length === 0) return [];
|
|
342
|
+
const { model: m, tokenizer: t } = await _crossEncoderPipeline(model);
|
|
343
|
+
const inputs = t(Array(texts.length).fill(query), { text_pair: texts, padding: true, truncation: true });
|
|
344
|
+
const { logits } = await m(inputs);
|
|
345
|
+
return logits.tolist().map(row => row[0]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Cross-encoder rerank: score the leading topM candidates as (query, text) pairs, sort
|
|
350
|
+
* DESC by score with ties broken on chunk id (so the reorder is deterministic and both
|
|
351
|
+
* backends rank identically), and preserve the unscored tail. Best-effort — any scorer
|
|
352
|
+
* failure or malformed output returns the original order untouched. Never mutates the
|
|
353
|
+
* fused `score` on a result (the git-boost branch downstream relies on that).
|
|
354
|
+
*
|
|
355
|
+
* @param {string} queryText
|
|
356
|
+
* @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
|
|
357
|
+
* @param {object} opts
|
|
358
|
+
* @param {(query:string, texts:string[])=>Promise<number[]>} opts.scorer Injectable (tests).
|
|
359
|
+
* @param {number} [opts.topM] How many leading results to rerank (default 12).
|
|
360
|
+
* @returns {Promise<Array<{score:number, chunk:object}>>}
|
|
361
|
+
*/
|
|
362
|
+
export async function rerankCrossEncoder(queryText, results, { scorer, topM = 12 } = {}) {
|
|
363
|
+
const head = results.slice(0, topM);
|
|
364
|
+
if (head.length < 2) return results;
|
|
365
|
+
let scores;
|
|
366
|
+
try { scores = await scorer(queryText, head.map(r => crossEncoderCandidateText(r.chunk))); }
|
|
367
|
+
catch { return results; }
|
|
368
|
+
if (!Array.isArray(scores) || scores.length !== head.length || scores.some(s => !Number.isFinite(s))) {
|
|
369
|
+
return results;
|
|
370
|
+
}
|
|
371
|
+
const order = head
|
|
372
|
+
.map((r, i) => ({ i, s: scores[i], id: r.chunk.id }))
|
|
373
|
+
.sort((a, b) => (b.s - a.s) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
374
|
+
.map(x => x.i);
|
|
375
|
+
return [...order.map(i => head[i]), ...results.slice(topM)];
|
|
376
|
+
}
|
|
377
|
+
|
|
283
378
|
/**
|
|
284
379
|
* Select the chunks worth enriching.
|
|
285
380
|
*
|
package/indexer.mjs
CHANGED
|
@@ -13,19 +13,99 @@
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { MAX_FILE_SIZE_BYTES, buildIgnoreFilter, extractImportsFromAST, extractSemanticChunks } from './parse/extractor.mjs';
|
|
16
|
-
import { EXTENSIONS, getParserForFile } from './parse/languages.mjs';
|
|
16
|
+
import { EXTENSIONS, getParserForFile, ensureLanguagesReady } from './parse/languages.mjs';
|
|
17
17
|
import { extractRoutes } from './parse/routes.mjs';
|
|
18
18
|
import { resolveLocalImports, buildEmbeddingPayload, fullBodyForEmbedding } from './parse/imports.mjs';
|
|
19
19
|
import { readEmbeddingBinary, writeEmbeddingBinary } from './engine/binary.mjs';
|
|
20
20
|
import { embeddingKeyFor, summaryEmbeddingText, SUMMARY_VEC_SUFFIX, WINDOW_VEC_SUFFIX, embeddingWindows } from './search-core.mjs';
|
|
21
|
-
import { createEmbedder, describeEmbedder, readEmbedMeta, writeEmbedMeta, _resetSubprocesses } from './embeddings.mjs';
|
|
21
|
+
import { createEmbedder, describeEmbedder, readEmbedMeta, writeEmbedMeta, embedModelChanged, _resetSubprocesses } from './embeddings.mjs';
|
|
22
22
|
import { resolveConfig, describeConfig, configNotices } from './config.mjs';
|
|
23
23
|
import { AUTO_SQLITE_CHUNK_THRESHOLD } from './storage.mjs';
|
|
24
24
|
import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
|
|
25
25
|
import { enrichCoreChunks } from './enrichment.mjs';
|
|
26
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
|
+
}
|
|
27
108
|
|
|
28
|
-
const config = resolveConfig();
|
|
29
109
|
const PROJECT_ROOT = config.projectRoot;
|
|
30
110
|
const INDEX_PATH = config.indexPath;
|
|
31
111
|
const EMBEDDINGS_PATH = config.embeddingPath;
|
|
@@ -57,6 +137,30 @@ async function main() {
|
|
|
57
137
|
for (const notice of configNotices(config)) console.log(`⚠️ ${notice}`);
|
|
58
138
|
console.log('');
|
|
59
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
|
+
}
|
|
163
|
+
|
|
60
164
|
const ig = buildIgnoreFilter(PROJECT_ROOT);
|
|
61
165
|
const files = walkRepo(PROJECT_ROOT, PROJECT_ROOT, ig);
|
|
62
166
|
console.log(`Found ${files.length} files to analyse.\n`);
|
|
@@ -70,16 +174,17 @@ async function main() {
|
|
|
70
174
|
if (embedder.provider === 'local') {
|
|
71
175
|
console.log(` ↪ Ollama not reachable at ${config.ollamaHost}; using the bundled in-process model (no daemon required).`);
|
|
72
176
|
} else if (embedder.provider === 'off') {
|
|
73
|
-
console.log(` ↪ No Ollama and no in-process model installed (\`npm
|
|
177
|
+
console.log(` ↪ No Ollama and no in-process model installed (\`npm run embed:setup:local\`) — indexing lexical-only.`);
|
|
74
178
|
}
|
|
75
179
|
}
|
|
76
180
|
console.log('');
|
|
77
181
|
|
|
78
182
|
let existingCache = readEmbeddingBinary(EMBEDDINGS_PATH);
|
|
79
183
|
// A model switch invalidates the cached vectors — vectors of different models
|
|
80
|
-
// (and dims) must never be mixed in one bin. Re-embed from scratch.
|
|
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.)
|
|
81
186
|
const prevMeta = readEmbedMeta(EMBEDDINGS_PATH);
|
|
82
|
-
if (prevMeta
|
|
187
|
+
if (embedModelChanged(prevMeta, embedder.model)) {
|
|
83
188
|
console.log(`⚠️ Embedding model changed (${prevMeta.model} → ${embedder.model}); re-embedding from scratch.`);
|
|
84
189
|
existingCache = new Map();
|
|
85
190
|
}
|
|
@@ -116,7 +221,7 @@ async function main() {
|
|
|
116
221
|
const imports = resolveLocalImports(rawImports, relPath, PROJECT_ROOT);
|
|
117
222
|
indexData.graph.dependencies[relPath] = imports;
|
|
118
223
|
|
|
119
|
-
const fileChunks = extractSemanticChunks(tree.rootNode, relPath, content, ext);
|
|
224
|
+
const fileChunks = extractSemanticChunks(tree.rootNode, relPath, content, ext, { interprocedural: config.interprocedural });
|
|
120
225
|
for (const chunk of fileChunks) {
|
|
121
226
|
const fullBody = fullBodyForEmbedding(chunk, content);
|
|
122
227
|
if (fullBody) fullBodies.set(chunk.id, fullBody);
|
|
@@ -131,6 +236,17 @@ async function main() {
|
|
|
131
236
|
}
|
|
132
237
|
}
|
|
133
238
|
|
|
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
|
+
|
|
134
250
|
// Enrichment runs before embedding so hypothetical questions share the same vector space.
|
|
135
251
|
if (config.enrichment.enabled) {
|
|
136
252
|
await enrichCoreChunks(pendingChunks, indexData.graph, config);
|
|
@@ -231,6 +347,122 @@ async function main() {
|
|
|
231
347
|
}
|
|
232
348
|
}
|
|
233
349
|
|
|
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
|
+
|
|
234
466
|
// Resolve 'auto' now that the true chunk count is known — the threshold is only
|
|
235
467
|
// meaningful after all chunks have been extracted.
|
|
236
468
|
const backend = config.storage === 'auto'
|
|
@@ -245,6 +477,8 @@ async function main() {
|
|
|
245
477
|
const store = new SqliteGraphStore(config.sqlitePath, { embeddingPath: EMBEDDINGS_PATH });
|
|
246
478
|
const res = store.buildFrom({
|
|
247
479
|
chunks: indexData.chunks, graph: indexData.graph, embeddingCache: indexData.embeddingCache,
|
|
480
|
+
edges: symbolEdges, centrality, taint: taintPayload, scipRefs: scipReferers,
|
|
481
|
+
sparseModel,
|
|
248
482
|
});
|
|
249
483
|
store.close?.();
|
|
250
484
|
for (const p of [INDEX_PATH, `${INDEX_PATH}.tmp`]) { try { fs.unlinkSync(p); } catch { /* none */ } }
|
|
@@ -254,7 +488,14 @@ async function main() {
|
|
|
254
488
|
const tmpPath = `${INDEX_PATH}.tmp`;
|
|
255
489
|
const tmpBinPath = `${EMBEDDINGS_PATH}.tmp`;
|
|
256
490
|
await Promise.all([
|
|
257
|
-
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
|
+
})),
|
|
258
499
|
fs.promises.writeFile(tmpBinPath, writeEmbeddingBinary(indexData.embeddingCache)),
|
|
259
500
|
]);
|
|
260
501
|
await Promise.all([
|
|
@@ -298,4 +539,8 @@ async function main() {
|
|
|
298
539
|
_resetSubprocesses();
|
|
299
540
|
}
|
|
300
541
|
|
|
301
|
-
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
|
+
});
|
package/init.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from './layout.mjs';
|
|
34
34
|
import { readPid, isAlive } from './daemon-lock.mjs';
|
|
35
35
|
import { ensureMlxEnv, mlxEnvReady, mlxVenvPython, mlxVenvDir } from './embedders/setup-mlx.mjs';
|
|
36
|
+
import { localEmbedAvailable } from './embeddings.mjs';
|
|
36
37
|
|
|
37
38
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
39
|
|
|
@@ -1359,6 +1360,7 @@ runMigration();
|
|
|
1359
1360
|
|
|
1360
1361
|
|
|
1361
1362
|
stepHeader(1, 'Languages to index');
|
|
1363
|
+
log(c.dim(' Each language\'s Tree-sitter grammar installs on first index build (not bundled — keeps a fresh install small); already-installed grammars are reused.'));
|
|
1362
1364
|
|
|
1363
1365
|
let selectedLanguages = null;
|
|
1364
1366
|
if (isInteractive) {
|
|
@@ -1375,6 +1377,7 @@ if (selectedLanguages) {
|
|
|
1375
1377
|
} else {
|
|
1376
1378
|
line(glyph.ok, 'Languages', 'all supported (default)');
|
|
1377
1379
|
if (detectedLanguages.size > 0) log(c.dim(` detected: ${Array.from(detectedLanguages).join(', ')}`));
|
|
1380
|
+
log(c.dim(' selecting a subset above installs only those grammars — narrower is faster/smaller'));
|
|
1378
1381
|
}
|
|
1379
1382
|
|
|
1380
1383
|
|
|
@@ -1448,9 +1451,9 @@ let mlxServerReady = null;
|
|
|
1448
1451
|
|
|
1449
1452
|
const engineItems = [
|
|
1450
1453
|
{ key: 'off', label: 'Lexical only', desc: c.dim('default · keyword/symbol + stemming · no vectors, no dependencies') },
|
|
1451
|
-
{ key: 'auto', label: 'Auto', desc: c.dim('Ollama if running, else a bundled local model
|
|
1454
|
+
{ key: 'auto', label: 'Auto', desc: c.dim('Ollama if running, else offers to install a bundled local model') },
|
|
1452
1455
|
{ key: 'ollama', label: 'Ollama', desc: c.dim('highest quality · needs the Ollama app + a pulled model') },
|
|
1453
|
-
{ key: 'local', label: 'Local (in-process)', desc: c.dim('no daemon ·
|
|
1456
|
+
{ key: 'local', label: 'Local (in-process)', desc: c.dim('no daemon · installs @huggingface/transformers (~50 MB) once + a ~25 MB model on first index') },
|
|
1454
1457
|
];
|
|
1455
1458
|
// MLX runs on the Apple Metal GPU and is macOS-only; only offer it there.
|
|
1456
1459
|
if (process.platform === 'darwin') {
|
|
@@ -1503,6 +1506,26 @@ let mlxServerReady = null;
|
|
|
1503
1506
|
}
|
|
1504
1507
|
}
|
|
1505
1508
|
|
|
1509
|
+
// Local (in-process) embeddings: the @huggingface/transformers + ONNX runtime
|
|
1510
|
+
// stack (100s of MB) is a devDependency, NOT shipped to end users by default
|
|
1511
|
+
// (npm's `optionalDependencies` installs eagerly regardless of "optional" in
|
|
1512
|
+
// its name — the opposite of what we want here), so it has to be installed on
|
|
1513
|
+
// demand. Same "provision now" pattern as MLX above. `auto` only needs this
|
|
1514
|
+
// when Ollama actually isn't reachable, so its "no setup" promise stays true
|
|
1515
|
+
// for anyone who already has Ollama running.
|
|
1516
|
+
if (embedProvider === 'local' || (embedProvider === 'auto' && ollamaProbed && !models)) {
|
|
1517
|
+
if (await localEmbedAvailable()) {
|
|
1518
|
+
line(glyph.ok, 'Local embeddings', '@huggingface/transformers installed');
|
|
1519
|
+
} else if (localDep && await confirm({ label: 'Install the local embedding package now? (npm i @huggingface/transformers, ~50 MB)', def: true })) {
|
|
1520
|
+
log(c.dim(' installing @huggingface/transformers…'));
|
|
1521
|
+
const res = spawnSync(NPM_BIN, ['install', '@huggingface/transformers'], { cwd: PROJECT_ROOT, stdio: 'inherit' });
|
|
1522
|
+
if (res.status === 0) line(glyph.ok, 'Local embeddings', 'installed');
|
|
1523
|
+
else line(glyph.warn, 'Local embeddings', `install failed — run ${c.cyan('npm i @huggingface/transformers')} manually`);
|
|
1524
|
+
} else {
|
|
1525
|
+
line(glyph.skip, 'Local embeddings', `install later with ${c.cyan('npm i @huggingface/transformers')}${localDep ? '' : ' in your project'}`);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1506
1529
|
const enrichEnabled = await confirm({ label: 'Enable LLM enrichment? (richer semantics, slower indexing)', def: Boolean(exEnrich.enabled) });
|
|
1507
1530
|
|
|
1508
1531
|
// Show a data-driven warning before the reranker question when the detected
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file mcp/centrality.mjs
|
|
3
|
+
* @description Symbol-level centrality (A5): confidence-weighted PageRank over the A4
|
|
4
|
+
* resolved chunk→chunk symbol graph. A definition referenced (called / extended /
|
|
5
|
+
* used as a type) by many *other central* symbols scores high — it is a hub the
|
|
6
|
+
* rest of the program leans on.
|
|
7
|
+
*
|
|
8
|
+
* This is DISTINCT from `computePageRank` in search-core.mjs, which ranks FILES
|
|
9
|
+
* over the import graph (and drives enrichment selection + get_repo_map file
|
|
10
|
+
* ordering). A5 is symbol-granular and call/type-aware, not import-aware.
|
|
11
|
+
*
|
|
12
|
+
* Like the edges themselves, it is computed ONCE at index time and serialized into
|
|
13
|
+
* the index, so both backends serve byte-identical scores/ranks — parity is free,
|
|
14
|
+
* and it never touches search ranking. Opt-in: only built when --symbol-graph is on.
|
|
15
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
16
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
17
|
+
* @license MIT
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-confidence edge weight. A `name_only` edge is a heuristic name match (no receiver type
|
|
22
|
+
* / no import evidence) and is noisier than a `high` edge, so it contributes half the flow.
|
|
23
|
+
* The A1 `resolved` tier is a *precision* refinement of `high` (a provably-unambiguous binding),
|
|
24
|
+
* so it carries the SAME weight — enabling the precise resolver does not perturb centrality.
|
|
25
|
+
* All deterministic constants — no tuning knob, no config surface.
|
|
26
|
+
*/
|
|
27
|
+
const CONFIDENCE_WEIGHT = { resolved: 1.0, high: 1.0, name_only: 0.5 };
|
|
28
|
+
|
|
29
|
+
const DAMPING = 0.85;
|
|
30
|
+
const MAX_ITERS = 100; // hard safety net; sparse symbol graphs converge in ~20–40
|
|
31
|
+
const TOLERANCE = 1e-12; // L1 delta convergence
|
|
32
|
+
const SCORE_DECIMALS = 8; // round before serialization → identical bytes both backends
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Confidence-weighted PageRank over the resolved symbol graph.
|
|
36
|
+
*
|
|
37
|
+
* Deterministic: nodes are the sorted set of edge endpoints, multi-edges between the same
|
|
38
|
+
* (from→to) pair sum their confidence weights, iteration walks nodes/adjacency in fixed order,
|
|
39
|
+
* and scores are rounded to a fixed precision. Ranks are a dense ordering by score DESC, then
|
|
40
|
+
* chunk-id ASC (the same tie-break the rest of the engine uses). The result is serialized, so
|
|
41
|
+
* cross-backend parity is by construction — both engines read these exact numbers.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array<{from_chunk_id:string,to_chunk_id:string,kind:string,confidence:string}>} edges
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {number} [opts.damping]
|
|
46
|
+
* @param {number} [opts.maxIters]
|
|
47
|
+
* @param {number} [opts.tolerance]
|
|
48
|
+
* @returns {{ centrality: Record<string,{score:number,rank:number}>, total:number, iters:number }}
|
|
49
|
+
* `centrality` is keyed by chunk id; `total` is the number of ranked (connected) chunks.
|
|
50
|
+
*/
|
|
51
|
+
export function computeSymbolCentrality(edges, {
|
|
52
|
+
damping = DAMPING, maxIters = MAX_ITERS, tolerance = TOLERANCE,
|
|
53
|
+
} = {}) {
|
|
54
|
+
// ── Node set = every chunk that appears in ANY edge (the connected sub-graph). Isolated
|
|
55
|
+
// chunks are not ranked — "rank N of M connected symbols" stays meaningful. ──────────
|
|
56
|
+
const nodeSet = new Set();
|
|
57
|
+
for (const e of (edges || [])) { nodeSet.add(e.from_chunk_id); nodeSet.add(e.to_chunk_id); }
|
|
58
|
+
const nodes = [...nodeSet].sort();
|
|
59
|
+
const N = nodes.length;
|
|
60
|
+
if (N === 0) return { centrality: {}, total: 0, iters: 0 };
|
|
61
|
+
|
|
62
|
+
const index = new Map();
|
|
63
|
+
nodes.forEach((id, i) => index.set(id, i));
|
|
64
|
+
|
|
65
|
+
// ── Aggregate edge weights by (from → to), summing confidence weights for multi-edges
|
|
66
|
+
// (a chunk that both calls AND extends a target flows more) → adjacency sorted by `to`
|
|
67
|
+
// index for deterministic float accumulation. ────────────────────────────────────────
|
|
68
|
+
const outMaps = nodes.map(() => new Map()); // i → Map(j → summed weight)
|
|
69
|
+
for (const e of (edges || [])) {
|
|
70
|
+
const i = index.get(e.from_chunk_id);
|
|
71
|
+
const j = index.get(e.to_chunk_id);
|
|
72
|
+
const w = CONFIDENCE_WEIGHT[e.confidence] ?? CONFIDENCE_WEIGHT.name_only;
|
|
73
|
+
outMaps[i].set(j, (outMaps[i].get(j) || 0) + w);
|
|
74
|
+
}
|
|
75
|
+
const adj = new Array(N); // i → [[j, weight], …] sorted by j
|
|
76
|
+
const outWeight = new Float64Array(N);
|
|
77
|
+
for (let i = 0; i < N; i++) {
|
|
78
|
+
const arr = [...outMaps[i].entries()].sort((a, b) => a[0] - b[0]);
|
|
79
|
+
let tot = 0;
|
|
80
|
+
for (const [, w] of arr) tot += w;
|
|
81
|
+
adj[i] = arr;
|
|
82
|
+
outWeight[i] = tot;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Power iteration. Dangling nodes (no out-edges) redistribute their mass uniformly so
|
|
86
|
+
// the rank vector stays a probability distribution. ────────────────────────────────────
|
|
87
|
+
let rank = new Float64Array(N).fill(1 / N);
|
|
88
|
+
const teleport = (1 - damping) / N;
|
|
89
|
+
let iters = 0;
|
|
90
|
+
for (; iters < maxIters; iters++) {
|
|
91
|
+
let dangling = 0;
|
|
92
|
+
for (let i = 0; i < N; i++) if (outWeight[i] === 0) dangling += rank[i];
|
|
93
|
+
const danglingShare = damping * dangling / N;
|
|
94
|
+
|
|
95
|
+
const next = new Float64Array(N);
|
|
96
|
+
for (let i = 0; i < N; i++) next[i] = teleport + danglingShare;
|
|
97
|
+
for (let i = 0; i < N; i++) {
|
|
98
|
+
if (outWeight[i] === 0) continue;
|
|
99
|
+
const share = damping * rank[i] / outWeight[i];
|
|
100
|
+
for (const [j, w] of adj[i]) next[j] += share * w;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let delta = 0;
|
|
104
|
+
for (let i = 0; i < N; i++) delta += Math.abs(next[i] - rank[i]);
|
|
105
|
+
rank = next;
|
|
106
|
+
if (delta < tolerance) { iters++; break; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Round, then assign dense ranks by score DESC / id ASC. ───────────────────────────────
|
|
110
|
+
const round = 10 ** SCORE_DECIMALS;
|
|
111
|
+
const scored = nodes.map((id, i) => ({ id, score: Math.round(rank[i] * round) / round }));
|
|
112
|
+
scored.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
113
|
+
|
|
114
|
+
const centrality = {};
|
|
115
|
+
scored.forEach((s, i) => { centrality[s.id] = { score: s.score, rank: i + 1 }; });
|
|
116
|
+
return { centrality, total: N, iters };
|
|
117
|
+
}
|