@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.
Files changed (69) hide show
  1. package/.github/workflows/publish-vscode.yml +76 -0
  2. package/AGENTS.md +30 -12
  3. package/CHANGELOG.md +9 -0
  4. package/README.md +273 -168
  5. package/ROADMAP.md +130 -98
  6. package/dist/analyzer.d.ts +4 -38
  7. package/dist/analyzer.js +85 -1510
  8. package/dist/cli.js +47 -4
  9. package/dist/config.js +1 -1
  10. package/dist/fix.d.ts +13 -0
  11. package/dist/fix.js +120 -0
  12. package/dist/git/blame.d.ts +22 -0
  13. package/dist/git/blame.js +227 -0
  14. package/dist/git/helpers.d.ts +36 -0
  15. package/dist/git/helpers.js +152 -0
  16. package/dist/git/trend.d.ts +21 -0
  17. package/dist/git/trend.js +80 -0
  18. package/dist/git.d.ts +0 -4
  19. package/dist/git.js +2 -2
  20. package/dist/report.js +620 -293
  21. package/dist/rules/phase0-basic.d.ts +11 -0
  22. package/dist/rules/phase0-basic.js +176 -0
  23. package/dist/rules/phase1-complexity.d.ts +31 -0
  24. package/dist/rules/phase1-complexity.js +277 -0
  25. package/dist/rules/phase2-crossfile.d.ts +27 -0
  26. package/dist/rules/phase2-crossfile.js +122 -0
  27. package/dist/rules/phase3-arch.d.ts +31 -0
  28. package/dist/rules/phase3-arch.js +148 -0
  29. package/dist/rules/phase5-ai.d.ts +8 -0
  30. package/dist/rules/phase5-ai.js +262 -0
  31. package/dist/rules/phase8-semantic.d.ts +22 -0
  32. package/dist/rules/phase8-semantic.js +109 -0
  33. package/dist/rules/shared.d.ts +7 -0
  34. package/dist/rules/shared.js +27 -0
  35. package/package.json +8 -3
  36. package/packages/vscode-drift/.vscodeignore +9 -0
  37. package/packages/vscode-drift/LICENSE +21 -0
  38. package/packages/vscode-drift/README.md +64 -0
  39. package/packages/vscode-drift/images/icon.png +0 -0
  40. package/packages/vscode-drift/images/icon.svg +30 -0
  41. package/packages/vscode-drift/package-lock.json +485 -0
  42. package/packages/vscode-drift/package.json +119 -0
  43. package/packages/vscode-drift/src/analyzer.ts +38 -0
  44. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  45. package/packages/vscode-drift/src/extension.ts +111 -0
  46. package/packages/vscode-drift/src/statusbar.ts +47 -0
  47. package/packages/vscode-drift/src/treeview.ts +108 -0
  48. package/packages/vscode-drift/tsconfig.json +18 -0
  49. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  50. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  51. package/src/analyzer.ts +124 -1726
  52. package/src/cli.ts +53 -4
  53. package/src/config.ts +1 -1
  54. package/src/fix.ts +154 -0
  55. package/src/git/blame.ts +279 -0
  56. package/src/git/helpers.ts +198 -0
  57. package/src/git/trend.ts +116 -0
  58. package/src/git.ts +2 -2
  59. package/src/report.ts +631 -296
  60. package/src/rules/phase0-basic.ts +187 -0
  61. package/src/rules/phase1-complexity.ts +302 -0
  62. package/src/rules/phase2-crossfile.ts +149 -0
  63. package/src/rules/phase3-arch.ts +179 -0
  64. package/src/rules/phase5-ai.ts +292 -0
  65. package/src/rules/phase8-semantic.ts +132 -0
  66. package/src/rules/shared.ts +39 -0
  67. package/tests/helpers.ts +45 -0
  68. package/tests/rules.test.ts +1269 -0
  69. 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 { TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
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
+ }
@@ -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
+ }