prjct-cli 0.44.1 → 0.45.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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Recent Tool - Find "hot" files based on git history
3
+ *
4
+ * Identifies recently and frequently modified files,
5
+ * which are likely to be relevant for current work.
6
+ *
7
+ * @module context-tools/recent-tool
8
+ * @version 1.0.0
9
+ */
10
+
11
+ import { exec as execCallback } from 'child_process'
12
+ import { promisify } from 'util'
13
+ import type { RecentToolOutput, HotFile } from './types'
14
+
15
+ const exec = promisify(execCallback)
16
+
17
+ // =============================================================================
18
+ // Constants
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Files to ignore in recent analysis
23
+ */
24
+ const IGNORE_PATTERNS = [
25
+ 'package-lock.json',
26
+ 'yarn.lock',
27
+ 'pnpm-lock.yaml',
28
+ 'bun.lockb',
29
+ '.gitignore',
30
+ '.env',
31
+ '.env.local',
32
+ '*.md',
33
+ 'CHANGELOG.md',
34
+ 'LICENSE',
35
+ ]
36
+
37
+ // =============================================================================
38
+ // Main Functions
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Get recently modified files based on git history
43
+ *
44
+ * @param projectPath - Project root path
45
+ * @param options - Analysis options
46
+ * @returns Hot files sorted by heat score
47
+ */
48
+ export async function getRecentFiles(
49
+ projectPath: string = process.cwd(),
50
+ options: {
51
+ commits?: number // Number of commits to analyze (default 30)
52
+ branch?: boolean // Only files changed in current branch vs main
53
+ maxFiles?: number // Max files to return (default 50)
54
+ } = {}
55
+ ): Promise<RecentToolOutput> {
56
+ const commits = options.commits ?? 30
57
+ const maxFiles = options.maxFiles ?? 50
58
+ const branchOnly = options.branch ?? false
59
+
60
+ try {
61
+ let hotFiles: HotFile[] = []
62
+ let branchOnlyFiles: string[] = []
63
+ let analysisWindow = `${commits} commits`
64
+
65
+ if (branchOnly) {
66
+ // Get files changed only in current branch
67
+ const result = await getBranchOnlyFiles(projectPath)
68
+ hotFiles = result.hotFiles
69
+ branchOnlyFiles = result.branchOnlyFiles
70
+ analysisWindow = result.analysisWindow
71
+ } else {
72
+ // Get files from recent commits
73
+ hotFiles = await getHotFilesFromCommits(projectPath, commits)
74
+ }
75
+
76
+ // Filter and limit
77
+ hotFiles = hotFiles
78
+ .filter((f) => !shouldIgnore(f.path))
79
+ .slice(0, maxFiles)
80
+
81
+ return {
82
+ hotFiles,
83
+ branchOnlyFiles,
84
+ metrics: {
85
+ commitsAnalyzed: commits,
86
+ totalFilesChanged: hotFiles.length,
87
+ filesReturned: Math.min(hotFiles.length, maxFiles),
88
+ analysisWindow,
89
+ },
90
+ }
91
+ } catch (error) {
92
+ // Git not available or not a repo
93
+ return {
94
+ hotFiles: [],
95
+ branchOnlyFiles: [],
96
+ metrics: {
97
+ commitsAnalyzed: 0,
98
+ totalFilesChanged: 0,
99
+ filesReturned: 0,
100
+ analysisWindow: 'N/A (git error)',
101
+ },
102
+ }
103
+ }
104
+ }
105
+
106
+ // =============================================================================
107
+ // Helper Functions
108
+ // =============================================================================
109
+
110
+ /**
111
+ * Get hot files from recent commits
112
+ */
113
+ async function getHotFilesFromCommits(
114
+ projectPath: string,
115
+ commits: number
116
+ ): Promise<HotFile[]> {
117
+ // Get file change counts and last modified times
118
+ const { stdout } = await exec(
119
+ `git log -${commits} --pretty=format:"%ct" --name-only | awk '
120
+ /^[0-9]+$/ { timestamp=$1; next }
121
+ NF {
122
+ count[$0]++
123
+ if (!lastmod[$0] || timestamp > lastmod[$0]) lastmod[$0]=timestamp
124
+ }
125
+ END {
126
+ for (f in count) print count[f], lastmod[f], f
127
+ }
128
+ ' | sort -rn`,
129
+ { cwd: projectPath, maxBuffer: 10 * 1024 * 1024 }
130
+ )
131
+
132
+ const hotFiles: HotFile[] = []
133
+ const lines = stdout.trim().split('\n').filter(Boolean)
134
+ const now = Math.floor(Date.now() / 1000)
135
+
136
+ // Find max changes for normalization
137
+ let maxChanges = 1
138
+ for (const line of lines) {
139
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/)
140
+ if (match) {
141
+ const changes = parseInt(match[1])
142
+ if (changes > maxChanges) maxChanges = changes
143
+ }
144
+ }
145
+
146
+ for (const line of lines) {
147
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/)
148
+ if (!match) continue
149
+
150
+ const changes = parseInt(match[1])
151
+ const timestamp = parseInt(match[2])
152
+ const filePath = match[3]
153
+
154
+ const secondsAgo = now - timestamp
155
+ const daysAgo = Math.floor(secondsAgo / 86400)
156
+ const hoursAgo = Math.floor(secondsAgo / 3600)
157
+
158
+ // Calculate heat score (combination of recency and frequency)
159
+ const recencyScore = Math.max(0, 1 - daysAgo / 30) // Decay over 30 days
160
+ const frequencyScore = changes / maxChanges
161
+ const heatScore = recencyScore * 0.6 + frequencyScore * 0.4
162
+
163
+ // Format last changed
164
+ let lastChanged: string
165
+ if (hoursAgo < 1) {
166
+ lastChanged = 'just now'
167
+ } else if (hoursAgo < 24) {
168
+ lastChanged = `${hoursAgo}h ago`
169
+ } else if (daysAgo < 7) {
170
+ lastChanged = `${daysAgo}d ago`
171
+ } else if (daysAgo < 30) {
172
+ lastChanged = `${Math.floor(daysAgo / 7)}w ago`
173
+ } else {
174
+ lastChanged = `${Math.floor(daysAgo / 30)}mo ago`
175
+ }
176
+
177
+ hotFiles.push({
178
+ path: filePath,
179
+ changes,
180
+ heatScore: Math.round(heatScore * 100) / 100,
181
+ lastChanged,
182
+ lastChangedAt: new Date(timestamp * 1000).toISOString(),
183
+ })
184
+ }
185
+
186
+ // Sort by heat score
187
+ return hotFiles.sort((a, b) => b.heatScore - a.heatScore)
188
+ }
189
+
190
+ /**
191
+ * Get files changed only in current branch vs main
192
+ */
193
+ async function getBranchOnlyFiles(projectPath: string): Promise<{
194
+ hotFiles: HotFile[]
195
+ branchOnlyFiles: string[]
196
+ analysisWindow: string
197
+ }> {
198
+ // Get current branch
199
+ const { stdout: branchOutput } = await exec('git branch --show-current', {
200
+ cwd: projectPath,
201
+ })
202
+ const currentBranch = branchOutput.trim()
203
+
204
+ // Determine base branch (main or master)
205
+ let baseBranch = 'main'
206
+ try {
207
+ await exec('git rev-parse --verify main', { cwd: projectPath })
208
+ } catch {
209
+ baseBranch = 'master'
210
+ }
211
+
212
+ // Get files changed in this branch
213
+ const { stdout: diffOutput } = await exec(
214
+ `git diff --name-only ${baseBranch}...HEAD`,
215
+ { cwd: projectPath }
216
+ )
217
+
218
+ const branchOnlyFiles = diffOutput.trim().split('\n').filter(Boolean)
219
+
220
+ // Get change counts for these files in the branch
221
+ const { stdout: logOutput } = await exec(
222
+ `git log ${baseBranch}..HEAD --pretty=format:"%ct" --name-only | awk '
223
+ /^[0-9]+$/ { timestamp=$1; next }
224
+ NF {
225
+ count[$0]++
226
+ if (!lastmod[$0] || timestamp > lastmod[$0]) lastmod[$0]=timestamp
227
+ }
228
+ END {
229
+ for (f in count) print count[f], lastmod[f], f
230
+ }
231
+ '`,
232
+ { cwd: projectPath, maxBuffer: 10 * 1024 * 1024 }
233
+ )
234
+
235
+ const hotFiles: HotFile[] = []
236
+ const lines = logOutput.trim().split('\n').filter(Boolean)
237
+ const now = Math.floor(Date.now() / 1000)
238
+
239
+ // Find max changes for normalization
240
+ let maxChanges = 1
241
+ for (const line of lines) {
242
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/)
243
+ if (match) {
244
+ const changes = parseInt(match[1])
245
+ if (changes > maxChanges) maxChanges = changes
246
+ }
247
+ }
248
+
249
+ for (const line of lines) {
250
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/)
251
+ if (!match) continue
252
+
253
+ const changes = parseInt(match[1])
254
+ const timestamp = parseInt(match[2])
255
+ const filePath = match[3]
256
+
257
+ const secondsAgo = now - timestamp
258
+ const daysAgo = Math.floor(secondsAgo / 86400)
259
+ const hoursAgo = Math.floor(secondsAgo / 3600)
260
+
261
+ const recencyScore = Math.max(0, 1 - daysAgo / 14) // Faster decay for branch
262
+ const frequencyScore = changes / maxChanges
263
+ const heatScore = recencyScore * 0.5 + frequencyScore * 0.5
264
+
265
+ let lastChanged: string
266
+ if (hoursAgo < 1) {
267
+ lastChanged = 'just now'
268
+ } else if (hoursAgo < 24) {
269
+ lastChanged = `${hoursAgo}h ago`
270
+ } else {
271
+ lastChanged = `${daysAgo}d ago`
272
+ }
273
+
274
+ hotFiles.push({
275
+ path: filePath,
276
+ changes,
277
+ heatScore: Math.round(heatScore * 100) / 100,
278
+ lastChanged,
279
+ lastChangedAt: new Date(timestamp * 1000).toISOString(),
280
+ })
281
+ }
282
+
283
+ return {
284
+ hotFiles: hotFiles.sort((a, b) => b.heatScore - a.heatScore),
285
+ branchOnlyFiles,
286
+ analysisWindow: `${baseBranch}..HEAD`,
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Check if a file should be ignored
292
+ */
293
+ function shouldIgnore(filePath: string): boolean {
294
+ const fileName = filePath.split('/').pop() || ''
295
+
296
+ for (const pattern of IGNORE_PATTERNS) {
297
+ if (pattern.startsWith('*.')) {
298
+ // Extension pattern
299
+ if (fileName.endsWith(pattern.slice(1))) return true
300
+ } else {
301
+ // Exact match
302
+ if (fileName === pattern) return true
303
+ }
304
+ }
305
+
306
+ return false
307
+ }
308
+
309
+ // =============================================================================
310
+ // Exports
311
+ // =============================================================================
312
+
313
+ export default { getRecentFiles }