sigmap 2.4.0 → 2.5.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 +31 -0
- package/README.md +32 -1
- package/gen-context.js +248 -3
- package/package.json +1 -1
- package/packages/core/README.md +8 -0
- package/src/config/defaults.js +8 -0
- package/src/graph/builder.js +259 -0
- package/src/graph/impact.js +235 -0
- package/src/mcp/handlers.js +21 -1
- package/src/mcp/server.js +3 -2
- package/src/mcp/tools.js +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ 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] — upcoming · [#14](https://github.com/manojmallick/sigmap/issues/14) · branch: `feat/v2.5-impact-layer`
|
|
29
|
+
|
|
30
|
+
### Planned additions
|
|
31
|
+
- **`src/map/dep-graph.js`** — reverse-dependency graph built from the existing import-graph analysis. `getImpacted(filePath, graph)` walks the graph via BFS/DFS and returns every file that transitively imports the given file.
|
|
32
|
+
- **`src/map/impact.js`** — `analyzeImpact(changedFiles, cwd)` → `{ changed, impacted, totalFiles }`. Aggregates dep-graph traversal with optional signature lookup.
|
|
33
|
+
- **`--impact <file>` CLI flag** — prints all files impacted by changing `<file>`, with their signatures. Supports `--impact --json` (machine-readable) and `--impact --depth <n>` (BFS depth limit).
|
|
34
|
+
- **`get_impact` MCP tool** (9th tool) — `{ file: string, depth?: number }` → list of impacted files + signatures, usable live in any MCP session.
|
|
35
|
+
- **`impact` config key** — `{ depth: 0, includeSigs: true }` in `gen-context.config.json`.
|
|
36
|
+
- **`test/integration/impact.test.js`** — 15 tests: direct deps, transitive deps, circular deps (no infinite loop), depth limit, unknown file returns `[]`, JSON output shape, MCP tool shape.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
9
40
|
## [2.4.0] — 2026-04-05
|
|
10
41
|
|
|
11
42
|
### Added
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<!-- Status -->
|
|
13
13
|
[](https://www.npmjs.com/package/sigmap)
|
|
14
|
-
[](https://github.com/manojmallick/sigmap/tree/main/test)
|
|
15
15
|
[](package.json)
|
|
16
16
|
[](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
|
-
|
|
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.
|
|
4307
|
+
const VERSION = '2.5.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
package/packages/core/README.md
CHANGED
|
@@ -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`.
|
package/src/config/defaults.js
CHANGED
|
@@ -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 };
|
package/src/mcp/handlers.js
CHANGED
|
@@ -457,4 +457,24 @@ function queryContext(args, cwd) {
|
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
|
|
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.
|
|
21
|
+
version: '2.5.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 };
|