graph-indexer 2.0.0 → 2.1.1

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/config.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { artifactPaths, CONFIG_FILE_NAME, DATA_DIR_NAME } from './layout.mjs';
14
+ import { assertSealCompatible } from './seal.mjs';
14
15
 
15
16
  export const DEFAULTS = Object.freeze({
16
17
  // 'auto' keeps the index in memory below AUTO_SQLITE_CHUNK_THRESHOLD chunks and
@@ -20,15 +21,20 @@ export const DEFAULTS = Object.freeze({
20
21
  embeddings: false, // OFF by default — lexical + stemming need zero dependencies.
21
22
  // Opt in with --embeddings or INDEXER_EMBEDDINGS=on.
22
23
  embedProvider: 'auto',
23
- // 'auto' → Ollama (if running w/ the model) → in-process local → lexical-only
24
- // 'ollama' → force Ollama daemon (requires: ollama serve + pulled model)
25
- // 'local' → in-process Xenova (requires: npm i @huggingface/transformers)
26
- // 'mlx' → Apple Metal GPU (requires: npm run embed:setup:mlx; macOS only)
27
- // 'off' → lexical-only, no vectors
24
+ // 'auto' → Ollama (if running w/ the model) → in-process local → lexical-only
25
+ // 'ollama' → force Ollama daemon (requires: ollama serve + pulled model)
26
+ // 'local' → in-process Xenova MiniLM (requires: npm i @huggingface/transformers)
27
+ // 'code-local'→ in-process code-specialized embedder (jina-code; same optional dep as
28
+ // 'local'; ~160 MB q8 first download, air-gapped after) — sharper code recall
29
+ // 'mlx' → Apple Metal GPU (requires: npm run embed:setup:mlx; macOS only)
30
+ // 'off' → lexical-only, no vectors
28
31
  embedModel: 'nomic-embed-text', // default Ollama embed model when embeddings are enabled.
29
32
  // qwen3-embedding:4b is a documented opt-in upgrade
30
33
  // (--embed-model / EMBED_MODEL), never a hardcoded default.
31
34
  localEmbedModel: 'Xenova/all-MiniLM-L6-v2', // in-process model (optional @huggingface/transformers)
35
+ codeEmbedModel: 'jinaai/jina-embeddings-v2-base-code', // in-process CODE-specialized model for
36
+ // provider 'code-local' (768-dim, MIT, 8192-token context).
37
+ // Override with --code-embed-model / INDEXER_CODE_EMBED_MODEL.
32
38
  mlxEmbedModel: 'mlx-community/all-MiniLM-L6-v2-4bit', // MLX embedder model (provider 'mlx').
33
39
  // Override with --mlx-embed-model / INDEXER_MLX_EMBED_MODEL;
34
40
  // must be an mlx_embeddings-compatible sentence model.
@@ -38,6 +44,42 @@ export const DEFAULTS = Object.freeze({
38
44
  mlxLmHost: 'http://localhost:8080', // mlx_lm.server endpoint (default port 8080)
39
45
  gitSignals: true, // collect local git churn/recency/co-change at index time (air-gapped)
40
46
  gitRankBoost: 0, // 0..1 opt-in recency/churn weight in search_code (0 = ranking unchanged)
47
+ interprocedural: false, // OPT-IN: index-time inter-procedural receiver-type fixpoint —
48
+ // propagate return types along factory call chains so multi-hop
49
+ // receivers resolve in get_call_graph / find_references. Air-gapped,
50
+ // deterministic; default index is byte-identical. Enable with
51
+ // --interprocedural / INDEXER_INTERPROCEDURAL.
52
+ symbolGraph: false, // OPT-IN: persist a resolved symbol graph (chunk→chunk edges with
53
+ // kind + confidence) at index time, exposed via getEdges. findCallers/
54
+ // findReferers read it when present (identical sets, fall back to scan).
55
+ // Air-gapped, deterministic; default index byte-identical; search
56
+ // ranking unaffected. Enable with --symbol-graph / INDEXER_SYMBOL_GRAPH.
57
+ taint: false, // OPT-IN: precompute + serialize taint flows (C2) at index time so the
58
+ // trace_taint / find_tainted_sinks tools serve instantly (both backends
59
+ // serve identical flows). Default off → tools compute query-time (the
60
+ // index is byte-identical). --taint / INDEXER_TAINT.
61
+ ranker: 'rrf', // Search ranker (D3). 'rrf' (default): the RRF + boost-ladder fusion —
62
+ // BYTE-IDENTICAL to before. 'learned': a post-fusion linear re-rank over
63
+ // RRF score + A5 centrality + resolved/SCIP in-degree + git (zero-dep dot
64
+ // product; lift is on --symbol-graph indexes). --ranker / INDEXER_RANKER.
65
+ learnedSparse: false, // OPT-IN: learn a corpus co-occurrence (PMI) association model (B3) at
66
+ // index time and serialize it, adding an NL-only vocabulary-EXPANSION
67
+ // channel at query time (zero new dep, fully air-gapped). Default off →
68
+ // no model → byte-identical. --learned-sparse / INDEXER_LEARNED_SPARSE.
69
+ sealed: 'off', // Sealed mode (F1): 'off' (default) | 'local' (loopback only) |
70
+ // 'strict' (zero network egress; lexical-only). Fail-closed:
71
+ // refuses to start if an enabled feature would egress beyond the
72
+ // tier, and installs a deny-by-default runtime egress guard.
73
+ // --sealed [strict|local] / INDEXER_SEALED.
74
+ resolver: 'heuristic', // Resolver provider for the symbol graph (A1/A2). 'heuristic' (default):
75
+ // edges stay { high, name_only } — byte-identical. 'precise' (A1): lift
76
+ // provably-unambiguous edges (sole definition / type-pinned receiver)
77
+ // to a `resolved` tier, powering impact_of_edit precision='strict'.
78
+ // 'scip' (A2): ingest a locally-generated SCIP index (--scip-index) and
79
+ // promote/suppress `calls` edges by real cross-file bindings. Only
80
+ // meaningful with --symbol-graph. --resolver / INDEXER_RESOLVER.
81
+ scipIndex: null, // Path to a locally-generated SCIP index for --resolver scip (A2).
82
+ // --scip-index / INDEXER_SCIP_INDEX / scipIndex config; null = none.
41
83
  enrichment: Object.freeze({
42
84
  enabled: false,
43
85
  model: 'qwen2.5-coder:1.5b', // smallest coder model; quality sufficient for summaries.
@@ -51,9 +93,16 @@ export const DEFAULTS = Object.freeze({
51
93
 
52
94
  }),
53
95
  rerank: Object.freeze({
54
- enabled: false, // opt-in: one LLM call per natural-language query
55
- model: 'qwen2.5-coder:7b', // judge quality matters: 7B measured +50% semantic rank-1, 1.5B ~nil
56
- topM: 12, // candidates shown to the judge to reorder
96
+ enabled: false, // opt-in: one rerank pass per natural-language query
97
+ provider: 'generative', // 'generative' = LLM judge (default) | 'cross-encoder' = local
98
+ // air-gapped MS-MARCO cross-encoder (no Ollama/daemon) |
99
+ // 'late-interaction' = ColBERT-style multi-vector MaxSim (B4,
100
+ // reuses the embedder + stored doc vectors; needs --embeddings).
101
+ // Set with --rerank-provider / RERANK_PROVIDER.
102
+ model: 'qwen2.5-coder:7b', // generative judge model: 7B measured +50% semantic rank-1, 1.5B ~nil
103
+ crossEncoderModel: 'Xenova/ms-marco-MiniLM-L-6-v2', // cross-encoder provider model
104
+ // (optional @huggingface/transformers; downloaded on first use)
105
+ topM: 12, // candidates the reranker reorders
57
106
  poolSize: 15, // over-fetch depth when reranking, so a correct-but-deep
58
107
  // hit can be RESCUED into top_k (then truncated to top_k)
59
108
  }),
@@ -150,10 +199,55 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
150
199
  ?? (Number.isFinite(file.gitRankBoost) ? file.gitRankBoost : DEFAULTS.gitRankBoost);
151
200
  const gitRankBoost = Math.min(1, Math.max(0, Number(gitRankBoostRaw) || 0));
152
201
 
202
+ // Opt-in inter-procedural receiver-type fixpoint (index-time, air-gapped).
203
+ const interprocedural = argv.includes('--interprocedural')
204
+ || env.INDEXER_INTERPROCEDURAL === 'on'
205
+ || file.interprocedural === true;
206
+
207
+ // Opt-in persistent resolved symbol graph (index-time, air-gapped).
208
+ const symbolGraph = argv.includes('--symbol-graph')
209
+ || env.INDEXER_SYMBOL_GRAPH === 'on'
210
+ || file.symbolGraph === true;
211
+
212
+ // Opt-in index-time taint serialization (C2): precompute + persist the flow set.
213
+ const taint = argv.includes('--taint')
214
+ || env.INDEXER_TAINT === 'on'
215
+ || file.taint === true;
216
+
217
+ // Opt-in learned-sparse model (B3): learn + persist the corpus association model at index time.
218
+ const learnedSparse = argv.includes('--learned-sparse')
219
+ || env.INDEXER_LEARNED_SPARSE === 'on'
220
+ || file.learnedSparse === true;
221
+
222
+ // Search ranker (D3). Unknown values fall back to the default 'rrf' (byte-identical fusion).
223
+ const rankerRaw = flagValue(argv, '--ranker') || env.INDEXER_RANKER || file.ranker || DEFAULTS.ranker;
224
+ const ranker = (rankerRaw === 'learned' || rankerRaw === 'rrf') ? rankerRaw : 'rrf';
225
+
226
+ // Resolver provider for the symbol graph (A1/A2). Unknown values fall back to 'heuristic'.
227
+ const resolverRaw = flagValue(argv, '--resolver') || env.INDEXER_RESOLVER || file.resolver || DEFAULTS.resolver;
228
+ const resolver = (resolverRaw === 'precise' || resolverRaw === 'heuristic' || resolverRaw === 'scip')
229
+ ? resolverRaw : 'heuristic';
230
+ // A2: path to a locally-generated SCIP index, consumed by the `scip` resolver. flag > env > file.
231
+ // Resolved against projectRoot (a relative path) so a config-file value is repo-relative.
232
+ const scipRaw = flagValue(argv, '--scip-index') || env.INDEXER_SCIP_INDEX || file.scipIndex || null;
233
+ const scipIndex = scipRaw ? path.resolve(projectRoot, scipRaw) : null;
234
+
235
+ // Sealed mode (F1). flag > env > file > default. A bare `--sealed` (no value, or followed by
236
+ // another flag) means strict; `--sealed local` opts into the loopback tier; env 'on' → strict.
237
+ const sealedFlagPresent = argv.includes('--sealed');
238
+ const sealedFlagVal = sealedFlagPresent ? flagValue(argv, '--sealed') : undefined;
239
+ let sealedRaw;
240
+ if (sealedFlagPresent) sealedRaw = (sealedFlagVal && !sealedFlagVal.startsWith('--')) ? sealedFlagVal : 'strict';
241
+ else if (env.INDEXER_SEALED) sealedRaw = env.INDEXER_SEALED;
242
+ else sealedRaw = typeof file.sealed === 'string' ? file.sealed : DEFAULTS.sealed;
243
+ const sealed = sealedRaw === 'on' ? 'strict'
244
+ : (['strict', 'local', 'off'].includes(sealedRaw) ? sealedRaw : 'off');
245
+
153
246
  const paths = artifactPaths(projectRoot);
154
247
 
155
- return Object.freeze({
248
+ const resolved = Object.freeze({
156
249
  projectRoot,
250
+ sealed,
157
251
  storage,
158
252
  dataDir: paths.dataDir,
159
253
  indexPath: paths.indexPath,
@@ -175,12 +269,24 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
175
269
  // This is the model `auto`/`ollama` providers pull (e.g. qwen3-embedding:4b).
176
270
  embedModel: flagValue(argv, '--embed-model') || env.EMBED_MODEL || file.embedModel || DEFAULTS.embedModel,
177
271
  localEmbedModel: file.localEmbedModel || DEFAULTS.localEmbedModel,
272
+ // Code-specialized in-process model (provider 'code-local'): --code-embed-model flag >
273
+ // INDEXER_CODE_EMBED_MODEL env > config > jina-code default. Stamped into the embed-meta
274
+ // so query time and index time agree, and a model switch forces a clean re-embed.
275
+ codeEmbedModel: flagValue(argv, '--code-embed-model') || env.INDEXER_CODE_EMBED_MODEL
276
+ || file.codeEmbedModel || DEFAULTS.codeEmbedModel,
178
277
  // MLX embedder model: --mlx-embed-model flag > INDEXER_MLX_EMBED_MODEL env >
179
278
  // config > pinned MiniLM default. Passed to the Python server + stamped in meta.
180
279
  mlxEmbedModel: flagValue(argv, '--mlx-embed-model') || env.INDEXER_MLX_EMBED_MODEL
181
280
  || file.mlxEmbedModel || DEFAULTS.mlxEmbedModel,
182
281
  gitSignals,
183
282
  gitRankBoost,
283
+ interprocedural,
284
+ symbolGraph,
285
+ taint,
286
+ ranker,
287
+ resolver,
288
+ scipIndex,
289
+ learnedSparse,
184
290
 
185
291
  enrichment: Object.freeze({
186
292
  enabled: enrichmentEnabled,
@@ -194,20 +300,34 @@ export function resolveConfig({ argv = process.argv.slice(2), env = process.env,
194
300
  : (Number(fileEnrich.concurrency) > 0 ? Number(fileEnrich.concurrency) : DEFAULTS.enrichment.concurrency),
195
301
  }),
196
302
 
197
- rerank: Object.freeze({
198
- // OFF unless requested: --rerank flag, RERANK_MODEL env (naming a model
199
- // implies enabling it), or the project config.
200
- enabled: argv.includes('--rerank') || Boolean(env.RERANK_MODEL) || Boolean((file.rerank || {}).enabled),
201
- model: env.RERANK_MODEL || (file.rerank || {}).model || DEFAULTS.rerank.model,
202
- topM: Number.isInteger((file.rerank || {}).topM) ? (file.rerank || {}).topM : DEFAULTS.rerank.topM,
203
- poolSize: Number.isInteger((file.rerank || {}).poolSize) ? (file.rerank || {}).poolSize : DEFAULTS.rerank.poolSize,
204
- }),
303
+ rerank: (() => {
304
+ // Provider: env > --rerank-provider flag > config; validated to a known provider.
305
+ const provRaw = env.RERANK_PROVIDER || flagValue(argv, '--rerank-provider') || (file.rerank || {}).provider;
306
+ const provider = ['generative', 'cross-encoder', 'late-interaction'].includes(provRaw) ? provRaw : DEFAULTS.rerank.provider;
307
+ return Object.freeze({
308
+ // OFF unless requested: --rerank flag, RERANK_MODEL env (naming a model implies
309
+ // enabling it), an explicit provider (env/flag), or the project config.
310
+ enabled: argv.includes('--rerank') || Boolean(env.RERANK_MODEL) || Boolean(provRaw)
311
+ || Boolean((file.rerank || {}).enabled),
312
+ provider,
313
+ model: env.RERANK_MODEL || (file.rerank || {}).model || DEFAULTS.rerank.model,
314
+ crossEncoderModel: env.RERANK_CROSS_ENCODER_MODEL || (file.rerank || {}).crossEncoderModel
315
+ || DEFAULTS.rerank.crossEncoderModel,
316
+ topM: Number.isInteger((file.rerank || {}).topM) ? (file.rerank || {}).topM : DEFAULTS.rerank.topM,
317
+ poolSize: Number.isInteger((file.rerank || {}).poolSize) ? (file.rerank || {}).poolSize : DEFAULTS.rerank.poolSize,
318
+ });
319
+ })(),
205
320
 
206
321
  hyde: Object.freeze({
207
322
  enabled: Boolean((file.hyde || {}).enabled),
208
323
  model: (file.hyde || {}).model || DEFAULTS.hyde.model,
209
324
  }),
210
325
  });
326
+
327
+ // F1: fail-closed. If --sealed was requested and an enabled feature would egress beyond the
328
+ // tier, throw here so a sealed-incompatible config can never be constructed (no-op when off).
329
+ assertSealCompatible(resolved);
330
+ return resolved;
211
331
  }
212
332
 
213
333
  // Memoised singleton for import-time consumers (e.g. parse/languages.mjs language loading).
@@ -229,10 +349,11 @@ export function getConfig() {
229
349
  * @returns {string[]}
230
350
  */
231
351
  export function describeConfig(config, { backend = config.storage } = {}) {
232
- // Show the model that the chosen provider actually loads (mlx/local use their own).
352
+ // Show the model that the chosen provider actually loads (mlx/local/code-local use their own).
233
353
  const provModel = config.embedProvider === 'mlx' ? config.mlxEmbedModel
234
354
  : config.embedProvider === 'local' ? config.localEmbedModel
235
- : config.embedModel;
355
+ : config.embedProvider === 'code-local' ? config.codeEmbedModel
356
+ : config.embedModel;
236
357
  const emb = config.embeddingsEnabled
237
358
  ? `on · provider=${config.embedProvider}, model=${provModel}`
238
359
  : 'off (lexical + stemming only)';
@@ -240,8 +361,24 @@ export function describeConfig(config, { backend = config.storage } = {}) {
240
361
  `storage : ${backend}`,
241
362
  `embeddings : ${emb}`,
242
363
  `enrichment : ${config.enrichment.enabled ? `on · model=${config.enrichment.model} · provider=${config.llmProvider}` : 'off'}`,
243
- `reranker : ${config.rerank.enabled ? `on · model=${config.rerank.model} · provider=${config.llmProvider}` : 'off'}`,
364
+ `reranker : ${config.rerank.enabled
365
+ ? (config.rerank.provider === 'cross-encoder'
366
+ ? `on · provider=cross-encoder · model=${config.rerank.crossEncoderModel}`
367
+ : config.rerank.provider === 'late-interaction'
368
+ ? 'on · provider=late-interaction · ColBERT-style multi-vector MaxSim (needs --embeddings)'
369
+ : `on · provider=generative · model=${config.rerank.model} · llm=${config.llmProvider}`)
370
+ : 'off'}`,
244
371
  `git signals : ${config.gitSignals ? 'on' : 'off'}${config.gitRankBoost ? ` · rank boost=${config.gitRankBoost}` : ''}`,
372
+ `interproc. : ${config.interprocedural ? 'on · factory return-type propagation' : 'off'}`,
373
+ `symbol graph: ${config.symbolGraph ? 'on · persisted resolved edges (getEdges)' : 'off'}`,
374
+ `taint : ${config.taint ? 'on · precomputed flows (trace_taint / find_tainted_sinks)' : 'off (query-time)'}`,
375
+ `ranker : ${config.ranker === 'learned' ? 'learned · post-fusion linear re-rank (opt-in)' : 'rrf (default)'}`,
376
+ `sparse : ${config.learnedSparse ? 'on · learned-sparse vocabulary expansion (NL-only channel)' : 'off'}`,
377
+ `resolver : ${config.resolver === 'precise' ? 'precise · unambiguous edges → `resolved` tier'
378
+ : config.resolver === 'scip' ? `scip · cross-file SCIP bindings → \`resolved\` (${config.scipIndex || '⚠️ no --scip-index'})`
379
+ : 'heuristic (default)'}`,
380
+ `sealed mode : ${config.sealed === 'strict' ? '🔒 strict · zero network egress (enforced)'
381
+ : config.sealed === 'local' ? '🔒 local · loopback only (enforced)' : 'off'}`,
245
382
  ];
246
383
  }
247
384
 
@@ -256,11 +393,99 @@ export function describeConfig(config, { backend = config.storage } = {}) {
256
393
  */
257
394
  export function configNotices(config) {
258
395
  const out = [];
396
+ if (config.sealed === 'strict' || config.sealed === 'local') {
397
+ out.push(`Sealed mode (${config.sealed}): a deny-by-default egress guard is installed — `
398
+ + `${config.sealed === 'strict' ? 'NO network connections are permitted' : 'only loopback connections are permitted'}. `
399
+ + 'The build refused to start if any enabled feature would egress beyond the tier. '
400
+ + 'The in-process guard (sockets/http/fetch) is airtight; the child_process denylist is '
401
+ + 'best-effort. Run `idx-index --attest` for the signed-able manifest.');
402
+ }
403
+ if (config.embeddingsEnabled && config.embedProvider === 'code-local') {
404
+ out.push(`Code-specialized embedder (code-local): the dense channel is built in-process by `
405
+ + `${config.codeEmbedModel} — a CODE-trained model (identifiers, call patterns, type names) `
406
+ + 'that sharpens semantic recall on typed languages where the general-purpose MiniLM is '
407
+ + 'weakest. It needs the optional \'@huggingface/transformers\' package; the quantized '
408
+ + '(q8) model is ~160 MB on the FIRST run (downloaded once, then fully air-gapped — no daemon, no network). '
409
+ + 'It is slower per chunk than MiniLM and produces 768-dim vectors — switching to it from '
410
+ + 'another provider forces a clean re-embed (vectors of different models must not be mixed). '
411
+ + 'Pair with the persistent embed cache to pay the embedding cost only once. Measure on '
412
+ + 'YOUR repo (`npm run test:eval -- --embeddings --embed-provider code-local`) before adopting.');
413
+ }
414
+ if (config.interprocedural) {
415
+ out.push('Inter-procedural receiver types are resolved at index time (a whole-program pass). '
416
+ + 'The watch daemon updates one file at a time and does NOT re-run the fixpoint, so '
417
+ + 'cross-file factory chains refresh on the next full `idx-index`; until then they fall '
418
+ + 'back to single-hop resolution (never worse than the default). It only sharpens '
419
+ + 'get_call_graph / find_references — search ranking is unaffected.');
420
+ }
421
+ if (config.symbolGraph) {
422
+ out.push('The resolved symbol graph is built once at index time (a whole-program pass), adding '
423
+ + 'index time roughly proportional to repo size. The watch daemon does NOT rebuild it, so '
424
+ + 'edges refresh on the next full `idx-index`; until then findCallers/findReferers fall back '
425
+ + 'to the name-match scan (never worse). It powers getEdges; search ranking is unaffected.');
426
+ }
427
+ if (config.ranker === 'learned') {
428
+ out.push('Learned ranker (D3): the fused top-N is re-ordered by a zero-dependency linear model '
429
+ + 'over the RRF score + symbol centrality + resolved/SCIP in-degree + git recency. It is a '
430
+ + 'post-fusion re-rank (the default RRF ordering is untouched). Its discriminating features '
431
+ + 'are zero without --symbol-graph, so pair it with --symbol-graph for the measured lift; on a '
432
+ + 'plain lexical index it is ≈neutral. RRF remains the default; this is opt-in.');
433
+ }
434
+ if (config.taint) {
435
+ out.push('Taint flows are precomputed once at index time and serialized, so trace_taint / '
436
+ + 'find_tainted_sinks answer instantly (both backends serve identical flows). The watch '
437
+ + 'daemon does NOT rebuild them — flows refresh on the next full `idx-index`; until then the '
438
+ + 'tools fall back to a query-time scan (never worse). It only affects the security tools; '
439
+ + 'search ranking is unaffected. Pair with --symbol-graph for cross-function precision.');
440
+ }
441
+ if (config.learnedSparse) {
442
+ out.push('Learned-sparse (B3): a corpus co-occurrence (PMI) association model is learned + '
443
+ + 'serialized at index time, adding a vocabulary-EXPANSION channel that fires ONLY for '
444
+ + 'natural-language queries (symbolic/exact lookups stay byte-identical). Zero new dependency, '
445
+ + 'fully air-gapped, parity-safe. The whole-program model refreshes on the next full '
446
+ + '`idx-index` (the daemon drops it on a per-file edit). Measure on YOUR repo (`test:eval`) — '
447
+ + 'it is a recall aid that helps lexical-gap semantic queries and is ≈neutral elsewhere.');
448
+ }
449
+ if ((config.resolver === 'precise' || config.resolver === 'scip') && !config.symbolGraph) {
450
+ out.push(`--resolver ${config.resolver} has no effect without --symbol-graph (the resolver runs only `
451
+ + 'when the symbol graph is built). Add --symbol-graph to enable the `resolved` edge tier.');
452
+ } else if (config.resolver === 'precise') {
453
+ out.push('Precise resolver: edges with a provably-unambiguous binding (sole definition, or a '
454
+ + 'type-pinned receiver) are promoted from `high` to a `resolved` tier — used by '
455
+ + "impact_of_edit(precision: 'strict') for a false-positive-free blast radius. Confidence "
456
+ + 'strings only change; edge sets, parity, and search ranking are unaffected.');
457
+ } else if (config.resolver === 'scip' && !config.scipIndex) {
458
+ out.push('--resolver scip needs --scip-index <path.scip> (a SCIP index generated locally by your '
459
+ + 'own toolchain — scip-typescript, scip-python, scip-java, rust-analyzer --scip). Without it '
460
+ + 'the resolver is inert and edges stay heuristic { high, name_only }.');
461
+ } else if (config.resolver === 'scip') {
462
+ out.push('SCIP resolver: cross-file bindings from a locally-generated SCIP index promote CONFIRMED '
463
+ + '`calls` edges to a `resolved` tier AND suppress the wrong-target fan-out an ambiguous name '
464
+ + 'would otherwise emit (heritage/type edges stay heuristic). Suppression is sound under '
465
+ + 'partial coverage — a def SCIP never recorded is never dropped. Air-gapped (a local file '
466
+ + 'read; sealed-compatible). Files the SCIP index does not cover fall back to heuristic, never '
467
+ + 'worse. Coverage is printed at index time; re-generate the .scip when code changes.');
468
+ }
259
469
  if (config.enrichment.enabled && !config.rerank.enabled) {
260
470
  out.push('Enrichment is most effective when paired with --rerank. Running enrichment-only may '
261
471
  + 'regress semantic precision (measured: gin MRR 0.48→0.39 on qwen3 embeddings).');
262
472
  }
263
- if (config.rerank.enabled) {
473
+ if (config.rerank.enabled && config.rerank.provider === 'cross-encoder') {
474
+ out.push('Cross-encoder reranker scores (query, candidate) pairs locally and air-gapped — '
475
+ + 'no LLM/daemon, deterministic, ~tens of ms per natural-language query. It needs the '
476
+ + "optional '@huggingface/transformers' package (first run downloads the model). It sees "
477
+ + 'only a candidate signature/summary line, not full code, so it sharpens near-ties rather '
478
+ + 'than reasoning about behaviour. Measure on your repo (`npm run test:eval -- --rerank '
479
+ + '--rerank-provider cross-encoder`) before adopting.');
480
+ } else if (config.rerank.enabled && config.rerank.provider === 'late-interaction') {
481
+ out.push('Late-interaction (ColBERT-style) reranker scores the query MULTI-vectors against '
482
+ + 'each candidate\'s stored document sub-vectors with MaxSim — air-gapped (the same local '
483
+ + 'embedder), deterministic, parity-safe, with NO storage blow-up (it reuses the vectors '
484
+ + '--embeddings already stores). It REQUIRES --embeddings (no doc vectors ⇒ no-op) and '
485
+ + 'approximates true token-level ColBERT with a sentence embedder over query sub-units. '
486
+ + 'Measure on your repo (`npm run test:eval -- --embeddings --rerank '
487
+ + '--rerank-provider late-interaction`) before adopting.');
488
+ } else if (config.rerank.enabled) {
264
489
  out.push('Reranker improves rank-1 for Go/Python repositories (gin: 0.20→0.40) but has shown '
265
490
  + 'regressions on JavaScript repositories (express: 0.43→0.29). Behaviour depends on '
266
491
  + 'repository language. See README for per-language benchmark data.');
package/embeddings.mjs CHANGED
@@ -31,6 +31,16 @@ const PYTHON_DIR = join(__dirname, 'embedders', 'python');
31
31
 
32
32
  export const LOCAL_EMBED_MODEL_DEFAULT = 'Xenova/all-MiniLM-L6-v2';
33
33
  export const LOCAL_EMBED_DIM = 384;
34
+ // Code-specialized in-process embedder (provider 'code-local'). Same optional
35
+ // @huggingface/transformers feature-extraction path as the general-purpose 'local'
36
+ // MiniLM, but a model trained on code semantics (identifiers, call patterns, type
37
+ // names) where MiniLM is weakest. jina-embeddings-v2-base-code is 768-dim, MIT, ships
38
+ // first-party ONNX (transformers.js reads it directly, no trust_remote_code), supports
39
+ // an 8192-token context, and is ~160 MB (q8 quantized) on first download (air-gapped thereafter).
40
+ // Override with --code-embed-model / INDEXER_CODE_EMBED_MODEL (e.g. the Xenova mirror
41
+ // `Xenova/jina-embeddings-v2-base-code`, or `Xenova/codet5p-110m-embedding`).
42
+ export const CODE_EMBED_MODEL_DEFAULT = 'jinaai/jina-embeddings-v2-base-code';
43
+ export const CODE_EMBED_DIM = 768; // informational only — dim is derived lazily from the first vector.
34
44
  // Passed to the Python server at spawn time (argv) AND stamped into the .meta.json
35
45
  // sidecar so index time and query time always agree; the indexer's model-change
36
46
  // detection forces a clean re-embed when this differs. Override via config.mlxEmbedModel.
@@ -80,12 +90,13 @@ export async function localEmbedAvailable() {
80
90
  /**
81
91
  * Decide which provider to use for THIS index/run.
82
92
  * • INDEXER_EMBEDDINGS=off / embedProvider 'off' → no vectors (lexical-only).
83
- * • 'ollama' / 'local' / 'mlx' → forced (mlx is a native Python
84
- * embedder on the Apple Metal GPU; macOS-only).
93
+ * • 'ollama' / 'local' / 'code-local' / 'mlx' → forced (code-local is the
94
+ * in-process code-specialized embedder; mlx is a native Python embedder on the
95
+ * Apple Metal GPU, macOS-only).
85
96
  * • 'auto' (default) → running Ollama, else the bundled local model, else off.
86
97
  * Probes are injectable for deterministic tests.
87
98
  *
88
- * @returns {Promise<{provider:'ollama'|'local'|'mlx'|'off', model:(string|null)}>}
99
+ * @returns {Promise<{provider:'ollama'|'local'|'code-local'|'mlx'|'off', model:(string|null)}>}
89
100
  */
90
101
  export async function resolveEmbedProvider(config, { probe = probeOllama, hasLocal = localEmbedAvailable, hasModel = ollamaHasModel } = {}) {
91
102
  if (config.embeddingsEnabled === false) return { provider: 'off', model: null };
@@ -94,6 +105,9 @@ export async function resolveEmbedProvider(config, { probe = probeOllama, hasLoc
94
105
  if (want === 'off') return { provider: 'off', model: null };
95
106
  if (want === 'ollama') return { provider: 'ollama', model: config.embedModel };
96
107
  if (want === 'local') return { provider: 'local', model: localModel };
108
+ // code-local: forced, in-process, code-specialized. Never reached by `auto` (it
109
+ // is an explicit opt-in — `auto` stays MiniLM so the no-config path is unchanged).
110
+ if (want === 'code-local') return { provider: 'code-local', model: config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT };
97
111
  if (want === 'mlx') { assertProviderPlatform('mlx'); return { provider: 'mlx', model: config.mlxEmbedModel || MLX_EMBED_MODEL }; }
98
112
  // Prefer Ollama only when it actually HAS the configured model pulled — otherwise
99
113
  // the indexer would crash on the first batch. Fall back to in-process, then off.
@@ -154,7 +168,8 @@ async function _localPipeline(model) {
154
168
  catch {
155
169
  throw new Error(
156
170
  "Local embeddings need the optional '@huggingface/transformers' package. "
157
- + "Install it (`npm i @huggingface/transformers`) or set embedProvider to 'ollama'/'off'."
171
+ + "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
172
+ + "or set embedProvider to 'ollama'/'off'."
158
173
  );
159
174
  }
160
175
  _pipe = await mod.pipeline('feature-extraction', model);
@@ -173,6 +188,67 @@ async function _localEmbedMany(model, texts) {
173
188
  /** Reset the cached local pipeline (tests). */
174
189
  export function _resetLocalPipeline() { _pipe = null; _pipeModel = null; }
175
190
 
191
+ // Code-specialized in-process embedder (provider 'code-local'). A SEPARATE lazy
192
+ // singleton from _localPipeline so a code-local and a plain-local run in the same
193
+ // process don't evict each other's model. The import lives inside the function, so
194
+ // the default path (lexical / `auto` → MiniLM) NEVER loads the code model — it is
195
+ // pulled only when 'code-local' is actually selected and a vector is requested.
196
+ // `_codeLoads` lets tests prove that lazy-load contract without a network call.
197
+ let _codePipe = null, _codePipeModel = null, _codeLoads = 0;
198
+ async function _codeLocalPipeline(model) {
199
+ if (_codePipe && _codePipeModel === model) return _codePipe;
200
+ let mod;
201
+ try { mod = await import('@huggingface/transformers'); }
202
+ catch {
203
+ throw new Error(
204
+ "The code-local embedder needs the optional '@huggingface/transformers' package. "
205
+ + "Install it (`npm run embed:setup:local`, or `npm i @huggingface/transformers`) "
206
+ + "or set embedProvider to 'ollama'/'local'/'off'."
207
+ );
208
+ }
209
+ // q8 quantized weights: a 161M-param code model is ~4× faster on CPU at q8 with
210
+ // negligible recall loss (the same quantized-ONNX choice the cross-encoder makes),
211
+ // which is what makes an in-process code embedder practical on a laptop. transformers.js
212
+ // falls back to fp32 automatically if a model ships no quantized ONNX.
213
+ _codePipe = await mod.pipeline('feature-extraction', model, { dtype: 'q8' });
214
+ _codePipeModel = model;
215
+ _codeLoads++;
216
+ return _codePipe;
217
+ }
218
+ async function _codeLocalEmbedMany(model, texts) {
219
+ const pipe = await _codeLocalPipeline(model);
220
+ // jina-code is symmetric (no nomic-style query/document prefixes); mean-pool +
221
+ // normalize, identical handling to the MiniLM path, so cache keys / windowing are
222
+ // unchanged. The 8000-char cap matches search-core's EMBEDDING_CONTEXT_LIMIT.
223
+ const out = await pipe(
224
+ texts.map(t => (t.length > DOC_CHAR_LIMIT ? t.slice(0, DOC_CHAR_LIMIT) : t)),
225
+ { pooling: 'mean', normalize: true }
226
+ );
227
+ return out.tolist();
228
+ }
229
+ /** Reset the cached code-local pipeline (tests). */
230
+ export function _resetCodeLocalPipeline() { _codePipe = null; _codePipeModel = null; }
231
+ /** Number of times the code-local model has been lazy-loaded (tests: lazy-load contract). */
232
+ export function _codeLocalPipelineLoads() { return _codeLoads; }
233
+
234
+ /**
235
+ * Should the cached vectors be discarded before this run? True only when the previous
236
+ * build's embed-meta records a DIFFERENT model than the embedder about to run. Vectors
237
+ * of different models/dims must never be mixed in one `.bin` — a 768-dim code-local
238
+ * vector and a 384-dim MiniLM vector under the same cache would silently no-op the
239
+ * dense channel for the mismatched dim (scan/sketch skip a wrong-dim entry). Model name
240
+ * is the pre-embed signal (the actual dim is unknown until the first vector returns), so
241
+ * switching `--embed-provider local` → `code-local` forces a clean re-embed rather than a
242
+ * silent half-empty index.
243
+ *
244
+ * @param {object|null} prevMeta The prior `.embeddings.bin.meta.json` ({ provider, model, dim }).
245
+ * @param {string|null} model The model the current embedder will use.
246
+ * @returns {boolean}
247
+ */
248
+ export function embedModelChanged(prevMeta, model) {
249
+ return Boolean(prevMeta?.model && model && prevMeta.model !== model);
250
+ }
251
+
176
252
  // ─── Native Python embedder (mlx) ────────────────────────────────────────────
177
253
  // MLX (Apple Metal) is Python-first with no Node bindings, so it runs as a long-lived
178
254
  // child process speaking newline-delimited JSON:
@@ -400,6 +476,7 @@ export function _resetSubprocesses() {
400
476
  /** Canonical model id for a forced provider when no explicit model is supplied. */
401
477
  function defaultModelForProvider(provider, config) {
402
478
  if (provider === 'local') return config.localEmbedModel || LOCAL_EMBED_MODEL_DEFAULT;
479
+ if (provider === 'code-local') return config.codeEmbedModel || CODE_EMBED_MODEL_DEFAULT;
403
480
  if (provider === 'mlx') return config.mlxEmbedModel || MLX_EMBED_MODEL;
404
481
  return config.embedModel;
405
482
  }
@@ -411,10 +488,11 @@ function defaultModelForProvider(provider, config) {
411
488
  *
412
489
  * @param {object} config
413
490
  * @param {object} [opts]
414
- * @param {'ollama'|'local'|'mlx'|'off'} [opts.provider] Force a provider (e.g.
415
- * the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
491
+ * @param {'ollama'|'local'|'code-local'|'mlx'|'off'} [opts.provider] Force a provider
492
+ * (e.g. the one stamped in the index meta). When omitted, resolveEmbedProvider decides.
416
493
  * @param {string} [opts.model]
417
- * @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany } (tests).
494
+ * @param {object} [opts.backends] Inject { ollamaEmbedOne, ollamaEmbedMany, localEmbedMany,
495
+ * codeLocalEmbedMany } (tests).
418
496
  * @returns {Promise<{provider:string, model:(string|null), dim:(number|null),
419
497
  * embedQuery:(t:string)=>Promise<number[]|null>,
420
498
  * embedDocuments:(t:string[])=>Promise<number[][]|null>}>}
@@ -429,6 +507,7 @@ export async function createEmbedder(config, opts = {}) {
429
507
  const ollamaOne = b.ollamaEmbedOne || _ollamaEmbedOne;
430
508
  const ollamaMany = b.ollamaEmbedMany || _ollamaEmbedMany;
431
509
  const localMany = b.localEmbedMany || _localEmbedMany;
510
+ const codeLocalMany = b.codeLocalEmbedMany || _codeLocalEmbedMany;
432
511
  // Derive dim lazily from the first returned vector. Pre-setting LOCAL_EMBED_DIM=384
433
512
  // mis-stamps models whose dim isn't 384 (e.g. jina-embeddings-v2-base-code = 768)
434
513
  // into the .meta.json before the first embed call.
@@ -443,7 +522,8 @@ export async function createEmbedder(config, opts = {}) {
443
522
  if (v && dim == null) dim = v.length;
444
523
  return v ?? null;
445
524
  }
446
- const [v] = await localMany(model, [text]);
525
+ const many = provider === 'code-local' ? codeLocalMany : localMany;
526
+ const [v] = await many(model, [text]);
447
527
  if (v && dim == null) dim = v.length;
448
528
  return v ?? null;
449
529
  } catch { return null; } // graceful: caller drops to lexical
@@ -454,6 +534,7 @@ export async function createEmbedder(config, opts = {}) {
454
534
  let vecs;
455
535
  if (provider === 'ollama') vecs = await _withRetry(() => ollamaMany(host, model, texts));
456
536
  else if (provider === 'mlx') vecs = await _subprocessEmbedMany(provider, texts, model);
537
+ else if (provider === 'code-local') vecs = await codeLocalMany(model, texts);
457
538
  else vecs = await localMany(model, texts);
458
539
  if (vecs && vecs[0] && dim == null) dim = vecs[0].length;
459
540
  return vecs;
@@ -484,6 +565,7 @@ export function writeEmbedMeta(embeddingPath, meta) {
484
565
  export function describeEmbedder({ provider, model } = {}) {
485
566
  if (provider === 'ollama') return `🧠 Ollama · ${model}`;
486
567
  if (provider === 'local') return `🧠 Local (in-process) · ${model}`;
568
+ if (provider === 'code-local') return `🧠 Code-local (in-process, code-specialized) · ${model}`;
487
569
  if (provider === 'mlx') return `🧠 MLX (Apple Metal) · ${model}`;
488
570
  return '🔤 Lexical only';
489
571
  }
package/engine/binary.mjs CHANGED
@@ -65,6 +65,44 @@ export function readEmbeddingBinary(filePath) {
65
65
  return cache;
66
66
  }
67
67
 
68
+ /**
69
+ * B4 — read ONLY the requested keys' vectors from an `.embeddings.bin` in ONE pass, materialising a
70
+ * Float32Array only for matched keys (so it never holds the whole corpus in memory — but it does read
71
+ * the file once and scans until every requested key is found, which for sparsely-present keys means
72
+ * to end-of-file). The late-interaction reranker uses this to fetch a small candidate set's stored
73
+ * sub-vectors IDENTICALLY for both backends (same file, same keys) → parity-by-construction. Returns
74
+ * the matches as Map<key, Float32Array>; missing keys are simply absent.
75
+ *
76
+ * @param {string} filePath the `.embeddings.bin` path
77
+ * @param {Set<string>} keys the exact keys to extract (base / `|s` / `|wN` embedding keys)
78
+ * @returns {Map<string, Float32Array>}
79
+ */
80
+ export function collectVectorsByKey(filePath, keys) {
81
+ const out = new Map();
82
+ if (!keys || keys.size === 0 || !fs.existsSync(filePath)) return out;
83
+ const buf = fs.readFileSync(filePath);
84
+ if (buf.length < 4) return out;
85
+ // A corrupt (not merely truncated) bin can push `off` past the buffer; any decode error returns
86
+ // the partial Map so callers honour the "missing keys are simply absent / never throws" contract.
87
+ try {
88
+ let off = 0;
89
+ const count = buf.readUInt32LE(off); off += 4;
90
+ for (let i = 0; i < count && out.size < keys.size; i++) {
91
+ const hashLen = buf.readUInt32LE(off); off += 4;
92
+ const hash = buf.subarray(off, off + hashLen).toString('utf8'); off += hashLen;
93
+ const dim = buf.readUInt32LE(off); off += 4;
94
+ if (keys.has(hash)) {
95
+ const vec = new Float32Array(dim);
96
+ for (let d = 0; d < dim; d++) { vec[d] = buf.readFloatLE(off); off += 4; }
97
+ out.set(hash, vec);
98
+ } else {
99
+ off += dim * 4; // skip the float payload
100
+ }
101
+ }
102
+ } catch { /* corrupt framing — return whatever was decoded cleanly */ }
103
+ return out;
104
+ }
105
+
68
106
  /**
69
107
  * Append embedding entries to an `.embeddings.bin` file in place (creating it if
70
108
  * absent) and return the absolute float-data offset of each appended entry.