@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.
@@ -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
@@ -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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export { analyzeProject, analyzeFile } from './analyzer.js'
2
+ export { buildReport, formatMarkdown } from './reporter.js'
3
+ export type { DriftReport, FileReport, DriftIssue } from './types.js'
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
+ }
@@ -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
+ }