@eduardbar/drift 0.9.0 → 0.9.1
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 +9 -0
- package/README.md +273 -168
- package/dist/analyzer.js +58 -25
- package/package.json +1 -1
- package/src/analyzer.ts +72 -25
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.9.1] — 2026-02-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `drift trend`: `analyzeSingleCommit` now analyses the full project snapshot at each commit instead of only the files changed in the diff. Uses `git ls-tree -r <hash> --name-only` to enumerate all tracked `.ts/.tsx` files, writes them to a temp directory via `git show <hash>:<file>`, runs `analyzeProject` on the snapshot, then cleans up. Score in each `TrendDataPoint` now reflects the total project health, not just the files touched in that commit.
|
|
14
|
+
- `drift trend`: added sampling to `analyzeHistoricalCommits` — selects at most 10 commits distributed evenly across the period (configurable via `maxSamples`). Prevents timeouts on repos with 100+ commits.
|
|
15
|
+
- `drift trend` / `drift blame`: propagate `DriftConfig` through the full call chain (`analyzeTrend` → `analyzeHistoricalCommits` → `analyzeSingleCommit` → `analyzeProject`) so custom rule configs are respected in historical analysis.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
10
19
|
## [Unreleased]
|
|
11
20
|
|
|
12
21
|
---
|
package/README.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-

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

|
|
10
8
|

|
|
@@ -12,20 +10,73 @@ _Vibe coding ships fast. drift tells you what it left behind._
|
|
|
12
10
|

|
|
13
11
|

|
|
14
12
|
|
|
15
|
-
[Installation](
|
|
13
|
+
[Why](#why) · [Installation](#installation) · [Commands](#commands) · [Rules](#rules) · [Score](#score) · [Configuration](#configuration) · [CI Integration](#ci-integration) · [drift-ignore](#drift-ignore) · [Contributing](#contributing)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
AI coding tools ship code fast. They also leave behind consistent, predictable structural patterns that accumulate silently: files that grow to 600 lines, catch blocks that swallow errors, exports that nothing imports, functions duplicated across three modules because the model regenerated instead of reusing.
|
|
20
|
+
|
|
21
|
+
GitClear's 2024 analysis of 211M lines of code found a **39.9% drop in refactoring activity** and an **8x increase in duplicated code blocks** since AI tools became mainstream. A senior engineer on r/vibecoding put it plainly: _"The code looks reviewed. It isn't. Nobody's reading 400-line files the AI dumped in one shot."_
|
|
22
|
+
|
|
23
|
+
drift gives you a 0–100 score per file and project so you know what to look at before it reaches production.
|
|
24
|
+
|
|
25
|
+
**How drift compares to existing tools:**
|
|
26
|
+
|
|
27
|
+
| Tool | What it does | What it misses |
|
|
28
|
+
|------|--------------|----------------|
|
|
29
|
+
| ESLint | Correctness and style within a single file | Structural patterns, cross-file dead code, architecture violations |
|
|
30
|
+
| SonarQube | Enterprise-grade static analysis | Costs money, requires infrastructure, overwhelming for small teams |
|
|
31
|
+
| drift | Structural debt + AI-specific patterns + cross-file analysis + 0–100 score | Not a linter — does not replace ESLint |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Run without installing
|
|
39
|
+
npx @eduardbar/drift scan .
|
|
40
|
+
|
|
41
|
+
# Install globally
|
|
42
|
+
npm install -g @eduardbar/drift
|
|
43
|
+
|
|
44
|
+
# Install as a dev dependency
|
|
45
|
+
npm install --save-dev @eduardbar/drift
|
|
46
|
+
```
|
|
16
47
|
|
|
17
48
|
---
|
|
18
49
|
|
|
19
|
-
##
|
|
50
|
+
## Commands
|
|
20
51
|
|
|
21
|
-
|
|
52
|
+
### `drift scan [path]`
|
|
22
53
|
|
|
23
|
-
|
|
54
|
+
Scan a directory and print a scored report to stdout.
|
|
24
55
|
|
|
25
56
|
```bash
|
|
26
|
-
|
|
57
|
+
drift scan .
|
|
58
|
+
drift scan ./src
|
|
59
|
+
drift scan ./src --output report.md
|
|
60
|
+
drift scan ./src --json
|
|
61
|
+
drift scan ./src --ai
|
|
62
|
+
drift scan ./src --fix
|
|
63
|
+
drift scan ./src --min-score 50
|
|
64
|
+
```
|
|
27
65
|
|
|
28
|
-
|
|
66
|
+
**Options:**
|
|
67
|
+
|
|
68
|
+
| Flag | Description |
|
|
69
|
+
|------|-------------|
|
|
70
|
+
| `--output <file>` | Write Markdown report to a file instead of stdout |
|
|
71
|
+
| `--json` | Output raw `DriftReport` JSON |
|
|
72
|
+
| `--ai` | Output structured JSON optimized for LLM consumption (Claude, GPT, etc.) |
|
|
73
|
+
| `--fix` | Print inline fix suggestions for each detected issue |
|
|
74
|
+
| `--min-score <n>` | Exit with code 1 if the overall score meets or exceeds this threshold |
|
|
75
|
+
|
|
76
|
+
**Example output:**
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
drift — technical debt detector
|
|
29
80
|
──────────────────────────────────────────────────
|
|
30
81
|
|
|
31
82
|
Score █████████████░░░░░░░ 67/100 HIGH
|
|
@@ -48,156 +99,181 @@ $ npx @eduardbar/drift scan ./src
|
|
|
48
99
|
|
|
49
100
|
---
|
|
50
101
|
|
|
51
|
-
|
|
102
|
+
### `drift diff [ref]`
|
|
103
|
+
|
|
104
|
+
Compare the current project state against any git ref. Defaults to `HEAD~1`.
|
|
52
105
|
|
|
53
106
|
```bash
|
|
54
|
-
#
|
|
55
|
-
|
|
107
|
+
drift diff # HEAD vs HEAD~1
|
|
108
|
+
drift diff HEAD~3 # HEAD vs 3 commits ago
|
|
109
|
+
drift diff main # HEAD vs branch main
|
|
110
|
+
drift diff abc1234 # HEAD vs a specific commit
|
|
111
|
+
drift diff --json # Output raw JSON diff
|
|
112
|
+
```
|
|
56
113
|
|
|
57
|
-
|
|
58
|
-
npm install -g @eduardbar/drift
|
|
59
|
-
drift scan ./src
|
|
114
|
+
**Options:**
|
|
60
115
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
| Flag | Description |
|
|
117
|
+
|------|-------------|
|
|
118
|
+
| `--json` | Output raw JSON diff |
|
|
119
|
+
|
|
120
|
+
Shows score delta, issues introduced, and issues resolved since the given ref.
|
|
64
121
|
|
|
65
122
|
---
|
|
66
123
|
|
|
67
|
-
|
|
124
|
+
### `drift report [path]`
|
|
125
|
+
|
|
126
|
+
Generate a self-contained HTML report. No server required — open in any browser.
|
|
68
127
|
|
|
69
128
|
```bash
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
npx @eduardbar/drift scan ./src --output report.md
|
|
74
|
-
npx @eduardbar/drift scan ./src --json
|
|
75
|
-
npx @eduardbar/drift scan ./src --ai
|
|
76
|
-
npx @eduardbar/drift scan ./src --fix
|
|
77
|
-
npx @eduardbar/drift scan ./src --min-score 50
|
|
78
|
-
|
|
79
|
-
# Install globally if you want the short 'drift' command
|
|
80
|
-
npm install -g @eduardbar/drift
|
|
81
|
-
drift scan .
|
|
129
|
+
drift report # scan current directory
|
|
130
|
+
drift report ./src # scan specific path
|
|
131
|
+
drift report ./src --output my-report.html
|
|
82
132
|
```
|
|
83
133
|
|
|
84
|
-
|
|
134
|
+
**Options:**
|
|
85
135
|
|
|
86
136
|
| Flag | Description |
|
|
87
137
|
|------|-------------|
|
|
88
|
-
| `--output <file>` |
|
|
89
|
-
| `--json` | Output raw JSON instead of console output |
|
|
90
|
-
| `--ai` | Output AI-optimized JSON for LLM consumption (Claude, GPT, etc.) |
|
|
91
|
-
| `--fix` | Show fix suggestions for each detected issue |
|
|
92
|
-
| `--min-score <n>` | Exit with code 1 if overall score exceeds threshold |
|
|
138
|
+
| `--output <file>` | Output path for the HTML file (default: `drift-report.html`) |
|
|
93
139
|
|
|
94
|
-
|
|
140
|
+
All styles and data are embedded inline in the output file.
|
|
95
141
|
|
|
96
|
-
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### `drift badge [path]`
|
|
145
|
+
|
|
146
|
+
Generate a `badge.svg` with the current score, compatible with shields.io style.
|
|
97
147
|
|
|
98
148
|
```bash
|
|
99
|
-
drift
|
|
100
|
-
drift
|
|
101
|
-
drift
|
|
102
|
-
drift diff abc1234 # HEAD vs specific commit
|
|
103
|
-
drift diff --json # Output raw JSON diff
|
|
149
|
+
drift badge # writes badge.svg to current directory
|
|
150
|
+
drift badge ./src
|
|
151
|
+
drift badge ./src --output ./assets/drift-badge.svg
|
|
104
152
|
```
|
|
105
153
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
### `drift report [path]`
|
|
154
|
+
**Options:**
|
|
109
155
|
|
|
110
|
-
|
|
156
|
+
| Flag | Description |
|
|
157
|
+
|------|-------------|
|
|
158
|
+
| `--output <file>` | Output path for the SVG file (default: `badge.svg`) |
|
|
111
159
|
|
|
112
|
-
|
|
113
|
-
drift report # scan current directory
|
|
114
|
-
drift report ./src # scan specific path
|
|
115
|
-
```
|
|
160
|
+
Add the badge to your README — see [README Badge](#readme-badge).
|
|
116
161
|
|
|
117
|
-
|
|
162
|
+
---
|
|
118
163
|
|
|
119
|
-
### `drift
|
|
164
|
+
### `drift ci [path]`
|
|
120
165
|
|
|
121
|
-
|
|
166
|
+
Emit GitHub Actions annotations and a step summary. Designed to run inside a CI workflow.
|
|
122
167
|
|
|
123
168
|
```bash
|
|
124
|
-
drift
|
|
125
|
-
drift
|
|
169
|
+
drift ci # scan current directory
|
|
170
|
+
drift ci ./src
|
|
171
|
+
drift ci ./src --min-score 60
|
|
126
172
|
```
|
|
127
173
|
|
|
128
|
-
|
|
174
|
+
**Options:**
|
|
129
175
|
|
|
130
|
-
|
|
176
|
+
| Flag | Description |
|
|
177
|
+
|------|-------------|
|
|
178
|
+
| `--min-score <n>` | Exit with code 1 if the overall score meets or exceeds this threshold |
|
|
179
|
+
|
|
180
|
+
Outputs `::error` and `::warning` annotations visible in the PR diff. Writes a markdown summary to `$GITHUB_STEP_SUMMARY`.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### `drift trend [period]`
|
|
131
185
|
|
|
132
|
-
|
|
186
|
+
Show score evolution over time. `period` accepts: `week`, `month`, `quarter`, `year`.
|
|
133
187
|
|
|
134
188
|
```bash
|
|
135
|
-
drift
|
|
136
|
-
drift
|
|
137
|
-
drift
|
|
189
|
+
drift trend week
|
|
190
|
+
drift trend month
|
|
191
|
+
drift trend quarter --since 2025-01-01
|
|
192
|
+
drift trend year --until 2025-12-31
|
|
138
193
|
```
|
|
139
194
|
|
|
140
|
-
|
|
195
|
+
**Options:**
|
|
141
196
|
|
|
142
|
-
|
|
197
|
+
| Flag | Description |
|
|
198
|
+
|------|-------------|
|
|
199
|
+
| `--since <date>` | Start date for the trend window (ISO 8601) |
|
|
200
|
+
| `--until <date>` | End date for the trend window (ISO 8601) |
|
|
201
|
+
|
|
202
|
+
---
|
|
143
203
|
|
|
144
|
-
|
|
204
|
+
### `drift blame [target]`
|
|
205
|
+
|
|
206
|
+
Identify which files, rules, or contributors are responsible for the most debt. `target` accepts: `file`, `rule`, `overall`.
|
|
145
207
|
|
|
146
208
|
```bash
|
|
147
|
-
|
|
209
|
+
drift blame file # top files by score
|
|
210
|
+
drift blame rule # top rules by frequency
|
|
211
|
+
drift blame overall
|
|
212
|
+
drift blame file --top 10
|
|
148
213
|
```
|
|
149
214
|
|
|
150
|
-
|
|
151
|
-
- Priority-ordered issues (by severity and effort)
|
|
152
|
-
- Fix suggestions for each issue
|
|
153
|
-
- Recommended action for quick wins
|
|
215
|
+
**Options:**
|
|
154
216
|
|
|
155
|
-
|
|
217
|
+
| Flag | Description |
|
|
218
|
+
|------|-------------|
|
|
219
|
+
| `--top <n>` | Limit output to top N results (default: 5) |
|
|
156
220
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Rules
|
|
224
|
+
|
|
225
|
+
26 rules across three severity levels. All run automatically unless marked as requiring configuration.
|
|
226
|
+
|
|
227
|
+
| Rule | Severity | Weight | What it detects |
|
|
228
|
+
|------|----------|--------|-----------------|
|
|
229
|
+
| `large-file` | error | 20 | Files exceeding 300 lines — AI generates monolithic files instead of splitting responsibility |
|
|
230
|
+
| `large-function` | error | 15 | Functions exceeding 50 lines — AI avoids decomposing logic into smaller units |
|
|
231
|
+
| `duplicate-function-name` | error | 18 | Function names that appear more than once (case-insensitive) — AI regenerates helpers instead of reusing them |
|
|
232
|
+
| `high-complexity` | error | 15 | Cyclomatic complexity above 10 — AI produces correct code, not necessarily simple code |
|
|
233
|
+
| `circular-dependency` | error | 14 | Circular import chains between modules — AI doesn't reason about module topology |
|
|
234
|
+
| `layer-violation` | error | 16 | Imports that cross architectural layers in the wrong direction (e.g., domain importing from infra) — requires `drift.config.ts` |
|
|
235
|
+
| `debug-leftover` | warning | 10 | `console.log`, `console.warn`, `console.error`, and `TODO` / `FIXME` / `HACK` comments — AI leaves scaffolding in place |
|
|
236
|
+
| `dead-code` | warning | 8 | Named imports that are never used in the file — AI imports broadly |
|
|
237
|
+
| `any-abuse` | warning | 8 | Explicit `any` type annotations — AI defaults to `any` when type inference is unclear |
|
|
238
|
+
| `catch-swallow` | warning | 10 | Empty `catch` blocks — AI makes code not throw without handling the error |
|
|
239
|
+
| `comment-contradiction` | warning | 12 | Comments that restate what the surrounding code already expresses — AI over-documents the obvious |
|
|
240
|
+
| `deep-nesting` | warning | 12 | Control flow nested more than 3 levels deep — results in code that is difficult to follow |
|
|
241
|
+
| `too-many-params` | warning | 8 | Functions with more than 4 parameters — AI avoids grouping related arguments into objects |
|
|
242
|
+
| `high-coupling` | warning | 10 | Files importing from more than 10 distinct modules — AI imports broadly without encapsulation |
|
|
243
|
+
| `promise-style-mix` | warning | 7 | `async/await` and `.then()` / `.catch()` used together in the same file — AI combines styles inconsistently |
|
|
244
|
+
| `unused-export` | warning | 8 | Named exports that are never imported anywhere in the project — cross-file dead code ESLint cannot detect |
|
|
245
|
+
| `dead-file` | warning | 10 | Files never imported by any other file in the project — invisible dead code |
|
|
246
|
+
| `unused-dependency` | warning | 6 | Packages listed in `package.json` with no corresponding import in source files |
|
|
247
|
+
| `cross-boundary-import` | warning | 10 | Imports that cross module boundaries outside the allowed list — requires `drift.config.ts` |
|
|
248
|
+
| `hardcoded-config` | warning | 10 | Hardcoded URLs, IP addresses, secrets, or connection strings — AI skips environment variable abstraction |
|
|
249
|
+
| `inconsistent-error-handling` | warning | 8 | Mixed `try/catch` and `.catch()` patterns in the same file — AI combines approaches without a consistent strategy |
|
|
250
|
+
| `unnecessary-abstraction` | warning | 7 | Wrapper functions or helpers that add no logic over what they wrap — AI over-engineers simple calls |
|
|
251
|
+
| `naming-inconsistency` | warning | 6 | Mixed `camelCase` and `snake_case` in the same module — AI forgets project conventions mid-generation |
|
|
252
|
+
| `semantic-duplication` | warning | 12 | Functions with structurally identical logic despite different names — detected via AST fingerprinting, not text comparison |
|
|
253
|
+
| `no-return-type` | info | 5 | Functions missing an explicit return type annotation |
|
|
254
|
+
| `magic-number` | info | 3 | Numeric literals used directly in logic without a named constant |
|
|
160
255
|
|
|
161
256
|
---
|
|
162
257
|
|
|
163
|
-
##
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
|
168
|
-
|
|
169
|
-
|
|
|
170
|
-
|
|
|
171
|
-
|
|
|
172
|
-
|
|
|
173
|
-
|
|
|
174
|
-
| `any-abuse` | warning | Explicit `any` type — AI defaults to `any` when it can't infer |
|
|
175
|
-
| `catch-swallow` | warning | Empty catch blocks — AI makes code "not throw" |
|
|
176
|
-
| `comment-contradiction` | warning | Comments that restate what the code already says — AI documents the obvious |
|
|
177
|
-
| `deep-nesting` | warning | Nesting depth > 3 — if inside for inside if inside try = unreadable |
|
|
178
|
-
| `too-many-params` | warning | Functions with more than 4 parameters — AI avoids options objects |
|
|
179
|
-
| `high-coupling` | warning | Files importing from more than 10 modules — AI imports broadly |
|
|
180
|
-
| `promise-style-mix` | warning | `async/await` and `.then()` mixed in the same file |
|
|
181
|
-
| `unused-export` | warning | Named exports never imported anywhere in the project — cross-file dead code |
|
|
182
|
-
| `dead-file` | warning | Files never imported by any other file — invisible dead code |
|
|
183
|
-
| `unused-dependency` | warning | Packages in `package.json` never imported in source code |
|
|
184
|
-
| `no-return-type` | info | Missing explicit return types on functions |
|
|
185
|
-
| `magic-number` | info | Numeric literals used directly in logic — extract to named constants |
|
|
186
|
-
| `layer-violation` | error | Layer imports a layer it's not allowed to (requires `drift.config.ts`) |
|
|
187
|
-
| `cross-boundary-import` | warning | Module imports from another module outside allowed boundaries (requires `drift.config.ts`) |
|
|
188
|
-
| `over-commented` | info | Functions where comments exceed 40% of lines — AI over-documents the obvious |
|
|
189
|
-
| `hardcoded-config` | warning | Hardcoded URLs, IPs, or connection strings — AI skips environment variables |
|
|
190
|
-
| `inconsistent-error-handling` | warning | Mixed `try/catch` and `.catch()` in the same file — AI combines styles randomly |
|
|
191
|
-
| `unnecessary-abstraction` | warning | Single-method interfaces or abstract classes with no reuse — AI over-engineers |
|
|
192
|
-
| `naming-inconsistency` | warning | Mixed camelCase and snake_case in the same scope — AI forgets project conventions |
|
|
258
|
+
## Score
|
|
259
|
+
|
|
260
|
+
**Calculation:** For each file, drift sums the weights of all detected issues, capped at 100. The project score is the average across all scanned files.
|
|
261
|
+
|
|
262
|
+
| Score | Grade | Meaning |
|
|
263
|
+
|-------|-------|---------|
|
|
264
|
+
| 0 | CLEAN | No issues found |
|
|
265
|
+
| 1–19 | LOW | Minor issues — safe to ship |
|
|
266
|
+
| 20–44 | MODERATE | Worth a review before merging |
|
|
267
|
+
| 45–69 | HIGH | Significant structural debt detected |
|
|
268
|
+
| 70–100 | CRITICAL | Review before this goes anywhere near production |
|
|
193
269
|
|
|
194
270
|
---
|
|
195
271
|
|
|
196
|
-
##
|
|
272
|
+
## Configuration
|
|
197
273
|
|
|
198
|
-
Architectural rules (`layer-violation`, `cross-boundary-import`) require a `drift.config.ts` at your project root:
|
|
274
|
+
drift runs with zero configuration. Architectural rules (`layer-violation`, `cross-boundary-import`) require a `drift.config.ts` (or `.js` / `.json`) at your project root:
|
|
199
275
|
|
|
200
|
-
```
|
|
276
|
+
```typescript
|
|
201
277
|
import type { DriftConfig } from '@eduardbar/drift'
|
|
202
278
|
|
|
203
279
|
export default {
|
|
@@ -206,117 +282,146 @@ export default {
|
|
|
206
282
|
{ name: 'app', patterns: ['src/app/**'], canImportFrom: ['domain'] },
|
|
207
283
|
{ name: 'infra', patterns: ['src/infra/**'], canImportFrom: ['domain', 'app'] },
|
|
208
284
|
],
|
|
209
|
-
|
|
285
|
+
boundaries: [
|
|
210
286
|
{ name: 'auth', root: 'src/modules/auth', allowedExternalImports: ['src/shared'] },
|
|
211
287
|
{ name: 'billing', root: 'src/modules/billing', allowedExternalImports: ['src/shared'] },
|
|
212
288
|
],
|
|
289
|
+
exclude: [
|
|
290
|
+
'src/generated/**',
|
|
291
|
+
'**/*.spec.ts',
|
|
292
|
+
],
|
|
293
|
+
rules: {
|
|
294
|
+
'large-file': { threshold: 400 }, // override default 300
|
|
295
|
+
'magic-number': 'off', // disable a rule
|
|
296
|
+
},
|
|
213
297
|
} satisfies DriftConfig
|
|
214
298
|
```
|
|
215
299
|
|
|
216
|
-
Without a config file,
|
|
300
|
+
Without a config file, `layer-violation` and `cross-boundary-import` are silently skipped. All other rules run with their defaults.
|
|
217
301
|
|
|
218
302
|
---
|
|
219
303
|
|
|
220
|
-
##
|
|
304
|
+
## CI Integration
|
|
221
305
|
|
|
222
|
-
|
|
306
|
+
### Basic gate with `scan`
|
|
223
307
|
|
|
224
308
|
```yaml
|
|
225
|
-
|
|
226
|
-
|
|
309
|
+
name: Drift
|
|
310
|
+
|
|
311
|
+
on: [pull_request]
|
|
312
|
+
|
|
313
|
+
jobs:
|
|
314
|
+
drift:
|
|
315
|
+
runs-on: ubuntu-latest
|
|
316
|
+
steps:
|
|
317
|
+
- uses: actions/checkout@v4
|
|
318
|
+
- uses: actions/setup-node@v4
|
|
319
|
+
with:
|
|
320
|
+
node-version: 20
|
|
321
|
+
- name: Check debt score
|
|
322
|
+
run: npx @eduardbar/drift scan ./src --min-score 60
|
|
227
323
|
```
|
|
228
324
|
|
|
229
|
-
|
|
230
|
-
- Emit inline annotations on the exact lines with issues (visible in the PR diff)
|
|
231
|
-
- Write a summary to the GitHub Actions step summary
|
|
232
|
-
- Exit with code 1 if the score exceeds the threshold
|
|
325
|
+
Exit code is `1` if the score meets or exceeds `--min-score`. Exit code `0` otherwise.
|
|
233
326
|
|
|
234
|
-
|
|
327
|
+
### Annotations and step summary with `drift ci`
|
|
235
328
|
|
|
236
329
|
```yaml
|
|
237
|
-
|
|
238
|
-
|
|
330
|
+
name: Drift
|
|
331
|
+
|
|
332
|
+
on: [pull_request]
|
|
333
|
+
|
|
334
|
+
jobs:
|
|
335
|
+
drift:
|
|
336
|
+
runs-on: ubuntu-latest
|
|
337
|
+
steps:
|
|
338
|
+
- uses: actions/checkout@v4
|
|
339
|
+
- uses: actions/setup-node@v4
|
|
340
|
+
with:
|
|
341
|
+
node-version: 20
|
|
342
|
+
- name: Run drift
|
|
343
|
+
run: npx @eduardbar/drift ci ./src --min-score 60
|
|
239
344
|
```
|
|
240
345
|
|
|
241
|
-
|
|
346
|
+
`drift ci` emits `::error` and `::warning` annotations that appear inline in the PR diff and writes a formatted summary to `$GITHUB_STEP_SUMMARY`. Use this when you want visibility beyond a pass/fail exit code.
|
|
242
347
|
|
|
243
348
|
---
|
|
244
349
|
|
|
245
|
-
##
|
|
350
|
+
## drift-ignore
|
|
246
351
|
|
|
247
|
-
|
|
248
|
-
|-------|-------|---------|
|
|
249
|
-
| 0 | CLEAN | No issues found |
|
|
250
|
-
| 1–19 | LOW | Minor issues, safe to ship |
|
|
251
|
-
| 20–44 | MODERATE | Worth a review before merging |
|
|
252
|
-
| 45–69 | HIGH | Significant structural debt detected |
|
|
253
|
-
| 70–100 | CRITICAL | Review before this goes anywhere near production |
|
|
352
|
+
### Suppress a single issue
|
|
254
353
|
|
|
255
|
-
|
|
354
|
+
Add `// drift-ignore` at the end of the flagged line or on the line immediately above it:
|
|
256
355
|
|
|
257
|
-
|
|
356
|
+
```typescript
|
|
357
|
+
console.log(debugPayload) // drift-ignore
|
|
358
|
+
```
|
|
258
359
|
|
|
360
|
+
```typescript
|
|
361
|
+
// drift-ignore
|
|
362
|
+
const result: any = parse(input)
|
|
259
363
|
```
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
364
|
+
|
|
365
|
+
### Suppress an entire file
|
|
366
|
+
|
|
367
|
+
Add `// drift-ignore-file` anywhere in the first 10 lines of the file:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// drift-ignore-file
|
|
371
|
+
// This file contains intentional console output — not debug leftovers.
|
|
267
372
|
```
|
|
268
373
|
|
|
374
|
+
When `drift-ignore-file` is present, `analyzeFile()` returns an empty report with score 0 for that file. Use this for files like loggers or CLI printers where `console.*` calls are intentional.
|
|
375
|
+
|
|
269
376
|
---
|
|
270
377
|
|
|
271
|
-
##
|
|
378
|
+
## README Badge
|
|
272
379
|
|
|
273
|
-
|
|
380
|
+
Generate a badge from your project score and add it to your README:
|
|
274
381
|
|
|
275
382
|
```bash
|
|
276
|
-
|
|
277
|
-
cd drift
|
|
278
|
-
npm install
|
|
279
|
-
npm run build
|
|
280
|
-
node dist/cli.js scan ./src
|
|
383
|
+
drift badge . --output ./assets/drift-badge.svg
|
|
281
384
|
```
|
|
282
385
|
|
|
283
|
-
|
|
386
|
+
Then reference it in your README:
|
|
284
387
|
|
|
285
|
-
```
|
|
286
|
-
|
|
388
|
+
```markdown
|
|
389
|
+

|
|
287
390
|
```
|
|
288
391
|
|
|
392
|
+
The badge uses shields.io-compatible styling and color-codes automatically by grade: green for LOW, yellow for MODERATE, orange for HIGH, red for CRITICAL.
|
|
393
|
+
|
|
289
394
|
---
|
|
290
395
|
|
|
291
|
-
##
|
|
396
|
+
## Contributing
|
|
292
397
|
|
|
293
|
-
|
|
398
|
+
Open an issue before starting significant work. Check [existing issues](https://github.com/eduardbar/drift/issues) first — use the bug report or feature request templates.
|
|
294
399
|
|
|
295
|
-
**
|
|
400
|
+
**To add a new detection rule:**
|
|
296
401
|
|
|
297
|
-
1.
|
|
298
|
-
2. Add
|
|
299
|
-
3. Implement
|
|
300
|
-
4. Add a `fix_suggestion`
|
|
402
|
+
1. Create a branch: `git checkout -b feat/rule-name`
|
|
403
|
+
2. Add `"rule-name": <weight>` to `RULE_WEIGHTS` in `src/analyzer.ts`
|
|
404
|
+
3. Implement AST detection logic using ts-morph in `analyzeFile()`
|
|
405
|
+
4. Add a `fix_suggestion` entry in `src/printer.ts`
|
|
301
406
|
5. Update the rules table in `README.md` and `AGENTS.md`
|
|
302
|
-
6. Open a PR
|
|
303
|
-
|
|
304
|
-
Before opening an issue, check [existing issues](https://github.com/eduardbar/drift/issues). Use the [bug report](./.github/ISSUE_TEMPLATE/bug_report.md) or [feature request](./.github/ISSUE_TEMPLATE/feature_request.md) templates.
|
|
407
|
+
6. Open a PR using the template in `.github/PULL_REQUEST_TEMPLATE.md`
|
|
305
408
|
|
|
306
|
-
|
|
409
|
+
See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before participating.
|
|
307
410
|
|
|
308
411
|
---
|
|
309
412
|
|
|
310
|
-
##
|
|
413
|
+
## Stack
|
|
414
|
+
|
|
415
|
+
| Package | Role |
|
|
416
|
+
|---------|------|
|
|
417
|
+
| [`ts-morph`](https://github.com/dsherret/ts-morph) | AST traversal and TypeScript analysis |
|
|
418
|
+
| [`commander`](https://github.com/tj/commander.js) | CLI commands and flags |
|
|
419
|
+
| [`kleur`](https://github.com/lukeed/kleur) | Terminal colors (zero dependencies) |
|
|
311
420
|
|
|
312
|
-
|
|
421
|
+
**Runtime:** Node.js 18+ · TypeScript 5.x · ES Modules
|
|
313
422
|
|
|
314
423
|
---
|
|
315
424
|
|
|
316
|
-
##
|
|
425
|
+
## License
|
|
317
426
|
|
|
318
427
|
MIT © [eduardbar](https://github.com/eduardbar)
|
|
319
|
-
|
|
320
|
-
---
|
|
321
|
-
|
|
322
|
-
_Built with mate by a developer who got tired of reviewing the same AI-generated patterns every week._
|
package/dist/analyzer.js
CHANGED
|
@@ -1371,39 +1371,67 @@ async function analyzeFileAtCommit(filePath, commitHash, projectRoot) {
|
|
|
1371
1371
|
}
|
|
1372
1372
|
}
|
|
1373
1373
|
/**
|
|
1374
|
-
* Analyse
|
|
1374
|
+
* Analyse ALL TypeScript files in the project snapshot at a given commit.
|
|
1375
|
+
* Uses `git ls-tree` to enumerate every file in the tree, writes them to a
|
|
1376
|
+
* temp directory, then runs `analyzeProject` on that full snapshot so that
|
|
1377
|
+
* the resulting `averageScore` reflects the complete project health rather
|
|
1378
|
+
* than only the files touched in that diff.
|
|
1375
1379
|
*/
|
|
1376
|
-
async function analyzeSingleCommit(commitHash, targetPath) {
|
|
1377
|
-
//
|
|
1378
|
-
const
|
|
1379
|
-
const
|
|
1380
|
-
// First non-empty line is the metadata line
|
|
1381
|
-
const metaLine = lines[0] ?? '';
|
|
1382
|
-
const [hash, dateStr, author, ...msgParts] = metaLine.split('|');
|
|
1380
|
+
async function analyzeSingleCommit(commitHash, targetPath, config) {
|
|
1381
|
+
// 1. Commit metadata
|
|
1382
|
+
const meta = execGit(`git show --no-patch --format="%H|%aI|%an|%s" ${commitHash}`, targetPath);
|
|
1383
|
+
const [hash, dateStr, author, ...msgParts] = meta.split('|');
|
|
1383
1384
|
const message = msgParts.join('|').trim();
|
|
1384
1385
|
const commitDate = new Date(dateStr ?? '');
|
|
1385
|
-
//
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1386
|
+
// 2. All .ts/.tsx files tracked at this commit (no diffs, full tree)
|
|
1387
|
+
const allFiles = execGit(`git ls-tree -r ${commitHash} --name-only`, targetPath)
|
|
1388
|
+
.split('\n')
|
|
1389
|
+
.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx')) &&
|
|
1390
|
+
!f.endsWith('.d.ts') &&
|
|
1391
|
+
!f.includes('node_modules') &&
|
|
1392
|
+
!f.startsWith('dist/'));
|
|
1393
|
+
if (allFiles.length === 0) {
|
|
1394
|
+
return {
|
|
1395
|
+
commitHash: hash ?? commitHash,
|
|
1396
|
+
commitDate,
|
|
1397
|
+
author: author ?? '',
|
|
1398
|
+
message,
|
|
1399
|
+
files: [],
|
|
1400
|
+
totalScore: 0,
|
|
1401
|
+
averageScore: 0,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
// 3. Write snapshot to temp directory
|
|
1405
|
+
const tmpDir = path.join(os.tmpdir(), `drift-${(hash ?? commitHash).slice(0, 8)}`);
|
|
1406
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1407
|
+
for (const relPath of allFiles) {
|
|
1408
|
+
try {
|
|
1409
|
+
const content = execGit(`git show ${commitHash}:${relPath}`, targetPath);
|
|
1410
|
+
const destPath = path.join(tmpDir, relPath);
|
|
1411
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
1412
|
+
fs.writeFileSync(destPath, content, 'utf-8');
|
|
1392
1413
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1414
|
+
catch {
|
|
1415
|
+
// skip files that can't be read (binary, deleted in partial clone, etc.)
|
|
1395
1416
|
}
|
|
1396
1417
|
}
|
|
1397
|
-
|
|
1398
|
-
const
|
|
1399
|
-
const totalScore =
|
|
1400
|
-
const averageScore =
|
|
1418
|
+
// 4. Analyse the full project snapshot
|
|
1419
|
+
const fileReports = analyzeProject(tmpDir, config);
|
|
1420
|
+
const totalScore = fileReports.reduce((sum, r) => sum + r.score, 0);
|
|
1421
|
+
const averageScore = fileReports.length > 0 ? totalScore / fileReports.length : 0;
|
|
1422
|
+
// 5. Cleanup
|
|
1423
|
+
try {
|
|
1424
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// non-fatal — temp dirs are cleaned by the OS eventually
|
|
1428
|
+
}
|
|
1401
1429
|
return {
|
|
1402
1430
|
commitHash: hash ?? commitHash,
|
|
1403
1431
|
commitDate,
|
|
1404
1432
|
author: author ?? '',
|
|
1405
1433
|
message,
|
|
1406
|
-
files:
|
|
1434
|
+
files: fileReports,
|
|
1407
1435
|
totalScore,
|
|
1408
1436
|
averageScore,
|
|
1409
1437
|
};
|
|
@@ -1412,14 +1440,19 @@ async function analyzeSingleCommit(commitHash, targetPath) {
|
|
|
1412
1440
|
* Run historical analysis over all commits since a given date.
|
|
1413
1441
|
* Returns results ordered chronologically (oldest first).
|
|
1414
1442
|
*/
|
|
1415
|
-
async function analyzeHistoricalCommits(sinceDate, targetPath, maxCommits) {
|
|
1443
|
+
async function analyzeHistoricalCommits(sinceDate, targetPath, maxCommits, config, maxSamples = 10) {
|
|
1416
1444
|
assertGitRepo(targetPath);
|
|
1417
1445
|
const isoDate = sinceDate.toISOString();
|
|
1418
1446
|
const raw = execGit(`git log --since="${isoDate}" --format="%H" --max-count=${maxCommits}`, targetPath);
|
|
1419
1447
|
if (!raw)
|
|
1420
1448
|
return [];
|
|
1421
1449
|
const hashes = raw.split('\n').filter(Boolean);
|
|
1422
|
-
|
|
1450
|
+
// Sample: distribute evenly across the range
|
|
1451
|
+
// E.g. 122 commits, maxSamples=10 → pick index 0, 13, 26, 39, 52, 65, 78, 91, 104, 121
|
|
1452
|
+
const sampled = hashes.length <= maxSamples
|
|
1453
|
+
? hashes
|
|
1454
|
+
: Array.from({ length: maxSamples }, (_, i) => hashes[Math.floor(i * (hashes.length - 1) / (maxSamples - 1))]);
|
|
1455
|
+
const analyses = await Promise.all(sampled.map(h => analyzeSingleCommit(h, targetPath, config).catch(() => null)));
|
|
1423
1456
|
return analyses
|
|
1424
1457
|
.filter((a) => a !== null)
|
|
1425
1458
|
.sort((a, b) => a.commitDate.getTime() - b.commitDate.getTime());
|
|
@@ -1482,7 +1515,7 @@ export class TrendAnalyzer {
|
|
|
1482
1515
|
const sinceDate = options.since
|
|
1483
1516
|
? new Date(options.since)
|
|
1484
1517
|
: new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
1485
|
-
const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100);
|
|
1518
|
+
const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100, this.config, 10);
|
|
1486
1519
|
const trendPoints = historicalAnalyses.map(h => ({
|
|
1487
1520
|
date: h.commitDate,
|
|
1488
1521
|
score: h.averageScore,
|
package/package.json
CHANGED
package/src/analyzer.ts
CHANGED
|
@@ -1523,49 +1523,85 @@ async function analyzeFileAtCommit(
|
|
|
1523
1523
|
}
|
|
1524
1524
|
|
|
1525
1525
|
/**
|
|
1526
|
-
* Analyse
|
|
1526
|
+
* Analyse ALL TypeScript files in the project snapshot at a given commit.
|
|
1527
|
+
* Uses `git ls-tree` to enumerate every file in the tree, writes them to a
|
|
1528
|
+
* temp directory, then runs `analyzeProject` on that full snapshot so that
|
|
1529
|
+
* the resulting `averageScore` reflects the complete project health rather
|
|
1530
|
+
* than only the files touched in that diff.
|
|
1527
1531
|
*/
|
|
1528
1532
|
async function analyzeSingleCommit(
|
|
1529
1533
|
commitHash: string,
|
|
1530
1534
|
targetPath: string,
|
|
1535
|
+
config?: DriftConfig,
|
|
1531
1536
|
): Promise<HistoricalAnalysis> {
|
|
1532
|
-
//
|
|
1533
|
-
const
|
|
1534
|
-
`git show --
|
|
1537
|
+
// 1. Commit metadata
|
|
1538
|
+
const meta = execGit(
|
|
1539
|
+
`git show --no-patch --format="%H|%aI|%an|%s" ${commitHash}`,
|
|
1535
1540
|
targetPath,
|
|
1536
1541
|
)
|
|
1537
|
-
|
|
1538
|
-
const lines = raw.split('\n')
|
|
1539
|
-
// First non-empty line is the metadata line
|
|
1540
|
-
const metaLine = lines[0] ?? ''
|
|
1541
|
-
const [hash, dateStr, author, ...msgParts] = metaLine.split('|')
|
|
1542
|
+
const [hash, dateStr, author, ...msgParts] = meta.split('|')
|
|
1542
1543
|
const message = msgParts.join('|').trim()
|
|
1543
1544
|
const commitDate = new Date(dateStr ?? '')
|
|
1544
1545
|
|
|
1545
|
-
//
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1546
|
+
// 2. All .ts/.tsx files tracked at this commit (no diffs, full tree)
|
|
1547
|
+
const allFiles = execGit(
|
|
1548
|
+
`git ls-tree -r ${commitHash} --name-only`,
|
|
1549
|
+
targetPath,
|
|
1550
|
+
)
|
|
1551
|
+
.split('\n')
|
|
1552
|
+
.filter(
|
|
1553
|
+
f =>
|
|
1554
|
+
(f.endsWith('.ts') || f.endsWith('.tsx')) &&
|
|
1555
|
+
!f.endsWith('.d.ts') &&
|
|
1556
|
+
!f.includes('node_modules') &&
|
|
1557
|
+
!f.startsWith('dist/'),
|
|
1558
|
+
)
|
|
1559
|
+
|
|
1560
|
+
if (allFiles.length === 0) {
|
|
1561
|
+
return {
|
|
1562
|
+
commitHash: hash ?? commitHash,
|
|
1563
|
+
commitDate,
|
|
1564
|
+
author: author ?? '',
|
|
1565
|
+
message,
|
|
1566
|
+
files: [],
|
|
1567
|
+
totalScore: 0,
|
|
1568
|
+
averageScore: 0,
|
|
1552
1569
|
}
|
|
1553
1570
|
}
|
|
1554
1571
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
)
|
|
1572
|
+
// 3. Write snapshot to temp directory
|
|
1573
|
+
const tmpDir = path.join(os.tmpdir(), `drift-${(hash ?? commitHash).slice(0, 8)}`)
|
|
1574
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
1575
|
+
|
|
1576
|
+
for (const relPath of allFiles) {
|
|
1577
|
+
try {
|
|
1578
|
+
const content = execGit(`git show ${commitHash}:${relPath}`, targetPath)
|
|
1579
|
+
const destPath = path.join(tmpDir, relPath)
|
|
1580
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
1581
|
+
fs.writeFileSync(destPath, content, 'utf-8')
|
|
1582
|
+
} catch {
|
|
1583
|
+
// skip files that can't be read (binary, deleted in partial clone, etc.)
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1558
1586
|
|
|
1559
|
-
|
|
1560
|
-
const
|
|
1561
|
-
const
|
|
1587
|
+
// 4. Analyse the full project snapshot
|
|
1588
|
+
const fileReports = analyzeProject(tmpDir, config)
|
|
1589
|
+
const totalScore = fileReports.reduce((sum, r) => sum + r.score, 0)
|
|
1590
|
+
const averageScore = fileReports.length > 0 ? totalScore / fileReports.length : 0
|
|
1591
|
+
|
|
1592
|
+
// 5. Cleanup
|
|
1593
|
+
try {
|
|
1594
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
1595
|
+
} catch {
|
|
1596
|
+
// non-fatal — temp dirs are cleaned by the OS eventually
|
|
1597
|
+
}
|
|
1562
1598
|
|
|
1563
1599
|
return {
|
|
1564
1600
|
commitHash: hash ?? commitHash,
|
|
1565
1601
|
commitDate,
|
|
1566
1602
|
author: author ?? '',
|
|
1567
1603
|
message,
|
|
1568
|
-
files:
|
|
1604
|
+
files: fileReports,
|
|
1569
1605
|
totalScore,
|
|
1570
1606
|
averageScore,
|
|
1571
1607
|
}
|
|
@@ -1579,6 +1615,8 @@ async function analyzeHistoricalCommits(
|
|
|
1579
1615
|
sinceDate: Date,
|
|
1580
1616
|
targetPath: string,
|
|
1581
1617
|
maxCommits: number,
|
|
1618
|
+
config?: DriftConfig,
|
|
1619
|
+
maxSamples: number = 10,
|
|
1582
1620
|
): Promise<HistoricalAnalysis[]> {
|
|
1583
1621
|
assertGitRepo(targetPath)
|
|
1584
1622
|
|
|
@@ -1591,8 +1629,17 @@ async function analyzeHistoricalCommits(
|
|
|
1591
1629
|
if (!raw) return []
|
|
1592
1630
|
|
|
1593
1631
|
const hashes = raw.split('\n').filter(Boolean)
|
|
1632
|
+
|
|
1633
|
+
// Sample: distribute evenly across the range
|
|
1634
|
+
// E.g. 122 commits, maxSamples=10 → pick index 0, 13, 26, 39, 52, 65, 78, 91, 104, 121
|
|
1635
|
+
const sampled = hashes.length <= maxSamples
|
|
1636
|
+
? hashes
|
|
1637
|
+
: Array.from({ length: maxSamples }, (_, i) =>
|
|
1638
|
+
hashes[Math.floor(i * (hashes.length - 1) / (maxSamples - 1))]
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1594
1641
|
const analyses = await Promise.all(
|
|
1595
|
-
|
|
1642
|
+
sampled.map(h => analyzeSingleCommit(h, targetPath, config).catch(() => null)),
|
|
1596
1643
|
)
|
|
1597
1644
|
|
|
1598
1645
|
return analyses
|
|
@@ -1678,7 +1725,7 @@ export class TrendAnalyzer {
|
|
|
1678
1725
|
? new Date(options.since)
|
|
1679
1726
|
: new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
|
1680
1727
|
|
|
1681
|
-
const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100)
|
|
1728
|
+
const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100, this.config, 10)
|
|
1682
1729
|
|
|
1683
1730
|
const trendPoints: TrendDataPoint[] = historicalAnalyses.map(h => ({
|
|
1684
1731
|
date: h.commitDate,
|