Skip to content

Commit 6e35ec6

Browse files
committed
feat(cli): add report, badge, and ci commands (Phase 6)
- drift report [path]: generates self-contained drift-report.html with score, per-file breakdown, collapsible issue list, and snippets - drift badge [path]: generates badge.svg with current drift score and grade label (LOW/MODERATE/HIGH/CRITICAL) for README embedding - drift ci [path]: emits GitHub Actions workflow annotations inline on PR diffs, writes step summary to GITHUB_STEP_SUMMARY, supports --min-score to gate PRs with exit code 1 New modules: src/report.ts, src/badge.ts, src/ci.ts Bump version to 0.6.0
1 parent 987ed0c commit 6e35ec6

8 files changed

Lines changed: 764 additions & 4 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ node_modules/
22
dist/
33
*.js.map
44
*.d.ts.map
5+
drift-report.html
6+
badge.svg

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1111

1212
### Added
1313

14+
- **Phase 6: Static HTML report + README badge** — output commands for visibility
15+
- `drift report [path]` — generates a self-contained `drift-report.html` with score, per-file breakdown, collapsible issue list, and fix suggestions
16+
- `drift badge [path]` — generates a `badge.svg` with the current drift score for your README
17+
- `drift ci [path]` — emits GitHub Actions workflow annotations inline on PR diffs and writes a step summary; supports `--min-score` to gate PRs
18+
19+
---
20+
21+
## [0.5.0] — 2026-02-24
22+
23+
### Added
24+
1425
- **Phase 5: AI authorship heuristics** — 5 new rules that detect patterns AI code generators produce
1526
- `over-commented` (info, weight 4): functions where comment density ≥ 40% — AI over-documents the obvious
1627
- `hardcoded-config` (warning, weight 10): hardcoded URLs, IPs, or connection strings instead of env vars

README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,40 @@ drift diff --json # Output raw JSON diff
105105

106106
Shows score delta, new issues introduced, and issues resolved per file.
107107

108+
### `drift report [path]`
109+
110+
Generate a self-contained `drift-report.html` — open in any browser:
111+
112+
```bash
113+
drift report # scan current directory
114+
drift report ./src # scan specific path
115+
```
116+
117+
No server needed. The file embeds all styles and data inline.
118+
119+
### `drift badge [path]`
120+
121+
Generate a `badge.svg` with the current score for your README:
122+
123+
```bash
124+
drift badge # writes badge.svg to current directory
125+
drift badge ./src # scan specific path
126+
```
127+
128+
Drop the generated file in your repo and reference it as a local badge.
129+
130+
### `drift ci [path]`
131+
132+
Emit GitHub Actions annotations and step summary:
133+
134+
```bash
135+
drift ci # scan current directory
136+
drift ci ./src # scan specific path
137+
drift ci --min-score 60 # exit code 1 if score exceeds threshold
138+
```
139+
140+
Outputs inline annotations visible in the PR diff. Use `--min-score` to gate merges.
141+
108142
### AI Integration
109143

110144
Use `--ai` to get structured output that LLMs can consume:
@@ -183,9 +217,21 @@ Without a config file, these two rules are silently skipped. All other rules run
183217

184218
---
185219

186-
## ⚙️ CI Integration
220+
## ⚙️ CI / GitHub Actions
221+
222+
Add drift to your PR workflow to gate on score and get inline annotations:
223+
224+
```yaml
225+
- name: Run drift
226+
run: npx @eduardbar/drift ci --min-score 60
227+
```
228+
229+
This will:
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
187233
188-
Drop this into your GitHub Actions workflow to block merges when drift exceeds your threshold:
234+
If you only need a pass/fail gate without annotations, `scan` works too:
189235

190236
```yaml
191237
- name: Check for vibe coding drift

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eduardbar/drift",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Detect silent technical debt left by AI-generated code",
55
"type": "module",
66
"main": "dist/index.js",

src/badge.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type {} from './types.js'
2+
3+
const LEFT_WIDTH = 47
4+
const CHAR_WIDTH = 7
5+
const PADDING = 16
6+
7+
function scoreColor(score: number): string {
8+
if (score < 20) return '#4c1'
9+
if (score < 45) return '#dfb317'
10+
if (score < 70) return '#fe7d37'
11+
return '#e05d44'
12+
}
13+
14+
function scoreLabel(score: number): string {
15+
if (score < 20) return 'LOW'
16+
if (score < 45) return 'MODERATE'
17+
if (score < 70) return 'HIGH'
18+
return 'CRITICAL'
19+
}
20+
21+
function rightWidth(text: string): number {
22+
return text.length * CHAR_WIDTH + PADDING
23+
}
24+
25+
export function generateBadge(score: number): string {
26+
const valueText = `${score} ${scoreLabel(score)}`
27+
const color = scoreColor(score)
28+
29+
const rWidth = rightWidth(valueText)
30+
const totalWidth = LEFT_WIDTH + rWidth
31+
32+
const leftCenterX = LEFT_WIDTH / 2
33+
const rightCenterX = LEFT_WIDTH + rWidth / 2
34+
35+
// shields.io pattern: font-size="110" + scale(.1) = effective 11px
36+
// all X/Y coords are ×10
37+
const leftTextWidth = (LEFT_WIDTH - 10) * 10
38+
const rightTextWidth = (rWidth - PADDING) * 10
39+
40+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
41+
<linearGradient id="s" x2="0" y2="100%">
42+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
43+
<stop offset="1" stop-opacity=".1"/>
44+
</linearGradient>
45+
<clipPath id="r">
46+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
47+
</clipPath>
48+
<g clip-path="url(#r)">
49+
<rect width="${LEFT_WIDTH}" height="20" fill="#555"/>
50+
<rect x="${LEFT_WIDTH}" width="${rWidth}" height="20" fill="${color}"/>
51+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
52+
</g>
53+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
54+
<text x="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
55+
<text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
56+
<text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
57+
<text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
58+
</g>
59+
</svg>`
60+
}

src/ci.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { writeFileSync } from 'node:fs'
2+
import { relative } from 'node:path'
3+
import type { DriftReport } from './types.js'
4+
5+
function encodeMessage(msg: string): string {
6+
return msg
7+
.replace(/%/g, '%25')
8+
.replace(/\r/g, '%0D')
9+
.replace(/\n/g, '%0A')
10+
.replace(/:/g, '%3A')
11+
.replace(/,/g, '%2C')
12+
}
13+
14+
function severityToAnnotation(s: string): 'error' | 'warning' | 'notice' {
15+
if (s === 'error') return 'error'
16+
if (s === 'warning') return 'warning'
17+
return 'notice'
18+
}
19+
20+
function scoreLabel(score: number): string {
21+
if (score >= 80) return 'A'
22+
if (score >= 60) return 'B'
23+
if (score >= 40) return 'C'
24+
if (score >= 20) return 'D'
25+
return 'F'
26+
}
27+
28+
export function emitCIAnnotations(report: DriftReport): void {
29+
for (const file of report.files) {
30+
for (const issue of file.issues) {
31+
const level = severityToAnnotation(issue.severity)
32+
const relPath = relative(process.cwd(), file.path).replace(/\\/g, '/')
33+
const msg = encodeMessage(`[drift/${issue.rule}] ${issue.message}`)
34+
const line = issue.line ?? 1
35+
const col = issue.column ?? 1
36+
process.stdout.write(`::${level} file=${relPath},line=${line},col=${col}::${msg}\n`)
37+
}
38+
}
39+
}
40+
41+
export function printCISummary(report: DriftReport): void {
42+
const summaryPath = process.env['GITHUB_STEP_SUMMARY']
43+
if (!summaryPath) return
44+
45+
const score = report.totalScore
46+
const grade = scoreLabel(score)
47+
48+
let errors = 0
49+
let warnings = 0
50+
let info = 0
51+
52+
for (const file of report.files) {
53+
for (const issue of file.issues) {
54+
if (issue.severity === 'error') errors++
55+
else if (issue.severity === 'warning') warnings++
56+
else info++
57+
}
58+
}
59+
60+
const sorted = [...report.files]
61+
.sort((a, b) => b.issues.length - a.issues.length)
62+
.slice(0, 10)
63+
64+
const rows = sorted
65+
.map((f) => {
66+
const relPath = relative(process.cwd(), f.path).replace(/\\/g, '/')
67+
return `| ${relPath} | ${f.score} | ${f.issues.length} |`
68+
})
69+
.join('\n')
70+
71+
const md = [
72+
'## drift scan results',
73+
'',
74+
`**Score:** ${score}/100 — Grade **${grade}**`,
75+
'',
76+
'### Top files by issue count',
77+
'',
78+
'| File | Score | Issues |',
79+
'|------|-------|--------|',
80+
rows,
81+
'',
82+
`**Total issues:** ${errors} errors, ${warnings} warnings, ${info} info`,
83+
'',
84+
].join('\n')
85+
86+
writeFileSync(summaryPath, md, { flag: 'a' })
87+
}

src/cli.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import { printConsole, printDiff } from './printer.js'
88
import { loadConfig } from './config.js'
99
import { extractFilesAtRef, cleanupTempDir } from './git.js'
1010
import { computeDiff } from './diff.js'
11+
import { generateHtmlReport } from './report.js'
12+
import { generateBadge } from './badge.js'
13+
import { emitCIAnnotations, printCISummary } from './ci.js'
1114

1215
const program = new Command()
1316

1417
program
1518
.name('drift')
1619
.description('Detect silent technical debt left by AI-generated code')
17-
.version('0.1.0')
20+
.version('0.6.0')
1821

1922
program
2023
.command('scan [path]', { isDefault: true })
@@ -109,4 +112,55 @@ program
109112
}
110113
})
111114

115+
program
116+
.command('report [path]')
117+
.description('Generate a self-contained HTML report')
118+
.option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
119+
.action(async (targetPath: string | undefined, options: { output: string }) => {
120+
const resolvedPath = resolve(targetPath ?? '.')
121+
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
122+
const config = await loadConfig(resolvedPath)
123+
const files = analyzeProject(resolvedPath, config)
124+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
125+
const report = buildReport(resolvedPath, files)
126+
const html = generateHtmlReport(report)
127+
const outPath = resolve(options.output)
128+
writeFileSync(outPath, html, 'utf8')
129+
process.stderr.write(` Report saved to ${outPath}\n\n`)
130+
})
131+
132+
program
133+
.command('badge [path]')
134+
.description('Generate a badge.svg with the current drift score')
135+
.option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
136+
.action(async (targetPath: string | undefined, options: { output: string }) => {
137+
const resolvedPath = resolve(targetPath ?? '.')
138+
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
139+
const config = await loadConfig(resolvedPath)
140+
const files = analyzeProject(resolvedPath, config)
141+
const report = buildReport(resolvedPath, files)
142+
const svg = generateBadge(report.totalScore)
143+
const outPath = resolve(options.output)
144+
writeFileSync(outPath, svg, 'utf8')
145+
process.stderr.write(` Badge saved to ${outPath}\n`)
146+
process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
147+
})
148+
149+
program
150+
.command('ci [path]')
151+
.description('Emit GitHub Actions annotations and step summary')
152+
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
153+
.action(async (targetPath: string | undefined, options: { minScore: string }) => {
154+
const resolvedPath = resolve(targetPath ?? '.')
155+
const config = await loadConfig(resolvedPath)
156+
const files = analyzeProject(resolvedPath, config)
157+
const report = buildReport(resolvedPath, files)
158+
emitCIAnnotations(report)
159+
printCISummary(report)
160+
const minScore = Number(options.minScore)
161+
if (minScore > 0 && report.totalScore > minScore) {
162+
process.exit(1)
163+
}
164+
})
165+
112166
program.parse()

0 commit comments

Comments
 (0)