@eduardbar/drift 0.4.0 → 0.6.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
@@ -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/badge.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type {} from './types.js'
2
+
3
+ const LEFT_WIDTH = 47
4
+ const CHAR_WIDTH = 7
5
+ const PADDING = 16
6
+
7
+ function scoreColor(score: number): string {
8
+ if (score < 20) return '#4c1'
9
+ if (score < 45) return '#dfb317'
10
+ if (score < 70) return '#fe7d37'
11
+ return '#e05d44'
12
+ }
13
+
14
+ function scoreLabel(score: number): string {
15
+ if (score < 20) return 'LOW'
16
+ if (score < 45) return 'MODERATE'
17
+ if (score < 70) return 'HIGH'
18
+ return 'CRITICAL'
19
+ }
20
+
21
+ function rightWidth(text: string): number {
22
+ return text.length * CHAR_WIDTH + PADDING
23
+ }
24
+
25
+ export function generateBadge(score: number): string {
26
+ const valueText = `${score} ${scoreLabel(score)}`
27
+ const color = scoreColor(score)
28
+
29
+ const rWidth = rightWidth(valueText)
30
+ const totalWidth = LEFT_WIDTH + rWidth
31
+
32
+ const leftCenterX = LEFT_WIDTH / 2
33
+ const rightCenterX = LEFT_WIDTH + rWidth / 2
34
+
35
+ // shields.io pattern: font-size="110" + scale(.1) = effective 11px
36
+ // all X/Y coords are ×10
37
+ const leftTextWidth = (LEFT_WIDTH - 10) * 10
38
+ const rightTextWidth = (rWidth - PADDING) * 10
39
+
40
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
41
+ <linearGradient id="s" x2="0" y2="100%">
42
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
43
+ <stop offset="1" stop-opacity=".1"/>
44
+ </linearGradient>
45
+ <clipPath id="r">
46
+ <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
47
+ </clipPath>
48
+ <g clip-path="url(#r)">
49
+ <rect width="${LEFT_WIDTH}" height="20" fill="#555"/>
50
+ <rect x="${LEFT_WIDTH}" width="${rWidth}" height="20" fill="${color}"/>
51
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
52
+ </g>
53
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
54
+ <text x="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
55
+ <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
56
+ <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
57
+ <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
58
+ </g>
59
+ </svg>`
60
+ }
package/src/ci.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { relative } from 'node:path'
3
+ import type { DriftReport } from './types.js'
4
+
5
+ function encodeMessage(msg: string): string {
6
+ return msg
7
+ .replace(/%/g, '%25')
8
+ .replace(/\r/g, '%0D')
9
+ .replace(/\n/g, '%0A')
10
+ .replace(/:/g, '%3A')
11
+ .replace(/,/g, '%2C')
12
+ }
13
+
14
+ function severityToAnnotation(s: string): 'error' | 'warning' | 'notice' {
15
+ if (s === 'error') return 'error'
16
+ if (s === 'warning') return 'warning'
17
+ return 'notice'
18
+ }
19
+
20
+ function scoreLabel(score: number): string {
21
+ if (score >= 80) return 'A'
22
+ if (score >= 60) return 'B'
23
+ if (score >= 40) return 'C'
24
+ if (score >= 20) return 'D'
25
+ return 'F'
26
+ }
27
+
28
+ export function emitCIAnnotations(report: DriftReport): void {
29
+ for (const file of report.files) {
30
+ for (const issue of file.issues) {
31
+ const level = severityToAnnotation(issue.severity)
32
+ const relPath = relative(process.cwd(), file.path).replace(/\\/g, '/')
33
+ const msg = encodeMessage(`[drift/${issue.rule}] ${issue.message}`)
34
+ const line = issue.line ?? 1
35
+ const col = issue.column ?? 1
36
+ process.stdout.write(`::${level} file=${relPath},line=${line},col=${col}::${msg}\n`)
37
+ }
38
+ }
39
+ }
40
+
41
+ export function printCISummary(report: DriftReport): void {
42
+ const summaryPath = process.env['GITHUB_STEP_SUMMARY']
43
+ if (!summaryPath) return
44
+
45
+ const score = report.totalScore
46
+ const grade = scoreLabel(score)
47
+
48
+ let errors = 0
49
+ let warnings = 0
50
+ let info = 0
51
+
52
+ for (const file of report.files) {
53
+ for (const issue of file.issues) {
54
+ if (issue.severity === 'error') errors++
55
+ else if (issue.severity === 'warning') warnings++
56
+ else info++
57
+ }
58
+ }
59
+
60
+ const sorted = [...report.files]
61
+ .sort((a, b) => b.issues.length - a.issues.length)
62
+ .slice(0, 10)
63
+
64
+ const rows = sorted
65
+ .map((f) => {
66
+ const relPath = relative(process.cwd(), f.path).replace(/\\/g, '/')
67
+ return `| ${relPath} | ${f.score} | ${f.issues.length} |`
68
+ })
69
+ .join('\n')
70
+
71
+ const md = [
72
+ '## drift scan results',
73
+ '',
74
+ `**Score:** ${score}/100 — Grade **${grade}**`,
75
+ '',
76
+ '### Top files by issue count',
77
+ '',
78
+ '| File | Score | Issues |',
79
+ '|------|-------|--------|',
80
+ rows,
81
+ '',
82
+ `**Total issues:** ${errors} errors, ${warnings} warnings, ${info} info`,
83
+ '',
84
+ ].join('\n')
85
+
86
+ writeFileSync(summaryPath, md, { flag: 'a' })
87
+ }
package/src/cli.ts CHANGED
@@ -8,13 +8,16 @@ import { printConsole, printDiff } from './printer.js'
8
8
  import { loadConfig } from './config.js'
9
9
  import { extractFilesAtRef, cleanupTempDir } from './git.js'
10
10
  import { computeDiff } from './diff.js'
11
+ import { generateHtmlReport } from './report.js'
12
+ import { generateBadge } from './badge.js'
13
+ import { emitCIAnnotations, printCISummary } from './ci.js'
11
14
 
12
15
  const program = new Command()
13
16
 
14
17
  program
15
18
  .name('drift')
16
19
  .description('Detect silent technical debt left by AI-generated code')
17
- .version('0.1.0')
20
+ .version('0.6.0')
18
21
 
19
22
  program
20
23
  .command('scan [path]', { isDefault: true })
@@ -109,4 +112,55 @@ program
109
112
  }
110
113
  })
111
114
 
115
+ program
116
+ .command('report [path]')
117
+ .description('Generate a self-contained HTML report')
118
+ .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
119
+ .action(async (targetPath: string | undefined, options: { output: string }) => {
120
+ const resolvedPath = resolve(targetPath ?? '.')
121
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`)
122
+ const config = await loadConfig(resolvedPath)
123
+ const files = analyzeProject(resolvedPath, config)
124
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
125
+ const report = buildReport(resolvedPath, files)
126
+ const html = generateHtmlReport(report)
127
+ const outPath = resolve(options.output)
128
+ writeFileSync(outPath, html, 'utf8')
129
+ process.stderr.write(` Report saved to ${outPath}\n\n`)
130
+ })
131
+
132
+ program
133
+ .command('badge [path]')
134
+ .description('Generate a badge.svg with the current drift score')
135
+ .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
136
+ .action(async (targetPath: string | undefined, options: { output: string }) => {
137
+ const resolvedPath = resolve(targetPath ?? '.')
138
+ process.stderr.write(`\nScanning ${resolvedPath}...\n`)
139
+ const config = await loadConfig(resolvedPath)
140
+ const files = analyzeProject(resolvedPath, config)
141
+ const report = buildReport(resolvedPath, files)
142
+ const svg = generateBadge(report.totalScore)
143
+ const outPath = resolve(options.output)
144
+ writeFileSync(outPath, svg, 'utf8')
145
+ process.stderr.write(` Badge saved to ${outPath}\n`)
146
+ process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
147
+ })
148
+
149
+ program
150
+ .command('ci [path]')
151
+ .description('Emit GitHub Actions annotations and step summary')
152
+ .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
153
+ .action(async (targetPath: string | undefined, options: { minScore: string }) => {
154
+ const resolvedPath = resolve(targetPath ?? '.')
155
+ const config = await loadConfig(resolvedPath)
156
+ const files = analyzeProject(resolvedPath, config)
157
+ const report = buildReport(resolvedPath, files)
158
+ emitCIAnnotations(report)
159
+ printCISummary(report)
160
+ const minScore = Number(options.minScore)
161
+ if (minScore > 0 && report.totalScore > minScore) {
162
+ process.exit(1)
163
+ }
164
+ })
165
+
112
166
  program.parse()
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
  }