sigmap 8.13.0 → 8.16.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.
@@ -1,12 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Evidence Pack v1 (v8.0 E1).
4
+ * Evidence Pack v2 (v8.0 E1; schema v2 adds a published schema URL, per-file
5
+ * multi-factor risk labels, measured test-discovery provenance, and generator
6
+ * identity — all additive over v1).
5
7
  *
6
8
  * A deterministic, machine-consumable signature-and-evidence map. Replaces the
7
9
  * "paste this into your prompt" workflow with a byte-stable JSON artifact that
8
10
  * an agent or CI can ingest directly — every entry anchored to a real file,
9
- * symbol, and line range.
11
+ * symbol, and line range. The published schema lives at
12
+ * docs-vp/public/schemas/evidence-pack-2.json (sigmap.io/schemas/).
10
13
  *
11
14
  * Composed entirely from shipped zero-dep modules:
12
15
  * - retrieval/ranker → ranked files, scores, signals
@@ -26,10 +29,21 @@ const crypto = require('crypto');
26
29
  const { buildSigIndex, rank, detectIntent } = require('../retrieval/ranker');
27
30
  const { scan } = require('../security/scanner');
28
31
 
29
- const SCHEMA_VERSION = '1.0';
32
+ const SCHEMA_VERSION = '2.0';
33
+ const SCHEMA_URL = 'https://sigmap.io/schemas/evidence-pack-2.json';
30
34
  const DEFAULT_BUDGET = 6000;
31
35
  const DEFAULT_TOP = 12;
32
36
 
37
+ // Measured accuracy of the stem-affix test-discovery method (C2). Constants
38
+ // are sourced from the committed benchmarks/reports/test-discovery.json and
39
+ // guarded by a test that fails on drift — re-run `npm run
40
+ // benchmark:test-discovery` and update together, never hand-invent.
41
+ const TEST_DISCOVERY = {
42
+ method: 'stem-affix-match',
43
+ measured: { f1: 0.98, precision: 0.971, recall: 0.988, pairs: 3701, repos: 28 },
44
+ benchmark: 'npm run benchmark:test-discovery',
45
+ };
46
+
33
47
  const GENERATED_RE = /(^|\/)(dist|build|out|vendor|node_modules)\/|\.(generated|min|bundle)\.|\.(pb|_pb)\.|\.pb\.go$|_pb2\.py$/;
34
48
  const TEST_RE = /(^|\/)(tests?|__tests__|spec|specs)\/|\.(test|spec)\.[a-z]+$|(^|\/)test_[^/]+\.py$|_test\.(go|py|rb)$/;
35
49
  const CONFIG_RE = /\.(json|ya?ml|toml|ini|conf|config|properties|env)$|(^|\/)(\.?[a-z]+rc)$|\.config\.[a-z]+$/i;
@@ -68,16 +82,29 @@ function parseAnchor(sig) {
68
82
  * @returns {'generated'|'test'|'migration'|'payment'|'auth'|'security'|'config'|'public-api'|'source'}
69
83
  */
70
84
  function riskLabelFor(relPath) {
85
+ return riskFactorsFor(relPath)[0];
86
+ }
87
+
88
+ /**
89
+ * Every risk category a file matches, in the same strict precedence order
90
+ * riskLabelFor uses (schema v2). Where v1 collapsed a migration touching
91
+ * payments to `migration`, the factors list carries both. Always non-empty:
92
+ * a file matching nothing is `['source']`.
93
+ * @param {string} relPath
94
+ * @returns {string[]}
95
+ */
96
+ function riskFactorsFor(relPath) {
71
97
  const p = relPath.replace(/\\/g, '/');
72
- if (GENERATED_RE.test(p)) return 'generated';
73
- if (TEST_RE.test(p)) return 'test';
74
- if (MIGRATION_RE.test(p)) return 'migration';
75
- if (PAYMENT_RE.test(p)) return 'payment';
76
- if (AUTH_RE.test(p)) return 'auth';
77
- if (SECURITY_RE.test(p)) return 'security';
78
- if (CONFIG_RE.test(p)) return 'config';
79
- if (PUBLIC_API_RE.test(p)) return 'public-api';
80
- return 'source';
98
+ const factors = [];
99
+ if (GENERATED_RE.test(p)) factors.push('generated');
100
+ if (TEST_RE.test(p)) factors.push('test');
101
+ if (MIGRATION_RE.test(p)) factors.push('migration');
102
+ if (PAYMENT_RE.test(p)) factors.push('payment');
103
+ if (AUTH_RE.test(p)) factors.push('auth');
104
+ if (SECURITY_RE.test(p)) factors.push('security');
105
+ if (CONFIG_RE.test(p)) factors.push('config');
106
+ if (PUBLIC_API_RE.test(p)) factors.push('public-api');
107
+ return factors.length ? factors : ['source'];
81
108
  }
82
109
 
83
110
  /** Filename stem (basename minus the first extension chain). */
@@ -209,6 +236,7 @@ function buildEvidencePack(query, cwd, opts = {}) {
209
236
  if (start !== null) sourceLines.push({ symbol, start, end });
210
237
  }
211
238
 
239
+ const riskFactors = riskFactorsFor(r.file);
212
240
  files.push({
213
241
  path: r.file,
214
242
  symbols,
@@ -216,7 +244,8 @@ function buildEvidencePack(query, cwd, opts = {}) {
216
244
  confidence: maxScore > 0 ? Math.round((r.score / maxScore) * 100) / 100 : 0,
217
245
  sourceLines,
218
246
  relatedTests: findRelatedTests(r.file, allFiles),
219
- riskLabel: riskLabelFor(r.file),
247
+ riskLabel: riskFactors[0],
248
+ riskFactors,
220
249
  });
221
250
  }
222
251
 
@@ -225,11 +254,14 @@ function buildEvidencePack(query, cwd, opts = {}) {
225
254
 
226
255
  const pack = {
227
256
  schemaVersion: SCHEMA_VERSION,
257
+ schemaUrl: SCHEMA_URL,
258
+ generator: { name: 'sigmap', version: typeof opts.version === 'string' ? opts.version : null },
228
259
  query,
229
260
  intent,
230
261
  files,
231
262
  tokenBudget: { limit: budget, used, remaining: Math.max(0, budget - used) },
232
263
  droppedFiles,
264
+ testDiscovery: TEST_DISCOVERY,
233
265
  grounding: {
234
266
  symbolCount,
235
267
  anchoredSymbols,
@@ -271,7 +303,8 @@ function formatMarkdown(pack) {
271
303
  L.push('');
272
304
 
273
305
  for (const f of pack.files) {
274
- L.push(`## \`${f.path}\` _(${f.riskLabel}, confidence ${f.confidence})_`);
306
+ const risk = (f.riskFactors && f.riskFactors.length > 1) ? f.riskFactors.join(' + ') : f.riskLabel;
307
+ L.push(`## \`${f.path}\` _(${risk}, confidence ${f.confidence})_`);
275
308
  L.push(`_${f.reason}_`);
276
309
  if (f.relatedTests.length) L.push(`Related tests: ${f.relatedTests.map((t) => `\`${t}\``).join(', ')}`);
277
310
  L.push('');
@@ -296,6 +329,9 @@ module.exports = {
296
329
  formatMarkdown,
297
330
  parseAnchor,
298
331
  riskLabelFor,
332
+ riskFactorsFor,
299
333
  findRelatedTests,
300
334
  SCHEMA_VERSION,
335
+ SCHEMA_URL,
336
+ TEST_DISCOVERY,
301
337
  };
@@ -1,14 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Method/caller-level call-graph (D4 v1).
4
+ * Method/caller-level call-graph (D4 v1, languages expanded in GR1).
5
5
  *
6
- * Builds symbol-level edges — which function calls which function — for JS/TS
7
- * and Python. Deterministic, zero-dependency, regex + brace/indent matching.
8
- * Call sites are resolved with high precision: a call resolves to a definition
9
- * of that name in the *same file* first, then in a *directly-imported* file
10
- * (via the existing file-level import graph). Names that resolve to no repo
11
- * definition produce no edge — over-approximation noise is avoided.
6
+ * Builds symbol-level edges — which function calls which function — for JS/TS,
7
+ * Python, Java, Go, and Rust. Deterministic, zero-dependency, regex +
8
+ * brace/indent matching. Call sites are resolved with high precision: a call
9
+ * resolves to a definition of that name in the *same file* first, then in a
10
+ * *directly-imported* file (via the existing file-level import graph). Names
11
+ * that resolve to no repo definition produce no edge — over-approximation
12
+ * noise is avoided. Constructs that can't be parsed dependency-free are
13
+ * skipped (less fidelity, never a parser dep).
12
14
  *
13
15
  * Symbol IDs are `relPath#symbolName` (forward-slashed, relative to cwd).
14
16
  *
@@ -21,6 +23,9 @@ const { build } = require('./builder');
21
23
 
22
24
  const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
23
25
  const PY_EXTS = new Set(['.py', '.pyw']);
26
+ const JAVA_EXTS = new Set(['.java']);
27
+ const GO_EXTS = new Set(['.go']);
28
+ const RS_EXTS = new Set(['.rs']);
24
29
 
25
30
  // Tokens that look like `name(` calls or definition headers but are language
26
31
  // keywords, not user symbols — never treated as a call or a definition.
@@ -30,6 +35,7 @@ const NON_CALL = new Set([
30
35
  'in', 'of', 'case', 'throw', 'print', 'and', 'or', 'not', 'assert',
31
36
  'lambda', 'class', 'def', 'elif', 'except', 'finally', 'raise', 'import',
32
37
  'from', 'global', 'nonlocal', 'del', 'pass', 'async', 'require', 'constructor',
38
+ 'synchronized',
33
39
  ]);
34
40
 
35
41
  function normalizePath(p) { return path.normalize(p).toLowerCase(); }
@@ -58,6 +64,32 @@ function maskJs(src) {
58
64
  return out.join('');
59
65
  }
60
66
 
67
+ // Rust: `//`, `/* */`, and `"..."` mask like JS, but a bare `'` is usually a
68
+ // lifetime (`'a`), not a string — masking to the "closing" quote would corrupt
69
+ // offsets. Only char literals (`'x'`, `'\n'`) are masked; lifetimes pass through.
70
+ function maskRust(src) {
71
+ const out = src.split('');
72
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
73
+ let i = 0; const n = src.length;
74
+ while (i < n) {
75
+ const c = src[i], d = src[i + 1];
76
+ if (c === '/' && d === '/') { let j = i + 2; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
77
+ if (c === '/' && d === '*') { let j = i + 2; while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++; j = Math.min(n, j + 2); blank(i, j); i = j; continue; }
78
+ if (c === '"') {
79
+ let j = i + 1;
80
+ while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === '"') break; j++; }
81
+ j = Math.min(n, j + 1); blank(i, j); i = j; continue;
82
+ }
83
+ if (c === "'") {
84
+ if (d === '\\' && src[i + 3] === "'") { blank(i, i + 4); i += 4; continue; } // '\n'
85
+ if (d !== undefined && src[i + 2] === "'") { blank(i, i + 3); i += 3; continue; } // 'x'
86
+ i++; continue; // lifetime `'a` — leave untouched
87
+ }
88
+ i++;
89
+ }
90
+ return out.join('');
91
+ }
92
+
61
93
  function maskPy(src) {
62
94
  const out = src.split('');
63
95
  const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
@@ -187,10 +219,98 @@ function pyDefs(masked) {
187
219
  return defs;
188
220
  }
189
221
 
222
+ // Go: func name(...) { } | func (r Recv) name(...) (T, error) { }
223
+ // The return list may itself be parenthesized, so scan past it to the body `{`.
224
+ function goDefs(masked) {
225
+ const defs = [];
226
+ const re = /(?:^|\n)func\s+(?:\([^)\n]*\)\s*)?([A-Za-z_]\w*)\s*\(/g;
227
+ let m;
228
+ while ((m = re.exec(masked)) !== null) {
229
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
230
+ const close = matchDelim(masked, paren, '(', ')');
231
+ let k = close + 1;
232
+ let depth = 0;
233
+ while (k < masked.length) {
234
+ const ch = masked[k];
235
+ if (ch === '(') depth++;
236
+ else if (ch === ')') depth--;
237
+ else if (ch === '{' && depth === 0) break;
238
+ else if (ch === '\n' && depth === 0) { k = -1; break; } // no body on this header
239
+ k++;
240
+ }
241
+ if (k === -1 || k >= masked.length) continue;
242
+ defs.push({ name: m[1], line: lineAt(masked, m.index + 1), bodyStart: k, bodyEnd: matchDelim(masked, k, '{', '}') });
243
+ }
244
+ return defs;
245
+ }
246
+
247
+ // Java: methods + constructors with braced bodies. Statement-shaped matches
248
+ // (calls, control flow) are rejected because their `)` is followed by `;`,
249
+ // and keyword headers (`if`, `while`, …) fall to the NON_CALL guard.
250
+ function javaDefs(masked) {
251
+ const defs = [];
252
+ const seen = new Set();
253
+ const re = /(?:^|\n)[ \t]*((?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*)(?:<[^>\n]{0,80}>\s*)?(?:[\w$][\w$.<>\[\],?\s]*?\s+)?([A-Za-z_$][\w$]*)\s*\(/g;
254
+ let m;
255
+ while ((m = re.exec(masked)) !== null) {
256
+ const name = m[2];
257
+ if (NON_CALL.has(name)) continue;
258
+ // `new Foo() { … }` anonymous classes are uses, not definitions.
259
+ const before = masked.slice(Math.max(0, m.index), m.index + m[0].length - name.length - 1);
260
+ if (/\bnew\s*$/.test(before)) continue;
261
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
262
+ const close = matchDelim(masked, paren, '(', ')');
263
+ // skip `throws A, B` up to the body `{` (same line — multi-line headers are skipped)
264
+ let k = close + 1;
265
+ while (k < masked.length && masked[k] !== '{' && masked[k] !== ';' && masked[k] !== '\n' && masked[k] !== '=') k++;
266
+ if (masked[k] !== '{') continue;
267
+ const key = name + ':' + k;
268
+ if (seen.has(key)) continue;
269
+ seen.add(key);
270
+ defs.push({ name, line: lineAt(masked, m.index + 1), bodyStart: k, bodyEnd: matchDelim(masked, k, '{', '}') });
271
+ }
272
+ return defs;
273
+ }
274
+
275
+ // Rust: fn name(...) { } | fn name<T>(...) -> T where … { } — inside or
276
+ // outside impl/trait blocks. A `;` before the body brace (trait declaration)
277
+ // means no body: skipped.
278
+ function rustDefs(masked) {
279
+ const defs = [];
280
+ const re = /\bfn\s+([A-Za-z_]\w*)/g;
281
+ let m;
282
+ while ((m = re.exec(masked)) !== null) {
283
+ let k = m.index + m[0].length;
284
+ while (k < masked.length && /\s/.test(masked[k])) k++;
285
+ if (masked[k] === '<') k = matchDelim(masked, k, '<', '>') + 1;
286
+ while (k < masked.length && /\s/.test(masked[k])) k++;
287
+ if (masked[k] !== '(') continue;
288
+ const close = matchDelim(masked, k, '(', ')');
289
+ // return type / where clause may span lines; stop at body `{` or decl `;`
290
+ let b = close + 1;
291
+ while (b < masked.length && masked[b] !== '{' && masked[b] !== ';') b++;
292
+ if (masked[b] !== '{') continue;
293
+ defs.push({ name: m[1], line: lineAt(masked, m.index), bodyStart: b, bodyEnd: matchDelim(masked, b, '{', '}') });
294
+ }
295
+ return defs;
296
+ }
297
+
298
+ // Pick the masker whose comment/string syntax matches the language.
299
+ // Java and Go share JS syntax (Go raw strings mask like template literals).
300
+ function maskFor(filePath, src) {
301
+ const ext = path.extname(filePath).toLowerCase();
302
+ if (PY_EXTS.has(ext)) return maskPy(src);
303
+ if (RS_EXTS.has(ext)) return maskRust(src);
304
+ return maskJs(src);
305
+ }
306
+
190
307
  function extractDefs(filePath, src) {
191
308
  const ext = path.extname(filePath).toLowerCase();
192
309
  if (JS_EXTS.has(ext)) return jsDefs(maskJs(src));
193
310
  if (PY_EXTS.has(ext)) return pyDefs(maskPy(src));
311
+ if (JAVA_EXTS.has(ext)) return javaDefs(maskJs(src));
312
+ if (GO_EXTS.has(ext)) return goDefs(maskJs(src));
313
+ if (RS_EXTS.has(ext)) return rustDefs(maskRust(src));
194
314
  return null; // unsupported language
195
315
  }
196
316
 
@@ -221,7 +341,7 @@ function _walk(dir, excludeSet, out, depth) {
221
341
  if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1);
222
342
  else if (e.isFile()) {
223
343
  const ext = path.extname(e.name).toLowerCase();
224
- if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) out.push(full);
344
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext) || JAVA_EXTS.has(ext) || GO_EXTS.has(ext) || RS_EXTS.has(ext)) out.push(full);
225
345
  }
226
346
  }
227
347
  }
@@ -288,10 +408,20 @@ function buildCallGraph(cwd, opts = {}) {
288
408
  };
289
409
 
290
410
  for (const [f, fileDefs] of perFileDefs.entries()) {
291
- const masked = JS_EXTS.has(path.extname(f).toLowerCase()) ? maskJs(fs.readFileSync(f, 'utf8')) : maskPy(fs.readFileSync(f, 'utf8'));
411
+ const masked = maskFor(f, fs.readFileSync(f, 'utf8'));
292
412
  // resolution scope: this file's defs, then directly-imported files' defs
293
413
  const importedAbs = (fileGraph.forward.get(normalizePath(path.resolve(f))) || [])
294
414
  .map((nf) => normToAbs.get(nf)).filter(Boolean);
415
+ // Go/Java: same-package symbols are visible with no import statement, and
416
+ // a package is (in practice) a directory — extend the scope to same-dir
417
+ // same-language siblings. Sorted for deterministic resolution order.
418
+ const ext = path.extname(f).toLowerCase();
419
+ if (GO_EXTS.has(ext) || JAVA_EXTS.has(ext)) {
420
+ const siblings = [...perFileDefs.keys()]
421
+ .filter((o) => o !== f && path.dirname(o) === path.dirname(f) && path.extname(o).toLowerCase() === ext)
422
+ .sort();
423
+ importedAbs.push(...siblings);
424
+ }
295
425
  for (const d of fileDefs) {
296
426
  const callerId = symId(cwd, f, d.name);
297
427
  if (!forward.has(callerId)) forward.set(callerId, new Set()); // ensure node exists
@@ -315,6 +445,42 @@ function buildCallGraph(cwd, opts = {}) {
315
445
  return { forward: toArr(forward), reverse: toArr(reverse), defs };
316
446
  }
317
447
 
448
+ /**
449
+ * Collapse the symbol-level call-graph to FILE-level bidirectional edges for
450
+ * the ranker's neighbor boost (opt-in via `retrieval.callGraphBoost`). A file
451
+ * whose functions call into — or are called by — another file gets an edge in
452
+ * both directions. Keys are `path.resolve`-form absolute paths (matching the
453
+ * ranker's lookups); entries and neighbor lists are sorted for determinism.
454
+ *
455
+ * @param {string} cwd
456
+ * @param {object} [opts] { graph } to inject a prebuilt call graph (tests)
457
+ * @returns {{ forward: Map<string,string[]> }}
458
+ */
459
+ function buildCallFileGraph(cwd, opts = {}) {
460
+ const graph = opts.graph || buildCallGraph(cwd, opts);
461
+ const edges = new Map(); // absFile → Set<absFile>
462
+ const add = (a, b) => {
463
+ if (a === b) return;
464
+ if (!edges.has(a)) edges.set(a, new Set());
465
+ edges.get(a).add(b);
466
+ };
467
+ for (const [callerId, calleeIds] of graph.forward.entries()) {
468
+ const callerDef = graph.defs.get(callerId);
469
+ if (!callerDef) continue;
470
+ for (const calleeId of calleeIds) {
471
+ const calleeDef = graph.defs.get(calleeId);
472
+ if (!calleeDef || calleeDef.file === callerDef.file) continue;
473
+ const a = path.resolve(cwd, callerDef.file);
474
+ const b = path.resolve(cwd, calleeDef.file);
475
+ add(a, b);
476
+ add(b, a);
477
+ }
478
+ }
479
+ const forward = new Map();
480
+ for (const k of [...edges.keys()].sort()) forward.set(k, [...edges.get(k)].sort());
481
+ return { forward };
482
+ }
483
+
318
484
  // Resolve a user-supplied symbol (bare name or full `file#name` id) to ids.
319
485
  function _resolveSymbol(symbol, defs) {
320
486
  if (defs.has(symbol)) return [symbol];
@@ -395,7 +561,7 @@ function formatCallGraphJSON(result, kind) {
395
561
  }
396
562
 
397
563
  module.exports = {
398
- buildCallGraph, methodImpact, methodCallees,
564
+ buildCallGraph, buildCallFileGraph, methodImpact, methodCallees,
399
565
  formatCallGraph, formatCallGraphJSON,
400
- extractDefs, maskJs, maskPy,
566
+ extractDefs, maskJs, maskPy, maskRust,
401
567
  };
@@ -421,7 +421,16 @@ function queryContext(args, cwd) {
421
421
  // Build dependency graph for neighbor boost — non-fatal if it fails
422
422
  let graph = null;
423
423
  try { graph = buildFromCwd(cwd); } catch (_) {}
424
- const results = rank(args.query, index, { topK, cwd, graph });
424
+ // Opt-in call-graph neighbor boost (retrieval.callGraphBoost) non-fatal
425
+ let callGraph = null;
426
+ try {
427
+ const { loadConfig } = require('../config/loader');
428
+ const retrieval = loadConfig(cwd).retrieval;
429
+ if (retrieval && retrieval.callGraphBoost) {
430
+ callGraph = require('../graph/call-graph').buildCallFileGraph(cwd);
431
+ }
432
+ } catch (_) {}
433
+ const results = rank(args.query, index, { topK, cwd, graph, callGraph });
425
434
  return formatRankTable(results, args.query);
426
435
  } catch (err) {
427
436
  return `_query_context failed: ${err.message}_`;
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.13.0',
21
+ version: '8.16.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
package/src/mcp/tools.js CHANGED
@@ -154,7 +154,7 @@ const TOOLS = [
154
154
  'Method-level blast radius for a symbol: every FUNCTION that (transitively) calls it — ' +
155
155
  'or, with direction "callees", every repo function it calls. Finer-grained than the ' +
156
156
  'file-level get_impact: tells an agent which functions break, not just which files. ' +
157
- 'JS/TS + Python call-graph; deterministic, no LLM.',
157
+ 'JS/TS, Python, Java, Go, and Rust call-graph; deterministic, no LLM.',
158
158
  inputSchema: {
159
159
  type: 'object',
160
160
  properties: {
@@ -37,6 +37,7 @@ const DEFAULT_WEIGHTS = {
37
37
  const GRAPH_BOOST_AMOUNTS = {
38
38
  hop1: 0.40, // direct import neighbor of a file with score > 0
39
39
  hop2: 0.15, // 2 hops away (transitive), with decay
40
+ callHop: 0.30, // call-graph file neighbor (opt-in retrieval.callGraphBoost)
40
41
  };
41
42
 
42
43
  // Intent-specific weight adjustments
@@ -169,6 +170,8 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
169
170
  * @param {object} [opts.weights] - override scoring weights
170
171
  * @param {string} [opts.cwd] - project root for learned ranking weights
171
172
  * @param {{ forward: Map<string,string[]> }} [opts.graph] - dependency graph for neighbor boost
173
+ * @param {{ forward: Map<string,string[]> }} [opts.callGraph] - file-level call-graph edges
174
+ * (from buildCallFileGraph) for the opt-in call-neighbor boost
172
175
  * @returns {{ file: string, score: number, sigs: string[], tokens: number, intent: string, signals: object }[]}
173
176
  */
174
177
  function rank(query, sigIndex, opts) {
@@ -292,6 +295,31 @@ function rank(query, sigIndex, opts) {
292
295
  }
293
296
  }
294
297
 
298
+ // Call-graph neighbor boost (opt-in via retrieval.callGraphBoost): a file
299
+ // whose functions call into — or are called by — a positively-scored file is
300
+ // relevant even when no import edge exists (Go/Java same-package, dynamic
301
+ // dispatch). Single hop; seeds snapshotted first so boosts never cascade.
302
+ const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
303
+ if (callGraph && cwd) {
304
+ const path = require('path');
305
+ const relToIdx = new Map();
306
+ for (let i = 0; i < scored.length; i++) relToIdx.set(scored[i].file, i);
307
+ const hubs = _computeHubs(callGraph);
308
+ const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
309
+ for (const file of seeds) {
310
+ const abs = path.resolve(cwd, file);
311
+ for (const neighborAbs of (callGraph.forward.get(abs) || [])) {
312
+ if (_isHub(neighborAbs) || hubs.has(neighborAbs)) continue;
313
+ const neighborRel = path.relative(cwd, neighborAbs).replace(/\\/g, '/');
314
+ const idx = relToIdx.get(neighborRel);
315
+ if (idx !== undefined && scored[idx].file !== file) {
316
+ scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
317
+ scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
318
+ }
319
+ }
320
+ }
321
+ }
322
+
295
323
  // Compute confidence levels based on score distribution
296
324
  if (scored.length > 0) {
297
325
  const scores = scored.map(s => s.score);