@eduardbar/drift 0.4.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/CHANGELOG.md +9 -0
- package/README.md +5 -0
- package/dist/analyzer.js +274 -0
- package/dist/printer.js +20 -0
- package/package.json +1 -1
- package/src/analyzer.ts +305 -0
- package/src/printer.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Phase 5: AI authorship heuristics** — 5 new rules that detect patterns AI code generators produce
|
|
15
|
+
- `over-commented` (info, weight 4): functions where comment density ≥ 40% — AI over-documents the obvious
|
|
16
|
+
- `hardcoded-config` (warning, weight 10): hardcoded URLs, IPs, or connection strings instead of env vars
|
|
17
|
+
- `inconsistent-error-handling` (warning, weight 8): mixed `try/catch` and `.catch()` patterns in the same file
|
|
18
|
+
- `unnecessary-abstraction` (warning, weight 7): single-method interfaces or abstract classes never reused
|
|
19
|
+
- `naming-inconsistency` (warning, weight 6): mixed camelCase and snake_case identifiers in the same scope
|
|
20
|
+
|
|
12
21
|
---
|
|
13
22
|
|
|
14
23
|
## [0.4.0] — 2026-02-23
|
package/README.md
CHANGED
|
@@ -151,6 +151,11 @@ npx @eduardbar/drift scan ./src --fix
|
|
|
151
151
|
| `magic-number` | info | Numeric literals used directly in logic — extract to named constants |
|
|
152
152
|
| `layer-violation` | error | Layer imports a layer it's not allowed to (requires `drift.config.ts`) |
|
|
153
153
|
| `cross-boundary-import` | warning | Module imports from another module outside allowed boundaries (requires `drift.config.ts`) |
|
|
154
|
+
| `over-commented` | info | Functions where comments exceed 40% of lines — AI over-documents the obvious |
|
|
155
|
+
| `hardcoded-config` | warning | Hardcoded URLs, IPs, or connection strings — AI skips environment variables |
|
|
156
|
+
| `inconsistent-error-handling` | warning | Mixed `try/catch` and `.catch()` in the same file — AI combines styles randomly |
|
|
157
|
+
| `unnecessary-abstraction` | warning | Single-method interfaces or abstract classes with no reuse — AI over-engineers |
|
|
158
|
+
| `naming-inconsistency` | warning | Mixed camelCase and snake_case in the same scope — AI forgets project conventions |
|
|
154
159
|
|
|
155
160
|
---
|
|
156
161
|
|
package/dist/analyzer.js
CHANGED
|
@@ -28,6 +28,12 @@ const RULE_WEIGHTS = {
|
|
|
28
28
|
// Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
|
|
29
29
|
'layer-violation': { severity: 'error', weight: 16 },
|
|
30
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 },
|
|
31
37
|
};
|
|
32
38
|
function hasIgnoreComment(file, line) {
|
|
33
39
|
const lines = file.getFullText().split('\n');
|
|
@@ -509,6 +515,268 @@ function detectCommentContradiction(file) {
|
|
|
509
515
|
return issues;
|
|
510
516
|
}
|
|
511
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
|
+
// ---------------------------------------------------------------------------
|
|
512
780
|
// Score
|
|
513
781
|
// ---------------------------------------------------------------------------
|
|
514
782
|
function calculateScore(issues) {
|
|
@@ -547,6 +815,12 @@ export function analyzeFile(file) {
|
|
|
547
815
|
// Stubs now implemented
|
|
548
816
|
...detectMagicNumbers(file),
|
|
549
817
|
...detectCommentContradiction(file),
|
|
818
|
+
// Phase 5: AI authorship heuristics
|
|
819
|
+
...detectOverCommented(file),
|
|
820
|
+
...detectHardcodedConfig(file),
|
|
821
|
+
...detectInconsistentErrorHandling(file),
|
|
822
|
+
...detectUnnecessaryAbstraction(file),
|
|
823
|
+
...detectNamingInconsistency(file),
|
|
550
824
|
];
|
|
551
825
|
return {
|
|
552
826
|
path: file.getFilePath(),
|
package/dist/printer.js
CHANGED
|
@@ -91,6 +91,26 @@ function formatFixSuggestion(issue) {
|
|
|
91
91
|
'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
|
|
92
92
|
'Consider using dependency injection or an event bus to decouple the modules',
|
|
93
93
|
],
|
|
94
|
+
'over-commented': [
|
|
95
|
+
'Remove comments that restate what the code already expresses clearly',
|
|
96
|
+
'Keep only comments that explain WHY, not WHAT — prefer self-documenting names',
|
|
97
|
+
],
|
|
98
|
+
'hardcoded-config': [
|
|
99
|
+
'Move the value to an environment variable: process.env.YOUR_VAR',
|
|
100
|
+
'Or extract it to a config file / constants module imported at the top',
|
|
101
|
+
],
|
|
102
|
+
'inconsistent-error-handling': [
|
|
103
|
+
'Pick one style (async/await + try/catch is preferred) and apply it consistently',
|
|
104
|
+
'Avoid mixing .then()/.catch() with await in the same file',
|
|
105
|
+
],
|
|
106
|
+
'unnecessary-abstraction': [
|
|
107
|
+
'Inline the abstraction if it has only one implementation and is never reused',
|
|
108
|
+
'Or document why the extension point exists (e.g., future plugin system)',
|
|
109
|
+
],
|
|
110
|
+
'naming-inconsistency': [
|
|
111
|
+
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
|
|
112
|
+
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
|
|
113
|
+
],
|
|
94
114
|
};
|
|
95
115
|
return suggestions[issue.rule] ?? ['Review and fix manually'];
|
|
96
116
|
}
|
package/package.json
CHANGED
package/src/analyzer.ts
CHANGED
|
@@ -39,6 +39,12 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
|
|
|
39
39
|
// Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
|
|
40
40
|
'layer-violation': { severity: 'error', weight: 16 },
|
|
41
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 },
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
@@ -562,6 +568,299 @@ function detectCommentContradiction(file: SourceFile): DriftIssue[] {
|
|
|
562
568
|
return issues
|
|
563
569
|
}
|
|
564
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
|
+
|
|
565
864
|
// ---------------------------------------------------------------------------
|
|
566
865
|
// Score
|
|
567
866
|
// ---------------------------------------------------------------------------
|
|
@@ -605,6 +904,12 @@ export function analyzeFile(file: SourceFile): FileReport {
|
|
|
605
904
|
// Stubs now implemented
|
|
606
905
|
...detectMagicNumbers(file),
|
|
607
906
|
...detectCommentContradiction(file),
|
|
907
|
+
// Phase 5: AI authorship heuristics
|
|
908
|
+
...detectOverCommented(file),
|
|
909
|
+
...detectHardcodedConfig(file),
|
|
910
|
+
...detectInconsistentErrorHandling(file),
|
|
911
|
+
...detectUnnecessaryAbstraction(file),
|
|
912
|
+
...detectNamingInconsistency(file),
|
|
608
913
|
]
|
|
609
914
|
|
|
610
915
|
return {
|
package/src/printer.ts
CHANGED
|
@@ -93,6 +93,26 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
93
93
|
'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
|
|
94
94
|
'Consider using dependency injection or an event bus to decouple the modules',
|
|
95
95
|
],
|
|
96
|
+
'over-commented': [
|
|
97
|
+
'Remove comments that restate what the code already expresses clearly',
|
|
98
|
+
'Keep only comments that explain WHY, not WHAT — prefer self-documenting names',
|
|
99
|
+
],
|
|
100
|
+
'hardcoded-config': [
|
|
101
|
+
'Move the value to an environment variable: process.env.YOUR_VAR',
|
|
102
|
+
'Or extract it to a config file / constants module imported at the top',
|
|
103
|
+
],
|
|
104
|
+
'inconsistent-error-handling': [
|
|
105
|
+
'Pick one style (async/await + try/catch is preferred) and apply it consistently',
|
|
106
|
+
'Avoid mixing .then()/.catch() with await in the same file',
|
|
107
|
+
],
|
|
108
|
+
'unnecessary-abstraction': [
|
|
109
|
+
'Inline the abstraction if it has only one implementation and is never reused',
|
|
110
|
+
'Or document why the extension point exists (e.g., future plugin system)',
|
|
111
|
+
],
|
|
112
|
+
'naming-inconsistency': [
|
|
113
|
+
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
|
|
114
|
+
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
|
|
115
|
+
],
|
|
96
116
|
}
|
|
97
117
|
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
98
118
|
}
|