@eduardbar/drift 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analyzer.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
1
3
  import {
2
4
  Project,
3
5
  SourceFile,
@@ -8,7 +10,7 @@ import {
8
10
  FunctionExpression,
9
11
  MethodDeclaration,
10
12
  } from 'ts-morph'
11
- import type { DriftIssue, FileReport } from './types.js'
13
+ import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
12
14
 
13
15
  // Rules and their drift score weight
14
16
  const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
@@ -22,6 +24,27 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
22
24
  'catch-swallow': { severity: 'warning', weight: 10 },
23
25
  'magic-number': { severity: 'info', weight: 3 },
24
26
  'any-abuse': { severity: 'warning', weight: 8 },
27
+ // Phase 1: complexity detection
28
+ 'high-complexity': { severity: 'error', weight: 15 },
29
+ 'deep-nesting': { severity: 'warning', weight: 12 },
30
+ 'too-many-params': { severity: 'warning', weight: 8 },
31
+ 'high-coupling': { severity: 'warning', weight: 10 },
32
+ 'promise-style-mix': { severity: 'warning', weight: 7 },
33
+ // Phase 2: cross-file dead code
34
+ 'unused-export': { severity: 'warning', weight: 8 },
35
+ 'dead-file': { severity: 'warning', weight: 10 },
36
+ 'unused-dependency': { severity: 'warning', weight: 6 },
37
+ // Phase 3: architectural boundaries
38
+ 'circular-dependency': { severity: 'error', weight: 14 },
39
+ // Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
40
+ 'layer-violation': { severity: 'error', weight: 16 },
41
+ 'cross-boundary-import': { severity: 'warning', weight: 10 },
42
+ // Phase 5: AI authorship heuristics
43
+ 'over-commented': { severity: 'info', weight: 4 },
44
+ 'hardcoded-config': { severity: 'warning', weight: 10 },
45
+ 'inconsistent-error-handling': { severity: 'warning', weight: 8 },
46
+ 'unnecessary-abstraction': { severity: 'warning', weight: 7 },
47
+ 'naming-inconsistency': { severity: 'warning', weight: 6 },
25
48
  }
26
49
 
27
50
  type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
@@ -55,6 +78,10 @@ function getFunctionLikeLines(node: FunctionLike): number {
55
78
  return node.getEndLineNumber() - node.getStartLineNumber()
56
79
  }
57
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Existing rules
83
+ // ---------------------------------------------------------------------------
84
+
58
85
  function detectLargeFile(file: SourceFile): DriftIssue[] {
59
86
  const lineCount = file.getEndLineNumber()
60
87
  if (lineCount > 300) {
@@ -239,6 +266,605 @@ function detectMissingReturnTypes(file: SourceFile): DriftIssue[] {
239
266
  return issues
240
267
  }
241
268
 
269
+ // ---------------------------------------------------------------------------
270
+ // Phase 1: complexity detection rules
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Cyclomatic complexity: count decision points in a function.
275
+ * Each if/else if/ternary/?:/for/while/do/case/catch/&&/|| adds 1.
276
+ * Threshold: > 10 is considered high complexity.
277
+ */
278
+ function getCyclomaticComplexity(fn: FunctionLike): number {
279
+ let complexity = 1 // base path
280
+
281
+ const incrementKinds = [
282
+ SyntaxKind.IfStatement,
283
+ SyntaxKind.ForStatement,
284
+ SyntaxKind.ForInStatement,
285
+ SyntaxKind.ForOfStatement,
286
+ SyntaxKind.WhileStatement,
287
+ SyntaxKind.DoStatement,
288
+ SyntaxKind.CaseClause,
289
+ SyntaxKind.CatchClause,
290
+ SyntaxKind.ConditionalExpression, // ternary
291
+ SyntaxKind.AmpersandAmpersandToken,
292
+ SyntaxKind.BarBarToken,
293
+ SyntaxKind.QuestionQuestionToken, // ??
294
+ ]
295
+
296
+ for (const kind of incrementKinds) {
297
+ complexity += fn.getDescendantsOfKind(kind).length
298
+ }
299
+
300
+ return complexity
301
+ }
302
+
303
+ function detectHighComplexity(file: SourceFile): DriftIssue[] {
304
+ const issues: DriftIssue[] = []
305
+ const fns: FunctionLike[] = [
306
+ ...file.getFunctions(),
307
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
308
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
309
+ ...file.getClasses().flatMap((c) => c.getMethods()),
310
+ ]
311
+
312
+ for (const fn of fns) {
313
+ const complexity = getCyclomaticComplexity(fn)
314
+ if (complexity > 10) {
315
+ const startLine = fn.getStartLineNumber()
316
+ if (hasIgnoreComment(file, startLine)) continue
317
+ issues.push({
318
+ rule: 'high-complexity',
319
+ severity: 'error',
320
+ message: `Cyclomatic complexity is ${complexity} (threshold: 10). AI generates correct code, not simple code.`,
321
+ line: startLine,
322
+ column: fn.getStartLinePos(),
323
+ snippet: getSnippet(fn, file),
324
+ })
325
+ }
326
+ }
327
+ return issues
328
+ }
329
+
330
+ /**
331
+ * Deep nesting: count the maximum nesting depth of control flow inside a function.
332
+ * Counts: if, for, while, do, try, switch.
333
+ * Threshold: > 3 levels.
334
+ */
335
+ function getMaxNestingDepth(fn: FunctionLike): number {
336
+ const nestingKinds = new Set([
337
+ SyntaxKind.IfStatement,
338
+ SyntaxKind.ForStatement,
339
+ SyntaxKind.ForInStatement,
340
+ SyntaxKind.ForOfStatement,
341
+ SyntaxKind.WhileStatement,
342
+ SyntaxKind.DoStatement,
343
+ SyntaxKind.TryStatement,
344
+ SyntaxKind.SwitchStatement,
345
+ ])
346
+
347
+ let maxDepth = 0
348
+
349
+ function walk(node: Node, depth: number): void {
350
+ if (nestingKinds.has(node.getKind())) {
351
+ depth++
352
+ if (depth > maxDepth) maxDepth = depth
353
+ }
354
+ for (const child of node.getChildren()) {
355
+ walk(child, depth)
356
+ }
357
+ }
358
+
359
+ walk(fn, 0)
360
+ return maxDepth
361
+ }
362
+
363
+ function detectDeepNesting(file: SourceFile): DriftIssue[] {
364
+ const issues: DriftIssue[] = []
365
+ const fns: FunctionLike[] = [
366
+ ...file.getFunctions(),
367
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
368
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
369
+ ...file.getClasses().flatMap((c) => c.getMethods()),
370
+ ]
371
+
372
+ for (const fn of fns) {
373
+ const depth = getMaxNestingDepth(fn)
374
+ if (depth > 3) {
375
+ const startLine = fn.getStartLineNumber()
376
+ if (hasIgnoreComment(file, startLine)) continue
377
+ issues.push({
378
+ rule: 'deep-nesting',
379
+ severity: 'warning',
380
+ message: `Maximum nesting depth is ${depth} (threshold: 3). Deep nesting is the #1 readability killer.`,
381
+ line: startLine,
382
+ column: fn.getStartLinePos(),
383
+ snippet: getSnippet(fn, file),
384
+ })
385
+ }
386
+ }
387
+ return issues
388
+ }
389
+
390
+ /**
391
+ * Too many parameters: functions with more than 4 parameters.
392
+ * AI avoids refactoring parameters into objects/options bags.
393
+ */
394
+ function detectTooManyParams(file: SourceFile): DriftIssue[] {
395
+ const issues: DriftIssue[] = []
396
+ const fns: FunctionLike[] = [
397
+ ...file.getFunctions(),
398
+ ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
399
+ ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
400
+ ...file.getClasses().flatMap((c) => c.getMethods()),
401
+ ]
402
+
403
+ for (const fn of fns) {
404
+ const paramCount = fn.getParameters().length
405
+ if (paramCount > 4) {
406
+ const startLine = fn.getStartLineNumber()
407
+ if (hasIgnoreComment(file, startLine)) continue
408
+ issues.push({
409
+ rule: 'too-many-params',
410
+ severity: 'warning',
411
+ message: `Function has ${paramCount} parameters (threshold: 4). AI avoids refactoring into options objects.`,
412
+ line: startLine,
413
+ column: fn.getStartLinePos(),
414
+ snippet: getSnippet(fn, file),
415
+ })
416
+ }
417
+ }
418
+ return issues
419
+ }
420
+
421
+ /**
422
+ * High coupling: files with more than 10 distinct import sources.
423
+ * AI imports broadly without considering module cohesion.
424
+ */
425
+ function detectHighCoupling(file: SourceFile): DriftIssue[] {
426
+ const imports = file.getImportDeclarations()
427
+ const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()))
428
+
429
+ if (sources.size > 10) {
430
+ return [
431
+ {
432
+ rule: 'high-coupling',
433
+ severity: 'warning',
434
+ message: `File imports from ${sources.size} distinct modules (threshold: 10). High coupling makes refactoring dangerous.`,
435
+ line: 1,
436
+ column: 1,
437
+ snippet: `// ${sources.size} import sources`,
438
+ },
439
+ ]
440
+ }
441
+ return []
442
+ }
443
+
444
+ /**
445
+ * Promise style mix: async/await and .then()/.catch() used in the same file.
446
+ * AI generates both styles without consistency.
447
+ */
448
+ function detectPromiseStyleMix(file: SourceFile): DriftIssue[] {
449
+ const text = file.getFullText()
450
+
451
+ // detect .then( or .catch( calls (property access on a promise)
452
+ const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
453
+ const name = node.getName()
454
+ return name === 'then' || name === 'catch'
455
+ })
456
+
457
+ // detect async keyword usage
458
+ const hasAsync =
459
+ file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
460
+ /\bawait\b/.test(text)
461
+
462
+ if (hasThen && hasAsync) {
463
+ return [
464
+ {
465
+ rule: 'promise-style-mix',
466
+ severity: 'warning',
467
+ message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
468
+ line: 1,
469
+ column: 1,
470
+ snippet: `// mixed promise styles detected`,
471
+ },
472
+ ]
473
+ }
474
+ return []
475
+ }
476
+
477
+ /**
478
+ * Magic numbers: numeric literals used directly in logic outside of named constants.
479
+ * Excludes 0, 1, -1 (universally understood) and array indices in obvious patterns.
480
+ */
481
+ function detectMagicNumbers(file: SourceFile): DriftIssue[] {
482
+ const issues: DriftIssue[] = []
483
+ const ALLOWED = new Set([0, 1, -1, 2, 100])
484
+
485
+ for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
486
+ const value = Number(node.getLiteralValue())
487
+ if (ALLOWED.has(value)) continue
488
+
489
+ // Skip: variable/const initializers at top level (those ARE the named constants)
490
+ const parent = node.getParent()
491
+ if (!parent) continue
492
+ const parentKind = parent.getKind()
493
+ if (
494
+ parentKind === SyntaxKind.VariableDeclaration ||
495
+ parentKind === SyntaxKind.PropertyAssignment ||
496
+ parentKind === SyntaxKind.EnumMember ||
497
+ parentKind === SyntaxKind.Parameter
498
+ ) continue
499
+
500
+ const line = node.getStartLineNumber()
501
+ if (hasIgnoreComment(file, line)) continue
502
+
503
+ issues.push({
504
+ rule: 'magic-number',
505
+ severity: 'info',
506
+ message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
507
+ line,
508
+ column: node.getStartLinePos(),
509
+ snippet: getSnippet(node, file),
510
+ })
511
+ }
512
+ return issues
513
+ }
514
+
515
+ /**
516
+ * Comment contradiction: comments that restate exactly what the code does.
517
+ * Classic AI pattern — documents the obvious instead of the why.
518
+ * Detects: "// increment counter" above counter++, "// return x" above return x, etc.
519
+ */
520
+ function detectCommentContradiction(file: SourceFile): DriftIssue[] {
521
+ const issues: DriftIssue[] = []
522
+ const lines = file.getFullText().split('\n')
523
+
524
+ // Patterns: comment that is a near-literal restatement of the next line
525
+ const trivialCommentPatterns = [
526
+ // "// return ..." above a return statement
527
+ { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
528
+ // "// increment ..." or "// increase ..." above x++ or x += 1
529
+ { comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
530
+ // "// decrement ..." above x-- or x -= 1
531
+ { comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
532
+ // "// log ..." above console.log
533
+ { comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
534
+ // "// set ... to ..." or "// assign ..." above assignment
535
+ { comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
536
+ // "// call ..." above a function call
537
+ { comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
538
+ // "// declare ..." or "// define ..." or "// create ..." above const/let/var
539
+ { comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
540
+ // "// check if ..." above an if statement
541
+ { comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
542
+ // "// loop ..." or "// iterate ..." above for/while
543
+ { comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
544
+ // "// import ..." above an import
545
+ { comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
546
+ ]
547
+
548
+ for (let i = 0; i < lines.length - 1; i++) {
549
+ const commentLine = lines[i].trim()
550
+ const nextLine = lines[i + 1]
551
+
552
+ for (const { comment, code } of trivialCommentPatterns) {
553
+ if (comment.test(commentLine) && code.test(nextLine)) {
554
+ if (hasIgnoreComment(file, i + 1)) continue
555
+ issues.push({
556
+ rule: 'comment-contradiction',
557
+ severity: 'warning',
558
+ message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
559
+ line: i + 1,
560
+ column: 1,
561
+ snippet: `${commentLine.slice(0, 60)}\n${nextLine.trim().slice(0, 60)}`,
562
+ })
563
+ break // one issue per comment line max
564
+ }
565
+ }
566
+ }
567
+
568
+ return issues
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // Phase 5: AI authorship heuristics
573
+ // ---------------------------------------------------------------------------
574
+
575
+ function detectOverCommented(file: SourceFile): DriftIssue[] {
576
+ const issues: DriftIssue[] = []
577
+
578
+ for (const fn of file.getFunctions()) {
579
+ const body = fn.getBody()
580
+ if (!body) continue
581
+
582
+ const bodyText = body.getText()
583
+ const lines = bodyText.split('\n')
584
+ const totalLines = lines.length
585
+
586
+ if (totalLines < 6) continue
587
+
588
+ let commentLines = 0
589
+ for (const line of lines) {
590
+ const trimmed = line.trim()
591
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
592
+ commentLines++
593
+ }
594
+ }
595
+
596
+ const ratio = commentLines / totalLines
597
+ if (ratio >= 0.4) {
598
+ issues.push({
599
+ rule: 'over-commented',
600
+ severity: 'info',
601
+ message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
602
+ line: fn.getStartLineNumber(),
603
+ column: fn.getStartLinePos(),
604
+ snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
605
+ })
606
+ }
607
+ }
608
+
609
+ for (const cls of file.getClasses()) {
610
+ for (const method of cls.getMethods()) {
611
+ const body = method.getBody()
612
+ if (!body) continue
613
+
614
+ const bodyText = body.getText()
615
+ const lines = bodyText.split('\n')
616
+ const totalLines = lines.length
617
+
618
+ if (totalLines < 6) continue
619
+
620
+ let commentLines = 0
621
+ for (const line of lines) {
622
+ const trimmed = line.trim()
623
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
624
+ commentLines++
625
+ }
626
+ }
627
+
628
+ const ratio = commentLines / totalLines
629
+ if (ratio >= 0.4) {
630
+ issues.push({
631
+ rule: 'over-commented',
632
+ severity: 'info',
633
+ message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
634
+ line: method.getStartLineNumber(),
635
+ column: method.getStartLinePos(),
636
+ snippet: `${cls.getName()}.${method.getName()}`,
637
+ })
638
+ }
639
+ }
640
+ }
641
+
642
+ return issues
643
+ }
644
+
645
+ function detectHardcodedConfig(file: SourceFile): DriftIssue[] {
646
+ const issues: DriftIssue[] = []
647
+
648
+ const CONFIG_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
649
+ { pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
650
+ { pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
651
+ { pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
652
+ { pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
653
+ { pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
654
+ { pattern: /^redis:\/\//i, label: 'Redis connection string' },
655
+ { pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
656
+ { pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
657
+ { pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
658
+ { pattern: /^\/[a-z]/i, label: 'Absolute file path' },
659
+ { pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
660
+ ]
661
+
662
+ const filePath = file.getFilePath().replace(/\\/g, '/')
663
+ if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
664
+ return issues
665
+ }
666
+
667
+ for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
668
+ const value = node.getLiteralValue()
669
+ if (!value || value.length < 4) continue
670
+
671
+ const parent = node.getParent()
672
+ if (!parent) continue
673
+ const parentKind = parent.getKindName()
674
+ if (
675
+ parentKind === 'ImportDeclaration' ||
676
+ parentKind === 'ExportDeclaration' ||
677
+ (parentKind === 'CallExpression' && parent.getText().startsWith('import('))
678
+ ) continue
679
+
680
+ for (const { pattern, label } of CONFIG_PATTERNS) {
681
+ if (pattern.test(value)) {
682
+ issues.push({
683
+ rule: 'hardcoded-config',
684
+ severity: 'warning',
685
+ message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
686
+ line: node.getStartLineNumber(),
687
+ column: node.getStartLinePos(),
688
+ snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
689
+ })
690
+ break
691
+ }
692
+ }
693
+ }
694
+
695
+ return issues
696
+ }
697
+
698
+ function detectInconsistentErrorHandling(file: SourceFile): DriftIssue[] {
699
+ const issues: DriftIssue[] = []
700
+
701
+ let hasTryCatch = false
702
+ let hasDotCatch = false
703
+ let hasThenErrorHandler = false
704
+ let firstLine = 0
705
+
706
+ // Detectar try/catch
707
+ const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement)
708
+ if (tryCatches.length > 0) {
709
+ hasTryCatch = true
710
+ firstLine = firstLine || tryCatches[0].getStartLineNumber()
711
+ }
712
+
713
+ // Detectar .catch(handler) en call expressions
714
+ for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
715
+ const expr = call.getExpression()
716
+ if (expr.getKindName() === 'PropertyAccessExpression') {
717
+ const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression)
718
+ const propName = propAccess.getName()
719
+ if (propName === 'catch') {
720
+ // Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
721
+ if (call.getArguments().length > 0) {
722
+ hasDotCatch = true
723
+ if (!firstLine) firstLine = call.getStartLineNumber()
724
+ }
725
+ }
726
+ // Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
727
+ if (propName === 'then' && call.getArguments().length >= 2) {
728
+ hasThenErrorHandler = true
729
+ if (!firstLine) firstLine = call.getStartLineNumber()
730
+ }
731
+ }
732
+ }
733
+
734
+ const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length
735
+
736
+ if (stylesUsed >= 2) {
737
+ const styles: string[] = []
738
+ if (hasTryCatch) styles.push('try/catch')
739
+ if (hasDotCatch) styles.push('.catch()')
740
+ if (hasThenErrorHandler) styles.push('.then(_, handler)')
741
+
742
+ issues.push({
743
+ rule: 'inconsistent-error-handling',
744
+ severity: 'warning',
745
+ message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
746
+ line: firstLine || 1,
747
+ column: 1,
748
+ snippet: styles.join(' + '),
749
+ })
750
+ }
751
+
752
+ return issues
753
+ }
754
+
755
+ function detectUnnecessaryAbstraction(file: SourceFile): DriftIssue[] {
756
+ const issues: DriftIssue[] = []
757
+ const fileText = file.getFullText()
758
+
759
+ // Interfaces con un solo método
760
+ for (const iface of file.getInterfaces()) {
761
+ const methods = iface.getMethods()
762
+ const properties = iface.getProperties()
763
+
764
+ // Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
765
+ if (methods.length !== 1 || properties.length !== 0) continue
766
+
767
+ const ifaceName = iface.getName()
768
+
769
+ // Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
770
+ const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length
771
+ // La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
772
+ // Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
773
+ if (usageCount <= 2) {
774
+ issues.push({
775
+ rule: 'unnecessary-abstraction',
776
+ severity: 'warning',
777
+ message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
778
+ line: iface.getStartLineNumber(),
779
+ column: iface.getStartLinePos(),
780
+ snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
781
+ })
782
+ }
783
+ }
784
+
785
+ // Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
786
+ for (const cls of file.getClasses()) {
787
+ if (!cls.isAbstract()) continue
788
+
789
+ const abstractMethods = cls.getMethods().filter(m => m.isAbstract())
790
+ const concreteMethods = cls.getMethods().filter(m => !m.isAbstract())
791
+
792
+ if (abstractMethods.length !== 1 || concreteMethods.length !== 0) continue
793
+
794
+ const clsName = cls.getName() ?? ''
795
+ const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length
796
+
797
+ if (usageCount <= 2) {
798
+ issues.push({
799
+ rule: 'unnecessary-abstraction',
800
+ severity: 'warning',
801
+ message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
802
+ line: cls.getStartLineNumber(),
803
+ column: cls.getStartLinePos(),
804
+ snippet: `abstract class ${clsName}`,
805
+ })
806
+ }
807
+ }
808
+
809
+ return issues
810
+ }
811
+
812
+ function detectNamingInconsistency(file: SourceFile): DriftIssue[] {
813
+ const issues: DriftIssue[] = []
814
+
815
+ const isCamelCase = (name: string) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)
816
+ const isSnakeCase = (name: string) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name)
817
+
818
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
819
+ function checkFunction(fn: any): void {
820
+ const vars = fn.getVariableDeclarations()
821
+ if (vars.length < 3) return // muy pocas vars para ser significativo
822
+
823
+ let camelCount = 0
824
+ let snakeCount = 0
825
+ const snakeExamples: string[] = []
826
+ const camelExamples: string[] = []
827
+
828
+ for (const v of vars) {
829
+ const name = v.getName()
830
+ if (isCamelCase(name)) {
831
+ camelCount++
832
+ if (camelExamples.length < 2) camelExamples.push(name)
833
+ } else if (isSnakeCase(name)) {
834
+ snakeCount++
835
+ if (snakeExamples.length < 2) snakeExamples.push(name)
836
+ }
837
+ }
838
+
839
+ if (camelCount >= 1 && snakeCount >= 1) {
840
+ issues.push({
841
+ rule: 'naming-inconsistency',
842
+ severity: 'warning',
843
+ message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
844
+ line: fn.getStartLineNumber(),
845
+ column: fn.getStartLinePos(),
846
+ snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
847
+ })
848
+ }
849
+ }
850
+
851
+ for (const fn of file.getFunctions()) {
852
+ checkFunction(fn)
853
+ }
854
+
855
+ for (const cls of file.getClasses()) {
856
+ for (const method of cls.getMethods()) {
857
+ checkFunction(method)
858
+ }
859
+ }
860
+
861
+ return issues
862
+ }
863
+
864
+ // ---------------------------------------------------------------------------
865
+ // Score
866
+ // ---------------------------------------------------------------------------
867
+
242
868
  function calculateScore(issues: DriftIssue[]): number {
243
869
  let raw = 0
244
870
  for (const issue of issues) {
@@ -247,6 +873,10 @@ function calculateScore(issues: DriftIssue[]): number {
247
873
  return Math.min(100, raw)
248
874
  }
249
875
 
876
+ // ---------------------------------------------------------------------------
877
+ // Public API
878
+ // ---------------------------------------------------------------------------
879
+
250
880
  export function analyzeFile(file: SourceFile): FileReport {
251
881
  if (isFileIgnored(file)) {
252
882
  return {
@@ -265,6 +895,21 @@ export function analyzeFile(file: SourceFile): FileReport {
265
895
  ...detectAnyAbuse(file),
266
896
  ...detectCatchSwallow(file),
267
897
  ...detectMissingReturnTypes(file),
898
+ // Phase 1: complexity
899
+ ...detectHighComplexity(file),
900
+ ...detectDeepNesting(file),
901
+ ...detectTooManyParams(file),
902
+ ...detectHighCoupling(file),
903
+ ...detectPromiseStyleMix(file),
904
+ // Stubs now implemented
905
+ ...detectMagicNumbers(file),
906
+ ...detectCommentContradiction(file),
907
+ // Phase 5: AI authorship heuristics
908
+ ...detectOverCommented(file),
909
+ ...detectHardcodedConfig(file),
910
+ ...detectInconsistentErrorHandling(file),
911
+ ...detectUnnecessaryAbstraction(file),
912
+ ...detectNamingInconsistency(file),
268
913
  ]
269
914
 
270
915
  return {
@@ -274,7 +919,7 @@ export function analyzeFile(file: SourceFile): FileReport {
274
919
  }
275
920
  }
276
921
 
277
- export function analyzeProject(targetPath: string): FileReport[] {
922
+ export function analyzeProject(targetPath: string, config?: DriftConfig): FileReport[] {
278
923
  const project = new Project({
279
924
  skipAddingFilesFromTsConfig: true,
280
925
  compilerOptions: { allowJs: true },
@@ -294,5 +939,350 @@ export function analyzeProject(targetPath: string): FileReport[] {
294
939
  `!${targetPath}/**/*.spec.*`,
295
940
  ])
296
941
 
297
- return project.getSourceFiles().map(analyzeFile)
942
+ const sourceFiles = project.getSourceFiles()
943
+
944
+ // Phase 1: per-file analysis
945
+ const reports: FileReport[] = sourceFiles.map(analyzeFile)
946
+ const reportByPath = new Map<string, FileReport>()
947
+ for (const r of reports) reportByPath.set(r.path, r)
948
+
949
+ // Phase 2: cross-file analysis — build import graph first
950
+ const allImportedPaths = new Set<string>() // absolute paths of files that are imported
951
+ const allImportedNames = new Map<string, Set<string>>() // file path → set of imported names
952
+ const allLiteralImports = new Set<string>() // raw module specifiers (for unused-dependency)
953
+ const importGraph = new Map<string, Set<string>>() // Phase 3: filePath → Set of imported filePaths
954
+
955
+ for (const sf of sourceFiles) {
956
+ const sfPath = sf.getFilePath()
957
+ for (const decl of sf.getImportDeclarations()) {
958
+ const moduleSpecifier = decl.getModuleSpecifierValue()
959
+ allLiteralImports.add(moduleSpecifier)
960
+
961
+ // Resolve to absolute path for dead-file / unused-export
962
+ const resolved = decl.getModuleSpecifierSourceFile()
963
+ if (resolved) {
964
+ const resolvedPath = resolved.getFilePath()
965
+ allImportedPaths.add(resolvedPath)
966
+
967
+ // Phase 3: populate directed import graph
968
+ if (!importGraph.has(sfPath)) importGraph.set(sfPath, new Set())
969
+ importGraph.get(sfPath)!.add(resolvedPath)
970
+
971
+ // Collect named imports { A, B } and default imports
972
+ const named = decl.getNamedImports().map(n => n.getName())
973
+ const def = decl.getDefaultImport()?.getText()
974
+ const ns = decl.getNamespaceImport()?.getText()
975
+
976
+ if (!allImportedNames.has(resolvedPath)) {
977
+ allImportedNames.set(resolvedPath, new Set())
978
+ }
979
+ const nameSet = allImportedNames.get(resolvedPath)!
980
+ for (const n of named) nameSet.add(n)
981
+ if (def) nameSet.add('default')
982
+ if (ns) nameSet.add('*') // namespace import — counts all exports as used
983
+ }
984
+ }
985
+
986
+ // Also register re-exports: export { X, Y } from './module'
987
+ // These count as "using" X and Y from the source module
988
+ for (const exportDecl of sf.getExportDeclarations()) {
989
+ const reExportedModule = exportDecl.getModuleSpecifierSourceFile()
990
+ if (!reExportedModule) continue
991
+
992
+ const reExportedPath = reExportedModule.getFilePath()
993
+ allImportedPaths.add(reExportedPath)
994
+
995
+ if (!allImportedNames.has(reExportedPath)) {
996
+ allImportedNames.set(reExportedPath, new Set())
997
+ }
998
+ const nameSet = allImportedNames.get(reExportedPath)!
999
+
1000
+ const namedExports = exportDecl.getNamedExports()
1001
+ if (namedExports.length === 0) {
1002
+ // export * from './module' — namespace re-export, all names used
1003
+ nameSet.add('*')
1004
+ } else {
1005
+ for (const ne of namedExports) nameSet.add(ne.getName())
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ // Detect unused-export and dead-file per source file
1011
+ for (const sf of sourceFiles) {
1012
+ const sfPath = sf.getFilePath()
1013
+ const report = reportByPath.get(sfPath)
1014
+ if (!report) continue
1015
+
1016
+ // dead-file: file is never imported by anyone
1017
+ // Exclude entry-point candidates: index.ts, main.ts, cli.ts, app.ts, bin files
1018
+ const basename = path.basename(sfPath)
1019
+ const isBinFile = sfPath.replace(/\\/g, '/').includes('/bin/')
1020
+ const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile
1021
+ if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
1022
+ const issue: DriftIssue = {
1023
+ rule: 'dead-file',
1024
+ severity: RULE_WEIGHTS['dead-file'].severity,
1025
+ message: 'File is never imported — may be dead code',
1026
+ line: 1,
1027
+ column: 1,
1028
+ snippet: basename,
1029
+ }
1030
+ report.issues.push(issue)
1031
+ report.score = calculateScore(report.issues)
1032
+ }
1033
+
1034
+ // unused-export: named exports not imported anywhere
1035
+ // Skip barrel files (index.ts) — their entire surface is the public API
1036
+ const isBarrel = /^index\.(ts|tsx|js|jsx)$/.test(basename)
1037
+ const importedNamesForFile = allImportedNames.get(sfPath)
1038
+ const hasNamespaceImport = importedNamesForFile?.has('*') ?? false
1039
+ if (!isBarrel && !hasNamespaceImport) {
1040
+ for (const exportDecl of sf.getExportDeclarations()) {
1041
+ for (const namedExport of exportDecl.getNamedExports()) {
1042
+ const name = namedExport.getName()
1043
+ if (!importedNamesForFile?.has(name)) {
1044
+ const line = namedExport.getStartLineNumber()
1045
+ const issue: DriftIssue = {
1046
+ rule: 'unused-export',
1047
+ severity: RULE_WEIGHTS['unused-export'].severity,
1048
+ message: `'${name}' is exported but never imported`,
1049
+ line,
1050
+ column: 1,
1051
+ snippet: namedExport.getText().slice(0, 80),
1052
+ }
1053
+ report.issues.push(issue)
1054
+ report.score = calculateScore(report.issues)
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ // Also check inline export declarations (export function foo, export const bar)
1060
+ for (const exportSymbol of sf.getExportedDeclarations()) {
1061
+ const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]]
1062
+ if (exportName === 'default') continue
1063
+ if (importedNamesForFile?.has(exportName)) continue
1064
+
1065
+ for (const decl of declarations) {
1066
+ // Skip if this is a re-export from another file
1067
+ if (decl.getSourceFile().getFilePath() !== sfPath) continue
1068
+
1069
+ const line = decl.getStartLineNumber()
1070
+ const issue: DriftIssue = {
1071
+ rule: 'unused-export',
1072
+ severity: RULE_WEIGHTS['unused-export'].severity,
1073
+ message: `'${exportName}' is exported but never imported`,
1074
+ line,
1075
+ column: 1,
1076
+ snippet: decl.getText().split('\n')[0].slice(0, 80),
1077
+ }
1078
+ report.issues.push(issue)
1079
+ report.score = calculateScore(report.issues)
1080
+ break // one issue per export name is enough
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ // Detect unused-dependency: packages in package.json never imported
1087
+ const pkgPath = path.join(targetPath, 'package.json')
1088
+ if (fs.existsSync(pkgPath)) {
1089
+ let pkg: Record<string, unknown>
1090
+ try {
1091
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
1092
+ } catch {
1093
+ pkg = {}
1094
+ }
1095
+
1096
+ const deps = {
1097
+ ...((pkg.dependencies as Record<string, string>) ?? {}),
1098
+ }
1099
+
1100
+ const unusedDeps: string[] = []
1101
+ for (const depName of Object.keys(deps)) {
1102
+ // Skip type-only packages (@types/*)
1103
+ if (depName.startsWith('@types/')) continue
1104
+
1105
+ // A dependency is "used" if any import specifier starts with the package name
1106
+ // (handles sub-paths like 'lodash/merge', 'date-fns/format', etc.)
1107
+ const isUsed = [...allLiteralImports].some(
1108
+ imp => imp === depName || imp.startsWith(depName + '/')
1109
+ )
1110
+ if (!isUsed) unusedDeps.push(depName)
1111
+ }
1112
+
1113
+ if (unusedDeps.length > 0) {
1114
+ const pkgIssues: DriftIssue[] = unusedDeps.map(dep => ({
1115
+ rule: 'unused-dependency',
1116
+ severity: RULE_WEIGHTS['unused-dependency'].severity,
1117
+ message: `'${dep}' is in package.json but never imported`,
1118
+ line: 1,
1119
+ column: 1,
1120
+ snippet: `"${dep}"`,
1121
+ }))
1122
+
1123
+ reports.push({
1124
+ path: pkgPath,
1125
+ issues: pkgIssues,
1126
+ score: calculateScore(pkgIssues),
1127
+ })
1128
+ }
1129
+ }
1130
+
1131
+ // Phase 3: circular-dependency — DFS cycle detection
1132
+ function findCycles(graph: Map<string, Set<string>>): Array<string[]> {
1133
+ const visited = new Set<string>()
1134
+ const inStack = new Set<string>()
1135
+ const cycles: Array<string[]> = []
1136
+
1137
+ function dfs(node: string, stack: string[]): void {
1138
+ visited.add(node)
1139
+ inStack.add(node)
1140
+ stack.push(node)
1141
+
1142
+ for (const neighbor of graph.get(node) ?? []) {
1143
+ if (!visited.has(neighbor)) {
1144
+ dfs(neighbor, stack)
1145
+ } else if (inStack.has(neighbor)) {
1146
+ // Found a cycle — extract the cycle portion from the stack
1147
+ const cycleStart = stack.indexOf(neighbor)
1148
+ cycles.push(stack.slice(cycleStart))
1149
+ }
1150
+ }
1151
+
1152
+ stack.pop()
1153
+ inStack.delete(node)
1154
+ }
1155
+
1156
+ for (const node of graph.keys()) {
1157
+ if (!visited.has(node)) {
1158
+ dfs(node, [])
1159
+ }
1160
+ }
1161
+
1162
+ return cycles
1163
+ }
1164
+
1165
+ const cycles = findCycles(importGraph)
1166
+
1167
+ // De-duplicate: each unique cycle (regardless of starting node) reported once per file
1168
+ const reportedCycleKeys = new Set<string>()
1169
+
1170
+ for (const cycle of cycles) {
1171
+ const cycleKey = [...cycle].sort().join('|')
1172
+ if (reportedCycleKeys.has(cycleKey)) continue
1173
+ reportedCycleKeys.add(cycleKey)
1174
+
1175
+ // Report on the first file in the cycle
1176
+ const firstFile = cycle[0]
1177
+ const report = reportByPath.get(firstFile)
1178
+ if (!report) continue
1179
+
1180
+ const cycleDisplay = cycle
1181
+ .map(p => path.basename(p))
1182
+ .concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
1183
+ .join(' → ')
1184
+
1185
+ const issue: DriftIssue = {
1186
+ rule: 'circular-dependency',
1187
+ severity: RULE_WEIGHTS['circular-dependency'].severity,
1188
+ message: `Circular dependency detected: ${cycleDisplay}`,
1189
+ line: 1,
1190
+ column: 1,
1191
+ snippet: cycleDisplay,
1192
+ }
1193
+ report.issues.push(issue)
1194
+ report.score = calculateScore(report.issues)
1195
+ }
1196
+
1197
+ // ── Phase 3b: layer-violation ──────────────────────────────────────────
1198
+ if (config?.layers && config.layers.length > 0) {
1199
+ const { layers } = config
1200
+
1201
+ function getLayer(filePath: string): LayerDefinition | undefined {
1202
+ const rel = filePath.replace(/\\/g, '/')
1203
+ return layers.find(layer =>
1204
+ layer.patterns.some(pattern => {
1205
+ const regexStr = pattern
1206
+ .replace(/\\/g, '/')
1207
+ .replace(/[.+^${}()|[\]]/g, '\\$&')
1208
+ .replace(/\*\*/g, '###DOUBLESTAR###')
1209
+ .replace(/\*/g, '[^/]*')
1210
+ .replace(/###DOUBLESTAR###/g, '.*')
1211
+ return new RegExp(`^${regexStr}`).test(rel)
1212
+ })
1213
+ )
1214
+ }
1215
+
1216
+ for (const [filePath, imports] of importGraph.entries()) {
1217
+ const fileLayer = getLayer(filePath)
1218
+ if (!fileLayer) continue
1219
+
1220
+ for (const importedPath of imports) {
1221
+ const importedLayer = getLayer(importedPath)
1222
+ if (!importedLayer) continue
1223
+ if (importedLayer.name === fileLayer.name) continue
1224
+
1225
+ if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
1226
+ const report = reportByPath.get(filePath)
1227
+ if (report) {
1228
+ const weight = RULE_WEIGHTS['layer-violation']?.weight ?? 5
1229
+ report.issues.push({
1230
+ rule: 'layer-violation',
1231
+ severity: 'error',
1232
+ message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
1233
+ line: 1,
1234
+ column: 1,
1235
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
1236
+ })
1237
+ report.score = Math.min(100, report.score + weight)
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ // ── Phase 3c: cross-boundary-import ────────────────────────────────────
1245
+ if (config?.modules && config.modules.length > 0) {
1246
+ const { modules } = config
1247
+
1248
+ function getModule(filePath: string): ModuleBoundary | undefined {
1249
+ const rel = filePath.replace(/\\/g, '/')
1250
+ return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')))
1251
+ }
1252
+
1253
+ for (const [filePath, imports] of importGraph.entries()) {
1254
+ const fileModule = getModule(filePath)
1255
+ if (!fileModule) continue
1256
+
1257
+ for (const importedPath of imports) {
1258
+ const importedModule = getModule(importedPath)
1259
+ if (!importedModule) continue
1260
+ if (importedModule.name === fileModule.name) continue
1261
+
1262
+ const allowedImports = fileModule.allowedExternalImports ?? []
1263
+ const relImported = importedPath.replace(/\\/g, '/')
1264
+ const isAllowed = allowedImports.some(allowed =>
1265
+ relImported.startsWith(allowed.replace(/\\/g, '/'))
1266
+ )
1267
+
1268
+ if (!isAllowed) {
1269
+ const report = reportByPath.get(filePath)
1270
+ if (report) {
1271
+ const weight = RULE_WEIGHTS['cross-boundary-import']?.weight ?? 5
1272
+ report.issues.push({
1273
+ rule: 'cross-boundary-import',
1274
+ severity: 'warning',
1275
+ message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
1276
+ line: 1,
1277
+ column: 1,
1278
+ snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
1279
+ })
1280
+ report.score = Math.min(100, report.score + weight)
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ return reports
298
1288
  }