@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.
- package/.github/copilot-instructions.md +116 -0
- package/.github/prompts/README.md +91 -0
- package/.github/prompts/task-001-refactor.prompt.md +241 -0
- package/.specify/bugs/bug-001-database-detector.md +222 -0
- package/.specify/plans/architecture.md +186 -0
- package/.specify/specs/features/api-impact-detection.md +317 -0
- package/.specify/specs/features/component-impact-detection.md +263 -0
- package/.specify/specs/features/database-impact-detection.md +247 -0
- package/.specify/tasks/task-001-refactor-api-detector.md +284 -0
- package/.specify/tasks/task-002-database-detector.md +593 -0
- package/.specify/tasks/task-003-component-detector.md +0 -0
- package/.specify/tasks/task-004-report.md +484 -0
- package/README.md +13 -19
- package/core/detectors/database-detector.js +702 -0
- package/{modules → core}/detectors/endpoint-detector.js +11 -8
- package/{modules → core}/report-generator.js +102 -20
- package/core/utils/logger.js +12 -0
- package/index.js +6 -5
- package/package.json +1 -1
- package/modules/detectors/database-detector.js +0 -182
- /package/{modules → core}/change-detector.js +0 -0
- /package/{modules → core}/impact-analyzer.js +0 -0
- /package/{modules → core}/utils/ast-parser.js +0 -0
- /package/{modules → core}/utils/dependency-graph.js +0 -0
- /package/{modules → core}/utils/file-utils.js +0 -0
- /package/{modules → core}/utils/git-utils.js +0 -0
- /package/{modules → core}/utils/method-call-graph.js +0 -0
|
@@ -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
|
+
}
|