@eduardbar/drift 0.5.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.
- package/CHANGELOG.md +21 -0
- package/README.md +48 -2
- package/assets/og.svg +105 -105
- package/dist/analyzer.d.ts +5 -1
- package/dist/analyzer.js +1 -1
- package/dist/badge.d.ts +2 -0
- package/dist/badge.js +57 -0
- package/dist/ci.d.ts +4 -0
- package/dist/ci.js +85 -0
- package/dist/cli.js +52 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/report.d.ts +3 -0
- package/dist/report.js +494 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/README.md +88 -0
- package/packages/eslint-plugin-drift/package-lock.json +1250 -0
- package/packages/eslint-plugin-drift/package.json +47 -0
- package/packages/eslint-plugin-drift/src/index.ts +170 -0
- package/packages/eslint-plugin-drift/tsconfig.json +17 -0
- package/src/analyzer.ts +1 -1
- package/src/badge.ts +60 -0
- package/src/ci.ts +87 -0
- package/src/cli.ts +55 -1
- package/src/index.ts +2 -2
- package/src/printer.ts +60 -60
- package/src/report.ts +500 -0
- package/src/types.ts +15 -15
- package/src/utils.ts +35 -35
|
@@ -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/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.
|
|
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/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}`) +
|