@grafema/cli 0.1.1-alpha

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.
Files changed (66) hide show
  1. package/LICENSE +190 -0
  2. package/dist/cli.d.ts +6 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +36 -0
  5. package/dist/commands/analyze.d.ts +6 -0
  6. package/dist/commands/analyze.d.ts.map +1 -0
  7. package/dist/commands/analyze.js +209 -0
  8. package/dist/commands/check.d.ts +10 -0
  9. package/dist/commands/check.d.ts.map +1 -0
  10. package/dist/commands/check.js +295 -0
  11. package/dist/commands/coverage.d.ts +11 -0
  12. package/dist/commands/coverage.d.ts.map +1 -0
  13. package/dist/commands/coverage.js +96 -0
  14. package/dist/commands/explore.d.ts +6 -0
  15. package/dist/commands/explore.d.ts.map +1 -0
  16. package/dist/commands/explore.js +633 -0
  17. package/dist/commands/get.d.ts +10 -0
  18. package/dist/commands/get.d.ts.map +1 -0
  19. package/dist/commands/get.js +189 -0
  20. package/dist/commands/impact.d.ts +10 -0
  21. package/dist/commands/impact.d.ts.map +1 -0
  22. package/dist/commands/impact.js +313 -0
  23. package/dist/commands/init.d.ts +6 -0
  24. package/dist/commands/init.d.ts.map +1 -0
  25. package/dist/commands/init.js +94 -0
  26. package/dist/commands/overview.d.ts +6 -0
  27. package/dist/commands/overview.d.ts.map +1 -0
  28. package/dist/commands/overview.js +91 -0
  29. package/dist/commands/query.d.ts +13 -0
  30. package/dist/commands/query.d.ts.map +1 -0
  31. package/dist/commands/query.js +340 -0
  32. package/dist/commands/server.d.ts +11 -0
  33. package/dist/commands/server.d.ts.map +1 -0
  34. package/dist/commands/server.js +300 -0
  35. package/dist/commands/stats.d.ts +6 -0
  36. package/dist/commands/stats.d.ts.map +1 -0
  37. package/dist/commands/stats.js +52 -0
  38. package/dist/commands/trace.d.ts +10 -0
  39. package/dist/commands/trace.d.ts.map +1 -0
  40. package/dist/commands/trace.js +270 -0
  41. package/dist/utils/codePreview.d.ts +28 -0
  42. package/dist/utils/codePreview.d.ts.map +1 -0
  43. package/dist/utils/codePreview.js +51 -0
  44. package/dist/utils/errorFormatter.d.ts +24 -0
  45. package/dist/utils/errorFormatter.d.ts.map +1 -0
  46. package/dist/utils/errorFormatter.js +32 -0
  47. package/dist/utils/formatNode.d.ts +53 -0
  48. package/dist/utils/formatNode.d.ts.map +1 -0
  49. package/dist/utils/formatNode.js +49 -0
  50. package/package.json +54 -0
  51. package/src/cli.ts +41 -0
  52. package/src/commands/analyze.ts +271 -0
  53. package/src/commands/check.ts +379 -0
  54. package/src/commands/coverage.ts +108 -0
  55. package/src/commands/explore.tsx +1056 -0
  56. package/src/commands/get.ts +265 -0
  57. package/src/commands/impact.ts +400 -0
  58. package/src/commands/init.ts +112 -0
  59. package/src/commands/overview.ts +108 -0
  60. package/src/commands/query.ts +425 -0
  61. package/src/commands/server.ts +335 -0
  62. package/src/commands/stats.ts +58 -0
  63. package/src/commands/trace.ts +341 -0
  64. package/src/utils/codePreview.ts +77 -0
  65. package/src/utils/errorFormatter.ts +35 -0
  66. package/src/utils/formatNode.ts +88 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Get command - Retrieve node by semantic ID
3
+ *
4
+ * Usage:
5
+ * grafema get "file.js->scope->TYPE->name"
6
+ * grafema get "file.js->scope->TYPE->name" --json
7
+ */
8
+
9
+ import { Command } from 'commander';
10
+ import { resolve, join } from 'path';
11
+ import { existsSync } from 'fs';
12
+ import { RFDBServerBackend } from '@grafema/core';
13
+ import { formatNodeDisplay } from '../utils/formatNode.js';
14
+ import { exitWithError } from '../utils/errorFormatter.js';
15
+
16
+ interface GetOptions {
17
+ project: string;
18
+ json?: boolean;
19
+ }
20
+
21
+ interface NodeInfo {
22
+ id: string;
23
+ type: string;
24
+ name: string;
25
+ file: string;
26
+ line?: number;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ interface Edge {
31
+ src: string;
32
+ dst: string;
33
+ edgeType: string;
34
+ type?: string;
35
+ }
36
+
37
+ interface EdgeWithName {
38
+ edgeType: string;
39
+ targetId: string;
40
+ targetName: string;
41
+ }
42
+
43
+ export const getCommand = new Command('get')
44
+ .description('Retrieve a node by its semantic ID')
45
+ .argument('<semantic-id>', 'Semantic ID of the node (e.g., "file.js->scope->TYPE->name")')
46
+ .option('-p, --project <path>', 'Project path', '.')
47
+ .option('-j, --json', 'Output as JSON')
48
+ .action(async (semanticId: string, options: GetOptions) => {
49
+ const projectPath = resolve(options.project);
50
+ const grafemaDir = join(projectPath, '.grafema');
51
+ const dbPath = join(grafemaDir, 'graph.rfdb');
52
+
53
+ if (!existsSync(dbPath)) {
54
+ exitWithError('No graph database found', ['Run: grafema analyze']);
55
+ }
56
+
57
+ const backend = new RFDBServerBackend({ dbPath });
58
+ await backend.connect();
59
+
60
+ try {
61
+ // Retrieve node by semantic ID
62
+ const node = await backend.getNode(semanticId);
63
+
64
+ if (!node) {
65
+ exitWithError('Node not found', [
66
+ `ID: ${semanticId}`,
67
+ 'Try: grafema query "<name>" to search for nodes',
68
+ ]);
69
+ }
70
+
71
+ // Get incoming and outgoing edges
72
+ const incomingEdges = await backend.getIncomingEdges(semanticId, null);
73
+ const outgoingEdges = await backend.getOutgoingEdges(semanticId, null);
74
+
75
+ if (options.json) {
76
+ await outputJSON(backend, node, incomingEdges, outgoingEdges);
77
+ } else {
78
+ await outputText(backend, node, incomingEdges, outgoingEdges, projectPath);
79
+ }
80
+
81
+ } finally {
82
+ await backend.close();
83
+ }
84
+ });
85
+
86
+ /**
87
+ * Output node and edges as JSON
88
+ */
89
+ async function outputJSON(
90
+ backend: RFDBServerBackend,
91
+ node: any,
92
+ incomingEdges: Edge[],
93
+ outgoingEdges: Edge[]
94
+ ): Promise<void> {
95
+ // Fetch target node names for all edges
96
+ const incomingWithNames = await Promise.all(
97
+ incomingEdges.map(async (edge) => ({
98
+ edgeType: edge.edgeType || edge.type || 'UNKNOWN',
99
+ targetId: edge.src,
100
+ targetName: await getNodeName(backend, edge.src),
101
+ }))
102
+ );
103
+
104
+ const outgoingWithNames = await Promise.all(
105
+ outgoingEdges.map(async (edge) => ({
106
+ edgeType: edge.edgeType || edge.type || 'UNKNOWN',
107
+ targetId: edge.dst,
108
+ targetName: await getNodeName(backend, edge.dst),
109
+ }))
110
+ );
111
+
112
+ const result = {
113
+ node: {
114
+ id: node.id,
115
+ type: node.type || 'UNKNOWN',
116
+ name: node.name || '',
117
+ file: node.file || '',
118
+ line: node.line,
119
+ ...getMetadataFields(node),
120
+ },
121
+ edges: {
122
+ incoming: incomingWithNames,
123
+ outgoing: outgoingWithNames,
124
+ },
125
+ stats: {
126
+ incomingCount: incomingEdges.length,
127
+ outgoingCount: outgoingEdges.length,
128
+ },
129
+ };
130
+
131
+ console.log(JSON.stringify(result, null, 2));
132
+ }
133
+
134
+ /**
135
+ * Output node and edges as formatted text
136
+ */
137
+ async function outputText(
138
+ backend: RFDBServerBackend,
139
+ node: any,
140
+ incomingEdges: Edge[],
141
+ outgoingEdges: Edge[],
142
+ projectPath: string
143
+ ): Promise<void> {
144
+ const nodeInfo: NodeInfo = {
145
+ id: node.id,
146
+ type: node.type || 'UNKNOWN',
147
+ name: node.name || '',
148
+ file: node.file || '',
149
+ line: node.line,
150
+ };
151
+
152
+ // Display node details
153
+ console.log(formatNodeDisplay(nodeInfo, { projectPath }));
154
+
155
+ // Display metadata if present
156
+ const metadata = getMetadataFields(node);
157
+ if (Object.keys(metadata).length > 0) {
158
+ console.log('');
159
+ console.log('Metadata:');
160
+ for (const [key, value] of Object.entries(metadata)) {
161
+ console.log(` ${key}: ${JSON.stringify(value)}`);
162
+ }
163
+ }
164
+
165
+ // Display edges
166
+ console.log('');
167
+ await displayEdges(backend, 'Incoming', incomingEdges, (edge) => edge.src);
168
+ console.log('');
169
+ await displayEdges(backend, 'Outgoing', outgoingEdges, (edge) => edge.dst);
170
+ }
171
+
172
+ /**
173
+ * Display edges grouped by type, limited to 20 in text mode
174
+ */
175
+ async function displayEdges(
176
+ backend: RFDBServerBackend,
177
+ direction: string,
178
+ edges: Edge[],
179
+ getTargetId: (edge: Edge) => string
180
+ ): Promise<void> {
181
+ const totalCount = edges.length;
182
+
183
+ if (totalCount === 0) {
184
+ console.log(`${direction} edges (0):`);
185
+ console.log(' (none)');
186
+ return;
187
+ }
188
+
189
+ // Group edges by type
190
+ const byType = new Map<string, EdgeWithName[]>();
191
+
192
+ for (const edge of edges) {
193
+ const edgeType = edge.edgeType || edge.type || 'UNKNOWN';
194
+ const targetId = getTargetId(edge);
195
+ const targetName = await getNodeName(backend, targetId);
196
+
197
+ if (!byType.has(edgeType)) {
198
+ byType.set(edgeType, []);
199
+ }
200
+ byType.get(edgeType)!.push({ edgeType, targetId, targetName });
201
+ }
202
+
203
+ // Display header with count
204
+ const limitApplied = totalCount > 20;
205
+ console.log(`${direction} edges (${totalCount}):`);
206
+
207
+ // Display edges, limited to 20 total
208
+ let displayed = 0;
209
+ const limit = 20;
210
+
211
+ for (const [edgeType, edgesOfType] of Array.from(byType.entries())) {
212
+ console.log(` ${edgeType}:`);
213
+
214
+ for (const edge of edgesOfType) {
215
+ if (displayed >= limit) break;
216
+
217
+ // Format: TYPE#name
218
+ const label = edge.targetName ? `${edge.edgeType}#${edge.targetName}` : edge.targetId;
219
+ console.log(` ${label}`);
220
+ displayed++;
221
+ }
222
+
223
+ if (displayed >= limit) break;
224
+ }
225
+
226
+ // Show "and X more" if we hit the limit
227
+ if (limitApplied) {
228
+ const remaining = totalCount - displayed;
229
+ console.log(` ... and ${remaining} more (use --json to see all)`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get node name for display
235
+ */
236
+ async function getNodeName(backend: RFDBServerBackend, nodeId: string): Promise<string> {
237
+ try {
238
+ const node = await backend.getNode(nodeId);
239
+ if (node) {
240
+ return node.name || '';
241
+ }
242
+ } catch {
243
+ // Ignore errors
244
+ }
245
+ return '';
246
+ }
247
+
248
+ /**
249
+ * Extract metadata fields (exclude standard fields)
250
+ */
251
+ function getMetadataFields(node: any): Record<string, unknown> {
252
+ const standardFields = new Set([
253
+ 'id', 'type', 'nodeType', 'name', 'file', 'line',
254
+ ]);
255
+
256
+ const metadata: Record<string, unknown> = {};
257
+
258
+ for (const [key, value] of Object.entries(node)) {
259
+ if (!standardFields.has(key) && value !== undefined && value !== null) {
260
+ metadata[key] = value;
261
+ }
262
+ }
263
+
264
+ return metadata;
265
+ }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Impact command - Change impact analysis
3
+ *
4
+ * Usage:
5
+ * grafema impact "function authenticate"
6
+ * grafema impact "class UserService"
7
+ */
8
+
9
+ import { Command } from 'commander';
10
+ import { resolve, join, dirname } from 'path';
11
+ import { relative } from 'path';
12
+ import { existsSync } from 'fs';
13
+ import { RFDBServerBackend } from '@grafema/core';
14
+ import { formatNodeDisplay, formatNodeInline } from '../utils/formatNode.js';
15
+ import { exitWithError } from '../utils/errorFormatter.js';
16
+
17
+ interface ImpactOptions {
18
+ project: string;
19
+ json?: boolean;
20
+ depth: string;
21
+ }
22
+
23
+ interface NodeInfo {
24
+ id: string;
25
+ type: string;
26
+ name: string;
27
+ file: string;
28
+ line?: number;
29
+ }
30
+
31
+ interface ImpactResult {
32
+ target: NodeInfo;
33
+ directCallers: NodeInfo[];
34
+ transitiveCallers: NodeInfo[];
35
+ affectedModules: Map<string, number>;
36
+ callChains: string[][];
37
+ }
38
+
39
+ export const impactCommand = new Command('impact')
40
+ .description('Analyze change impact for a function or class')
41
+ .argument('<pattern>', 'Target: "function X" or "class Y" or just "X"')
42
+ .option('-p, --project <path>', 'Project path', '.')
43
+ .option('-j, --json', 'Output as JSON')
44
+ .option('-d, --depth <n>', 'Max traversal depth', '10')
45
+ .action(async (pattern: string, options: ImpactOptions) => {
46
+ const projectPath = resolve(options.project);
47
+ const grafemaDir = join(projectPath, '.grafema');
48
+ const dbPath = join(grafemaDir, 'graph.rfdb');
49
+
50
+ if (!existsSync(dbPath)) {
51
+ exitWithError('No graph database found', ['Run: grafema analyze']);
52
+ }
53
+
54
+ const backend = new RFDBServerBackend({ dbPath });
55
+ await backend.connect();
56
+
57
+ try {
58
+ const { type, name } = parsePattern(pattern);
59
+ const maxDepth = parseInt(options.depth, 10);
60
+
61
+ console.log(`Analyzing impact of changing ${name}...`);
62
+ console.log('');
63
+
64
+ // Find target node
65
+ const target = await findTarget(backend, type, name);
66
+
67
+ if (!target) {
68
+ console.log(`No ${type || 'node'} "${name}" found`);
69
+ return;
70
+ }
71
+
72
+ // Analyze impact
73
+ const impact = await analyzeImpact(backend, target, maxDepth, projectPath);
74
+
75
+ if (options.json) {
76
+ console.log(JSON.stringify({
77
+ target: impact.target,
78
+ directCallers: impact.directCallers.length,
79
+ transitiveCallers: impact.transitiveCallers.length,
80
+ affectedModules: Object.fromEntries(impact.affectedModules),
81
+ callChains: impact.callChains.slice(0, 5),
82
+ }, null, 2));
83
+ return;
84
+ }
85
+
86
+ // Display results
87
+ displayImpact(impact, projectPath);
88
+
89
+ } finally {
90
+ await backend.close();
91
+ }
92
+ });
93
+
94
+ /**
95
+ * Parse pattern like "function authenticate"
96
+ */
97
+ function parsePattern(pattern: string): { type: string | null; name: string } {
98
+ const words = pattern.trim().split(/\s+/);
99
+
100
+ if (words.length >= 2) {
101
+ const typeWord = words[0].toLowerCase();
102
+ const name = words.slice(1).join(' ');
103
+
104
+ const typeMap: Record<string, string> = {
105
+ function: 'FUNCTION',
106
+ fn: 'FUNCTION',
107
+ class: 'CLASS',
108
+ module: 'MODULE',
109
+ };
110
+
111
+ if (typeMap[typeWord]) {
112
+ return { type: typeMap[typeWord], name };
113
+ }
114
+ }
115
+
116
+ return { type: null, name: pattern.trim() };
117
+ }
118
+
119
+ /**
120
+ * Find target node
121
+ */
122
+ async function findTarget(
123
+ backend: RFDBServerBackend,
124
+ type: string | null,
125
+ name: string
126
+ ): Promise<NodeInfo | null> {
127
+ const searchTypes = type ? [type] : ['FUNCTION', 'CLASS'];
128
+
129
+ for (const nodeType of searchTypes) {
130
+ for await (const node of backend.queryNodes({ nodeType: nodeType as any })) {
131
+ const nodeName = node.name || '';
132
+ if (nodeName.toLowerCase() === name.toLowerCase()) {
133
+ return {
134
+ id: node.id,
135
+ type: node.type || nodeType,
136
+ name: nodeName,
137
+ file: node.file || '',
138
+ line: node.line,
139
+ };
140
+ }
141
+ }
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Analyze impact of changing a node
149
+ */
150
+ async function analyzeImpact(
151
+ backend: RFDBServerBackend,
152
+ target: NodeInfo,
153
+ maxDepth: number,
154
+ projectPath: string
155
+ ): Promise<ImpactResult> {
156
+ const directCallers: NodeInfo[] = [];
157
+ const transitiveCallers: NodeInfo[] = [];
158
+ const affectedModules = new Map<string, number>();
159
+ const callChains: string[][] = [];
160
+ const visited = new Set<string>();
161
+
162
+ // BFS to find all callers
163
+ const queue: Array<{ id: string; depth: number; chain: string[] }> = [
164
+ { id: target.id, depth: 0, chain: [target.name] }
165
+ ];
166
+
167
+ while (queue.length > 0) {
168
+ const { id, depth, chain } = queue.shift()!;
169
+
170
+ if (visited.has(id)) continue;
171
+ visited.add(id);
172
+
173
+ if (depth > maxDepth) continue;
174
+
175
+ try {
176
+ // Find what calls this node
177
+ // First, find CALL nodes that have this as target
178
+ const containingCalls = await findCallsToNode(backend, id);
179
+
180
+ for (const callNode of containingCalls) {
181
+ // Find the function containing this call
182
+ const container = await findContainingFunction(backend, callNode.id);
183
+
184
+ if (container && !visited.has(container.id)) {
185
+ const caller: NodeInfo = {
186
+ id: container.id,
187
+ type: container.type,
188
+ name: container.name,
189
+ file: container.file,
190
+ line: container.line,
191
+ };
192
+
193
+ if (depth === 0) {
194
+ directCallers.push(caller);
195
+ } else {
196
+ transitiveCallers.push(caller);
197
+ }
198
+
199
+ // Track affected modules
200
+ const modulePath = getModulePath(caller.file, projectPath);
201
+ affectedModules.set(modulePath, (affectedModules.get(modulePath) || 0) + 1);
202
+
203
+ // Track call chain
204
+ const newChain = [...chain, caller.name];
205
+ if (newChain.length <= 4) {
206
+ callChains.push(newChain);
207
+ }
208
+
209
+ // Continue BFS
210
+ queue.push({ id: container.id, depth: depth + 1, chain: newChain });
211
+ }
212
+ }
213
+ } catch {
214
+ // Ignore errors
215
+ }
216
+ }
217
+
218
+ // Sort call chains by length
219
+ callChains.sort((a, b) => b.length - a.length);
220
+
221
+ return {
222
+ target,
223
+ directCallers,
224
+ transitiveCallers,
225
+ affectedModules,
226
+ callChains,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Find CALL nodes that reference a target
232
+ */
233
+ async function findCallsToNode(
234
+ backend: RFDBServerBackend,
235
+ targetId: string
236
+ ): Promise<NodeInfo[]> {
237
+ const calls: NodeInfo[] = [];
238
+
239
+ try {
240
+ // Get incoming CALLS edges
241
+ const edges = await backend.getIncomingEdges(targetId, ['CALLS']);
242
+
243
+ for (const edge of edges) {
244
+ const callNode = await backend.getNode(edge.src);
245
+ if (callNode) {
246
+ calls.push({
247
+ id: callNode.id,
248
+ type: callNode.type || 'CALL',
249
+ name: callNode.name || '',
250
+ file: callNode.file || '',
251
+ line: callNode.line,
252
+ });
253
+ }
254
+ }
255
+ } catch {
256
+ // Ignore
257
+ }
258
+
259
+ return calls;
260
+ }
261
+
262
+ /**
263
+ * Find the function that contains a call node
264
+ *
265
+ * Path: CALL → CONTAINS → SCOPE → CONTAINS → SCOPE → HAS_SCOPE → FUNCTION
266
+ */
267
+ async function findContainingFunction(
268
+ backend: RFDBServerBackend,
269
+ nodeId: string,
270
+ maxDepth: number = 15
271
+ ): Promise<NodeInfo | null> {
272
+ const visited = new Set<string>();
273
+ const queue: Array<{ id: string; depth: number }> = [{ id: nodeId, depth: 0 }];
274
+
275
+ while (queue.length > 0) {
276
+ const { id, depth } = queue.shift()!;
277
+
278
+ if (visited.has(id) || depth > maxDepth) continue;
279
+ visited.add(id);
280
+
281
+ try {
282
+ // Get incoming edges: CONTAINS, HAS_SCOPE
283
+ const edges = await backend.getIncomingEdges(id, null);
284
+
285
+ for (const edge of edges) {
286
+ const edgeType = (edge as any).edgeType || (edge as any).type;
287
+
288
+ // Only follow structural edges
289
+ if (!['CONTAINS', 'HAS_SCOPE', 'DECLARES'].includes(edgeType)) continue;
290
+
291
+ const parent = await backend.getNode(edge.src);
292
+ if (!parent || visited.has(parent.id)) continue;
293
+
294
+ const parentType = parent.type;
295
+
296
+ // FUNCTION, CLASS, or MODULE (for top-level calls)
297
+ if (parentType === 'FUNCTION' || parentType === 'CLASS' || parentType === 'MODULE') {
298
+ return {
299
+ id: parent.id,
300
+ type: parentType,
301
+ name: parent.name || '',
302
+ file: parent.file || '',
303
+ line: parent.line,
304
+ };
305
+ }
306
+
307
+ queue.push({ id: parent.id, depth: depth + 1 });
308
+ }
309
+ } catch {
310
+ // Ignore
311
+ }
312
+ }
313
+
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * Get module path relative to project
319
+ */
320
+ function getModulePath(file: string, projectPath: string): string {
321
+ if (!file) return '<unknown>';
322
+ const relPath = relative(projectPath, file);
323
+ const dir = dirname(relPath);
324
+ return dir === '.' ? relPath : `${dir}/*`;
325
+ }
326
+
327
+ /**
328
+ * Display impact analysis results with semantic IDs
329
+ */
330
+ function displayImpact(impact: ImpactResult, projectPath: string): void {
331
+ console.log(formatNodeDisplay(impact.target, { projectPath }));
332
+ console.log('');
333
+
334
+ // Direct impact
335
+ console.log('Direct impact:');
336
+ console.log(` ${impact.directCallers.length} direct callers`);
337
+ console.log(` ${impact.transitiveCallers.length} transitive callers`);
338
+ console.log(` ${impact.directCallers.length + impact.transitiveCallers.length} total affected`);
339
+ console.log('');
340
+
341
+ // Show direct callers
342
+ if (impact.directCallers.length > 0) {
343
+ console.log('Direct callers:');
344
+ for (const caller of impact.directCallers.slice(0, 10)) {
345
+ console.log(` <- ${formatNodeInline(caller)}`);
346
+ }
347
+ if (impact.directCallers.length > 10) {
348
+ console.log(` ... and ${impact.directCallers.length - 10} more`);
349
+ }
350
+ console.log('');
351
+ }
352
+
353
+ // Affected modules
354
+ if (impact.affectedModules.size > 0) {
355
+ console.log('Affected modules:');
356
+ const sorted = [...impact.affectedModules.entries()].sort((a, b) => b[1] - a[1]);
357
+ for (const [module, count] of sorted.slice(0, 5)) {
358
+ console.log(` ├─ ${module} (${count} calls)`);
359
+ }
360
+ if (sorted.length > 5) {
361
+ console.log(` └─ ... and ${sorted.length - 5} more modules`);
362
+ }
363
+ console.log('');
364
+ }
365
+
366
+ // Call chains
367
+ if (impact.callChains.length > 0) {
368
+ console.log('Call chains (sample):');
369
+ for (const chain of impact.callChains.slice(0, 3)) {
370
+ console.log(` ${chain.join(' → ')}`);
371
+ }
372
+ console.log('');
373
+ }
374
+
375
+ // Risk assessment
376
+ const totalAffected = impact.directCallers.length + impact.transitiveCallers.length;
377
+ const moduleCount = impact.affectedModules.size;
378
+
379
+ let risk = 'LOW';
380
+ let color = '\x1b[32m'; // green
381
+
382
+ if (totalAffected > 20 || moduleCount > 5) {
383
+ risk = 'HIGH';
384
+ color = '\x1b[31m'; // red
385
+ } else if (totalAffected > 5 || moduleCount > 2) {
386
+ risk = 'MEDIUM';
387
+ color = '\x1b[33m'; // yellow
388
+ }
389
+
390
+ console.log(`Risk level: ${color}${risk}\x1b[0m`);
391
+
392
+ if (risk === 'HIGH') {
393
+ console.log('');
394
+ console.log('Recommendation:');
395
+ console.log(' • Consider adding backward-compatible wrapper');
396
+ console.log(' • Update tests in affected modules');
397
+ console.log(' • Notify team about breaking change');
398
+ }
399
+ }
400
+