@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/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# drift
|
|
4
|
+
|
|
5
|
+
Detect silent technical debt left by AI-generated code. One command. Zero config.
|
|
6
|
+
|
|
7
|
+
_Vibe coding ships fast. drift tells you what it left behind._
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
[Installation](#-installation) • [Usage](#-usage) • [Rules](#-what-it-detects) • [CI Integration](#-ci-integration) • [Score](#-score) • [Contributing](#-contributing)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 🎯 Why?
|
|
20
|
+
|
|
21
|
+
You reviewed the AI-generated code today. Huge files, unused functions, empty catch blocks, duplicate helpers, `console.log` everywhere. It ran fine in dev. It will bite you in prod.
|
|
22
|
+
|
|
23
|
+
drift scans your TypeScript/JavaScript codebase for the specific patterns AI tools leave behind and gives you a score so you know where to look first.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
$ npx @eduardbar/drift scan ./src
|
|
27
|
+
|
|
28
|
+
drift — vibe coding debt detector
|
|
29
|
+
|
|
30
|
+
Score 67/100 HIGH
|
|
31
|
+
4 file(s) with issues · 5 errors · 12 warnings · 3 info
|
|
32
|
+
|
|
33
|
+
src/api/users.ts (score 85/100)
|
|
34
|
+
✖ L1 large-file File has 412 lines (threshold: 300)
|
|
35
|
+
▲ L34 debug-leftover console.log left in production code
|
|
36
|
+
▲ L89 catch-swallow Empty catch block silently swallows errors
|
|
37
|
+
▲ L201 any-abuse Explicit 'any' type detected
|
|
38
|
+
|
|
39
|
+
src/utils/helpers.ts (score 70/100)
|
|
40
|
+
✖ L12 duplicate-function-name 'formatDate' looks like a duplicate
|
|
41
|
+
▲ L55 dead-code Unused import 'debounce'
|
|
42
|
+
|
|
43
|
+
Top rules:
|
|
44
|
+
· debug-leftover: 8
|
|
45
|
+
· any-abuse: 5
|
|
46
|
+
· no-return-type: 3
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 📦 Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Run without installing
|
|
55
|
+
npx @eduardbar/drift scan ./src
|
|
56
|
+
|
|
57
|
+
# Install globally
|
|
58
|
+
npm install -g @eduardbar/drift
|
|
59
|
+
drift scan ./src
|
|
60
|
+
|
|
61
|
+
# Install as dev dependency
|
|
62
|
+
npm install --save-dev @eduardbar/drift
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 🚀 Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
drift scan # Scan current directory
|
|
71
|
+
drift scan ./src # Scan a specific path
|
|
72
|
+
drift scan ./src --output report.md # Write Markdown report to file
|
|
73
|
+
drift scan ./src --json # Output raw JSON
|
|
74
|
+
drift scan ./src --min-score 50 # Exit code 1 if score > 50
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Options
|
|
78
|
+
|
|
79
|
+
| Flag | Description |
|
|
80
|
+
|------|-------------|
|
|
81
|
+
| `--output <file>` | Write Markdown report to a file |
|
|
82
|
+
| `--json` | Output raw JSON instead of console output |
|
|
83
|
+
| `--min-score <n>` | Exit with code 1 if overall score exceeds threshold |
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 🔍 What it detects
|
|
88
|
+
|
|
89
|
+
| Rule | Severity | What it catches |
|
|
90
|
+
|------|----------|-----------------|
|
|
91
|
+
| `large-file` | error | Files over 300 lines — AI dumps everything into one place |
|
|
92
|
+
| `large-function` | error | Functions over 50 lines — AI avoids splitting logic |
|
|
93
|
+
| `debug-leftover` | warning | `console.log`, `TODO`, `FIXME`, `HACK` comments |
|
|
94
|
+
| `dead-code` | warning | Unused imports — AI imports more than it uses |
|
|
95
|
+
| `duplicate-function-name` | error | Near-identical function names — AI regenerates instead of reusing |
|
|
96
|
+
| `any-abuse` | warning | Explicit `any` type — AI defaults to `any` when it can't infer |
|
|
97
|
+
| `catch-swallow` | warning | Empty catch blocks — AI makes code "not throw" |
|
|
98
|
+
| `no-return-type` | info | Missing explicit return types on functions |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## ⚙️ CI Integration
|
|
103
|
+
|
|
104
|
+
Drop this into your GitHub Actions workflow to block merges when drift exceeds your threshold:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
- name: Check for vibe coding drift
|
|
108
|
+
run: npx @eduardbar/drift scan ./src --min-score 60
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Exit code `1` if score exceeds `--min-score`. Exit code `0` otherwise.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 📊 Score
|
|
116
|
+
|
|
117
|
+
| Score | Grade | Meaning |
|
|
118
|
+
|-------|-------|---------|
|
|
119
|
+
| 0 | CLEAN | No issues found |
|
|
120
|
+
| 1–19 | LOW | Minor issues, safe to ship |
|
|
121
|
+
| 20–44 | MODERATE | Worth a review before merging |
|
|
122
|
+
| 45–69 | HIGH | Significant structural debt detected |
|
|
123
|
+
| 70–100 | CRITICAL | Review before this goes anywhere near production |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 🗂️ Project structure
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
src/
|
|
131
|
+
├── types.ts — DriftIssue, FileReport, DriftReport interfaces
|
|
132
|
+
├── analyzer.ts — AST analysis with ts-morph, 8 detection rules
|
|
133
|
+
├── reporter.ts — buildReport() + Markdown formatter
|
|
134
|
+
├── printer.ts — Console output with color (kleur)
|
|
135
|
+
├── index.ts — Public API re-exports
|
|
136
|
+
└── cli.ts — CLI entry point (Commander.js)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 🧪 Run on yourself
|
|
142
|
+
|
|
143
|
+
drift passes its own scan with a MODERATE score — the `console.log` calls in `printer.ts` are intentional CLI output, not debug leftovers. We eat our own dog food.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
git clone https://github.com/eduardbar/drift
|
|
147
|
+
cd drift
|
|
148
|
+
npm install
|
|
149
|
+
npm run build
|
|
150
|
+
node dist/cli.js scan ./src
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 🤝 Contributing
|
|
156
|
+
|
|
157
|
+
PRs are welcome. If you find a new AI code pattern worth detecting, open an issue with an example and we'll add a rule.
|
|
158
|
+
|
|
159
|
+
1. Fork the repo
|
|
160
|
+
2. Create a branch: `git checkout -b feat/rule-name`
|
|
161
|
+
3. Add your rule in `src/analyzer.ts`
|
|
162
|
+
4. Open a PR
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 🧱 Stack
|
|
167
|
+
|
|
168
|
+
TypeScript · ts-morph · commander · kleur
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 📄 License
|
|
173
|
+
|
|
174
|
+
MIT © [eduardbar](https://github.com/eduardbar)
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
_Built with mate by a developer who got tired of reviewing the same AI-generated patterns every week._
|
package/assets/og.svg
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" style="stop-color:#0a0a0f"/>
|
|
5
|
+
<stop offset="100%" style="stop-color:#0f0f1a"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
8
|
+
<stop offset="0%" style="stop-color:#6366f1"/>
|
|
9
|
+
<stop offset="100%" style="stop-color:#8b5cf6"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<linearGradient id="glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
12
|
+
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:0.15"/>
|
|
13
|
+
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:0.05"/>
|
|
14
|
+
</linearGradient>
|
|
15
|
+
<filter id="blur-glow">
|
|
16
|
+
<feGaussianBlur stdDeviation="40" result="blur"/>
|
|
17
|
+
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
|
18
|
+
</filter>
|
|
19
|
+
<filter id="soft-glow">
|
|
20
|
+
<feGaussianBlur stdDeviation="3" result="blur"/>
|
|
21
|
+
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
|
22
|
+
</filter>
|
|
23
|
+
</defs>
|
|
24
|
+
|
|
25
|
+
<!-- Background -->
|
|
26
|
+
<rect width="1200" height="630" fill="url(#bg)"/>
|
|
27
|
+
|
|
28
|
+
<!-- Ambient glow top-left -->
|
|
29
|
+
<ellipse cx="200" cy="200" rx="300" ry="250" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.8"/>
|
|
30
|
+
|
|
31
|
+
<!-- Ambient glow bottom-right -->
|
|
32
|
+
<ellipse cx="1050" cy="480" rx="280" ry="220" fill="url(#glow)" filter="url(#blur-glow)" opacity="0.6"/>
|
|
33
|
+
|
|
34
|
+
<!-- Grid lines subtle -->
|
|
35
|
+
<line x1="0" y1="210" x2="1200" y2="210" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
|
36
|
+
<line x1="0" y1="420" x2="1200" y2="420" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
|
37
|
+
<line x1="300" y1="0" x2="300" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
|
38
|
+
<line x1="900" y1="0" x2="900" y2="630" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
|
39
|
+
|
|
40
|
+
<!-- Left accent bar -->
|
|
41
|
+
<rect x="72" y="180" width="4" height="270" rx="2" fill="url(#accent)"/>
|
|
42
|
+
|
|
43
|
+
<!-- Main title "drift" -->
|
|
44
|
+
<text x="102" y="310" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', monospace" font-size="120" font-weight="700" fill="#ffffff" letter-spacing="-4">drift</text>
|
|
45
|
+
|
|
46
|
+
<!-- Subtitle -->
|
|
47
|
+
<text x="104" y="370" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="24" font-weight="400" fill="#94a3b8" letter-spacing="0.5">Detect silent technical debt left by AI-generated code.</text>
|
|
48
|
+
|
|
49
|
+
<!-- Terminal block right side -->
|
|
50
|
+
<rect x="680" y="165" width="448" height="300" rx="12" fill="#0d0d18" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
|
|
51
|
+
|
|
52
|
+
<!-- Terminal header bar -->
|
|
53
|
+
<rect x="680" y="165" width="448" height="40" rx="12" fill="#1a1a2e"/>
|
|
54
|
+
<rect x="680" y="185" width="448" height="20" fill="#1a1a2e"/>
|
|
55
|
+
|
|
56
|
+
<!-- Terminal dots -->
|
|
57
|
+
<circle cx="710" cy="185" r="6" fill="#ff5f57"/>
|
|
58
|
+
<circle cx="730" cy="185" r="6" fill="#febc2e"/>
|
|
59
|
+
<circle cx="750" cy="185" r="6" fill="#28c840"/>
|
|
60
|
+
|
|
61
|
+
<!-- Terminal text -->
|
|
62
|
+
<text x="700" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#6366f1">$</text>
|
|
63
|
+
<text x="716" y="235" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#e2e8f0"> npx @eduardbar/drift scan ./src</text>
|
|
64
|
+
|
|
65
|
+
<text x="700" y="265" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> drift — vibe coding debt detector</text>
|
|
66
|
+
|
|
67
|
+
<text x="700" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b"> Score </text>
|
|
68
|
+
<text x="755" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" font-weight="700" fill="#ef4444"> 67</text>
|
|
69
|
+
<text x="779" y="295" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="13" fill="#64748b">/100 HIGH</text>
|
|
70
|
+
|
|
71
|
+
<text x="700" y="320" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> 4 file(s) · 5 errors · 12 warnings</text>
|
|
72
|
+
|
|
73
|
+
<text x="700" y="352" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b"> src/api/users.ts</text>
|
|
74
|
+
<text x="700" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444"> ✖ </text>
|
|
75
|
+
<text x="720" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#ef4444">large-file</text>
|
|
76
|
+
<text x="800" y="371" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> 412 lines detected</text>
|
|
77
|
+
|
|
78
|
+
<text x="700" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
|
|
79
|
+
<text x="720" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">catch-swallow</text>
|
|
80
|
+
<text x="820" y="390" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> empty catch block</text>
|
|
81
|
+
|
|
82
|
+
<text x="700" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308"> ▲ </text>
|
|
83
|
+
<text x="720" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#eab308">any-abuse</text>
|
|
84
|
+
<text x="793" y="409" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#94a3b8"> explicit any type</text>
|
|
85
|
+
|
|
86
|
+
<text x="700" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#22c55e"> ◦ </text>
|
|
87
|
+
<text x="720" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#64748b">no-return-type</text>
|
|
88
|
+
<text x="830" y="440" font-family="'Cascadia Code', 'Fira Code', 'Consolas', monospace" font-size="11" fill="#475569"> missing return type</text>
|
|
89
|
+
|
|
90
|
+
<!-- Badges row -->
|
|
91
|
+
<rect x="104" y="405" width="100" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
|
|
92
|
+
<text x="154" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">TypeScript</text>
|
|
93
|
+
|
|
94
|
+
<rect x="214" y="405" width="72" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
|
|
95
|
+
<text x="250" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">ts-morph</text>
|
|
96
|
+
|
|
97
|
+
<rect x="296" y="405" width="52" height="26" rx="5" fill="#1e1e3a" stroke="#6366f1" stroke-opacity="0.4" stroke-width="1"/>
|
|
98
|
+
<text x="322" y="423" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="12" fill="#818cf8" text-anchor="middle">MIT</text>
|
|
99
|
+
|
|
100
|
+
<!-- Author -->
|
|
101
|
+
<text x="104" y="520" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="16" fill="#334155">github.com/eduardbar/drift</text>
|
|
102
|
+
|
|
103
|
+
<!-- Bottom accent line -->
|
|
104
|
+
<rect x="0" y="622" width="1200" height="8" fill="url(#accent)" opacity="0.7"/>
|
|
105
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { SourceFile } from 'ts-morph';
|
|
2
|
+
import type { FileReport } from './types.js';
|
|
3
|
+
export declare function analyzeFile(file: SourceFile): FileReport;
|
|
4
|
+
export declare function analyzeProject(targetPath: string): FileReport[];
|
|
5
|
+
//# sourceMappingURL=analyzer.d.ts.map
|
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Project, SyntaxKind, } from 'ts-morph';
|
|
2
|
+
// Rules and their drift score weight
|
|
3
|
+
const RULE_WEIGHTS = {
|
|
4
|
+
'large-file': { severity: 'error', weight: 20 },
|
|
5
|
+
'large-function': { severity: 'error', weight: 15 },
|
|
6
|
+
'debug-leftover': { severity: 'warning', weight: 10 },
|
|
7
|
+
'dead-code': { severity: 'warning', weight: 8 },
|
|
8
|
+
'duplicate-function-name': { severity: 'error', weight: 18 },
|
|
9
|
+
'comment-contradiction': { severity: 'warning', weight: 12 },
|
|
10
|
+
'no-return-type': { severity: 'info', weight: 5 },
|
|
11
|
+
'catch-swallow': { severity: 'warning', weight: 10 },
|
|
12
|
+
'magic-number': { severity: 'info', weight: 3 },
|
|
13
|
+
'any-abuse': { severity: 'warning', weight: 8 },
|
|
14
|
+
};
|
|
15
|
+
function getSnippet(node, file) {
|
|
16
|
+
const startLine = node.getStartLineNumber();
|
|
17
|
+
const lines = file.getFullText().split('\n');
|
|
18
|
+
return lines
|
|
19
|
+
.slice(Math.max(0, startLine - 1), startLine + 1)
|
|
20
|
+
.join('\n')
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, 120);
|
|
23
|
+
}
|
|
24
|
+
function getFunctionLikeLines(node) {
|
|
25
|
+
return node.getEndLineNumber() - node.getStartLineNumber();
|
|
26
|
+
}
|
|
27
|
+
function detectLargeFile(file) {
|
|
28
|
+
const lineCount = file.getEndLineNumber();
|
|
29
|
+
if (lineCount > 300) {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
rule: 'large-file',
|
|
33
|
+
severity: 'error',
|
|
34
|
+
message: `File has ${lineCount} lines (threshold: 300). Large files are the #1 sign of AI-generated structural drift.`,
|
|
35
|
+
line: 1,
|
|
36
|
+
column: 1,
|
|
37
|
+
snippet: `// ${lineCount} lines total`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
function detectLargeFunctions(file) {
|
|
44
|
+
const issues = [];
|
|
45
|
+
const fns = [
|
|
46
|
+
...file.getFunctions(),
|
|
47
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
48
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
49
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
50
|
+
];
|
|
51
|
+
for (const fn of fns) {
|
|
52
|
+
const lines = getFunctionLikeLines(fn);
|
|
53
|
+
if (lines > 50) {
|
|
54
|
+
issues.push({
|
|
55
|
+
rule: 'large-function',
|
|
56
|
+
severity: 'error',
|
|
57
|
+
message: `Function spans ${lines} lines (threshold: 50). AI tends to dump logic into single functions.`,
|
|
58
|
+
line: fn.getStartLineNumber(),
|
|
59
|
+
column: fn.getStartLinePos(),
|
|
60
|
+
snippet: getSnippet(fn, file),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return issues;
|
|
65
|
+
}
|
|
66
|
+
function detectDebugLeftovers(file) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
69
|
+
const expr = call.getExpression().getText();
|
|
70
|
+
if (/^console\.(log|warn|error|debug|info)\b/.test(expr)) {
|
|
71
|
+
issues.push({
|
|
72
|
+
rule: 'debug-leftover',
|
|
73
|
+
severity: 'warning',
|
|
74
|
+
message: `console.${expr.split('.')[1]} left in production code.`,
|
|
75
|
+
line: call.getStartLineNumber(),
|
|
76
|
+
column: call.getStartLinePos(),
|
|
77
|
+
snippet: getSnippet(call, file),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const lines = file.getFullText().split('\n');
|
|
82
|
+
lines.forEach((line, i) => {
|
|
83
|
+
if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(line)) {
|
|
84
|
+
issues.push({
|
|
85
|
+
rule: 'debug-leftover',
|
|
86
|
+
severity: 'warning',
|
|
87
|
+
message: `Unresolved marker found: ${line.trim().slice(0, 60)}`,
|
|
88
|
+
line: i + 1,
|
|
89
|
+
column: 1,
|
|
90
|
+
snippet: line.trim().slice(0, 120),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return issues;
|
|
95
|
+
}
|
|
96
|
+
function detectDeadCode(file) {
|
|
97
|
+
const issues = [];
|
|
98
|
+
for (const imp of file.getImportDeclarations()) {
|
|
99
|
+
for (const named of imp.getNamedImports()) {
|
|
100
|
+
const name = named.getName();
|
|
101
|
+
const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter((id) => id.getText() === name && id !== named.getNameNode());
|
|
102
|
+
if (refs.length === 0) {
|
|
103
|
+
issues.push({
|
|
104
|
+
rule: 'dead-code',
|
|
105
|
+
severity: 'warning',
|
|
106
|
+
message: `Unused import '${name}'. AI often imports more than it uses.`,
|
|
107
|
+
line: imp.getStartLineNumber(),
|
|
108
|
+
column: imp.getStartLinePos(),
|
|
109
|
+
snippet: getSnippet(imp, file),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return issues;
|
|
115
|
+
}
|
|
116
|
+
function detectDuplicateFunctionNames(file) {
|
|
117
|
+
const issues = [];
|
|
118
|
+
const seen = new Map();
|
|
119
|
+
const fns = file.getFunctions();
|
|
120
|
+
for (const fn of fns) {
|
|
121
|
+
const name = fn.getName();
|
|
122
|
+
if (!name)
|
|
123
|
+
continue;
|
|
124
|
+
const normalized = name.toLowerCase().replace(/[_-]/g, '');
|
|
125
|
+
if (seen.has(normalized)) {
|
|
126
|
+
issues.push({
|
|
127
|
+
rule: 'duplicate-function-name',
|
|
128
|
+
severity: 'error',
|
|
129
|
+
message: `Function '${name}' looks like a duplicate of a previously defined function. AI often generates near-identical helpers.`,
|
|
130
|
+
line: fn.getStartLineNumber(),
|
|
131
|
+
column: fn.getStartLinePos(),
|
|
132
|
+
snippet: getSnippet(fn, file),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
seen.set(normalized, fn.getStartLineNumber());
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return issues;
|
|
140
|
+
}
|
|
141
|
+
function detectAnyAbuse(file) {
|
|
142
|
+
const issues = [];
|
|
143
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.AnyKeyword)) {
|
|
144
|
+
issues.push({
|
|
145
|
+
rule: 'any-abuse',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
message: `Explicit 'any' type detected. AI defaults to 'any' when it can't infer types properly.`,
|
|
148
|
+
line: node.getStartLineNumber(),
|
|
149
|
+
column: node.getStartLinePos(),
|
|
150
|
+
snippet: getSnippet(node, file),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return issues;
|
|
154
|
+
}
|
|
155
|
+
function detectCatchSwallow(file) {
|
|
156
|
+
const issues = [];
|
|
157
|
+
for (const tryCatch of file.getDescendantsOfKind(SyntaxKind.TryStatement)) {
|
|
158
|
+
const catchClause = tryCatch.getCatchClause();
|
|
159
|
+
if (!catchClause)
|
|
160
|
+
continue;
|
|
161
|
+
const block = catchClause.getBlock();
|
|
162
|
+
const stmts = block.getStatements();
|
|
163
|
+
if (stmts.length === 0) {
|
|
164
|
+
issues.push({
|
|
165
|
+
rule: 'catch-swallow',
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
|
|
168
|
+
line: catchClause.getStartLineNumber(),
|
|
169
|
+
column: catchClause.getStartLinePos(),
|
|
170
|
+
snippet: getSnippet(catchClause, file),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
176
|
+
function detectMissingReturnTypes(file) {
|
|
177
|
+
const issues = [];
|
|
178
|
+
for (const fn of file.getFunctions()) {
|
|
179
|
+
if (!fn.getReturnTypeNode()) {
|
|
180
|
+
issues.push({
|
|
181
|
+
rule: 'no-return-type',
|
|
182
|
+
severity: 'info',
|
|
183
|
+
message: `Function '${fn.getName() ?? 'anonymous'}' has no explicit return type.`,
|
|
184
|
+
line: fn.getStartLineNumber(),
|
|
185
|
+
column: fn.getStartLinePos(),
|
|
186
|
+
snippet: getSnippet(fn, file),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return issues;
|
|
191
|
+
}
|
|
192
|
+
function calculateScore(issues) {
|
|
193
|
+
let raw = 0;
|
|
194
|
+
for (const issue of issues) {
|
|
195
|
+
raw += RULE_WEIGHTS[issue.rule]?.weight ?? 5;
|
|
196
|
+
}
|
|
197
|
+
return Math.min(100, raw);
|
|
198
|
+
}
|
|
199
|
+
export function analyzeFile(file) {
|
|
200
|
+
const issues = [
|
|
201
|
+
...detectLargeFile(file),
|
|
202
|
+
...detectLargeFunctions(file),
|
|
203
|
+
...detectDebugLeftovers(file),
|
|
204
|
+
...detectDeadCode(file),
|
|
205
|
+
...detectDuplicateFunctionNames(file),
|
|
206
|
+
...detectAnyAbuse(file),
|
|
207
|
+
...detectCatchSwallow(file),
|
|
208
|
+
...detectMissingReturnTypes(file),
|
|
209
|
+
];
|
|
210
|
+
return {
|
|
211
|
+
path: file.getFilePath(),
|
|
212
|
+
issues,
|
|
213
|
+
score: calculateScore(issues),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export function analyzeProject(targetPath) {
|
|
217
|
+
const project = new Project({
|
|
218
|
+
skipAddingFilesFromTsConfig: true,
|
|
219
|
+
compilerOptions: { allowJs: true },
|
|
220
|
+
});
|
|
221
|
+
project.addSourceFilesAtPaths([
|
|
222
|
+
`${targetPath}/**/*.ts`,
|
|
223
|
+
`${targetPath}/**/*.tsx`,
|
|
224
|
+
`${targetPath}/**/*.js`,
|
|
225
|
+
`${targetPath}/**/*.jsx`,
|
|
226
|
+
`!${targetPath}/**/node_modules/**`,
|
|
227
|
+
`!${targetPath}/**/dist/**`,
|
|
228
|
+
`!${targetPath}/**/.next/**`,
|
|
229
|
+
`!${targetPath}/**/build/**`,
|
|
230
|
+
`!${targetPath}/**/*.d.ts`,
|
|
231
|
+
`!${targetPath}/**/*.test.*`,
|
|
232
|
+
`!${targetPath}/**/*.spec.*`,
|
|
233
|
+
]);
|
|
234
|
+
return project.getSourceFiles().map(analyzeFile);
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=analyzer.js.map
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name('drift')
|
|
11
|
+
.description('Detect silent technical debt left by AI-generated code')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
program
|
|
14
|
+
.command('scan [path]', { isDefault: true })
|
|
15
|
+
.description('Scan a directory for vibe coding drift')
|
|
16
|
+
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
17
|
+
.option('--json', 'Output raw JSON report')
|
|
18
|
+
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
19
|
+
.action((targetPath, options) => {
|
|
20
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
21
|
+
console.error(`\nScanning ${resolvedPath}...`);
|
|
22
|
+
const files = analyzeProject(resolvedPath);
|
|
23
|
+
const report = buildReport(resolvedPath, files);
|
|
24
|
+
if (options.json) {
|
|
25
|
+
process.stdout.write(JSON.stringify(report, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
printConsole(report);
|
|
29
|
+
if (options.output) {
|
|
30
|
+
const md = formatMarkdown(report);
|
|
31
|
+
const outPath = resolve(options.output);
|
|
32
|
+
writeFileSync(outPath, md, 'utf8');
|
|
33
|
+
console.error(`Report saved to ${outPath}`);
|
|
34
|
+
}
|
|
35
|
+
const minScore = Number(options.minScore);
|
|
36
|
+
if (minScore > 0 && report.totalScore > minScore) {
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
program.parse();
|
|
41
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/printer.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
export function printConsole(report) {
|
|
3
|
+
console.log();
|
|
4
|
+
console.log(kleur.bold().white(' drift') + kleur.gray(' — vibe coding debt detector'));
|
|
5
|
+
console.log();
|
|
6
|
+
const grade = scoreToGrade(report.totalScore);
|
|
7
|
+
const scoreColor = report.totalScore === 0
|
|
8
|
+
? kleur.green
|
|
9
|
+
: report.totalScore < 45
|
|
10
|
+
? kleur.yellow
|
|
11
|
+
: kleur.red;
|
|
12
|
+
console.log(` Score ${scoreColor().bold(String(report.totalScore).padStart(3))}${kleur.gray('/100')} ${grade.badge}`);
|
|
13
|
+
console.log(kleur.gray(` ${report.files.length} file(s) with issues · ${report.summary.errors} errors · ${report.summary.warnings} warnings · ${report.summary.infos} info`));
|
|
14
|
+
console.log();
|
|
15
|
+
if (report.files.length === 0) {
|
|
16
|
+
console.log(kleur.green(' No drift detected. Clean codebase.'));
|
|
17
|
+
console.log();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const file of report.files) {
|
|
21
|
+
const rel = file.path.replace(report.targetPath, '').replace(/^[\\/]/, '');
|
|
22
|
+
console.log(kleur.bold().white(` ${rel}`) +
|
|
23
|
+
kleur.gray(` (score ${file.score}/100)`));
|
|
24
|
+
for (const issue of file.issues) {
|
|
25
|
+
const icon = severityIcon(issue.severity);
|
|
26
|
+
const colorFn = (s) => issue.severity === 'error'
|
|
27
|
+
? kleur.red(s)
|
|
28
|
+
: issue.severity === 'warning'
|
|
29
|
+
? kleur.yellow(s)
|
|
30
|
+
: kleur.cyan(s);
|
|
31
|
+
console.log(` ${colorFn(icon)} ` +
|
|
32
|
+
kleur.gray(`L${issue.line}`) +
|
|
33
|
+
` ` +
|
|
34
|
+
colorFn(issue.rule) +
|
|
35
|
+
` ` +
|
|
36
|
+
kleur.white(issue.message));
|
|
37
|
+
if (issue.snippet) {
|
|
38
|
+
console.log(kleur.gray(` ${issue.snippet.split('\n')[0].slice(0, 100)}`));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
// Top drifting rules summary
|
|
44
|
+
const sorted = Object.entries(report.summary.byRule).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
45
|
+
if (sorted.length > 0) {
|
|
46
|
+
console.log(kleur.gray(' Top rules:'));
|
|
47
|
+
for (const [rule, count] of sorted) {
|
|
48
|
+
console.log(kleur.gray(` · ${rule}: ${count}`));
|
|
49
|
+
}
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function scoreToGrade(score) {
|
|
54
|
+
if (score === 0)
|
|
55
|
+
return { badge: kleur.green('CLEAN') };
|
|
56
|
+
if (score < 20)
|
|
57
|
+
return { badge: kleur.green('LOW') };
|
|
58
|
+
if (score < 45)
|
|
59
|
+
return { badge: kleur.yellow('MODERATE') };
|
|
60
|
+
if (score < 70)
|
|
61
|
+
return { badge: kleur.red('HIGH') };
|
|
62
|
+
return { badge: kleur.bold().red('CRITICAL') };
|
|
63
|
+
}
|
|
64
|
+
function severityIcon(s) {
|
|
65
|
+
if (s === 'error')
|
|
66
|
+
return '✖';
|
|
67
|
+
if (s === 'warning')
|
|
68
|
+
return '▲';
|
|
69
|
+
return '◦';
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=printer.js.map
|