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.
Files changed (70) hide show
  1. package/CHANGELOG.md +854 -0
  2. package/README.md +258 -595
  3. package/SECURITY.md +85 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/colbert.mjs +168 -0
  7. package/config.mjs +408 -32
  8. package/daemon-ctl.mjs +175 -0
  9. package/daemon-lock.mjs +69 -0
  10. package/embedders/python/mlx_embed_server.py +61 -0
  11. package/embedders/python/requirements-mlx.txt +5 -0
  12. package/embedders/setup-mlx.mjs +110 -0
  13. package/embeddings.mjs +571 -0
  14. package/engine/binary.mjs +437 -0
  15. package/{core-engine.mjs → engine/memory.mjs} +321 -441
  16. package/{sqlite-store.mjs → engine/sqlite.mjs} +413 -45
  17. package/enrichment.mjs +206 -32
  18. package/git-signals.mjs +248 -0
  19. package/indexer.mjs +399 -45
  20. package/init.mjs +1626 -171
  21. package/layout.mjs +123 -0
  22. package/mcp/centrality.mjs +117 -0
  23. package/mcp/format.mjs +61 -0
  24. package/mcp/resolver.mjs +116 -0
  25. package/mcp/symbolgraph.mjs +137 -0
  26. package/mcp/taint.mjs +225 -0
  27. package/mcp/tools.mjs +1582 -0
  28. package/mcp/topology.mjs +676 -0
  29. package/mcp-server.mjs +80 -25
  30. package/package.json +66 -27
  31. package/parse/extractor.mjs +628 -0
  32. package/parse/imports.mjs +365 -0
  33. package/parse/interprocedural.mjs +134 -0
  34. package/parse/languages.mjs +253 -0
  35. package/parse/metadata.mjs +701 -0
  36. package/parse/routes.mjs +327 -0
  37. package/parse/scip.mjs +391 -0
  38. package/parse/taint-patterns.mjs +119 -0
  39. package/prompts/CORE.md +91 -0
  40. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  41. package/prompts/INTEGRATION.md +123 -0
  42. package/prompts/frameworks/ANDROID.md +17 -0
  43. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  44. package/prompts/frameworks/FASTAPI_DJANGO.md +20 -0
  45. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  46. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +19 -0
  47. package/prompts/frameworks/RAILS.md +20 -0
  48. package/prompts/frameworks/REACT.md +20 -0
  49. package/prompts/frameworks/SPRING_BOOT.md +20 -0
  50. package/prompts/languages/BASH.md +22 -0
  51. package/prompts/languages/C.md +21 -0
  52. package/prompts/languages/CSHARP.md +24 -0
  53. package/prompts/languages/CSS_SCSS.md +22 -0
  54. package/prompts/languages/GO.md +24 -0
  55. package/prompts/languages/JAVA.md +23 -0
  56. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  57. package/prompts/languages/KOTLIN.md +24 -0
  58. package/prompts/languages/PHP.md +23 -0
  59. package/prompts/languages/PYTHON.md +23 -0
  60. package/prompts/languages/RUBY.md +23 -0
  61. package/prompts/languages/RUST.md +21 -0
  62. package/prompts/languages/SWIFT.md +21 -0
  63. package/seal.mjs +400 -0
  64. package/search-core.mjs +405 -44
  65. package/search-sparse.mjs +183 -0
  66. package/storage.mjs +33 -3
  67. package/watch-daemon.mjs +75 -24
  68. package/PROMPT.md +0 -182
  69. package/mcp-tools.mjs +0 -545
  70. package/parser-utils.mjs +0 -1170
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file sqlite-store.mjs
2
+ * @file engine/sqlite.mjs
3
3
  * @description Disk-backed graph store for enterprise-scale monorepos. Implements
4
4
  * the same read contract as MemoryGraphIndex (see storage.mjs) over
5
5
  * `node:sqlite` — built into Node, so this adds ZERO dependencies.
@@ -28,19 +28,21 @@
28
28
  import fs from 'fs';
29
29
  import {
30
30
  tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
31
- SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
- } from './search-core.mjs';
31
+ LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
+ isNaturalLanguageQuery, baseEmbeddingKey, scoreSparseExpanded,
33
+ } from '../search-core.mjs';
34
+ import { expandSparseQuery } from '../search-sparse.mjs';
33
35
  import {
34
36
  writeEmbeddingBinary, appendEmbeddingBinary, scanEmbeddingBinary,
35
- updateVectorSketch, searchVectorSketch,
36
- } from './core-engine.mjs';
37
+ updateVectorSketch, searchVectorSketch, SKETCH_THRESHOLD,
38
+ } from './binary.mjs';
37
39
 
38
40
  // node:sqlite is loaded lazily so the default in-memory path never requires it.
39
41
  let DatabaseSync = null;
40
42
  try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* reported in ctor */ }
41
43
 
42
- const SKETCH_THRESHOLD = 10000; // build the binary sketch above this many vectors.
43
- // Below it the exact streaming scan stays under ~20 ms
44
+ // SKETCH_THRESHOLD (shared with the memory store via binary.mjs) — build the binary
45
+ // sketch above this many vectors. Below it the exact streaming scan stays under ~20 ms
44
46
  // AND both backends rank identically (the sketch is
45
47
  // approximate in the candidate tail); above it the
46
48
  // sketch caps latency at ~5–15 ms regardless of size.
@@ -53,12 +55,28 @@ CREATE TABLE IF NOT EXISTS chunks (
53
55
  start_line INTEGER, end_line INTEGER,
54
56
  calls TEXT, params TEXT, return_type TEXT, class_context TEXT,
55
57
  type_refs TEXT, decorators TEXT, extends_ TEXT, hyde TEXT, summary TEXT, concepts TEXT,
56
- doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER
58
+ doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER,
59
+ call_sites TEXT
57
60
  );
58
61
  CREATE TABLE IF NOT EXISTS postings (term TEXT, chunk_id TEXT, tf INTEGER);
59
62
  CREATE TABLE IF NOT EXISTS terms (term TEXT PRIMARY KEY, df INTEGER);
60
63
  CREATE TABLE IF NOT EXISTS call_edges (callee TEXT, chunk_id TEXT);
64
+ CREATE TABLE IF NOT EXISTS edges (from_chunk_id TEXT, to_chunk_id TEXT, kind TEXT, confidence TEXT);
65
+ CREATE TABLE IF NOT EXISTS centrality (chunk_id TEXT, score REAL, rank INTEGER);
66
+ CREATE TABLE IF NOT EXISTS taint (id INTEGER PRIMARY KEY, payload TEXT);
67
+ CREATE TABLE IF NOT EXISTS scip_refs (id INTEGER PRIMARY KEY, payload TEXT);
68
+ CREATE TABLE IF NOT EXISTS sparse_model (id INTEGER PRIMARY KEY, payload TEXT);
61
69
  CREATE TABLE IF NOT EXISTS deps (file TEXT, dep TEXT);
70
+ CREATE TABLE IF NOT EXISTS routes (
71
+ id INTEGER PRIMARY KEY,
72
+ method TEXT NOT NULL,
73
+ path TEXT NOT NULL,
74
+ handler_name TEXT,
75
+ handler_chunk_id TEXT,
76
+ file_path TEXT NOT NULL,
77
+ line INTEGER,
78
+ framework TEXT
79
+ );
62
80
  `;
63
81
 
64
82
  const SCHEMA_INDEXES = `
@@ -69,15 +87,22 @@ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(content_hash);
69
87
  CREATE INDEX IF NOT EXISTS idx_postings_term ON postings(term);
70
88
  CREATE INDEX IF NOT EXISTS idx_postings_chunk ON postings(chunk_id);
71
89
  CREATE INDEX IF NOT EXISTS idx_calledges_callee ON call_edges(callee);
90
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_chunk_id);
91
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_chunk_id);
92
+ CREATE INDEX IF NOT EXISTS idx_centrality_chunk ON centrality(chunk_id);
93
+ CREATE INDEX IF NOT EXISTS idx_centrality_rank ON centrality(rank);
72
94
  CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(file);
73
95
  CREATE INDEX IF NOT EXISTS idx_deps_dep ON deps(dep);
96
+ CREATE INDEX IF NOT EXISTS idx_routes_method ON routes(method);
97
+ CREATE INDEX IF NOT EXISTS idx_routes_path ON routes(path);
98
+ CREATE INDEX IF NOT EXISTS idx_routes_handler ON routes(handler_chunk_id);
74
99
  `;
75
100
 
76
101
  const CHUNK_COLS = [
77
102
  'id', 'file_path', 'node_type', 'name', 'name_lower', 'docstring', 'code_snippet',
78
103
  'content_hash', 'start_line', 'end_line', 'calls', 'params', 'return_type',
79
104
  'class_context', 'type_refs', 'decorators', 'extends_', 'hyde', 'summary', 'concepts',
80
- 'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim',
105
+ 'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim', 'call_sites',
81
106
  ];
82
107
 
83
108
  function jsonArr(v) { return JSON.stringify(Array.isArray(v) ? v : []); }
@@ -114,6 +139,14 @@ export class SqliteGraphStore {
114
139
  this._dataVersion = -1;
115
140
  this._writeReady = false;
116
141
  this._sketch = null; // binary-quantized vector sketch (built above SKETCH_THRESHOLD)
142
+ this._hasEdges = false; // A4: resolved symbol graph present (opt-in)
143
+ this._hasCentrality = false; // A5: symbol centrality present (opt-in, built with the graph)
144
+ this._centralityTotal = 0;
145
+ this._hasTaint = false; // C2: serialized taint flows present (opt-in --taint)
146
+ this._hasScipRefs = false; // A2 v2: SCIP precise references present (opt-in --resolver scip)
147
+ this._scipRefsObj = null; // lazily-parsed `{ defId: [refId,…] }` payload (memoized)
148
+ this._hasSparseModel = false; // B3: learned-sparse model present (opt-in --learned-sparse)
149
+ this._sparseModelObj = null; // lazily-parsed `{ assoc: {…} }` payload (memoized)
117
150
  }
118
151
 
119
152
  get backend() { return 'sqlite'; }
@@ -145,17 +178,73 @@ export class SqliteGraphStore {
145
178
  // Pre-enrichment-key indexes stored vectors under the plain content hash.
146
179
  this.db.exec('UPDATE chunks SET vec_key = content_hash WHERE vec_key IS NULL');
147
180
  } catch { /* exists */ }
181
+ // Receiver-aware call graph (precise get_call_graph) landed later; older
182
+ // indexes lack the column. NULL → callers fall back to name-only grouping.
183
+ try { this.db.exec('ALTER TABLE chunks ADD COLUMN call_sites TEXT DEFAULT NULL'); } catch { /* exists */ }
148
184
  }
149
185
  this.db.exec(SCHEMA_INDEXES);
150
186
  this._reloadMeta();
187
+ // Does this corpus carry LLM enrichment? One cheap probe → drives the NL
188
+ // vector-channel weight in fuseAndRank (parity with the in-memory engine).
189
+ try {
190
+ this._corpusEnriched = Boolean(
191
+ this.db.prepare("SELECT 1 FROM chunks WHERE summary IS NOT NULL AND summary != '' LIMIT 1").get()
192
+ );
193
+ } catch { this._corpusEnriched = false; }
151
194
  this._loadGraph();
152
195
  this._openVecFd();
153
196
  this._refreshSketch();
154
197
  this._prepare();
198
+ this._refreshHasEdges();
199
+ this._refreshHasCentrality();
200
+ this._refreshHasTaint();
201
+ this._refreshHasScipRefs();
202
+ this._refreshHasSparseModel();
155
203
  this._dataVersion = this._readDataVersion();
156
204
  return this;
157
205
  }
158
206
 
207
+ /** Whether the opt-in resolved symbol graph (A4) has any edges. */
208
+ _refreshHasEdges() {
209
+ try { this._hasEdges = Boolean(this.db.prepare('SELECT 1 FROM edges LIMIT 1').get()); }
210
+ catch { this._hasEdges = false; }
211
+ }
212
+
213
+ /** Whether opt-in symbol centrality (A5) is present, and how many chunks are ranked. */
214
+ _refreshHasCentrality() {
215
+ try {
216
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM centrality').get();
217
+ this._centralityTotal = row ? row.n : 0;
218
+ this._hasCentrality = this._centralityTotal > 0;
219
+ } catch { this._hasCentrality = false; this._centralityTotal = 0; }
220
+ }
221
+
222
+ /** Whether opt-in serialized taint flows (C2 --taint) are present. */
223
+ _refreshHasTaint() {
224
+ try {
225
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM taint').get();
226
+ this._hasTaint = Boolean(row && row.n > 0);
227
+ } catch { this._hasTaint = false; }
228
+ }
229
+
230
+ /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are present. */
231
+ _refreshHasScipRefs() {
232
+ try {
233
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM scip_refs').get();
234
+ this._hasScipRefs = Boolean(row && row.n > 0);
235
+ } catch { this._hasScipRefs = false; }
236
+ this._scipRefsObj = null; // drop any memoized payload — the row may have changed
237
+ }
238
+
239
+ /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are present. */
240
+ _refreshHasSparseModel() {
241
+ try {
242
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM sparse_model').get();
243
+ this._hasSparseModel = Boolean(row && row.n > 0);
244
+ } catch { this._hasSparseModel = false; }
245
+ this._sparseModelObj = null; // drop any memoized payload — the row may have changed
246
+ }
247
+
159
248
  _openVecFd() {
160
249
  if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
161
250
  if (fs.existsSync(this._embeddingPath)) {
@@ -259,6 +348,12 @@ export class SqliteGraphStore {
259
348
  this._stmtCallers = this.db.prepare(
260
349
  'SELECT c.* FROM chunks c JOIN call_edges e ON e.chunk_id = c.id WHERE e.callee = ?'
261
350
  );
351
+ // type_refs / extends_ are JSON arrays of names. LIKE is a cheap prefilter
352
+ // (ASCII case-insensitive in SQLite); findReferers() verifies exact, quoted
353
+ // membership in JS so e.g. "User" never matches "UserProfile".
354
+ this._stmtReferers = this.db.prepare(
355
+ 'SELECT * FROM chunks WHERE type_refs LIKE ? ESCAPE \'\\\' OR extends_ LIKE ? ESCAPE \'\\\''
356
+ );
262
357
  this._stmtPosting = this.db.prepare(
263
358
  'SELECT p.chunk_id AS id, p.tf AS tf, c.doc_len AS doc_len '
264
359
  + 'FROM postings p JOIN chunks c ON c.id = p.chunk_id WHERE p.term = ?'
@@ -269,6 +364,33 @@ export class SqliteGraphStore {
269
364
  // ids in a deterministic order keeps both backends rank-identical on ties.
270
365
  this._stmtIdsByKey = this.db.prepare('SELECT id FROM chunks WHERE vec_key = ? ORDER BY id');
271
366
  this._stmtIdsByHash = this.db.prepare('SELECT id FROM chunks WHERE content_hash = ? ORDER BY id');
367
+ // ORDER BY id (rowid = insertion order) keeps routes in index-build order,
368
+ // matching the in-memory graph.routes array order for cross-backend parity.
369
+ this._stmtRoutes = this.db.prepare(
370
+ 'SELECT method, path, handler_name, handler_chunk_id, file_path, line, framework '
371
+ + 'FROM routes ORDER BY id'
372
+ );
373
+ // A4 symbol graph. ORDER mirrors the in-memory engine's per-endpoint slice of the
374
+ // globally-sorted edge list (fixed `to` → sort by from,kind,confidence; fixed `from`
375
+ // → sort by to,kind,confidence) so getEdges is byte-identical cross-backend.
376
+ this._stmtEdgesIn = this.db.prepare(
377
+ 'SELECT from_chunk_id, to_chunk_id, kind, confidence FROM edges WHERE to_chunk_id = ? '
378
+ + 'ORDER BY from_chunk_id, kind, confidence'
379
+ );
380
+ this._stmtEdgesOut = this.db.prepare(
381
+ 'SELECT from_chunk_id, to_chunk_id, kind, confidence FROM edges WHERE from_chunk_id = ? '
382
+ + 'ORDER BY to_chunk_id, kind, confidence'
383
+ );
384
+ // A5 symbol centrality. rank/score are precomputed at index time; topCentral orders by
385
+ // the stored rank (chunk_id tie-break mirrors the in-memory ranked cache).
386
+ this._stmtCentrality = this.db.prepare('SELECT score, rank FROM centrality WHERE chunk_id = ?');
387
+ this._stmtTopCentral = this.db.prepare(
388
+ 'SELECT chunk_id, score, rank FROM centrality ORDER BY rank ASC, chunk_id ASC LIMIT ?'
389
+ );
390
+ // C2 serialized taint flows — a single JSON payload row (id = 0).
391
+ this._stmtTaint = this.db.prepare('SELECT payload FROM taint WHERE id = 0');
392
+ this._stmtScipRefs = this.db.prepare('SELECT payload FROM scip_refs WHERE id = 0');
393
+ this._stmtSparseModel = this.db.prepare('SELECT payload FROM sparse_model WHERE id = 0');
272
394
  }
273
395
 
274
396
  _prepareWrite() {
@@ -312,7 +434,7 @@ export class SqliteGraphStore {
312
434
  return_type: row.return_type || '', class_context: row.class_context || '',
313
435
  type_refs: parseArr(row.type_refs), decorators: parseArr(row.decorators),
314
436
  extends: parseArr(row.extends_), hyde: row.hyde || '', summary: row.summary || '',
315
- concepts: parseArr(row.concepts),
437
+ concepts: parseArr(row.concepts), call_sites: parseArr(row.call_sites),
316
438
  };
317
439
  }
318
440
 
@@ -328,7 +450,166 @@ export class SqliteGraphStore {
328
450
  return this._stmtByName.all(key).map(r => this._rowToChunk(r));
329
451
  }
330
452
 
331
- findCallers(funcName) { return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r)); }
453
+ /**
454
+ * Resolved symbol-graph edges incident to a chunk (A4). Mirrors MemoryGraphIndex.getEdges,
455
+ * including deterministic order. Returns [] unless the opt-in graph was built.
456
+ */
457
+ /** Whether the opt-in resolved symbol graph (A4) is loaded. */
458
+ hasSymbolGraph() { return this._hasEdges; }
459
+
460
+ getEdges(chunkId, { kind = null, direction = 'in' } = {}) {
461
+ if (!this._hasEdges) return [];
462
+ const rows = direction === 'out' ? this._stmtEdgesOut.all(chunkId) : this._stmtEdgesIn.all(chunkId);
463
+ const out = [];
464
+ for (const e of rows) {
465
+ if (kind && e.kind !== kind) continue;
466
+ const otherId = direction === 'out' ? e.to_chunk_id : e.from_chunk_id;
467
+ out.push({ from_chunk_id: e.from_chunk_id, to_chunk_id: e.to_chunk_id, kind: e.kind, confidence: e.confidence, chunk: this.getChunk(otherId) });
468
+ }
469
+ return out;
470
+ }
471
+
472
+ /** Whether opt-in symbol centrality (A5) is loaded. */
473
+ hasCentrality() { return this._hasCentrality; }
474
+
475
+ /** Whether opt-in serialized taint flows (C2 --taint) are loaded. */
476
+ hasTaint() { return this._hasTaint; }
477
+
478
+ /** The serialized taint payload `{ flows, meta }` (C2), or null. Mirrors MemoryGraphIndex. */
479
+ getTaintFlows() {
480
+ if (!this._hasTaint) return null;
481
+ const row = this._stmtTaint.get();
482
+ if (!row || !row.payload) return null;
483
+ try { return JSON.parse(row.payload); } catch { return null; }
484
+ }
485
+
486
+ /** Whether opt-in SCIP precise references (A2 v2 --resolver scip) are loaded. */
487
+ hasScipRefs() { return this._hasScipRefs; }
488
+
489
+ /** Parse the single JSON payload `{ defId: [refId,…] }` once, then memoize. */
490
+ _loadScipRefs() {
491
+ if (!this._hasScipRefs) return {};
492
+ if (!this._scipRefsObj) {
493
+ const row = this._stmtScipRefs.get();
494
+ try { this._scipRefsObj = row && row.payload ? JSON.parse(row.payload) : {}; }
495
+ catch { this._scipRefsObj = {}; }
496
+ }
497
+ return this._scipRefsObj;
498
+ }
499
+
500
+ /** SCIP-confirmed referer chunk ids for a definition chunk (A2 v2), or []. Mirrors MemoryGraphIndex. */
501
+ getScipReferers(defChunkId) {
502
+ const refs = this._loadScipRefs()[defChunkId];
503
+ return Array.isArray(refs) ? refs : [];
504
+ }
505
+
506
+ /** How many definitions carry SCIP precise references (A2 v2). 0 when not loaded. */
507
+ scipRefCount() { return this._hasScipRefs ? Object.keys(this._loadScipRefs()).length : 0; }
508
+
509
+ /** Whether opt-in learned-sparse associations (B3 --learned-sparse) are loaded. */
510
+ hasSparseModel() { return this._hasSparseModel; }
511
+
512
+ /** Path to the shared `.embeddings.bin` (B4 late-interaction reranker reads candidate vectors from it). */
513
+ embeddingBinPath() { return this._embeddingPath; }
514
+
515
+ /** Parse the single learned-sparse payload once, then memoize. Mirrors MemoryGraphIndex._sparseModel. */
516
+ _loadSparseModel() {
517
+ if (!this._hasSparseModel) return null;
518
+ if (!this._sparseModelObj) {
519
+ const row = this._stmtSparseModel.get();
520
+ try { this._sparseModelObj = row && row.payload ? JSON.parse(row.payload) : null; }
521
+ catch { this._sparseModelObj = null; }
522
+ }
523
+ return this._sparseModelObj;
524
+ }
525
+
526
+ /**
527
+ * Symbol centrality (A5) for one chunk. Mirrors MemoryGraphIndex.getCentrality — same
528
+ * { score, rank, total } shape; null when unranked or centrality was not built.
529
+ */
530
+ getCentrality(chunkId) {
531
+ if (!this._hasCentrality) return null;
532
+ const row = this._stmtCentrality.get(chunkId);
533
+ return row ? { score: row.score, rank: row.rank, total: this._centralityTotal } : null;
534
+ }
535
+
536
+ /** The most central symbols (A5), rank-ascending. @returns {Array<{chunk,score,rank}>} */
537
+ topCentral(limit = 20) {
538
+ if (!this._hasCentrality) return [];
539
+ const out = [];
540
+ for (const row of this._stmtTopCentral.all(limit)) {
541
+ const chunk = this.getChunk(row.chunk_id);
542
+ if (chunk) out.push({ chunk, score: row.score, rank: row.rank });
543
+ }
544
+ return out;
545
+ }
546
+
547
+ /** From-chunks of edges of a kept kind pointing at any of `defs`, id-sorted. */
548
+ _referrersByEdge(defs, keepKind) {
549
+ const ids = new Set();
550
+ for (const def of defs) for (const e of this._stmtEdgesIn.all(def.id)) if (keepKind(e.kind)) ids.add(e.from_chunk_id);
551
+ const out = [];
552
+ for (const id of [...ids].sort()) { const c = this.getChunk(id); if (c) out.push(c); }
553
+ return out;
554
+ }
555
+
556
+ findCallers(funcName) {
557
+ if (this._hasEdges) {
558
+ const defs = this.resolveSymbol(funcName);
559
+ if (defs.length > 0) return this._referrersByEdge(defs, k => k === 'calls');
560
+ }
561
+ return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r));
562
+ }
563
+
564
+ findReferers(symbol) {
565
+ const key = String(symbol).toLowerCase().trim();
566
+ if (!key) return [];
567
+ if (this._hasEdges) {
568
+ const defs = this.resolveSymbol(symbol);
569
+ if (defs.length > 0) return this._referrersByEdge(defs, k => k === 'extends' || k === 'type');
570
+ }
571
+ // Escape LIKE metacharacters in the symbol, then bracket with JSON quotes so
572
+ // the prefilter only fires on a whole array element.
573
+ const esc = key.replace(/[\\%_]/g, m => '\\' + m);
574
+ const pat = `%"${esc}"%`;
575
+ const out = [];
576
+ for (const row of this._stmtReferers.all(pat, pat)) {
577
+ const c = this._rowToChunk(row);
578
+ // A class/type lists its own name in type_refs — that self-mention is a
579
+ // definition, not a reference; skip it.
580
+ if (c.name && c.name.toLowerCase() === key) continue;
581
+ const hit = c.type_refs.some(t => t.toLowerCase() === key)
582
+ || c.extends.some(t => t.toLowerCase() === key);
583
+ if (hit) out.push(c);
584
+ }
585
+ return out;
586
+ }
587
+
588
+ /**
589
+ * HTTP routes filtered by method and/or path prefix, each augmented with its
590
+ * resolved handler chunk (`chunk`, or null). Filtering logic is byte-identical
591
+ * to MemoryGraphIndex.findRoutes; rows arrive in index-build order (ORDER BY id)
592
+ * so both backends return identical records. An index built before this feature
593
+ * (empty routes table) yields [].
594
+ * @returns {object[]}
595
+ */
596
+ findRoutes({ method = null, pathPrefix = null } = {}) {
597
+ this._maybeRefresh();
598
+ const m = method ? String(method).toUpperCase().trim() : null;
599
+ const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
600
+ const out = [];
601
+ for (const r of this._stmtRoutes.all()) {
602
+ if (m && String(r.method || '').toUpperCase() !== m) continue;
603
+ if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
604
+ const chunk = r.handler_chunk_id ? this.getChunk(r.handler_chunk_id) : null;
605
+ out.push({
606
+ method: r.method, path: r.path, handler_name: r.handler_name,
607
+ handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
608
+ line: r.line, framework: r.framework, chunk,
609
+ });
610
+ }
611
+ return out;
612
+ }
332
613
 
333
614
  *iterateChunks() {
334
615
  // Stream rows via a cursor so repo-map / call-graph passes never materialise
@@ -408,8 +689,9 @@ export class SqliteGraphStore {
408
689
  searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
409
690
  this._maybeRefresh();
410
691
 
411
- // 1. Lexical BM25 over posting lists (one indexed lookup per query term).
412
- const qTokens = tokenize(queryText);
692
+ // Asymmetric stemming: stem only for natural-language queries so symbolic
693
+ // lookups stay exact — mirrors the in-memory engine for parity.
694
+ const qTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
413
695
  const occ = new Map();
414
696
  for (const t of qTokens) occ.set(t, (occ.get(t) || 0) + 1);
415
697
 
@@ -429,10 +711,14 @@ export class SqliteGraphStore {
429
711
  .slice(0, LEXICAL_FUSION_CAP)
430
712
  .map(([id, score], i) => ({ id, score, rank: i + 1 }));
431
713
 
432
- // 2. Vector channel: stream the FULL embeddings bin (bounded buffers) so a
433
- // conceptual query that shares no tokens with the code can still match.
434
- // The old design scored only lexical candidates, which silently turned
435
- // semantic search off exactly where it was needed.
714
+ // B3 learned-sparse channel: opt-in (model present) + NL-asymmetric. Shares scoreSparseExpanded
715
+ // with the in-memory engine over identically-ordered terms → byte-identical to memory. [] when
716
+ // inert (no model / symbolic query / no association) → fusion byte-identical to the default path.
717
+ const sparseResults = this._searchSparseExpanded(queryText);
718
+
719
+ // Stream the FULL embeddings bin (bounded buffers) so conceptual queries
720
+ // that share no tokens with the code can still match — scoring only lexical
721
+ // candidates silently disabled semantic search where it mattered most.
436
722
  let vectorResults = [];
437
723
  if (queryVector && this._vecFd >= 0) {
438
724
  // Binary sketch above SKETCH_THRESHOLD (Hamming prefilter + exact
@@ -446,9 +732,9 @@ export class SqliteGraphStore {
446
732
  });
447
733
  const entries = [];
448
734
  for (const { key, score } of hits) {
449
- // Summary-only vectors live under `<key>|s` — fold onto the chunk.
450
- const baseKey = key.endsWith(SUMMARY_VEC_SUFFIX)
451
- ? key.slice(0, -SUMMARY_VEC_SUFFIX.length) : key;
735
+ // Summary-only (`<key>|s`) and per-window (`<key>|wN`) vectors fold onto
736
+ // the chunk; finalizeVectorCandidates keeps the max-sim score per chunk.
737
+ const baseKey = baseEmbeddingKey(key);
452
738
  let rows = this._stmtIdsByKey.all(baseKey);
453
739
  if (rows.length === 0) rows = this._stmtIdsByHash.all(baseKey); // legacy bins
454
740
  for (const { id } of rows) entries.push({ id, score });
@@ -456,9 +742,6 @@ export class SqliteGraphStore {
456
742
  vectorResults = finalizeVectorCandidates(entries);
457
743
  }
458
744
 
459
- // 3. Fetch candidate rows ONCE (transient, bounded by the caps above), then
460
- // fuse + boost with the shared ranker. getChunk lazy-fetches stragglers
461
- // (e.g. exact-boost ids) and caches them for the duration of the call.
462
745
  const rowCache = new Map();
463
746
  const fetchRow = (id) => {
464
747
  if (rowCache.has(id)) return rowCache.get(id);
@@ -494,12 +777,36 @@ export class SqliteGraphStore {
494
777
  };
495
778
 
496
779
  return fuseAndRank({
497
- lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
780
+ lexicalResults, vectorResults, sparseResults, getChunk, getPathTokens, getDf,
498
781
  docCount: this._docCount, rrfK: this.rrfK, topK, queryText, exactBoostName,
782
+ corpusEnriched: this._corpusEnriched,
499
783
  resolveExact: (term) => this._stmtIdByName.all(term).map(r => r.id),
500
784
  });
501
785
  }
502
786
 
787
+ /**
788
+ * B3 — the learned-sparse expansion channel (NL queries, model present). Mirrors
789
+ * MemoryGraphIndex._searchSparseExpanded: identical expansion + the shared scoreSparseExpanded
790
+ * scorer over identically-ordered terms → byte-identical to memory. Returns [] when inert.
791
+ */
792
+ _searchSparseExpanded(queryText) {
793
+ if (!this._hasSparseModel || !isNaturalLanguageQuery(queryText)) return [];
794
+ const model = this._loadSparseModel();
795
+ const termWeights = model && expandSparseQuery(queryText, model);
796
+ if (!termWeights) return [];
797
+ const avgdl = this._avgdl;
798
+ const docLenCache = new Map();
799
+ return scoreSparseExpanded(termWeights, {
800
+ docCount: this._docCount,
801
+ avgdl,
802
+ getDf: (t) => { const r = this._stmtDf.get(t); return r ? r.df : 0; },
803
+ // .map() materialises the postings AND populates the doc-len cache before scoring iterates,
804
+ // so getDocLen always resolves without a second query.
805
+ getPostings: (t) => this._stmtPosting.all(t).map(r => { docLenCache.set(r.id, r.doc_len); return [r.id, r.tf]; }),
806
+ getDocLen: (id) => docLenCache.get(id) ?? avgdl,
807
+ });
808
+ }
809
+
503
810
  close() {
504
811
  if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
505
812
  if (this.db) { try { this.db.close(); } catch {} this.db = null; }
@@ -509,8 +816,9 @@ export class SqliteGraphStore {
509
816
 
510
817
  /** Internal: index one chunk's lexical document + row + call edges. */
511
818
  _insertChunk(chunk, imports, vecEntry) {
512
- const tokens = tokenize(buildLexicalDocument(chunk, imports));
513
- const docLen = tokens.length;
819
+ const doc = buildLexicalDocument(chunk, imports);
820
+ const tokens = tokenize(doc); // raw + additive stems
821
+ const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
514
822
 
515
823
  const termCounts = new Map();
516
824
  for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
@@ -519,7 +827,7 @@ export class SqliteGraphStore {
519
827
  this._stmtInsPost.run(term, chunk.id, cnt);
520
828
  }
521
829
 
522
- const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
830
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
523
831
  this._stmtInsChunk.run(
524
832
  chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
525
833
  nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
@@ -529,7 +837,8 @@ export class SqliteGraphStore {
529
837
  jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
530
838
  docLen, JSON.stringify(pathTokens),
531
839
  chunk.content_hash ? embeddingKeyFor(chunk) : null,
532
- vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0
840
+ vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0,
841
+ jsonArr(chunk.call_sites)
533
842
  );
534
843
  for (const callee of (chunk.calls || [])) this._stmtInsEdge.run(callee, chunk.id);
535
844
  return docLen;
@@ -557,8 +866,8 @@ export class SqliteGraphStore {
557
866
  this._prepareWrite();
558
867
  this._maybeRefresh();
559
868
 
560
- // 1. Append new vectors first — orphan bytes on a failed commit are
561
- // harmless and compacted away by the next full index run.
869
+ // Append new vectors first — orphan bytes on a failed commit are harmless
870
+ // and compacted away by the next full index run.
562
871
  let appended = new Map();
563
872
  const embCount = embeddings ? (embeddings instanceof Map ? embeddings.size : Object.keys(embeddings).length) : 0;
564
873
  if (embCount > 0) {
@@ -574,8 +883,17 @@ export class SqliteGraphStore {
574
883
 
575
884
  this.db.exec('BEGIN');
576
885
  try {
577
- // 2. Capture reusable vector offsets for incoming chunks BEFORE the
578
- // file's old rows (which may hold those offsets) are deleted.
886
+ // A per-file edit makes the whole-program symbol graph (A4) stale; clear it so
887
+ // findCallers/findReferers fall back to the always-correct scan until the next
888
+ // full `idx-index` rebuilds it (the daemon never rebuilds the graph).
889
+ if (this._hasEdges) { this.db.exec('DELETE FROM edges'); this._hasEdges = false; }
890
+ if (this._hasCentrality) { this.db.exec('DELETE FROM centrality'); this._hasCentrality = false; this._centralityTotal = 0; }
891
+ if (this._hasTaint) { this.db.exec('DELETE FROM taint'); this._hasTaint = false; }
892
+ if (this._hasScipRefs) { this.db.exec('DELETE FROM scip_refs'); this._hasScipRefs = false; this._scipRefsObj = null; }
893
+ if (this._hasSparseModel) { this.db.exec('DELETE FROM sparse_model'); this._hasSparseModel = false; this._sparseModelObj = null; }
894
+
895
+ // Capture reusable vector offsets BEFORE deleting old rows, which may
896
+ // be the only rows that hold those offsets.
579
897
  const reuse = new Map();
580
898
  for (const chunk of chunks) {
581
899
  if (!chunk.content_hash) continue;
@@ -585,7 +903,6 @@ export class SqliteGraphStore {
585
903
  if (row) reuse.set(key, { offset: row.vec_offset, dim: row.vec_dim });
586
904
  }
587
905
 
588
- // 3. Remove the file's old rows with exact BM25 bookkeeping.
589
906
  let docCount = this._docCount;
590
907
  let totalDocLen = this._totalDocLen;
591
908
  for (const row of this._stmtByFile.all(filePath)) {
@@ -599,7 +916,6 @@ export class SqliteGraphStore {
599
916
  docCount--;
600
917
  }
601
918
 
602
- // 4. Insert the new chunks.
603
919
  for (const chunk of chunks) {
604
920
  const key = chunk.content_hash ? embeddingKeyFor(chunk) : null;
605
921
  const vecEntry = key ? (appended.get(key) ?? reuse.get(key) ?? null) : null;
@@ -608,7 +924,6 @@ export class SqliteGraphStore {
608
924
  }
609
925
  this._stmtPruneTerms.run();
610
926
 
611
- // 5. Dependency edges + meta.
612
927
  this._stmtDelDeps.run(filePath);
613
928
  if (!imports || imports.length === 0) this._stmtInsDep.run(filePath, null);
614
929
  else for (const d of imports) this._stmtInsDep.run(filePath, d);
@@ -629,7 +944,6 @@ export class SqliteGraphStore {
629
944
  throw err;
630
945
  }
631
946
 
632
- // 6. Mirror the in-RAM dependency graph (same shape as updateFileGraph).
633
947
  const oldDeps = this._graph.dependencies[filePath] || [];
634
948
  for (const oldDep of oldDeps) {
635
949
  if (this._graph.importedBy[oldDep]) {
@@ -670,11 +984,10 @@ export class SqliteGraphStore {
670
984
  * @param {{dependencies:Object<string,string[]>}} payload.graph
671
985
  * @param {Map<string,Float32Array>|object} [payload.embeddingCache] Keyed by embeddingKeyFor(chunk).
672
986
  */
673
- buildFrom({ chunks, graph, embeddingCache }) {
987
+ buildFrom({ chunks, graph, embeddingCache, edges = null, centrality = null, taint = null, scipRefs = null, sparseModel = null }) {
674
988
  this._open();
675
- this.db.exec('PRAGMA synchronous = OFF;'); // fast bulk load; rebuilt artifact
989
+ this.db.exec('PRAGMA synchronous = OFF;'); // safe only for a full rebuild; not used for incremental writes
676
990
 
677
- // Write the embedding binary and derive each key's float offset.
678
991
  const offsets = new Map();
679
992
  let dim = 0;
680
993
  const cacheSize = embeddingCache instanceof Map ? embeddingCache.size : Object.keys(embeddingCache || {}).length;
@@ -697,12 +1010,15 @@ export class SqliteGraphStore {
697
1010
  let totalDocLen = 0, docCount = 0;
698
1011
 
699
1012
  this.db.exec('BEGIN');
700
- // Fresh build — drop any prior content (inside the txn: readers keep the
701
- // old index until COMMIT, instead of briefly seeing empty tables).
1013
+ // Drop inside the transaction so concurrent readers see the old index
1014
+ // until COMMIT, never briefly-empty tables.
702
1015
  this.db.exec(`
703
1016
  DROP TABLE IF EXISTS chunks; DROP TABLE IF EXISTS postings;
704
1017
  DROP TABLE IF EXISTS terms; DROP TABLE IF EXISTS call_edges;
705
1018
  DROP TABLE IF EXISTS deps; DROP TABLE IF EXISTS meta;
1019
+ DROP TABLE IF EXISTS routes; DROP TABLE IF EXISTS edges;
1020
+ DROP TABLE IF EXISTS centrality; DROP TABLE IF EXISTS taint;
1021
+ DROP TABLE IF EXISTS scip_refs; DROP TABLE IF EXISTS sparse_model;
706
1022
  `);
707
1023
  this.db.exec(SCHEMA_TABLES);
708
1024
  this.db.exec(SCHEMA_INDEXES);
@@ -716,8 +1032,9 @@ export class SqliteGraphStore {
716
1032
 
717
1033
  for (const chunk of chunks) {
718
1034
  const deps = (graph?.dependencies?.[chunk.file_path]) || [];
719
- const tokens = tokenize(buildLexicalDocument(chunk, deps));
720
- const docLen = tokens.length;
1035
+ const doc = buildLexicalDocument(chunk, deps);
1036
+ const tokens = tokenize(doc); // raw + additive stems
1037
+ const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
721
1038
  totalDocLen += docLen; docCount++;
722
1039
 
723
1040
  const termCounts = new Map();
@@ -727,7 +1044,7 @@ export class SqliteGraphStore {
727
1044
  insPost.run(term, chunk.id, cnt);
728
1045
  }
729
1046
 
730
- const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
1047
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
731
1048
  const vecKey = chunk.content_hash ? embeddingKeyFor(chunk) : null;
732
1049
  const vec = vecKey ? (offsets.get(vecKey) ?? offsets.get(chunk.content_hash)) : null;
733
1050
 
@@ -739,7 +1056,8 @@ export class SqliteGraphStore {
739
1056
  chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
740
1057
  jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
741
1058
  docLen, JSON.stringify(pathTokens), vecKey,
742
- vec ? vec.offset : -1, vec ? vec.dim : 0
1059
+ vec ? vec.offset : -1, vec ? vec.dim : 0,
1060
+ jsonArr(chunk.call_sites)
743
1061
  );
744
1062
  for (const callee of (chunk.calls || [])) insEdge.run(callee, chunk.id);
745
1063
  }
@@ -753,6 +1071,56 @@ export class SqliteGraphStore {
753
1071
  else for (const d of list) insDep.run(file, d);
754
1072
  }
755
1073
 
1074
+ // Inserted in array order so the autoincrement id mirrors the in-memory
1075
+ // graph.routes order, keeping both backends rank-identical.
1076
+ const insRoute = this.db.prepare(
1077
+ 'INSERT INTO routes (method, path, handler_name, handler_chunk_id, file_path, line, framework) '
1078
+ + 'VALUES (?, ?, ?, ?, ?, ?, ?)'
1079
+ );
1080
+ for (const r of (graph?.routes || [])) {
1081
+ insRoute.run(
1082
+ String(r.method || ''), String(r.path ?? ''), r.handler_name ?? null,
1083
+ r.handler_chunk_id ?? null, String(r.file_path || ''),
1084
+ r.line ?? null, r.framework ?? null
1085
+ );
1086
+ }
1087
+
1088
+ // A4 resolved symbol graph (opt-in). Inserted in the deterministic order produced
1089
+ // by buildSymbolGraph so the table mirrors the in-memory edge list.
1090
+ if (Array.isArray(edges) && edges.length) {
1091
+ const insEdge = this.db.prepare(
1092
+ 'INSERT INTO edges (from_chunk_id, to_chunk_id, kind, confidence) VALUES (?, ?, ?, ?)'
1093
+ );
1094
+ for (const e of edges) insEdge.run(e.from_chunk_id, e.to_chunk_id, e.kind, e.confidence);
1095
+ }
1096
+
1097
+ // A5 symbol centrality (opt-in). Precomputed score/rank per chunk id; both backends
1098
+ // read these exact numbers → parity-free. Inserted in rank order for tidy storage.
1099
+ if (centrality && typeof centrality === 'object') {
1100
+ const insCen = this.db.prepare('INSERT INTO centrality (chunk_id, score, rank) VALUES (?, ?, ?)');
1101
+ const rows = Object.entries(centrality).sort((a, b) => a[1].rank - b[1].rank);
1102
+ for (const [id, v] of rows) insCen.run(id, v.score, v.rank);
1103
+ }
1104
+
1105
+ // C2 serialized taint flows (opt-in). A single JSON payload (id = 0); both backends parse
1106
+ // the identical bytes → parity-free, exactly like edges/centrality.
1107
+ if (taint && typeof taint === 'object') {
1108
+ this.db.prepare('INSERT INTO taint (id, payload) VALUES (0, ?)').run(JSON.stringify(taint));
1109
+ }
1110
+
1111
+ // A2 v2 SCIP precise references (opt-in --resolver scip). A single JSON payload (id = 0) of
1112
+ // `{ defChunkId: [refererChunkId, …] }`; both backends parse identical bytes → parity-free,
1113
+ // exactly like taint. find_references reads it for the cross-file `resolved` reference tier.
1114
+ if (scipRefs && typeof scipRefs === 'object' && Object.keys(scipRefs).length) {
1115
+ this.db.prepare('INSERT INTO scip_refs (id, payload) VALUES (0, ?)').run(JSON.stringify(scipRefs));
1116
+ }
1117
+
1118
+ // B3 learned-sparse model (opt-in --learned-sparse). A single JSON payload (id = 0); both
1119
+ // backends parse identical bytes → parity-free, exactly like taint/scip_refs.
1120
+ if (sparseModel && typeof sparseModel === 'object' && sparseModel.assoc) {
1121
+ this.db.prepare('INSERT INTO sparse_model (id, payload) VALUES (0, ?)').run(JSON.stringify(sparseModel));
1122
+ }
1123
+
756
1124
  const insMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
757
1125
  insMeta.run('doc_count', String(docCount));
758
1126
  insMeta.run('total_doc_len', String(totalDocLen));