sigmap 8.5.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,25 @@ 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
+
21
+ ## [8.6.0] — 2026-07-05
22
+
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.
24
+
25
+ ### Added
26
+ - **Public benchmark harness (#425, PR #426):** new `public-benchmarks/` — a self-contained, third-party-runnable retrieval harness. `repos.csv` (18 real repos pinned to exact commits), `queries.json` (90 natural-language query → expected-file tasks), `run.sh` (shallow-clones the pinned repos, then scores), `score.mjs` (maps each repo with `gen-context.js`, ranks with the **shipped** identifier-aware BM25 ranker `src/retrieval/bm25.js`, reports hit@1/hit@5/MRR), and a methodology `README.md`. Deterministic — pinned commits + a byte-stable map + rank-only math → the same numbers on any machine; no LLM, no API keys, no external deps. Dev-only (excluded from the npm package). Turns the published retrieval numbers from a claim into a third-party-verifiable fact (v9.0 G1).
27
+ - **Version pins in the context header (#425, PR #426):** the generated header now carries a `## versions (installed direct deps)` block of sorted `name@version` pins for installed JS + Python direct dependencies, so agents reading `CLAUDE.md`/`AGENTS.md` ground against what is **actually installed here** (compounds with the G5/D5 verify moat). New `collectVersionPins(cwd)` in `src/verify/lib-index.js` (versions only — no symbol parsing, cheap enough to run per-build); gated by a new `versionPins` config key (default on). Byte-stable given a fixed installed tree (D8).
28
+
29
+ ### Changed
30
+ - **`sigmap verify` — the grounding flagship (#425, PR #426):** `verify` is now a first-class alias of `verify-ai-output` (identical flags, output, and exit codes), with a dedicated flagship section near the top of the README and a `--help` entry. Positions the deterministic grounding guard — the one capability no agentic-grep loop or competitor offers — as the headline (v9.0 G2).
31
+
13
32
  ## [8.5.0] — 2026-07-05
14
33
 
15
34
  Minor release — **deterministic query expansion (a vocabulary-mismatch recall aid).** The BM25 ranker now bridges common code-domain synonyms/abbreviations so a query for `authentication` can still surface a file whose signatures only say `auth`. Zero-dependency, deterministic. **Honest framing:** measured on the retrieval benchmark, this is **benchmark-neutral** (hit@5 unchanged within the harness's 86.7–87.8% noise band at the shipped weight) — not a hit@5 improvement. The benefit is for real users whose query vocabulary differs from the code, a case the curated benchmark doesn't exercise.
package/README.md CHANGED
@@ -52,15 +52,15 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
52
52
 
53
53
  **Deterministic and verifiable — the two things an agentic-grep loop can't give you:**
54
54
  - **Deterministic** — no LLM calls, no agent loop; the same repo always produces a byte-identical map you can diff, cache, and gate in CI.
55
- - **Auditable & grounded** — every file and symbol traces to a real line anchor; `sigmap verify-ai-output` flags any AI claim that isn't.
55
+ - **Auditable & grounded** — every file and symbol traces to a real line anchor; `sigmap verify` flags any AI claim that isn't.
56
56
  - **Zero dependencies** — `npx sigmap` on any machine; no embeddings, no vector DB, no hosted service, fully offline.
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
- - **67.8% task success rate** — up from 10% without context
63
- - **1.46 prompts per task** — down from 2.84 (48.8% 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
@@ -69,12 +69,34 @@ That map is exactly what agentic grep is worst at: reproducible, auditable conte
69
69
 
70
70
  ---
71
71
 
72
+ ## 🔒 `sigmap verify` — the grounding flagship
73
+
74
+ The one thing no agentic-grep loop, and no competitor, gives you: **prove an AI answer is anchored to real signatures and line numbers before you trust it.** Deterministic, offline, no LLM — SigMap indexes your repo *plus the libraries actually installed here* and flags every fabricated file, import, symbol, test, or npm script.
75
+
76
+ ```bash
77
+ sigmap verify answer.md # ✓ grounded, or a line-by-line list of fabrications
78
+ sigmap verify answer.md --json # machine-readable report; exits 1 if any issue (CI gate)
79
+ sigmap verify answer.md --report # standalone red/amber/green HTML report
80
+ ```
81
+
82
+ ```text
83
+ [sigmap] ✗ answer.md — 2 issues found
84
+ fake-file: 1 fake-test-file: 0 fake-import: 0 fake-symbol: 1 fake-npm-script: 0
85
+
86
+ L12 [Fake file] src/auth/session-store.js does not exist
87
+ L27 [Fake symbol] authorize() — did you mean authenticate()?
88
+ ```
89
+
90
+ `verify` is the flagship; `verify-ai-output` remains as the full command name. Pair it with `sigmap verify-plan` (check a plan before execution) and the `verify_suggestion` MCP tool (verify AI code against repo + private + installed-library symbols mid-session).
91
+
92
+ ---
93
+
72
94
  ## Replace this with SigMap
73
95
 
74
96
  | Without SigMap | With SigMap |
75
97
  |---|---|
76
98
  | ❌ Non-reproducible agent guesses | ✅ Deterministic map — same input, same output, every time |
77
- | ❌ "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 |
78
100
  | ❌ Embeddings / vector DB required | ✅ Zero deps, no infra, fully offline |
79
101
 
80
102
  ---
@@ -98,13 +120,13 @@ Ask → Rank → Context → Validate → Judge → Learn
98
120
 
99
121
  <!--SM:benchmarkBlock-->
100
122
  ```
101
- Benchmark : sigmap-v8.5-main (21 repositories, including R language)
102
- Date : 2026-07-04
123
+ Benchmark : sigmap-v8.7-main (21 repositories, including R language)
124
+ Date : 2026-07-05
103
125
 
104
- Hit@5 : 86.7% (baseline 13.6% — 6.4× lift)
126
+ Hit@5 : 87.8% (baseline 13.6% — 6.5× lift)
105
127
  Token reduction: 97.0% (across 21 repos)
106
- Prompt reduction : 48.8% (2.84 → 1.46 prompts per task)
107
- Task success : 67.8% (baseline 10%)
128
+ Prompt reduction : 49.6% (2.84 → 1.43 prompts per task)
129
+ Task success : 68.9% (baseline 10%)
108
130
  Repos tested : 21 (JavaScript, Python, Go, Rust, Java, R, C++, C#, Dart, Swift, Ruby, PHP, Scala, Kotlin, and more)
109
131
  ```
110
132
  <!--/SM:benchmarkBlock-->
package/gen-context.js CHANGED
@@ -1498,6 +1498,9 @@ __factories["./src/config/defaults"] = function(module, exports) {
1498
1498
  // Include a compact import dependency map at top of output
1499
1499
  depMap: true,
1500
1500
 
1501
+ // Include a compact `name@version` list of installed direct deps (D8)
1502
+ versionPins: true,
1503
+
1501
1504
  // Include TODO/FIXME/HACK/XXX comments as compact section
1502
1505
  todos: true,
1503
1506
 
@@ -10450,6 +10453,411 @@ __factories["./src/graph/builder"] = function(module, exports) {
10450
10453
 
10451
10454
  };
10452
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
+
10453
10861
  // ── ./src/graph/impact ──
10454
10862
  __factories["./src/graph/impact"] = function(module, exports) {
10455
10863
 
@@ -13108,7 +13516,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
13108
13516
 
13109
13517
  const SERVER_INFO = {
13110
13518
  name: 'sigmap',
13111
- version: '8.5.0',
13519
+ version: '8.7.0',
13112
13520
  description: 'SigMap MCP server — code signatures on demand',
13113
13521
  };
13114
13522
 
@@ -17311,8 +17719,37 @@ __factories["./src/verify/lib-index"] = function(module, exports) {
17311
17719
  .map((l) => `${l.name}@${l.version}`);
17312
17720
  }
17313
17721
 
17722
+ /**
17723
+ * D8: collect `name@version` pins for direct dependencies — versions only, no
17724
+ * symbol extraction, no cache. Cheap enough to run on every context build so
17725
+ * the generated header can ground against what is actually installed (JS +
17726
+ * Python). Deterministic: sorted, then capped.
17727
+ * @param {string} cwd
17728
+ * @param {object} [opts]
17729
+ * @param {number} [opts.limit=40] max pins returned (after sort)
17730
+ * @returns {{ pins: string[], total: number }} pins capped to limit; total = all resolved
17731
+ */
17732
+ function collectVersionPins(cwd, opts = {}) {
17733
+ const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : 40;
17734
+ const pins = [];
17735
+ for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
17736
+ const r = resolveEntry(cwd, dep);
17737
+ if (r && r.version) pins.push(`${dep}@${r.version}`);
17738
+ }
17739
+ const sitePkgs = findSitePackages(cwd);
17740
+ if (sitePkgs.length) {
17741
+ for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
17742
+ const r = resolvePyEntry(sitePkgs, dep);
17743
+ if (r && r.version) pins.push(`${dep}@${r.version}`);
17744
+ }
17745
+ }
17746
+ pins.sort();
17747
+ return { pins: limit ? pins.slice(0, limit) : pins, total: pins.length };
17748
+ }
17749
+
17314
17750
  module.exports = {
17315
17751
  buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
17752
+ collectVersionPins,
17316
17753
  extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
17317
17754
  };
17318
17755
 
@@ -17647,7 +18084,7 @@ function __tryGit(args, opts = {}) {
17647
18084
  catch (_) { return ''; }
17648
18085
  }
17649
18086
 
17650
- const VERSION = '8.5.0';
18087
+ const VERSION = '8.7.0';
17651
18088
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
17652
18089
 
17653
18090
  function requireSourceOrBundled(key) {
@@ -18250,6 +18687,23 @@ function formatOutput(fileEntries, cwd, routingEnabled, config, extras) {
18250
18687
  lines.push('');
18251
18688
  }
18252
18689
 
18690
+ // D8: installed direct-dependency version pins — grounds agents against what
18691
+ // is actually installed here (byte-stable given a fixed installed tree).
18692
+ if (!config || config.versionPins !== false) {
18693
+ try {
18694
+ const { collectVersionPins } = requireSourceOrBundled('./src/verify/lib-index');
18695
+ const { pins, total } = collectVersionPins(cwd);
18696
+ if (pins.length) {
18697
+ lines.push('## versions (installed direct deps)');
18698
+ lines.push('```');
18699
+ lines.push(...pins);
18700
+ if (total > pins.length) lines.push(`… +${total - pins.length} more`);
18701
+ lines.push('```');
18702
+ lines.push('');
18703
+ }
18704
+ } catch (_) { /* no/uninstalled deps → skip */ }
18705
+ }
18706
+
18253
18707
  const todoLines = buildTodoSection(fileEntries, cwd, config || {});
18254
18708
  if (todoLines.length) {
18255
18709
  lines.push('## todos');
@@ -19442,9 +19896,13 @@ Usage:
19442
19896
  ${cmd} --impact <file> Show every file impacted by changing <file>
19443
19897
  ${cmd} --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
19444
19898
  ${cmd} --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
19445
- ${cmd} verify-ai-output <answer.md> Flag fake files/tests/imports/symbols/npm-scripts in an AI answer
19446
- ${cmd} verify-ai-output <answer.md> --json Hallucination report as JSON (exits 1 if issues)
19447
- ${cmd} verify-ai-output <answer.md> --report Write a standalone HTML report (red/amber/green)
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)
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)
19903
+ ${cmd} verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
19904
+ ${cmd} verify <answer.md> --report Write a standalone HTML report (red/amber/green)
19905
+ ${cmd} verify-ai-output <answer.md> Full command name for ${cmd} verify
19448
19906
  ${cmd} conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
19449
19907
  ${cmd} scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
19450
19908
  ${cmd} verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
@@ -21304,7 +21762,10 @@ function main() {
21304
21762
  process.exit(s.ok ? 0 : 1);
21305
21763
  }
21306
21764
 
21307
- if (args[0] === 'verify-ai-output') {
21765
+ // `sigmap verify` is the flagship alias for `verify-ai-output` — the grounding
21766
+ // guard that proves an AI answer is anchored to real signatures + line anchors.
21767
+ if (args[0] === 'verify-ai-output' || args[0] === 'verify') {
21768
+ const cmdName = args[0];
21308
21769
  const target = args[1];
21309
21770
  const jsonOut = args.includes('--json');
21310
21771
  const reportIdx = args.indexOf('--report');
@@ -21312,7 +21773,7 @@ function main() {
21312
21773
  ? (args[reportIdx + 1] && !args[reportIdx + 1].startsWith('--') ? args[reportIdx + 1] : 'sigmap-verify-report.html')
21313
21774
  : null;
21314
21775
  if (!target || target.startsWith('--')) {
21315
- console.error('[sigmap] Usage: sigmap verify-ai-output <answer.md> [--json] [--report [out.html]]');
21776
+ console.error(`[sigmap] Usage: sigmap ${cmdName} <answer.md> [--json] [--report [out.html]]`);
21316
21777
  process.exit(1);
21317
21778
  }
21318
21779
  const absTarget = path.resolve(cwd, target);
@@ -22014,6 +22475,37 @@ function main() {
22014
22475
  process.exit(0);
22015
22476
  }
22016
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
+
22017
22509
  if (args.includes('--report')) {
22018
22510
  if (args.includes('--history')) {
22019
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.5.0 | Benchmark: sigmap-v8.5-main (2026-07-04)
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.5-main, 2026-07-04)
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% | 67.8% |
27
- | Prompts per task | 2.84 | 1.46 (48.8% 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,9 +99,13 @@ 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 verify-ai-output <answer.md> Flag fake files/tests/imports/symbols/npm-scripts in an AI answer
103
- sigmap verify-ai-output <answer.md> --json Hallucination report as JSON (exits 1 if issues)
104
- sigmap verify-ai-output <answer.md> --report Write a standalone HTML report (red/amber/green)
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)
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)
106
+ sigmap verify <answer.md> --json Grounding report as JSON (exits 1 if issues)
107
+ sigmap verify <answer.md> --report Write a standalone HTML report (red/amber/green)
108
+ sigmap verify-ai-output <answer.md> Full command name for gen-context verify
105
109
  sigmap conventions Extract repo file-naming/export/test conventions (--conflicts, --inject, --report, --fix)
106
110
  sigmap scaffold "<name>" Propose a convention-matched file/dir scaffold (--ext, --threshold, --force, --json)
107
111
  sigmap verify-plan <plan.md|-> Check a plan vs the live index — files/symbols exist, blast radius, scope (--json)
@@ -310,6 +314,7 @@ format = default
310
314
  tracking = false
311
315
  mcp = {"autoRegister":true}
312
316
  depMap = true
317
+ versionPins = true
313
318
  todos = true
314
319
  changes = true
315
320
  changesCommits = 10
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.5.0 | Benchmark: sigmap-v8.5-main (2026-07-04)
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.5-main, 2026-07-04)
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: 67.8% vs 10% without SigMap
31
- - Prompts per task: 1.46 vs 2.84 baseline (48.8% 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.5.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.5.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.5.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": [
@@ -114,6 +114,9 @@ const DEFAULTS = {
114
114
  // Include a compact import dependency map at top of output
115
115
  depMap: true,
116
116
 
117
+ // Include a compact `name@version` list of installed direct deps (D8)
118
+ versionPins: true,
119
+
117
120
  // Include TODO/FIXME/HACK/XXX comments as compact section
118
121
  todos: true,
119
122
 
@@ -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.5.0',
21
+ version: '8.7.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -298,7 +298,36 @@ function formatVersionPins(libraries) {
298
298
  .map((l) => `${l.name}@${l.version}`);
299
299
  }
300
300
 
301
+ /**
302
+ * D8: collect `name@version` pins for direct dependencies — versions only, no
303
+ * symbol extraction, no cache. Cheap enough to run on every context build so
304
+ * the generated header can ground against what is actually installed (JS +
305
+ * Python). Deterministic: sorted, then capped.
306
+ * @param {string} cwd
307
+ * @param {object} [opts]
308
+ * @param {number} [opts.limit=40] max pins returned (after sort)
309
+ * @returns {{ pins: string[], total: number }} pins capped to limit; total = all resolved
310
+ */
311
+ function collectVersionPins(cwd, opts = {}) {
312
+ const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : 40;
313
+ const pins = [];
314
+ for (const dep of directDeps(cwd).slice(0, MAX_DEPS)) {
315
+ const r = resolveEntry(cwd, dep);
316
+ if (r && r.version) pins.push(`${dep}@${r.version}`);
317
+ }
318
+ const sitePkgs = findSitePackages(cwd);
319
+ if (sitePkgs.length) {
320
+ for (const dep of pythonDirectDeps(cwd).slice(0, MAX_DEPS)) {
321
+ const r = resolvePyEntry(sitePkgs, dep);
322
+ if (r && r.version) pins.push(`${dep}@${r.version}`);
323
+ }
324
+ }
325
+ pins.sort();
326
+ return { pins: limit ? pins.slice(0, limit) : pins, total: pins.length };
327
+ }
328
+
301
329
  module.exports = {
302
330
  buildLibraryIndex, extractDtsExports, directDeps, resolveEntry, formatVersionPins,
331
+ collectVersionPins,
303
332
  extractPyExports, pythonDirectDeps, findSitePackages, resolvePyEntry,
304
333
  };