sigmap 2.4.0 → 2.6.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
@@ -6,6 +6,49 @@ Format: [Semantic Versioning](https://semver.org/)
6
6
 
7
7
  ---
8
8
 
9
+ ## [2.6.0] — upcoming · [#16](https://github.com/manojmallick/sigmap/issues/16) · branch: `feat/v2.6-research-mode`
10
+
11
+ ### Planned additions
12
+ - **`benchmarks/repos/`** — register 5 real open-source repos (express, flask, gin, spring-petclinic, rails) as git submodules or clone targets for evaluation
13
+ - **`benchmarks/tasks/retrieval-real.jsonl`** — 50 real evaluation tasks across all 5 repos; structured JSONL format compatible with the v2.1 benchmark runner
14
+ - **`--benchmark --repo <path>` CLI flag** — run benchmark against external repository; supports any git-cloned project
15
+ - **`--report --paper` CLI flag** — generates `benchmarks/reports/paper-metrics.md`: token reduction table (baseline vs SigMap per repo), hit@5 and MRR per repo, latency table (p50, p95, p99 in ms), LaTeX-ready table block for copy-paste into academic papers
16
+ - **`src/eval/paper.js`** — formats paper-ready markdown + LaTeX tables; zero dependencies
17
+ - **`test/integration/paper.test.js`** — 8 tests: `--report --paper` creates the report file, report contains all required sections, LaTeX table block present and syntactically valid, `--benchmark --repo <missing>` fails gracefully
18
+
19
+ ### Go / No-go criteria
20
+ - All tests green (21 extractor + all integration suites)
21
+ - `--report --paper` generates a valid markdown file
22
+ - LaTeX table block present in report
23
+ - Overall hit@5 across all repos ≥ 0.75
24
+ - `--benchmark --repo .` completes in < 30 s on SigMap repo
25
+
26
+ ---
27
+
28
+ ## [2.5.0] — 2026-04-05
29
+
30
+ ### Added
31
+ - **Impact analysis layer** — `src/graph/impact.js` provides dependency impact analysis: `getImpact(changedFile, graph)` → `{ changed, direct, transitive, tests, routes }`. Uses reverse dependency graph (BFS traversal) to find all files affected by a change.
32
+ - **`--impact <file>` CLI flag** — prints all files impacted by changing `<file>`, with their signatures. Supports `--impact --json` (machine-readable output) and `--impact --depth <n>` (BFS depth limit).
33
+ - **`get_impact` MCP tool** — 9th MCP tool; accepts `{ file: string, depth?: number }` and returns list of impacted files + signatures, usable live in any MCP session.
34
+ - **Dependency graph builder** — `src/graph/builder.js` enhanced: `build(files, cwd)` now returns `{ forward, reverse }` maps; reverse map powers impact analysis.
35
+ - **Impact config** — `config.impact.depth` (default: unlimited) and `config.impact.includeSigs` (default: true) added to `src/config/defaults.js`.
36
+ - **`test/integration/impact.test.js`** — 20 integration tests: direct deps, transitive deps, circular dependency handling (no infinite loop), depth limit, unknown file returns empty, JSON output shape, MCP tool contract, formatImpact output.
37
+
38
+ ### Changed
39
+ - `src/mcp/server.js` version bumped to `2.5.0`.
40
+ - `src/mcp/tools.js` now includes `get_impact` tool definition (9th tool).
41
+ - `test/integration/mcp-server.test.js` updated to assert 9 tools.
42
+
43
+ ### Validation gate
44
+ - 21/21 extractor unit tests passed
45
+ - 22/22 integration suites passed (0 failures, including new `impact.test.js`)
46
+ - `--impact src/graph/impact.js` returns correct transitive dependencies
47
+ - MCP `tools/list` returns 9 tools
48
+ - No infinite loops on circular dependencies
49
+
50
+ ---
51
+
9
52
  ## [2.4.0] — 2026-04-05
10
53
 
11
54
  ### Added
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  <!-- Status -->
13
13
  [![npm version](https://img.shields.io/npm/v/sigmap?color=7c6af7&label=latest&logo=npm)](https://www.npmjs.com/package/sigmap)
14
- [![Tests](https://img.shields.io/badge/tests-325%20passing-22c55e)](https://github.com/manojmallick/sigmap/tree/main/test)
14
+ [![Tests](https://img.shields.io/badge/tests-340%20passing-22c55e)](https://github.com/manojmallick/sigmap/tree/main/test)
15
15
  [![Zero deps](https://img.shields.io/badge/dependencies-zero-22c55e)](package.json)
16
16
  [![Last commit](https://img.shields.io/github/last-commit/manojmallick/sigmap?color=7c6af7)](https://github.com/manojmallick/sigmap/commits/main)
17
17
 
@@ -86,6 +86,37 @@ AI agent session starts with full context
86
86
 
87
87
  ---
88
88
 
89
+ ## 🔭 What's next — v2.5-v2.6 (in progress · [#14](https://github.com/manojmallick/sigmap/issues/14) · [#16](https://github.com/manojmallick/sigmap/issues/16))
90
+
91
+ ### v2.5 — Impact Layer
92
+
93
+ | Feature | Description |
94
+ |---|---|
95
+ | **`--impact <file>`** | Show every file that transitively depends on a changed file — instant blast-radius awareness |
96
+ | **`--impact --json`** | Machine-readable output for CI pipelines |
97
+ | **`get_impact` MCP tool** | 9th MCP tool — `{ file, depth? }` → impacted files + signatures |
98
+ | **`src/map/dep-graph.js`** | Reverse-dependency graph built from the import analysis; circular deps handled safely |
99
+ | **15 new tests** | `impact.test.js` — direct deps, transitive deps, depth limit, JSON output |
100
+
101
+ ### v2.6 — Research Mode
102
+
103
+ | Feature | Description |
104
+ |---|---|
105
+ | **`--benchmark --repo <path>`** | Run benchmarks against any external repository (express, flask, gin, spring-petclinic, rails) |
106
+ | **`--report --paper`** | Generate paper-ready metrics: markdown + LaTeX tables for academic publishing |
107
+ | **50 real eval tasks** | JSONL task file covering 5 real open-source repos — `benchmarks/tasks/retrieval-real.jsonl` |
108
+ | **`src/eval/paper.js`** | Zero-dependency LaTeX table formatter for token reduction, hit@5, MRR, latency (p50/p95/p99) |
109
+ | **8 new tests** | `paper.test.js` — report generation, LaTeX syntax validation, graceful failures |
110
+
111
+ ## 🆕 What's new in 2.4
112
+
113
+ | Feature | Description |
114
+ |---|---|
115
+ | **Programmatic API** | `require('sigmap')` — use `extract`, `rank`, `buildSigIndex`, `scan`, `score` directly, no CLI subprocess |
116
+ | **`packages/core/`** | New `sigmap-core` package with stable API surface for third-party integrations |
117
+ | **`packages/cli/`** | Thin `sigmap-cli` forward-compat shim for the v3.0 adapter architecture |
118
+ | **15 new tests** | `core-api.test.js` covers all exported functions, edge cases, and backward compat |
119
+
89
120
  ## 🆕 What's new in 2.3
90
121
 
91
122
  | Feature | Description |
package/gen-context.js CHANGED
@@ -2462,6 +2462,182 @@ __factories["./src/map/route-table"] = function(module, exports) {
2462
2462
 
2463
2463
  };
2464
2464
 
2465
+ // ── ./src/graph/builder ──
2466
+ __factories["./src/graph/builder"] = function(module, exports) {
2467
+ 'use strict';
2468
+ const fs = require('fs');
2469
+ const path = require('path');
2470
+ const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
2471
+ const PY_EXTS = new Set(['.py', '.pyw']);
2472
+ const GO_EXTS = new Set(['.go']);
2473
+ const RS_EXTS = new Set(['.rs']);
2474
+ const JVM_EXTS = new Set(['.java', '.kt', '.kts', '.scala', '.sc']);
2475
+ const RB_EXTS = new Set(['.rb', '.rake']);
2476
+ function resolveJsPath(dir, importStr, fileSet) {
2477
+ const base = path.resolve(dir, importStr);
2478
+ const candidates = [base, base+'.ts', base+'.tsx', base+'.js', base+'.jsx', base+'.mjs', base+'.cjs', path.join(base,'index.ts'), path.join(base,'index.js')];
2479
+ for (const c of candidates) { if (fileSet.has(c)) return c; }
2480
+ return null;
2481
+ }
2482
+ function extractFileDeps(filePath, content, fileSet) {
2483
+ const ext = path.extname(filePath).toLowerCase();
2484
+ const dir = path.dirname(filePath);
2485
+ const found = [];
2486
+ if (JS_EXTS.has(ext)) {
2487
+ const stripped = content.replace(/\/\/.*$/gm,'').replace(/\/\*[\s\S]*?\*\//g,'');
2488
+ const reEs = /(?:^|[\r\n])\s*import\s+(?:[^'";\r\n]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
2489
+ let m;
2490
+ while ((m = reEs.exec(stripped)) !== null) { const r = resolveJsPath(dir, m[1], fileSet); if (r) found.push(r); }
2491
+ const reCjs = /\brequire\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
2492
+ while ((m = reCjs.exec(stripped)) !== null) { const r = resolveJsPath(dir, m[1], fileSet); if (r) found.push(r); }
2493
+ }
2494
+ if (PY_EXTS.has(ext)) {
2495
+ const re = /^[ \t]*from\s+(\.+[\w.]*)\s+import/gm; let m;
2496
+ while ((m = re.exec(content)) !== null) {
2497
+ const dotCount = (m[1].match(/^\.+/)||[''])[0].length;
2498
+ const modPart = m[1].slice(dotCount).replace(/\./g,'/');
2499
+ let base = dir; for (let i=1;i<dotCount;i++) base=path.dirname(base);
2500
+ const candidate = modPart ? path.join(base,modPart+'.py') : null;
2501
+ if (candidate && fileSet.has(candidate)) found.push(candidate);
2502
+ }
2503
+ }
2504
+ if (GO_EXTS.has(ext)) {
2505
+ const imports = []; let m;
2506
+ const re = /import\s*\(\s*([\s\S]*?)\s*\)/g;
2507
+ const reInline = /import\s+"([^"]+)"/g;
2508
+ while ((m = re.exec(content)) !== null) { for (const imp of m[1].matchAll(/"([^"]+)"/g)) imports.push(imp[1]); }
2509
+ while ((m = reInline.exec(content)) !== null) imports.push(m[1]);
2510
+ for (const imp of imports) {
2511
+ const suffix = imp.split('/').pop();
2512
+ for (const f of fileSet) { if (f.endsWith(path.sep+suffix+'.go')||f.includes(path.sep+suffix+path.sep)) { found.push(f); break; } }
2513
+ }
2514
+ }
2515
+ if (RS_EXTS.has(ext)) {
2516
+ const reMod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm; let m;
2517
+ while ((m = reMod.exec(content)) !== null) {
2518
+ const c1 = path.join(dir,m[1]+'.rs'); if (fileSet.has(c1)) found.push(c1);
2519
+ const c2 = path.join(dir,m[1],'mod.rs'); if (fileSet.has(c2)) found.push(c2);
2520
+ }
2521
+ }
2522
+ if (JVM_EXTS.has(ext)) {
2523
+ const re = /^\s*import\s+([\w.]+)\s*;?/gm; let m;
2524
+ while ((m = re.exec(content)) !== null) {
2525
+ const asPath = m[1].replace(/\./g,path.sep);
2526
+ for (const jvmExt of ['.java','.kt','.kts','.scala','.sc']) {
2527
+ for (const f of fileSet) { if (f.endsWith(asPath+jvmExt)) { found.push(f); break; } }
2528
+ }
2529
+ }
2530
+ }
2531
+ if (RB_EXTS.has(ext)) {
2532
+ const re = /^\s*require_relative\s+['"]([^'"]+)['"]/gm; let m;
2533
+ while ((m = re.exec(content)) !== null) {
2534
+ const base = path.resolve(dir,m[1]);
2535
+ const candidate = base.endsWith('.rb') ? base : base+'.rb';
2536
+ if (fileSet.has(candidate)) found.push(candidate);
2537
+ }
2538
+ }
2539
+ return [...new Set(found)];
2540
+ }
2541
+ function build(files, cwd) {
2542
+ const fileSet = new Set(files.map((f) => path.resolve(f)));
2543
+ const forward = new Map(); const reverse = new Map();
2544
+ for (const f of fileSet) { if (!forward.has(f)) forward.set(f,[]); if (!reverse.has(f)) reverse.set(f,[]); }
2545
+ for (const filePath of fileSet) {
2546
+ let content; try { content = fs.readFileSync(filePath,'utf8'); } catch(_) { continue; }
2547
+ const deps = extractFileDeps(filePath, content, fileSet);
2548
+ if (deps.length > 0) {
2549
+ forward.set(filePath, deps);
2550
+ for (const dep of deps) { if (!reverse.has(dep)) reverse.set(dep,[]); reverse.get(dep).push(filePath); }
2551
+ }
2552
+ }
2553
+ return { forward, reverse };
2554
+ }
2555
+ function buildFromCwd(cwd, opts) {
2556
+ const { srcDirs=['src','app','lib'], exclude=['node_modules','.git','dist','build'] } = opts||{};
2557
+ const excludeSet = new Set(exclude);
2558
+ function walkDir(dir,depth) {
2559
+ if (depth>8) return [];
2560
+ let entries; try { entries = fs.readdirSync(dir,{withFileTypes:true}); } catch(_) { return []; }
2561
+ const out=[];
2562
+ for (const e of entries) {
2563
+ if (excludeSet.has(e.name)||e.name.startsWith('.')) continue;
2564
+ const full = path.join(dir,e.name);
2565
+ if (e.isDirectory()) out.push(...walkDir(full,depth+1));
2566
+ else if (e.isFile()) {
2567
+ const ext = path.extname(e.name).toLowerCase();
2568
+ if (JS_EXTS.has(ext)||PY_EXTS.has(ext)||GO_EXTS.has(ext)||RS_EXTS.has(ext)||JVM_EXTS.has(ext)||RB_EXTS.has(ext)) out.push(full);
2569
+ }
2570
+ }
2571
+ return out;
2572
+ }
2573
+ const files=[];
2574
+ for (const sd of srcDirs) { const absDir=path.resolve(cwd,sd); if (fs.existsSync(absDir)) files.push(...walkDir(absDir,0)); }
2575
+ for (const rootFile of ['gen-context.js','index.js','main.js','app.js']) {
2576
+ const abs=path.resolve(cwd,rootFile); if (fs.existsSync(abs)) files.push(abs);
2577
+ }
2578
+ return build(files, cwd);
2579
+ }
2580
+ module.exports = { build, buildFromCwd, extractFileDeps };
2581
+ };
2582
+
2583
+ // ── ./src/graph/impact ──
2584
+ __factories["./src/graph/impact"] = function(module, exports) {
2585
+ 'use strict';
2586
+ const path = require('path');
2587
+ const { buildFromCwd } = __require('./src/graph/builder');
2588
+ const TEST_PATTERNS = [/[./\\](test|tests|spec|__tests__)[./\\]/,/\.(test|spec)\.[jt]sx?$/,/_test\.[jt]sx?$/,/_test\.py$/,/test_[^/\\]+\.py$/];
2589
+ const ROUTE_PATTERNS = [/router?\.[jt]sx?$/i,/routes?\.[jt]sx?$/i,/controller\.[jt]sx?$/i,/views?\.[jt]sx?$/i,/handlers?\.[jt]sx?$/i];
2590
+ function isTestFile(f) { return TEST_PATTERNS.some((re) => re.test(f.replace(/\\/g,'/'))); }
2591
+ function isRouteFile(f) { return ROUTE_PATTERNS.some((re) => re.test(f.replace(/\\/g,'/'))); }
2592
+ function bfs(startFile, reverseGraph, maxDepth) {
2593
+ const direct=new Set(); const transitive=new Set(); const visited=new Set([startFile]);
2594
+ const firstLevel = reverseGraph.get(startFile)||[];
2595
+ for (const f of firstLevel) { if (!visited.has(f)) { direct.add(f); visited.add(f); } }
2596
+ if (maxDepth===1) return {direct,transitive};
2597
+ let frontier=[...direct]; let depth=1;
2598
+ while (frontier.length>0 && (maxDepth===0||depth<maxDepth)) {
2599
+ const nextFrontier=[];
2600
+ for (const node of frontier) {
2601
+ const importers=reverseGraph.get(node)||[];
2602
+ for (const imp of importers) { if (!visited.has(imp)) { transitive.add(imp); visited.add(imp); nextFrontier.push(imp); } }
2603
+ }
2604
+ frontier=nextFrontier; depth++;
2605
+ }
2606
+ return {direct,transitive};
2607
+ }
2608
+ function getImpact(changedFile, graph, opts) {
2609
+ const {depth=0,cwd=process.cwd()} = opts||{};
2610
+ const absChanged = path.resolve(cwd,changedFile);
2611
+ if (!graph||!graph.reverse) return {changed:changedFile,direct:[],transitive:[],tests:[],routes:[],totalImpact:0};
2612
+ const {direct,transitive} = bfs(absChanged,graph.reverse,depth);
2613
+ const allImpacted=[...direct,...transitive];
2614
+ const tests=allImpacted.filter(isTestFile); const routes=allImpacted.filter(isRouteFile);
2615
+ const toRel=(f)=>path.relative(cwd,f).replace(/\\/g,'/');
2616
+ return {changed:toRel(absChanged),direct:[...direct].map(toRel),transitive:[...transitive].map(toRel),tests:tests.map(toRel),routes:routes.map(toRel),totalImpact:direct.size+transitive.size};
2617
+ }
2618
+ function analyzeImpact(changedFiles, cwd, opts) {
2619
+ const {depth=3}=opts||{};
2620
+ const files=Array.isArray(changedFiles)?changedFiles:[changedFiles];
2621
+ let graph;
2622
+ try { graph=buildFromCwd(cwd,opts); } catch(_) { graph={forward:new Map(),reverse:new Map()}; }
2623
+ return files.map((f)=>({file:f,impact:getImpact(f,graph,{depth,cwd})}));
2624
+ }
2625
+ function formatImpact(result) {
2626
+ const lines=[`## Impact: \`${result.changed}\``,``];
2627
+ if (result.direct.length===0&&result.transitive.length===0) { lines.push('_No files import this file — zero blast radius._'); return lines.join('\n'); }
2628
+ lines.push(`**Total impacted files:** ${result.totalImpact}`,``);
2629
+ if (result.direct.length>0) { lines.push('### Direct importers'); for (const f of result.direct) lines.push(`- \`${f}\``); lines.push(''); }
2630
+ if (result.transitive.length>0) { lines.push('### Transitive importers'); for (const f of result.transitive) lines.push(`- \`${f}\``); lines.push(''); }
2631
+ if (result.tests.length>0) { lines.push('### Affected tests'); for (const f of result.tests) lines.push(`- \`${f}\``); lines.push(''); }
2632
+ if (result.routes.length>0) { lines.push('### Affected routes / controllers'); for (const f of result.routes) lines.push(`- \`${f}\``); lines.push(''); }
2633
+ return lines.join('\n');
2634
+ }
2635
+ function formatImpactJSON(result) {
2636
+ return {changed:result.changed,direct:result.direct,transitive:result.transitive,tests:result.tests,routes:result.routes,totalImpact:result.totalImpact};
2637
+ }
2638
+ module.exports = { getImpact, analyzeImpact, formatImpact, formatImpactJSON };
2639
+ };
2640
+
2465
2641
  // ── ./src/mcp/handlers ──
2466
2642
  __factories["./src/mcp/handlers"] = function(module, exports) {
2467
2643
 
@@ -2895,7 +3071,19 @@ __factories["./src/mcp/handlers"] = function(module, exports) {
2895
3071
  }
2896
3072
  }
2897
3073
 
2898
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext };
3074
+ function getImpact(args, cwd) {
3075
+ if (!args || !args.file) return 'Missing required argument: file';
3076
+ try {
3077
+ const { analyzeImpact, formatImpact } = __require('./src/graph/impact');
3078
+ const depth = Math.max(0, parseInt(args.depth, 10) || 3);
3079
+ const results = analyzeImpact(args.file, cwd, { depth });
3080
+ return results.map((r) => formatImpact(r.impact)).join('\n\n---\n\n');
3081
+ } catch (err) {
3082
+ return `_get_impact failed: ${err.message}_`;
3083
+ }
3084
+ }
3085
+
3086
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact };
2899
3087
  };
2900
3088
 
2901
3089
  // ── ./src/mcp/server ──
@@ -2915,7 +3103,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
2915
3103
 
2916
3104
  const readline = require('readline');
2917
3105
  const { TOOLS } = __require('./src/mcp/tools');
2918
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext } = __require('./src/mcp/handlers');
3106
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact } = __require('./src/mcp/handlers');
2919
3107
 
2920
3108
  const SERVER_INFO = {
2921
3109
  name: 'sigmap',
@@ -2975,6 +3163,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
2975
3163
  else if (name === 'explain_file') text = explainFile(args, cwd);
2976
3164
  else if (name === 'list_modules') text = listModules(args, cwd);
2977
3165
  else if (name === 'query_context') text = queryContext(args, cwd);
3166
+ else if (name === 'get_impact') text = getImpact(args, cwd);
2978
3167
  else {
2979
3168
  respondError(id, -32601, `Unknown tool: ${name}`);
2980
3169
  return;
@@ -3178,6 +3367,30 @@ __factories["./src/mcp/tools"] = function(module, exports) {
3178
3367
  required: ['query'],
3179
3368
  },
3180
3369
  },
3370
+ {
3371
+ name: 'get_impact',
3372
+ description:
3373
+ 'Show every file that is impacted when a given file changes — direct importers, ' +
3374
+ 'transitive importers, affected tests, and affected routes/controllers. ' +
3375
+ 'Gives agents instant blast-radius awareness before making a change. ' +
3376
+ 'Handles circular dependencies safely (no infinite loops).',
3377
+ inputSchema: {
3378
+ type: 'object',
3379
+ properties: {
3380
+ file: {
3381
+ type: 'string',
3382
+ description:
3383
+ 'Relative path from the project root of the file that changed ' +
3384
+ '(e.g. "src/extractors/python.js"). Use forward slashes.',
3385
+ },
3386
+ depth: {
3387
+ type: 'number',
3388
+ description: 'BFS traversal depth limit (default: 3). Use 0 for unlimited.',
3389
+ },
3390
+ },
3391
+ required: ['file'],
3392
+ },
3393
+ },
3181
3394
  ];
3182
3395
 
3183
3396
  module.exports = { TOOLS };
@@ -4091,7 +4304,7 @@ const path = require('path');
4091
4304
  const os = require('os');
4092
4305
  const { execSync } = require('child_process');
4093
4306
 
4094
- const VERSION = '2.4.0';
4307
+ const VERSION = '2.6.0';
4095
4308
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
4096
4309
 
4097
4310
  function requireSourceOrBundled(key) {
@@ -5307,6 +5520,9 @@ Usage:
5307
5520
  node gen-context.js --query "<text>" Rank files by relevance to a query
5308
5521
  node gen-context.js --query "<text>" --json Ranked results as JSON
5309
5522
  node gen-context.js --query "<text>" --top <n> Limit results to top N files (default 10)
5523
+ node gen-context.js --impact <file> Show every file impacted by changing <file>
5524
+ node gen-context.js --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
5525
+ node gen-context.js --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
5310
5526
  node gen-context.js --init Write example config + .contextignore scaffold
5311
5527
  node gen-context.js --help Show this message
5312
5528
  node gen-context.js --version Show version
@@ -5625,6 +5841,35 @@ function main() {
5625
5841
  process.exit(0);
5626
5842
  }
5627
5843
 
5844
+ // ── --impact <file> ────────────────────────────────────────────────────────
5845
+ if (args.includes('--impact')) {
5846
+ try {
5847
+ const impIdx = args.indexOf('--impact');
5848
+ const targetFile = (args[impIdx + 1] || '').trim();
5849
+ if (!targetFile || targetFile.startsWith('--')) {
5850
+ console.error('[sigmap] --impact requires a file path');
5851
+ console.error(' Example: node gen-context.js --impact src/extractors/python.js');
5852
+ process.exit(1);
5853
+ }
5854
+ const { analyzeImpact, formatImpact, formatImpactJSON } = requireSourceOrBundled('./src/graph/impact');
5855
+ const depthIdx = args.indexOf('--depth');
5856
+ const depth = depthIdx >= 0 ? Math.max(0, parseInt(args[depthIdx + 1], 10) || 3) : ((config && config.impact && config.impact.depth) || 3);
5857
+ const results = analyzeImpact(targetFile, cwd, { depth });
5858
+ if (args.includes('--json')) {
5859
+ const out = results.map((r) => formatImpactJSON(r.impact));
5860
+ process.stdout.write(JSON.stringify(out.length === 1 ? out[0] : out) + '\n');
5861
+ } else {
5862
+ for (const r of results) {
5863
+ process.stdout.write(formatImpact(r.impact) + '\n');
5864
+ }
5865
+ }
5866
+ } catch (err) {
5867
+ console.error(`[sigmap] impact error: ${err.message}`);
5868
+ process.exit(1);
5869
+ }
5870
+ process.exit(0);
5871
+ }
5872
+
5628
5873
  if (args.includes('--report')) {
5629
5874
  if (args.includes('--history')) {
5630
5875
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Zero-dependency AI context engine — 97% token reduction. No npm install. Runs on Node 18+.",
5
5
  "main": "gen-context.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -128,6 +128,14 @@ const health = score('/path/to/project');
128
128
 
129
129
  All existing CLI flags (`--generate`, `--watch`, `--mcp`, `--query`, `--analyze`, `--benchmark`, `--health`, …) are unchanged.
130
130
 
131
+ ## What's next — v2.5-v2.6
132
+
133
+ v2.5 adds `analyzeImpact(changedFiles, cwd)` to `packages/core` — given a list of changed files, it returns every file that transitively imports them. See [issue #14](https://github.com/manojmallick/sigmap/issues/14).
134
+
135
+ v2.6 adds benchmark and paper reporting capabilities — run evaluations against external repos and export metrics in LaTeX format for academic papers. See [issue #16](https://github.com/manojmallick/sigmap/issues/16).
136
+
137
+ See the full [roadmap](https://manojmallick.github.io/sigmap/roadmap.html).
138
+
131
139
  ## Zero dependencies
132
140
 
133
141
  This package has zero runtime npm dependencies. It uses only Node.js built-ins: `fs`, `path`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -100,6 +100,14 @@ const DEFAULTS = {
100
100
  // Multiplier applied to recently-changed files (>1 boosts them up)
101
101
  recencyBoost: 1.5,
102
102
  },
103
+
104
+ // Impact layer settings (v2.5)
105
+ impact: {
106
+ // BFS traversal depth limit for --impact (0 = unlimited)
107
+ depth: 3,
108
+ // Include signatures of impacted files in --impact output
109
+ includeSigs: true,
110
+ },
103
111
  };
104
112
 
105
113
  module.exports = { DEFAULTS };
@@ -0,0 +1,259 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Dependency graph builder (v2.5).
5
+ *
6
+ * Builds a forward and reverse dependency graph by resolving import/require
7
+ * statements across JS/TS, Python, Go, Rust, Java, Kotlin, and Ruby files.
8
+ *
9
+ * @module src/graph/builder
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Language-specific import extractors
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const JS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
20
+ const PY_EXTS = new Set(['.py', '.pyw']);
21
+ const GO_EXTS = new Set(['.go']);
22
+ const RS_EXTS = new Set(['.rs']);
23
+ const JVM_EXTS = new Set(['.java', '.kt', '.kts', '.scala', '.sc']);
24
+ const RB_EXTS = new Set(['.rb', '.rake']);
25
+
26
+ /**
27
+ * Resolve a JS/TS relative import string to an absolute path in fileSet.
28
+ * @param {string} dir - directory of the importing file
29
+ * @param {string} importStr - raw import string (e.g. './utils', '../store')
30
+ * @param {Set<string>} fileSet
31
+ * @returns {string|null}
32
+ */
33
+ function resolveJsPath(dir, importStr, fileSet) {
34
+ const base = path.resolve(dir, importStr);
35
+ const candidates = [
36
+ base,
37
+ base + '.ts', base + '.tsx',
38
+ base + '.js', base + '.jsx', base + '.mjs', base + '.cjs',
39
+ path.join(base, 'index.ts'),
40
+ path.join(base, 'index.js'),
41
+ ];
42
+ for (const c of candidates) {
43
+ if (fileSet.has(c)) return c;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Extract absolute dependency paths from a single file.
50
+ * @param {string} filePath - absolute path to the file
51
+ * @param {string} content - file source content
52
+ * @param {Set<string>} fileSet - set of all known absolute file paths
53
+ * @returns {string[]} resolved absolute paths this file imports
54
+ */
55
+ function extractFileDeps(filePath, content, fileSet) {
56
+ const ext = path.extname(filePath).toLowerCase();
57
+ const dir = path.dirname(filePath);
58
+ const found = [];
59
+
60
+ // ── JS / TS ───────────────────────────────────────────────────────────────
61
+ if (JS_EXTS.has(ext)) {
62
+ const stripped = content
63
+ .replace(/\/\/.*$/gm, '')
64
+ .replace(/\/\*[\s\S]*?\*\//g, '');
65
+
66
+ // ES imports: import ... from './foo' or import './side-effect'
67
+ const reEs = /(?:^|[\r\n])\s*import\s+(?:[^'";\r\n]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
68
+ let m;
69
+ while ((m = reEs.exec(stripped)) !== null) {
70
+ const r = resolveJsPath(dir, m[1], fileSet);
71
+ if (r) found.push(r);
72
+ }
73
+ // CommonJS: require('./foo')
74
+ const reCjs = /\brequire\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
75
+ while ((m = reCjs.exec(stripped)) !== null) {
76
+ const r = resolveJsPath(dir, m[1], fileSet);
77
+ if (r) found.push(r);
78
+ }
79
+ }
80
+
81
+ // ── Python ────────────────────────────────────────────────────────────────
82
+ if (PY_EXTS.has(ext)) {
83
+ // from .module import ... / from ..pkg import ...
84
+ const re = /^[ \t]*from\s+(\.+[\w.]*)\s+import/gm;
85
+ let m;
86
+ while ((m = re.exec(content)) !== null) {
87
+ const dotCount = (m[1].match(/^\.+/) || [''])[0].length;
88
+ const modPart = m[1].slice(dotCount).replace(/\./g, '/');
89
+ let base = dir;
90
+ for (let i = 1; i < dotCount; i++) base = path.dirname(base);
91
+ const candidate = modPart
92
+ ? path.join(base, modPart + '.py')
93
+ : null;
94
+ if (candidate && fileSet.has(candidate)) found.push(candidate);
95
+ }
96
+ }
97
+
98
+ // ── Go ────────────────────────────────────────────────────────────────────
99
+ // Go uses module paths, not relative file paths — we match same-module paths
100
+ // by checking if any known file's relative path matches the imported suffix.
101
+ if (GO_EXTS.has(ext)) {
102
+ const re = /import\s*\(\s*([\s\S]*?)\s*\)/g;
103
+ const reInline = /import\s+"([^"]+)"/g;
104
+ const imports = [];
105
+ let m;
106
+ while ((m = re.exec(content)) !== null) {
107
+ for (const imp of m[1].matchAll(/"([^"]+)"/g)) imports.push(imp[1]);
108
+ }
109
+ while ((m = reInline.exec(content)) !== null) imports.push(m[1]);
110
+
111
+ for (const imp of imports) {
112
+ const suffix = imp.split('/').pop();
113
+ for (const f of fileSet) {
114
+ if (f.endsWith(path.sep + suffix + '.go') ||
115
+ f.includes(path.sep + suffix + path.sep)) {
116
+ found.push(f);
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // ── Rust ──────────────────────────────────────────────────────────────────
124
+ // Match `mod foo;` and `use crate::foo::bar` — resolve to sibling .rs files
125
+ if (RS_EXTS.has(ext)) {
126
+ const reMod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm;
127
+ let m;
128
+ while ((m = reMod.exec(content)) !== null) {
129
+ const candidate = path.join(dir, m[1] + '.rs');
130
+ if (fileSet.has(candidate)) found.push(candidate);
131
+ // Also try mod/mod.rs
132
+ const candidate2 = path.join(dir, m[1], 'mod.rs');
133
+ if (fileSet.has(candidate2)) found.push(candidate2);
134
+ }
135
+ }
136
+
137
+ // ── Java / Kotlin / Scala ─────────────────────────────────────────────────
138
+ // Match same-project import statements by matching package-relative paths
139
+ if (JVM_EXTS.has(ext)) {
140
+ const re = /^\s*import\s+([\w.]+)\s*;?/gm;
141
+ let m;
142
+ while ((m = re.exec(content)) !== null) {
143
+ // Convert com.example.utils.StringHelper → com/example/utils/StringHelper.java
144
+ const asPath = m[1].replace(/\./g, path.sep);
145
+ for (const jvmExt of ['.java', '.kt', '.kts', '.scala', '.sc']) {
146
+ for (const f of fileSet) {
147
+ if (f.endsWith(asPath + jvmExt)) { found.push(f); break; }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // ── Ruby ──────────────────────────────────────────────────────────────────
154
+ if (RB_EXTS.has(ext)) {
155
+ const re = /^\s*require_relative\s+['"]([^'"]+)['"]/gm;
156
+ let m;
157
+ while ((m = re.exec(content)) !== null) {
158
+ const base = path.resolve(dir, m[1]);
159
+ const candidate = base.endsWith('.rb') ? base : base + '.rb';
160
+ if (fileSet.has(candidate)) found.push(candidate);
161
+ }
162
+ }
163
+
164
+ return [...new Set(found)];
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Public API
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Build a forward and reverse dependency graph for all given files.
173
+ *
174
+ * @param {string[]} files - absolute file paths to analyze
175
+ * @param {string} cwd - project root (used only for error reporting)
176
+ * @returns {{ forward: Map<string,string[]>, reverse: Map<string,string[]> }}
177
+ */
178
+ function build(files, cwd) {
179
+ const fileSet = new Set(files.map((f) => path.resolve(f)));
180
+ const forward = new Map();
181
+ const reverse = new Map();
182
+
183
+ // Initialise every known file in both maps (ensures isolated files appear)
184
+ for (const f of fileSet) {
185
+ if (!forward.has(f)) forward.set(f, []);
186
+ if (!reverse.has(f)) reverse.set(f, []);
187
+ }
188
+
189
+ for (const filePath of fileSet) {
190
+ let content;
191
+ try {
192
+ content = fs.readFileSync(filePath, 'utf8');
193
+ } catch (_) {
194
+ continue;
195
+ }
196
+
197
+ const deps = extractFileDeps(filePath, content, fileSet);
198
+ if (deps.length > 0) {
199
+ forward.set(filePath, deps);
200
+ for (const dep of deps) {
201
+ if (!reverse.has(dep)) reverse.set(dep, []);
202
+ reverse.get(dep).push(filePath);
203
+ }
204
+ }
205
+ }
206
+
207
+ return { forward, reverse };
208
+ }
209
+
210
+ /**
211
+ * Build a dependency graph scoped to a single cwd by walking all JS/TS/Py/Go
212
+ * files under srcDirs. Useful for the MCP tool handler.
213
+ *
214
+ * @param {string} cwd
215
+ * @param {object} [opts]
216
+ * @param {string[]} [opts.srcDirs]
217
+ * @param {string[]} [opts.exclude]
218
+ * @returns {{ forward: Map<string,string[]>, reverse: Map<string,string[]> }}
219
+ */
220
+ function buildFromCwd(cwd, opts) {
221
+ const { srcDirs = ['src', 'app', 'lib'], exclude = ['node_modules', '.git', 'dist', 'build'] } = opts || {};
222
+ const excludeSet = new Set(exclude);
223
+
224
+ function walkDir(dir, depth) {
225
+ if (depth > 8) return [];
226
+ let entries;
227
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
228
+ const out = [];
229
+ for (const e of entries) {
230
+ if (excludeSet.has(e.name) || e.name.startsWith('.')) continue;
231
+ const full = path.join(dir, e.name);
232
+ if (e.isDirectory()) {
233
+ out.push(...walkDir(full, depth + 1));
234
+ } else if (e.isFile()) {
235
+ const ext = path.extname(e.name).toLowerCase();
236
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext) || GO_EXTS.has(ext) ||
237
+ RS_EXTS.has(ext) || JVM_EXTS.has(ext) || RB_EXTS.has(ext)) {
238
+ out.push(full);
239
+ }
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ const files = [];
246
+ for (const sd of srcDirs) {
247
+ const absDir = path.resolve(cwd, sd);
248
+ if (fs.existsSync(absDir)) files.push(...walkDir(absDir, 0));
249
+ }
250
+ // Also include root-level entry files
251
+ for (const rootFile of ['gen-context.js', 'index.js', 'main.js', 'app.js']) {
252
+ const abs = path.resolve(cwd, rootFile);
253
+ if (fs.existsSync(abs)) files.push(abs);
254
+ }
255
+
256
+ return build(files, cwd);
257
+ }
258
+
259
+ module.exports = { build, buildFromCwd, extractFileDeps };
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Impact radius calculator (v2.5).
5
+ *
6
+ * Given a changed file and a dependency graph, finds every file that
7
+ * transitively imports it using BFS. Handles circular dependencies safely.
8
+ *
9
+ * @module src/graph/impact
10
+ */
11
+
12
+ const path = require('path');
13
+ const { buildFromCwd } = require('./builder');
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Core BFS traversal
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Walk the reverse graph from `startFile` using BFS up to `maxDepth` levels.
21
+ * Returns separate sets for direct and transitive dependents.
22
+ *
23
+ * @param {string} startFile - absolute path of file that changed
24
+ * @param {Map<string,string[]>} reverseGraph
25
+ * @param {number} maxDepth - 0 = unlimited
26
+ * @returns {{ direct: Set<string>, transitive: Set<string> }}
27
+ */
28
+ function bfs(startFile, reverseGraph, maxDepth) {
29
+ const direct = new Set();
30
+ const transitive = new Set();
31
+ const visited = new Set([startFile]);
32
+
33
+ // Level 1 — direct importers
34
+ const firstLevel = reverseGraph.get(startFile) || [];
35
+ for (const f of firstLevel) {
36
+ if (!visited.has(f)) {
37
+ direct.add(f);
38
+ visited.add(f);
39
+ }
40
+ }
41
+
42
+ if (maxDepth === 1) return { direct, transitive };
43
+
44
+ // BFS for deeper levels
45
+ let frontier = [...direct];
46
+ let depth = 1;
47
+
48
+ while (frontier.length > 0 && (maxDepth === 0 || depth < maxDepth)) {
49
+ const nextFrontier = [];
50
+ for (const node of frontier) {
51
+ const importers = reverseGraph.get(node) || [];
52
+ for (const imp of importers) {
53
+ if (!visited.has(imp)) {
54
+ transitive.add(imp);
55
+ visited.add(imp);
56
+ nextFrontier.push(imp);
57
+ }
58
+ }
59
+ }
60
+ frontier = nextFrontier;
61
+ depth++;
62
+ }
63
+
64
+ return { direct, transitive };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helper: classify impacted files into tests / routes / other
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const TEST_PATTERNS = [
72
+ /[./\\](test|tests|spec|__tests__)[./\\]/,
73
+ /\.(test|spec)\.[jt]sx?$/,
74
+ /_test\.[jt]sx?$/,
75
+ /_test\.py$/,
76
+ /test_[^/\\]+\.py$/,
77
+ ];
78
+
79
+ const ROUTE_PATTERNS = [
80
+ /router?\.[jt]sx?$/i,
81
+ /routes?\.[jt]sx?$/i,
82
+ /controller\.[jt]sx?$/i,
83
+ /views?\.[jt]sx?$/i,
84
+ /handlers?\.[jt]sx?$/i,
85
+ ];
86
+
87
+ function isTestFile(f) { return TEST_PATTERNS.some((re) => re.test(f.replace(/\\/g, '/'))); }
88
+ function isRouteFile(f) { return ROUTE_PATTERNS.some((re) => re.test(f.replace(/\\/g, '/'))); }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Public API
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Compute the impact of changing `changedFile`.
96
+ *
97
+ * @param {string} changedFile - absolute or cwd-relative path
98
+ * @param {{ forward: Map<string,string[]>, reverse: Map<string,string[]> }} graph
99
+ * @param {object} [opts]
100
+ * @param {number} [opts.depth=0] - BFS depth limit (0 = unlimited)
101
+ * @param {string} [opts.cwd] - project root for relative path display
102
+ * @returns {{
103
+ * changed: string,
104
+ * direct: string[],
105
+ * transitive: string[],
106
+ * tests: string[],
107
+ * routes: string[],
108
+ * totalImpact: number
109
+ * }}
110
+ */
111
+ function getImpact(changedFile, graph, opts) {
112
+ const { depth = 0, cwd = process.cwd() } = opts || {};
113
+
114
+ const absChanged = path.resolve(cwd, changedFile);
115
+
116
+ // Bail gracefully if file not in graph
117
+ if (!graph || !graph.reverse) {
118
+ return { changed: changedFile, direct: [], transitive: [], tests: [], routes: [], totalImpact: 0 };
119
+ }
120
+
121
+ const { direct, transitive } = bfs(absChanged, graph.reverse, depth);
122
+
123
+ const allImpacted = [...direct, ...transitive];
124
+ const tests = allImpacted.filter(isTestFile);
125
+ const routes = allImpacted.filter(isRouteFile);
126
+
127
+ const toRel = (f) => path.relative(cwd, f).replace(/\\/g, '/');
128
+
129
+ return {
130
+ changed: toRel(absChanged),
131
+ direct: [...direct].map(toRel),
132
+ transitive: [...transitive].map(toRel),
133
+ tests: tests.map(toRel),
134
+ routes: routes.map(toRel),
135
+ totalImpact: direct.size + transitive.size,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Analyse the impact of one or more changed files, building the graph from cwd.
141
+ * This is the high-level convenience function used by the CLI and MCP tool.
142
+ *
143
+ * @param {string|string[]} changedFiles
144
+ * @param {string} cwd
145
+ * @param {object} [opts]
146
+ * @param {number} [opts.depth=3]
147
+ * @param {string[]} [opts.srcDirs]
148
+ * @param {string[]} [opts.exclude]
149
+ * @returns {{ file: string, impact: object }[]}
150
+ */
151
+ function analyzeImpact(changedFiles, cwd, opts) {
152
+ const { depth = 3 } = opts || {};
153
+ const files = Array.isArray(changedFiles) ? changedFiles : [changedFiles];
154
+
155
+ let graph;
156
+ try {
157
+ graph = buildFromCwd(cwd, opts);
158
+ } catch (_) {
159
+ graph = { forward: new Map(), reverse: new Map() };
160
+ }
161
+
162
+ return files.map((f) => ({
163
+ file: f,
164
+ impact: getImpact(f, graph, { depth, cwd }),
165
+ }));
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Formatting helpers
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Format an impact result as a readable markdown string.
174
+ *
175
+ * @param {object} result - return value of getImpact()
176
+ * @returns {string}
177
+ */
178
+ function formatImpact(result) {
179
+ const lines = [];
180
+ lines.push(`## Impact: \`${result.changed}\``);
181
+ lines.push('');
182
+
183
+ if (result.direct.length === 0 && result.transitive.length === 0) {
184
+ lines.push('_No files import this file — zero blast radius._');
185
+ return lines.join('\n');
186
+ }
187
+
188
+ lines.push(`**Total impacted files:** ${result.totalImpact}`);
189
+ lines.push('');
190
+
191
+ if (result.direct.length > 0) {
192
+ lines.push('### Direct importers');
193
+ for (const f of result.direct) lines.push(`- \`${f}\``);
194
+ lines.push('');
195
+ }
196
+
197
+ if (result.transitive.length > 0) {
198
+ lines.push('### Transitive importers');
199
+ for (const f of result.transitive) lines.push(`- \`${f}\``);
200
+ lines.push('');
201
+ }
202
+
203
+ if (result.tests.length > 0) {
204
+ lines.push('### Affected tests');
205
+ for (const f of result.tests) lines.push(`- \`${f}\``);
206
+ lines.push('');
207
+ }
208
+
209
+ if (result.routes.length > 0) {
210
+ lines.push('### Affected routes / controllers');
211
+ for (const f of result.routes) lines.push(`- \`${f}\``);
212
+ lines.push('');
213
+ }
214
+
215
+ return lines.join('\n');
216
+ }
217
+
218
+ /**
219
+ * Format an impact result as a JSON-serialisable object.
220
+ *
221
+ * @param {object} result - return value of getImpact()
222
+ * @returns {object}
223
+ */
224
+ function formatImpactJSON(result) {
225
+ return {
226
+ changed: result.changed,
227
+ direct: result.direct,
228
+ transitive: result.transitive,
229
+ tests: result.tests,
230
+ routes: result.routes,
231
+ totalImpact: result.totalImpact,
232
+ };
233
+ }
234
+
235
+ module.exports = { getImpact, analyzeImpact, formatImpact, formatImpactJSON };
@@ -457,4 +457,24 @@ function queryContext(args, cwd) {
457
457
  }
458
458
  }
459
459
 
460
- module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext };
460
+ /**
461
+ * get_impact({ file, depth? }) → string
462
+ *
463
+ * Returns a formatted markdown impact report for the given file:
464
+ * direct importers, transitive importers, affected tests, affected routes.
465
+ */
466
+ function getImpact(args, cwd) {
467
+ if (!args || !args.file) return 'Missing required argument: file';
468
+
469
+ try {
470
+ const { analyzeImpact, formatImpact } = require('../graph/impact');
471
+ const depth = Math.max(0, parseInt(args.depth, 10) || 3);
472
+ const results = analyzeImpact(args.file, cwd, { depth });
473
+ if (results.length === 0) return `No impact data for: ${args.file}`;
474
+ return results.map((r) => formatImpact(r.impact)).join('\n\n---\n\n');
475
+ } catch (err) {
476
+ return `_get_impact failed: ${err.message}_`;
477
+ }
478
+ }
479
+
480
+ module.exports = { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact };
package/src/mcp/server.js CHANGED
@@ -14,11 +14,11 @@
14
14
 
15
15
  const readline = require('readline');
16
16
  const { TOOLS } = require('./tools');
17
- const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext } = require('./handlers');
17
+ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, explainFile, listModules, queryContext, getImpact } = require('./handlers');
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '2.4.0',
21
+ version: '2.6.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -74,6 +74,7 @@ function dispatch(msg, cwd) {
74
74
  else if (name === 'explain_file') text = explainFile(args, cwd);
75
75
  else if (name === 'list_modules') text = listModules(args, cwd);
76
76
  else if (name === 'query_context') text = queryContext(args, cwd);
77
+ else if (name === 'get_impact') text = getImpact(args, cwd);
77
78
  else {
78
79
  respondError(id, -32601, `Unknown tool: ${name}`);
79
80
  return;
package/src/mcp/tools.js CHANGED
@@ -144,6 +144,30 @@ const TOOLS = [
144
144
  required: ['query'],
145
145
  },
146
146
  },
147
+ {
148
+ name: 'get_impact',
149
+ description:
150
+ 'Show every file that is impacted when a given file changes — direct importers, ' +
151
+ 'transitive importers, affected tests, and affected routes/controllers. ' +
152
+ 'Gives agents instant blast-radius awareness before making a change. ' +
153
+ 'Handles circular dependencies safely (no infinite loops).',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ file: {
158
+ type: 'string',
159
+ description:
160
+ 'Relative path from the project root of the file that changed ' +
161
+ '(e.g. "src/extractors/python.js"). Use forward slashes.',
162
+ },
163
+ depth: {
164
+ type: 'number',
165
+ description: 'BFS traversal depth limit (default: 3). Use 0 for unlimited.',
166
+ },
167
+ },
168
+ required: ['file'],
169
+ },
170
+ },
147
171
  ];
148
172
 
149
173
  module.exports = { TOOLS };