@eduardbar/drift 0.9.1 → 1.0.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/workflows/publish-vscode.yml +76 -0
- package/AGENTS.md +30 -12
- package/README.md +1 -1
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +4 -38
- package/dist/analyzer.js +85 -1543
- package/dist/cli.js +47 -4
- package/dist/config.js +1 -1
- package/dist/fix.d.ts +13 -0
- package/dist/fix.js +120 -0
- package/dist/git/blame.d.ts +22 -0
- package/dist/git/blame.js +227 -0
- package/dist/git/helpers.d.ts +36 -0
- package/dist/git/helpers.js +152 -0
- package/dist/git/trend.d.ts +21 -0
- package/dist/git/trend.js +80 -0
- package/dist/git.d.ts +0 -4
- package/dist/git.js +2 -2
- package/dist/report.js +620 -293
- package/dist/rules/phase0-basic.d.ts +11 -0
- package/dist/rules/phase0-basic.js +176 -0
- package/dist/rules/phase1-complexity.d.ts +31 -0
- package/dist/rules/phase1-complexity.js +277 -0
- package/dist/rules/phase2-crossfile.d.ts +27 -0
- package/dist/rules/phase2-crossfile.js +122 -0
- package/dist/rules/phase3-arch.d.ts +31 -0
- package/dist/rules/phase3-arch.js +148 -0
- package/dist/rules/phase5-ai.d.ts +8 -0
- package/dist/rules/phase5-ai.js +262 -0
- package/dist/rules/phase8-semantic.d.ts +22 -0
- package/dist/rules/phase8-semantic.js +109 -0
- package/dist/rules/shared.d.ts +7 -0
- package/dist/rules/shared.js +27 -0
- package/package.json +8 -3
- package/packages/vscode-drift/.vscodeignore +9 -0
- package/packages/vscode-drift/LICENSE +21 -0
- package/packages/vscode-drift/README.md +64 -0
- package/packages/vscode-drift/images/icon.png +0 -0
- package/packages/vscode-drift/images/icon.svg +30 -0
- package/packages/vscode-drift/package-lock.json +485 -0
- package/packages/vscode-drift/package.json +119 -0
- package/packages/vscode-drift/src/analyzer.ts +38 -0
- package/packages/vscode-drift/src/diagnostics.ts +55 -0
- package/packages/vscode-drift/src/extension.ts +111 -0
- package/packages/vscode-drift/src/statusbar.ts +47 -0
- package/packages/vscode-drift/src/treeview.ts +108 -0
- package/packages/vscode-drift/tsconfig.json +18 -0
- package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
- package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
- package/src/analyzer.ts +124 -1773
- package/src/cli.ts +53 -4
- package/src/config.ts +1 -1
- package/src/fix.ts +154 -0
- package/src/git/blame.ts +279 -0
- package/src/git/helpers.ts +198 -0
- package/src/git/trend.ts +116 -0
- package/src/git.ts +2 -2
- package/src/report.ts +631 -296
- package/src/rules/phase0-basic.ts +187 -0
- package/src/rules/phase1-complexity.ts +302 -0
- package/src/rules/phase2-crossfile.ts +149 -0
- package/src/rules/phase3-arch.ts +179 -0
- package/src/rules/phase5-ai.ts +292 -0
- package/src/rules/phase8-semantic.ts +132 -0
- package/src/rules/shared.ts +39 -0
- package/tests/helpers.ts +45 -0
- package/tests/rules.test.ts +1269 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as crypto from 'node:crypto'
|
|
6
|
+
import { execSync } from 'node:child_process'
|
|
7
|
+
import { Project } from 'ts-morph'
|
|
8
|
+
import type { SourceFile } from 'ts-morph'
|
|
9
|
+
import type { FileReport, DriftConfig, HistoricalAnalysis } from '../types.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Analyse a file given its absolute path string.
|
|
13
|
+
* Accepts analyzeFile as a parameter to avoid circular dependency.
|
|
14
|
+
*/
|
|
15
|
+
export function analyzeFilePath(
|
|
16
|
+
filePath: string,
|
|
17
|
+
analyzeFile: (sf: SourceFile) => FileReport,
|
|
18
|
+
): FileReport {
|
|
19
|
+
const proj = new Project({
|
|
20
|
+
skipAddingFilesFromTsConfig: true,
|
|
21
|
+
compilerOptions: { allowJs: true },
|
|
22
|
+
})
|
|
23
|
+
const sf = proj.addSourceFileAtPath(filePath)
|
|
24
|
+
return analyzeFile(sf)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute a git command synchronously and return stdout.
|
|
29
|
+
* Throws a descriptive error if the command fails or git is not available.
|
|
30
|
+
*/
|
|
31
|
+
export function execGit(cmd: string, cwd: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
36
|
+
throw new Error(`Git command failed: ${cmd}\n${msg}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify the given directory is a git repository.
|
|
42
|
+
* Throws if git is not available or the directory is not a repo.
|
|
43
|
+
*/
|
|
44
|
+
export function assertGitRepo(cwd: string): void {
|
|
45
|
+
try {
|
|
46
|
+
execGit('git rev-parse --is-inside-work-tree', cwd)
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`Directory is not a git repository: ${cwd}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Analyse a single file as it existed at a given commit hash.
|
|
54
|
+
* Writes the blob to a temp file, runs analyzeFile, then cleans up.
|
|
55
|
+
*/
|
|
56
|
+
export async function analyzeFileAtCommit( // drift-ignore
|
|
57
|
+
filePath: string,
|
|
58
|
+
commitHash: string,
|
|
59
|
+
projectRoot: string,
|
|
60
|
+
analyzeFile: (sf: SourceFile) => FileReport,
|
|
61
|
+
): Promise<FileReport> {
|
|
62
|
+
const relPath = path.relative(projectRoot, filePath).replace(/\\/g, '/')
|
|
63
|
+
const blob = execGit(`git show ${commitHash}:${relPath}`, projectRoot)
|
|
64
|
+
|
|
65
|
+
const tmpFile = path.join(os.tmpdir(), `drift-${crypto.randomBytes(8).toString('hex')}.ts`)
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(tmpFile, blob, 'utf8')
|
|
68
|
+
const report = analyzeFilePath(tmpFile, analyzeFile)
|
|
69
|
+
// Replace temp path with original for readable output
|
|
70
|
+
return { ...report, path: filePath }
|
|
71
|
+
} finally {
|
|
72
|
+
try { fs.unlinkSync(tmpFile) } catch { /* ignore cleanup errors */ } // drift-ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Analyse ALL TypeScript files in the project snapshot at a given commit.
|
|
78
|
+
* Uses `git ls-tree` to enumerate every file in the tree, writes them to a
|
|
79
|
+
* temp directory, then runs `analyzeProject` on that full snapshot.
|
|
80
|
+
*/
|
|
81
|
+
export async function analyzeSingleCommit( // drift-ignore
|
|
82
|
+
commitHash: string,
|
|
83
|
+
targetPath: string,
|
|
84
|
+
analyzeProject: (targetPath: string, config?: DriftConfig) => FileReport[],
|
|
85
|
+
config?: DriftConfig,
|
|
86
|
+
): Promise<HistoricalAnalysis> {
|
|
87
|
+
// 1. Commit metadata
|
|
88
|
+
const meta = execGit(
|
|
89
|
+
`git show --no-patch --format="%H|%aI|%an|%s" ${commitHash}`,
|
|
90
|
+
targetPath,
|
|
91
|
+
)
|
|
92
|
+
const [hash, dateStr, author, ...msgParts] = meta.split('|')
|
|
93
|
+
const message = msgParts.join('|').trim()
|
|
94
|
+
const commitDate = new Date(dateStr ?? '')
|
|
95
|
+
|
|
96
|
+
// 2. All .ts/.tsx files tracked at this commit (no diffs, full tree)
|
|
97
|
+
const allFiles = execGit(
|
|
98
|
+
`git ls-tree -r ${commitHash} --name-only`,
|
|
99
|
+
targetPath,
|
|
100
|
+
)
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter(
|
|
103
|
+
f =>
|
|
104
|
+
(f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) &&
|
|
105
|
+
!f.endsWith('.d.ts') &&
|
|
106
|
+
!f.includes('node_modules') &&
|
|
107
|
+
!f.startsWith('dist/'),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if (allFiles.length === 0) {
|
|
111
|
+
return {
|
|
112
|
+
commitHash: hash ?? commitHash,
|
|
113
|
+
commitDate,
|
|
114
|
+
author: author ?? '',
|
|
115
|
+
message,
|
|
116
|
+
files: [],
|
|
117
|
+
totalScore: 0,
|
|
118
|
+
averageScore: 0,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. Write snapshot to temp directory
|
|
123
|
+
const tmpDir = path.join(os.tmpdir(), `drift-${(hash ?? commitHash).slice(0, 8)}`)
|
|
124
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
125
|
+
|
|
126
|
+
for (const relPath of allFiles) {
|
|
127
|
+
try {
|
|
128
|
+
const content = execGit(`git show ${commitHash}:${relPath}`, targetPath)
|
|
129
|
+
const destPath = path.join(tmpDir, relPath)
|
|
130
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
131
|
+
fs.writeFileSync(destPath, content, 'utf-8')
|
|
132
|
+
} catch { // drift-ignore
|
|
133
|
+
// skip files that can't be read (binary, deleted in partial clone, etc.)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 4. Analyse the full project snapshot
|
|
138
|
+
const fileReports = analyzeProject(tmpDir, config)
|
|
139
|
+
const totalScore = fileReports.reduce((sum, r) => sum + r.score, 0)
|
|
140
|
+
const averageScore = fileReports.length > 0 ? totalScore / fileReports.length : 0
|
|
141
|
+
|
|
142
|
+
// 5. Cleanup
|
|
143
|
+
try {
|
|
144
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
145
|
+
} catch { // drift-ignore
|
|
146
|
+
// non-fatal — temp dirs are cleaned by the OS eventually
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
commitHash: hash ?? commitHash,
|
|
151
|
+
commitDate,
|
|
152
|
+
author: author ?? '',
|
|
153
|
+
message,
|
|
154
|
+
files: fileReports,
|
|
155
|
+
totalScore,
|
|
156
|
+
averageScore,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run historical analysis over all commits since a given date.
|
|
162
|
+
* Returns results ordered chronologically (oldest first).
|
|
163
|
+
*/
|
|
164
|
+
export async function analyzeHistoricalCommits(
|
|
165
|
+
sinceDate: Date,
|
|
166
|
+
targetPath: string,
|
|
167
|
+
maxCommits: number,
|
|
168
|
+
analyzeProject: (targetPath: string, config?: DriftConfig) => FileReport[],
|
|
169
|
+
config?: DriftConfig,
|
|
170
|
+
maxSamples: number = 10,
|
|
171
|
+
): Promise<HistoricalAnalysis[]> {
|
|
172
|
+
assertGitRepo(targetPath)
|
|
173
|
+
|
|
174
|
+
const isoDate = sinceDate.toISOString()
|
|
175
|
+
const raw = execGit(
|
|
176
|
+
`git log --since="${isoDate}" --format="%H" --max-count=${maxCommits}`,
|
|
177
|
+
targetPath,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if (!raw) return []
|
|
181
|
+
|
|
182
|
+
const hashes = raw.split('\n').filter(Boolean)
|
|
183
|
+
|
|
184
|
+
// Sample: distribute evenly across the range
|
|
185
|
+
const sampled = hashes.length <= maxSamples
|
|
186
|
+
? hashes
|
|
187
|
+
: Array.from({ length: maxSamples }, (_, i) =>
|
|
188
|
+
hashes[Math.floor(i * (hashes.length - 1) / (maxSamples - 1))]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const analyses = await Promise.all(
|
|
192
|
+
sampled.map(h => analyzeSingleCommit(h, targetPath, analyzeProject, config).catch(() => null)),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return analyses
|
|
196
|
+
.filter((a): a is HistoricalAnalysis => a !== null)
|
|
197
|
+
.sort((a, b) => a.commitDate.getTime() - b.commitDate.getTime())
|
|
198
|
+
}
|
package/src/git/trend.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { SourceFile } from 'ts-morph'
|
|
2
|
+
import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport, BlameAttribution } from '../types.js'
|
|
3
|
+
import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js'
|
|
4
|
+
import { buildReport } from '../reporter.js'
|
|
5
|
+
|
|
6
|
+
export class TrendAnalyzer {
|
|
7
|
+
private readonly projectPath: string
|
|
8
|
+
private readonly config: DriftConfig | undefined
|
|
9
|
+
private readonly analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[]
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
projectPath: string,
|
|
13
|
+
analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[],
|
|
14
|
+
config?: DriftConfig,
|
|
15
|
+
) {
|
|
16
|
+
this.projectPath = projectPath
|
|
17
|
+
this.analyzeProjectFn = analyzeProjectFn
|
|
18
|
+
this.config = config
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Static utility methods -----------------------------------------------
|
|
22
|
+
|
|
23
|
+
static calculateMovingAverage(data: TrendDataPoint[], windowSize: number): number[] {
|
|
24
|
+
return data.map((_, i) => {
|
|
25
|
+
const start = Math.max(0, i - windowSize + 1)
|
|
26
|
+
const window = data.slice(start, i + 1)
|
|
27
|
+
return window.reduce((s, p) => s + p.score, 0) / window.length
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static linearRegression(data: TrendDataPoint[]): { slope: number; intercept: number; r2: number } {
|
|
32
|
+
const n = data.length
|
|
33
|
+
if (n < 2) return { slope: 0, intercept: data[0]?.score ?? 0, r2: 0 }
|
|
34
|
+
|
|
35
|
+
const xs = data.map((_, i) => i)
|
|
36
|
+
const ys = data.map(p => p.score)
|
|
37
|
+
|
|
38
|
+
const xMean = xs.reduce((s, x) => s + x, 0) / n
|
|
39
|
+
const yMean = ys.reduce((s, y) => s + y, 0) / n
|
|
40
|
+
|
|
41
|
+
const ssXX = xs.reduce((s, x) => s + (x - xMean) ** 2, 0)
|
|
42
|
+
const ssXY = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i]! - yMean), 0)
|
|
43
|
+
const ssYY = ys.reduce((s, y) => s + (y - yMean) ** 2, 0)
|
|
44
|
+
|
|
45
|
+
const slope = ssXX === 0 ? 0 : ssXY / ssXX
|
|
46
|
+
const intercept = yMean - slope * xMean
|
|
47
|
+
const r2 = ssYY === 0 ? 1 : (ssXY ** 2) / (ssXX * ssYY)
|
|
48
|
+
|
|
49
|
+
return { slope, intercept, r2 }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Generate a simple horizontal ASCII bar chart (one bar per data point). */
|
|
53
|
+
static generateTrendChart(data: TrendDataPoint[]): string {
|
|
54
|
+
if (data.length === 0) return '(no data)'
|
|
55
|
+
|
|
56
|
+
const maxScore = Math.max(...data.map(p => p.score), 1)
|
|
57
|
+
const chartWidth = 40
|
|
58
|
+
|
|
59
|
+
const lines = data.map(p => {
|
|
60
|
+
const barLen = Math.round((p.score / maxScore) * chartWidth)
|
|
61
|
+
const bar = '█'.repeat(barLen)
|
|
62
|
+
const dateStr = p.date.toISOString().slice(0, 10)
|
|
63
|
+
return `${dateStr} │${bar.padEnd(chartWidth)} ${p.score.toFixed(1)}`
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return lines.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Instance method -------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async analyzeTrend(options: {
|
|
72
|
+
period?: 'week' | 'month' | 'quarter' | 'year'
|
|
73
|
+
since?: string
|
|
74
|
+
until?: string
|
|
75
|
+
}): Promise<DriftTrendReport> {
|
|
76
|
+
assertGitRepo(this.projectPath)
|
|
77
|
+
|
|
78
|
+
const periodDays: Record<string, number> = {
|
|
79
|
+
week: 7, month: 30, quarter: 90, year: 365,
|
|
80
|
+
}
|
|
81
|
+
const days = periodDays[options.period ?? 'month'] ?? 30
|
|
82
|
+
const sinceDate = options.since
|
|
83
|
+
? new Date(options.since)
|
|
84
|
+
: new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
|
85
|
+
|
|
86
|
+
const historicalAnalyses = await analyzeHistoricalCommits(
|
|
87
|
+
sinceDate,
|
|
88
|
+
this.projectPath,
|
|
89
|
+
100,
|
|
90
|
+
this.analyzeProjectFn,
|
|
91
|
+
this.config,
|
|
92
|
+
10,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const trendPoints: TrendDataPoint[] = historicalAnalyses.map(h => ({
|
|
96
|
+
date: h.commitDate,
|
|
97
|
+
score: h.averageScore,
|
|
98
|
+
fileCount: h.files.length,
|
|
99
|
+
avgIssuesPerFile: h.files.length > 0
|
|
100
|
+
? h.files.reduce((s, f) => s + f.issues.length, 0) / h.files.length
|
|
101
|
+
: 0,
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
const regression = TrendAnalyzer.linearRegression(trendPoints)
|
|
105
|
+
|
|
106
|
+
// Current state report
|
|
107
|
+
const currentFiles = this.analyzeProjectFn(this.projectPath, this.config)
|
|
108
|
+
const baseReport = buildReport(this.projectPath, currentFiles)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
...baseReport,
|
|
112
|
+
trend: trendPoints,
|
|
113
|
+
regression,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
|
|
|
42
42
|
const tsFiles = fileList
|
|
43
43
|
.split('\n')
|
|
44
44
|
.map(f => f.trim())
|
|
45
|
-
.filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'))
|
|
45
|
+
.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'))
|
|
46
46
|
|
|
47
47
|
if (tsFiles.length === 0) {
|
|
48
48
|
throw new Error(`No TypeScript files found at ref '${ref}'`)
|
|
@@ -86,7 +86,7 @@ export function cleanupTempDir(tempDir: string): void {
|
|
|
86
86
|
/**
|
|
87
87
|
* Get the short hash of a git ref for display purposes.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
function resolveRefHash(projectPath: string, ref: string): string {
|
|
90
90
|
try {
|
|
91
91
|
return execSync(
|
|
92
92
|
`git rev-parse --short ${ref}`,
|