@sun-asterisk/sunlint 1.3.34 → 1.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/core/architecture-integration.js +16 -7
  2. package/core/auto-performance-manager.js +1 -1
  3. package/core/cli-action-handler.js +92 -2
  4. package/core/cli-program.js +96 -138
  5. package/core/file-targeting-service.js +62 -4
  6. package/core/git-utils.js +19 -12
  7. package/core/github-annotate-service.js +326 -11
  8. package/core/html-report-generator.js +326 -731
  9. package/core/impact-integration.js +433 -0
  10. package/core/output-service.js +293 -21
  11. package/core/scoring-service.js +3 -2
  12. package/engines/arch-detect/core/analyzer.js +413 -0
  13. package/engines/arch-detect/core/index.js +22 -0
  14. package/engines/arch-detect/engine/hybrid-detector.js +176 -0
  15. package/engines/arch-detect/engine/index.js +24 -0
  16. package/engines/arch-detect/engine/rule-executor.js +228 -0
  17. package/engines/arch-detect/engine/score-calculator.js +214 -0
  18. package/engines/arch-detect/engine/violation-detector.js +616 -0
  19. package/engines/arch-detect/index.js +50 -0
  20. package/engines/arch-detect/rules/base-rule.js +187 -0
  21. package/engines/arch-detect/rules/index.js +35 -0
  22. package/engines/arch-detect/rules/layered/index.js +28 -0
  23. package/engines/arch-detect/rules/layered/l001-presentation-layer.js +237 -0
  24. package/engines/arch-detect/rules/layered/l002-business-layer.js +215 -0
  25. package/engines/arch-detect/rules/layered/l003-data-layer.js +229 -0
  26. package/engines/arch-detect/rules/layered/l004-model-layer.js +204 -0
  27. package/engines/arch-detect/rules/layered/l005-layer-separation.js +215 -0
  28. package/engines/arch-detect/rules/layered/l006-dependency-direction.js +221 -0
  29. package/engines/arch-detect/rules/layered/layered-rules-collection.js +445 -0
  30. package/engines/arch-detect/rules/modular/index.js +27 -0
  31. package/engines/arch-detect/rules/modular/m001-feature-modules.js +238 -0
  32. package/engines/arch-detect/rules/modular/m002-core-module.js +169 -0
  33. package/engines/arch-detect/rules/modular/m003-module-declaration.js +186 -0
  34. package/engines/arch-detect/rules/modular/m004-public-api.js +171 -0
  35. package/engines/arch-detect/rules/modular/m005-no-deep-imports.js +220 -0
  36. package/engines/arch-detect/rules/modular/modular-rules-collection.js +357 -0
  37. package/engines/arch-detect/rules/presentation/index.js +27 -0
  38. package/engines/arch-detect/rules/presentation/pr001-view-layer.js +221 -0
  39. package/engines/arch-detect/rules/presentation/pr002-presentation-logic.js +192 -0
  40. package/engines/arch-detect/rules/presentation/pr004-data-binding.js +187 -0
  41. package/engines/arch-detect/rules/presentation/pr006-router-layer.js +185 -0
  42. package/engines/arch-detect/rules/presentation/pr007-interactor-layer.js +181 -0
  43. package/engines/arch-detect/rules/presentation/presentation-rules-collection.js +507 -0
  44. package/engines/arch-detect/rules/project-scanner/index.js +31 -0
  45. package/engines/arch-detect/rules/project-scanner/ps001-project-root.js +213 -0
  46. package/engines/arch-detect/rules/project-scanner/ps002-language-detection.js +192 -0
  47. package/engines/arch-detect/rules/project-scanner/ps003-framework-detection.js +339 -0
  48. package/engines/arch-detect/rules/project-scanner/ps004-build-system.js +171 -0
  49. package/engines/arch-detect/rules/project-scanner/ps005-source-directory.js +163 -0
  50. package/engines/arch-detect/rules/project-scanner/ps006-test-directory.js +184 -0
  51. package/engines/arch-detect/rules/project-scanner/ps007-documentation.js +149 -0
  52. package/engines/arch-detect/rules/project-scanner/ps008-cicd-detection.js +163 -0
  53. package/engines/arch-detect/rules/project-scanner/ps009-code-quality.js +152 -0
  54. package/engines/arch-detect/rules/project-scanner/ps010-statistics.js +180 -0
  55. package/engines/arch-detect/rules/rule-registry.js +111 -0
  56. package/engines/arch-detect/types/context.types.js +60 -0
  57. package/engines/arch-detect/types/enums.js +161 -0
  58. package/engines/arch-detect/types/index.js +25 -0
  59. package/engines/arch-detect/types/result.types.js +7 -0
  60. package/engines/arch-detect/types/rule.types.js +7 -0
  61. package/engines/arch-detect/utils/file-scanner.js +411 -0
  62. package/engines/arch-detect/utils/index.js +23 -0
  63. package/engines/arch-detect/utils/pattern-matcher.js +328 -0
  64. package/engines/impact/cli.js +106 -0
  65. package/engines/impact/config/default-config.js +54 -0
  66. package/engines/impact/core/change-detector.js +258 -0
  67. package/engines/impact/core/detectors/database-detector.js +1317 -0
  68. package/engines/impact/core/detectors/endpoint-detector.js +55 -0
  69. package/engines/impact/core/impact-analyzer.js +124 -0
  70. package/engines/impact/core/report-generator.js +462 -0
  71. package/engines/impact/core/utils/ast-parser.js +241 -0
  72. package/engines/impact/core/utils/dependency-graph.js +159 -0
  73. package/engines/impact/core/utils/file-utils.js +116 -0
  74. package/engines/impact/core/utils/git-utils.js +203 -0
  75. package/engines/impact/core/utils/logger.js +13 -0
  76. package/engines/impact/core/utils/method-call-graph.js +1192 -0
  77. package/engines/impact/index.js +135 -0
  78. package/engines/impact/package.json +29 -0
  79. package/package.json +18 -43
  80. package/scripts/build-release.sh +0 -0
  81. package/scripts/copy-impact-analyzer.js +135 -0
  82. package/scripts/install.sh +0 -0
  83. package/scripts/manual-release.sh +0 -0
  84. package/scripts/pre-release-test.sh +0 -0
  85. package/scripts/prepare-release.sh +0 -0
  86. package/scripts/quick-performance-test.js +0 -0
  87. package/scripts/setup-github-registry.sh +0 -0
  88. package/scripts/trigger-release.sh +0 -0
  89. package/scripts/verify-install.sh +0 -0
  90. package/templates/combined-report.html +1418 -0
@@ -0,0 +1,1317 @@
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 fs from 'fs';
11
+ import { createLogger } from '../utils/logger.js';
12
+
13
+ export class DatabaseDetector {
14
+ constructor(methodCallGraph, config) {
15
+ this.methodCallGraph = methodCallGraph;
16
+ this.config = config;
17
+ this.logger = createLogger(config.verbose);
18
+ }
19
+
20
+ /**
21
+ * Detect database impact from changed files using layer-aware tracking
22
+ * Implements FR-001 to FR-006 from spec
23
+ */
24
+ async detect(changedFiles) {
25
+ const startTime = Date.now();
26
+ this.logger.info('\n 🔍 Analyzing database impacts...');
27
+ this.logger.verbose('DatabaseDetector', `Processing ${changedFiles.length} files`);
28
+
29
+ const databaseChanges = {
30
+ tables: new Map(), // tableName -> { entity, file, operations, fields, isEntityFile, hasRelationChange }
31
+ entityFiles: new Set(),
32
+ };
33
+
34
+ // Extract changed methods
35
+ const allChangedMethods = [];
36
+ for (const changedFile of changedFiles) {
37
+ // FR-001: Detect entity file changes
38
+ if (this.isEntityFile(changedFile)) {
39
+ this.logger.verbose('DatabaseDetector', `Analyzing entity: ${path.basename(changedFile.path)}`);
40
+ this.analyzeEntityChange(changedFile, databaseChanges);
41
+ }
42
+
43
+ const changedMethods = this.methodCallGraph.getChangedMethods(
44
+ changedFile.diff || '',
45
+ changedFile.absolutePath
46
+ );
47
+ allChangedMethods.push(...changedMethods);
48
+
49
+ // Also analyze changed code directly for database operations
50
+ this.analyzeChangedCodeForDatabaseOps(changedFile, databaseChanges);
51
+ }
52
+
53
+ this.logger.verbose('DatabaseDetector', `Found ${allChangedMethods.length} changed methods`);
54
+
55
+ // FR-003: Find affected repository methods through call graph
56
+ const affectedRepoMethods = this.findAffectedRepositoryMethods(allChangedMethods);
57
+
58
+ this.logger.verbose('DatabaseDetector', `Found ${affectedRepoMethods.length} affected repository methods`);
59
+
60
+ // Analyze repository methods for database operations
61
+ for (const repoMethod of affectedRepoMethods) {
62
+ const dbOps = this.extractDatabaseOperationsFromCallGraph(repoMethod);
63
+
64
+ for (const op of dbOps) {
65
+ if (!databaseChanges.tables.has(op.table)) {
66
+ databaseChanges.tables.set(op.table, {
67
+ entity: this.tableNameToEntityName(op.table),
68
+ file: op.file || repoMethod.file,
69
+ operations: new Set(),
70
+ fields: new Set(),
71
+ isEntityFile: false,
72
+ hasRelationChange: false,
73
+ changeSource: { path: repoMethod.file }
74
+ });
75
+ }
76
+
77
+ const tableData = databaseChanges.tables.get(op.table);
78
+ tableData.operations.add(op.operation);
79
+
80
+ if (op.fields && op.fields.length > 0) {
81
+ op.fields.forEach(field => tableData.fields.add(field));
82
+ }
83
+ }
84
+ }
85
+
86
+ const duration = Date.now() - startTime;
87
+ this.logger.verbose('DatabaseDetector', `Completed in ${duration}ms`);
88
+
89
+ // FR-006: Format according to spec output schema
90
+ return this.formatDatabaseImpact(databaseChanges);
91
+ }
92
+
93
+ /**
94
+ * FR-001: Check if file is an entity/model file
95
+ * Detects TypeORM entities and Sequelize models
96
+ */
97
+ isEntityFile(file) {
98
+ const filePath = file.path.toLowerCase();
99
+ const content = file.content || '';
100
+
101
+ // Check file naming conventions
102
+ if (filePath.includes('.entity.') || filePath.includes('/entities/') ||
103
+ filePath.includes('.model.') || filePath.includes('/models/')) {
104
+ return true;
105
+ }
106
+
107
+ // Check for ORM decorators
108
+ if (content.includes('@Entity(') || content.includes('@Table(')) {
109
+ return true;
110
+ }
111
+
112
+ // Check for Model extension
113
+ if (content.includes('extends Model')) {
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ /**
121
+ * FR-001: Analyze entity file changes
122
+ * Extracts table name, detects column changes, relation changes
123
+ */
124
+ analyzeEntityChange(changedFile, databaseChanges) {
125
+ const content = changedFile.content || '';
126
+ const className = this.extractClassName(content);
127
+
128
+ if (!className) {
129
+ this.logger.verbose('DatabaseDetector', `Could not extract class name from ${changedFile.path}`);
130
+ return;
131
+ }
132
+
133
+ // FR-002: Extract table name
134
+ const tableName = this.extractTableNameFromEntity(content, className);
135
+ this.logger.verbose('DatabaseDetector', `Extracted table name: ${tableName}`);
136
+
137
+ // Check for relation changes
138
+ const hasRelationChange = this.detectRelationChange(changedFile.diff || '');
139
+
140
+ if (!databaseChanges.tables.has(tableName)) {
141
+ databaseChanges.tables.set(tableName, {
142
+ entity: className,
143
+ file: changedFile.path,
144
+ operations: new Set(['SELECT', 'INSERT', 'UPDATE']), // Schema changes affect all ops
145
+ fields: new Set(),
146
+ isEntityFile: true,
147
+ hasRelationChange: hasRelationChange,
148
+ changeSource: { path: changedFile.path, status: changedFile.status }
149
+ });
150
+ } else {
151
+ const tableData = databaseChanges.tables.get(tableName);
152
+ tableData.isEntityFile = true;
153
+ tableData.hasRelationChange = hasRelationChange || tableData.hasRelationChange;
154
+ }
155
+
156
+ // Extract changed fields from diff
157
+ const changedFields = this.extractChangedFields(changedFile.diff || '');
158
+ if (changedFields.length > 0) {
159
+ const tableData = databaseChanges.tables.get(tableName);
160
+ changedFields.forEach(field => tableData.fields.add(field));
161
+ this.logger.verbose('DatabaseDetector', `Detected changed fields: ${changedFields.join(', ')}`);
162
+ }
163
+
164
+ databaseChanges.entityFiles.add(changedFile.path);
165
+ }
166
+
167
+ /**
168
+ * Extract class name from entity file content
169
+ */
170
+ extractClassName(content) {
171
+ // Match: class ClassName or export class ClassName
172
+ const classMatch = content.match(/(?:export\s+)?class\s+(\w+)/);
173
+ return classMatch ? classMatch[1] : null;
174
+ }
175
+
176
+ /**
177
+ * FR-002: Extract table name from entity
178
+ * Priority: 1. @Entity('name') 2. @Entity({ name: 'name' }) 3. className conversion
179
+ */
180
+ extractTableNameFromEntity(content, className) {
181
+ // Check for explicit table name in @Entity decorator
182
+ const explicitMatch = content.match(/@Entity\s*\(\s*['"](\w+)['"]/);
183
+ if (explicitMatch) return explicitMatch[1];
184
+
185
+ // Check for options object
186
+ const optionsMatch = content.match(/@Entity\s*\(\s*\{[^}]*name:\s*['"](\w+)['"]/);
187
+ if (optionsMatch) return optionsMatch[1];
188
+
189
+ // Check for Sequelize @Table
190
+ const tableMatch = content.match(/@Table\s*\(\s*\{[^}]*tableName:\s*['"](\w+)['"]/);
191
+ if (tableMatch) return tableMatch[1];
192
+
193
+ // Convert class name to snake_case
194
+ return this.entityToTableName(className);
195
+ }
196
+
197
+ /**
198
+ * Detect if relation decorators changed
199
+ */
200
+ detectRelationChange(diff) {
201
+ const relationDecorators = [
202
+ '@OneToMany', '@ManyToOne', '@OneToOne', '@ManyToMany',
203
+ '@HasMany', '@BelongsTo', '@BelongsToMany'
204
+ ];
205
+
206
+ const addedLines = diff.split('\n').filter(line => line.startsWith('+'));
207
+
208
+ for (const line of addedLines) {
209
+ for (const decorator of relationDecorators) {
210
+ if (line.includes(decorator)) {
211
+ return true;
212
+ }
213
+ }
214
+ }
215
+
216
+ return false;
217
+ }
218
+
219
+ /**
220
+ * Extract changed field names from diff
221
+ */
222
+ extractChangedFields(diff) {
223
+ const fields = new Set();
224
+ const lines = diff.split('\n');
225
+
226
+ for (const line of lines) {
227
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
228
+
229
+ const cleanLine = line.substring(1).trim();
230
+
231
+ // Match @Column() decorator followed by field name
232
+ if (cleanLine.includes('@Column') || cleanLine.includes('@PrimaryColumn')) {
233
+ // Look for field declaration on same or next line
234
+ const fieldMatch = cleanLine.match(/(@Column[^;]*)\s+(\w+)\s*[;:]/);
235
+ if (fieldMatch) {
236
+ fields.add(fieldMatch[2]);
237
+ }
238
+ }
239
+
240
+ // Match direct field declarations with type annotation
241
+ const fieldDeclMatch = cleanLine.match(/^\s*(\w+)\s*[?:]?\s*:/);
242
+ if (fieldDeclMatch && !cleanLine.includes('function') && !cleanLine.includes('constructor')) {
243
+ const fieldName = fieldDeclMatch[1];
244
+ if (fieldName !== 'delFlg') {
245
+ fields.add(fieldName);
246
+ }
247
+ }
248
+ }
249
+
250
+ return Array.from(fields);
251
+ }
252
+
253
+ /**
254
+ * Convert table name back to entity name
255
+ */
256
+ tableNameToEntityName(tableName) {
257
+ // Convert: user_profile → UserProfile
258
+ return tableName
259
+ .split('_')
260
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
261
+ .join('') + 'Entity';
262
+ }
263
+
264
+ findAffectedRepositoryMethods(changedMethods) {
265
+ const visited = new Set();
266
+ const repoMethods = [];
267
+ const queue = [...changedMethods];
268
+
269
+ while (queue.length > 0) {
270
+ const method = queue.shift();
271
+
272
+ if (!method || !method.file) continue;
273
+
274
+ const key = `${method.file}:${method.className}.${method.methodName}`;
275
+ if (visited.has(key)) continue;
276
+ visited.add(key);
277
+
278
+ const isRepository = method.file.includes('repository') ||
279
+ (method.className && method.className.toLowerCase().includes('repository'));
280
+
281
+ if (isRepository) {
282
+ repoMethods.push(method);
283
+ }
284
+
285
+ const callers = this.methodCallGraph.getCallers(method);
286
+
287
+ queue.push(...callers);
288
+ }
289
+
290
+ return repoMethods;
291
+ }
292
+
293
+ /**
294
+ * BUG-002: Detect @InjectRepository decorators and extract entity information
295
+ * Enables detection of repository usage in services without call graph
296
+ */
297
+ detectInjectedRepositories(content, filePath) {
298
+ const injectedRepos = [];
299
+ const lines = content.split('\n');
300
+
301
+ for (let i = 0; i < lines.length; i++) {
302
+ const line = lines[i];
303
+
304
+ // Pattern: @InjectRepository(UserEntity)
305
+ const injectMatch = line.match(/@InjectRepository\s*\(\s*(\w+Entity)\s*\)/);
306
+ if (injectMatch) {
307
+ const entityName = injectMatch[1];
308
+
309
+ // Look for field name in next few lines
310
+ // private readonly userRepository: Repository<UserEntity>
311
+ for (let j = i; j < Math.min(i + 3, lines.length); j++) {
312
+ const fieldMatch = lines[j].match(/(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:/);
313
+ if (fieldMatch) {
314
+ injectedRepos.push({
315
+ entityName: entityName,
316
+ fieldName: fieldMatch[1],
317
+ tableName: this.entityToTableName(entityName),
318
+ file: filePath
319
+ });
320
+ this.logger.verbose('DatabaseDetector',
321
+ `Found injected repository: ${fieldMatch[1]} -> ${entityName} -> ${this.entityToTableName(entityName)}`);
322
+ break;
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ return injectedRepos;
329
+ }
330
+
331
+ /**
332
+ * BUG-002: Detect database operations from repository field usage
333
+ * Detects: this.userRepository.find(...), this.repo.update(...), etc.
334
+ */
335
+ detectOperationFromRepositoryUsage(line, repo, databaseChanges) {
336
+ const operations = {
337
+ 'createQueryBuilder': 'SELECT',
338
+ 'find': 'SELECT',
339
+ 'findOne': 'SELECT',
340
+ 'findBy': 'SELECT',
341
+ 'findOneBy': 'SELECT',
342
+ 'findAndCount': 'SELECT',
343
+ 'update': 'UPDATE',
344
+ 'insert': 'INSERT',
345
+ 'delete': 'DELETE',
346
+ 'remove': 'DELETE',
347
+ 'save': 'INSERT/UPDATE',
348
+ 'merge': 'UPDATE',
349
+ };
350
+
351
+ // Detect operation
352
+ let detectedOp = null;
353
+ for (const [method, opType] of Object.entries(operations)) {
354
+ if (line.includes(`.${method}(`)) {
355
+ detectedOp = opType;
356
+ break;
357
+ }
358
+ }
359
+
360
+ if (!detectedOp) return;
361
+
362
+ // Extract entity/table info
363
+ const tableName = repo.tableName;
364
+
365
+ if (!databaseChanges.tables.has(tableName)) {
366
+ databaseChanges.tables.set(tableName, {
367
+ entity: repo.entityName,
368
+ file: repo.file,
369
+ operations: new Set(),
370
+ fields: new Set(),
371
+ isEntityFile: false,
372
+ hasRelationChange: false,
373
+ changeSource: { path: repo.file }
374
+ });
375
+ }
376
+
377
+ const tableData = databaseChanges.tables.get(tableName);
378
+ tableData.operations.add(detectedOp);
379
+
380
+ // Extract fields from query builder
381
+ this.extractFieldsFromQueryBuilder(line, tableData);
382
+
383
+ this.logger.verbose('DatabaseDetector',
384
+ `Detected ${detectedOp} on ${tableName} via ${repo.fieldName}`);
385
+ }
386
+
387
+ /**
388
+ * BUG-002: Extract field names from query builder select() and where()
389
+ */
390
+ extractFieldsFromQueryBuilder(line, tableData) {
391
+ // Pattern: .select(['user.id', 'user.name', 'user.email'])
392
+ const selectMatch = line.match(/\.select\s*\(\s*\[([^\]]+)\]/);
393
+ if (selectMatch) {
394
+ const fieldsStr = selectMatch[1];
395
+ const fieldMatches = fieldsStr.matchAll(/['"](?:\w+\.)?(\w+)['"]/g);
396
+
397
+ for (const match of fieldMatches) {
398
+ const fieldName = match[1];
399
+ if (fieldName && fieldName !== 'delFlg') {
400
+ tableData.fields.add(fieldName);
401
+ }
402
+ }
403
+ }
404
+
405
+ // Pattern: .where('user.name = :name', ...)
406
+ const whereMatch = line.match(/\.where\s*\(\s*['"](?:\w+\.)?(\w+)\s*[=<>]/);
407
+ if (whereMatch) {
408
+ const fieldName = whereMatch[1];
409
+ if (fieldName && fieldName !== 'delFlg') {
410
+ tableData.fields.add(fieldName);
411
+ }
412
+ }
413
+
414
+ // Pattern: { id: 1, name: 'value' } in object literals
415
+ const objectFieldMatches = line.matchAll(/\{\s*(\w+)\s*:/g);
416
+ for (const match of objectFieldMatches) {
417
+ const fieldName = match[1];
418
+ if (fieldName && fieldName !== 'delFlg' && fieldName !== 'where') {
419
+ tableData.fields.add(fieldName);
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * BUG-002: Collect multi-line repository usage blocks from diff
426
+ * Handles statements that span multiple lines like:
427
+ * this.repo
428
+ * .createQueryBuilder()
429
+ * .select([...])
430
+ */
431
+ collectRepositoryUsageBlocks(lines, injectedRepos) {
432
+ const blocks = [];
433
+
434
+ for (let i = 0; i < lines.length; i++) {
435
+ const line = lines[i];
436
+
437
+ if (!line.startsWith('+')) continue;
438
+
439
+ const addedLine = line.substring(1).trim();
440
+
441
+ // Check if this line starts repository usage
442
+ for (const repo of injectedRepos) {
443
+ if (addedLine.includes(`this.${repo.fieldName}`)) {
444
+ // Collect all subsequent lines that are method chains
445
+ const codeBlock = [addedLine];
446
+ let j = i + 1;
447
+
448
+ // Look ahead for chained methods (lines starting with .)
449
+ while (j < lines.length) {
450
+ const nextLine = lines[j];
451
+ if (!nextLine.startsWith('+')) break;
452
+
453
+ const nextAddedLine = nextLine.substring(1).trim();
454
+
455
+ // Check if it's a method chain continuation
456
+ if (nextAddedLine.startsWith('.') ||
457
+ nextAddedLine.startsWith(')') ||
458
+ codeBlock[codeBlock.length - 1].endsWith(',') ||
459
+ codeBlock[codeBlock.length - 1].endsWith('(')) {
460
+ codeBlock.push(nextAddedLine);
461
+ j++;
462
+ } else {
463
+ break;
464
+ }
465
+ }
466
+
467
+ blocks.push({
468
+ repo: repo,
469
+ code: codeBlock.join(' ')
470
+ });
471
+
472
+ // Skip the lines we've already processed
473
+ i = j - 1;
474
+ break;
475
+ }
476
+ }
477
+ }
478
+
479
+ return blocks;
480
+ }
481
+
482
+ /**
483
+ * BUG-004: Detect raw SQL query patterns in changed files
484
+ * CORRECTED APPROACH: Use full file content, not just diff
485
+ *
486
+ * Strategy:
487
+ * 1. Read full file content from filesystem
488
+ * 2. Find changed line numbers from diff
489
+ * 3. Find parent statements containing those lines
490
+ * 4. Extract SQL from those statements
491
+ */
492
+ detectRawSQLQueries(filePath, diff) {
493
+ const sqlQueries = [];
494
+
495
+ try {
496
+ // Step 1: Read full file content
497
+ if (!fs.existsSync(filePath)) {
498
+ this.logger.verbose('DatabaseDetector', `File not found: ${filePath}`);
499
+ return sqlQueries;
500
+ }
501
+
502
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
503
+ const fileLines = fileContent.split('\n');
504
+
505
+ // Step 2: Extract changed line numbers from diff
506
+ const changedLineNumbers = this.extractChangedLineNumbers(diff);
507
+
508
+ if (changedLineNumbers.length === 0) {
509
+ return sqlQueries;
510
+ }
511
+
512
+ this.logger.verbose('DatabaseDetector',
513
+ `Analyzing ${changedLineNumbers.length} changed lines in ${filePath}`);
514
+
515
+ // Step 3: Find query/execute statements that contain changed lines
516
+ const processedStatements = new Set();
517
+
518
+ for (const lineNum of changedLineNumbers) {
519
+ const statement = this.findQueryStatementContainingLine(fileLines, lineNum);
520
+
521
+ if (!statement) continue;
522
+
523
+ const key = `${statement.startLine}-${statement.endLine}`;
524
+ if (processedStatements.has(key)) continue;
525
+ processedStatements.add(key);
526
+
527
+ // Step 4: Extract SQL from statement
528
+ const sqlData = this.extractSQLFromStatement(statement.content);
529
+
530
+ if (sqlData) {
531
+ this.logger.verbose('DatabaseDetector',
532
+ `Found SQL query at lines ${statement.startLine}-${statement.endLine}`);
533
+ sqlQueries.push({
534
+ sql: sqlData.sql,
535
+ method: statement.method,
536
+ changedLineNum: lineNum
537
+ });
538
+ }
539
+ }
540
+
541
+ } catch (error) {
542
+ this.logger.error('DatabaseDetector', `Error detecting raw SQL: ${error.message}`);
543
+ }
544
+
545
+ return sqlQueries;
546
+ }
547
+
548
+ /**
549
+ * Extract line numbers that were changed in the diff
550
+ */
551
+ extractChangedLineNumbers(diff) {
552
+ const changedLines = [];
553
+ const lines = diff.split('\n');
554
+ let currentLineNum = 0;
555
+
556
+ for (const line of lines) {
557
+ if (line.startsWith('@@')) {
558
+ // Parse: @@ -oldStart,oldCount +newStart,newCount @@
559
+ const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)/);
560
+ if (match) {
561
+ currentLineNum = parseInt(match[1], 10);
562
+ }
563
+ continue;
564
+ }
565
+
566
+ if (line.startsWith('+') && !line.startsWith('+++')) {
567
+ changedLines.push(currentLineNum);
568
+ }
569
+
570
+ if (!line.startsWith('-')) {
571
+ currentLineNum++;
572
+ }
573
+ }
574
+
575
+ return changedLines;
576
+ }
577
+
578
+ /**
579
+ * Find the .query() or .execute() statement that contains the given line number
580
+ */
581
+ findQueryStatementContainingLine(fileLines, targetLineNum) {
582
+ // Adjust for 0-based array indexing
583
+ const targetIndex = targetLineNum - 1;
584
+
585
+ if (targetIndex < 0 || targetIndex >= fileLines.length) {
586
+ return null;
587
+ }
588
+
589
+ // Search backwards to find .query( or .execute(
590
+ const searchRadius = 50;
591
+ let startLine = -1;
592
+ let method = null;
593
+
594
+ for (let i = targetIndex; i >= Math.max(0, targetIndex - searchRadius); i--) {
595
+ const line = fileLines[i];
596
+ const match = line.match(/\.(query|execute)\s*</);
597
+
598
+ if (match) {
599
+ method = match[1];
600
+ startLine = i + 1; // Convert to 1-based line number
601
+ break;
602
+ }
603
+ }
604
+
605
+ if (!method) return null;
606
+
607
+ // Search forward to find the end of the statement (closing semicolon or })
608
+ let endLine = targetLineNum;
609
+ let parenDepth = 0;
610
+ let inTemplate = false;
611
+
612
+ for (let i = startLine - 1; i < Math.min(fileLines.length, startLine + searchRadius); i++) {
613
+ const line = fileLines[i];
614
+
615
+ // Track template literal boundaries
616
+ for (const char of line) {
617
+ if (char === '`') inTemplate = !inTemplate;
618
+ if (!inTemplate) {
619
+ if (char === '(') parenDepth++;
620
+ if (char === ')') parenDepth--;
621
+ }
622
+ }
623
+
624
+ endLine = i + 1;
625
+
626
+ // Found the end when parentheses are balanced and we hit a semicolon
627
+ if (parenDepth === 0 && line.includes(';')) {
628
+ break;
629
+ }
630
+ }
631
+
632
+ // Extract the statement content
633
+ const statementLines = fileLines.slice(startLine - 1, endLine);
634
+
635
+ return {
636
+ startLine,
637
+ endLine,
638
+ method,
639
+ content: statementLines.join('\n')
640
+ };
641
+ }
642
+
643
+ /**
644
+ * Extract SQL and metadata from a query/execute statement
645
+ */
646
+ extractSQLFromStatement(statementContent) {
647
+ // Extract SQL from template literal (backticks)
648
+ const templateMatch = statementContent.match(/`([^`]+)`/s);
649
+
650
+ if (templateMatch) {
651
+ const sql = templateMatch[1].trim();
652
+ return { sql };
653
+ }
654
+
655
+ // Try single/double quotes as fallback
656
+ const quoteMatch = statementContent.match(/['"]([^'"]+)['"]/s);
657
+ if (quoteMatch) {
658
+ const sql = quoteMatch[1].trim();
659
+ return { sql };
660
+ }
661
+
662
+ return null;
663
+ }
664
+
665
+ /**
666
+ * BUG-004: Parse SQL query to extract database impact
667
+ * Extracts: operation type, table names, field names
668
+ */
669
+ parseSQLQuery(sql) {
670
+ const impact = {
671
+ operation: null,
672
+ tables: [],
673
+ fields: [],
674
+ hasJoin: false,
675
+ joinType: null
676
+ };
677
+
678
+ // Normalize SQL (remove extra whitespace, newlines)
679
+ const normalizedSQL = sql.replace(/\s+/g, ' ').trim().toUpperCase();
680
+
681
+ // Detect operation type
682
+ if (normalizedSQL.startsWith('SELECT')) {
683
+ impact.operation = 'SELECT';
684
+ } else if (normalizedSQL.startsWith('INSERT')) {
685
+ impact.operation = 'INSERT';
686
+ } else if (normalizedSQL.startsWith('UPDATE')) {
687
+ impact.operation = 'UPDATE';
688
+ } else if (normalizedSQL.startsWith('DELETE')) {
689
+ impact.operation = 'DELETE';
690
+ } else if (normalizedSQL.startsWith('ALTER')) {
691
+ impact.operation = 'ALTER';
692
+ } else if (normalizedSQL.startsWith('CREATE')) {
693
+ impact.operation = 'CREATE';
694
+ } else if (normalizedSQL.startsWith('DROP')) {
695
+ impact.operation = 'DROP';
696
+ }
697
+
698
+ // Extract table names
699
+ impact.tables = this.extractTablesFromSQL(normalizedSQL, impact.operation);
700
+
701
+ // Extract field names (use original SQL for case sensitivity)
702
+ impact.fields = this.extractFieldsFromSQL(sql, impact.operation);
703
+
704
+ // Detect JOIN
705
+ if (normalizedSQL.includes('JOIN')) {
706
+ impact.hasJoin = true;
707
+
708
+ if (normalizedSQL.includes('INNER JOIN')) {
709
+ impact.joinType = 'INNER';
710
+ } else if (normalizedSQL.includes('LEFT JOIN')) {
711
+ impact.joinType = 'LEFT';
712
+ } else if (normalizedSQL.includes('RIGHT JOIN')) {
713
+ impact.joinType = 'RIGHT';
714
+ } else if (normalizedSQL.includes('FULL JOIN')) {
715
+ impact.joinType = 'FULL';
716
+ }
717
+ }
718
+
719
+ return impact;
720
+ }
721
+
722
+ /**
723
+ * BUG-004: Extract table names from SQL query
724
+ */
725
+ extractTablesFromSQL(sql, operation) {
726
+ const tables = new Set();
727
+
728
+ // FROM clause: SELECT ... FROM table_name
729
+ const fromMatches = sql.matchAll(/FROM\s+([a-z_][a-z0-9_]*)/gi);
730
+ for (const match of fromMatches) {
731
+ tables.add(match[1].toLowerCase());
732
+ }
733
+
734
+ // JOIN clause: JOIN table_name
735
+ const joinMatches = sql.matchAll(/JOIN\s+([a-z_][a-z0-9_]*)/gi);
736
+ for (const match of joinMatches) {
737
+ tables.add(match[1].toLowerCase());
738
+ }
739
+
740
+ // INSERT INTO: INSERT INTO table_name
741
+ if (operation === 'INSERT') {
742
+ const insertMatch = sql.match(/INSERT\s+INTO\s+([a-z_][a-z0-9_]*)/i);
743
+ if (insertMatch) {
744
+ tables.add(insertMatch[1].toLowerCase());
745
+ }
746
+ }
747
+
748
+ // UPDATE: UPDATE table_name
749
+ if (operation === 'UPDATE') {
750
+ const updateMatch = sql.match(/UPDATE\s+([a-z_][a-z0-9_]*)/i);
751
+ if (updateMatch) {
752
+ tables.add(updateMatch[1].toLowerCase());
753
+ }
754
+ }
755
+
756
+ // ALTER TABLE: ALTER TABLE table_name
757
+ if (operation === 'ALTER') {
758
+ const alterMatch = sql.match(/ALTER\s+TABLE\s+([a-z_][a-z0-9_]*)/i);
759
+ if (alterMatch) {
760
+ tables.add(alterMatch[1].toLowerCase());
761
+ }
762
+ }
763
+
764
+ return Array.from(tables);
765
+ }
766
+
767
+ /**
768
+ * BUG-004: Extract field names from SQL query
769
+ */
770
+ extractFieldsFromSQL(sql, operation) {
771
+ const fields = new Set();
772
+
773
+ // SELECT fields: SELECT field1, field2, table.field3
774
+ if (operation === 'SELECT') {
775
+ const selectMatch = sql.match(/SELECT\s+(.*?)\s+FROM/is);
776
+ if (selectMatch) {
777
+ const fieldList = selectMatch[1];
778
+
779
+ // Extract field names (handle table.field format)
780
+ const fieldMatches = fieldList.matchAll(/(?:^|,)\s*(?:\w+\.)?(\w+)(?:\s+as\s+\w+)?/gi);
781
+ for (const match of fieldMatches) {
782
+ const fieldName = match[1].toLowerCase();
783
+ // Skip SQL keywords and functions
784
+ if (!['count', 'sum', 'avg', 'max', 'min', 'distinct', '*'].includes(fieldName)) {
785
+ fields.add(fieldName);
786
+ }
787
+ }
788
+ }
789
+ }
790
+
791
+ // WHERE clause fields: WHERE user.id = $1 AND user.name = $2
792
+ const whereMatches = sql.matchAll(/(?:WHERE|AND|OR)\s+(?:\w+\.)?(\w+)\s*[=<>!]/gi);
793
+ for (const match of whereMatches) {
794
+ const fieldName = match[1].toLowerCase();
795
+ fields.add(fieldName);
796
+ }
797
+
798
+ // JOIN ON clause: ON table1.field = table2.field
799
+ const onMatches = sql.matchAll(/ON\s+(?:\w+\.)?(\w+)\s*=\s*(?:\w+\.)?(\w+)/gi);
800
+ for (const match of onMatches) {
801
+ fields.add(match[1].toLowerCase());
802
+ fields.add(match[2].toLowerCase());
803
+ }
804
+
805
+ // INSERT fields: INSERT INTO table (field1, field2)
806
+ if (operation === 'INSERT') {
807
+ const insertMatch = sql.match(/INSERT\s+INTO\s+\w+\s*\((.*?)\)/is);
808
+ if (insertMatch) {
809
+ const fieldList = insertMatch[1];
810
+ const fieldMatches = fieldList.matchAll(/(\w+)/g);
811
+ for (const match of fieldMatches) {
812
+ fields.add(match[1].toLowerCase());
813
+ }
814
+ }
815
+ }
816
+
817
+ // UPDATE SET: UPDATE table SET field1 = $1, field2 = $2
818
+ if (operation === 'UPDATE') {
819
+ const setMatch = sql.match(/SET\s+(.*?)(?:WHERE|$)/is);
820
+ if (setMatch) {
821
+ const setList = setMatch[1];
822
+ const fieldMatches = setList.matchAll(/(\w+)\s*=/gi);
823
+ for (const match of fieldMatches) {
824
+ fields.add(match[1].toLowerCase());
825
+ }
826
+ }
827
+ }
828
+
829
+ // ALTER TABLE ADD/MODIFY: ALTER TABLE table ADD COLUMN field_name
830
+ if (operation === 'ALTER') {
831
+ const alterMatches = sql.matchAll(/(?:ADD|MODIFY|DROP)\s+(?:COLUMN\s+)?(\w+)/gi);
832
+ for (const match of alterMatches) {
833
+ fields.add(match[1].toLowerCase());
834
+ }
835
+ }
836
+
837
+ return Array.from(fields).filter(f => f !== 'delflg');
838
+ }
839
+
840
+ analyzeChangedCodeForDatabaseOps(changedFile, databaseChanges) {
841
+ const diff = changedFile.diff || '';
842
+ const content = changedFile.content || '';
843
+ const lines = diff.split('\n');
844
+
845
+ // BUG-002: Step 1 - Detect injected repositories in file
846
+ const injectedRepos = this.detectInjectedRepositories(content, changedFile.path);
847
+
848
+ if (injectedRepos.length > 0) {
849
+ this.logger.verbose('DatabaseDetector',
850
+ `Found ${injectedRepos.length} injected repositories in ${changedFile.path}`);
851
+ }
852
+
853
+ // BUG-002: Step 2 - Collect multi-line repository usage statements
854
+ const repoUsageBlocks = this.collectRepositoryUsageBlocks(lines, injectedRepos);
855
+
856
+ for (const block of repoUsageBlocks) {
857
+ this.detectOperationFromRepositoryUsage(
858
+ block.code,
859
+ block.repo,
860
+ databaseChanges
861
+ );
862
+ }
863
+
864
+ // BUG-004: Step 3 - Detect raw SQL queries
865
+ const rawSQLQueries = this.detectRawSQLQueries(changedFile.path, changedFile.diff || '');
866
+
867
+ if (rawSQLQueries.length > 0) {
868
+ this.logger.verbose('DatabaseDetector',
869
+ `Found ${rawSQLQueries.length} raw SQL queries in ${changedFile.path}`);
870
+ }
871
+
872
+ for (const query of rawSQLQueries) {
873
+ const sqlImpact = this.parseSQLQuery(query.sql);
874
+
875
+ // Add each affected table to database changes
876
+ for (const tableName of sqlImpact.tables) {
877
+ if (!databaseChanges.tables.has(tableName)) {
878
+ databaseChanges.tables.set(tableName, {
879
+ entity: this.tableNameToEntityName(tableName),
880
+ file: changedFile.path,
881
+ operations: new Set(),
882
+ fields: new Set(),
883
+ isEntityFile: false,
884
+ hasRelationChange: false,
885
+ changeSource: { path: changedFile.path, type: 'raw-sql' }
886
+ });
887
+ }
888
+
889
+ const tableData = databaseChanges.tables.get(tableName);
890
+
891
+ if (sqlImpact.operation) {
892
+ tableData.operations.add(sqlImpact.operation);
893
+ }
894
+
895
+ for (const field of sqlImpact.fields) {
896
+ tableData.fields.add(field);
897
+ }
898
+
899
+ // Add SQL-specific metadata
900
+ if (sqlImpact.hasJoin) {
901
+ tableData.hasJoin = true;
902
+ tableData.joinType = sqlImpact.joinType;
903
+ }
904
+
905
+ this.logger.verbose('DatabaseDetector',
906
+ `Raw SQL: ${sqlImpact.operation} on ${tableName} with fields: ${sqlImpact.fields.join(', ')}`);
907
+ }
908
+ }
909
+
910
+ const typeOrmOps = {
911
+ 'insert': 'INSERT',
912
+ 'save': 'INSERT/UPDATE',
913
+ 'create': 'INSERT',
914
+ 'update': 'UPDATE',
915
+ 'merge': 'UPDATE',
916
+ 'delete': 'DELETE',
917
+ 'remove': 'DELETE',
918
+ 'softDelete': 'SOFT_DELETE',
919
+ // SELECT operations
920
+ 'find': 'SELECT',
921
+ 'findOne': 'SELECT',
922
+ 'findBy': 'SELECT',
923
+ 'findOneBy': 'SELECT',
924
+ 'findAndCount': 'SELECT',
925
+ 'select': 'SELECT',
926
+ 'query': 'SELECT',
927
+ 'getMany': 'SELECT',
928
+ 'getOne': 'SELECT',
929
+ };
930
+
931
+ let inDatabaseOperation = false;
932
+ let currentEntityName = null;
933
+ let currentOperation = null;
934
+ let currentTableName = null;
935
+
936
+ for (let i = 0; i < lines.length; i++) {
937
+ const line = lines[i];
938
+ const cleanLine = line.startsWith('+') ? line.substring(1).trim() : line.trim();
939
+
940
+ // Detect start of database operations (even in context lines)
941
+ for (const [opName, opType] of Object.entries(typeOrmOps)) {
942
+ if (cleanLine.includes(`.${opName}(`)) {
943
+ inDatabaseOperation = true;
944
+ currentOperation = opType;
945
+
946
+ // Extract entity name from the operation
947
+ const entityName = this.extractEntityFromOperation(cleanLine, lines, i);
948
+ if (entityName) {
949
+ currentEntityName = entityName;
950
+ currentTableName = this.entityToTableName(entityName);
951
+ }
952
+
953
+ // Also check for repository pattern
954
+ const repoMatch = cleanLine.match(/(\w+Repository)\.(save|update|insert|create|delete|remove)\(/);
955
+ if (repoMatch && !currentEntityName) {
956
+ const repoName = repoMatch[1];
957
+ currentEntityName = repoName.replace(/Repository$/, '') + 'Entity';
958
+ currentTableName = this.entityToTableName(currentEntityName);
959
+ }
960
+
961
+ if (currentTableName) {
962
+ if (!databaseChanges.tables.has(currentTableName)) {
963
+ databaseChanges.tables.set(currentTableName, {
964
+ entity: currentEntityName,
965
+ file: changedFile.path,
966
+ operations: new Set(),
967
+ fields: new Set(),
968
+ isEntityFile: false,
969
+ hasRelationChange: false,
970
+ changeSource: { path: changedFile.path }
971
+ });
972
+ }
973
+
974
+ const tableData = databaseChanges.tables.get(currentTableName);
975
+ tableData.operations.add(currentOperation);
976
+ }
977
+ }
978
+ }
979
+
980
+ // When in a database operation context, extract fields from added lines
981
+ if (inDatabaseOperation && line.startsWith('+') && !line.startsWith('+++')) {
982
+ const addedLine = line.substring(1).trim();
983
+
984
+ // Extract fields from this added line
985
+ const fields = this.extractFieldsFromLine(addedLine);
986
+
987
+ if (fields.length > 0 && currentTableName) {
988
+ const tableData = databaseChanges.tables.get(currentTableName);
989
+ fields.forEach(field => tableData.fields.add(field));
990
+
991
+ this.logger.verbose('DatabaseDetector', `Detected ${currentOperation} on ${currentTableName} with fields: ${fields.join(', ')}`);
992
+ }
993
+ }
994
+
995
+ // Reset context when operation ends
996
+ if (inDatabaseOperation && cleanLine.includes(');')) {
997
+ inDatabaseOperation = false;
998
+ currentEntityName = null;
999
+ currentOperation = null;
1000
+ currentTableName = null;
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ extractFieldsFromLine(line) {
1006
+ const fields = [];
1007
+
1008
+ // Match field: value patterns
1009
+ const fieldMatches = line.matchAll(/(\w+)\s*:/g);
1010
+ for (const match of fieldMatches) {
1011
+ const field = match[1];
1012
+ if (field && field !== 'delFlg') {
1013
+ fields.push(field);
1014
+ }
1015
+ }
1016
+
1017
+ // Also check for spread with new fields pattern: { ...obj, newField: value }
1018
+ if (line.includes('...') && line.includes(':')) {
1019
+ const afterSpread = line.split('...').slice(1).join('...');
1020
+ const newFieldMatches = afterSpread.matchAll(/(\w+)\s*:/g);
1021
+ for (const match of newFieldMatches) {
1022
+ const field = match[1];
1023
+ if (field && field !== 'delFlg' && !fields.includes(field)) {
1024
+ fields.push(field);
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ return fields;
1030
+ }
1031
+
1032
+ extractEntityFromOperation(line, lines, lineIndex) {
1033
+ // Pattern 1: queryRunner.manager.update(UserEntity, ...)
1034
+ const directEntityMatch = line.match(/\.(update|save|insert|create|delete|remove)\s*\(\s*(\w+Entity)/);
1035
+ if (directEntityMatch) {
1036
+ return directEntityMatch[2];
1037
+ }
1038
+
1039
+ // Pattern 2: Look forward and backward for entity type
1040
+ for (let i = Math.max(0, lineIndex - 5); i < Math.min(lines.length, lineIndex + 5); i++) {
1041
+ const checkLine = lines[i];
1042
+ if (!checkLine) continue;
1043
+
1044
+ const cleanCheckLine = checkLine.startsWith('+') || checkLine.startsWith('-') ?
1045
+ checkLine.substring(1) : checkLine;
1046
+
1047
+ if (cleanCheckLine.includes('Entity')) {
1048
+ const entityMatch = cleanCheckLine.match(/(\w+Entity)/);
1049
+ if (entityMatch) {
1050
+ return entityMatch[1];
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ return null;
1056
+ }
1057
+
1058
+ extractFieldsFromOperation(line, lines, lineIndex, operation) {
1059
+ const fields = [];
1060
+
1061
+ // Extract fields from object literal in current line
1062
+ const objectMatch = line.match(/\{\s*([^}]+)\s*\}/);
1063
+ if (objectMatch) {
1064
+ const content = objectMatch[1];
1065
+ const fieldMatches = content.match(/(\w+):/g);
1066
+ if (fieldMatches) {
1067
+ fieldMatches.forEach(match => {
1068
+ const field = match.replace(':', '').trim();
1069
+ if (field && field !== 'delFlg') {
1070
+ fields.push(field);
1071
+ }
1072
+ });
1073
+ }
1074
+
1075
+ // Also check for spread operator
1076
+ const spreadMatch = content.match(/\.\.\.(\w+)/);
1077
+ if (spreadMatch) {
1078
+ // Try to find the variable definition
1079
+ const varName = spreadMatch[1];
1080
+ const varFields = this.findVariableFields(varName, lines, lineIndex);
1081
+ fields.push(...varFields);
1082
+ }
1083
+ }
1084
+
1085
+ // Check if operation continues on next lines (multi-line object)
1086
+ if (line.includes('{') && !line.includes('}')) {
1087
+ for (let i = lineIndex + 1; i < Math.min(lines.length, lineIndex + 10); i++) {
1088
+ const nextLine = lines[i];
1089
+ if (!nextLine || !nextLine.startsWith('+')) break;
1090
+
1091
+ const cleanNext = nextLine.substring(1).trim();
1092
+ if (cleanNext.includes('}')) break;
1093
+
1094
+ const fieldMatch = cleanNext.match(/(\w+):/);
1095
+ if (fieldMatch) {
1096
+ const field = fieldMatch[1];
1097
+ if (field && field !== 'delFlg') {
1098
+ fields.push(field);
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ return [...new Set(fields)];
1105
+ }
1106
+
1107
+ findVariableFields(varName, lines, currentIndex) {
1108
+ const fields = [];
1109
+
1110
+ for (let i = Math.max(0, currentIndex - 20); i < currentIndex; i++) {
1111
+ const line = lines[i];
1112
+ if (!line || !line.startsWith('+')) continue;
1113
+
1114
+ const cleanLine = line.substring(1).trim();
1115
+
1116
+ // Look for variable declaration
1117
+ if (cleanLine.includes(`${varName}`) && cleanLine.includes('=') && cleanLine.includes('{')) {
1118
+ // Start capturing fields
1119
+ for (let j = i; j < Math.min(lines.length, i + 15); j++) {
1120
+ const varLine = lines[j];
1121
+ if (!varLine || !varLine.startsWith('+')) continue;
1122
+
1123
+ const cleanVarLine = varLine.substring(1).trim();
1124
+
1125
+ if (cleanVarLine.includes('}')) break;
1126
+
1127
+ const fieldMatch = cleanVarLine.match(/(\w+):/);
1128
+ if (fieldMatch) {
1129
+ const field = fieldMatch[1];
1130
+ if (field && field !== 'delFlg') {
1131
+ fields.push(field);
1132
+ }
1133
+ }
1134
+ }
1135
+ break;
1136
+ }
1137
+ }
1138
+
1139
+ return fields;
1140
+ }
1141
+
1142
+ extractDatabaseOperationsFromCallGraph(repoMethod) {
1143
+ const operations = [];
1144
+
1145
+ const methodKey = `${repoMethod.className}.${repoMethod.methodName}`;
1146
+ const methodCalls = this.methodCallGraph.methodCallsMap?.get(methodKey) || [];
1147
+
1148
+ this.logger.verbose('DatabaseDetector', ` 📞 Analyzing ${methodCalls.length} calls in ${repoMethod.className}.${repoMethod.methodName}`);
1149
+
1150
+ for (const call of methodCalls) {
1151
+ const dbOp = this.detectDatabaseOperationFromCall(call);
1152
+
1153
+ if (dbOp) {
1154
+ operations.push({
1155
+ ...dbOp,
1156
+ method: `${repoMethod.className}.${repoMethod.methodName}`,
1157
+ file: repoMethod.file,
1158
+ });
1159
+ }
1160
+ }
1161
+
1162
+ return operations;
1163
+ }
1164
+
1165
+ detectDatabaseOperationFromCall(call) {
1166
+ const typeOrmOps = {
1167
+ 'insert': 'INSERT',
1168
+ 'save': 'INSERT/UPDATE',
1169
+ 'create': 'INSERT',
1170
+ 'update': 'UPDATE',
1171
+ 'merge': 'UPDATE',
1172
+ 'delete': 'DELETE',
1173
+ 'remove': 'DELETE',
1174
+ 'softDelete': 'SOFT_DELETE',
1175
+ // SELECT operations
1176
+ 'find': 'SELECT',
1177
+ 'findOne': 'SELECT',
1178
+ 'findBy': 'SELECT',
1179
+ 'findOneBy': 'SELECT',
1180
+ 'findAndCount': 'SELECT',
1181
+ 'select': 'SELECT',
1182
+ 'query': 'SELECT',
1183
+ 'getMany': 'SELECT',
1184
+ 'getOne': 'SELECT',
1185
+ };
1186
+
1187
+ // Handle both string and object format
1188
+ let methodName, target;
1189
+
1190
+ if (typeof call === 'string') {
1191
+ // Old format: "ClassName.methodName"
1192
+ const parts = call.split('.');
1193
+ if (parts.length >= 2) {
1194
+ target = parts[0];
1195
+ methodName = parts[1];
1196
+ }
1197
+ } else if (typeof call === 'object') {
1198
+ // New format: { target, method, arguments }
1199
+ methodName = call.method;
1200
+ target = call.target;
1201
+ }
1202
+
1203
+ if (!methodName || !typeOrmOps[methodName]) return null;
1204
+
1205
+ const entityMatch = target?.match(/(\w+Repository)/);
1206
+
1207
+ if (!entityMatch) return null;
1208
+
1209
+ const entityName = entityMatch[1].replace(/Repository$/, '') + 'Entity';
1210
+
1211
+ // Extract fields from arguments if available
1212
+ const fields = [];
1213
+ if (call.arguments && Array.isArray(call.arguments)) {
1214
+ for (const arg of call.arguments) {
1215
+ if (arg.type === 'object' && arg.fields) {
1216
+ fields.push(...arg.fields);
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ return {
1222
+ table: this.entityToTableName(entityName),
1223
+ operation: typeOrmOps[methodName],
1224
+ fields: fields.filter(f => f !== '...' && f !== 'delFlg'),
1225
+ };
1226
+ }
1227
+
1228
+ entityToTableName(entityName) {
1229
+ return entityName
1230
+ .replace(/Entity$/, '')
1231
+ .replace(/([A-Z])/g, '_$1')
1232
+ .toLowerCase()
1233
+ .replace(/^_/, '');
1234
+ }
1235
+
1236
+ /**
1237
+ * FR-006: Format database impact according to spec output schema
1238
+ * Output matches: .specify/specs/features/database-impact-detection.md Section 5
1239
+ *
1240
+ * @returns {DatabaseImpact[]} Array of database impacts matching spec schema
1241
+ */
1242
+ formatDatabaseImpact(databaseChanges) {
1243
+ const impacts = [];
1244
+
1245
+ for (const [tableName, tableData] of databaseChanges.tables.entries()) {
1246
+ const impactType = this.classifyImpactType(tableData);
1247
+
1248
+ const impact = {
1249
+ // Spec-required fields
1250
+ tableName: tableName,
1251
+ modelName: tableData.entity,
1252
+ modelPath: tableData.file,
1253
+ impactType: impactType,
1254
+ operations: Array.from(tableData.operations),
1255
+ changeSource: tableData.changeSource,
1256
+ severity: this.calculateSeverity(tableData, impactType),
1257
+ };
1258
+
1259
+ // Optional field - only include if present
1260
+ if (tableData.fields.size > 0) {
1261
+ impact.fields = Array.from(tableData.fields);
1262
+ }
1263
+
1264
+ impacts.push(impact);
1265
+ }
1266
+
1267
+ return impacts;
1268
+ }
1269
+
1270
+ /**
1271
+ * FR-004: Classify impact type
1272
+ * Categories: 'schema' | 'query' | 'relation' | 'migration'
1273
+ */
1274
+ classifyImpactType(tableData) {
1275
+ // Schema: entity file changed
1276
+ if (tableData.isEntityFile) return 'schema';
1277
+
1278
+ // Relation: relation decorators changed
1279
+ if (tableData.hasRelationChange) return 'relation';
1280
+
1281
+ // Migration: migration file changed
1282
+ if (tableData.file && tableData.file.includes('migration')) return 'migration';
1283
+
1284
+ // Query: repository/service changed
1285
+ return 'query';
1286
+ }
1287
+
1288
+ /**
1289
+ * Calculate severity based on spec Section 6
1290
+ * Critical: Schema changes (columns, constraints)
1291
+ * High: Relation changes, DELETE operations
1292
+ * Medium: INSERT, UPDATE operations
1293
+ * Low: SELECT-only query changes
1294
+ */
1295
+ calculateSeverity(tableData, impactType) {
1296
+ // Critical: Schema changes
1297
+ if (impactType === 'schema') {
1298
+ return 'critical';
1299
+ }
1300
+
1301
+ // High: Relation changes or DELETE operations
1302
+ if (impactType === 'relation' || tableData.operations.has('DELETE') || tableData.operations.has('SOFT_DELETE')) {
1303
+ return 'high';
1304
+ }
1305
+
1306
+ // Medium: INSERT or UPDATE operations
1307
+ if (tableData.operations.has('INSERT') || tableData.operations.has('UPDATE') ||
1308
+ tableData.operations.has('INSERT/UPDATE')) {
1309
+ return 'medium';
1310
+ }
1311
+
1312
+ // Low: SELECT-only
1313
+ return 'low';
1314
+ }
1315
+ }
1316
+
1317
+ export default DatabaseDetector;