@eduardbar/drift 0.1.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/README.md +178 -0
- package/assets/og.svg +105 -0
- package/dist/analyzer.d.ts +5 -0
- package/dist/analyzer.js +236 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +41 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/printer.d.ts +3 -0
- package/dist/printer.js +71 -0
- package/dist/reporter.d.ts +4 -0
- package/dist/reporter.js +92 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +2 -0
- package/package.json +28 -0
- package/src/analyzer.ts +270 -0
- package/src/cli.ts +50 -0
- package/src/index.ts +3 -0
- package/src/printer.ts +86 -0
- package/src/reporter.ts +97 -0
- package/src/types.ts +28 -0
- package/tsconfig.json +17 -0
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export function buildReport(targetPath, files) {
|
|
2
|
+
const allIssues = files.flatMap((f) => f.issues);
|
|
3
|
+
const byRule = {};
|
|
4
|
+
for (const issue of allIssues) {
|
|
5
|
+
byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1;
|
|
6
|
+
}
|
|
7
|
+
const totalScore = files.length > 0
|
|
8
|
+
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
9
|
+
: 0;
|
|
10
|
+
return {
|
|
11
|
+
scannedAt: new Date().toISOString(),
|
|
12
|
+
targetPath,
|
|
13
|
+
files: files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score),
|
|
14
|
+
totalIssues: allIssues.length,
|
|
15
|
+
totalScore,
|
|
16
|
+
summary: {
|
|
17
|
+
errors: allIssues.filter((i) => i.severity === 'error').length,
|
|
18
|
+
warnings: allIssues.filter((i) => i.severity === 'warning').length,
|
|
19
|
+
infos: allIssues.filter((i) => i.severity === 'info').length,
|
|
20
|
+
byRule,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function formatMarkdown(report) {
|
|
25
|
+
const grade = scoreToGrade(report.totalScore);
|
|
26
|
+
const lines = [];
|
|
27
|
+
lines.push(`# drift report`);
|
|
28
|
+
lines.push(``);
|
|
29
|
+
lines.push(`> Generated: ${new Date(report.scannedAt).toLocaleString()}`);
|
|
30
|
+
lines.push(`> Path: \`${report.targetPath}\``);
|
|
31
|
+
lines.push(``);
|
|
32
|
+
lines.push(`## Overall drift score: ${report.totalScore}/100 ${grade.badge}`);
|
|
33
|
+
lines.push(``);
|
|
34
|
+
lines.push(`| | Count |`);
|
|
35
|
+
lines.push(`|---|---|`);
|
|
36
|
+
lines.push(`| Errors | ${report.summary.errors} |`);
|
|
37
|
+
lines.push(`| Warnings | ${report.summary.warnings} |`);
|
|
38
|
+
lines.push(`| Info | ${report.summary.infos} |`);
|
|
39
|
+
lines.push(`| Files with issues | ${report.files.length} |`);
|
|
40
|
+
lines.push(`| Total issues | ${report.totalIssues} |`);
|
|
41
|
+
lines.push(``);
|
|
42
|
+
if (Object.keys(report.summary.byRule).length > 0) {
|
|
43
|
+
lines.push(`## Issues by rule`);
|
|
44
|
+
lines.push(``);
|
|
45
|
+
const sorted = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]);
|
|
46
|
+
for (const [rule, count] of sorted) {
|
|
47
|
+
lines.push(`- \`${rule}\`: ${count}`);
|
|
48
|
+
}
|
|
49
|
+
lines.push(``);
|
|
50
|
+
}
|
|
51
|
+
if (report.files.length === 0) {
|
|
52
|
+
lines.push(`## No drift detected`);
|
|
53
|
+
lines.push(``);
|
|
54
|
+
lines.push(`No issues found. Clean codebase.`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
lines.push(`## Files (sorted by drift score)`);
|
|
58
|
+
lines.push(``);
|
|
59
|
+
for (const file of report.files) {
|
|
60
|
+
lines.push(`### \`${file.path}\` — score ${file.score}/100`);
|
|
61
|
+
lines.push(``);
|
|
62
|
+
for (const issue of file.issues) {
|
|
63
|
+
const icon = severityIcon(issue.severity);
|
|
64
|
+
lines.push(`**${icon} [${issue.rule}]** Line ${issue.line}: ${issue.message}`);
|
|
65
|
+
lines.push(`\`\`\``);
|
|
66
|
+
lines.push(issue.snippet);
|
|
67
|
+
lines.push(`\`\`\``);
|
|
68
|
+
lines.push(``);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
function scoreToGrade(score) {
|
|
75
|
+
if (score === 0)
|
|
76
|
+
return { badge: '✦ CLEAN', label: 'clean' };
|
|
77
|
+
if (score < 20)
|
|
78
|
+
return { badge: '◎ LOW', label: 'low' };
|
|
79
|
+
if (score < 45)
|
|
80
|
+
return { badge: '◈ MODERATE', label: 'moderate' };
|
|
81
|
+
if (score < 70)
|
|
82
|
+
return { badge: '◉ HIGH', label: 'high' };
|
|
83
|
+
return { badge: '⬡ CRITICAL', label: 'critical' };
|
|
84
|
+
}
|
|
85
|
+
function severityIcon(s) {
|
|
86
|
+
if (s === 'error')
|
|
87
|
+
return '✖';
|
|
88
|
+
if (s === 'warning')
|
|
89
|
+
return '▲';
|
|
90
|
+
return '◦';
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=reporter.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export interface FileReport {
|
|
10
|
+
path: string;
|
|
11
|
+
issues: DriftIssue[];
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
export interface DriftReport {
|
|
15
|
+
scannedAt: string;
|
|
16
|
+
targetPath: string;
|
|
17
|
+
files: FileReport[];
|
|
18
|
+
totalIssues: number;
|
|
19
|
+
totalScore: number;
|
|
20
|
+
summary: {
|
|
21
|
+
errors: number;
|
|
22
|
+
warnings: number;
|
|
23
|
+
infos: number;
|
|
24
|
+
byRule: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eduardbar/drift",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Detect silent technical debt left by AI-generated code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"drift": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/cli.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["vibe-coding", "technical-debt", "ai", "cli", "typescript", "static-analysis"],
|
|
17
|
+
"author": "eduardbar",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^14.0.3",
|
|
21
|
+
"kleur": "^4.1.5",
|
|
22
|
+
"ts-morph": "^27.0.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^25.3.0",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Project,
|
|
3
|
+
SourceFile,
|
|
4
|
+
SyntaxKind,
|
|
5
|
+
Node,
|
|
6
|
+
FunctionDeclaration,
|
|
7
|
+
ArrowFunction,
|
|
8
|
+
FunctionExpression,
|
|
9
|
+
MethodDeclaration,
|
|
10
|
+
} from 'ts-morph'
|
|
11
|
+
import type { DriftIssue, FileReport } from './types.js'
|
|
12
|
+
|
|
13
|
+
// Rules and their drift score weight
|
|
14
|
+
const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
|
|
15
|
+
'large-file': { severity: 'error', weight: 20 },
|
|
16
|
+
'large-function': { severity: 'error', weight: 15 },
|
|
17
|
+
'debug-leftover': { severity: 'warning', weight: 10 },
|
|
18
|
+
'dead-code': { severity: 'warning', weight: 8 },
|
|
19
|
+
'duplicate-function-name': { severity: 'error', weight: 18 },
|
|
20
|
+
'comment-contradiction': { severity: 'warning', weight: 12 },
|
|
21
|
+
'no-return-type': { severity: 'info', weight: 5 },
|
|
22
|
+
'catch-swallow': { severity: 'warning', weight: 10 },
|
|
23
|
+
'magic-number': { severity: 'info', weight: 3 },
|
|
24
|
+
'any-abuse': { severity: 'warning', weight: 8 },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
28
|
+
|
|
29
|
+
function getSnippet(node: Node, file: SourceFile): string {
|
|
30
|
+
const startLine = node.getStartLineNumber()
|
|
31
|
+
const lines = file.getFullText().split('\n')
|
|
32
|
+
return lines
|
|
33
|
+
.slice(Math.max(0, startLine - 1), startLine + 1)
|
|
34
|
+
.join('\n')
|
|
35
|
+
.trim()
|
|
36
|
+
.slice(0, 120)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getFunctionLikeLines(node: FunctionLike): number {
|
|
40
|
+
return node.getEndLineNumber() - node.getStartLineNumber()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectLargeFile(file: SourceFile): DriftIssue[] {
|
|
44
|
+
const lineCount = file.getEndLineNumber()
|
|
45
|
+
if (lineCount > 300) {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
rule: 'large-file',
|
|
49
|
+
severity: 'error',
|
|
50
|
+
message: `File has ${lineCount} lines (threshold: 300). Large files are the #1 sign of AI-generated structural drift.`,
|
|
51
|
+
line: 1,
|
|
52
|
+
column: 1,
|
|
53
|
+
snippet: `// ${lineCount} lines total`,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function detectLargeFunctions(file: SourceFile): DriftIssue[] {
|
|
61
|
+
const issues: DriftIssue[] = []
|
|
62
|
+
const fns: FunctionLike[] = [
|
|
63
|
+
...file.getFunctions(),
|
|
64
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
65
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
66
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
for (const fn of fns) {
|
|
70
|
+
const lines = getFunctionLikeLines(fn)
|
|
71
|
+
if (lines > 50) {
|
|
72
|
+
issues.push({
|
|
73
|
+
rule: 'large-function',
|
|
74
|
+
severity: 'error',
|
|
75
|
+
message: `Function spans ${lines} lines (threshold: 50). AI tends to dump logic into single functions.`,
|
|
76
|
+
line: fn.getStartLineNumber(),
|
|
77
|
+
column: fn.getStartLinePos(),
|
|
78
|
+
snippet: getSnippet(fn, file),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return issues
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
|
|
86
|
+
const issues: DriftIssue[] = []
|
|
87
|
+
|
|
88
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
89
|
+
const expr = call.getExpression().getText()
|
|
90
|
+
if (/^console\.(log|warn|error|debug|info)\b/.test(expr)) {
|
|
91
|
+
issues.push({
|
|
92
|
+
rule: 'debug-leftover',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
message: `console.${expr.split('.')[1]} left in production code.`,
|
|
95
|
+
line: call.getStartLineNumber(),
|
|
96
|
+
column: call.getStartLinePos(),
|
|
97
|
+
snippet: getSnippet(call, file),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lines = file.getFullText().split('\n')
|
|
103
|
+
lines.forEach((line, i) => {
|
|
104
|
+
if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(line)) {
|
|
105
|
+
issues.push({
|
|
106
|
+
rule: 'debug-leftover',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
message: `Unresolved marker found: ${line.trim().slice(0, 60)}`,
|
|
109
|
+
line: i + 1,
|
|
110
|
+
column: 1,
|
|
111
|
+
snippet: line.trim().slice(0, 120),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return issues
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detectDeadCode(file: SourceFile): DriftIssue[] {
|
|
120
|
+
const issues: DriftIssue[] = []
|
|
121
|
+
|
|
122
|
+
for (const imp of file.getImportDeclarations()) {
|
|
123
|
+
for (const named of imp.getNamedImports()) {
|
|
124
|
+
const name = named.getName()
|
|
125
|
+
const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter(
|
|
126
|
+
(id) => id.getText() === name && id !== named.getNameNode()
|
|
127
|
+
)
|
|
128
|
+
if (refs.length === 0) {
|
|
129
|
+
issues.push({
|
|
130
|
+
rule: 'dead-code',
|
|
131
|
+
severity: 'warning',
|
|
132
|
+
message: `Unused import '${name}'. AI often imports more than it uses.`,
|
|
133
|
+
line: imp.getStartLineNumber(),
|
|
134
|
+
column: imp.getStartLinePos(),
|
|
135
|
+
snippet: getSnippet(imp, file),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return issues
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function detectDuplicateFunctionNames(file: SourceFile): DriftIssue[] {
|
|
145
|
+
const issues: DriftIssue[] = []
|
|
146
|
+
const seen = new Map<string, number>()
|
|
147
|
+
|
|
148
|
+
const fns = file.getFunctions()
|
|
149
|
+
for (const fn of fns) {
|
|
150
|
+
const name = fn.getName()
|
|
151
|
+
if (!name) continue
|
|
152
|
+
const normalized = name.toLowerCase().replace(/[_-]/g, '')
|
|
153
|
+
if (seen.has(normalized)) {
|
|
154
|
+
issues.push({
|
|
155
|
+
rule: 'duplicate-function-name',
|
|
156
|
+
severity: 'error',
|
|
157
|
+
message: `Function '${name}' looks like a duplicate of a previously defined function. AI often generates near-identical helpers.`,
|
|
158
|
+
line: fn.getStartLineNumber(),
|
|
159
|
+
column: fn.getStartLinePos(),
|
|
160
|
+
snippet: getSnippet(fn, file),
|
|
161
|
+
})
|
|
162
|
+
} else {
|
|
163
|
+
seen.set(normalized, fn.getStartLineNumber())
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return issues
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function detectAnyAbuse(file: SourceFile): DriftIssue[] {
|
|
170
|
+
const issues: DriftIssue[] = []
|
|
171
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.AnyKeyword)) {
|
|
172
|
+
issues.push({
|
|
173
|
+
rule: 'any-abuse',
|
|
174
|
+
severity: 'warning',
|
|
175
|
+
message: `Explicit 'any' type detected. AI defaults to 'any' when it can't infer types properly.`,
|
|
176
|
+
line: node.getStartLineNumber(),
|
|
177
|
+
column: node.getStartLinePos(),
|
|
178
|
+
snippet: getSnippet(node, file),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return issues
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function detectCatchSwallow(file: SourceFile): DriftIssue[] {
|
|
185
|
+
const issues: DriftIssue[] = []
|
|
186
|
+
for (const tryCatch of file.getDescendantsOfKind(SyntaxKind.TryStatement)) {
|
|
187
|
+
const catchClause = tryCatch.getCatchClause()
|
|
188
|
+
if (!catchClause) continue
|
|
189
|
+
const block = catchClause.getBlock()
|
|
190
|
+
const stmts = block.getStatements()
|
|
191
|
+
if (stmts.length === 0) {
|
|
192
|
+
issues.push({
|
|
193
|
+
rule: 'catch-swallow',
|
|
194
|
+
severity: 'warning',
|
|
195
|
+
message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
|
|
196
|
+
line: catchClause.getStartLineNumber(),
|
|
197
|
+
column: catchClause.getStartLinePos(),
|
|
198
|
+
snippet: getSnippet(catchClause, file),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return issues
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function detectMissingReturnTypes(file: SourceFile): DriftIssue[] {
|
|
206
|
+
const issues: DriftIssue[] = []
|
|
207
|
+
for (const fn of file.getFunctions()) {
|
|
208
|
+
if (!fn.getReturnTypeNode()) {
|
|
209
|
+
issues.push({
|
|
210
|
+
rule: 'no-return-type',
|
|
211
|
+
severity: 'info',
|
|
212
|
+
message: `Function '${fn.getName() ?? 'anonymous'}' has no explicit return type.`,
|
|
213
|
+
line: fn.getStartLineNumber(),
|
|
214
|
+
column: fn.getStartLinePos(),
|
|
215
|
+
snippet: getSnippet(fn, file),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return issues
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function calculateScore(issues: DriftIssue[]): number {
|
|
223
|
+
let raw = 0
|
|
224
|
+
for (const issue of issues) {
|
|
225
|
+
raw += RULE_WEIGHTS[issue.rule]?.weight ?? 5
|
|
226
|
+
}
|
|
227
|
+
return Math.min(100, raw)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function analyzeFile(file: SourceFile): FileReport {
|
|
231
|
+
const issues: DriftIssue[] = [
|
|
232
|
+
...detectLargeFile(file),
|
|
233
|
+
...detectLargeFunctions(file),
|
|
234
|
+
...detectDebugLeftovers(file),
|
|
235
|
+
...detectDeadCode(file),
|
|
236
|
+
...detectDuplicateFunctionNames(file),
|
|
237
|
+
...detectAnyAbuse(file),
|
|
238
|
+
...detectCatchSwallow(file),
|
|
239
|
+
...detectMissingReturnTypes(file),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
path: file.getFilePath(),
|
|
244
|
+
issues,
|
|
245
|
+
score: calculateScore(issues),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function analyzeProject(targetPath: string): FileReport[] {
|
|
250
|
+
const project = new Project({
|
|
251
|
+
skipAddingFilesFromTsConfig: true,
|
|
252
|
+
compilerOptions: { allowJs: true },
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
project.addSourceFilesAtPaths([
|
|
256
|
+
`${targetPath}/**/*.ts`,
|
|
257
|
+
`${targetPath}/**/*.tsx`,
|
|
258
|
+
`${targetPath}/**/*.js`,
|
|
259
|
+
`${targetPath}/**/*.jsx`,
|
|
260
|
+
`!${targetPath}/**/node_modules/**`,
|
|
261
|
+
`!${targetPath}/**/dist/**`,
|
|
262
|
+
`!${targetPath}/**/.next/**`,
|
|
263
|
+
`!${targetPath}/**/build/**`,
|
|
264
|
+
`!${targetPath}/**/*.d.ts`,
|
|
265
|
+
`!${targetPath}/**/*.test.*`,
|
|
266
|
+
`!${targetPath}/**/*.spec.*`,
|
|
267
|
+
])
|
|
268
|
+
|
|
269
|
+
return project.getSourceFiles().map(analyzeFile)
|
|
270
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { writeFileSync } from 'node:fs'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
5
|
+
import { analyzeProject } from './analyzer.js'
|
|
6
|
+
import { buildReport, formatMarkdown } from './reporter.js'
|
|
7
|
+
import { printConsole } from './printer.js'
|
|
8
|
+
|
|
9
|
+
const program = new Command()
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('drift')
|
|
13
|
+
.description('Detect silent technical debt left by AI-generated code')
|
|
14
|
+
.version('0.1.0')
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('scan [path]', { isDefault: true })
|
|
18
|
+
.description('Scan a directory for vibe coding drift')
|
|
19
|
+
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
20
|
+
.option('--json', 'Output raw JSON report')
|
|
21
|
+
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
22
|
+
.action((targetPath: string | undefined, options: { output?: string; json?: boolean; minScore: string }) => {
|
|
23
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
24
|
+
|
|
25
|
+
console.error(`\nScanning ${resolvedPath}...`)
|
|
26
|
+
|
|
27
|
+
const files = analyzeProject(resolvedPath)
|
|
28
|
+
const report = buildReport(resolvedPath, files)
|
|
29
|
+
|
|
30
|
+
if (options.json) {
|
|
31
|
+
process.stdout.write(JSON.stringify(report, null, 2))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
printConsole(report)
|
|
36
|
+
|
|
37
|
+
if (options.output) {
|
|
38
|
+
const md = formatMarkdown(report)
|
|
39
|
+
const outPath = resolve(options.output)
|
|
40
|
+
writeFileSync(outPath, md, 'utf8')
|
|
41
|
+
console.error(`Report saved to ${outPath}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const minScore = Number(options.minScore)
|
|
45
|
+
if (minScore > 0 && report.totalScore > minScore) {
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
program.parse()
|
package/src/index.ts
ADDED
package/src/printer.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import kleur from 'kleur'
|
|
2
|
+
import type { DriftReport, DriftIssue } from './types.js'
|
|
3
|
+
|
|
4
|
+
export function printConsole(report: DriftReport): void {
|
|
5
|
+
console.log()
|
|
6
|
+
console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'))
|
|
7
|
+
console.log()
|
|
8
|
+
|
|
9
|
+
const grade = scoreToGrade(report.totalScore)
|
|
10
|
+
const scoreColor = report.totalScore === 0
|
|
11
|
+
? kleur.green
|
|
12
|
+
: report.totalScore < 45
|
|
13
|
+
? kleur.yellow
|
|
14
|
+
: kleur.red
|
|
15
|
+
|
|
16
|
+
console.log(
|
|
17
|
+
` Score ${scoreColor().bold(String(report.totalScore).padStart(3))}${kleur.gray('/100')} ${grade.badge}`
|
|
18
|
+
)
|
|
19
|
+
console.log(
|
|
20
|
+
kleur.gray(
|
|
21
|
+
` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info`
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
console.log()
|
|
25
|
+
|
|
26
|
+
if (report.files.length === 0) {
|
|
27
|
+
console.log(kleur.green(' No drift detected. Clean codebase.'))
|
|
28
|
+
console.log()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const file of report.files) {
|
|
33
|
+
const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '')
|
|
34
|
+
console.log(
|
|
35
|
+
kleur.bold().white(` ${rel}`) +
|
|
36
|
+
kleur.gray(` (score ${file.score}/100)`)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for (const issue of file.issues) {
|
|
40
|
+
const icon = severityIcon(issue.severity)
|
|
41
|
+
const colorFn = (s: string) =>
|
|
42
|
+
issue.severity === 'error'
|
|
43
|
+
? kleur.red(s)
|
|
44
|
+
: issue.severity === 'warning'
|
|
45
|
+
? kleur.yellow(s)
|
|
46
|
+
: kleur.cyan(s)
|
|
47
|
+
|
|
48
|
+
console.log(
|
|
49
|
+
` ${colorFn(icon)} ` +
|
|
50
|
+
kleur.gray(`L${issue.line}`) +
|
|
51
|
+
` ` +
|
|
52
|
+
colorFn(issue.rule) +
|
|
53
|
+
` ` +
|
|
54
|
+
kleur.white(issue.message)
|
|
55
|
+
)
|
|
56
|
+
if (issue.snippet) {
|
|
57
|
+
console.log(kleur.gray(` ${issue.snippet.split('\n')[0].slice(0, 100)}`))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
console.log()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Top drifting rules summary
|
|
64
|
+
const sorted = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
65
|
+
if (sorted.length > 0) {
|
|
66
|
+
console.log(kleur.gray(' Top rules:'))
|
|
67
|
+
for (const [rule, count] of sorted) {
|
|
68
|
+
console.log(kleur.gray(` · ${rule}: ${count}`))
|
|
69
|
+
}
|
|
70
|
+
console.log()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreToGrade(score: number): { badge: string } {
|
|
75
|
+
if (score === 0) return { badge: kleur.green('CLEAN') }
|
|
76
|
+
if (score < 20) return { badge: kleur.green('LOW') }
|
|
77
|
+
if (score < 45) return { badge: kleur.yellow('MODERATE') }
|
|
78
|
+
if (score < 70) return { badge: kleur.red('HIGH') }
|
|
79
|
+
return { badge: kleur.bold().red('CRITICAL') }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function severityIcon(s: DriftIssue['severity']): string {
|
|
83
|
+
if (s === 'error') return '✖'
|
|
84
|
+
if (s === 'warning') return '▲'
|
|
85
|
+
return '◦'
|
|
86
|
+
}
|
package/src/reporter.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FileReport, DriftReport, DriftIssue } from './types.js'
|
|
2
|
+
|
|
3
|
+
export function buildReport(targetPath: string, files: FileReport[]): DriftReport {
|
|
4
|
+
const allIssues = files.flatMap((f) => f.issues)
|
|
5
|
+
const byRule: Record<string, number> = {}
|
|
6
|
+
|
|
7
|
+
for (const issue of allIssues) {
|
|
8
|
+
byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const totalScore =
|
|
12
|
+
files.length > 0
|
|
13
|
+
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
14
|
+
: 0
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
scannedAt: new Date().toISOString(),
|
|
18
|
+
targetPath,
|
|
19
|
+
files: files.filter((f) => f.issues.length > 0).sort((a, b) => b.score - a.score),
|
|
20
|
+
totalIssues: allIssues.length,
|
|
21
|
+
totalScore,
|
|
22
|
+
summary: {
|
|
23
|
+
errors: allIssues.filter((i) => i.severity === 'error').length,
|
|
24
|
+
warnings: allIssues.filter((i) => i.severity === 'warning').length,
|
|
25
|
+
infos: allIssues.filter((i) => i.severity === 'info').length,
|
|
26
|
+
byRule,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatMarkdown(report: DriftReport): string {
|
|
32
|
+
const grade = scoreToGrade(report.totalScore)
|
|
33
|
+
const lines: string[] = []
|
|
34
|
+
|
|
35
|
+
lines.push(`# drift report`)
|
|
36
|
+
lines.push(``)
|
|
37
|
+
lines.push(`> Generated: ${new Date(report.scannedAt).toLocaleString()}`)
|
|
38
|
+
lines.push(`> Path: \`${report.targetPath}\``)
|
|
39
|
+
lines.push(``)
|
|
40
|
+
lines.push(`## Overall drift score: ${report.totalScore}/100 ${grade.badge}`)
|
|
41
|
+
lines.push(``)
|
|
42
|
+
lines.push(`| | Count |`)
|
|
43
|
+
lines.push(`|---|---|`)
|
|
44
|
+
lines.push(`| Errors | ${report.summary.errors} |`)
|
|
45
|
+
lines.push(`| Warnings | ${report.summary.warnings} |`)
|
|
46
|
+
lines.push(`| Info | ${report.summary.infos} |`)
|
|
47
|
+
lines.push(`| Files with issues | ${report.files.length} |`)
|
|
48
|
+
lines.push(`| Total issues | ${report.totalIssues} |`)
|
|
49
|
+
lines.push(``)
|
|
50
|
+
|
|
51
|
+
if (Object.keys(report.summary.byRule).length > 0) {
|
|
52
|
+
lines.push(`## Issues by rule`)
|
|
53
|
+
lines.push(``)
|
|
54
|
+
const sorted = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1])
|
|
55
|
+
for (const [rule, count] of sorted) {
|
|
56
|
+
lines.push(`- \`${rule}\`: ${count}`)
|
|
57
|
+
}
|
|
58
|
+
lines.push(``)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (report.files.length === 0) {
|
|
62
|
+
lines.push(`## No drift detected`)
|
|
63
|
+
lines.push(``)
|
|
64
|
+
lines.push(`No issues found. Clean codebase.`)
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(`## Files (sorted by drift score)`)
|
|
67
|
+
lines.push(``)
|
|
68
|
+
for (const file of report.files) {
|
|
69
|
+
lines.push(`### \`${file.path}\` — score ${file.score}/100`)
|
|
70
|
+
lines.push(``)
|
|
71
|
+
for (const issue of file.issues) {
|
|
72
|
+
const icon = severityIcon(issue.severity)
|
|
73
|
+
lines.push(`**${icon} [${issue.rule}]** Line ${issue.line}: ${issue.message}`)
|
|
74
|
+
lines.push(`\`\`\``)
|
|
75
|
+
lines.push(issue.snippet)
|
|
76
|
+
lines.push(`\`\`\``)
|
|
77
|
+
lines.push(``)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scoreToGrade(score: number): { badge: string; label: string } {
|
|
86
|
+
if (score === 0) return { badge: '✦ CLEAN', label: 'clean' }
|
|
87
|
+
if (score < 20) return { badge: '◎ LOW', label: 'low' }
|
|
88
|
+
if (score < 45) return { badge: '◈ MODERATE', label: 'moderate' }
|
|
89
|
+
if (score < 70) return { badge: '◉ HIGH', label: 'high' }
|
|
90
|
+
return { badge: '⬡ CRITICAL', label: 'critical' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function severityIcon(s: DriftIssue['severity']): string {
|
|
94
|
+
if (s === 'error') return '✖'
|
|
95
|
+
if (s === 'warning') return '▲'
|
|
96
|
+
return '◦'
|
|
97
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
export interface DriftReport {
|
|
17
|
+
scannedAt: string
|
|
18
|
+
targetPath: string
|
|
19
|
+
files: FileReport[]
|
|
20
|
+
totalIssues: number
|
|
21
|
+
totalScore: number
|
|
22
|
+
summary: {
|
|
23
|
+
errors: number
|
|
24
|
+
warnings: number
|
|
25
|
+
infos: number
|
|
26
|
+
byRule: Record<string, number>
|
|
27
|
+
}
|
|
28
|
+
}
|