@eduardbar/drift 0.9.0 → 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/CHANGELOG.md +9 -0
- package/README.md +273 -168
- package/ROADMAP.md +130 -98
- package/dist/analyzer.d.ts +4 -38
- package/dist/analyzer.js +85 -1510
- 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 -1726
- 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
package/src/cli.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// drift-ignore-file
|
|
2
3
|
import { Command } from 'commander'
|
|
3
4
|
import { writeFileSync } from 'node:fs'
|
|
4
5
|
import { resolve } from 'node:path'
|
|
5
6
|
import { createRequire } from 'node:module'
|
|
6
7
|
const require = createRequire(import.meta.url)
|
|
7
8
|
const { version: VERSION } = require('../package.json') as { version: string }
|
|
8
|
-
import { analyzeProject } from './analyzer.js'
|
|
9
|
+
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
|
|
9
10
|
import { buildReport, formatMarkdown, formatAIOutput } from './reporter.js'
|
|
10
11
|
import { printConsole, printDiff } from './printer.js'
|
|
11
12
|
import { loadConfig } from './config.js'
|
|
@@ -14,7 +15,7 @@ import { computeDiff } from './diff.js'
|
|
|
14
15
|
import { generateHtmlReport } from './report.js'
|
|
15
16
|
import { generateBadge } from './badge.js'
|
|
16
17
|
import { emitCIAnnotations, printCISummary } from './ci.js'
|
|
17
|
-
import {
|
|
18
|
+
import { applyFixes, type FixResult } from './fix.js'
|
|
18
19
|
|
|
19
20
|
const program = new Command()
|
|
20
21
|
|
|
@@ -177,7 +178,7 @@ program
|
|
|
177
178
|
process.stderr.write(`\nAnalyzing trend in ${resolvedPath}...\n`)
|
|
178
179
|
|
|
179
180
|
const config = await loadConfig(resolvedPath)
|
|
180
|
-
const analyzer = new TrendAnalyzer(resolvedPath, config)
|
|
181
|
+
const analyzer = new TrendAnalyzer(resolvedPath, analyzeProject, config)
|
|
181
182
|
|
|
182
183
|
const trendData = await analyzer.analyzeTrend({
|
|
183
184
|
period: period as 'week' | 'month' | 'quarter' | 'year',
|
|
@@ -198,7 +199,7 @@ program
|
|
|
198
199
|
process.stderr.write(`\nAnalyzing blame in ${resolvedPath}...\n`)
|
|
199
200
|
|
|
200
201
|
const config = await loadConfig(resolvedPath)
|
|
201
|
-
const analyzer = new BlameAnalyzer(resolvedPath, config)
|
|
202
|
+
const analyzer = new BlameAnalyzer(resolvedPath, analyzeProject, analyzeFile, config)
|
|
202
203
|
|
|
203
204
|
const blameData = await analyzer.analyzeBlame({
|
|
204
205
|
target: target as 'file' | 'rule' | 'overall' | undefined,
|
|
@@ -209,4 +210,52 @@ program
|
|
|
209
210
|
process.stdout.write(JSON.stringify(blameData, null, 2) + '\n')
|
|
210
211
|
})
|
|
211
212
|
|
|
213
|
+
program
|
|
214
|
+
.command('fix [path]')
|
|
215
|
+
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
216
|
+
.option('--rule <rule>', 'Fix only a specific rule')
|
|
217
|
+
.option('--dry-run', 'Show what would change without writing files')
|
|
218
|
+
.action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean }) => {
|
|
219
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
220
|
+
const config = await loadConfig(resolvedPath)
|
|
221
|
+
|
|
222
|
+
const results = await applyFixes(resolvedPath, config, {
|
|
223
|
+
rule: options.rule,
|
|
224
|
+
dryRun: options.dryRun,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
if (results.length === 0) {
|
|
228
|
+
console.log('No fixable issues found.')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const applied = results.filter(r => r.applied)
|
|
233
|
+
|
|
234
|
+
if (options.dryRun) {
|
|
235
|
+
console.log(`\ndrift fix --dry-run: ${results.length} fixable issues found\n`)
|
|
236
|
+
} else {
|
|
237
|
+
console.log(`\ndrift fix: ${applied.length} fixes applied\n`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Group by file for clean output
|
|
241
|
+
const byFile = new Map<string, FixResult[]>()
|
|
242
|
+
for (const r of results) {
|
|
243
|
+
if (!byFile.has(r.file)) byFile.set(r.file, [])
|
|
244
|
+
byFile.get(r.file)!.push(r)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const [file, fileResults] of byFile) {
|
|
248
|
+
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '')
|
|
249
|
+
console.log(` ${relPath}`)
|
|
250
|
+
for (const r of fileResults) {
|
|
251
|
+
const status = r.applied ? (options.dryRun ? 'would fix' : 'fixed') : 'skipped'
|
|
252
|
+
console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!options.dryRun && applied.length > 0) {
|
|
257
|
+
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
212
261
|
program.parse()
|
package/src/config.ts
CHANGED
|
@@ -36,7 +36,7 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
|
|
|
36
36
|
const config: DriftConfig = mod.default ?? mod
|
|
37
37
|
|
|
38
38
|
return config
|
|
39
|
-
} catch {
|
|
39
|
+
} catch { // drift-ignore
|
|
40
40
|
// drift-ignore: catch-swallow — config is optional; load failure is non-fatal
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/fix.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { analyzeProject, analyzeFile } from './analyzer.js'
|
|
4
|
+
import type { DriftIssue, DriftConfig } from './types.js'
|
|
5
|
+
import { Project } from 'ts-morph'
|
|
6
|
+
|
|
7
|
+
export interface FixResult {
|
|
8
|
+
file: string
|
|
9
|
+
rule: string
|
|
10
|
+
line: number
|
|
11
|
+
description: string
|
|
12
|
+
applied: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FIXABLE_RULES = new Set(['debug-leftover', 'catch-swallow'])
|
|
16
|
+
|
|
17
|
+
function isConsoleDebug(issue: DriftIssue): boolean {
|
|
18
|
+
// debug-leftover for console.* has messages like "console.log left in production code."
|
|
19
|
+
// Unresolved markers start with "Unresolved marker"
|
|
20
|
+
return issue.rule === 'debug-leftover' && !issue.message.startsWith('Unresolved marker')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isFixable(issue: DriftIssue): boolean {
|
|
24
|
+
if (issue.rule === 'debug-leftover') return isConsoleDebug(issue)
|
|
25
|
+
return FIXABLE_RULES.has(issue.rule)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fixDebugLeftover(lines: string[], line: number): string[] {
|
|
29
|
+
// line is 1-based, lines is 0-based
|
|
30
|
+
return [...lines.slice(0, line - 1), ...lines.slice(line)]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fixCatchSwallow(lines: string[], line: number): string[] {
|
|
34
|
+
// line is 1-based — points to the catch (...) line
|
|
35
|
+
let openBraceLine = line - 1 // convert to 0-based index
|
|
36
|
+
|
|
37
|
+
// Find the opening { of the catch block (same line or next few lines)
|
|
38
|
+
for (let i = openBraceLine; i < Math.min(openBraceLine + 3, lines.length); i++) { // drift-ignore
|
|
39
|
+
if (lines[i].includes('{')) {
|
|
40
|
+
openBraceLine = i
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const indentMatch = lines[openBraceLine].match(/^(\s*)/)
|
|
46
|
+
const indent = indentMatch ? indentMatch[1] + ' ' : ' '
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
...lines.slice(0, openBraceLine + 1),
|
|
50
|
+
`${indent}// TODO: handle error`, // drift-ignore
|
|
51
|
+
...lines.slice(openBraceLine + 1),
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function applyFixToLines(
|
|
56
|
+
lines: string[],
|
|
57
|
+
issue: DriftIssue
|
|
58
|
+
): { newLines: string[]; description: string } | null {
|
|
59
|
+
if (issue.rule === 'debug-leftover' && isConsoleDebug(issue)) {
|
|
60
|
+
return {
|
|
61
|
+
newLines: fixDebugLeftover(lines, issue.line),
|
|
62
|
+
description: `remove ${issue.message.split(' ')[0]} statement`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (issue.rule === 'catch-swallow') {
|
|
67
|
+
return {
|
|
68
|
+
newLines: fixCatchSwallow(lines, issue.line),
|
|
69
|
+
description: 'add TODO comment to empty catch block',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function applyFixes(
|
|
77
|
+
targetPath: string,
|
|
78
|
+
config?: DriftConfig,
|
|
79
|
+
options?: { rule?: string; dryRun?: boolean }
|
|
80
|
+
): Promise<FixResult[]> {
|
|
81
|
+
const resolvedPath = resolve(targetPath)
|
|
82
|
+
const dryRun = options?.dryRun ?? false
|
|
83
|
+
|
|
84
|
+
// Determine if target is a file or directory
|
|
85
|
+
let fileReports
|
|
86
|
+
const stat = statSync(resolvedPath)
|
|
87
|
+
|
|
88
|
+
if (stat.isFile()) {
|
|
89
|
+
const project = new Project({
|
|
90
|
+
skipAddingFilesFromTsConfig: true,
|
|
91
|
+
compilerOptions: { allowJs: true, jsx: 1 },
|
|
92
|
+
})
|
|
93
|
+
const sourceFile = project.addSourceFileAtPath(resolvedPath)
|
|
94
|
+
fileReports = [analyzeFile(sourceFile)]
|
|
95
|
+
} else {
|
|
96
|
+
fileReports = analyzeProject(resolvedPath, config)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Collect fixable issues, optionally filtered by rule
|
|
100
|
+
const fixableByFile = new Map<string, DriftIssue[]>()
|
|
101
|
+
|
|
102
|
+
for (const report of fileReports) {
|
|
103
|
+
const fixableIssues = report.issues.filter(issue => {
|
|
104
|
+
if (!isFixable(issue)) return false
|
|
105
|
+
if (options?.rule && issue.rule !== options.rule) return false
|
|
106
|
+
return true
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (fixableIssues.length > 0) {
|
|
110
|
+
fixableByFile.set(report.path, fixableIssues)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const results: FixResult[] = []
|
|
115
|
+
|
|
116
|
+
for (const [filePath, issues] of fixableByFile) {
|
|
117
|
+
const content = readFileSync(filePath, 'utf8')
|
|
118
|
+
let lines = content.split('\n')
|
|
119
|
+
|
|
120
|
+
// Sort issues by line descending to avoid line number drift after fixes
|
|
121
|
+
const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
|
|
122
|
+
|
|
123
|
+
// Track line offset caused by deletions (debug-leftover removes lines)
|
|
124
|
+
// We process top-to-bottom after sorting descending, so no offset needed per issue
|
|
125
|
+
for (const issue of sortedIssues) {
|
|
126
|
+
const fixResult = applyFixToLines(lines, issue)
|
|
127
|
+
|
|
128
|
+
if (fixResult) {
|
|
129
|
+
results.push({
|
|
130
|
+
file: filePath,
|
|
131
|
+
rule: issue.rule,
|
|
132
|
+
line: issue.line,
|
|
133
|
+
description: fixResult.description,
|
|
134
|
+
applied: true,
|
|
135
|
+
})
|
|
136
|
+
lines = fixResult.newLines
|
|
137
|
+
} else {
|
|
138
|
+
results.push({
|
|
139
|
+
file: filePath,
|
|
140
|
+
rule: issue.rule,
|
|
141
|
+
line: issue.line,
|
|
142
|
+
description: 'no fix available',
|
|
143
|
+
applied: false,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!dryRun) {
|
|
149
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return results
|
|
154
|
+
}
|
package/src/git/blame.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import type { SourceFile } from 'ts-morph'
|
|
5
|
+
import type { FileReport, DriftConfig, BlameAttribution, DriftBlameReport } from '../types.js'
|
|
6
|
+
import { assertGitRepo, execGit, analyzeFilePath } from './helpers.js'
|
|
7
|
+
import { buildReport } from '../reporter.js'
|
|
8
|
+
import { RULE_WEIGHTS } from '../analyzer.js'
|
|
9
|
+
|
|
10
|
+
interface GitBlameEntry {
|
|
11
|
+
hash: string
|
|
12
|
+
author: string
|
|
13
|
+
email: string
|
|
14
|
+
line: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseGitBlame(blameOutput: string): GitBlameEntry[] {
|
|
18
|
+
const entries: GitBlameEntry[] = []
|
|
19
|
+
const lines = blameOutput.split('\n')
|
|
20
|
+
let i = 0
|
|
21
|
+
|
|
22
|
+
while (i < lines.length) {
|
|
23
|
+
const headerLine = lines[i]
|
|
24
|
+
if (!headerLine || headerLine.trim() === '') { i++; continue }
|
|
25
|
+
|
|
26
|
+
// Porcelain blame format: first line is "<hash> <orig-line> <final-line> [<num-lines>]"
|
|
27
|
+
const headerMatch = headerLine.match(/^([0-9a-f]{40})\s/)
|
|
28
|
+
if (!headerMatch) { i++; continue }
|
|
29
|
+
|
|
30
|
+
const hash = headerMatch[1]!
|
|
31
|
+
let author = ''
|
|
32
|
+
let email = ''
|
|
33
|
+
let codeLine = ''
|
|
34
|
+
i++
|
|
35
|
+
|
|
36
|
+
while (i < lines.length && !lines[i]!.match(/^[0-9a-f]{40}\s/)) {
|
|
37
|
+
const l = lines[i]!
|
|
38
|
+
if (l.startsWith('author ')) author = l.slice(7).trim()
|
|
39
|
+
else if (l.startsWith('author-mail ')) email = l.slice(12).replace(/[<>]/g, '').trim()
|
|
40
|
+
else if (l.startsWith('\t')) codeLine = l.slice(1)
|
|
41
|
+
i++
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
entries.push({ hash, author, email, line: codeLine })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return entries
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class BlameAnalyzer {
|
|
51
|
+
private readonly projectPath: string
|
|
52
|
+
private readonly config: DriftConfig | undefined
|
|
53
|
+
private readonly analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[]
|
|
54
|
+
private readonly analyzeFileFn: (sf: SourceFile) => FileReport
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
projectPath: string,
|
|
58
|
+
analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[],
|
|
59
|
+
analyzeFileFn: (sf: SourceFile) => FileReport,
|
|
60
|
+
config?: DriftConfig,
|
|
61
|
+
) {
|
|
62
|
+
this.projectPath = projectPath
|
|
63
|
+
this.analyzeProjectFn = analyzeProjectFn
|
|
64
|
+
this.analyzeFileFn = analyzeFileFn
|
|
65
|
+
this.config = config
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Blame a single file: returns per-author attribution. */
|
|
69
|
+
static async analyzeFileBlame(
|
|
70
|
+
filePath: string,
|
|
71
|
+
analyzeFileFn: (sf: SourceFile) => FileReport,
|
|
72
|
+
): Promise<BlameAttribution[]> {
|
|
73
|
+
const dir = path.dirname(filePath)
|
|
74
|
+
assertGitRepo(dir)
|
|
75
|
+
|
|
76
|
+
const blameOutput = execGit(`git blame --porcelain "${filePath}"`, dir)
|
|
77
|
+
const entries = parseGitBlame(blameOutput)
|
|
78
|
+
|
|
79
|
+
// Analyse issues in the file
|
|
80
|
+
const report = analyzeFilePath(filePath, analyzeFileFn)
|
|
81
|
+
|
|
82
|
+
// Map line numbers of issues to authors
|
|
83
|
+
const issuesByLine = new Map<number, number>()
|
|
84
|
+
for (const issue of report.issues) {
|
|
85
|
+
issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Aggregate by author
|
|
89
|
+
const byAuthor = new Map<string, BlameAttribution>()
|
|
90
|
+
entries.forEach((entry, idx) => {
|
|
91
|
+
const key = entry.email || entry.author
|
|
92
|
+
if (!byAuthor.has(key)) {
|
|
93
|
+
byAuthor.set(key, {
|
|
94
|
+
author: entry.author,
|
|
95
|
+
email: entry.email,
|
|
96
|
+
commits: 0,
|
|
97
|
+
linesChanged: 0,
|
|
98
|
+
issuesIntroduced: 0,
|
|
99
|
+
avgScoreImpact: 0,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
const attr = byAuthor.get(key)!
|
|
103
|
+
attr.linesChanged++
|
|
104
|
+
const lineNum = idx + 1
|
|
105
|
+
if (issuesByLine.has(lineNum)) {
|
|
106
|
+
attr.issuesIntroduced += issuesByLine.get(lineNum)!
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Count unique commits per author
|
|
111
|
+
const commitsByAuthor = new Map<string, Set<string>>()
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const key = entry.email || entry.author
|
|
114
|
+
if (!commitsByAuthor.has(key)) commitsByAuthor.set(key, new Set())
|
|
115
|
+
commitsByAuthor.get(key)!.add(entry.hash)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const total = entries.length || 1
|
|
119
|
+
const results: BlameAttribution[] = []
|
|
120
|
+
for (const [key, attr] of byAuthor) {
|
|
121
|
+
attr.commits = commitsByAuthor.get(key)?.size ?? 0
|
|
122
|
+
attr.avgScoreImpact = (attr.linesChanged / total) * report.score
|
|
123
|
+
results.push(attr)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results.sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Blame for a specific rule across all files in targetPath. */
|
|
130
|
+
static async analyzeRuleBlame(
|
|
131
|
+
rule: string,
|
|
132
|
+
targetPath: string,
|
|
133
|
+
analyzeFileFn: (sf: SourceFile) => FileReport,
|
|
134
|
+
): Promise<BlameAttribution[]> {
|
|
135
|
+
assertGitRepo(targetPath)
|
|
136
|
+
|
|
137
|
+
const tsFiles = fs
|
|
138
|
+
.readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
|
|
139
|
+
.filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.includes('node_modules') && !f.endsWith('.d.ts'))
|
|
140
|
+
.map(f => path.join(targetPath, f))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
const combined = new Map<string, BlameAttribution>()
|
|
144
|
+
const commitsByAuthor = new Map<string, Set<string>>()
|
|
145
|
+
|
|
146
|
+
for (const file of tsFiles) {
|
|
147
|
+
let blameEntries: GitBlameEntry[] = []
|
|
148
|
+
try {
|
|
149
|
+
const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
|
|
150
|
+
blameEntries = parseGitBlame(blameOutput)
|
|
151
|
+
} catch { continue }
|
|
152
|
+
|
|
153
|
+
const report = analyzeFilePath(file, analyzeFileFn)
|
|
154
|
+
const issuesByLine = new Map<number, number>()
|
|
155
|
+
for (const issue of report.issues) {
|
|
156
|
+
issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
blameEntries.forEach((entry, idx) => {
|
|
160
|
+
const key = entry.email || entry.author
|
|
161
|
+
if (!combined.has(key)) {
|
|
162
|
+
combined.set(key, {
|
|
163
|
+
author: entry.author,
|
|
164
|
+
email: entry.email,
|
|
165
|
+
commits: 0,
|
|
166
|
+
linesChanged: 0,
|
|
167
|
+
issuesIntroduced: 0,
|
|
168
|
+
avgScoreImpact: 0,
|
|
169
|
+
})
|
|
170
|
+
commitsByAuthor.set(key, new Set())
|
|
171
|
+
}
|
|
172
|
+
const attr = combined.get(key)!
|
|
173
|
+
attr.linesChanged++
|
|
174
|
+
commitsByAuthor.get(key)!.add(entry.hash)
|
|
175
|
+
const lineNum = idx + 1
|
|
176
|
+
if (issuesByLine.has(lineNum)) {
|
|
177
|
+
attr.issuesIntroduced += issuesByLine.get(lineNum)!
|
|
178
|
+
attr.avgScoreImpact += report.score * (1 / (blameEntries.length || 1))
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const [key, attr] of combined) {
|
|
184
|
+
attr.commits = commitsByAuthor.get(key)?.size ?? 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Overall blame across all files and rules. */
|
|
191
|
+
static async analyzeOverallBlame(
|
|
192
|
+
targetPath: string,
|
|
193
|
+
analyzeFileFn: (sf: SourceFile) => FileReport,
|
|
194
|
+
): Promise<BlameAttribution[]> {
|
|
195
|
+
assertGitRepo(targetPath)
|
|
196
|
+
|
|
197
|
+
const tsFiles = fs
|
|
198
|
+
.readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
|
|
199
|
+
.filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.includes('node_modules') && !f.endsWith('.d.ts'))
|
|
200
|
+
.map(f => path.join(targetPath, f))
|
|
201
|
+
|
|
202
|
+
const combined = new Map<string, BlameAttribution>()
|
|
203
|
+
const commitsByAuthor = new Map<string, Set<string>>()
|
|
204
|
+
|
|
205
|
+
for (const file of tsFiles) {
|
|
206
|
+
let blameEntries: GitBlameEntry[] = []
|
|
207
|
+
try {
|
|
208
|
+
const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
|
|
209
|
+
blameEntries = parseGitBlame(blameOutput)
|
|
210
|
+
} catch { continue }
|
|
211
|
+
|
|
212
|
+
const report = analyzeFilePath(file, analyzeFileFn)
|
|
213
|
+
const issuesByLine = new Map<number, number>()
|
|
214
|
+
for (const issue of report.issues) {
|
|
215
|
+
issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
blameEntries.forEach((entry, idx) => {
|
|
219
|
+
const key = entry.email || entry.author
|
|
220
|
+
if (!combined.has(key)) {
|
|
221
|
+
combined.set(key, {
|
|
222
|
+
author: entry.author,
|
|
223
|
+
email: entry.email,
|
|
224
|
+
commits: 0,
|
|
225
|
+
linesChanged: 0,
|
|
226
|
+
issuesIntroduced: 0,
|
|
227
|
+
avgScoreImpact: 0,
|
|
228
|
+
})
|
|
229
|
+
commitsByAuthor.set(key, new Set())
|
|
230
|
+
}
|
|
231
|
+
const attr = combined.get(key)!
|
|
232
|
+
attr.linesChanged++
|
|
233
|
+
commitsByAuthor.get(key)!.add(entry.hash)
|
|
234
|
+
const lineNum = idx + 1
|
|
235
|
+
if (issuesByLine.has(lineNum)) {
|
|
236
|
+
attr.issuesIntroduced += issuesByLine.get(lineNum)!
|
|
237
|
+
attr.avgScoreImpact += report.score * (1 / (blameEntries.length || 1))
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const [key, attr] of combined) {
|
|
243
|
+
attr.commits = commitsByAuthor.get(key)?.size ?? 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Instance method -------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
async analyzeBlame(options: {
|
|
252
|
+
target?: 'file' | 'rule' | 'overall'
|
|
253
|
+
top?: number
|
|
254
|
+
filePath?: string
|
|
255
|
+
rule?: string
|
|
256
|
+
}): Promise<DriftBlameReport> {
|
|
257
|
+
assertGitRepo(this.projectPath)
|
|
258
|
+
|
|
259
|
+
let blame: BlameAttribution[] = []
|
|
260
|
+
const mode = options.target ?? 'overall'
|
|
261
|
+
|
|
262
|
+
if (mode === 'file' && options.filePath) {
|
|
263
|
+
blame = await BlameAnalyzer.analyzeFileBlame(options.filePath, this.analyzeFileFn)
|
|
264
|
+
} else if (mode === 'rule' && options.rule) {
|
|
265
|
+
blame = await BlameAnalyzer.analyzeRuleBlame(options.rule, this.projectPath, this.analyzeFileFn)
|
|
266
|
+
} else {
|
|
267
|
+
blame = await BlameAnalyzer.analyzeOverallBlame(this.projectPath, this.analyzeFileFn)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (options.top) {
|
|
271
|
+
blame = blame.slice(0, options.top)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const currentFiles = this.analyzeProjectFn(this.projectPath, this.config)
|
|
275
|
+
const baseReport = buildReport(this.projectPath, currentFiles)
|
|
276
|
+
|
|
277
|
+
return { ...baseReport, blame }
|
|
278
|
+
}
|
|
279
|
+
}
|