sigmap 8.6.0 → 8.7.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/CHANGELOG.md CHANGED
@@ -10,6 +10,14 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.7.0] — 2026-07-05
14
+
15
+ Minor release — **method/caller-level call-graph (D4 v1): the graph goes from files to functions.** SigMap's dependency graph was file-level only (imports → `--impact`/`get_impact`). This adds **symbol-level** edges — *which function calls which function* — and the **method-level blast radius** of changing a single symbol. Deterministic, zero-dependency, JS/TS + Python. This is the plan's single hardest unbuilt gap and the biggest remaining lever on graph intelligence (v9.5 GR1).
16
+
17
+ ### Added
18
+ - **Method/caller-level call-graph (#429, PR #430):** new `src/graph/call-graph.js` builds symbol edges keyed by `relPath#symbol` for **JS/TS + Python**. Definitions are extracted with real body ranges (brace-matching for JS, indentation for Python) over **comment- and string-masked** source, so braces/calls inside literals never create phantom edges. Each call site is resolved with **high precision** — a call binds to a definition of that name in the **same file** first, then in a **directly-imported file** (reusing the existing file-level import graph); names with no repo definition produce **no edge**, avoiding global name-collision noise. Public API: `buildCallGraph(cwd)` → `{ forward, reverse, defs }`, `methodImpact(symbol)` (transitive callers = method blast radius), `methodCallees(symbol)` (what it transitively calls), plus formatters mirroring `src/graph/impact.js`.
19
+ - **`--callers` / `--callees` CLI (#429, PR #430):** `sigmap --callers <symbol>` prints the method-level blast radius (every function that transitively calls `<symbol>`); `sigmap --callees <symbol>` prints what it calls. Both accept a bare name or a `file#symbol` id, support `--json` and `--depth <n>` (0 = unlimited), and mirror the `--impact` command. Documented in `--help`.
20
+
13
21
  ## [8.6.0] — 2026-07-05
14
22
 
15
23
  Minor release — **Phase 1 "bank the A": the grounding moat's supporting surface.** Three master-plan items land together: a self-contained, third-party-runnable benchmark harness (G1), installed dependency version pins in the generated context header (D8), and `verify` promoted to a documented flagship command (G2). All zero-dependency, deterministic, and in-boundary.
package/README.md CHANGED
@@ -57,10 +57,10 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
57
57
 
58
58
  **Proof it pays off** (full benchmark below):
59
59
  <!--SM:whyMetrics-->
60
- - **86.7% hit@5** — right file found in top 5 results (vs 13.6% baseline)
60
+ - **87.8% hit@5** — right file found in top 5 results (vs 13.6% baseline)
61
61
  - **97.0% token reduction** — average across 21 real repos
62
- - **66.7% task success rate** — up from 10% without context
63
- - **1.47 prompts per task** — down from 2.84 (48.4% fewer retries)
62
+ - **68.9% task success rate** — up from 10% without context
63
+ - **1.43 prompts per task** — down from 2.84 (49.6% fewer retries)
64
64
  <!--/SM:whyMetrics-->
65
65
  - **<!--SM:languages-->33<!--/SM:languages--> languages supported** — TypeScript, Python, Go, Rust, Java, R, and more
66
66
  - **No vendor lock-in** — works with any AI assistant or local LLM
@@ -96,7 +96,7 @@ sigmap verify answer.md --report # standalone red/amber/green HTML report
96
96
  | Without SigMap | With SigMap |
97
97
  |---|---|
98
98
  | ❌ Non-reproducible agent guesses | ✅ Deterministic map — same input, same output, every time |
99
- | ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->87%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
99
+ | ❌ "Trust me" AI answers | ✅ Grounded — right file in context <!--SM:hitWhole-->88%<!--/SM:hitWhole--> of the time, every symbol on a real line anchor |
100
100
  | ❌ Embeddings / vector DB required | ✅ Zero deps, no infra, fully offline |
101
101
 
102
102
  ---
@@ -120,13 +120,13 @@ Ask → Rank → Context → Validate → Judge → Learn
120
120
 
121
121
  <!--SM:benchmarkBlock-->
122
122
  ```
123
- Benchmark : sigmap-v8.6-main (21 repositories, including R language)
123
+ Benchmark : sigmap-v8.7-main (21 repositories, including R language)
124
124
  Date : 2026-07-05
125
125
 
126
- Hit@5 : 86.7% (baseline 13.6% — 6.4× lift)
126
+ Hit@5 : 87.8% (baseline 13.6% — 6.5× lift)
127
127
  Token reduction: 97.0% (across 21 repos)
128
- Prompt reduction : 48.4% (2.84 → 1.47 prompts per task)
129
- Task success : 66.7% (baseline 10%)
128
+ Prompt reduction : 49.6% (2.84 → 1.43 prompts per task)
129
+ Task success : 68.9% (baseline 10%)
130
130
  Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
131
131
  ```
132
132
  <!--/SM:benchmarkBlock-->
package/gen-context.js CHANGED
@@ -10453,6 +10453,411 @@ __factories["./src/graph/builder"] = function(module, exports) {
10453
10453
 
10454
10454
  };
10455
10455
 
10456
+ // ── ./src/graph/call-graph ──
10457
+ __factories["./src/graph/call-graph"] = function(module, exports) {
10458
+
10459
+ /**
10460
+ * Method/caller-level call-graph (D4 v1).
10461
+ *
10462
+ * Builds symbol-level edges — which function calls which function — for JS/TS
10463
+ * and Python. Deterministic, zero-dependency, regex + brace/indent matching.
10464
+ * Call sites are resolved with high precision: a call resolves to a definition
10465
+ * of that name in the *same file* first, then in a *directly-imported* file
10466
+ * (via the existing file-level import graph). Names that resolve to no repo
10467
+ * definition produce no edge — over-approximation noise is avoided.
10468
+ *
10469
+ * Symbol IDs are `relPath#symbolName` (forward-slashed, relative to cwd).
10470
+ *
10471
+ * @module src/graph/call-graph
10472
+ */
10473
+
10474
+ const fs = require('fs');
10475
+ const path = require('path');
10476
+ const { build } = __require('./src/graph/builder');
10477
+
10478
+ const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
10479
+ const PY_EXTS = new Set(['.py', '.pyw']);
10480
+
10481
+ // Tokens that look like `name(` calls or definition headers but are language
10482
+ // keywords, not user symbols — never treated as a call or a definition.
10483
+ const NON_CALL = new Set([
10484
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'typeof',
10485
+ 'await', 'new', 'super', 'else', 'do', 'with', 'yield', 'void', 'delete',
10486
+ 'in', 'of', 'case', 'throw', 'print', 'and', 'or', 'not', 'assert',
10487
+ 'lambda', 'class', 'def', 'elif', 'except', 'finally', 'raise', 'import',
10488
+ 'from', 'global', 'nonlocal', 'del', 'pass', 'async', 'require', 'constructor',
10489
+ ]);
10490
+
10491
+ function normalizePath(p) { return path.normalize(p).toLowerCase(); }
10492
+ function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
10493
+ function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
10494
+
10495
+ // ── Length- and newline-preserving maskers ──────────────────────────────────
10496
+ // Replace comment / string bodies with spaces so their braces, parens, and
10497
+ // call-looking tokens never confuse structure detection. Offsets stay aligned.
10498
+
10499
+ function maskJs(src) {
10500
+ const out = src.split('');
10501
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
10502
+ let i = 0; const n = src.length;
10503
+ while (i < n) {
10504
+ const c = src[i], d = src[i + 1];
10505
+ if (c === '/' && d === '/') { let j = i + 2; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
10506
+ 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; }
10507
+ if (c === '"' || c === "'" || c === '`') {
10508
+ let j = i + 1;
10509
+ while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c) break; if (c !== '`' && src[j] === '\n') break; j++; }
10510
+ j = Math.min(n, j + 1); blank(i, j); i = j; continue;
10511
+ }
10512
+ i++;
10513
+ }
10514
+ return out.join('');
10515
+ }
10516
+
10517
+ function maskPy(src) {
10518
+ const out = src.split('');
10519
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
10520
+ let i = 0; const n = src.length;
10521
+ while (i < n) {
10522
+ const c = src[i];
10523
+ if (c === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
10524
+ if (c === '"' || c === "'") {
10525
+ if (src.substr(i, 3) === c + c + c) {
10526
+ let j = i + 3; while (j < n && src.substr(j, 3) !== c + c + c) j++; j = Math.min(n, j + 3); blank(i, j); i = j; continue;
10527
+ }
10528
+ let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c || src[j] === '\n') break; j++; }
10529
+ j = Math.min(n, j + 1); blank(i, j); i = j; continue;
10530
+ }
10531
+ i++;
10532
+ }
10533
+ return out.join('');
10534
+ }
10535
+
10536
+ // ── Balanced-delimiter matchers (operate on masked source) ───────────────────
10537
+ function matchDelim(masked, openIdx, open, close) {
10538
+ let depth = 0;
10539
+ for (let i = openIdx; i < masked.length; i++) {
10540
+ if (masked[i] === open) depth++;
10541
+ else if (masked[i] === close) { depth--; if (depth === 0) return i; }
10542
+ }
10543
+ return masked.length - 1;
10544
+ }
10545
+
10546
+ function lineAt(src, idx) {
10547
+ let line = 1;
10548
+ const end = Math.min(idx, src.length);
10549
+ for (let i = 0; i < end; i++) if (src.charCodeAt(i) === 10) line++;
10550
+ return line;
10551
+ }
10552
+
10553
+ // ── Definition extraction ────────────────────────────────────────────────────
10554
+ // Each def: { name, line, bodyStart, bodyEnd } with char offsets into `masked`.
10555
+
10556
+ function jsDefs(masked) {
10557
+ const defs = [];
10558
+ const seen = new Set();
10559
+ const push = (name, headerIdx, bodyStart, bodyEnd) => {
10560
+ const key = name + ':' + bodyStart;
10561
+ if (NON_CALL.has(name) || seen.has(key)) return;
10562
+ seen.add(key);
10563
+ defs.push({ name, line: lineAt(masked, headerIdx), bodyStart, bodyEnd });
10564
+ };
10565
+
10566
+ // Locate the `{` body (or `=>` expression) that follows a param list `)`.
10567
+ const bodyAfterParams = (closeParen) => {
10568
+ let k = closeParen + 1;
10569
+ // skip a `=>`, return-type annotations, and whitespace up to `{` or a statement end
10570
+ while (k < masked.length && masked[k] !== '{' && masked[k] !== ';' && masked[k] !== '\n') k++;
10571
+ if (masked[k] === '{') { const end = matchDelim(masked, k, '{', '}'); return { bodyStart: k, bodyEnd: end }; }
10572
+ return null; // no braced body (interface/abstract/overload signature) — skip
10573
+ };
10574
+
10575
+ // 1) function declarations: (async) function name(...) { ... }
10576
+ const reFn = /\b(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
10577
+ let m;
10578
+ while ((m = reFn.exec(masked)) !== null) {
10579
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
10580
+ const close = matchDelim(masked, paren, '(', ')');
10581
+ const body = bodyAfterParams(close);
10582
+ if (body) push(m[1], m.index, body.bodyStart, body.bodyEnd);
10583
+ }
10584
+
10585
+ // 2) arrow / function expressions: const name = (...) => { } | = function(...) { }
10586
+ const reArrow = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b\s*\*?\s*[A-Za-z_$]*\s*)?\(/g;
10587
+ while ((m = reArrow.exec(masked)) !== null) {
10588
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
10589
+ const close = matchDelim(masked, paren, '(', ')');
10590
+ let k = close + 1;
10591
+ while (k < masked.length && /\s/.test(masked[k])) k++;
10592
+ if (masked[k] === '{') { push(m[1], m.index, k, matchDelim(masked, k, '{', '}')); continue; }
10593
+ if (masked[k] === '=' && masked[k + 1] === '>') {
10594
+ let j = k + 2; while (j < masked.length && /\s/.test(masked[j])) j++;
10595
+ if (masked[j] === '{') push(m[1], m.index, j, matchDelim(masked, j, '{', '}'));
10596
+ else { // single-expression arrow body → to end of statement
10597
+ let e = j; let d = 0;
10598
+ while (e < masked.length) { const ch = masked[e]; if (ch === '(' || ch === '[') d++; else if (ch === ')' || ch === ']') d--; else if ((ch === ';' || ch === '\n') && d <= 0) break; e++; }
10599
+ push(m[1], m.index, j, e);
10600
+ }
10601
+ }
10602
+ }
10603
+
10604
+ // 3) class methods: class X { name(...) { } }
10605
+ const reClass = /\bclass\s+[A-Za-z_$][\w$]*/g;
10606
+ while ((m = reClass.exec(masked)) !== null) {
10607
+ const brace = masked.indexOf('{', m.index);
10608
+ if (brace === -1) continue;
10609
+ const classEnd = matchDelim(masked, brace, '{', '}');
10610
+ const reMethod = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+|abstract\s+|async\s+|get\s+|set\s+|\*\s*)*([A-Za-z_$][\w$]*)\s*\(/g;
10611
+ reMethod.lastIndex = brace;
10612
+ let mm;
10613
+ while ((mm = reMethod.exec(masked)) !== null && mm.index < classEnd) {
10614
+ const paren = masked.indexOf('(', mm.index + mm[0].length - 1);
10615
+ const close = matchDelim(masked, paren, '(', ')');
10616
+ const body = bodyAfterParams(close);
10617
+ if (body && body.bodyEnd <= classEnd) push(mm[1], mm.index, body.bodyStart, body.bodyEnd);
10618
+ }
10619
+ }
10620
+
10621
+ return defs;
10622
+ }
10623
+
10624
+ function pyDefs(masked) {
10625
+ const defs = [];
10626
+ const lines = masked.split('\n');
10627
+ // precompute char offset of each line start
10628
+ const offsets = [0];
10629
+ for (let i = 0; i < lines.length; i++) offsets.push(offsets[i] + lines[i].length + 1);
10630
+ for (let i = 0; i < lines.length; i++) {
10631
+ const m = lines[i].match(/^([ \t]*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
10632
+ if (!m) continue;
10633
+ const indent = m[1].length;
10634
+ let j = i + 1;
10635
+ for (; j < lines.length; j++) {
10636
+ const ln = lines[j];
10637
+ if (!ln.trim()) continue; // blank
10638
+ const ind = ln.match(/^[ \t]*/)[0].length;
10639
+ if (ind <= indent) break; // dedent → block ends
10640
+ }
10641
+ defs.push({ name: m[2], line: i + 1, bodyStart: offsets[i], bodyEnd: offsets[j] || masked.length });
10642
+ }
10643
+ return defs;
10644
+ }
10645
+
10646
+ function extractDefs(filePath, src) {
10647
+ const ext = path.extname(filePath).toLowerCase();
10648
+ if (JS_EXTS.has(ext)) return jsDefs(maskJs(src));
10649
+ if (PY_EXTS.has(ext)) return pyDefs(maskPy(src));
10650
+ return null; // unsupported language
10651
+ }
10652
+
10653
+ // Collect `name(` call tokens within [start,end) of masked source.
10654
+ function callsInRange(masked, start, end) {
10655
+ const slice = masked.slice(start, end);
10656
+ const names = new Set();
10657
+ const re = /([A-Za-z_$][\w$]*)\s*\(/g;
10658
+ let m;
10659
+ while ((m = re.exec(slice)) !== null) {
10660
+ // skip a `.name(` method access (can't resolve the receiver deterministically)
10661
+ const before = slice[m.index - 1];
10662
+ if (before === '.') continue;
10663
+ if (!NON_CALL.has(m[1])) names.add(m[1]);
10664
+ }
10665
+ return names;
10666
+ }
10667
+
10668
+ // ── Public API ───────────────────────────────────────────────────────────────
10669
+
10670
+ function _walk(dir, excludeSet, out, depth) {
10671
+ if (depth > 8) return;
10672
+ let entries;
10673
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
10674
+ for (const e of entries) {
10675
+ if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
10676
+ const full = path.join(dir, e.name);
10677
+ if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1);
10678
+ else if (e.isFile()) {
10679
+ const ext = path.extname(e.name).toLowerCase();
10680
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) out.push(full);
10681
+ }
10682
+ }
10683
+ }
10684
+
10685
+ /**
10686
+ * Build the method-level call-graph for a project.
10687
+ *
10688
+ * @param {string} cwd
10689
+ * @param {object} [opts]
10690
+ * @param {string[]} [opts.srcDirs=['src','app','lib']]
10691
+ * @param {string[]} [opts.exclude]
10692
+ * @param {string[]} [opts.files] explicit absolute file list (skips the walk)
10693
+ * @returns {{
10694
+ * forward: Map<string,string[]>, // callerId → calleeIds
10695
+ * reverse: Map<string,string[]>, // calleeId → callerIds
10696
+ * defs: Map<string,{file:string,name:string,line:number}>
10697
+ * }}
10698
+ */
10699
+ function buildCallGraph(cwd, opts = {}) {
10700
+ const excludeSet = new Set(opts.exclude || ['node_modules', '.git', 'dist', 'build', 'coverage', 'vendor']);
10701
+ let files = opts.files ? opts.files.map((f) => path.resolve(f)) : [];
10702
+ if (!opts.files) {
10703
+ for (const sd of (opts.srcDirs || ['src', 'app', 'lib'])) {
10704
+ const abs = path.resolve(cwd, sd);
10705
+ if (fs.existsSync(abs)) _walk(abs, excludeSet, files, 0);
10706
+ }
10707
+ }
10708
+
10709
+ // File-level import graph (for precise call-site resolution). Keys normalized.
10710
+ let fileGraph;
10711
+ try { fileGraph = build(files, cwd); } catch (_) { fileGraph = { forward: new Map() }; }
10712
+
10713
+ // Per-file definitions + name→file lookups.
10714
+ const perFileDefs = new Map(); // absFile → def[]
10715
+ const defsByName = new Map(); // absFile → Map<name, symbolId[]>
10716
+ const normToAbs = new Map(); // normalized abs → abs
10717
+ const defs = new Map(); // symbolId → {file,name,line}
10718
+
10719
+ for (const f of files) {
10720
+ normToAbs.set(normalizePath(path.resolve(f)), path.resolve(f));
10721
+ let src;
10722
+ try { src = fs.readFileSync(f, 'utf8'); } catch (_) { continue; }
10723
+ const fileDefs = extractDefs(f, src);
10724
+ if (!fileDefs) continue;
10725
+ perFileDefs.set(f, fileDefs);
10726
+ const byName = new Map();
10727
+ for (const d of fileDefs) {
10728
+ const id = symId(cwd, f, d.name);
10729
+ defs.set(id, { file: toRel(cwd, f), name: d.name, line: d.line });
10730
+ if (!byName.has(d.name)) byName.set(d.name, []);
10731
+ byName.get(d.name).push(id);
10732
+ }
10733
+ defsByName.set(f, byName);
10734
+ }
10735
+
10736
+ const forward = new Map();
10737
+ const reverse = new Map();
10738
+ const addEdge = (from, to) => {
10739
+ if (from === to) return;
10740
+ if (!forward.has(from)) forward.set(from, new Set());
10741
+ forward.get(from).add(to);
10742
+ if (!reverse.has(to)) reverse.set(to, new Set());
10743
+ reverse.get(to).add(from);
10744
+ };
10745
+
10746
+ for (const [f, fileDefs] of perFileDefs.entries()) {
10747
+ const masked = JS_EXTS.has(path.extname(f).toLowerCase()) ? maskJs(fs.readFileSync(f, 'utf8')) : maskPy(fs.readFileSync(f, 'utf8'));
10748
+ // resolution scope: this file's defs, then directly-imported files' defs
10749
+ const importedAbs = (fileGraph.forward.get(normalizePath(path.resolve(f))) || [])
10750
+ .map((nf) => normToAbs.get(nf)).filter(Boolean);
10751
+ for (const d of fileDefs) {
10752
+ const callerId = symId(cwd, f, d.name);
10753
+ if (!forward.has(callerId)) forward.set(callerId, new Set()); // ensure node exists
10754
+ const callees = callsInRange(masked, d.bodyStart, d.bodyEnd);
10755
+ for (const nm of callees) {
10756
+ const local = (defsByName.get(f) || new Map()).get(nm);
10757
+ if (local && local.length) { for (const id of local) addEdge(callerId, id); continue; }
10758
+ for (const imp of importedAbs) {
10759
+ const ids = (defsByName.get(imp) || new Map()).get(nm);
10760
+ if (ids && ids.length) { for (const id of ids) addEdge(callerId, id); break; }
10761
+ }
10762
+ }
10763
+ }
10764
+ }
10765
+
10766
+ const toArr = (mapOfSets) => {
10767
+ const out = new Map();
10768
+ for (const [k, set] of mapOfSets.entries()) out.set(k, [...set]);
10769
+ return out;
10770
+ };
10771
+ return { forward: toArr(forward), reverse: toArr(reverse), defs };
10772
+ }
10773
+
10774
+ // Resolve a user-supplied symbol (bare name or full `file#name` id) to ids.
10775
+ function _resolveSymbol(symbol, defs) {
10776
+ if (defs.has(symbol)) return [symbol];
10777
+ const ids = [];
10778
+ for (const id of defs.keys()) if (id.slice(id.indexOf('#') + 1) === symbol) ids.push(id);
10779
+ return ids;
10780
+ }
10781
+
10782
+ // BFS over a graph map from seed ids up to maxDepth (0 = unlimited).
10783
+ function _bfs(seedIds, graph, maxDepth) {
10784
+ const direct = new Set();
10785
+ const transitive = new Set();
10786
+ const visited = new Set(seedIds);
10787
+ let frontier = [];
10788
+ for (const s of seedIds) for (const nb of (graph.get(s) || [])) if (!visited.has(nb)) { direct.add(nb); visited.add(nb); frontier.push(nb); }
10789
+ let depth = 1;
10790
+ while (frontier.length && (maxDepth === 0 || depth < maxDepth)) {
10791
+ const next = [];
10792
+ for (const node of frontier) for (const nb of (graph.get(node) || [])) if (!visited.has(nb)) { transitive.add(nb); visited.add(nb); next.push(nb); }
10793
+ frontier = next; depth++;
10794
+ }
10795
+ return { direct: [...direct], transitive: [...transitive] };
10796
+ }
10797
+
10798
+ /**
10799
+ * Method-level blast radius: everything that (transitively) calls `symbol`.
10800
+ *
10801
+ * @param {string} symbol bare name or `file#name`
10802
+ * @param {string} cwd
10803
+ * @param {object} [opts] { depth=0, ...buildCallGraph opts }
10804
+ * @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
10805
+ */
10806
+ function methodImpact(symbol, cwd, opts = {}) {
10807
+ const graph = opts.graph || buildCallGraph(cwd, opts);
10808
+ const ids = _resolveSymbol(symbol, graph.defs);
10809
+ if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
10810
+ const { direct, transitive } = _bfs(ids, graph.reverse, opts.depth || 0);
10811
+ return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
10812
+ }
10813
+
10814
+ /**
10815
+ * What `symbol` (transitively) calls.
10816
+ * @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
10817
+ */
10818
+ function methodCallees(symbol, cwd, opts = {}) {
10819
+ const graph = opts.graph || buildCallGraph(cwd, opts);
10820
+ const ids = _resolveSymbol(symbol, graph.defs);
10821
+ if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
10822
+ const { direct, transitive } = _bfs(ids, graph.forward, opts.depth || 0);
10823
+ return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
10824
+ }
10825
+
10826
+ // ── Formatters ───────────────────────────────────────────────────────────────
10827
+ function formatCallGraph(result, kind) {
10828
+ const verb = kind === 'callees' ? 'calls' : 'callers of';
10829
+ const lines = [`## ${kind === 'callees' ? 'Callees' : 'Callers'}: \`${result.symbol}\``, ''];
10830
+ if (result.unresolved) { lines.push('_symbol not found in the call-graph._'); return lines.join('\n'); }
10831
+ if (result.total === 0) {
10832
+ lines.push(kind === 'callees' ? '_calls no repo-defined symbols._' : '_no repo symbol calls this — zero method blast radius._');
10833
+ return lines.join('\n');
10834
+ }
10835
+ lines.push(`**Total ${verb}:** ${result.total}`, '');
10836
+ if (result.direct.length) { lines.push(`### Direct`); for (const id of result.direct) lines.push(`- \`${id}\``); lines.push(''); }
10837
+ if (result.transitive.length) { lines.push(`### Transitive`); for (const id of result.transitive) lines.push(`- \`${id}\``); lines.push(''); }
10838
+ return lines.join('\n');
10839
+ }
10840
+
10841
+ function formatCallGraphJSON(result, kind) {
10842
+ return {
10843
+ symbol: result.symbol,
10844
+ kind: kind === 'callees' ? 'callees' : 'callers',
10845
+ resolved: result.resolved,
10846
+ direct: result.direct,
10847
+ transitive: result.transitive,
10848
+ total: result.total,
10849
+ unresolved: result.unresolved,
10850
+ };
10851
+ }
10852
+
10853
+ module.exports = {
10854
+ buildCallGraph, methodImpact, methodCallees,
10855
+ formatCallGraph, formatCallGraphJSON,
10856
+ extractDefs, maskJs, maskPy,
10857
+ };
10858
+
10859
+ };
10860
+
10456
10861
  // ── ./src/graph/impact ──
10457
10862
  __factories["./src/graph/impact"] = function(module, exports) {
10458
10863
 
@@ -13111,7 +13516,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
13111
13516
 
13112
13517
  const SERVER_INFO = {
13113
13518
  name: 'sigmap',
13114
- version: '8.6.0',
13519
+ version: '8.7.0',
13115
13520
  description: 'SigMap MCP server — code signatures on demand',
13116
13521
  };
13117
13522
 
@@ -17679,7 +18084,7 @@ function __tryGit(args, opts = {}) {
17679
18084
  catch (_) { return ''; }
17680
18085
  }
17681
18086
 
17682
- const VERSION = '8.6.0';
18087
+ const VERSION = '8.7.0';
17683
18088
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
17684
18089
 
17685
18090
  function requireSourceOrBundled(key) {
@@ -19491,6 +19896,9 @@ Usage:
19491
19896
  ${cmd} --impact <file> Show every file impacted by changing <file>
19492
19897
  ${cmd} --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
19493
19898
  ${cmd} --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
19899
+ ${cmd} --callers <symbol> Method-level blast radius — every function that (transitively) calls <symbol> (JS/TS + Python)
19900
+ ${cmd} --callees <symbol> Every repo function that <symbol> (transitively) calls
19901
+ ${cmd} --callers <symbol> --json --depth <n> Call-graph edges as JSON (depth 0 = unlimited)
19494
19902
  ${cmd} verify <answer.md> Flagship grounding guard — flag fake files/tests/imports/symbols/npm-scripts in an AI answer (alias of verify-ai-output)
19495
19903
  ${cmd} verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
19496
19904
  ${cmd} verify <answer.md> --report Write a standalone HTML report (red/amber/green)
@@ -22067,6 +22475,37 @@ function main() {
22067
22475
  process.exit(0);
22068
22476
  }
22069
22477
 
22478
+ // Method-level call-graph (D4): --callers <symbol> = method blast radius,
22479
+ // --callees <symbol> = what it calls. JS/TS + Python, deterministic.
22480
+ if (args.includes('--callers') || args.includes('--callees')) {
22481
+ try {
22482
+ const kind = args.includes('--callees') ? 'callees' : 'callers';
22483
+ const flag = kind === 'callees' ? '--callees' : '--callers';
22484
+ const symbol = (args[args.indexOf(flag) + 1] || '').trim();
22485
+ if (!symbol || symbol.startsWith('--')) {
22486
+ console.error(`[sigmap] ${flag} requires a symbol name (or file#symbol)`);
22487
+ console.error(` Example: node gen-context.js ${flag} extractFileDeps`);
22488
+ process.exit(1);
22489
+ }
22490
+ const { methodImpact, methodCallees, formatCallGraph, formatCallGraphJSON } = requireSourceOrBundled('./src/graph/call-graph');
22491
+ const depthIdx = args.indexOf('--depth');
22492
+ const depth = depthIdx >= 0 ? Math.max(0, parseInt(args[depthIdx + 1], 10) || 0) : 0;
22493
+ const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length) ? config.srcDirs : undefined;
22494
+ const result = kind === 'callees'
22495
+ ? methodCallees(symbol, cwd, { depth, srcDirs })
22496
+ : methodImpact(symbol, cwd, { depth, srcDirs });
22497
+ if (args.includes('--json')) {
22498
+ process.stdout.write(JSON.stringify(formatCallGraphJSON(result, kind)) + '\n');
22499
+ } else {
22500
+ process.stdout.write(formatCallGraph(result, kind) + '\n');
22501
+ }
22502
+ } catch (err) {
22503
+ console.error(`[sigmap] call-graph error: ${err.message}`);
22504
+ process.exit(1);
22505
+ }
22506
+ process.exit(0);
22507
+ }
22508
+
22070
22509
  if (args.includes('--report')) {
22071
22510
  if (args.includes('--history')) {
22072
22511
  try {
package/llms-full.txt CHANGED
@@ -11,20 +11,20 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.6.0 | Benchmark: sigmap-v8.6-main (2026-07-05)
14
+ # Version: 8.7.0 | Benchmark: sigmap-v8.7-main (2026-07-05)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
18
18
  ---
19
19
 
20
- ## Core metrics (benchmark: sigmap-v8.6-main, 2026-07-05)
20
+ ## Core metrics (benchmark: sigmap-v8.7-main, 2026-07-05)
21
21
 
22
22
  | Metric | Without SigMap | With SigMap |
23
23
  |--------|----------------|-------------|
24
- | Retrieval hit@5 | 13.6% (random) | 86.7% (6.4× lift) |
24
+ | Retrieval hit@5 | 13.6% (random) | 87.8% (6.5× lift) |
25
25
  | Token reduction | — | 97.0% average |
26
- | Task success proxy | 10% | 66.7% |
27
- | Prompts per task | 2.84 | 1.47 (48.4% fewer) |
26
+ | Task success proxy | 10% | 68.9% |
27
+ | Prompts per task | 2.84 | 1.43 (49.6% fewer) |
28
28
  | Supported languages | — | 33 |
29
29
  | MCP tools | — | 18 |
30
30
  | npm runtime dependencies | — | 0 |
@@ -99,6 +99,9 @@ sigmap weights --json Learned weights as JSON
99
99
  sigmap --impact <file> Show every file impacted by changing <file>
100
100
  sigmap --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
101
101
  sigmap --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
102
+ sigmap --callers <symbol> Method-level blast radius — every function that (transitively) calls <symbol> (JS/TS + Python)
103
+ sigmap --callees <symbol> Every repo function that <symbol> (transitively) calls
104
+ sigmap --callers <symbol> --json --depth <n> Call-graph edges as JSON (depth 0 = unlimited)
102
105
  sigmap verify <answer.md> Flagship grounding guard — flag fake files/tests/imports/symbols/npm-scripts in an AI answer (alias of verify-ai-output)
103
106
  sigmap verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
104
107
  sigmap verify <answer.md> --report Write a standalone HTML report (red/amber/green)
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.6.0 | Benchmark: sigmap-v8.6-main (2026-07-05)
14
+ # Version: 8.7.0 | Benchmark: sigmap-v8.7-main (2026-07-05)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
@@ -23,12 +23,12 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
23
23
  - No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
24
24
  - Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
25
25
 
26
- ## Core metrics (benchmark: sigmap-v8.6-main, 2026-07-05)
26
+ ## Core metrics (benchmark: sigmap-v8.7-main, 2026-07-05)
27
27
 
28
- - hit@5 retrieval: 86.7% vs 13.6% random baseline (6.4× lift)
28
+ - hit@5 retrieval: 87.8% vs 13.6% random baseline (6.5× lift)
29
29
  - Token reduction: 97.0% average across benchmark repos
30
- - Task success: 66.7% vs 10% without SigMap
31
- - Prompts per task: 1.47 vs 2.84 baseline (48.4% fewer)
30
+ - Task success: 68.9% vs 10% without SigMap
31
+ - Prompts per task: 1.43 vs 2.84 baseline (49.6% fewer)
32
32
  - Languages: 33 supported · MCP tools: 18
33
33
  - Dependencies: zero npm runtime dependencies · fully offline
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "description": "97% token reduction for AI coding. Extracts function & class signatures with TF-IDF ranking to feed only the right files to Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP. Zero dependencies, runs offline via npx.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.6.0",
3
+ "version": "8.7.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.6.0",
3
+ "version": "8.7.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,401 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Method/caller-level call-graph (D4 v1).
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.
12
+ *
13
+ * Symbol IDs are `relPath#symbolName` (forward-slashed, relative to cwd).
14
+ *
15
+ * @module src/graph/call-graph
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { build } = require('./builder');
21
+
22
+ const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
23
+ const PY_EXTS = new Set(['.py', '.pyw']);
24
+
25
+ // Tokens that look like `name(` calls or definition headers but are language
26
+ // keywords, not user symbols — never treated as a call or a definition.
27
+ const NON_CALL = new Set([
28
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'typeof',
29
+ 'await', 'new', 'super', 'else', 'do', 'with', 'yield', 'void', 'delete',
30
+ 'in', 'of', 'case', 'throw', 'print', 'and', 'or', 'not', 'assert',
31
+ 'lambda', 'class', 'def', 'elif', 'except', 'finally', 'raise', 'import',
32
+ 'from', 'global', 'nonlocal', 'del', 'pass', 'async', 'require', 'constructor',
33
+ ]);
34
+
35
+ function normalizePath(p) { return path.normalize(p).toLowerCase(); }
36
+ function toRel(cwd, f) { return path.relative(cwd, f).replace(/\\/g, '/'); }
37
+ function symId(cwd, absFile, name) { return `${toRel(cwd, absFile)}#${name}`; }
38
+
39
+ // ── Length- and newline-preserving maskers ──────────────────────────────────
40
+ // Replace comment / string bodies with spaces so their braces, parens, and
41
+ // call-looking tokens never confuse structure detection. Offsets stay aligned.
42
+
43
+ function maskJs(src) {
44
+ const out = src.split('');
45
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
46
+ let i = 0; const n = src.length;
47
+ while (i < n) {
48
+ const c = src[i], d = src[i + 1];
49
+ if (c === '/' && d === '/') { let j = i + 2; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
50
+ 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; }
51
+ if (c === '"' || c === "'" || c === '`') {
52
+ let j = i + 1;
53
+ while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c) break; if (c !== '`' && src[j] === '\n') break; j++; }
54
+ j = Math.min(n, j + 1); blank(i, j); i = j; continue;
55
+ }
56
+ i++;
57
+ }
58
+ return out.join('');
59
+ }
60
+
61
+ function maskPy(src) {
62
+ const out = src.split('');
63
+ const blank = (a, b) => { for (let k = a; k < b; k++) if (out[k] !== '\n') out[k] = ' '; };
64
+ let i = 0; const n = src.length;
65
+ while (i < n) {
66
+ const c = src[i];
67
+ if (c === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; blank(i, j); i = j; continue; }
68
+ if (c === '"' || c === "'") {
69
+ if (src.substr(i, 3) === c + c + c) {
70
+ let j = i + 3; while (j < n && src.substr(j, 3) !== c + c + c) j++; j = Math.min(n, j + 3); blank(i, j); i = j; continue;
71
+ }
72
+ let j = i + 1; while (j < n) { if (src[j] === '\\') { j += 2; continue; } if (src[j] === c || src[j] === '\n') break; j++; }
73
+ j = Math.min(n, j + 1); blank(i, j); i = j; continue;
74
+ }
75
+ i++;
76
+ }
77
+ return out.join('');
78
+ }
79
+
80
+ // ── Balanced-delimiter matchers (operate on masked source) ───────────────────
81
+ function matchDelim(masked, openIdx, open, close) {
82
+ let depth = 0;
83
+ for (let i = openIdx; i < masked.length; i++) {
84
+ if (masked[i] === open) depth++;
85
+ else if (masked[i] === close) { depth--; if (depth === 0) return i; }
86
+ }
87
+ return masked.length - 1;
88
+ }
89
+
90
+ function lineAt(src, idx) {
91
+ let line = 1;
92
+ const end = Math.min(idx, src.length);
93
+ for (let i = 0; i < end; i++) if (src.charCodeAt(i) === 10) line++;
94
+ return line;
95
+ }
96
+
97
+ // ── Definition extraction ────────────────────────────────────────────────────
98
+ // Each def: { name, line, bodyStart, bodyEnd } with char offsets into `masked`.
99
+
100
+ function jsDefs(masked) {
101
+ const defs = [];
102
+ const seen = new Set();
103
+ const push = (name, headerIdx, bodyStart, bodyEnd) => {
104
+ const key = name + ':' + bodyStart;
105
+ if (NON_CALL.has(name) || seen.has(key)) return;
106
+ seen.add(key);
107
+ defs.push({ name, line: lineAt(masked, headerIdx), bodyStart, bodyEnd });
108
+ };
109
+
110
+ // Locate the `{` body (or `=>` expression) that follows a param list `)`.
111
+ const bodyAfterParams = (closeParen) => {
112
+ let k = closeParen + 1;
113
+ // skip a `=>`, return-type annotations, and whitespace up to `{` or a statement end
114
+ while (k < masked.length && masked[k] !== '{' && masked[k] !== ';' && masked[k] !== '\n') k++;
115
+ if (masked[k] === '{') { const end = matchDelim(masked, k, '{', '}'); return { bodyStart: k, bodyEnd: end }; }
116
+ return null; // no braced body (interface/abstract/overload signature) — skip
117
+ };
118
+
119
+ // 1) function declarations: (async) function name(...) { ... }
120
+ const reFn = /\b(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
121
+ let m;
122
+ while ((m = reFn.exec(masked)) !== null) {
123
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
124
+ const close = matchDelim(masked, paren, '(', ')');
125
+ const body = bodyAfterParams(close);
126
+ if (body) push(m[1], m.index, body.bodyStart, body.bodyEnd);
127
+ }
128
+
129
+ // 2) arrow / function expressions: const name = (...) => { } | = function(...) { }
130
+ const reArrow = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b\s*\*?\s*[A-Za-z_$]*\s*)?\(/g;
131
+ while ((m = reArrow.exec(masked)) !== null) {
132
+ const paren = masked.indexOf('(', m.index + m[0].length - 1);
133
+ const close = matchDelim(masked, paren, '(', ')');
134
+ let k = close + 1;
135
+ while (k < masked.length && /\s/.test(masked[k])) k++;
136
+ if (masked[k] === '{') { push(m[1], m.index, k, matchDelim(masked, k, '{', '}')); continue; }
137
+ if (masked[k] === '=' && masked[k + 1] === '>') {
138
+ let j = k + 2; while (j < masked.length && /\s/.test(masked[j])) j++;
139
+ if (masked[j] === '{') push(m[1], m.index, j, matchDelim(masked, j, '{', '}'));
140
+ else { // single-expression arrow body → to end of statement
141
+ let e = j; let d = 0;
142
+ while (e < masked.length) { const ch = masked[e]; if (ch === '(' || ch === '[') d++; else if (ch === ')' || ch === ']') d--; else if ((ch === ';' || ch === '\n') && d <= 0) break; e++; }
143
+ push(m[1], m.index, j, e);
144
+ }
145
+ }
146
+ }
147
+
148
+ // 3) class methods: class X { name(...) { } }
149
+ const reClass = /\bclass\s+[A-Za-z_$][\w$]*/g;
150
+ while ((m = reClass.exec(masked)) !== null) {
151
+ const brace = masked.indexOf('{', m.index);
152
+ if (brace === -1) continue;
153
+ const classEnd = matchDelim(masked, brace, '{', '}');
154
+ const reMethod = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|static\s+|readonly\s+|abstract\s+|async\s+|get\s+|set\s+|\*\s*)*([A-Za-z_$][\w$]*)\s*\(/g;
155
+ reMethod.lastIndex = brace;
156
+ let mm;
157
+ while ((mm = reMethod.exec(masked)) !== null && mm.index < classEnd) {
158
+ const paren = masked.indexOf('(', mm.index + mm[0].length - 1);
159
+ const close = matchDelim(masked, paren, '(', ')');
160
+ const body = bodyAfterParams(close);
161
+ if (body && body.bodyEnd <= classEnd) push(mm[1], mm.index, body.bodyStart, body.bodyEnd);
162
+ }
163
+ }
164
+
165
+ return defs;
166
+ }
167
+
168
+ function pyDefs(masked) {
169
+ const defs = [];
170
+ const lines = masked.split('\n');
171
+ // precompute char offset of each line start
172
+ const offsets = [0];
173
+ for (let i = 0; i < lines.length; i++) offsets.push(offsets[i] + lines[i].length + 1);
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const m = lines[i].match(/^([ \t]*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
176
+ if (!m) continue;
177
+ const indent = m[1].length;
178
+ let j = i + 1;
179
+ for (; j < lines.length; j++) {
180
+ const ln = lines[j];
181
+ if (!ln.trim()) continue; // blank
182
+ const ind = ln.match(/^[ \t]*/)[0].length;
183
+ if (ind <= indent) break; // dedent → block ends
184
+ }
185
+ defs.push({ name: m[2], line: i + 1, bodyStart: offsets[i], bodyEnd: offsets[j] || masked.length });
186
+ }
187
+ return defs;
188
+ }
189
+
190
+ function extractDefs(filePath, src) {
191
+ const ext = path.extname(filePath).toLowerCase();
192
+ if (JS_EXTS.has(ext)) return jsDefs(maskJs(src));
193
+ if (PY_EXTS.has(ext)) return pyDefs(maskPy(src));
194
+ return null; // unsupported language
195
+ }
196
+
197
+ // Collect `name(` call tokens within [start,end) of masked source.
198
+ function callsInRange(masked, start, end) {
199
+ const slice = masked.slice(start, end);
200
+ const names = new Set();
201
+ const re = /([A-Za-z_$][\w$]*)\s*\(/g;
202
+ let m;
203
+ while ((m = re.exec(slice)) !== null) {
204
+ // skip a `.name(` method access (can't resolve the receiver deterministically)
205
+ const before = slice[m.index - 1];
206
+ if (before === '.') continue;
207
+ if (!NON_CALL.has(m[1])) names.add(m[1]);
208
+ }
209
+ return names;
210
+ }
211
+
212
+ // ── Public API ───────────────────────────────────────────────────────────────
213
+
214
+ function _walk(dir, excludeSet, out, depth) {
215
+ if (depth > 8) return;
216
+ let entries;
217
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
218
+ for (const e of entries) {
219
+ if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
220
+ const full = path.join(dir, e.name);
221
+ if (e.isDirectory()) _walk(full, excludeSet, out, depth + 1);
222
+ else if (e.isFile()) {
223
+ const ext = path.extname(e.name).toLowerCase();
224
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) out.push(full);
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Build the method-level call-graph for a project.
231
+ *
232
+ * @param {string} cwd
233
+ * @param {object} [opts]
234
+ * @param {string[]} [opts.srcDirs=['src','app','lib']]
235
+ * @param {string[]} [opts.exclude]
236
+ * @param {string[]} [opts.files] explicit absolute file list (skips the walk)
237
+ * @returns {{
238
+ * forward: Map<string,string[]>, // callerId → calleeIds
239
+ * reverse: Map<string,string[]>, // calleeId → callerIds
240
+ * defs: Map<string,{file:string,name:string,line:number}>
241
+ * }}
242
+ */
243
+ function buildCallGraph(cwd, opts = {}) {
244
+ const excludeSet = new Set(opts.exclude || ['node_modules', '.git', 'dist', 'build', 'coverage', 'vendor']);
245
+ let files = opts.files ? opts.files.map((f) => path.resolve(f)) : [];
246
+ if (!opts.files) {
247
+ for (const sd of (opts.srcDirs || ['src', 'app', 'lib'])) {
248
+ const abs = path.resolve(cwd, sd);
249
+ if (fs.existsSync(abs)) _walk(abs, excludeSet, files, 0);
250
+ }
251
+ }
252
+
253
+ // File-level import graph (for precise call-site resolution). Keys normalized.
254
+ let fileGraph;
255
+ try { fileGraph = build(files, cwd); } catch (_) { fileGraph = { forward: new Map() }; }
256
+
257
+ // Per-file definitions + name→file lookups.
258
+ const perFileDefs = new Map(); // absFile → def[]
259
+ const defsByName = new Map(); // absFile → Map<name, symbolId[]>
260
+ const normToAbs = new Map(); // normalized abs → abs
261
+ const defs = new Map(); // symbolId → {file,name,line}
262
+
263
+ for (const f of files) {
264
+ normToAbs.set(normalizePath(path.resolve(f)), path.resolve(f));
265
+ let src;
266
+ try { src = fs.readFileSync(f, 'utf8'); } catch (_) { continue; }
267
+ const fileDefs = extractDefs(f, src);
268
+ if (!fileDefs) continue;
269
+ perFileDefs.set(f, fileDefs);
270
+ const byName = new Map();
271
+ for (const d of fileDefs) {
272
+ const id = symId(cwd, f, d.name);
273
+ defs.set(id, { file: toRel(cwd, f), name: d.name, line: d.line });
274
+ if (!byName.has(d.name)) byName.set(d.name, []);
275
+ byName.get(d.name).push(id);
276
+ }
277
+ defsByName.set(f, byName);
278
+ }
279
+
280
+ const forward = new Map();
281
+ const reverse = new Map();
282
+ const addEdge = (from, to) => {
283
+ if (from === to) return;
284
+ if (!forward.has(from)) forward.set(from, new Set());
285
+ forward.get(from).add(to);
286
+ if (!reverse.has(to)) reverse.set(to, new Set());
287
+ reverse.get(to).add(from);
288
+ };
289
+
290
+ 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'));
292
+ // resolution scope: this file's defs, then directly-imported files' defs
293
+ const importedAbs = (fileGraph.forward.get(normalizePath(path.resolve(f))) || [])
294
+ .map((nf) => normToAbs.get(nf)).filter(Boolean);
295
+ for (const d of fileDefs) {
296
+ const callerId = symId(cwd, f, d.name);
297
+ if (!forward.has(callerId)) forward.set(callerId, new Set()); // ensure node exists
298
+ const callees = callsInRange(masked, d.bodyStart, d.bodyEnd);
299
+ for (const nm of callees) {
300
+ const local = (defsByName.get(f) || new Map()).get(nm);
301
+ if (local && local.length) { for (const id of local) addEdge(callerId, id); continue; }
302
+ for (const imp of importedAbs) {
303
+ const ids = (defsByName.get(imp) || new Map()).get(nm);
304
+ if (ids && ids.length) { for (const id of ids) addEdge(callerId, id); break; }
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ const toArr = (mapOfSets) => {
311
+ const out = new Map();
312
+ for (const [k, set] of mapOfSets.entries()) out.set(k, [...set]);
313
+ return out;
314
+ };
315
+ return { forward: toArr(forward), reverse: toArr(reverse), defs };
316
+ }
317
+
318
+ // Resolve a user-supplied symbol (bare name or full `file#name` id) to ids.
319
+ function _resolveSymbol(symbol, defs) {
320
+ if (defs.has(symbol)) return [symbol];
321
+ const ids = [];
322
+ for (const id of defs.keys()) if (id.slice(id.indexOf('#') + 1) === symbol) ids.push(id);
323
+ return ids;
324
+ }
325
+
326
+ // BFS over a graph map from seed ids up to maxDepth (0 = unlimited).
327
+ function _bfs(seedIds, graph, maxDepth) {
328
+ const direct = new Set();
329
+ const transitive = new Set();
330
+ const visited = new Set(seedIds);
331
+ let frontier = [];
332
+ for (const s of seedIds) for (const nb of (graph.get(s) || [])) if (!visited.has(nb)) { direct.add(nb); visited.add(nb); frontier.push(nb); }
333
+ let depth = 1;
334
+ while (frontier.length && (maxDepth === 0 || depth < maxDepth)) {
335
+ const next = [];
336
+ for (const node of frontier) for (const nb of (graph.get(node) || [])) if (!visited.has(nb)) { transitive.add(nb); visited.add(nb); next.push(nb); }
337
+ frontier = next; depth++;
338
+ }
339
+ return { direct: [...direct], transitive: [...transitive] };
340
+ }
341
+
342
+ /**
343
+ * Method-level blast radius: everything that (transitively) calls `symbol`.
344
+ *
345
+ * @param {string} symbol bare name or `file#name`
346
+ * @param {string} cwd
347
+ * @param {object} [opts] { depth=0, ...buildCallGraph opts }
348
+ * @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
349
+ */
350
+ function methodImpact(symbol, cwd, opts = {}) {
351
+ const graph = opts.graph || buildCallGraph(cwd, opts);
352
+ const ids = _resolveSymbol(symbol, graph.defs);
353
+ if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
354
+ const { direct, transitive } = _bfs(ids, graph.reverse, opts.depth || 0);
355
+ return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
356
+ }
357
+
358
+ /**
359
+ * What `symbol` (transitively) calls.
360
+ * @returns {{ symbol:string, resolved:string[], direct:string[], transitive:string[], total:number, unresolved:boolean }}
361
+ */
362
+ function methodCallees(symbol, cwd, opts = {}) {
363
+ const graph = opts.graph || buildCallGraph(cwd, opts);
364
+ const ids = _resolveSymbol(symbol, graph.defs);
365
+ if (ids.length === 0) return { symbol, resolved: [], direct: [], transitive: [], total: 0, unresolved: true };
366
+ const { direct, transitive } = _bfs(ids, graph.forward, opts.depth || 0);
367
+ return { symbol, resolved: ids, direct, transitive, total: direct.length + transitive.length, unresolved: false };
368
+ }
369
+
370
+ // ── Formatters ───────────────────────────────────────────────────────────────
371
+ function formatCallGraph(result, kind) {
372
+ const verb = kind === 'callees' ? 'calls' : 'callers of';
373
+ const lines = [`## ${kind === 'callees' ? 'Callees' : 'Callers'}: \`${result.symbol}\``, ''];
374
+ if (result.unresolved) { lines.push('_symbol not found in the call-graph._'); return lines.join('\n'); }
375
+ if (result.total === 0) {
376
+ lines.push(kind === 'callees' ? '_calls no repo-defined symbols._' : '_no repo symbol calls this — zero method blast radius._');
377
+ return lines.join('\n');
378
+ }
379
+ lines.push(`**Total ${verb}:** ${result.total}`, '');
380
+ if (result.direct.length) { lines.push(`### Direct`); for (const id of result.direct) lines.push(`- \`${id}\``); lines.push(''); }
381
+ if (result.transitive.length) { lines.push(`### Transitive`); for (const id of result.transitive) lines.push(`- \`${id}\``); lines.push(''); }
382
+ return lines.join('\n');
383
+ }
384
+
385
+ function formatCallGraphJSON(result, kind) {
386
+ return {
387
+ symbol: result.symbol,
388
+ kind: kind === 'callees' ? 'callees' : 'callers',
389
+ resolved: result.resolved,
390
+ direct: result.direct,
391
+ transitive: result.transitive,
392
+ total: result.total,
393
+ unresolved: result.unresolved,
394
+ };
395
+ }
396
+
397
+ module.exports = {
398
+ buildCallGraph, methodImpact, methodCallees,
399
+ formatCallGraph, formatCallGraphJSON,
400
+ extractDefs, maskJs, maskPy,
401
+ };
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.6.0',
21
+ version: '8.7.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24