@eduardbar/drift 0.2.3 → 0.4.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/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +39 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/AGENTS.md +229 -0
- package/CHANGELOG.md +105 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +125 -0
- package/LICENSE +21 -0
- package/README.md +89 -7
- package/ROADMAP.md +213 -0
- package/assets/og-v030-linkedin.png +0 -0
- package/assets/og-v030-linkedin.svg +120 -0
- package/assets/og-v030-x.png +0 -0
- package/assets/og-v030-x.svg +94 -0
- package/content-v030.txt +165 -0
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +630 -2
- package/dist/cli.js +61 -5
- package/dist/config.d.ts +12 -0
- package/dist/config.js +40 -0
- package/dist/diff.d.ts +10 -0
- package/dist/diff.js +58 -0
- package/dist/git.d.ts +19 -0
- package/dist/git.js +84 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/printer.d.ts +5 -2
- package/dist/printer.js +157 -2
- package/dist/reporter.d.ts +2 -1
- package/dist/reporter.js +86 -0
- package/dist/types.d.ts +70 -0
- package/package.json +9 -1
- package/src/analyzer.ts +688 -3
- package/src/cli.ts +66 -5
- package/src/config.ts +45 -0
- package/src/diff.ts +74 -0
- package/src/git.ts +98 -0
- package/src/index.ts +2 -1
- package/src/printer.ts +175 -3
- package/src/reporter.ts +94 -1
- package/src/types.ts +85 -0
package/src/cli.ts
CHANGED
|
@@ -3,8 +3,11 @@ import { Command } from 'commander'
|
|
|
3
3
|
import { writeFileSync } from 'node:fs'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
5
|
import { analyzeProject } from './analyzer.js'
|
|
6
|
-
import { buildReport, formatMarkdown } from './reporter.js'
|
|
7
|
-
import { printConsole } from './printer.js'
|
|
6
|
+
import { buildReport, formatMarkdown, formatAIOutput } from './reporter.js'
|
|
7
|
+
import { printConsole, printDiff } from './printer.js'
|
|
8
|
+
import { loadConfig } from './config.js'
|
|
9
|
+
import { extractFilesAtRef, cleanupTempDir } from './git.js'
|
|
10
|
+
import { computeDiff } from './diff.js'
|
|
8
11
|
|
|
9
12
|
const program = new Command()
|
|
10
13
|
|
|
@@ -18,21 +21,30 @@ program
|
|
|
18
21
|
.description('Scan a directory for vibe coding drift')
|
|
19
22
|
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
20
23
|
.option('--json', 'Output raw JSON report')
|
|
24
|
+
.option('--ai', 'Output AI-optimized JSON for LLM consumption')
|
|
25
|
+
.option('--fix', 'Show fix suggestions for each issue')
|
|
21
26
|
.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 }) => {
|
|
27
|
+
.action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string }) => {
|
|
23
28
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
24
29
|
|
|
25
30
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
26
|
-
const
|
|
31
|
+
const config = await loadConfig(resolvedPath)
|
|
32
|
+
const files = analyzeProject(resolvedPath, config)
|
|
27
33
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
28
34
|
const report = buildReport(resolvedPath, files)
|
|
29
35
|
|
|
36
|
+
if (options.ai) {
|
|
37
|
+
const aiOutput = formatAIOutput(report)
|
|
38
|
+
process.stdout.write(JSON.stringify(aiOutput, null, 2))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
if (options.json) {
|
|
31
43
|
process.stdout.write(JSON.stringify(report, null, 2))
|
|
32
44
|
return
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
printConsole(report)
|
|
47
|
+
printConsole(report, { showFix: options.fix })
|
|
36
48
|
|
|
37
49
|
if (options.output) {
|
|
38
50
|
const md = formatMarkdown(report)
|
|
@@ -48,4 +60,53 @@ program
|
|
|
48
60
|
}
|
|
49
61
|
})
|
|
50
62
|
|
|
63
|
+
program
|
|
64
|
+
.command('diff [ref]')
|
|
65
|
+
.description('Compare current state against a git ref (default: HEAD~1)')
|
|
66
|
+
.option('--json', 'Output raw JSON diff')
|
|
67
|
+
.action(async (ref: string | undefined, options: { json?: boolean }) => {
|
|
68
|
+
const baseRef = ref ?? 'HEAD~1'
|
|
69
|
+
const projectPath = resolve('.')
|
|
70
|
+
|
|
71
|
+
let tempDir: string | undefined
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`)
|
|
75
|
+
|
|
76
|
+
// Scan current state
|
|
77
|
+
const config = await loadConfig(projectPath)
|
|
78
|
+
const currentFiles = analyzeProject(projectPath, config)
|
|
79
|
+
const currentReport = buildReport(projectPath, currentFiles)
|
|
80
|
+
|
|
81
|
+
// Extract base state from git
|
|
82
|
+
tempDir = extractFilesAtRef(projectPath, baseRef)
|
|
83
|
+
const baseFiles = analyzeProject(tempDir, config)
|
|
84
|
+
|
|
85
|
+
// Remap base file paths to match current project paths
|
|
86
|
+
// (temp dir paths → project paths for accurate comparison)
|
|
87
|
+
const baseReport = buildReport(tempDir, baseFiles)
|
|
88
|
+
const remappedBase = {
|
|
89
|
+
...baseReport,
|
|
90
|
+
files: baseReport.files.map(f => ({
|
|
91
|
+
...f,
|
|
92
|
+
path: f.path.replace(tempDir!, projectPath),
|
|
93
|
+
})),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const diff = computeDiff(remappedBase, currentReport, baseRef)
|
|
97
|
+
|
|
98
|
+
if (options.json) {
|
|
99
|
+
process.stdout.write(JSON.stringify(diff, null, 2) + '\n')
|
|
100
|
+
} else {
|
|
101
|
+
printDiff(diff)
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
105
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
106
|
+
process.exit(1)
|
|
107
|
+
} finally {
|
|
108
|
+
if (tempDir) cleanupTempDir(tempDir)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
51
112
|
program.parse()
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import type { DriftConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load drift.config.ts / .js / .json from the given project root.
|
|
8
|
+
* Returns undefined if no config file is found.
|
|
9
|
+
*
|
|
10
|
+
* Search order (first match wins):
|
|
11
|
+
* 1. drift.config.ts
|
|
12
|
+
* 2. drift.config.js
|
|
13
|
+
* 3. drift.config.json
|
|
14
|
+
*/
|
|
15
|
+
export async function loadConfig(projectRoot: string): Promise<DriftConfig | undefined> {
|
|
16
|
+
const candidates = [
|
|
17
|
+
join(projectRoot, 'drift.config.ts'),
|
|
18
|
+
join(projectRoot, 'drift.config.js'),
|
|
19
|
+
join(projectRoot, 'drift.config.json'),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
if (!existsSync(candidate)) continue
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const ext = candidate.split('.').pop()
|
|
27
|
+
|
|
28
|
+
if (ext === 'json') {
|
|
29
|
+
const { readFileSync } = await import('node:fs')
|
|
30
|
+
return JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// .ts / .js — dynamic import via file URL
|
|
34
|
+
const fileUrl = pathToFileURL(resolve(candidate)).href
|
|
35
|
+
const mod = await import(fileUrl)
|
|
36
|
+
const config: DriftConfig = mod.default ?? mod
|
|
37
|
+
|
|
38
|
+
return config
|
|
39
|
+
} catch {
|
|
40
|
+
// drift-ignore: catch-swallow — config is optional; load failure is non-fatal
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute the diff between two DriftReports.
|
|
5
|
+
*
|
|
6
|
+
* Issues are matched by (rule + line + column) as a unique key within a file.
|
|
7
|
+
* A "new" issue exists in `current` but not in `base`.
|
|
8
|
+
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
|
+
*/
|
|
10
|
+
export function computeDiff(
|
|
11
|
+
base: DriftReport,
|
|
12
|
+
current: DriftReport,
|
|
13
|
+
baseRef: string,
|
|
14
|
+
): DriftDiff {
|
|
15
|
+
const fileDiffs: FileDiff[] = []
|
|
16
|
+
|
|
17
|
+
// Build a map of base files by path for O(1) lookup
|
|
18
|
+
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
19
|
+
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
20
|
+
|
|
21
|
+
// All unique paths across both reports
|
|
22
|
+
const allPaths = new Set([
|
|
23
|
+
...base.files.map(f => f.path),
|
|
24
|
+
...current.files.map(f => f.path),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
for (const filePath of allPaths) {
|
|
28
|
+
const baseFile = baseByPath.get(filePath)
|
|
29
|
+
const currentFile = currentByPath.get(filePath)
|
|
30
|
+
|
|
31
|
+
const scoreBefore = baseFile?.score ?? 0
|
|
32
|
+
const scoreAfter = currentFile?.score ?? 0
|
|
33
|
+
const scoreDelta = scoreAfter - scoreBefore
|
|
34
|
+
|
|
35
|
+
const baseIssues = baseFile?.issues ?? []
|
|
36
|
+
const currentIssues = currentFile?.issues ?? []
|
|
37
|
+
|
|
38
|
+
// Issue identity key: rule + line + column
|
|
39
|
+
const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
+
|
|
41
|
+
const baseKeys = new Set(baseIssues.map(issueKey))
|
|
42
|
+
const currentKeys = new Set(currentIssues.map(issueKey))
|
|
43
|
+
|
|
44
|
+
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
|
|
45
|
+
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
|
|
46
|
+
|
|
47
|
+
// Only include files that have actual changes
|
|
48
|
+
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
49
|
+
fileDiffs.push({
|
|
50
|
+
path: filePath,
|
|
51
|
+
scoreBefore,
|
|
52
|
+
scoreAfter,
|
|
53
|
+
scoreDelta,
|
|
54
|
+
newIssues,
|
|
55
|
+
resolvedIssues,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sort: most regressed first, then most improved last
|
|
61
|
+
fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
baseRef,
|
|
65
|
+
projectPath: current.targetPath,
|
|
66
|
+
scannedAt: new Date().toISOString(),
|
|
67
|
+
files: fileDiffs,
|
|
68
|
+
totalScoreBefore: base.totalScore,
|
|
69
|
+
totalScoreAfter: current.totalScore,
|
|
70
|
+
totalDelta: current.totalScore - base.totalScore,
|
|
71
|
+
newIssuesCount: fileDiffs.reduce((sum, f) => sum + f.newIssues.length, 0),
|
|
72
|
+
resolvedIssuesCount: fileDiffs.reduce((sum, f) => sum + f.resolvedIssues.length, 0),
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join, sep } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract all TypeScript files from the project at a given git ref into a
|
|
9
|
+
* temporary directory. Returns the temp directory path.
|
|
10
|
+
*
|
|
11
|
+
* Uses `git ls-tree` to list files and `git show <ref>:<path>` to read each
|
|
12
|
+
* file — no checkout, no stash, no repo state mutation.
|
|
13
|
+
*
|
|
14
|
+
* Throws if the directory is not a git repo or the ref is invalid.
|
|
15
|
+
*/
|
|
16
|
+
export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
17
|
+
// Verify git repo
|
|
18
|
+
try {
|
|
19
|
+
execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(`Not a git repository: ${projectPath}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Verify ref exists
|
|
25
|
+
try {
|
|
26
|
+
execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// List all .ts files tracked at this ref (excluding .d.ts)
|
|
32
|
+
let fileList: string
|
|
33
|
+
try {
|
|
34
|
+
fileList = execSync(
|
|
35
|
+
`git ls-tree -r --name-only ${ref}`,
|
|
36
|
+
{ cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
|
|
37
|
+
)
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`Failed to list files at ref '${ref}'`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tsFiles = fileList
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(f => f.trim())
|
|
45
|
+
.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'))
|
|
46
|
+
|
|
47
|
+
if (tsFiles.length === 0) {
|
|
48
|
+
throw new Error(`No TypeScript files found at ref '${ref}'`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create temp directory
|
|
52
|
+
const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
|
|
53
|
+
mkdirSync(tempDir, { recursive: true })
|
|
54
|
+
|
|
55
|
+
// Extract each file
|
|
56
|
+
for (const filePath of tsFiles) {
|
|
57
|
+
let content: string
|
|
58
|
+
try {
|
|
59
|
+
content = execSync(
|
|
60
|
+
`git show ${ref}:${filePath}`,
|
|
61
|
+
{ cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
|
|
62
|
+
)
|
|
63
|
+
} catch {
|
|
64
|
+
// File may not exist at this ref — skip
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const destPath = join(tempDir, filePath.split('/').join(sep))
|
|
69
|
+
const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
|
|
70
|
+
mkdirSync(destDir, { recursive: true })
|
|
71
|
+
writeFileSync(destPath, content, 'utf-8')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return tempDir
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clean up a temporary directory created by extractFilesAtRef.
|
|
79
|
+
*/
|
|
80
|
+
export function cleanupTempDir(tempDir: string): void {
|
|
81
|
+
if (existsSync(tempDir)) {
|
|
82
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the short hash of a git ref for display purposes.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveRefHash(projectPath: string, ref: string): string {
|
|
90
|
+
try {
|
|
91
|
+
return execSync(
|
|
92
|
+
`git rev-parse --short ${ref}`,
|
|
93
|
+
{ cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
|
|
94
|
+
).trim()
|
|
95
|
+
} catch {
|
|
96
|
+
return ref
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { analyzeProject, analyzeFile } from './analyzer.js'
|
|
2
2
|
export { buildReport, formatMarkdown } from './reporter.js'
|
|
3
|
-
export
|
|
3
|
+
export { computeDiff } from './diff.js'
|
|
4
|
+
export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff } from './types.js'
|
package/src/printer.ts
CHANGED
|
@@ -1,9 +1,103 @@
|
|
|
1
1
|
// drift-ignore-file
|
|
2
2
|
import kleur from 'kleur'
|
|
3
|
-
import type { DriftReport } from './types.js'
|
|
3
|
+
import type { DriftIssue, DriftReport, DriftDiff } from './types.js'
|
|
4
4
|
import { scoreToGrade, severityIcon, scoreBar } from './utils.js'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
7
|
+
const suggestions: Record<string, string[]> = {
|
|
8
|
+
'debug-leftover': [
|
|
9
|
+
'Remove this console.log statement',
|
|
10
|
+
'Or replace with a proper logging library',
|
|
11
|
+
],
|
|
12
|
+
'any-abuse': [
|
|
13
|
+
"Replace 'any' with 'unknown' for type safety",
|
|
14
|
+
'Or define a proper interface/type for this data',
|
|
15
|
+
],
|
|
16
|
+
'dead-code': [
|
|
17
|
+
'Remove this unused import',
|
|
18
|
+
],
|
|
19
|
+
'catch-swallow': [
|
|
20
|
+
'Add error handling: console.error(error) or logger.error(error)',
|
|
21
|
+
'Or re-throw if this should bubble up: throw error',
|
|
22
|
+
],
|
|
23
|
+
'large-function': [
|
|
24
|
+
'Extract logic into smaller functions',
|
|
25
|
+
'Each function should do one thing',
|
|
26
|
+
],
|
|
27
|
+
'large-file': [
|
|
28
|
+
'Split into multiple files by responsibility',
|
|
29
|
+
'Consider using a directory with index.ts',
|
|
30
|
+
],
|
|
31
|
+
'no-return-type': [
|
|
32
|
+
'Add explicit return type: function foo(): ReturnType',
|
|
33
|
+
],
|
|
34
|
+
'duplicate-function-name': [
|
|
35
|
+
'Consolidate with existing function',
|
|
36
|
+
'Or rename to clarify different behavior',
|
|
37
|
+
],
|
|
38
|
+
'high-complexity': [
|
|
39
|
+
'Extract each branch into a named function',
|
|
40
|
+
'Use early returns to reduce nesting and branching',
|
|
41
|
+
'Consider a strategy pattern or lookup table for switch-heavy logic',
|
|
42
|
+
],
|
|
43
|
+
'deep-nesting': [
|
|
44
|
+
'Invert conditions and return early instead of nesting',
|
|
45
|
+
'Extract inner blocks into separate functions',
|
|
46
|
+
'Flatten promise chains with async/await',
|
|
47
|
+
],
|
|
48
|
+
'too-many-params': [
|
|
49
|
+
'Group related params into an options object: foo({ a, b, c, d, e })',
|
|
50
|
+
'Consider if this function is doing too many things',
|
|
51
|
+
],
|
|
52
|
+
'high-coupling': [
|
|
53
|
+
'Group related imports into a single module',
|
|
54
|
+
'Consider if this file has too many responsibilities',
|
|
55
|
+
'Extract a sub-module that encapsulates some of these dependencies',
|
|
56
|
+
],
|
|
57
|
+
'promise-style-mix': [
|
|
58
|
+
'Pick one style and use it consistently: async/await is preferred',
|
|
59
|
+
'Convert .then()/.catch() chains to async/await',
|
|
60
|
+
],
|
|
61
|
+
'magic-number': [
|
|
62
|
+
'Extract to a named constant: const MAX_RETRIES = 3',
|
|
63
|
+
'Use an enum for related numeric values',
|
|
64
|
+
],
|
|
65
|
+
'comment-contradiction': [
|
|
66
|
+
'Remove the comment — the code already says what it does',
|
|
67
|
+
'Replace with a comment explaining WHY, not what: // retry because upstream is flaky',
|
|
68
|
+
],
|
|
69
|
+
'unused-export': [
|
|
70
|
+
"Remove the export keyword if it's only used internally",
|
|
71
|
+
'Or delete the declaration entirely if it serves no purpose',
|
|
72
|
+
],
|
|
73
|
+
'dead-file': [
|
|
74
|
+
'Delete the file if it is no longer needed',
|
|
75
|
+
'Or import it from an entry point if it should be active',
|
|
76
|
+
],
|
|
77
|
+
'unused-dependency': [
|
|
78
|
+
'Remove it from package.json: npm uninstall <pkg>',
|
|
79
|
+
'Or verify it is used transitively and document why it is kept',
|
|
80
|
+
],
|
|
81
|
+
'circular-dependency': [
|
|
82
|
+
'Introduce an abstraction (interface or shared module) that both files depend on',
|
|
83
|
+
'Move shared logic to a third file that neither of the cyclic modules imports',
|
|
84
|
+
'Use dependency injection to break the compile-time dependency',
|
|
85
|
+
],
|
|
86
|
+
'layer-violation': [
|
|
87
|
+
'Move the import to a layer that is allowed to access this dependency',
|
|
88
|
+
'Introduce a port/interface in the domain layer to invert the dependency (Dependency Inversion Principle)',
|
|
89
|
+
'Or adjust the layer rules in drift.config.ts if this import is intentional',
|
|
90
|
+
],
|
|
91
|
+
'cross-boundary-import': [
|
|
92
|
+
"Import from the module's public API barrel (index.ts) instead of internal paths",
|
|
93
|
+
'Or add the module to allowedExternalImports in drift.config.ts if this is intentional',
|
|
94
|
+
'Consider using dependency injection or an event bus to decouple the modules',
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function printConsole(report: DriftReport, options?: { showFix?: boolean }): void {
|
|
7
101
|
const sep = kleur.gray(' ' + '─'.repeat(50))
|
|
8
102
|
|
|
9
103
|
console.log()
|
|
@@ -72,7 +166,18 @@ export function printConsole(report: DriftReport): void {
|
|
|
72
166
|
` ` +
|
|
73
167
|
kleur.white(issue.message)
|
|
74
168
|
)
|
|
75
|
-
if (
|
|
169
|
+
if (options?.showFix) {
|
|
170
|
+
const fixes = formatFixSuggestion(issue)
|
|
171
|
+
console.log(kleur.gray(' ┌──────────────────────────────────────────────────────┐'))
|
|
172
|
+
if (issue.snippet) {
|
|
173
|
+
const line = issue.snippet.split('\n')[0].slice(0, 48)
|
|
174
|
+
console.log(kleur.gray(' │ ') + kleur.red('- ' + line))
|
|
175
|
+
}
|
|
176
|
+
for (const fix of fixes) {
|
|
177
|
+
console.log(kleur.gray(' │ ') + kleur.green('+ ' + fix))
|
|
178
|
+
}
|
|
179
|
+
console.log(kleur.gray(' └──────────────────────────────────────────────────────┘'))
|
|
180
|
+
} else if (issue.snippet) {
|
|
76
181
|
const snippetIndent = ' ' + ' '.repeat(icon.length + 1)
|
|
77
182
|
console.log(kleur.gray(`${snippetIndent}${issue.snippet.split('\n')[0].slice(0, 120)}`))
|
|
78
183
|
}
|
|
@@ -80,3 +185,70 @@ export function printConsole(report: DriftReport): void {
|
|
|
80
185
|
console.log()
|
|
81
186
|
}
|
|
82
187
|
}
|
|
188
|
+
|
|
189
|
+
export function printDiff(diff: DriftDiff): void {
|
|
190
|
+
const { totalDelta, totalScoreBefore, totalScoreAfter, newIssuesCount, resolvedIssuesCount } = diff
|
|
191
|
+
|
|
192
|
+
const deltaSign = totalDelta > 0 ? '+' : ''
|
|
193
|
+
const deltaColor = totalDelta > 0 ? kleur.red : totalDelta < 0 ? kleur.green : kleur.white
|
|
194
|
+
const baseGrade = scoreToGrade(totalScoreBefore)
|
|
195
|
+
const headGrade = scoreToGrade(totalScoreAfter)
|
|
196
|
+
|
|
197
|
+
console.log()
|
|
198
|
+
console.log(kleur.bold(' drift diff') + kleur.gray(` — comparing HEAD vs ${diff.baseRef}`))
|
|
199
|
+
console.log(' ' + '─'.repeat(50))
|
|
200
|
+
console.log()
|
|
201
|
+
console.log(
|
|
202
|
+
` Score ${kleur.bold(String(totalScoreBefore))} ${baseGrade.badge} → ` +
|
|
203
|
+
`${kleur.bold(String(totalScoreAfter))} ${headGrade.badge} ` +
|
|
204
|
+
deltaColor(`(${deltaSign}${totalDelta})`)
|
|
205
|
+
)
|
|
206
|
+
console.log()
|
|
207
|
+
|
|
208
|
+
if (newIssuesCount > 0) {
|
|
209
|
+
console.log(` ${kleur.red(`▲ ${newIssuesCount} new issue${newIssuesCount !== 1 ? 's' : ''} introduced`)}`)
|
|
210
|
+
}
|
|
211
|
+
if (resolvedIssuesCount > 0) {
|
|
212
|
+
console.log(` ${kleur.green(`▼ ${resolvedIssuesCount} issue${resolvedIssuesCount !== 1 ? 's' : ''} resolved`)}`)
|
|
213
|
+
}
|
|
214
|
+
if (newIssuesCount === 0 && resolvedIssuesCount === 0) {
|
|
215
|
+
console.log(` ${kleur.gray('No issue changes detected')}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (diff.files.length === 0) {
|
|
219
|
+
console.log()
|
|
220
|
+
console.log(` ${kleur.gray('No file-level changes detected')}`)
|
|
221
|
+
console.log()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
console.log()
|
|
226
|
+
console.log(' ' + '─'.repeat(50))
|
|
227
|
+
console.log()
|
|
228
|
+
|
|
229
|
+
for (const file of diff.files) {
|
|
230
|
+
const rel = file.path.replace(/\\/g, '/').split('/').pop() ?? file.path
|
|
231
|
+
const fileDeltaSign = file.scoreDelta > 0 ? '+' : ''
|
|
232
|
+
const fileDeltaColor = file.scoreDelta > 0 ? kleur.red : kleur.green
|
|
233
|
+
|
|
234
|
+
console.log(
|
|
235
|
+
` ${kleur.bold(rel)}` +
|
|
236
|
+
` ${kleur.gray(`${file.scoreBefore} → ${file.scoreAfter}`)}` +
|
|
237
|
+
` ${fileDeltaColor(`${fileDeltaSign}${file.scoreDelta}`)}`
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
for (const issue of file.newIssues) {
|
|
241
|
+
console.log(
|
|
242
|
+
` ${kleur.red('+')} ${severityIcon(issue.severity)} ` +
|
|
243
|
+
`${kleur.yellow(issue.rule)} ${kleur.gray(`L${issue.line}`)} ${issue.message}`
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
for (const issue of file.resolvedIssues) {
|
|
247
|
+
console.log(
|
|
248
|
+
` ${kleur.green('-')} ${severityIcon(issue.severity)} ` +
|
|
249
|
+
`${kleur.yellow(issue.rule)} ${kleur.gray(`L${issue.line}`)} ${issue.message}`
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
console.log()
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/reporter.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
import type { FileReport, DriftReport, DriftIssue } from './types.js'
|
|
1
|
+
import type { FileReport, DriftReport, DriftIssue, AIOutput, AIIssue } from './types.js'
|
|
2
2
|
import { scoreToGradeText, severityIcon } from './utils.js'
|
|
3
3
|
|
|
4
|
+
const FIX_SUGGESTIONS: Record<string, string> = {
|
|
5
|
+
'large-file': 'Consider splitting this file into smaller modules with single responsibility',
|
|
6
|
+
'large-function': 'Extract logic into smaller functions with descriptive names',
|
|
7
|
+
'debug-leftover': 'Remove this console.log or replace with proper logging library',
|
|
8
|
+
'dead-code': 'Remove unused import to keep code clean',
|
|
9
|
+
'duplicate-function-name': 'Consolidate with existing function or rename to clarify different behavior',
|
|
10
|
+
'any-abuse': "Replace 'any' with proper type definition",
|
|
11
|
+
'catch-swallow': 'Add error handling or logging in catch block',
|
|
12
|
+
'no-return-type': 'Add explicit return type for better type safety',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RULE_EFFORT: Record<string, 'low' | 'medium' | 'high'> = {
|
|
16
|
+
'debug-leftover': 'low',
|
|
17
|
+
'dead-code': 'low',
|
|
18
|
+
'no-return-type': 'low',
|
|
19
|
+
'any-abuse': 'medium',
|
|
20
|
+
'catch-swallow': 'medium',
|
|
21
|
+
'large-file': 'high',
|
|
22
|
+
'large-function': 'high',
|
|
23
|
+
'duplicate-function-name': 'high',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEVERITY_ORDER: Record<string, number> = { error: 0, warning: 1, info: 2 }
|
|
27
|
+
const EFFORT_ORDER: Record<string, number> = { low: 0, medium: 1, high: 2 }
|
|
28
|
+
|
|
4
29
|
export function buildReport(targetPath: string, files: FileReport[]): DriftReport {
|
|
5
30
|
const allIssues = files.flatMap((f) => f.issues)
|
|
6
31
|
const byRule: Record<string, number> = {}
|
|
@@ -94,3 +119,71 @@ export function formatMarkdown(report: DriftReport): string {
|
|
|
94
119
|
|
|
95
120
|
return lines.join('\n')
|
|
96
121
|
}
|
|
122
|
+
|
|
123
|
+
function collectAllIssues(report: DriftReport): Array<{ file: string; issue: DriftIssue }> {
|
|
124
|
+
const all: Array<{ file: string; issue: DriftIssue }> = []
|
|
125
|
+
for (const file of report.files) {
|
|
126
|
+
for (const issue of file.issues) {
|
|
127
|
+
all.push({ file: file.path, issue })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return all
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sortIssues(issues: Array<{ file: string; issue: DriftIssue }>): Array<{ file: string; issue: DriftIssue }> {
|
|
134
|
+
return issues.sort((a, b) => {
|
|
135
|
+
const sevDiff = SEVERITY_ORDER[a.issue.severity] - SEVERITY_ORDER[b.issue.severity]
|
|
136
|
+
if (sevDiff !== 0) return sevDiff
|
|
137
|
+
const effortA = RULE_EFFORT[a.issue.rule] ?? 'medium'
|
|
138
|
+
const effortB = RULE_EFFORT[b.issue.rule] ?? 'medium'
|
|
139
|
+
return EFFORT_ORDER[effortA] - EFFORT_ORDER[effortB]
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildAIIssue(item: { file: string; issue: DriftIssue }, rank: number): AIIssue {
|
|
144
|
+
return {
|
|
145
|
+
rank,
|
|
146
|
+
file: item.file,
|
|
147
|
+
line: item.issue.line,
|
|
148
|
+
rule: item.issue.rule,
|
|
149
|
+
severity: item.issue.severity,
|
|
150
|
+
message: item.issue.message,
|
|
151
|
+
snippet: item.issue.snippet,
|
|
152
|
+
fix_suggestion: FIX_SUGGESTIONS[item.issue.rule] ?? 'Review and fix this issue',
|
|
153
|
+
effort: RULE_EFFORT[item.issue.rule] ?? 'medium',
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildRecommendedAction(priorityOrder: AIIssue[]): string {
|
|
158
|
+
if (priorityOrder.length === 0) return 'No issues detected. Codebase looks clean.'
|
|
159
|
+
const lowEffortCount = priorityOrder.filter((i) => i.effort === 'low').length
|
|
160
|
+
if (lowEffortCount > 0) {
|
|
161
|
+
return `Focus on fixing ${lowEffortCount} low-effort issue(s) first - they're quick wins.`
|
|
162
|
+
}
|
|
163
|
+
return 'Start with the highest priority issue and work through them in order.'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatAIOutput(report: DriftReport): AIOutput {
|
|
167
|
+
const allIssues = collectAllIssues(report)
|
|
168
|
+
const sortedIssues = sortIssues(allIssues)
|
|
169
|
+
const priorityOrder = sortedIssues.map((item, i) => buildAIIssue(item, i + 1))
|
|
170
|
+
const rulesDetected = [...new Set(allIssues.map((i) => i.issue.rule))]
|
|
171
|
+
const grade = scoreToGradeText(report.totalScore)
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
summary: {
|
|
175
|
+
score: report.totalScore,
|
|
176
|
+
grade: grade.label.toUpperCase(),
|
|
177
|
+
total_issues: report.totalIssues,
|
|
178
|
+
files_affected: report.files.length,
|
|
179
|
+
files_clean: report.totalFiles - report.files.length,
|
|
180
|
+
},
|
|
181
|
+
priority_order: priorityOrder,
|
|
182
|
+
context_for_ai: {
|
|
183
|
+
project_type: 'typescript',
|
|
184
|
+
scan_path: report.targetPath,
|
|
185
|
+
rules_detected: rulesDetected,
|
|
186
|
+
recommended_action: buildRecommendedAction(priorityOrder),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|