@eduardbar/drift 0.9.1 → 1.1.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 (129) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +78 -0
  4. package/AGENTS.md +83 -23
  5. package/README.md +69 -2
  6. package/ROADMAP.md +130 -98
  7. package/dist/analyzer.d.ts +8 -38
  8. package/dist/analyzer.js +181 -1526
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +125 -4
  12. package/dist/config.js +1 -1
  13. package/dist/diff.d.ts +0 -7
  14. package/dist/diff.js +26 -25
  15. package/dist/fix.d.ts +17 -0
  16. package/dist/fix.js +132 -0
  17. package/dist/git/blame.d.ts +22 -0
  18. package/dist/git/blame.js +227 -0
  19. package/dist/git/helpers.d.ts +36 -0
  20. package/dist/git/helpers.js +152 -0
  21. package/dist/git/trend.d.ts +21 -0
  22. package/dist/git/trend.js +81 -0
  23. package/dist/git.d.ts +0 -13
  24. package/dist/git.js +27 -21
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +3 -0
  27. package/dist/map.d.ts +3 -0
  28. package/dist/map.js +103 -0
  29. package/dist/metrics.d.ts +4 -0
  30. package/dist/metrics.js +176 -0
  31. package/dist/plugins.d.ts +6 -0
  32. package/dist/plugins.js +74 -0
  33. package/dist/printer.js +20 -0
  34. package/dist/report.js +654 -293
  35. package/dist/reporter.js +85 -2
  36. package/dist/review.d.ts +15 -0
  37. package/dist/review.js +80 -0
  38. package/dist/rules/comments.d.ts +4 -0
  39. package/dist/rules/comments.js +45 -0
  40. package/dist/rules/complexity.d.ts +4 -0
  41. package/dist/rules/complexity.js +51 -0
  42. package/dist/rules/coupling.d.ts +4 -0
  43. package/dist/rules/coupling.js +19 -0
  44. package/dist/rules/magic.d.ts +4 -0
  45. package/dist/rules/magic.js +33 -0
  46. package/dist/rules/nesting.d.ts +5 -0
  47. package/dist/rules/nesting.js +82 -0
  48. package/dist/rules/phase0-basic.d.ts +11 -0
  49. package/dist/rules/phase0-basic.js +183 -0
  50. package/dist/rules/phase1-complexity.d.ts +7 -0
  51. package/dist/rules/phase1-complexity.js +8 -0
  52. package/dist/rules/phase2-crossfile.d.ts +23 -0
  53. package/dist/rules/phase2-crossfile.js +135 -0
  54. package/dist/rules/phase3-arch.d.ts +23 -0
  55. package/dist/rules/phase3-arch.js +151 -0
  56. package/dist/rules/phase3-configurable.d.ts +6 -0
  57. package/dist/rules/phase3-configurable.js +97 -0
  58. package/dist/rules/phase5-ai.d.ts +8 -0
  59. package/dist/rules/phase5-ai.js +262 -0
  60. package/dist/rules/phase8-semantic.d.ts +17 -0
  61. package/dist/rules/phase8-semantic.js +110 -0
  62. package/dist/rules/promise.d.ts +4 -0
  63. package/dist/rules/promise.js +24 -0
  64. package/dist/rules/shared.d.ts +7 -0
  65. package/dist/rules/shared.js +27 -0
  66. package/dist/snapshot.d.ts +19 -0
  67. package/dist/snapshot.js +119 -0
  68. package/dist/types.d.ts +69 -0
  69. package/dist/utils.d.ts +2 -1
  70. package/dist/utils.js +1 -0
  71. package/docs/AGENTS.md +146 -0
  72. package/docs/PRD.md +208 -0
  73. package/package.json +8 -3
  74. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  75. package/packages/vscode-drift/.vscodeignore +9 -0
  76. package/packages/vscode-drift/LICENSE +21 -0
  77. package/packages/vscode-drift/README.md +64 -0
  78. package/packages/vscode-drift/images/icon.png +0 -0
  79. package/packages/vscode-drift/images/icon.svg +30 -0
  80. package/packages/vscode-drift/package-lock.json +485 -0
  81. package/packages/vscode-drift/package.json +119 -0
  82. package/packages/vscode-drift/src/analyzer.ts +40 -0
  83. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  84. package/packages/vscode-drift/src/extension.ts +135 -0
  85. package/packages/vscode-drift/src/statusbar.ts +55 -0
  86. package/packages/vscode-drift/src/treeview.ts +110 -0
  87. package/packages/vscode-drift/tsconfig.json +18 -0
  88. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  89. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  90. package/src/analyzer.ts +248 -1765
  91. package/src/badge.ts +38 -16
  92. package/src/ci.ts +38 -17
  93. package/src/cli.ts +143 -4
  94. package/src/config.ts +1 -1
  95. package/src/diff.ts +36 -30
  96. package/src/fix.ts +178 -0
  97. package/src/git/blame.ts +279 -0
  98. package/src/git/helpers.ts +198 -0
  99. package/src/git/trend.ts +117 -0
  100. package/src/git.ts +33 -24
  101. package/src/index.ts +16 -1
  102. package/src/map.ts +117 -0
  103. package/src/metrics.ts +200 -0
  104. package/src/plugins.ts +76 -0
  105. package/src/printer.ts +20 -0
  106. package/src/report.ts +666 -296
  107. package/src/reporter.ts +95 -2
  108. package/src/review.ts +98 -0
  109. package/src/rules/comments.ts +56 -0
  110. package/src/rules/complexity.ts +57 -0
  111. package/src/rules/coupling.ts +23 -0
  112. package/src/rules/magic.ts +38 -0
  113. package/src/rules/nesting.ts +88 -0
  114. package/src/rules/phase0-basic.ts +194 -0
  115. package/src/rules/phase1-complexity.ts +8 -0
  116. package/src/rules/phase2-crossfile.ts +177 -0
  117. package/src/rules/phase3-arch.ts +183 -0
  118. package/src/rules/phase3-configurable.ts +132 -0
  119. package/src/rules/phase5-ai.ts +292 -0
  120. package/src/rules/phase8-semantic.ts +136 -0
  121. package/src/rules/promise.ts +29 -0
  122. package/src/rules/shared.ts +39 -0
  123. package/src/snapshot.ts +175 -0
  124. package/src/types.ts +75 -1
  125. package/src/utils.ts +3 -1
  126. package/tests/helpers.ts +45 -0
  127. package/tests/new-features.test.ts +153 -0
  128. package/tests/rules.test.ts +1269 -0
  129. package/vitest.config.ts +15 -0
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ // drift-ignore-file
2
+
3
+ import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport } from '../types.js'
4
+ import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js'
5
+ import { buildReport } from '../reporter.js'
6
+
7
+ export class TrendAnalyzer {
8
+ private readonly projectPath: string
9
+ private readonly config: DriftConfig | undefined
10
+ private readonly analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[]
11
+
12
+ constructor(
13
+ projectPath: string,
14
+ analyzeProjectFn: (targetPath: string, config?: DriftConfig) => FileReport[],
15
+ config?: DriftConfig,
16
+ ) {
17
+ this.projectPath = projectPath
18
+ this.analyzeProjectFn = analyzeProjectFn
19
+ this.config = config
20
+ }
21
+
22
+ // --- Static utility methods -----------------------------------------------
23
+
24
+ static calculateMovingAverage(data: TrendDataPoint[], windowSize: number): number[] {
25
+ return data.map((_, i) => {
26
+ const start = Math.max(0, i - windowSize + 1)
27
+ const window = data.slice(start, i + 1)
28
+ return window.reduce((s, p) => s + p.score, 0) / window.length
29
+ })
30
+ }
31
+
32
+ static linearRegression(data: TrendDataPoint[]): { slope: number; intercept: number; r2: number } {
33
+ const n = data.length
34
+ if (n < 2) return { slope: 0, intercept: data[0]?.score ?? 0, r2: 0 }
35
+
36
+ const xs = data.map((_, i) => i)
37
+ const ys = data.map(p => p.score)
38
+
39
+ const xMean = xs.reduce((s, x) => s + x, 0) / n
40
+ const yMean = ys.reduce((s, y) => s + y, 0) / n
41
+
42
+ const ssXX = xs.reduce((s, x) => s + (x - xMean) ** 2, 0)
43
+ const ssXY = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i]! - yMean), 0)
44
+ const ssYY = ys.reduce((s, y) => s + (y - yMean) ** 2, 0)
45
+
46
+ const slope = ssXX === 0 ? 0 : ssXY / ssXX
47
+ const intercept = yMean - slope * xMean
48
+ const r2 = ssYY === 0 ? 1 : (ssXY ** 2) / (ssXX * ssYY)
49
+
50
+ return { slope, intercept, r2 }
51
+ }
52
+
53
+ /** Generate a simple horizontal ASCII bar chart (one bar per data point). */
54
+ static generateTrendChart(data: TrendDataPoint[]): string {
55
+ if (data.length === 0) return '(no data)'
56
+
57
+ const maxScore = Math.max(...data.map(p => p.score), 1)
58
+ const chartWidth = 40
59
+
60
+ const lines = data.map(p => {
61
+ const barLen = Math.round((p.score / maxScore) * chartWidth)
62
+ const bar = '█'.repeat(barLen)
63
+ const dateStr = p.date.toISOString().slice(0, 10)
64
+ return `${dateStr} │${bar.padEnd(chartWidth)} ${p.score.toFixed(1)}`
65
+ })
66
+
67
+ return lines.join('\n')
68
+ }
69
+
70
+ // --- Instance method -------------------------------------------------------
71
+
72
+ async analyzeTrend(options: {
73
+ period?: 'week' | 'month' | 'quarter' | 'year'
74
+ since?: string
75
+ until?: string
76
+ }): Promise<DriftTrendReport> {
77
+ assertGitRepo(this.projectPath)
78
+
79
+ const periodDays: Record<string, number> = {
80
+ week: 7, month: 30, quarter: 90, year: 365,
81
+ }
82
+ const days = periodDays[options.period ?? 'month'] ?? 30
83
+ const sinceDate = options.since
84
+ ? new Date(options.since)
85
+ : new Date(Date.now() - days * 24 * 60 * 60 * 1000)
86
+
87
+ const historicalAnalyses = await analyzeHistoricalCommits(
88
+ sinceDate,
89
+ this.projectPath,
90
+ 100,
91
+ this.analyzeProjectFn,
92
+ this.config,
93
+ 10,
94
+ )
95
+
96
+ const trendPoints: TrendDataPoint[] = historicalAnalyses.map(h => ({
97
+ date: h.commitDate,
98
+ score: h.averageScore,
99
+ fileCount: h.files.length,
100
+ avgIssuesPerFile: h.files.length > 0
101
+ ? h.files.reduce((s, f) => s + f.issues.length, 0) / h.files.length
102
+ : 0,
103
+ }))
104
+
105
+ const regression = TrendAnalyzer.linearRegression(trendPoints)
106
+
107
+ // Current state report
108
+ const currentFiles = this.analyzeProjectFn(this.projectPath, this.config)
109
+ const baseReport = buildReport(this.projectPath, currentFiles)
110
+
111
+ return {
112
+ ...baseReport,
113
+ trend: trendPoints,
114
+ regression,
115
+ }
116
+ }
117
+ }
package/src/git.ts CHANGED
@@ -13,22 +13,23 @@ import { randomUUID } from 'node:crypto'
13
13
  *
14
14
  * Throws if the directory is not a git repo or the ref is invalid.
15
15
  */
16
- export function extractFilesAtRef(projectPath: string, ref: string): string {
17
- // Verify git repo
16
+ function verifyGitRepo(projectPath: string): void {
18
17
  try {
19
18
  execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
20
19
  } catch {
21
20
  throw new Error(`Not a git repository: ${projectPath}`)
22
21
  }
22
+ }
23
23
 
24
- // Verify ref exists
24
+ function verifyRefExists(projectPath: string, ref: string): void {
25
25
  try {
26
26
  execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
27
27
  } catch {
28
28
  throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`)
29
29
  }
30
+ }
30
31
 
31
- // List all .ts files tracked at this ref (excluding .d.ts)
32
+ function listTsFilesAtRef(projectPath: string, ref: string): string[] {
32
33
  let fileList: string
33
34
  try {
34
35
  fileList = execSync(
@@ -39,36 +40,44 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
39
40
  throw new Error(`Failed to list files at ref '${ref}'`)
40
41
  }
41
42
 
42
- const tsFiles = fileList
43
+ return fileList
43
44
  .split('\n')
44
45
  .map(f => f.trim())
45
- .filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'))
46
+ .filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'))
47
+ }
48
+
49
+ function extractFile(projectPath: string, ref: string, filePath: string, tempDir: string): void {
50
+ let content: string
51
+ try {
52
+ content = execSync(
53
+ `git show ${ref}:${filePath}`,
54
+ { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
55
+ )
56
+ } catch {
57
+ return
58
+ }
59
+
60
+ const destPath = join(tempDir, filePath.split('/').join(sep))
61
+ const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
62
+ mkdirSync(destDir, { recursive: true })
63
+ writeFileSync(destPath, content, 'utf-8')
64
+ }
65
+
66
+ export function extractFilesAtRef(projectPath: string, ref: string): string {
67
+ verifyGitRepo(projectPath)
68
+ verifyRefExists(projectPath, ref)
69
+
70
+ const tsFiles = listTsFilesAtRef(projectPath, ref)
46
71
 
47
72
  if (tsFiles.length === 0) {
48
73
  throw new Error(`No TypeScript files found at ref '${ref}'`)
49
74
  }
50
75
 
51
- // Create temp directory
52
76
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
53
77
  mkdirSync(tempDir, { recursive: true })
54
78
 
55
- // Extract each file
56
79
  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')
80
+ extractFile(projectPath, ref, filePath, tempDir)
72
81
  }
73
82
 
74
83
  return tempDir
@@ -86,7 +95,7 @@ export function cleanupTempDir(tempDir: string): void {
86
95
  /**
87
96
  * Get the short hash of a git ref for display purposes.
88
97
  */
89
- export function resolveRefHash(projectPath: string, ref: string): string {
98
+ function resolveRefHash(projectPath: string, ref: string): string {
90
99
  try {
91
100
  return execSync(
92
101
  `git rev-parse --short ${ref}`,