@eduardbar/drift 0.6.0 → 0.7.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.
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "eslint-plugin-drift",
3
+ "version": "0.1.0",
4
+ "description": "ESLint plugin that exposes drift's technical debt rules for ESLint 9 flat config",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "keywords": [
16
+ "eslint",
17
+ "eslintplugin",
18
+ "eslint-plugin",
19
+ "drift",
20
+ "technical-debt",
21
+ "ai",
22
+ "typescript"
23
+ ],
24
+ "author": "eduardbar",
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/eduardbar/drift#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/eduardbar/drift.git"
30
+ },
31
+ "peerDependencies": {
32
+ "eslint": ">=9.0.0"
33
+ },
34
+ "dependencies": {
35
+ "@eduardbar/drift": "^0.7.0",
36
+ "ts-morph": "^27.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.0.0",
40
+ "eslint": "^9.0.0",
41
+ "typescript": "^5.9.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc",
45
+ "prepublishOnly": "npm run build"
46
+ }
47
+ }
@@ -0,0 +1,170 @@
1
+ import { analyzeFile } from '@eduardbar/drift'
2
+ import type { FileReport } from '@eduardbar/drift'
3
+ import { Project } from 'ts-morph'
4
+ import type { Rule } from 'eslint'
5
+
6
+ // Tipos auxiliares
7
+ type RuleType = 'problem' | 'suggestion' | 'layout'
8
+
9
+ // Mapeo rule → descripción legible
10
+ const RULE_DOCS: Record<string, { type: RuleType; description: string }> = {
11
+ 'large-file': { type: 'problem', description: 'Files over 300 lines — AI dumps everything into one place' },
12
+ 'large-function': { type: 'problem', description: 'Functions over 50 lines — AI avoids splitting logic' },
13
+ 'duplicate-function-name': { type: 'problem', description: 'Near-identical function names — AI regenerates instead of reusing' },
14
+ 'high-complexity': { type: 'problem', description: 'Cyclomatic complexity > 10 — AI generates correct code, not simple code' },
15
+ 'circular-dependency': { type: 'problem', description: 'Circular import chains between modules' },
16
+ 'layer-violation': { type: 'problem', description: 'Import from a prohibited architectural layer (requires drift.config.ts)' },
17
+ 'debug-leftover': { type: 'suggestion', description: 'console.log, TODO, FIXME comments left in production code' },
18
+ 'dead-code': { type: 'suggestion', description: 'Unused imports — AI imports more than it uses' },
19
+ 'any-abuse': { type: 'suggestion', description: 'Explicit any type — AI defaults to any when it cannot infer' },
20
+ 'catch-swallow': { type: 'suggestion', description: 'Empty catch blocks — AI makes code not throw' },
21
+ 'comment-contradiction': { type: 'suggestion', description: 'Comments that restate what the code already says' },
22
+ 'deep-nesting': { type: 'suggestion', description: 'Nesting depth > 3 — unreadable control flow' },
23
+ 'too-many-params': { type: 'suggestion', description: 'Functions with more than 4 parameters' },
24
+ 'high-coupling': { type: 'suggestion', description: 'Files importing from more than 10 modules' },
25
+ 'promise-style-mix': { type: 'suggestion', description: 'async/await and .then() mixed in the same file' },
26
+ 'unused-export': { type: 'suggestion', description: 'Named exports never imported anywhere in the project' },
27
+ 'dead-file': { type: 'suggestion', description: 'Files never imported by any other file' },
28
+ 'unused-dependency': { type: 'suggestion', description: 'Packages in package.json never imported in source code' },
29
+ 'cross-boundary-import': { type: 'suggestion', description: 'Module imports from another module outside allowed boundaries' },
30
+ 'hardcoded-config': { type: 'suggestion', description: 'Hardcoded URLs, IPs, or connection strings — AI skips env vars' },
31
+ 'inconsistent-error-handling': { type: 'suggestion', description: 'Mixed try/catch and .catch() patterns in the same file' },
32
+ 'unnecessary-abstraction': { type: 'suggestion', description: 'Single-method interfaces or abstract classes never reused' },
33
+ 'naming-inconsistency': { type: 'suggestion', description: 'Mixed camelCase and snake_case in the same scope' },
34
+ 'no-return-type': { type: 'suggestion', description: 'Missing explicit return types on functions' },
35
+ 'magic-number': { type: 'suggestion', description: 'Numeric literals used directly in logic' },
36
+ 'over-commented': { type: 'suggestion', description: 'Functions where comments exceed 40% of lines' },
37
+ }
38
+
39
+ // ts-morph Project singleton — reutilizado para todos los archivos en un lint run
40
+ const project = new Project({
41
+ skipAddingFilesFromTsConfig: true,
42
+ compilerOptions: { allowJs: true },
43
+ })
44
+
45
+ // Cache de FileReport por filename para no llamar analyzeFile 26 veces por archivo
46
+ const cache = new Map<string, FileReport>()
47
+
48
+ function getFileReport(filename: string): FileReport {
49
+ if (cache.has(filename)) return cache.get(filename)!
50
+
51
+ // Obtener o agregar el SourceFile al Project
52
+ let sourceFile = project.getSourceFile(filename)
53
+ if (!sourceFile) {
54
+ sourceFile = project.addSourceFileAtPath(filename)
55
+ }
56
+
57
+ const report = analyzeFile(sourceFile)
58
+ cache.set(filename, report)
59
+
60
+ // Evitar memory leak en watch mode — mantener máximo 100 entradas
61
+ if (cache.size > 100) {
62
+ const firstKey = cache.keys().next().value
63
+ if (firstKey !== undefined) cache.delete(firstKey)
64
+ }
65
+
66
+ return report
67
+ }
68
+
69
+ // Crea una regla ESLint para una regla drift específica
70
+ function createRule(ruleName: string): Rule.RuleModule {
71
+ const doc = RULE_DOCS[ruleName] ?? { type: 'suggestion' as RuleType, description: ruleName }
72
+
73
+ return {
74
+ meta: {
75
+ type: doc.type,
76
+ docs: {
77
+ description: doc.description,
78
+ url: `https://github.com/eduardbar/drift#${ruleName}`,
79
+ },
80
+ schema: [],
81
+ messages: {
82
+ issue: '{{ message }}',
83
+ },
84
+ },
85
+ create(context) {
86
+ return {
87
+ 'Program:exit'() {
88
+ const filename = context.filename
89
+ if (!filename.endsWith('.ts') && !filename.endsWith('.tsx')) return
90
+ if (filename.includes('node_modules')) return
91
+
92
+ try {
93
+ const fileReport = getFileReport(filename)
94
+ for (const issue of fileReport.issues) {
95
+ if (issue.rule !== ruleName) continue
96
+ const col = issue.column > 0 ? issue.column - 1 : 0
97
+ context.report({
98
+ loc: {
99
+ start: { line: issue.line, column: col },
100
+ end: { line: issue.line, column: col + 1 },
101
+ },
102
+ messageId: 'issue',
103
+ data: { message: issue.message },
104
+ })
105
+ }
106
+ } catch {
107
+ // Archivo no parseable por ts-morph — silenciar
108
+ }
109
+ },
110
+ }
111
+ },
112
+ }
113
+ }
114
+
115
+ // Objeto con todas las reglas
116
+ const rules: Record<string, Rule.RuleModule> = Object.fromEntries(
117
+ Object.keys(RULE_DOCS).map(name => [name, createRule(name)])
118
+ )
119
+
120
+ // Plugin object
121
+ const plugin = {
122
+ meta: {
123
+ name: 'eslint-plugin-drift',
124
+ version: '0.1.0',
125
+ },
126
+ rules,
127
+ configs: {} as Record<string, unknown>,
128
+ }
129
+
130
+ // Config recommended — todas las reglas en su severidad canónica de drift
131
+ Object.assign(plugin.configs, {
132
+ recommended: [
133
+ {
134
+ plugins: { drift: plugin },
135
+ rules: {
136
+ // errors
137
+ 'drift/large-file': 'error',
138
+ 'drift/large-function': 'error',
139
+ 'drift/duplicate-function-name': 'error',
140
+ 'drift/high-complexity': 'error',
141
+ 'drift/circular-dependency': 'error',
142
+ 'drift/layer-violation': 'error',
143
+ // warnings
144
+ 'drift/debug-leftover': 'warn',
145
+ 'drift/dead-code': 'warn',
146
+ 'drift/any-abuse': 'warn',
147
+ 'drift/catch-swallow': 'warn',
148
+ 'drift/comment-contradiction': 'warn',
149
+ 'drift/deep-nesting': 'warn',
150
+ 'drift/too-many-params': 'warn',
151
+ 'drift/high-coupling': 'warn',
152
+ 'drift/promise-style-mix': 'warn',
153
+ 'drift/unused-export': 'warn',
154
+ 'drift/dead-file': 'warn',
155
+ 'drift/unused-dependency': 'warn',
156
+ 'drift/cross-boundary-import': 'warn',
157
+ 'drift/hardcoded-config': 'warn',
158
+ 'drift/inconsistent-error-handling': 'warn',
159
+ 'drift/unnecessary-abstraction': 'warn',
160
+ 'drift/naming-inconsistency': 'warn',
161
+ // info → ESLint no tiene "info", mapeamos a "warn"
162
+ 'drift/no-return-type': 'warn',
163
+ 'drift/magic-number': 'warn',
164
+ 'drift/over-commented': 'warn',
165
+ },
166
+ },
167
+ ],
168
+ })
169
+
170
+ export default plugin
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
package/src/analyzer.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
14
14
 
15
15
  // Rules and their drift score weight
16
- const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
16
+ export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
17
17
  'large-file': { severity: 'error', weight: 20 },
18
18
  'large-function': { severity: 'error', weight: 15 },
19
19
  'debug-leftover': { severity: 'warning', weight: 10 },
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { analyzeProject, analyzeFile } from './analyzer.js'
1
+ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
3
  export { computeDiff } from './diff.js'
4
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js'
4
+ export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js'
package/src/printer.ts CHANGED
@@ -118,66 +118,66 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
118
118
  }
119
119
 
120
120
  export function printConsole(report: DriftReport, options?: { showFix?: boolean }): void {
121
- const sep = kleur.gray(' ' + '─'.repeat(50))
122
-
123
- console.log()
124
- console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
125
- console.log(sep)
126
- console.log()
127
-
128
- const grade = scoreToGrade(report.totalScore)
129
- const scoreColor = report.totalScore === 0
130
- ? kleur.green
131
- : report.totalScore < 45
132
- ? kleur.yellow
133
- : kleur.red
134
-
135
- const bar = scoreBar(report.totalScore)
136
- console.log(
137
- ` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
138
- )
139
-
140
- const cleanFiles = report.totalFiles - report.files.length
141
- console.log(
142
- kleur.gray(
143
- ` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
144
- )
145
- )
146
- console.log()
147
-
148
- // Top issues in header
149
- const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
150
- if (topRules.length > 0) {
151
- const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
152
- console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
153
- console.log()
154
- }
155
-
156
- console.log(sep)
157
- console.log()
158
-
159
- if (report.files.length === 0) {
160
- console.log(kleur.green(' No drift detected. Clean codebase.'))
161
- console.log()
162
- return
163
- }
164
-
165
- for (const file of report.files) {
166
- const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
167
- console.log(
168
- kleur.bold().white(` ${rel}`) +
169
- kleur.gray(` (score ${file.score}/100)`)
170
- )
171
-
172
- for (const issue of file.issues) {
173
- const icon = severityIcon(issue.severity)
174
- const colorFn = (s: string) =>
175
- issue.severity === 'error'
176
- ? kleur.red(s)
177
- : issue.severity === 'warning'
178
- ? kleur.yellow(s)
179
- : kleur.cyan(s)
180
-
121
+ const sep = kleur.gray(' ' + '─'.repeat(50))
122
+
123
+ console.log()
124
+ console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
125
+ console.log(sep)
126
+ console.log()
127
+
128
+ const grade = scoreToGrade(report.totalScore)
129
+ const scoreColor = report.totalScore === 0
130
+ ? kleur.green
131
+ : report.totalScore < 45
132
+ ? kleur.yellow
133
+ : kleur.red
134
+
135
+ const bar = scoreBar(report.totalScore)
136
+ console.log(
137
+ ` Score ${kleur.gray(bar)} ${scoreColor().bold(String(report.totalScore))}/100 ${grade.badge}`
138
+ )
139
+
140
+ const cleanFiles = report.totalFiles - report.files.length
141
+ console.log(
142
+ kleur.gray(
143
+ ` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info · ${cleanFiles} files clean`
144
+ )
145
+ )
146
+ console.log()
147
+
148
+ // Top issues in header
149
+ const topRules = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
150
+ if (topRules.length > 0) {
151
+ const parts = topRules.map(([rule, count]) => `${kleur.cyan(rule)} ${kleur.gray(`×${count}`)}`)
152
+ console.log(` Top issues: ${parts.join(kleur.gray(' · '))}`)
153
+ console.log()
154
+ }
155
+
156
+ console.log(sep)
157
+ console.log()
158
+
159
+ if (report.files.length === 0) {
160
+ console.log(kleur.green(' No drift detected. Clean codebase.'))
161
+ console.log()
162
+ return
163
+ }
164
+
165
+ for (const file of report.files) {
166
+ const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
167
+ console.log(
168
+ kleur.bold().white(` ${rel}`) +
169
+ kleur.gray(` (score ${file.score}/100)`)
170
+ )
171
+
172
+ for (const issue of file.issues) {
173
+ const icon = severityIcon(issue.severity)
174
+ const colorFn = (s: string) =>
175
+ issue.severity === 'error'
176
+ ? kleur.red(s)
177
+ : issue.severity === 'warning'
178
+ ? kleur.yellow(s)
179
+ : kleur.cyan(s)
180
+
181
181
  console.log(
182
182
  ` ${colorFn(icon)} ` +
183
183
  kleur.gray(`L${issue.line}`) +
package/src/types.ts CHANGED
@@ -1,18 +1,18 @@
1
- export interface DriftIssue {
2
- rule: string
3
- severity: 'error' | 'warning' | 'info'
4
- message: string
5
- line: number
6
- column: number
7
- snippet: string
8
- }
9
-
10
- export interface FileReport {
11
- path: string
12
- issues: DriftIssue[]
13
- score: number // 0–100, higher = more drift
14
- }
15
-
1
+ export interface DriftIssue {
2
+ rule: string
3
+ severity: 'error' | 'warning' | 'info'
4
+ message: string
5
+ line: number
6
+ column: number
7
+ snippet: string
8
+ }
9
+
10
+ export interface FileReport {
11
+ path: string
12
+ issues: DriftIssue[]
13
+ score: number // 0–100, higher = more drift
14
+ }
15
+
16
16
  export interface DriftReport {
17
17
  scannedAt: string
18
18
  targetPath: string
package/src/utils.ts CHANGED
@@ -1,35 +1,35 @@
1
- import kleur from 'kleur'
2
- import type { DriftIssue } from './types.js'
3
-
4
- export interface Grade {
5
- badge: string
6
- label: string
7
- }
8
-
9
- export function scoreToGrade(score: number): Grade {
10
- if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
11
- if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
12
- if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
13
- if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
14
- return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
15
- }
16
-
17
- export function scoreToGradeText(score: number): Grade {
18
- if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
19
- if (score < 20) return { badge: '◎ LOW', label: 'low' }
20
- if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
21
- if (score < 70) return { badge: '◉ HIGH', label: 'high' }
22
- return { badge: '⬡ CRITICAL', label: 'critical' }
23
- }
24
-
25
- export function severityIcon(s: DriftIssue['severity']): string {
26
- if (s === 'error') return '✖'
27
- if (s === 'warning') return '▲'
28
- return '◦'
29
- }
30
-
31
- export function scoreBar(score: number, width = 20): string {
32
- const filled = Math.round((score / 100) * width)
33
- const empty = width - filled
34
- return '█'.repeat(filled) + '░'.repeat(empty)
35
- }
1
+ import kleur from 'kleur'
2
+ import type { DriftIssue } from './types.js'
3
+
4
+ export interface Grade {
5
+ badge: string
6
+ label: string
7
+ }
8
+
9
+ export function scoreToGrade(score: number): Grade {
10
+ if (score === 0) return { badge: kleur.green('CLEAN'), label: 'clean' }
11
+ if (score < 20) return { badge: kleur.green('LOW'), label: 'low' }
12
+ if (score < 45) return { badge: kleur.yellow('MODERATE'), label: 'moderate' }
13
+ if (score < 70) return { badge: kleur.red('HIGH'), label: 'high' }
14
+ return { badge: kleur.bold().red('CRITICAL'), label: 'critical' }
15
+ }
16
+
17
+ export function scoreToGradeText(score: number): Grade {
18
+ if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
19
+ if (score < 20) return { badge: '◎ LOW', label: 'low' }
20
+ if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
21
+ if (score < 70) return { badge: '◉ HIGH', label: 'high' }
22
+ return { badge: '⬡ CRITICAL', label: 'critical' }
23
+ }
24
+
25
+ export function severityIcon(s: DriftIssue['severity']): string {
26
+ if (s === 'error') return '✖'
27
+ if (s === 'warning') return '▲'
28
+ return '◦'
29
+ }
30
+
31
+ export function scoreBar(score: number, width = 20): string {
32
+ const filled = Math.round((score / 100) * width)
33
+ const empty = width - filled
34
+ return '█'.repeat(filled) + '░'.repeat(empty)
35
+ }