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