@sun-asterisk/impact-analyzer 1.0.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/index.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Impact Analyzer - Main Entry Point
5
+ * Orchestrates the entire impact analysis workflow
6
+ */
7
+
8
+ import { CLI } from './cli.js';
9
+ import { loadConfig } from './config/default-config.js';
10
+ import { ChangeDetector } from './modules/change-detector.js';
11
+ import { ImpactAnalyzer } from './modules/impact-analyzer.js';
12
+ import { ReportGenerator } from './modules/report-generator.js';
13
+ import { GitUtils } from './modules/utils/git-utils.js';
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ async function main() {
18
+ const cli = new CLI(process.argv);
19
+
20
+ // Load configuration
21
+ const config = loadConfig(cli);
22
+ const absoluteSourceDir = path.resolve(config.sourceDir);
23
+
24
+ console.log('🚀 Starting Impact Analysis...\n');
25
+ console.log('Configuration:');
26
+ console.log(` Source Dir: ${config.sourceDir}`);
27
+ console.log(` Absolute Path: ${absoluteSourceDir}`);
28
+ console.log(` Base Ref: ${config.baseRef}`);
29
+ console.log(` Head Ref: ${config.headRef}`);
30
+ console.log(` Exclude: ${config.excludePaths.join(', ')}`);
31
+ console.log('');
32
+
33
+ try {
34
+ // ============================================
35
+ // Validation
36
+ // ============================================
37
+
38
+ // Check if source directory exists
39
+ if (! fs.existsSync(absoluteSourceDir)) {
40
+ throw new Error(`Source directory does not exist: ${absoluteSourceDir}`);
41
+ }
42
+
43
+ // Check if it's a git repository
44
+ if (!GitUtils.isGitRepo(absoluteSourceDir)) {
45
+ throw new Error(`Source directory is not a git repository: ${absoluteSourceDir}`);
46
+ }
47
+
48
+ // Check if refs exist
49
+ if (!GitUtils.refExists(config.baseRef, absoluteSourceDir)) {
50
+ throw new Error(`Base ref does not exist: ${config.baseRef}`);
51
+ }
52
+
53
+ console.log('✓ Validation passed\n');
54
+
55
+ // ============================================
56
+ // Step 1: Detect Changes
57
+ // ============================================
58
+ console.log('📝 Step 1: Detecting changes...');
59
+ const detector = new ChangeDetector(config);
60
+
61
+ const changedFiles = detector.detectChangedFiles();
62
+ console.log(` ✓ Found ${changedFiles.length} changed files`);
63
+
64
+ const changedSymbols = detector.detectChangedSymbols(changedFiles);
65
+ console.log(` ✓ Found ${changedSymbols.length} changed symbols\n`);
66
+
67
+ const changes = {
68
+ changedFiles,
69
+ changedSymbols,
70
+ };
71
+
72
+ // ============================================
73
+ // Step 2: Analyze Impact
74
+ // ============================================
75
+ console.log('🔍 Step 2: Analyzing impact...');
76
+ const analyzer = new ImpactAnalyzer(config);
77
+
78
+ // Initialize method-level call graph for precise tracking
79
+ await analyzer.initializeMethodCallGraph();
80
+
81
+ const impact = await analyzer.analyzeImpact(changes);
82
+ console.log(` ✓ Impact score: ${impact.impactScore}`);
83
+ console.log(` ✓ Severity: ${impact.severity.toUpperCase()}\n`);
84
+
85
+ // ============================================
86
+ // Step 3: Generate Reports
87
+ // ============================================
88
+ console.log('📄 Step 3: Generating reports...\n');
89
+ const reporter = new ReportGenerator();
90
+
91
+ // Console report
92
+ reporter.generateConsoleReport(changes, impact);
93
+
94
+ // Markdown report
95
+ const markdownReport = reporter.generateMarkdownReport(changes, impact);
96
+
97
+ // Save to file
98
+ const outputFile = cli.getArg('output', 'impact-report.md');
99
+ fs.writeFileSync(outputFile, markdownReport);
100
+ console.log(`✅ Report saved to: ${outputFile}\n`);
101
+
102
+ // JSON output (optional)
103
+ if (cli.hasArg('json')) {
104
+ const jsonOutput = cli.getArg('json');
105
+ fs.writeFileSync(jsonOutput, JSON.stringify({ changes, impact }, null, 2));
106
+ console.log(`✅ JSON report saved to: ${jsonOutput}\n`);
107
+ }
108
+
109
+ // Exit with code based on severity
110
+ if (impact.severity === 'critical' && ! cli.hasArg('no-fail')) {
111
+ console.log('âš ī¸ Critical impact detected - exiting with error code');
112
+ process.exit(1);
113
+ }
114
+
115
+ console.log('✨ Analysis completed successfully!\n');
116
+ process.exit(0);
117
+
118
+ } catch (error) {
119
+ console.error('❌ Error during analysis:', error.message);
120
+ if (cli.hasArg('verbose')) {
121
+ console.error(error.stack);
122
+ }
123
+ process.exit(1);
124
+ }
125
+ }
126
+
127
+ // Run if executed directly
128
+ main();
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Change Detector Module
3
+ * Detects file and symbol changes between git refs
4
+ */
5
+
6
+ import { GitUtils } from './utils/git-utils.js';
7
+ import { FileUtils } from './utils/file-utils.js';
8
+ import { ASTParser } from './utils/ast-parser.js';
9
+ import path from 'path';
10
+ import fs from 'fs';
11
+
12
+ export class ChangeDetector {
13
+ constructor(config) {
14
+ this.config = config;
15
+ // Resolve absolute path for source directory
16
+ this.absoluteSourceDir = path.resolve(config.sourceDir);
17
+ }
18
+
19
+ /**
20
+ * Detect all changed files in PR/commit range
21
+ */
22
+ detectChangedFiles() {
23
+ // Get git changes from the source directory
24
+ const gitChanges = GitUtils.getChangedFiles(
25
+ this.config.baseRef,
26
+ this.config.headRef,
27
+ this.absoluteSourceDir
28
+ );
29
+
30
+ if (this.config.verbose) {
31
+ console.log(`\n📝 Detecting changed files...`);
32
+ console.log(` Base: ${this.config.baseRef}`);
33
+ console.log(` Head: ${this.config.headRef}`);
34
+ console.log(` Git detected ${gitChanges.length} changed files`);
35
+ }
36
+
37
+ const changedFiles = [];
38
+
39
+ for (const change of gitChanges) {
40
+ const filePath = change.path;
41
+
42
+ // Filter based on configuration
43
+ if (!this.shouldAnalyzeFile(filePath)) {
44
+ if (this.config.verbose) {
45
+ console.log(` ⊗ Skipped: ${filePath} (excluded or not source file)`);
46
+ }
47
+ continue;
48
+ }
49
+
50
+ // Get file diff to analyze actual changes
51
+ const diff = GitUtils.getFileDiff(
52
+ filePath,
53
+ this.config.baseRef,
54
+ this.config.headRef,
55
+ this.absoluteSourceDir
56
+ );
57
+
58
+ // Skip files with only whitespace/comment changes
59
+ if (!this.hasSignificantChanges(diff, filePath)) {
60
+ if (this.config.verbose) {
61
+ console.log(` ⊗ Skipped: ${filePath} (only whitespace/comment changes)`);
62
+ }
63
+ continue;
64
+ }
65
+
66
+ // Get file contents from the source directory
67
+ const content = GitUtils.getFileContent(
68
+ filePath,
69
+ this.config.headRef,
70
+ this.absoluteSourceDir
71
+ );
72
+
73
+ const oldContent = change.status !== 'added'
74
+ ? GitUtils.getFileContent(
75
+ filePath,
76
+ this.config.baseRef,
77
+ this.absoluteSourceDir
78
+ )
79
+ : '';
80
+
81
+ // Calculate line changes
82
+ const changes = FileUtils.calculateLineChanges(oldContent, content);
83
+
84
+ const fileInfo = {
85
+ path: filePath,
86
+ absolutePath: path.join(this.absoluteSourceDir, filePath),
87
+ status: change.status,
88
+ type: FileUtils.categorizeFile(filePath),
89
+ changes,
90
+ content,
91
+ oldContent,
92
+ diff, // Store the diff for further analysis
93
+ };
94
+
95
+ if (this.config.verbose) {
96
+ console.log(` ✓ ${change.status.toUpperCase()}: ${filePath} (+${changes.added} -${changes.deleted})`);
97
+ }
98
+
99
+ changedFiles.push(fileInfo);
100
+ }
101
+
102
+ if (this.config.verbose) {
103
+ console.log(` Total files to analyze: ${changedFiles.length}\n`);
104
+ }
105
+
106
+ return changedFiles;
107
+ }
108
+
109
+ /**
110
+ * Check if diff contains significant code changes
111
+ * Ignore whitespace-only, comment-only, or formatting changes
112
+ */
113
+ hasSignificantChanges(diff, filePath) {
114
+ if (!diff || diff.trim().length === 0) {
115
+ // For new/deleted files, consider them significant
116
+ return true;
117
+ }
118
+
119
+ // Extract added/removed lines from diff
120
+ const lines = diff.split('\n');
121
+ const codeChanges = lines.filter(line => {
122
+ // Skip diff headers
123
+ if (line.startsWith('diff') || line.startsWith('index') ||
124
+ line.startsWith('+++') || line.startsWith('---') ||
125
+ line.startsWith('@@')) {
126
+ return false;
127
+ }
128
+
129
+ // Check for actual code changes (lines starting with + or -)
130
+ if (line.startsWith('+') || line.startsWith('-')) {
131
+ const content = line.substring(1).trim();
132
+
133
+ // Ignore empty lines
134
+ if (content.length === 0) return false;
135
+
136
+ // Ignore comment-only lines (be conservative - keep if unsure)
137
+ const isCommentOnly = content.startsWith('//') ||
138
+ content.startsWith('/*') ||
139
+ content.startsWith('*') ||
140
+ content === '*/';
141
+
142
+ if (isCommentOnly) return false;
143
+
144
+ return true; // This is a significant change
145
+ }
146
+
147
+ return false;
148
+ });
149
+
150
+ // If we found any code changes, or if the diff is non-empty, consider it significant
151
+ // Be conservative - if in doubt, include it
152
+ return codeChanges.length > 0 || lines.length > 10;
153
+ }
154
+
155
+ /**
156
+ * Detect changed symbols (functions, classes, methods)
157
+ */
158
+ detectChangedSymbols(changedFiles) {
159
+ const allChangedSymbols = [];
160
+
161
+ for (const file of changedFiles) {
162
+ // Only analyze source files
163
+ if (file.type !== 'source') continue;
164
+
165
+ // Extract symbols from current and old versions
166
+ const currentSymbols = ASTParser.extractSymbols(file.content, file.path);
167
+ const oldSymbols = file.oldContent
168
+ ? ASTParser.extractSymbols(file.oldContent, file.path)
169
+ : [];
170
+
171
+ // Compare and categorize changes
172
+ const changedSymbols = this.compareSymbols(currentSymbols, oldSymbols, file.path);
173
+ allChangedSymbols.push(...changedSymbols);
174
+ }
175
+
176
+ return allChangedSymbols;
177
+ }
178
+
179
+ /**
180
+ * Compare symbols to find added, modified, and deleted
181
+ */
182
+ compareSymbols(currentSymbols, oldSymbols, filePath) {
183
+ const changes = [];
184
+
185
+ // Create maps for easy lookup
186
+ const oldMap = new Map(oldSymbols.map(s => [s.name, s]));
187
+ const currentMap = new Map(currentSymbols.map(s => [s.name, s]));
188
+
189
+ // Find added and modified symbols
190
+ for (const symbol of currentSymbols) {
191
+ const oldSymbol = oldMap.get(symbol.name);
192
+
193
+ if (! oldSymbol) {
194
+ // New symbol
195
+ changes.push({
196
+ ...symbol,
197
+ changeType: 'added',
198
+ file: filePath,
199
+ });
200
+ } else if (this.hasSymbolChanged(symbol, oldSymbol)) {
201
+ // Modified symbol
202
+ changes.push({
203
+ ...symbol,
204
+ changeType: 'modified',
205
+ file: filePath,
206
+ oldSignature: oldSymbol.signature,
207
+ });
208
+ }
209
+ }
210
+
211
+ // Find deleted symbols
212
+ for (const symbol of oldSymbols) {
213
+ if (!currentMap.has(symbol.name)) {
214
+ changes.push({
215
+ ...symbol,
216
+ changeType: 'deleted',
217
+ file: filePath,
218
+ });
219
+ }
220
+ }
221
+
222
+ return changes;
223
+ }
224
+
225
+ /**
226
+ * Check if symbol has changed
227
+ */
228
+ hasSymbolChanged(newSymbol, oldSymbol) {
229
+ // Check signature change
230
+ if (newSymbol.signature !== oldSymbol.signature) return true;
231
+
232
+ // Check line number change (indicates body change)
233
+ if (newSymbol.startLine !== oldSymbol.startLine) return true;
234
+ if (newSymbol.endLine !== oldSymbol.endLine) return true;
235
+
236
+ return false;
237
+ }
238
+
239
+ /**
240
+ * Determine if file should be analyzed
241
+ */
242
+ shouldAnalyzeFile(filePath) {
243
+ // Must be a source file
244
+ if (!FileUtils.isSourceFile(filePath)) return false;
245
+
246
+ // Check exclude paths
247
+ if (this.config.excludePaths.some(exclude => filePath.includes(exclude))) {
248
+ return false;
249
+ }
250
+
251
+ // Optionally exclude test files
252
+ if (! this.config.includeTests && FileUtils.isTestFile(filePath)) {
253
+ return false;
254
+ }
255
+
256
+ return true;
257
+ }
258
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Database Impact Detector
3
+ * Detects database changes using layer-aware method tracking
4
+ */
5
+
6
+ import path from 'path';
7
+
8
+ export class DatabaseDetector {
9
+ constructor(methodCallGraph, config) {
10
+ this.methodCallGraph = methodCallGraph;
11
+ this.config = config;
12
+ }
13
+
14
+ /**
15
+ * Detect database impact from changed files using layer-aware tracking
16
+ */
17
+ async detect(changedFiles) {
18
+ const databaseChanges = {
19
+ tables: new Set(),
20
+ fields: new Map(), // table -> Set of fields
21
+ operations: new Map(), // table -> Set of operations
22
+ };
23
+
24
+ // Extract changed methods
25
+ const allChangedMethods = [];
26
+ for (const changedFile of changedFiles) {
27
+ const changedMethods = this.methodCallGraph.getChangedMethods(
28
+ changedFile.diff || '',
29
+ changedFile.absolutePath
30
+ );
31
+ allChangedMethods.push(...changedMethods);
32
+ }
33
+
34
+ if (this.config.verbose) {
35
+ console.log(`\n 🔍 Analyzing database impact for ${allChangedMethods.length} changed methods`);
36
+ }
37
+
38
+ // Find affected repository methods through call graph
39
+ const affectedRepoMethods = this.findAffectedRepositoryMethods(allChangedMethods);
40
+
41
+ if (this.config.verbose) {
42
+ console.log(`\n 📊 Found ${affectedRepoMethods.length} affected repository methods`);
43
+ }
44
+
45
+ // Analyze repository methods for database operations
46
+ for (const repoMethod of affectedRepoMethods) {
47
+ const dbOps = this.extractDatabaseOperationsFromCallGraph(repoMethod);
48
+
49
+
50
+
51
+ for (const op of dbOps) {
52
+ databaseChanges.tables.add(op.table);
53
+
54
+ if (!databaseChanges.operations.has(op.table)) {
55
+ databaseChanges.operations.set(op.table, new Set());
56
+ }
57
+ databaseChanges.operations.get(op.table).add(op.operation);
58
+
59
+ if (op.fields && op.fields.length > 0) {
60
+ if (!databaseChanges.fields.has(op.table)) {
61
+ databaseChanges.fields.set(op.table, new Set());
62
+ }
63
+ op.fields.forEach(field => databaseChanges.fields.get(op.table).add(field));
64
+ }
65
+ }
66
+ }
67
+
68
+ return this.formatDatabaseImpact(databaseChanges);
69
+ }
70
+
71
+ findAffectedRepositoryMethods(changedMethods) {
72
+ const visited = new Set();
73
+ const repoMethods = [];
74
+ const queue = [...changedMethods];
75
+
76
+ while (queue.length > 0) {
77
+ const method = queue.shift();
78
+
79
+ if (!method || !method.file) continue;
80
+
81
+ const key = `${method.file}:${method.className}.${method.methodName}`;
82
+ if (visited.has(key)) continue;
83
+ visited.add(key);
84
+
85
+ const isRepository = method.file.includes('repository') ||
86
+ (method.className && method.className.toLowerCase().includes('repository'));
87
+
88
+ if (isRepository) {
89
+ repoMethods.push(method);
90
+ }
91
+
92
+ const callers = this.methodCallGraph.getCallers(method);
93
+ queue.push(...callers);
94
+ }
95
+
96
+ return repoMethods;
97
+ }
98
+
99
+ extractDatabaseOperationsFromCallGraph(repoMethod) {
100
+ const operations = [];
101
+
102
+ const methodKey = `${repoMethod.className}.${repoMethod.methodName}`;
103
+ const methodCalls = this.methodCallGraph.methodCallsMap?.get(methodKey) || [];
104
+
105
+ if (this.config.verbose) {
106
+ console.log(` 📞 Analyzing ${methodCalls.length} calls in ${repoMethod.className}.${repoMethod.methodName}`);
107
+ }
108
+
109
+ for (const call of methodCalls) {
110
+ const dbOp = this.detectDatabaseOperationFromCall(call);
111
+
112
+ if (dbOp) {
113
+ operations.push({
114
+ ...dbOp,
115
+ method: `${repoMethod.className}.${repoMethod.methodName}`,
116
+ file: repoMethod.file,
117
+ });
118
+ }
119
+ }
120
+
121
+ return operations;
122
+ }
123
+
124
+ detectDatabaseOperationFromCall(call) {
125
+ const typeOrmOps = {
126
+ 'insert': 'INSERT',
127
+ 'save': 'INSERT/UPDATE',
128
+ 'create': 'INSERT',
129
+ 'update': 'UPDATE',
130
+ 'merge': 'UPDATE',
131
+ 'delete': 'DELETE',
132
+ 'remove': 'DELETE',
133
+ 'softDelete': 'SOFT_DELETE',
134
+ 'find': 'SELECT',
135
+ 'findOne': 'SELECT',
136
+ 'findAndCount': 'SELECT',
137
+ 'findBy': 'SELECT',
138
+ 'findOneBy': 'SELECT',
139
+ };
140
+
141
+ const methodName = call.method;
142
+ if (!typeOrmOps[methodName]) return null;
143
+
144
+ const target = call.target;
145
+ const entityMatch = target?.match(/(\w+Repository)/);
146
+
147
+ if (!entityMatch) return null;
148
+
149
+ const entityName = entityMatch[1].replace(/Repository$/, '') + 'Entity';
150
+
151
+ return {
152
+ table: this.entityToTableName(entityName),
153
+ operation: typeOrmOps[methodName],
154
+ fields: [],
155
+ };
156
+ }
157
+
158
+ entityToTableName(entityName) {
159
+ return entityName
160
+ .replace(/Entity$/, '')
161
+ .replace(/([A-Z])/g, '_$1')
162
+ .toLowerCase()
163
+ .replace(/^_/, '');
164
+ }
165
+
166
+ formatDatabaseImpact(databaseChanges) {
167
+ const tables = [];
168
+
169
+ for (const tableName of databaseChanges.tables) {
170
+ const operations = Array.from(databaseChanges.operations.get(tableName) || []);
171
+ const fields = Array.from(databaseChanges.fields.get(tableName) || []);
172
+
173
+ tables.push({
174
+ name: tableName,
175
+ operations,
176
+ fields: fields.length > 0 ? fields : undefined,
177
+ });
178
+ }
179
+
180
+ return tables;
181
+ }
182
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Endpoint Impact Detector
3
+ * Detects affected API endpoints using method-level call graph
4
+ */
5
+
6
+ export class EndpointDetector {
7
+ constructor(methodCallGraph, config) {
8
+ this.methodCallGraph = methodCallGraph;
9
+ this.config = config;
10
+ }
11
+
12
+ /**
13
+ * Detect affected endpoints from changed files
14
+ */
15
+ async detect(changedFiles) {
16
+ const allChangedMethods = [];
17
+
18
+ for (const changedFile of changedFiles) {
19
+ const changedMethods = this.methodCallGraph.getChangedMethods(
20
+ changedFile.diff || '',
21
+ changedFile.absolutePath
22
+ );
23
+
24
+ allChangedMethods.push(...changedMethods);
25
+ }
26
+
27
+ if (allChangedMethods.length === 0) {
28
+ if (this.config.verbose) {
29
+ console.log(' â„šī¸ No method-level changes detected');
30
+ }
31
+ return [];
32
+ }
33
+
34
+ if (this.config.verbose) {
35
+ console.log(`\n 🔍 Finding affected endpoints for ${allChangedMethods.length} changed methods...`);
36
+ console.log(` Call graph size: ${this.methodCallGraph.methodCallMap.size} entries`);
37
+ }
38
+
39
+ const affectedEndpoints = this.methodCallGraph.findAffectedEndpoints(allChangedMethods);
40
+
41
+ if (this.config.verbose) {
42
+ for (const endpoint of affectedEndpoints) {
43
+ console.log(`\n 📍 Analyzing: ${endpoint.affectedBy}`);
44
+ console.log(` ✓ Endpoint: ${endpoint.method} ${endpoint.path}`);
45
+ console.log(` Chain: ${endpoint.callChain.join(' → ')}`);
46
+ console.log(` Layers: ${endpoint.layers.join(' → ')}`);
47
+ }
48
+ }
49
+
50
+ return affectedEndpoints;
51
+ }
52
+ }