@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.
- package/LICENSE +190 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +36 -0
- package/dist/commands/analyze.d.ts +6 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +209 -0
- package/dist/commands/check.d.ts +10 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +295 -0
- package/dist/commands/coverage.d.ts +11 -0
- package/dist/commands/coverage.d.ts.map +1 -0
- package/dist/commands/coverage.js +96 -0
- package/dist/commands/explore.d.ts +6 -0
- package/dist/commands/explore.d.ts.map +1 -0
- package/dist/commands/explore.js +633 -0
- package/dist/commands/get.d.ts +10 -0
- package/dist/commands/get.d.ts.map +1 -0
- package/dist/commands/get.js +189 -0
- package/dist/commands/impact.d.ts +10 -0
- package/dist/commands/impact.d.ts.map +1 -0
- package/dist/commands/impact.js +313 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +94 -0
- package/dist/commands/overview.d.ts +6 -0
- package/dist/commands/overview.d.ts.map +1 -0
- package/dist/commands/overview.js +91 -0
- package/dist/commands/query.d.ts +13 -0
- package/dist/commands/query.d.ts.map +1 -0
- package/dist/commands/query.js +340 -0
- package/dist/commands/server.d.ts +11 -0
- package/dist/commands/server.d.ts.map +1 -0
- package/dist/commands/server.js +300 -0
- package/dist/commands/stats.d.ts +6 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/stats.js +52 -0
- package/dist/commands/trace.d.ts +10 -0
- package/dist/commands/trace.d.ts.map +1 -0
- package/dist/commands/trace.js +270 -0
- package/dist/utils/codePreview.d.ts +28 -0
- package/dist/utils/codePreview.d.ts.map +1 -0
- package/dist/utils/codePreview.js +51 -0
- package/dist/utils/errorFormatter.d.ts +24 -0
- package/dist/utils/errorFormatter.d.ts.map +1 -0
- package/dist/utils/errorFormatter.js +32 -0
- package/dist/utils/formatNode.d.ts +53 -0
- package/dist/utils/formatNode.d.ts.map +1 -0
- package/dist/utils/formatNode.js +49 -0
- package/package.json +54 -0
- package/src/cli.ts +41 -0
- package/src/commands/analyze.ts +271 -0
- package/src/commands/check.ts +379 -0
- package/src/commands/coverage.ts +108 -0
- package/src/commands/explore.tsx +1056 -0
- package/src/commands/get.ts +265 -0
- package/src/commands/impact.ts +400 -0
- package/src/commands/init.ts +112 -0
- package/src/commands/overview.ts +108 -0
- package/src/commands/query.ts +425 -0
- package/src/commands/server.ts +335 -0
- package/src/commands/stats.ts +58 -0
- package/src/commands/trace.ts +341 -0
- package/src/utils/codePreview.ts +77 -0
- package/src/utils/errorFormatter.ts +35 -0
- 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
|
+
|