sigmap 8.12.0 → 8.14.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,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.12.0",
3
+ "version": "8.14.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.12.0",
3
+ "version": "8.14.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Method-level blast-radius scoring (GR2).
5
+ *
6
+ * Consumes the D4 call-graph (src/graph/call-graph.js): for each changed file,
7
+ * resolve the functions it defines, BFS the reverse call edges, and score how
8
+ * much of the codebase transitively calls into the change. The score is a
9
+ * documented deterministic formula — no heuristics that vary run to run — so
10
+ * review-pr findings and PR Evidence lines are byte-stable for a fixed tree.
11
+ *
12
+ * Score: min(100, direct×4 + transitive×1). Tiers:
13
+ * 0 → none · 1–9 → low · 10–29 → medium · 30–59 → high · 60+ → critical
14
+ */
15
+
16
+ const path = require('path');
17
+ const { buildCallGraph } = require('./call-graph');
18
+
19
+ const DIRECT_WEIGHT = 4;
20
+ const TRANSITIVE_WEIGHT = 1;
21
+ const IMPACTED_FUNCTIONS_CAP = 12;
22
+
23
+ const TEST_FILE_RE = /\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.(py|go)$|(^|\/)(tests?|__tests__|spec)\//;
24
+
25
+ function tierFor(score) {
26
+ if (score >= 60) return 'critical';
27
+ if (score >= 30) return 'high';
28
+ if (score >= 10) return 'medium';
29
+ if (score >= 1) return 'low';
30
+ return 'none';
31
+ }
32
+
33
+ function _normRel(p) {
34
+ return String(p).replace(/\\/g, '/');
35
+ }
36
+
37
+ // BFS the reverse edges from a set of symbol ids; returns direct/transitive id sets.
38
+ function _bfs(seedIds, reverse, maxDepth) {
39
+ const direct = new Set();
40
+ const transitive = new Set();
41
+ const visited = new Set(seedIds);
42
+ let frontier = [];
43
+ for (const s of seedIds) {
44
+ for (const nb of (reverse.get(s) || [])) {
45
+ if (!visited.has(nb)) { direct.add(nb); visited.add(nb); frontier.push(nb); }
46
+ }
47
+ }
48
+ let depth = 1;
49
+ while (frontier.length && (maxDepth === 0 || depth < maxDepth)) {
50
+ const next = [];
51
+ for (const node of frontier) {
52
+ for (const nb of (reverse.get(node) || [])) {
53
+ if (!visited.has(nb)) { transitive.add(nb); visited.add(nb); next.push(nb); }
54
+ }
55
+ }
56
+ frontier = next;
57
+ depth++;
58
+ }
59
+ return { direct, transitive };
60
+ }
61
+
62
+ /**
63
+ * Score the method-level blast radius of a changed-file list.
64
+ *
65
+ * @param {string[]} changedFiles repo-relative paths
66
+ * @param {string} cwd
67
+ * @param {object} [opts]
68
+ * @param {object} [opts.graph] injected call graph (tests); else built from cwd
69
+ * @param {number} [opts.depth=0] BFS depth limit (0 = unlimited)
70
+ * @returns {{
71
+ * available: boolean,
72
+ * files: Array<{ file:string, symbols:number, directCallers:number,
73
+ * transitiveCallers:number, testCallers:number,
74
+ * impactedFunctions:string[], score:number, tier:string }>,
75
+ * aggregate: { score:number, tier:string, impactedFunctions:number }
76
+ * }}
77
+ */
78
+ function methodBlastRadius(changedFiles, cwd, opts = {}) {
79
+ const empty = { available: false, files: [], aggregate: { score: 0, tier: 'none', impactedFunctions: 0 } };
80
+ let graph;
81
+ try {
82
+ graph = opts.graph || buildCallGraph(cwd, opts);
83
+ } catch (_) {
84
+ return empty;
85
+ }
86
+ if (!graph || !graph.defs || graph.defs.size === 0) return empty;
87
+
88
+ // Group defined symbol ids by their (normalized) defining file.
89
+ const idsByFile = new Map();
90
+ for (const [id, def] of graph.defs.entries()) {
91
+ const rel = _normRel(def.file);
92
+ if (!idsByFile.has(rel)) idsByFile.set(rel, []);
93
+ idsByFile.get(rel).push(id);
94
+ }
95
+
96
+ const depth = Number.isFinite(opts.depth) ? opts.depth : 0;
97
+ const files = [];
98
+ const allImpacted = new Set();
99
+
100
+ for (const changed of (changedFiles || []).map(_normRel).sort()) {
101
+ const ids = idsByFile.get(changed);
102
+ if (!ids || !ids.length) continue;
103
+ const { direct, transitive } = _bfs(ids, graph.reverse, depth);
104
+ const impacted = [...direct, ...transitive].sort();
105
+ for (const id of impacted) allImpacted.add(id);
106
+ const testCallers = impacted.filter((id) => {
107
+ const def = graph.defs.get(id);
108
+ return def && TEST_FILE_RE.test(_normRel(def.file));
109
+ }).length;
110
+ const score = Math.min(100, direct.size * DIRECT_WEIGHT + transitive.size * TRANSITIVE_WEIGHT);
111
+ files.push({
112
+ file: changed,
113
+ symbols: ids.length,
114
+ directCallers: direct.size,
115
+ transitiveCallers: transitive.size,
116
+ testCallers,
117
+ impactedFunctions: impacted.slice(0, IMPACTED_FUNCTIONS_CAP),
118
+ score,
119
+ tier: tierFor(score),
120
+ });
121
+ }
122
+
123
+ if (!files.length) return empty;
124
+ const maxScore = files.reduce((m, f) => Math.max(m, f.score), 0);
125
+ return {
126
+ available: true,
127
+ files,
128
+ aggregate: { score: maxScore, tier: tierFor(maxScore), impactedFunctions: allImpacted.size },
129
+ };
130
+ }
131
+
132
+ module.exports = { methodBlastRadius, tierFor, DIRECT_WEIGHT, TRANSITIVE_WEIGHT };
@@ -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
@@ -397,5 +527,5 @@ function formatCallGraphJSON(result, kind) {
397
527
  module.exports = {
398
528
  buildCallGraph, methodImpact, methodCallees,
399
529
  formatCallGraph, formatCallGraphJSON,
400
- extractDefs, maskJs, maskPy,
530
+ extractDefs, maskJs, maskPy, maskRust,
401
531
  };
@@ -428,6 +428,28 @@ function queryContext(args, cwd) {
428
428
  }
429
429
  }
430
430
 
431
+ /**
432
+ * get_method_impact({ symbol, direction?, depth? }) → string
433
+ *
434
+ * Method-level blast radius (GR2): every function that transitively calls
435
+ * `symbol` (direction 'callers', default), or everything it calls ('callees').
436
+ */
437
+ function getMethodImpact(args, cwd) {
438
+ if (!args || !args.symbol) return 'Missing required argument: symbol';
439
+
440
+ try {
441
+ const { methodImpact, methodCallees, formatCallGraph } = require('../graph/call-graph');
442
+ const kind = args.direction === 'callees' ? 'callees' : 'callers';
443
+ const depth = Math.max(0, parseInt(args.depth, 10) || 0);
444
+ const result = kind === 'callees'
445
+ ? methodCallees(args.symbol, cwd, { depth })
446
+ : methodImpact(args.symbol, cwd, { depth });
447
+ return formatCallGraph(result, kind);
448
+ } catch (err) {
449
+ return `_get_method_impact failed: ${err.message}_`;
450
+ }
451
+ }
452
+
431
453
  /**
432
454
  * get_impact({ file, depth? }) → string
433
455
  *
@@ -941,4 +963,4 @@ function squeezeOutput(args, cwd) {
941
963
  return header + sq.squeezed;
942
964
  }
943
965
 
944
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput };
966
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput };
package/src/mcp/server.js CHANGED
@@ -14,11 +14,11 @@
14
14
 
15
15
  const readline = require('readline');
16
16
  const { TOOLS } = require('./tools');
17
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput } = require('./handlers');
17
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getMethodImpact, getImpact, getLines, readMemory, getCalleeSignatures, notifyFileCreated, notifySymbolAdded, notifyFileDeleted, getDiffContext, getArchitectureOverview, verifySuggestion, squeezeOutput } = require('./handlers');
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.12.0',
21
+ version: '8.14.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -74,6 +74,7 @@ function dispatch(msg, cwd) {
74
74
  else if (name === 'explain_file') text = explainFile(args, cwd);
75
75
  else if (name === 'list_modules') text = listModules(args, cwd);
76
76
  else if (name === 'query_context') text = queryContext(args, cwd);
77
+ else if (name === 'get_method_impact') text = getMethodImpact(args, cwd);
77
78
  else if (name === 'get_impact') text = getImpact(args, cwd);
78
79
  else if (name === 'get_lines') text = getLines(args, cwd);
79
80
  else if (name === 'read_memory') text = readMemory(args, cwd);
package/src/mcp/tools.js CHANGED
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * MCP tool definitions for SigMap (19 tools).
4
+ * MCP tool definitions for SigMap (20 tools).
5
5
  * read_context, search_signatures, get_map, create_checkpoint, get_routing,
6
- * explain_file, list_modules, query_context, get_impact, get_lines, read_memory,
7
- * get_callee_signatures, sigmap_notify_file_created, sigmap_notify_symbol_added,
8
- * sigmap_notify_file_deleted, get_diff_context, get_architecture_overview,
9
- * verify_suggestion, squeeze_output.
6
+ * explain_file, list_modules, query_context, get_method_impact, get_impact,
7
+ * get_lines, read_memory, get_callee_signatures, sigmap_notify_file_created,
8
+ * sigmap_notify_symbol_added, sigmap_notify_file_deleted, get_diff_context,
9
+ * get_architecture_overview, verify_suggestion, squeeze_output.
10
10
  */
11
11
 
12
12
  const TOOLS = [
@@ -148,6 +148,35 @@ const TOOLS = [
148
148
  required: ['query'],
149
149
  },
150
150
  },
151
+ {
152
+ name: 'get_method_impact',
153
+ description:
154
+ 'Method-level blast radius for a symbol: every FUNCTION that (transitively) calls it — ' +
155
+ 'or, with direction "callees", every repo function it calls. Finer-grained than the ' +
156
+ 'file-level get_impact: tells an agent which functions break, not just which files. ' +
157
+ 'JS/TS, Python, Java, Go, and Rust call-graph; deterministic, no LLM.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ symbol: {
162
+ type: 'string',
163
+ description:
164
+ 'Function/method name (e.g. "validateToken") or a full "file#name" id ' +
165
+ '(e.g. "src/auth/session.js#validateToken") to disambiguate.',
166
+ },
167
+ direction: {
168
+ type: 'string',
169
+ enum: ['callers', 'callees'],
170
+ description: '"callers" (default) = blast radius; "callees" = what the symbol calls.',
171
+ },
172
+ depth: {
173
+ type: 'number',
174
+ description: 'BFS depth limit (default 0 = unlimited).',
175
+ },
176
+ },
177
+ required: ['symbol'],
178
+ },
179
+ },
151
180
  {
152
181
  name: 'get_impact',
153
182
  description:
@@ -50,6 +50,12 @@ function buildPrEvidence(changedFiles, cwd, opts = {}) {
50
50
  impactByFile = new Map(analyzeImpact(srcPaths, cwd, { depth }).map((r) => [r.file, r.impact]));
51
51
  } catch (_) { /* graph optional */ }
52
52
 
53
+ // GR2: method-level blast radius per changed file (reviewPr already computed
54
+ // it when the call graph resolved — reuse, don't rebuild the graph).
55
+ const methodBlastByFile = new Map(
56
+ (review.methodBlast && review.methodBlast.files || []).map((m) => [m.file, m])
57
+ );
58
+
53
59
  const fileReports = files.map((f) => {
54
60
  const deleted = f.status === 'D';
55
61
  let signatures = [];
@@ -58,6 +64,7 @@ function buildPrEvidence(changedFiles, cwd, opts = {}) {
58
64
  }
59
65
  const impact = impactByFile.get(f.path) || null;
60
66
  return {
67
+ methodBlast: methodBlastByFile.get(f.path.replace(/\\/g, '/')) || null,
61
68
  path: f.path,
62
69
  status: f.status,
63
70
  riskLabel: riskLabelFor(f.path),
@@ -100,6 +107,7 @@ function formatPrEvidenceMarkdown(evidence, opts = {}) {
100
107
  else if (f.type === 'security-file') L.push(`- ⚠️ **sensitive path touched** (path heuristic, not a content scan) — \`${f.file}\``);
101
108
  else if (f.type === 'secret-detected') L.push(`- 🔑 **secret detected** (${f.secret}) — \`${f.file}\``);
102
109
  else if (f.type === 'god-node') L.push(`- ⚠️ **god node** — \`${f.file}\` → ${f.count} dependents (high blast radius)`);
110
+ else if (f.type === 'method-blast') L.push(`- ⚠️ **method blast radius ${f.tier}** — \`${f.file}\` → ${f.functions} function(s) transitively call into this change (score ${f.score}/100)`);
103
111
  else if (f.type === 'scope-drift') L.push(`- ⚠️ **scope drift** — ${f.count} top-level dirs touched (${f.dirs.join(', ')})`);
104
112
  }
105
113
  L.push('');
@@ -121,6 +129,15 @@ function formatPrEvidenceMarkdown(evidence, opts = {}) {
121
129
  } else {
122
130
  L.push('**Blast radius:** _(not in dependency graph — new or leaf file)_');
123
131
  }
132
+ if (f.methodBlast && (f.methodBlast.directCallers + f.methodBlast.transitiveCallers) > 0) {
133
+ const mb = f.methodBlast;
134
+ const total = mb.directCallers + mb.transitiveCallers;
135
+ L.push(
136
+ `**Method blast radius:** ${total} function(s) impacted (score ${mb.score}/100, ${mb.tier}) — ` +
137
+ mb.impactedFunctions.slice(0, 6).map((id) => '`' + id + '`').join(', ') +
138
+ (total > 6 ? ` +${total - 6} more` : '')
139
+ );
140
+ }
124
141
  if (f.relatedTests.length) L.push(`Related tests: ${f.relatedTests.slice(0, 8).map((t) => '`' + t + '`').join(', ')}`);
125
142
 
126
143
  if (f.signatures.length) {
@@ -43,7 +43,7 @@ function isSource(p) {
43
43
  * @param {object} [opts]
44
44
  * @param {number} [opts.godNodeThreshold=15]
45
45
  * @param {number} [opts.scopeThreshold=5]
46
- * @returns {{ findings: object[], blast: object[], summary: object }}
46
+ * @returns {{ findings: object[], blast: object[], methodBlast: object|null, summary: object }}
47
47
  */
48
48
  function reviewPr(changedFiles, cwd, opts = {}) {
49
49
  const godThreshold = opts.godNodeThreshold != null ? opts.godNodeThreshold : GOD_NODE_THRESHOLD;
@@ -104,6 +104,27 @@ function reviewPr(changedFiles, cwd, opts = {}) {
104
104
  blast.sort((a, b) => b.totalImpact - a.totalImpact);
105
105
  }
106
106
 
107
+ // 3b. Method-level blast radius (GR2) — how many FUNCTIONS transitively call
108
+ // into the change, scored deterministically. Graph optional, like 3.
109
+ let methodBlast = null;
110
+ if (srcChanged.length) {
111
+ try {
112
+ const { methodBlastRadius } = require('../graph/blast-radius');
113
+ const mb = methodBlastRadius(srcChanged, cwd, opts.methodBlastOpts || {});
114
+ if (mb.available) {
115
+ methodBlast = mb;
116
+ for (const f of mb.files) {
117
+ if (f.tier === 'high' || f.tier === 'critical') {
118
+ findings.push({
119
+ type: 'method-blast', file: f.file, severity: 'warn',
120
+ functions: f.directCallers + f.transitiveCallers, score: f.score, tier: f.tier,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ } catch (_) { /* call graph optional */ }
126
+ }
127
+
107
128
  // 4. Scope drift: distinct top-level directories touched.
108
129
  const dirs = [...new Set(paths.map((p) => (p.includes('/') ? p.split('/')[0] : '.')))];
109
130
  if (dirs.length > scopeThreshold) {
@@ -114,6 +135,7 @@ function reviewPr(changedFiles, cwd, opts = {}) {
114
135
  return {
115
136
  findings,
116
137
  blast,
138
+ methodBlast,
117
139
  summary: {
118
140
  filesChanged: files.length,
119
141
  sourceChanged: srcChanged.length,