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.
- package/AGENTS.md +128 -120
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/gen-context.js +285 -33
- package/llms-full.txt +5 -5
- package/llms.txt +2 -2
- package/package.json +2 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/config/defaults.js +2 -0
- package/src/evidence/pack.js +50 -14
- package/src/graph/call-graph.js +177 -11
- package/src/mcp/handlers.js +10 -1
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +1 -1
- package/src/retrieval/ranker.js +28 -0
package/src/evidence/pack.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Evidence Pack
|
|
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 = '
|
|
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
|
-
|
|
73
|
-
if (
|
|
74
|
-
if (
|
|
75
|
-
if (
|
|
76
|
-
if (
|
|
77
|
-
if (
|
|
78
|
-
if (
|
|
79
|
-
if (
|
|
80
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
};
|
package/src/graph/call-graph.js
CHANGED
|
@@ -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
|
|
8
|
-
* Call sites are resolved with high precision: a call
|
|
9
|
-
* of that name in the *same file* first, then in a
|
|
10
|
-
* (via the existing file-level import graph). Names
|
|
11
|
-
* definition produce no edge — over-approximation
|
|
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 =
|
|
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
|
};
|
package/src/mcp/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
157
|
+
'JS/TS, Python, Java, Go, and Rust call-graph; deterministic, no LLM.',
|
|
158
158
|
inputSchema: {
|
|
159
159
|
type: 'object',
|
|
160
160
|
properties: {
|
package/src/retrieval/ranker.js
CHANGED
|
@@ -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);
|