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
package/enrichment.mjs CHANGED
@@ -58,32 +58,67 @@ export function attachEnrichment(chunk, entry) {
58
58
  return Boolean(chunk.summary || chunk.concepts.length);
59
59
  }
60
60
 
61
- // Two-line output format optimised for small (1.5B–3B) models:
62
- // • SUMMARY — declarative sentence in developer vocabulary; goes into the vector
63
- // embedding as the leading field, aligned with nomic-embed-text's
64
- // search_document: training objective.
65
- // • TAGS — comma-separated domain keywords; joined into chunk.hyde so BM25
66
- // receives high-IDF concept terms instead of question stopword noise.
67
- //
68
- // One-shot example is mandatory for 1.5B models to reliably follow the format.
61
+ // Single-line SUMMARY keeps parseEnrichResponse trivial regardless of model capability.
62
+ // One-shot example is mandatory for small (1.5B) models to follow the format.
63
+ // TAGS → chunk.hyde gives BM25 high-IDF domain terms instead of question stopword noise.
69
64
  export const buildEnrichPrompt = (chunk) => {
70
- const code = (chunk.code_snippet || '').slice(0, 700);
65
+ const code = (chunk.code_snippet || '').slice(0, 1000);
71
66
  const base = path.basename(chunk.file_path);
72
- const ctx = chunk.class_context ? ` (method of class ${chunk.class_context})` : '';
73
- const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 200)}\n` : '';
67
+ const ctx = chunk.class_context ? ` (member of ${chunk.class_context})` : '';
68
+ const doc = chunk.docstring ? `Doc: ${chunk.docstring.slice(0, 240)}\n` : '';
74
69
  return (
75
- `Output exactly two lines for this ${chunk.node_type} "${chunk.name}"${ctx} in ${base}:\n`
76
- + `SUMMARY: one sentence describing what it DOES in plain developer vocabulary (the behavior, not the syntax)\n`
77
- + `TAGS: 5-8 comma-separated search keywords a developer would type to find this\n\n`
70
+ `You are indexing source code for semantic search. A developer will search by INTENT\n`
71
+ + `("where is the code that …", "how does it …") in plain words — NOT by the symbol name.\n\n`
72
+ + `Describe this ${chunk.node_type} "${chunk.name}"${ctx} in ${base} as exactly two lines:\n`
73
+ + `SUMMARY: one or two sentences on a SINGLE line, in the vocabulary a developer would\n`
74
+ + ` search with, covering: WHAT IT DOES and the DOMAIN it acts on; the PROBLEM it solves;\n`
75
+ + ` its key INPUTS and OUTPUTS; and any notable SIDE EFFECTS (I/O, state mutation, network\n`
76
+ + ` calls, errors thrown). Use concrete domain nouns and action verbs. Do NOT restate the\n`
77
+ + ` symbol name and do NOT use filler words like "function", "method", "class", "handles",\n`
78
+ + ` "manages", or "responsible for".\n`
79
+ + `TAGS: 5-8 comma-separated domain keywords a developer would type to find this code.\n\n`
78
80
  + `Example:\n`
79
- + `SUMMARY: validates JWT tokens and refreshes expired authentication sessions\n`
80
- + `TAGS: authentication, JWT, token validation, session refresh, middleware\n\n`
81
+ + `SUMMARY: validates JWT bearer tokens against the signing key and refreshes expired sessions, taking the incoming request's Authorization header and returning the decoded principal, rejecting tampered or expired tokens with a 401 so downstream handlers only ever see authenticated requests\n`
82
+ + `TAGS: authentication, JWT, token validation, session refresh, middleware, authorization\n\n`
81
83
  + doc
82
84
  + `Code:\n${code}\n`
83
85
  + `Output:`
84
86
  );
85
87
  };
86
88
 
89
+ // Preference ladder for `enrichment.model: "auto"` — pick the strongest code-aware
90
+ // model the local Ollama actually has pulled, falling back to the fast 1.5B floor.
91
+ // Enrichment is offline and cached, so a heavier model here costs nothing at query
92
+ // time; "auto" means "use the best you have" without the user naming it.
93
+ const ENRICH_MODEL_FLOOR = 'qwen2.5-coder:1.5b';
94
+ const ENRICH_MODEL_PREFERENCE = [
95
+ 'qwen2.5-coder:7b', 'qwen2.5-coder:3b', 'deepseek-coder-v2', 'qwen2.5-coder:1.5b',
96
+ 'codellama:13b', 'codellama:7b',
97
+ ];
98
+
99
+ /**
100
+ * Resolve the enrichment model. A concrete name is returned as-is; "auto" probes
101
+ * the Ollama model list and returns the strongest available code model (then any
102
+ * coder/code model, then the 1.5B floor). Best-effort and non-throwing — an
103
+ * unreachable Ollama yields the floor (enrichment then no-ops gracefully anyway).
104
+ *
105
+ * @param {string} model Configured model, or "auto".
106
+ * @param {string} ollamaHost
107
+ * @param {{timeoutMs?:number, fetchImpl?:typeof fetch}} [opts] fetchImpl injectable for tests.
108
+ */
109
+ export async function resolveEnrichModel(model, ollamaHost, { timeoutMs = 2000, fetchImpl = fetch } = {}) {
110
+ if (model && model !== 'auto') return model;
111
+ try {
112
+ const res = await fetchImpl(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
113
+ if (!res.ok) return ENRICH_MODEL_FLOOR;
114
+ const names = ((await res.json()).models || []).map(m => m.name);
115
+ const has = (p) => names.some(n => n === p || n === `${p}:latest` || n.startsWith(`${p}:`) || n.split(':')[0] === p);
116
+ for (const pref of ENRICH_MODEL_PREFERENCE) if (has(pref)) return pref;
117
+ const coder = names.find(n => /coder|code/i.test(n));
118
+ return coder || ENRICH_MODEL_FLOOR;
119
+ } catch { return ENRICH_MODEL_FLOOR; }
120
+ }
121
+
87
122
  /**
88
123
  * Default generator: a single non-streaming Ollama /api/generate call. Returns the
89
124
  * response text, or null on any failure (enrichment is best-effort and never fatal).
@@ -107,11 +142,31 @@ export async function ollamaGenerate(prompt, { model, ollamaHost, timeoutMs = 30
107
142
  }
108
143
  }
109
144
 
145
+ /**
146
+ * MLX-LM generator: calls a local mlx_lm.server (OpenAI-compatible /v1/completions).
147
+ * Start the server with: mlx_lm.server --model <model-name> [--port 8080]
148
+ * Returns the completion text, or null on any failure.
149
+ */
150
+ export async function mlxLmGenerate(prompt, { mlxLmHost = 'http://localhost:8080', timeoutMs = 60000 } = {}) {
151
+ try {
152
+ const res = await fetch(`${mlxLmHost}/v1/completions`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ prompt, max_tokens: 150, temperature: 0.1 }),
156
+ signal: AbortSignal.timeout(timeoutMs),
157
+ });
158
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
159
+ const data = await res.json();
160
+ return (data.choices?.[0]?.text || '').trim() || null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
110
166
  /**
111
167
  * Parse the SUMMARY + TAGS response into { summary, concepts, hyde }.
112
168
  *
113
- * hyde = concepts.join(' ') — a space-separated keyword string fed to BM25.
114
- * Concepts are domain terms only (stopwords scrubbed), so every token has
169
+ * Concepts are domain terms only (stopwords scrubbed) so every token has
115
170
  * meaningful IDF weight. Falls back to extracting non-stopword keywords from
116
171
  * the summary when the model omits the TAGS line (graceful degradation).
117
172
  */
@@ -142,7 +197,7 @@ export function parseEnrichResponse(text) {
142
197
 
143
198
  if (!summary && lines.length) summary = lines[0].replace(/^SUMMARY:\s*/i, '').trim();
144
199
 
145
- // Fallback: derive concepts from the summary when TAGS line is absent or empty.
200
+
146
201
  if (concepts.length === 0 && summary) {
147
202
  const words = summary.toLowerCase().match(/\b[a-z][a-z-]{2,}\b/g) || [];
148
203
  concepts.push(...words.filter(w => !_stop.has(w)).slice(0, 8));
@@ -196,11 +251,19 @@ export function parseRerankResponse(text, n) {
196
251
  }
197
252
 
198
253
  /**
199
- * Rerank the fused top-M results with a local LLM. Best-effort: any failure
200
- * (model unreachable, unparseable output) returns the original order.
254
+ * Rerank the fused top-M results with a local LLM: the judge's order wins outright
255
+ * for the head, the unjudged tail is preserved. Best-effort — any failure (model
256
+ * unreachable, unparseable output) returns the original order untouched.
257
+ *
258
+ * A reciprocal-rank BLEND of judge-order with retrieval-order was A/B-tested (to
259
+ * anchor strongly-retrieved hits against judge mistakes) and measured WORSE on
260
+ * both rank-1 and MRR while not recovering recall@k — so the straight reorder is
261
+ * what ships. Recall@k is protected upstream instead, by over-fetching a deep pool
262
+ * before reranking (see mcp/tools.mjs search_code) so the judge reorders real
263
+ * candidates rather than a thin top_k.
201
264
  *
202
265
  * @param {string} queryText
203
- * @param {Array<{score:number, chunk:object}>} results Fused results (mutated copy returned).
266
+ * @param {Array<{score:number, chunk:object}>} results Fused results (copy returned).
204
267
  * @param {object} opts
205
268
  * @param {(prompt:string)=>Promise<string|null>} opts.generate
206
269
  * @param {number} [opts.topM] How many leading results to rerank (default 8).
@@ -217,6 +280,101 @@ export async function rerankResults(queryText, results, { generate, topM = 8 } =
217
280
  return [...picked, ...rest, ...results.slice(topM)];
218
281
  }
219
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
+
220
378
  /**
221
379
  * Select the chunks worth enriching.
222
380
  *
@@ -270,9 +428,24 @@ export function selectCoreChunks(chunks, graph, { coreRatio = 1.0, maxChunks = 5
270
428
  * @returns {Promise<{enriched:number, attempted:number, cached:number}>}
271
429
  */
272
430
  export async function enrichCoreChunks(chunks, graph, config, { generate, concurrency, cachePath } = {}) {
273
- const { model, coreRatio, maxChunks } = config.enrichment;
431
+ const { coreRatio, maxChunks } = config.enrichment;
274
432
  const ollamaHost = config.ollamaHost;
275
- const gen = generate || ((prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 20000 }));
433
+ const mlxLmHost = config.mlxLmHost;
434
+ const useMlx = config.llmProvider === 'mlx';
435
+ // Resolve "auto" → the strongest code model the local Ollama has (1.5B floor).
436
+ // A pinned generate (tests) skips the network probe — model is only used to
437
+ // build the default generator and to stamp cache provenance.
438
+ const model = generate ? config.enrichment.model
439
+ : (useMlx ? config.enrichment.model : await resolveEnrichModel(config.enrichment.model, ollamaHost));
440
+ // 60 s timeout: a larger judge model on a busy/CPU-bound local Ollama can take
441
+ // 10–25 s per generation; the old 20 s cap made every call fail there and aborted
442
+ // enrichment entirely. Concurrency defaults low for the same reason — parallel
443
+ // requests to one local model thrash and slow each other (set higher only when
444
+ // OLLAMA_NUM_PARALLEL and the hardware support it).
445
+ const gen = generate
446
+ || (useMlx
447
+ ? (prompt) => mlxLmGenerate(prompt, { mlxLmHost, timeoutMs: 60000 })
448
+ : (prompt) => ollamaGenerate(prompt, { model, ollamaHost, timeoutMs: 60000 }));
276
449
  const slots = concurrency ?? config.enrichment.concurrency ?? 12;
277
450
  const cacheFile = cachePath === false ? null : (cachePath || config.enrichmentCachePath || null);
278
451
  const cache = cacheFile ? loadEnrichmentCache(cacheFile) : new Map();
@@ -292,22 +465,21 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
292
465
 
293
466
  const core = selectCoreChunks(chunks, graph, { coreRatio, maxChunks });
294
467
  if (core.length === 0 && cached === 0) {
295
- console.log('🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
468
+ console.log('\n 🧠 LLM enrichment: no core chunks selected (empty graph) — skipping.');
296
469
  return { enriched: 0, attempted: 0, cached: 0 };
297
470
  }
298
471
  const pending = core.filter(c => !c.summary && !(c.concepts?.length > 0));
299
472
 
300
473
  if (pending.length === 0) {
301
- console.log(`🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
474
+ console.log(`\n 🧠 LLM enrichment: all ${cached} core chunks served from cache.`);
302
475
  return { enriched: cached, attempted: 0, cached };
303
476
  }
304
477
 
305
- console.log(`🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
478
+ console.log(`\n 🧠 LLM enrichment: ${pending.length} chunks via ${model} (${cached} from cache, concurrency: ${slots}) …`);
306
479
  let enriched = 0, attempted = 0, failures = 0;
307
480
  let aborted = false;
308
481
 
309
- // Sliding-window pool: `slots` workers each drain the queue independently.
310
- // No batch boundaries — a worker immediately takes the next item when done.
482
+
311
483
  let cursor = 0;
312
484
  async function runWorker() {
313
485
  while (cursor < pending.length && !aborted) {
@@ -318,7 +490,7 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
318
490
  if (parsed) {
319
491
  chunk.summary = parsed.summary;
320
492
  chunk.concepts = parsed.concepts;
321
- chunk.hyde = parsed.hyde; // = concepts.join(' ') — domain keywords for BM25
493
+ chunk.hyde = parsed.hyde;
322
494
  if (chunk.content_hash) {
323
495
  cache.set(chunk.content_hash, { summary: parsed.summary, concepts: parsed.concepts, model });
324
496
  }
@@ -327,8 +499,10 @@ export async function enrichCoreChunks(chunks, graph, config, { generate, concur
327
499
  failures++;
328
500
  }
329
501
  process.stdout.write(`\r enriched ${enriched}/${pending.length} (failures: ${failures}) `);
330
- // Bail early if the model is clearly unreachable.
331
- if (attempted >= slots * 2 && enriched === 0) {
502
+ // Bail early if the model is clearly unreachable. Require a real minimum
503
+ // of attempts (not just slots×2) so a slow first generation at low
504
+ // concurrency doesn't abort a perfectly healthy run.
505
+ if (attempted >= Math.max(8, slots * 2) && enriched === 0) {
332
506
  aborted = true;
333
507
  console.log(`\n⚠️ LLM enrichment: generator unreachable (${failures} failures) — continuing without enrichment.`);
334
508
  }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * @file git-signals.mjs
3
+ * @description Lightweight, air-gapped git-history signals for ranking and
4
+ * blast-radius hints. Reads ONLY the local repository's commit log
5
+ * (no network, no remote, no code sent anywhere) and derives three
6
+ * cheap per-file signals competitors mostly ignore:
7
+ *
8
+ * • churn — how often a file changes (commit count);
9
+ * • recency — how recently it last changed (last-commit time);
10
+ * • co-change — which files historically change *together* (the
11
+ * "if you touch A you usually touch B" hint that
12
+ * sharpens the refactoring/blast-radius story).
13
+ *
14
+ * Computed once at index time into a sidecar (code-index.git.json),
15
+ * kept out of the storage contract and the ranking math: the
16
+ * co-change hint is surfaced by the blast-radius tools, and the
17
+ * recency/churn boost is applied opt-in in the tool layer (default
18
+ * off), so the measured retrieval ranking is unchanged by default.
19
+ * @author MaquinaTech <https://github.com/MaquinaTech>
20
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
21
+ * @license MIT
22
+ */
23
+ import { execFileSync } from 'child_process';
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+
27
+ // Record/field separators chosen to never collide with file paths.
28
+ const REC = '\x01';
29
+ const FS_ = '\x1f';
30
+
31
+ /**
32
+ * Collect git signals for a repository from its local commit log.
33
+ *
34
+ * Strictly local: shells out to `git log` on `repoRoot` and parses stdout. No
35
+ * remote is contacted. Returns `null` (a no-op) when the directory is not a git
36
+ * repo, git is unavailable, or there is no history — callers degrade silently.
37
+ *
38
+ * @param {string} repoRoot
39
+ * @param {object} [opts]
40
+ * @param {number} [opts.maxCommits=500] History depth (bounds cost + size).
41
+ * @param {number} [opts.topCoChange=8] Co-changed files kept per file.
42
+ * @param {number} [opts.maxFilesPerCommit=50] Skip sweeping commits (mass renames,
43
+ * formatting) whose co-change is noise.
44
+ * @returns {null | {generated:number, commits:number, maxChurn:number,
45
+ * churn:Object<string,number>, lastCommit:Object<string,number>,
46
+ * coChange:Object<string,Array<[string,number]>>}}
47
+ */
48
+ export function collectGitSignals(repoRoot, { maxCommits = 500, topCoChange = 8, maxFilesPerCommit = 50 } = {}) {
49
+ // Resolve the actual repo root. If `repoRoot` is a SUBDIRECTORY of a larger
50
+ // repo (e.g. indexing one package, or a fixture nested in this repo), a bare
51
+ // `git -C <dir> log` walks UP and returns the PARENT repo's history with
52
+ // parent-root-relative paths that never match this index's file paths. So we
53
+ // find the toplevel, scope the log to this subtree, and strip the prefix so
54
+ // emitted paths are relative to `repoRoot` (matching chunk.file_path).
55
+ let prefix = '';
56
+ try {
57
+ const toplevel = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--show-toplevel'],
58
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
59
+ if (!toplevel) return null;
60
+ // Compare REAL paths: git returns the physical toplevel, while repoRoot may
61
+ // contain symlinks (e.g. macOS /var → /private/var), which would otherwise
62
+ // produce a bogus prefix.
63
+ const realTop = (() => { try { return fs.realpathSync(toplevel); } catch { return toplevel; } })();
64
+ const realRoot = (() => { try { return fs.realpathSync(path.resolve(repoRoot)); } catch { return path.resolve(repoRoot); } })();
65
+ const rel = path.relative(realTop, realRoot).replace(/\\/g, '/');
66
+ prefix = rel && rel !== '.' ? rel : '';
67
+ } catch {
68
+ return null; // not a git repo / git missing — air-gapped no-op
69
+ }
70
+
71
+ let raw;
72
+ try {
73
+ const args = ['-C', repoRoot, 'log', '--no-merges', `-n${maxCommits}`,
74
+ `--pretty=format:${REC}%H${FS_}%ct`, '--name-only'];
75
+ if (prefix) args.push('--', '.'); // limit to this subtree (paths stay toplevel-relative)
76
+ raw = execFileSync('git', args,
77
+ { encoding: 'utf-8', maxBuffer: 128 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
78
+ } catch {
79
+ return null;
80
+ }
81
+ if (!raw || !raw.includes(REC)) return null;
82
+ const stripPrefix = prefix ? prefix + '/' : '';
83
+
84
+ const churn = Object.create(null);
85
+ const lastCommit = Object.create(null);
86
+ const pairCounts = new Map(); // file → Map<coFile, count>
87
+ let commits = 0;
88
+
89
+ const bump = (f, ts) => {
90
+ churn[f] = (churn[f] || 0) + 1;
91
+ if (ts > (lastCommit[f] || 0)) lastCommit[f] = ts;
92
+ };
93
+
94
+ for (const block of raw.split(REC)) {
95
+ if (!block) continue;
96
+ const nl = block.indexOf('\n');
97
+ const header = nl === -1 ? block : block.slice(0, nl);
98
+ const ts = parseInt(header.split(FS_)[1], 10);
99
+ if (!Number.isFinite(ts)) continue;
100
+ commits++;
101
+
102
+ const files = [];
103
+ if (nl !== -1) {
104
+ for (const line of block.slice(nl + 1).split('\n')) {
105
+ let f = line.trim();
106
+ if (!f) continue;
107
+ // git quotes paths with unusual chars: "src/é.ts" — unwrap.
108
+ if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1);
109
+ if (stripPrefix) {
110
+ if (!f.startsWith(stripPrefix)) continue; // outside the indexed subtree
111
+ f = f.slice(stripPrefix.length);
112
+ }
113
+ files.push(f);
114
+ }
115
+ }
116
+ for (const f of files) bump(f, ts);
117
+
118
+ // Co-change: only for focused commits — a 400-file sweep links nothing useful.
119
+ if (files.length >= 2 && files.length <= maxFilesPerCommit) {
120
+ for (let i = 0; i < files.length; i++) {
121
+ const a = files[i];
122
+ let row = pairCounts.get(a);
123
+ if (!row) { row = new Map(); pairCounts.set(a, row); }
124
+ for (let j = 0; j < files.length; j++) {
125
+ if (i === j) continue;
126
+ const b = files[j];
127
+ row.set(b, (row.get(b) || 0) + 1);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ if (commits === 0) return null;
134
+
135
+ // Stamp HEAD so clients can report "indexed at commit X" and detect drift
136
+ // (the freshness contract). Best-effort — absence just omits the commit.
137
+ let head = null;
138
+ try {
139
+ head = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
140
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
141
+ } catch { /* detached/empty — no stamp */ }
142
+
143
+ const coChange = Object.create(null);
144
+ for (const [file, row] of pairCounts) {
145
+ const top = [...row.entries()]
146
+ .sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))
147
+ .slice(0, topCoChange);
148
+ if (top.length) coChange[file] = top;
149
+ }
150
+
151
+ let maxChurn = 1;
152
+ for (const k in churn) if (churn[k] > maxChurn) maxChurn = churn[k];
153
+
154
+ return { generated: Math.floor(Date.now() / 1000), head, commits, maxChurn, churn, lastCommit, coChange };
155
+ }
156
+
157
+ /** Persist signals next to the index (best-effort). */
158
+ export function writeGitSignals(filePath, signals) {
159
+ try { fs.writeFileSync(filePath, JSON.stringify(signals)); return true; }
160
+ catch { return false; }
161
+ }
162
+
163
+ /** Load a signals sidecar, or null if absent/corrupt. */
164
+ export function loadGitSignals(filePath) {
165
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); }
166
+ catch { return null; }
167
+ }
168
+
169
+ /** Files that historically change together with `file`, strongest first. */
170
+ export function coChangesFor(signals, file, limit = 5) {
171
+ if (!signals || !signals.coChange) return [];
172
+ const row = signals.coChange[file];
173
+ if (!row) return [];
174
+ return row.slice(0, limit).map(([f, count]) => ({ file: f, count }));
175
+ }
176
+
177
+ // ─── Index freshness (query-time drift detection) ────────────────────────────
178
+ // The blast-radius tools are only trustworthy if the index reflects the working
179
+ // tree. We surface a compact freshness signal on tool responses: index age, the
180
+ // commit it was built at (stamped above), and whether the tree has drifted since.
181
+ // The git read is cheap and cached briefly so a burst of tool calls costs one
182
+ // `git status`; it degrades silently (null) outside a git repo.
183
+
184
+ const _SRC_RE = /\.(js|jsx|mjs|cjs|ts|tsx|py|go|rs|java|kt|cs|rb|php|swift|c|h|cc|cpp|hpp|scss|css|sh|bash)$/i;
185
+ let _gitStateCache = null; // { at, root, head, dirtyCount }
186
+
187
+ /** Reset the cached working-tree state (tests). */
188
+ export function _resetGitStateCache() { _gitStateCache = null; }
189
+
190
+ /**
191
+ * Cheap, cached working-tree state: current short HEAD + count of changed SOURCE
192
+ * files (uncommitted). Cached `ttlMs` per root so a burst of tool calls shells out
193
+ * at most once. `exec`/`nowMs` injectable for tests. Returns {head:null,dirtyCount:null}
194
+ * outside a git repo.
195
+ */
196
+ export function currentGitState(repoRoot, { ttlMs = 5000, nowMs = Date.now(), exec = execFileSync } = {}) {
197
+ if (_gitStateCache && _gitStateCache.root === repoRoot && (nowMs - _gitStateCache.at) < ttlMs) return _gitStateCache;
198
+ let head = null, dirtyCount = null;
199
+ try {
200
+ head = exec('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'],
201
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
202
+ const status = exec('git', ['-C', repoRoot, 'status', '--porcelain'],
203
+ { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
204
+ dirtyCount = status.split('\n').filter(l => l.trim() && _SRC_RE.test(l)).length;
205
+ } catch { /* not a git repo / git missing → unknown */ }
206
+ _gitStateCache = { at: nowMs, root: repoRoot, head, dirtyCount };
207
+ return _gitStateCache;
208
+ }
209
+
210
+ /**
211
+ * Derive the freshness verdict from index age + the stamped index commit + the
212
+ * current working-tree state + whether the watch daemon is live. Pure (no I/O) so
213
+ * it is trivially testable.
214
+ *
215
+ * @returns {{ageSeconds:number|null, ageLabel:string, indexedCommit:string|null,
216
+ * currentCommit:string|null, pendingChanges:number|null, commitMoved:boolean,
217
+ * daemonRunning:boolean, stale:boolean, syncing:boolean}}
218
+ */
219
+ export function computeFreshness({ ageSeconds = null, indexedCommit = null, current = null, daemonRunning = false } = {}) {
220
+ const ageLabel = ageSeconds == null ? 'unknown'
221
+ : ageSeconds < 60 ? `${ageSeconds}s` : ageSeconds < 3600 ? `${Math.floor(ageSeconds / 60)}m` : `${Math.floor(ageSeconds / 3600)}h`;
222
+ const currentCommit = current?.head ?? null;
223
+ const pendingChanges = current?.dirtyCount ?? null;
224
+ const commitMoved = Boolean(indexedCommit && currentCommit && indexedCommit !== currentCommit);
225
+ const hasPending = (pendingChanges != null && pendingChanges > 0) || commitMoved;
226
+ // A live daemon syncs changes incrementally, so pending work is "syncing", not
227
+ // "stale". Only when no daemon is keeping the index fresh is drift truly stale.
228
+ return {
229
+ ageSeconds, ageLabel, indexedCommit, currentCommit, pendingChanges, commitMoved,
230
+ daemonRunning, stale: hasPending && !daemonRunning, syncing: hasPending && daemonRunning,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * A bounded 0..1 "this file is hot" score combining recency and churn, for the
236
+ * OPT-IN ranking boost only. Returns 0 when there is no signal for the file, so a
237
+ * disabled or signal-less repo never perturbs ranking.
238
+ */
239
+ export function gitBoostScore(signals, file, nowSec = Date.now() / 1000) {
240
+ if (!signals) return 0;
241
+ const churn = (signals.churn && signals.churn[file]) || 0;
242
+ const last = (signals.lastCommit && signals.lastCommit[file]) || 0;
243
+ if (!churn && !last) return 0;
244
+ const ageDays = last ? Math.max(0, (nowSec - last) / 86400) : 3650;
245
+ const recency = Math.exp(-ageDays / 90); // ~quarter half-life
246
+ const churnScore = Math.log1p(churn) / Math.log1p(signals.maxChurn || 1); // 0..1
247
+ return 0.5 * recency + 0.5 * churnScore;
248
+ }