sigmap 2.3.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.
@@ -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.3.0',
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 };