@sun-asterisk/impact-analyzer 1.0.4 → 1.0.5

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,702 @@
1
+ /**
2
+ * Database Impact Detector
3
+ * Detects database changes using layer-aware method tracking
4
+ *
5
+ * Spec: .specify/specs/features/database-impact-detection.md
6
+ * Implements FR-001 to FR-006
7
+ */
8
+
9
+ import path from 'path';
10
+ import { createLogger } from '../utils/logger.js';
11
+
12
+ export class DatabaseDetector {
13
+ constructor(methodCallGraph, config) {
14
+ this.methodCallGraph = methodCallGraph;
15
+ this.config = config;
16
+ this.logger = createLogger(config.verbose);
17
+ }
18
+
19
+ /**
20
+ * Detect database impact from changed files using layer-aware tracking
21
+ * Implements FR-001 to FR-006 from spec
22
+ */
23
+ async detect(changedFiles) {
24
+ const startTime = Date.now();
25
+ this.logger.info('\n 🔍 Analyzing database impacts...');
26
+ this.logger.verbose('DatabaseDetector', `Processing ${changedFiles.length} files`);
27
+
28
+ const databaseChanges = {
29
+ tables: new Map(), // tableName -> { entity, file, operations, fields, isEntityFile, hasRelationChange }
30
+ entityFiles: new Set(),
31
+ };
32
+
33
+ // Extract changed methods
34
+ const allChangedMethods = [];
35
+ for (const changedFile of changedFiles) {
36
+ // FR-001: Detect entity file changes
37
+ if (this.isEntityFile(changedFile)) {
38
+ this.logger.verbose('DatabaseDetector', `Analyzing entity: ${path.basename(changedFile.path)}`);
39
+ this.analyzeEntityChange(changedFile, databaseChanges);
40
+ }
41
+
42
+ const changedMethods = this.methodCallGraph.getChangedMethods(
43
+ changedFile.diff || '',
44
+ changedFile.absolutePath
45
+ );
46
+ allChangedMethods.push(...changedMethods);
47
+
48
+ // Also analyze changed code directly for database operations
49
+ this.analyzeChangedCodeForDatabaseOps(changedFile, databaseChanges);
50
+ }
51
+
52
+ this.logger.verbose('DatabaseDetector', `Found ${allChangedMethods.length} changed methods`);
53
+
54
+ // FR-003: Find affected repository methods through call graph
55
+ const affectedRepoMethods = this.findAffectedRepositoryMethods(allChangedMethods);
56
+
57
+ this.logger.verbose('DatabaseDetector', `Found ${affectedRepoMethods.length} affected repository methods`);
58
+
59
+ // Analyze repository methods for database operations
60
+ for (const repoMethod of affectedRepoMethods) {
61
+ const dbOps = this.extractDatabaseOperationsFromCallGraph(repoMethod);
62
+
63
+ for (const op of dbOps) {
64
+ if (!databaseChanges.tables.has(op.table)) {
65
+ databaseChanges.tables.set(op.table, {
66
+ entity: this.tableNameToEntityName(op.table),
67
+ file: op.file || repoMethod.file,
68
+ operations: new Set(),
69
+ fields: new Set(),
70
+ isEntityFile: false,
71
+ hasRelationChange: false,
72
+ changeSource: { path: repoMethod.file }
73
+ });
74
+ }
75
+
76
+ const tableData = databaseChanges.tables.get(op.table);
77
+ tableData.operations.add(op.operation);
78
+
79
+ if (op.fields && op.fields.length > 0) {
80
+ op.fields.forEach(field => tableData.fields.add(field));
81
+ }
82
+ }
83
+ }
84
+
85
+ const duration = Date.now() - startTime;
86
+ this.logger.verbose('DatabaseDetector', `Completed in ${duration}ms`);
87
+
88
+ // FR-006: Format according to spec output schema
89
+ return this.formatDatabaseImpact(databaseChanges);
90
+ }
91
+
92
+ /**
93
+ * FR-001: Check if file is an entity/model file
94
+ * Detects TypeORM entities and Sequelize models
95
+ */
96
+ isEntityFile(file) {
97
+ const filePath = file.path.toLowerCase();
98
+ const content = file.content || '';
99
+
100
+ // Check file naming conventions
101
+ if (filePath.includes('.entity.') || filePath.includes('/entities/') ||
102
+ filePath.includes('.model.') || filePath.includes('/models/')) {
103
+ return true;
104
+ }
105
+
106
+ // Check for ORM decorators
107
+ if (content.includes('@Entity(') || content.includes('@Table(')) {
108
+ return true;
109
+ }
110
+
111
+ // Check for Model extension
112
+ if (content.includes('extends Model')) {
113
+ return true;
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * FR-001: Analyze entity file changes
121
+ * Extracts table name, detects column changes, relation changes
122
+ */
123
+ analyzeEntityChange(changedFile, databaseChanges) {
124
+ const content = changedFile.content || '';
125
+ const className = this.extractClassName(content);
126
+
127
+ if (!className) {
128
+ this.logger.verbose('DatabaseDetector', `Could not extract class name from ${changedFile.path}`);
129
+ return;
130
+ }
131
+
132
+ // FR-002: Extract table name
133
+ const tableName = this.extractTableNameFromEntity(content, className);
134
+ this.logger.verbose('DatabaseDetector', `Extracted table name: ${tableName}`);
135
+
136
+ // Check for relation changes
137
+ const hasRelationChange = this.detectRelationChange(changedFile.diff || '');
138
+
139
+ if (!databaseChanges.tables.has(tableName)) {
140
+ databaseChanges.tables.set(tableName, {
141
+ entity: className,
142
+ file: changedFile.path,
143
+ operations: new Set(['SELECT', 'INSERT', 'UPDATE']), // Schema changes affect all ops
144
+ fields: new Set(),
145
+ isEntityFile: true,
146
+ hasRelationChange: hasRelationChange,
147
+ changeSource: { path: changedFile.path, status: changedFile.status }
148
+ });
149
+ } else {
150
+ const tableData = databaseChanges.tables.get(tableName);
151
+ tableData.isEntityFile = true;
152
+ tableData.hasRelationChange = hasRelationChange || tableData.hasRelationChange;
153
+ }
154
+
155
+ // Extract changed fields from diff
156
+ const changedFields = this.extractChangedFields(changedFile.diff || '');
157
+ if (changedFields.length > 0) {
158
+ const tableData = databaseChanges.tables.get(tableName);
159
+ changedFields.forEach(field => tableData.fields.add(field));
160
+ this.logger.verbose('DatabaseDetector', `Detected changed fields: ${changedFields.join(', ')}`);
161
+ }
162
+
163
+ databaseChanges.entityFiles.add(changedFile.path);
164
+ }
165
+
166
+ /**
167
+ * Extract class name from entity file content
168
+ */
169
+ extractClassName(content) {
170
+ // Match: class ClassName or export class ClassName
171
+ const classMatch = content.match(/(?:export\s+)?class\s+(\w+)/);
172
+ return classMatch ? classMatch[1] : null;
173
+ }
174
+
175
+ /**
176
+ * FR-002: Extract table name from entity
177
+ * Priority: 1. @Entity('name') 2. @Entity({ name: 'name' }) 3. className conversion
178
+ */
179
+ extractTableNameFromEntity(content, className) {
180
+ // Check for explicit table name in @Entity decorator
181
+ const explicitMatch = content.match(/@Entity\s*\(\s*['"](\w+)['"]/);
182
+ if (explicitMatch) return explicitMatch[1];
183
+
184
+ // Check for options object
185
+ const optionsMatch = content.match(/@Entity\s*\(\s*\{[^}]*name:\s*['"](\w+)['"]/);
186
+ if (optionsMatch) return optionsMatch[1];
187
+
188
+ // Check for Sequelize @Table
189
+ const tableMatch = content.match(/@Table\s*\(\s*\{[^}]*tableName:\s*['"](\w+)['"]/);
190
+ if (tableMatch) return tableMatch[1];
191
+
192
+ // Convert class name to snake_case
193
+ return this.entityToTableName(className);
194
+ }
195
+
196
+ /**
197
+ * Detect if relation decorators changed
198
+ */
199
+ detectRelationChange(diff) {
200
+ const relationDecorators = [
201
+ '@OneToMany', '@ManyToOne', '@OneToOne', '@ManyToMany',
202
+ '@HasMany', '@BelongsTo', '@BelongsToMany'
203
+ ];
204
+
205
+ const addedLines = diff.split('\n').filter(line => line.startsWith('+'));
206
+
207
+ for (const line of addedLines) {
208
+ for (const decorator of relationDecorators) {
209
+ if (line.includes(decorator)) {
210
+ return true;
211
+ }
212
+ }
213
+ }
214
+
215
+ return false;
216
+ }
217
+
218
+ /**
219
+ * Extract changed field names from diff
220
+ */
221
+ extractChangedFields(diff) {
222
+ const fields = new Set();
223
+ const lines = diff.split('\n');
224
+
225
+ for (const line of lines) {
226
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
227
+
228
+ const cleanLine = line.substring(1).trim();
229
+
230
+ // Match @Column() decorator followed by field name
231
+ if (cleanLine.includes('@Column') || cleanLine.includes('@PrimaryColumn')) {
232
+ // Look for field declaration on same or next line
233
+ const fieldMatch = cleanLine.match(/(@Column[^;]*)\s+(\w+)\s*[;:]/);
234
+ if (fieldMatch) {
235
+ fields.add(fieldMatch[2]);
236
+ }
237
+ }
238
+
239
+ // Match direct field declarations with type annotation
240
+ const fieldDeclMatch = cleanLine.match(/^\s*(\w+)\s*[?:]?\s*:/);
241
+ if (fieldDeclMatch && !cleanLine.includes('function') && !cleanLine.includes('constructor')) {
242
+ const fieldName = fieldDeclMatch[1];
243
+ if (fieldName !== 'delFlg') {
244
+ fields.add(fieldName);
245
+ }
246
+ }
247
+ }
248
+
249
+ return Array.from(fields);
250
+ }
251
+
252
+ /**
253
+ * Convert table name back to entity name
254
+ */
255
+ tableNameToEntityName(tableName) {
256
+ // Convert: user_profile → UserProfile
257
+ return tableName
258
+ .split('_')
259
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
260
+ .join('') + 'Entity';
261
+ }
262
+
263
+ findAffectedRepositoryMethods(changedMethods) {
264
+ const visited = new Set();
265
+ const repoMethods = [];
266
+ const queue = [...changedMethods];
267
+
268
+ while (queue.length > 0) {
269
+ const method = queue.shift();
270
+
271
+ if (!method || !method.file) continue;
272
+
273
+ const key = `${method.file}:${method.className}.${method.methodName}`;
274
+ if (visited.has(key)) continue;
275
+ visited.add(key);
276
+
277
+ const isRepository = method.file.includes('repository') ||
278
+ (method.className && method.className.toLowerCase().includes('repository'));
279
+
280
+ if (isRepository) {
281
+ repoMethods.push(method);
282
+ }
283
+
284
+ const callers = this.methodCallGraph.getCallers(method);
285
+ queue.push(...callers);
286
+ }
287
+
288
+ return repoMethods;
289
+ }
290
+
291
+ analyzeChangedCodeForDatabaseOps(changedFile, databaseChanges) {
292
+ const diff = changedFile.diff || '';
293
+ const lines = diff.split('\n');
294
+
295
+ const typeOrmOps = {
296
+ 'insert': 'INSERT',
297
+ 'save': 'INSERT/UPDATE',
298
+ 'create': 'INSERT',
299
+ 'update': 'UPDATE',
300
+ 'merge': 'UPDATE',
301
+ 'delete': 'DELETE',
302
+ 'remove': 'DELETE',
303
+ 'softDelete': 'SOFT_DELETE',
304
+ // SELECT operations
305
+ 'find': 'SELECT',
306
+ 'findOne': 'SELECT',
307
+ 'findBy': 'SELECT',
308
+ 'findOneBy': 'SELECT',
309
+ 'findAndCount': 'SELECT',
310
+ 'select': 'SELECT',
311
+ 'query': 'SELECT',
312
+ 'getMany': 'SELECT',
313
+ 'getOne': 'SELECT',
314
+ };
315
+
316
+ let inDatabaseOperation = false;
317
+ let currentEntityName = null;
318
+ let currentOperation = null;
319
+ let currentTableName = null;
320
+
321
+ for (let i = 0; i < lines.length; i++) {
322
+ const line = lines[i];
323
+ const cleanLine = line.startsWith('+') ? line.substring(1).trim() : line.trim();
324
+
325
+ // Detect start of database operations (even in context lines)
326
+ for (const [opName, opType] of Object.entries(typeOrmOps)) {
327
+ if (cleanLine.includes(`.${opName}(`)) {
328
+ inDatabaseOperation = true;
329
+ currentOperation = opType;
330
+
331
+ // Extract entity name from the operation
332
+ const entityName = this.extractEntityFromOperation(cleanLine, lines, i);
333
+ if (entityName) {
334
+ currentEntityName = entityName;
335
+ currentTableName = this.entityToTableName(entityName);
336
+ }
337
+
338
+ // Also check for repository pattern
339
+ const repoMatch = cleanLine.match(/(\w+Repository)\.(save|update|insert|create|delete|remove)\(/);
340
+ if (repoMatch && !currentEntityName) {
341
+ const repoName = repoMatch[1];
342
+ currentEntityName = repoName.replace(/Repository$/, '') + 'Entity';
343
+ currentTableName = this.entityToTableName(currentEntityName);
344
+ }
345
+
346
+ if (currentTableName) {
347
+ if (!databaseChanges.tables.has(currentTableName)) {
348
+ databaseChanges.tables.set(currentTableName, {
349
+ entity: currentEntityName,
350
+ file: changedFile.path,
351
+ operations: new Set(),
352
+ fields: new Set(),
353
+ isEntityFile: false,
354
+ hasRelationChange: false,
355
+ changeSource: { path: changedFile.path }
356
+ });
357
+ }
358
+
359
+ const tableData = databaseChanges.tables.get(currentTableName);
360
+ tableData.operations.add(currentOperation);
361
+ }
362
+ }
363
+ }
364
+
365
+ // When in a database operation context, extract fields from added lines
366
+ if (inDatabaseOperation && line.startsWith('+') && !line.startsWith('+++')) {
367
+ const addedLine = line.substring(1).trim();
368
+
369
+ // Extract fields from this added line
370
+ const fields = this.extractFieldsFromLine(addedLine);
371
+
372
+ if (fields.length > 0 && currentTableName) {
373
+ const tableData = databaseChanges.tables.get(currentTableName);
374
+ fields.forEach(field => tableData.fields.add(field));
375
+
376
+ this.logger.verbose('DatabaseDetector', `Detected ${currentOperation} on ${currentTableName} with fields: ${fields.join(', ')}`);
377
+ }
378
+ }
379
+
380
+ // Reset context when operation ends
381
+ if (inDatabaseOperation && cleanLine.includes(');')) {
382
+ inDatabaseOperation = false;
383
+ currentEntityName = null;
384
+ currentOperation = null;
385
+ currentTableName = null;
386
+ }
387
+ }
388
+ }
389
+
390
+ extractFieldsFromLine(line) {
391
+ const fields = [];
392
+
393
+ // Match field: value patterns
394
+ const fieldMatches = line.matchAll(/(\w+)\s*:/g);
395
+ for (const match of fieldMatches) {
396
+ const field = match[1];
397
+ if (field && field !== 'delFlg') {
398
+ fields.push(field);
399
+ }
400
+ }
401
+
402
+ // Also check for spread with new fields pattern: { ...obj, newField: value }
403
+ if (line.includes('...') && line.includes(':')) {
404
+ const afterSpread = line.split('...').slice(1).join('...');
405
+ const newFieldMatches = afterSpread.matchAll(/(\w+)\s*:/g);
406
+ for (const match of newFieldMatches) {
407
+ const field = match[1];
408
+ if (field && field !== 'delFlg' && !fields.includes(field)) {
409
+ fields.push(field);
410
+ }
411
+ }
412
+ }
413
+
414
+ return fields;
415
+ }
416
+
417
+ extractEntityFromOperation(line, lines, lineIndex) {
418
+ // Pattern 1: queryRunner.manager.update(UserEntity, ...)
419
+ const directEntityMatch = line.match(/\.(update|save|insert|create|delete|remove)\s*\(\s*(\w+Entity)/);
420
+ if (directEntityMatch) {
421
+ return directEntityMatch[2];
422
+ }
423
+
424
+ // Pattern 2: Look forward and backward for entity type
425
+ for (let i = Math.max(0, lineIndex - 5); i < Math.min(lines.length, lineIndex + 5); i++) {
426
+ const checkLine = lines[i];
427
+ if (!checkLine) continue;
428
+
429
+ const cleanCheckLine = checkLine.startsWith('+') || checkLine.startsWith('-') ?
430
+ checkLine.substring(1) : checkLine;
431
+
432
+ if (cleanCheckLine.includes('Entity')) {
433
+ const entityMatch = cleanCheckLine.match(/(\w+Entity)/);
434
+ if (entityMatch) {
435
+ return entityMatch[1];
436
+ }
437
+ }
438
+ }
439
+
440
+ return null;
441
+ }
442
+
443
+ extractFieldsFromOperation(line, lines, lineIndex, operation) {
444
+ const fields = [];
445
+
446
+ // Extract fields from object literal in current line
447
+ const objectMatch = line.match(/\{\s*([^}]+)\s*\}/);
448
+ if (objectMatch) {
449
+ const content = objectMatch[1];
450
+ const fieldMatches = content.match(/(\w+):/g);
451
+ if (fieldMatches) {
452
+ fieldMatches.forEach(match => {
453
+ const field = match.replace(':', '').trim();
454
+ if (field && field !== 'delFlg') {
455
+ fields.push(field);
456
+ }
457
+ });
458
+ }
459
+
460
+ // Also check for spread operator
461
+ const spreadMatch = content.match(/\.\.\.(\w+)/);
462
+ if (spreadMatch) {
463
+ // Try to find the variable definition
464
+ const varName = spreadMatch[1];
465
+ const varFields = this.findVariableFields(varName, lines, lineIndex);
466
+ fields.push(...varFields);
467
+ }
468
+ }
469
+
470
+ // Check if operation continues on next lines (multi-line object)
471
+ if (line.includes('{') && !line.includes('}')) {
472
+ for (let i = lineIndex + 1; i < Math.min(lines.length, lineIndex + 10); i++) {
473
+ const nextLine = lines[i];
474
+ if (!nextLine || !nextLine.startsWith('+')) break;
475
+
476
+ const cleanNext = nextLine.substring(1).trim();
477
+ if (cleanNext.includes('}')) break;
478
+
479
+ const fieldMatch = cleanNext.match(/(\w+):/);
480
+ if (fieldMatch) {
481
+ const field = fieldMatch[1];
482
+ if (field && field !== 'delFlg') {
483
+ fields.push(field);
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ return [...new Set(fields)];
490
+ }
491
+
492
+ findVariableFields(varName, lines, currentIndex) {
493
+ const fields = [];
494
+
495
+ for (let i = Math.max(0, currentIndex - 20); i < currentIndex; i++) {
496
+ const line = lines[i];
497
+ if (!line || !line.startsWith('+')) continue;
498
+
499
+ const cleanLine = line.substring(1).trim();
500
+
501
+ // Look for variable declaration
502
+ if (cleanLine.includes(`${varName}`) && cleanLine.includes('=') && cleanLine.includes('{')) {
503
+ // Start capturing fields
504
+ for (let j = i; j < Math.min(lines.length, i + 15); j++) {
505
+ const varLine = lines[j];
506
+ if (!varLine || !varLine.startsWith('+')) continue;
507
+
508
+ const cleanVarLine = varLine.substring(1).trim();
509
+
510
+ if (cleanVarLine.includes('}')) break;
511
+
512
+ const fieldMatch = cleanVarLine.match(/(\w+):/);
513
+ if (fieldMatch) {
514
+ const field = fieldMatch[1];
515
+ if (field && field !== 'delFlg') {
516
+ fields.push(field);
517
+ }
518
+ }
519
+ }
520
+ break;
521
+ }
522
+ }
523
+
524
+ return fields;
525
+ }
526
+
527
+ extractDatabaseOperationsFromCallGraph(repoMethod) {
528
+ const operations = [];
529
+
530
+ const methodKey = `${repoMethod.className}.${repoMethod.methodName}`;
531
+ const methodCalls = this.methodCallGraph.methodCallsMap?.get(methodKey) || [];
532
+
533
+ if (this.config.verbose) {
534
+ console.log(` 📞 Analyzing ${methodCalls.length} calls in ${repoMethod.className}.${repoMethod.methodName}`);
535
+ }
536
+
537
+ for (const call of methodCalls) {
538
+ const dbOp = this.detectDatabaseOperationFromCall(call);
539
+
540
+ if (dbOp) {
541
+ operations.push({
542
+ ...dbOp,
543
+ method: `${repoMethod.className}.${repoMethod.methodName}`,
544
+ file: repoMethod.file,
545
+ });
546
+ }
547
+ }
548
+
549
+ return operations;
550
+ }
551
+
552
+ detectDatabaseOperationFromCall(call) {
553
+ const typeOrmOps = {
554
+ 'insert': 'INSERT',
555
+ 'save': 'INSERT/UPDATE',
556
+ 'create': 'INSERT',
557
+ 'update': 'UPDATE',
558
+ 'merge': 'UPDATE',
559
+ 'delete': 'DELETE',
560
+ 'remove': 'DELETE',
561
+ 'softDelete': 'SOFT_DELETE',
562
+ // SELECT operations
563
+ 'find': 'SELECT',
564
+ 'findOne': 'SELECT',
565
+ 'findBy': 'SELECT',
566
+ 'findOneBy': 'SELECT',
567
+ 'findAndCount': 'SELECT',
568
+ 'select': 'SELECT',
569
+ 'query': 'SELECT',
570
+ 'getMany': 'SELECT',
571
+ 'getOne': 'SELECT',
572
+ };
573
+
574
+ // Handle both string and object format
575
+ let methodName, target;
576
+
577
+ if (typeof call === 'string') {
578
+ // Old format: "ClassName.methodName"
579
+ const parts = call.split('.');
580
+ if (parts.length >= 2) {
581
+ target = parts[0];
582
+ methodName = parts[1];
583
+ }
584
+ } else if (typeof call === 'object') {
585
+ // New format: { target, method, arguments }
586
+ methodName = call.method;
587
+ target = call.target;
588
+ }
589
+
590
+ if (!methodName || !typeOrmOps[methodName]) return null;
591
+
592
+ const entityMatch = target?.match(/(\w+Repository)/);
593
+
594
+ if (!entityMatch) return null;
595
+
596
+ const entityName = entityMatch[1].replace(/Repository$/, '') + 'Entity';
597
+
598
+ // Extract fields from arguments if available
599
+ const fields = [];
600
+ if (call.arguments && Array.isArray(call.arguments)) {
601
+ for (const arg of call.arguments) {
602
+ if (arg.type === 'object' && arg.fields) {
603
+ fields.push(...arg.fields);
604
+ }
605
+ }
606
+ }
607
+
608
+ return {
609
+ table: this.entityToTableName(entityName),
610
+ operation: typeOrmOps[methodName],
611
+ fields: fields.filter(f => f !== '...' && f !== 'delFlg'),
612
+ };
613
+ }
614
+
615
+ entityToTableName(entityName) {
616
+ return entityName
617
+ .replace(/Entity$/, '')
618
+ .replace(/([A-Z])/g, '_$1')
619
+ .toLowerCase()
620
+ .replace(/^_/, '');
621
+ }
622
+
623
+ /**
624
+ * FR-006: Format database impact according to spec output schema
625
+ * Output matches: .specify/specs/features/database-impact-detection.md Section 5
626
+ *
627
+ * @returns {DatabaseImpact[]} Array of database impacts matching spec schema
628
+ */
629
+ formatDatabaseImpact(databaseChanges) {
630
+ const impacts = [];
631
+
632
+ for (const [tableName, tableData] of databaseChanges.tables.entries()) {
633
+ const impactType = this.classifyImpactType(tableData);
634
+
635
+ const impact = {
636
+ // Spec-required fields
637
+ tableName: tableName,
638
+ modelName: tableData.entity,
639
+ modelPath: tableData.file,
640
+ impactType: impactType,
641
+ operations: Array.from(tableData.operations),
642
+ changeSource: tableData.changeSource,
643
+ severity: this.calculateSeverity(tableData, impactType),
644
+ };
645
+
646
+ // Optional field - only include if present
647
+ if (tableData.fields.size > 0) {
648
+ impact.fields = Array.from(tableData.fields);
649
+ }
650
+
651
+ impacts.push(impact);
652
+ }
653
+
654
+ return impacts;
655
+ }
656
+
657
+ /**
658
+ * FR-004: Classify impact type
659
+ * Categories: 'schema' | 'query' | 'relation' | 'migration'
660
+ */
661
+ classifyImpactType(tableData) {
662
+ // Schema: entity file changed
663
+ if (tableData.isEntityFile) return 'schema';
664
+
665
+ // Relation: relation decorators changed
666
+ if (tableData.hasRelationChange) return 'relation';
667
+
668
+ // Migration: migration file changed
669
+ if (tableData.file && tableData.file.includes('migration')) return 'migration';
670
+
671
+ // Query: repository/service changed
672
+ return 'query';
673
+ }
674
+
675
+ /**
676
+ * Calculate severity based on spec Section 6
677
+ * Critical: Schema changes (columns, constraints)
678
+ * High: Relation changes, DELETE operations
679
+ * Medium: INSERT, UPDATE operations
680
+ * Low: SELECT-only query changes
681
+ */
682
+ calculateSeverity(tableData, impactType) {
683
+ // Critical: Schema changes
684
+ if (impactType === 'schema') {
685
+ return 'critical';
686
+ }
687
+
688
+ // High: Relation changes or DELETE operations
689
+ if (impactType === 'relation' || tableData.operations.has('DELETE') || tableData.operations.has('SOFT_DELETE')) {
690
+ return 'high';
691
+ }
692
+
693
+ // Medium: INSERT or UPDATE operations
694
+ if (tableData.operations.has('INSERT') || tableData.operations.has('UPDATE') ||
695
+ tableData.operations.has('INSERT/UPDATE')) {
696
+ return 'medium';
697
+ }
698
+
699
+ // Low: SELECT-only
700
+ return 'low';
701
+ }
702
+ }