@repolensai/cli 0.1.1
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/README.md +70 -0
- package/bin/repolens.mjs +3518 -0
- package/lib/auth-store.d.ts +21 -0
- package/lib/auth-store.mjs +68 -0
- package/lib/git-history.d.ts +214 -0
- package/lib/git-history.mjs +1064 -0
- package/lib/history-metadata-shared.d.ts +18 -0
- package/lib/history-metadata-shared.mjs +87 -0
- package/lib/history-report-shared.d.ts +6 -0
- package/lib/history-report-shared.mjs +410 -0
- package/lib/protocol-handler.d.ts +15 -0
- package/lib/protocol-handler.mjs +158 -0
- package/lib/publish-shared.d.ts +14 -0
- package/lib/publish-shared.mjs +299 -0
- package/lib/runtime-events.d.ts +31 -0
- package/lib/runtime-events.mjs +80 -0
- package/package.json +38 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function collectEvolutionReport(params) {
|
|
5
|
+
const { cwd, targetPath, sinceRef = null, limit = 20, exec } = params;
|
|
6
|
+
const scopedTrackedFiles = listScopedTrackedFiles(targetPath, exec);
|
|
7
|
+
const pathKind = resolvePathKind(cwd, targetPath, scopedTrackedFiles);
|
|
8
|
+
const follow = pathKind === "file" && shouldFollowPathHistory(cwd, targetPath);
|
|
9
|
+
const range = sinceRef ? `${sinceRef}..HEAD` : null;
|
|
10
|
+
|
|
11
|
+
const logArgs = ["log", "--date=short", "--format=%H%x09%ad%x09%an%x09%s"];
|
|
12
|
+
if (follow) {
|
|
13
|
+
logArgs.push("--follow");
|
|
14
|
+
}
|
|
15
|
+
if (range) {
|
|
16
|
+
logArgs.push(range);
|
|
17
|
+
}
|
|
18
|
+
logArgs.push("--", targetPath);
|
|
19
|
+
|
|
20
|
+
const commits = parseCommitLog(exec(logArgs, { allowFailure: true })).slice(0, limit);
|
|
21
|
+
const authorStats = summarizeAuthors(commits);
|
|
22
|
+
|
|
23
|
+
const numstatArgs = ["log", "--numstat", "--format=commit\t%H"];
|
|
24
|
+
if (follow) {
|
|
25
|
+
numstatArgs.push("--follow");
|
|
26
|
+
}
|
|
27
|
+
if (range) {
|
|
28
|
+
numstatArgs.push(range);
|
|
29
|
+
}
|
|
30
|
+
numstatArgs.push("--", targetPath);
|
|
31
|
+
|
|
32
|
+
const sortedFileStats = summarizeNumstat(parseNumstatLog(exec(numstatArgs, { allowFailure: true })))
|
|
33
|
+
.sort((left, right) => right.score - left.score || right.touchedCommits - left.touchedCommits || left.filePath.localeCompare(right.filePath));
|
|
34
|
+
const renameHistory = collectRenameHistory({
|
|
35
|
+
targetPath,
|
|
36
|
+
follow,
|
|
37
|
+
range,
|
|
38
|
+
exec,
|
|
39
|
+
});
|
|
40
|
+
const topFiles = sortedFileStats.slice(0, Math.min(limit, 5));
|
|
41
|
+
const directoryStats = pathKind === "directory"
|
|
42
|
+
? summarizeDirectoryStats(sortedFileStats, targetPath).slice(0, Math.min(limit, 5))
|
|
43
|
+
: [];
|
|
44
|
+
const commitThemes = summarizeCommitThemes(commits, Math.min(limit, 4), [targetPath, ...topFiles.map((file) => file.filePath)]);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
targetPath,
|
|
48
|
+
pathKind,
|
|
49
|
+
sinceRef,
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
trackedFileCount: scopedTrackedFiles.length,
|
|
52
|
+
commitCount: commits.length,
|
|
53
|
+
commits,
|
|
54
|
+
authors: authorStats,
|
|
55
|
+
fileStats: sortedFileStats.slice(0, 10),
|
|
56
|
+
topFiles,
|
|
57
|
+
directoryStats,
|
|
58
|
+
commitThemes,
|
|
59
|
+
whyChanged: buildWhyChangedSummary({
|
|
60
|
+
targetPath,
|
|
61
|
+
pathKind,
|
|
62
|
+
commitThemes,
|
|
63
|
+
renameHistory,
|
|
64
|
+
topOwner: authorStats[0] ?? null,
|
|
65
|
+
topFile: topFiles[0] ?? null,
|
|
66
|
+
topDirectory: directoryStats[0] ?? null,
|
|
67
|
+
}),
|
|
68
|
+
renameHistory,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function collectHotspotReport(params) {
|
|
73
|
+
const { sinceRef = "HEAD~100", limit = 15, exec } = params;
|
|
74
|
+
const range = `${sinceRef}..HEAD`;
|
|
75
|
+
const raw = exec(["log", "--numstat", "--format=commit\t%H", range], { allowFailure: true });
|
|
76
|
+
const rows = summarizeNumstat(parseNumstatLog(raw))
|
|
77
|
+
.sort((left, right) => right.score - left.score || right.touchedCommits - left.touchedCommits)
|
|
78
|
+
.slice(0, limit);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
sinceRef,
|
|
82
|
+
generatedAt: new Date().toISOString(),
|
|
83
|
+
files: rows,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function collectOwnershipReport(params) {
|
|
88
|
+
const { cwd, targetPath, sinceRef = null, limit = 10, exec } = params;
|
|
89
|
+
const scopedTrackedFiles = listScopedTrackedFiles(targetPath, exec);
|
|
90
|
+
const pathKind = resolvePathKind(cwd, targetPath, scopedTrackedFiles);
|
|
91
|
+
const follow = pathKind === "file" && shouldFollowPathHistory(cwd, targetPath);
|
|
92
|
+
const range = sinceRef ? `${sinceRef}..HEAD` : null;
|
|
93
|
+
|
|
94
|
+
const logArgs = ["log", "--date=short", "--format=%H%x09%ad%x09%an%x09%s"];
|
|
95
|
+
if (follow) {
|
|
96
|
+
logArgs.push("--follow");
|
|
97
|
+
}
|
|
98
|
+
if (range) {
|
|
99
|
+
logArgs.push(range);
|
|
100
|
+
}
|
|
101
|
+
logArgs.push("--", targetPath);
|
|
102
|
+
|
|
103
|
+
const commits = parseCommitLog(exec(logArgs, { allowFailure: true }));
|
|
104
|
+
const byAuthor = new Map();
|
|
105
|
+
|
|
106
|
+
for (const commit of commits) {
|
|
107
|
+
const current = byAuthor.get(commit.author) ?? {
|
|
108
|
+
author: commit.author,
|
|
109
|
+
commits: 0,
|
|
110
|
+
lastTouchedAt: commit.date,
|
|
111
|
+
lastSubject: commit.subject,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
current.commits += 1;
|
|
115
|
+
if (!current.lastTouchedAt || commit.date > current.lastTouchedAt) {
|
|
116
|
+
current.lastTouchedAt = commit.date;
|
|
117
|
+
current.lastSubject = commit.subject;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
byAuthor.set(commit.author, current);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const detailedNumstatArgs = ["log", "--date=short", "--numstat", "--format=commit\t%H\t%ad\t%an\t%s"];
|
|
124
|
+
if (follow) {
|
|
125
|
+
detailedNumstatArgs.push("--follow");
|
|
126
|
+
}
|
|
127
|
+
if (range) {
|
|
128
|
+
detailedNumstatArgs.push(range);
|
|
129
|
+
}
|
|
130
|
+
detailedNumstatArgs.push("--", targetPath);
|
|
131
|
+
|
|
132
|
+
const blameOwners = pathKind === "file" && shouldReadBlamePath(cwd, targetPath)
|
|
133
|
+
? summarizeBlameOwners(parseBlamePorcelain(exec(["blame", "--line-porcelain", "--", targetPath], { allowFailure: true })))
|
|
134
|
+
.slice(0, limit)
|
|
135
|
+
: [];
|
|
136
|
+
const fileOwners = summarizeFileOwners(parseDetailedNumstatLog(exec(detailedNumstatArgs, { allowFailure: true }))).slice(0, limit);
|
|
137
|
+
const renameHistory = collectRenameHistory({
|
|
138
|
+
targetPath,
|
|
139
|
+
follow,
|
|
140
|
+
range,
|
|
141
|
+
exec,
|
|
142
|
+
}).slice(0, limit);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
targetPath,
|
|
146
|
+
pathKind,
|
|
147
|
+
sinceRef,
|
|
148
|
+
generatedAt: new Date().toISOString(),
|
|
149
|
+
trackedFileCount: scopedTrackedFiles.length,
|
|
150
|
+
owners: Array.from(byAuthor.values())
|
|
151
|
+
.sort((left, right) => right.commits - left.commits || left.author.localeCompare(right.author))
|
|
152
|
+
.slice(0, limit),
|
|
153
|
+
blameOwners,
|
|
154
|
+
fileOwners,
|
|
155
|
+
renameHistory,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function collectStartHereReport(params) {
|
|
160
|
+
const { cwd, targetPath, sinceRef = null, limit = 5, exec } = params;
|
|
161
|
+
const evolution = collectEvolutionReport({
|
|
162
|
+
cwd,
|
|
163
|
+
targetPath,
|
|
164
|
+
sinceRef,
|
|
165
|
+
limit: Math.max(limit, 5),
|
|
166
|
+
exec,
|
|
167
|
+
});
|
|
168
|
+
const ownership = collectOwnershipReport({
|
|
169
|
+
cwd,
|
|
170
|
+
targetPath,
|
|
171
|
+
sinceRef,
|
|
172
|
+
limit: Math.max(limit, 5),
|
|
173
|
+
exec,
|
|
174
|
+
});
|
|
175
|
+
const readingOrder = buildReadingOrder({
|
|
176
|
+
targetPath,
|
|
177
|
+
pathKind: evolution.pathKind,
|
|
178
|
+
trackedFileCount: evolution.trackedFileCount,
|
|
179
|
+
commitCount: evolution.commitCount,
|
|
180
|
+
topFiles: evolution.topFiles,
|
|
181
|
+
directoryStats: evolution.directoryStats,
|
|
182
|
+
fileOwners: ownership.fileOwners,
|
|
183
|
+
renameHistory: evolution.renameHistory,
|
|
184
|
+
limit,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
targetPath,
|
|
189
|
+
pathKind: evolution.pathKind,
|
|
190
|
+
sinceRef,
|
|
191
|
+
generatedAt: new Date().toISOString(),
|
|
192
|
+
trackedFileCount: evolution.trackedFileCount,
|
|
193
|
+
summary: buildStartHereSummary({
|
|
194
|
+
pathKind: evolution.pathKind,
|
|
195
|
+
targetPath,
|
|
196
|
+
commitCount: evolution.commitCount,
|
|
197
|
+
authorCount: evolution.authors.length,
|
|
198
|
+
trackedFileCount: evolution.trackedFileCount,
|
|
199
|
+
topOwner: ownership.owners[0] ?? null,
|
|
200
|
+
topFile: evolution.topFiles[0] ?? null,
|
|
201
|
+
}),
|
|
202
|
+
commitThemes: evolution.commitThemes,
|
|
203
|
+
whyChanged: evolution.whyChanged,
|
|
204
|
+
readingOrder,
|
|
205
|
+
hotspotFiles: evolution.topFiles.slice(0, limit),
|
|
206
|
+
directoryStats: evolution.directoryStats.slice(0, limit),
|
|
207
|
+
owners: ownership.owners.slice(0, limit),
|
|
208
|
+
fileOwners: ownership.fileOwners.slice(0, limit),
|
|
209
|
+
recentCommits: evolution.commits.slice(0, limit),
|
|
210
|
+
renameHistory: evolution.renameHistory.slice(0, limit),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function collectTraceReport(params) {
|
|
215
|
+
const { cwd, query, sinceRef = null, limit = 5, exec } = params;
|
|
216
|
+
const trackedFiles = exec(["ls-files"], { allowFailure: true })
|
|
217
|
+
.split(/\r?\n/)
|
|
218
|
+
.map((line) => line.trim())
|
|
219
|
+
.filter(Boolean);
|
|
220
|
+
|
|
221
|
+
const matches = collectTraceSignalMatches({ query, trackedFiles, exec });
|
|
222
|
+
|
|
223
|
+
const hotspotReport = collectHotspotReport({
|
|
224
|
+
sinceRef: sinceRef ?? "HEAD~100",
|
|
225
|
+
limit: Math.max(limit * 10, 25),
|
|
226
|
+
exec,
|
|
227
|
+
});
|
|
228
|
+
const hotspotScores = new Map(hotspotReport.files.map((file) => [file.filePath, file.score]));
|
|
229
|
+
|
|
230
|
+
const candidateMatches = mergeTraceMatches(matches)
|
|
231
|
+
.sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath))
|
|
232
|
+
.slice(0, Math.max(limit * 3, 10));
|
|
233
|
+
|
|
234
|
+
const enrichedMatches = candidateMatches.map((match) => {
|
|
235
|
+
const evolution = collectEvolutionReport({
|
|
236
|
+
cwd,
|
|
237
|
+
targetPath: match.filePath,
|
|
238
|
+
sinceRef,
|
|
239
|
+
limit: 5,
|
|
240
|
+
exec,
|
|
241
|
+
});
|
|
242
|
+
const ownership = collectOwnershipReport({
|
|
243
|
+
cwd,
|
|
244
|
+
targetPath: match.filePath,
|
|
245
|
+
sinceRef,
|
|
246
|
+
limit: 3,
|
|
247
|
+
exec,
|
|
248
|
+
});
|
|
249
|
+
const scoreBreakdown = buildTraceScoreBreakdown({
|
|
250
|
+
signalScores: match.signalScores,
|
|
251
|
+
hotspotScore: hotspotScores.get(match.filePath) ?? 0,
|
|
252
|
+
evolution,
|
|
253
|
+
ownership,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
filePath: match.filePath,
|
|
258
|
+
score: scoreBreakdown.total,
|
|
259
|
+
matchType: match.matchType,
|
|
260
|
+
matchSignals: match.matchSignals,
|
|
261
|
+
matchedLines: match.matchedLines,
|
|
262
|
+
scoreBreakdown,
|
|
263
|
+
evolution,
|
|
264
|
+
ownership,
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
query,
|
|
270
|
+
sinceRef,
|
|
271
|
+
generatedAt: new Date().toISOString(),
|
|
272
|
+
matches: enrichedMatches
|
|
273
|
+
.sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath))
|
|
274
|
+
.slice(0, limit),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function parseCommitLog(raw) {
|
|
279
|
+
return raw
|
|
280
|
+
.split(/\r?\n/)
|
|
281
|
+
.map((line) => line.trim())
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.map((line) => {
|
|
284
|
+
const [sha, date, author, ...subjectParts] = line.split("\t");
|
|
285
|
+
return {
|
|
286
|
+
sha: sha ?? "",
|
|
287
|
+
date: date ?? "",
|
|
288
|
+
author: author ?? "",
|
|
289
|
+
subject: subjectParts.join("\t"),
|
|
290
|
+
};
|
|
291
|
+
})
|
|
292
|
+
.filter((entry) => entry.sha);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function parseNumstatLog(raw) {
|
|
296
|
+
const entries = [];
|
|
297
|
+
let currentCommit = null;
|
|
298
|
+
|
|
299
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
300
|
+
if (!line.trim()) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (line.startsWith("commit\t")) {
|
|
305
|
+
currentCommit = line.slice("commit\t".length).trim();
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const [added, deleted, filePath] = line.split("\t");
|
|
310
|
+
if (!currentCommit || !filePath) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
entries.push({
|
|
315
|
+
commit: currentCommit,
|
|
316
|
+
filePath,
|
|
317
|
+
additions: parseNumstatNumber(added),
|
|
318
|
+
deletions: parseNumstatNumber(deleted),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return entries;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function parseBlamePorcelain(raw) {
|
|
326
|
+
const entries = [];
|
|
327
|
+
let current = null;
|
|
328
|
+
|
|
329
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
330
|
+
if (!line.trim()) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (/^[0-9a-f]{40}\s/.test(line)) {
|
|
335
|
+
if (current) {
|
|
336
|
+
entries.push(current);
|
|
337
|
+
}
|
|
338
|
+
const [commit] = line.split(" ");
|
|
339
|
+
current = {
|
|
340
|
+
commit,
|
|
341
|
+
author: "Unknown",
|
|
342
|
+
lineCount: 1,
|
|
343
|
+
};
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!current) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (line.startsWith("author ")) {
|
|
352
|
+
current.author = line.slice("author ".length).trim() || "Unknown";
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (current) {
|
|
358
|
+
entries.push(current);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return entries;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function parseGrepResults(raw) {
|
|
365
|
+
return raw
|
|
366
|
+
.split(/\r?\n/)
|
|
367
|
+
.map((line) => line.trimEnd())
|
|
368
|
+
.filter(Boolean)
|
|
369
|
+
.map((line) => {
|
|
370
|
+
const firstColon = line.indexOf(":");
|
|
371
|
+
const secondColon = firstColon >= 0 ? line.indexOf(":", firstColon + 1) : -1;
|
|
372
|
+
if (firstColon < 0 || secondColon < 0) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const filePath = line.slice(0, firstColon);
|
|
377
|
+
const lineNumber = Number.parseInt(line.slice(firstColon + 1, secondColon), 10);
|
|
378
|
+
const preview = line.slice(secondColon + 1).trim();
|
|
379
|
+
|
|
380
|
+
if (!filePath || Number.isNaN(lineNumber)) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { filePath, lineNumber, preview };
|
|
385
|
+
})
|
|
386
|
+
.filter(Boolean);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function parseNameStatusLog(raw) {
|
|
390
|
+
const entries = [];
|
|
391
|
+
let currentCommit = null;
|
|
392
|
+
|
|
393
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
394
|
+
if (!line.trim()) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (line.startsWith("commit\t")) {
|
|
399
|
+
currentCommit = line.slice("commit\t".length).trim();
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!currentCommit) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const [status, fromPath, toPath] = line.split("\t");
|
|
408
|
+
if (!status || !fromPath) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
entries.push({
|
|
413
|
+
commit: currentCommit,
|
|
414
|
+
status,
|
|
415
|
+
fromPath,
|
|
416
|
+
toPath: toPath ?? null,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return entries;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function parseDetailedNumstatLog(raw) {
|
|
424
|
+
const entries = [];
|
|
425
|
+
let currentCommit = null;
|
|
426
|
+
|
|
427
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
428
|
+
if (!line.trim()) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (line.startsWith("commit\t")) {
|
|
433
|
+
const [, sha = "", date = "", author = "", ...subjectParts] = line.split("\t");
|
|
434
|
+
currentCommit = {
|
|
435
|
+
sha,
|
|
436
|
+
date,
|
|
437
|
+
author,
|
|
438
|
+
subject: subjectParts.join("\t"),
|
|
439
|
+
};
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const [added, deleted, filePath] = line.split("\t");
|
|
444
|
+
if (!currentCommit || !filePath) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
entries.push({
|
|
449
|
+
commit: currentCommit.sha,
|
|
450
|
+
date: currentCommit.date,
|
|
451
|
+
author: currentCommit.author,
|
|
452
|
+
subject: currentCommit.subject,
|
|
453
|
+
filePath,
|
|
454
|
+
additions: parseNumstatNumber(added),
|
|
455
|
+
deletions: parseNumstatNumber(deleted),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return entries;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function summarizeAuthors(commits) {
|
|
463
|
+
const counts = new Map();
|
|
464
|
+
|
|
465
|
+
for (const commit of commits) {
|
|
466
|
+
counts.set(commit.author, (counts.get(commit.author) ?? 0) + 1);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return Array.from(counts.entries())
|
|
470
|
+
.map(([author, commitsCount]) => ({ author, commits: commitsCount }))
|
|
471
|
+
.sort((left, right) => right.commits - left.commits || left.author.localeCompare(right.author));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function summarizeNumstat(entries) {
|
|
475
|
+
const files = new Map();
|
|
476
|
+
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
const current = files.get(entry.filePath) ?? {
|
|
479
|
+
filePath: entry.filePath,
|
|
480
|
+
additions: 0,
|
|
481
|
+
deletions: 0,
|
|
482
|
+
touchedCommits: 0,
|
|
483
|
+
score: 0,
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
current.additions += entry.additions;
|
|
487
|
+
current.deletions += entry.deletions;
|
|
488
|
+
current.touchedCommits += 1;
|
|
489
|
+
current.score = current.touchedCommits * 5 + current.additions + current.deletions;
|
|
490
|
+
files.set(entry.filePath, current);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return Array.from(files.values());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function summarizeBlameOwners(entries) {
|
|
497
|
+
const owners = new Map();
|
|
498
|
+
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const current = owners.get(entry.author) ?? {
|
|
501
|
+
author: entry.author,
|
|
502
|
+
lines: 0,
|
|
503
|
+
commits: new Set(),
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
current.lines += entry.lineCount;
|
|
507
|
+
current.commits.add(entry.commit);
|
|
508
|
+
owners.set(entry.author, current);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return Array.from(owners.values())
|
|
512
|
+
.map((owner) => ({
|
|
513
|
+
author: owner.author,
|
|
514
|
+
lines: owner.lines,
|
|
515
|
+
commits: owner.commits.size,
|
|
516
|
+
}))
|
|
517
|
+
.sort((left, right) => right.lines - left.lines || right.commits - left.commits || left.author.localeCompare(right.author));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function summarizeFileOwners(entries) {
|
|
521
|
+
const files = new Map();
|
|
522
|
+
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
const current = files.get(entry.filePath) ?? {
|
|
525
|
+
filePath: entry.filePath,
|
|
526
|
+
authors: new Map(),
|
|
527
|
+
touchedCommits: new Set(),
|
|
528
|
+
additions: 0,
|
|
529
|
+
deletions: 0,
|
|
530
|
+
lastTouchedAt: "",
|
|
531
|
+
lastSubject: "",
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
current.authors.set(entry.author, (current.authors.get(entry.author) ?? 0) + 1);
|
|
535
|
+
current.touchedCommits.add(entry.commit);
|
|
536
|
+
current.additions += entry.additions;
|
|
537
|
+
current.deletions += entry.deletions;
|
|
538
|
+
if (!current.lastTouchedAt || entry.date > current.lastTouchedAt) {
|
|
539
|
+
current.lastTouchedAt = entry.date;
|
|
540
|
+
current.lastSubject = entry.subject;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
files.set(entry.filePath, current);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return Array.from(files.values())
|
|
547
|
+
.map((entry) => {
|
|
548
|
+
const rankedAuthors = Array.from(entry.authors.entries())
|
|
549
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
550
|
+
const [primaryOwner = "Unknown", ownerCommits = 0] = rankedAuthors[0] ?? [];
|
|
551
|
+
return {
|
|
552
|
+
filePath: entry.filePath,
|
|
553
|
+
primaryOwner,
|
|
554
|
+
ownerCommits,
|
|
555
|
+
touchedCommits: entry.touchedCommits.size,
|
|
556
|
+
lastTouchedAt: entry.lastTouchedAt,
|
|
557
|
+
lastSubject: entry.lastSubject,
|
|
558
|
+
additions: entry.additions,
|
|
559
|
+
deletions: entry.deletions,
|
|
560
|
+
};
|
|
561
|
+
})
|
|
562
|
+
.sort((left, right) => right.touchedCommits - left.touchedCommits || right.ownerCommits - left.ownerCommits || left.filePath.localeCompare(right.filePath));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function summarizeCommitThemes(commits, limit = 3, hints = []) {
|
|
566
|
+
const tokenStats = new Map();
|
|
567
|
+
|
|
568
|
+
for (const commit of commits) {
|
|
569
|
+
const tokens = Array.from(new Set(extractThemeTokens(commit.subject)));
|
|
570
|
+
for (const token of tokens) {
|
|
571
|
+
const current = tokenStats.get(token) ?? {
|
|
572
|
+
token,
|
|
573
|
+
count: 0,
|
|
574
|
+
subjects: [],
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
current.count += 1;
|
|
578
|
+
current.subjects.push(commit.subject);
|
|
579
|
+
tokenStats.set(token, current);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const ranked = Array.from(tokenStats.values())
|
|
584
|
+
.filter((entry) => entry.count >= 2)
|
|
585
|
+
.sort((left, right) => right.count - left.count || left.token.localeCompare(right.token))
|
|
586
|
+
.slice(0, limit)
|
|
587
|
+
.map((entry) => ({
|
|
588
|
+
label: `${toTitleCase(entry.token)} changes`,
|
|
589
|
+
commits: entry.count,
|
|
590
|
+
examples: Array.from(new Set(entry.subjects)).slice(0, 2),
|
|
591
|
+
}));
|
|
592
|
+
|
|
593
|
+
if (ranked.length > 0) {
|
|
594
|
+
return ranked;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const hintedTokens = Array.from(new Set(hints.flatMap((hint) => extractThemeTokens(hint))));
|
|
598
|
+
if (hintedTokens.length > 0 && commits.length > 0) {
|
|
599
|
+
return hintedTokens.slice(0, limit).map((token) => ({
|
|
600
|
+
label: `${toTitleCase(token)} changes`,
|
|
601
|
+
commits: commits.length,
|
|
602
|
+
examples: commits.slice(0, 2).map((commit) => commit.subject),
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return commits.slice(0, limit).map((commit) => ({
|
|
607
|
+
label: commit.subject,
|
|
608
|
+
commits: 1,
|
|
609
|
+
examples: [commit.subject],
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function parseNumstatNumber(value) {
|
|
614
|
+
if (value === "-" || value == null) {
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const parsed = Number.parseInt(value, 10);
|
|
619
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function scoreTraceMatch(filePath, query) {
|
|
623
|
+
const normalized = filePath.toLowerCase();
|
|
624
|
+
const basename = path.basename(normalized);
|
|
625
|
+
const stem = basename.includes(".") ? basename.slice(0, basename.indexOf(".")) : basename;
|
|
626
|
+
|
|
627
|
+
if (!query) {
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (normalized === query) {
|
|
632
|
+
return 1000;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (normalized.endsWith(`/${query}`)) {
|
|
636
|
+
return 800;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (stem === query) {
|
|
640
|
+
return 760;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (basename.startsWith(`${query}.`) || basename.startsWith(`${query}-`) || basename.startsWith(`${query}_`)) {
|
|
644
|
+
return 700;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (normalized.includes(`/${query}/`)) {
|
|
648
|
+
return 660;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (normalized.includes(query)) {
|
|
652
|
+
return 400 - Math.max(normalized.length - query.length, 0);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (basename.includes(query)) {
|
|
656
|
+
return 300 - Math.max(basename.length - query.length, 0);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function choosePrimaryTraceSignal(signalScores) {
|
|
663
|
+
if ((signalScores.definition ?? 0) > 0) {
|
|
664
|
+
return "definition";
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if ((signalScores.symbol ?? 0) > 0) {
|
|
668
|
+
return "symbol";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if ((signalScores.content ?? 0) > 0) {
|
|
672
|
+
return "content";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return "path";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function dedupeMatchedLines(lines) {
|
|
679
|
+
const seen = new Set();
|
|
680
|
+
const deduped = [];
|
|
681
|
+
|
|
682
|
+
for (const line of lines) {
|
|
683
|
+
const key = `${line.filePath}:${line.lineNumber}:${line.preview}`;
|
|
684
|
+
if (seen.has(key)) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
seen.add(key);
|
|
689
|
+
deduped.push(line);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return deduped;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isLikelySymbolQuery(query) {
|
|
696
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(query);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function buildDefinitionRegex(query) {
|
|
700
|
+
const escaped = escapeRegex(query);
|
|
701
|
+
return `(^|[^[:alnum:]_])(export[[:space:]]+)?(async[[:space:]]+)?(function|class|interface|type|enum|const|let|var)[[:space:]]+${escaped}([^[:alnum:]_]|$)`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function escapeRegex(value) {
|
|
705
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function mergeTraceMatches(matches) {
|
|
709
|
+
const merged = new Map();
|
|
710
|
+
|
|
711
|
+
for (const match of matches) {
|
|
712
|
+
const current = merged.get(match.filePath) ?? {
|
|
713
|
+
filePath: match.filePath,
|
|
714
|
+
score: 0,
|
|
715
|
+
matchType: match.matchType,
|
|
716
|
+
matchSignals: [],
|
|
717
|
+
signalScores: {
|
|
718
|
+
path: 0,
|
|
719
|
+
content: 0,
|
|
720
|
+
symbol: 0,
|
|
721
|
+
definition: 0,
|
|
722
|
+
},
|
|
723
|
+
matchedLines: [],
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
current.score = Math.max(current.score, match.score);
|
|
727
|
+
current.signalScores[match.matchType] = Math.max(current.signalScores[match.matchType] ?? 0, match.score);
|
|
728
|
+
current.matchSignals = Array.from(new Set([...current.matchSignals, match.matchType]));
|
|
729
|
+
current.matchType = choosePrimaryTraceSignal(current.signalScores);
|
|
730
|
+
current.matchedLines = dedupeMatchedLines([...current.matchedLines, ...(match.matchedLines ?? [])]).slice(0, 3);
|
|
731
|
+
merged.set(match.filePath, current);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return Array.from(merged.values());
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function buildTraceScoreBreakdown({ signalScores, hotspotScore, evolution, ownership }) {
|
|
738
|
+
const pathScore = signalScores.path ?? 0;
|
|
739
|
+
const contentScore = signalScores.content ?? 0;
|
|
740
|
+
const symbolScore = signalScores.symbol ?? 0;
|
|
741
|
+
const definitionScore = signalScores.definition ?? 0;
|
|
742
|
+
const hotspotBonus = Math.min(hotspotScore, 120);
|
|
743
|
+
const historyBonus = Math.min(evolution.commitCount * 12, 60);
|
|
744
|
+
const ownershipBonus = Math.min((ownership.owners[0]?.commits ?? 0) * 8, 32);
|
|
745
|
+
const blameBonus = ownership.blameOwners.length > 0 ? Math.min(ownership.blameOwners[0].lines, 25) : 0;
|
|
746
|
+
const renameBonus = evolution.renameHistory.length > 0 ? 12 : 0;
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
path: pathScore,
|
|
750
|
+
content: contentScore,
|
|
751
|
+
symbol: symbolScore,
|
|
752
|
+
definition: definitionScore,
|
|
753
|
+
hotspot: hotspotBonus,
|
|
754
|
+
history: historyBonus,
|
|
755
|
+
ownership: ownershipBonus,
|
|
756
|
+
blame: blameBonus,
|
|
757
|
+
rename: renameBonus,
|
|
758
|
+
total: pathScore + contentScore + symbolScore + definitionScore + hotspotBonus + historyBonus + ownershipBonus + blameBonus + renameBonus,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function collectTraceSignalMatches({ query, trackedFiles, exec }) {
|
|
763
|
+
const normalizedQuery = query.toLowerCase();
|
|
764
|
+
const pathMatches = trackedFiles
|
|
765
|
+
.map((filePath) => ({
|
|
766
|
+
filePath,
|
|
767
|
+
score: scoreTraceMatch(filePath, normalizedQuery),
|
|
768
|
+
matchType: "path",
|
|
769
|
+
matchedLines: [],
|
|
770
|
+
}))
|
|
771
|
+
.filter((entry) => entry.score > 0);
|
|
772
|
+
|
|
773
|
+
const contentMatches = parseGrepResults(
|
|
774
|
+
exec(["grep", "-n", "-I", "--full-name", "-e", query, "--", ...trackedFiles], { allowFailure: true })
|
|
775
|
+
).map((entry) => ({
|
|
776
|
+
filePath: entry.filePath,
|
|
777
|
+
score: 500 + Math.max(60 - entry.lineNumber, 0),
|
|
778
|
+
matchType: "content",
|
|
779
|
+
matchedLines: [entry],
|
|
780
|
+
}));
|
|
781
|
+
|
|
782
|
+
const symbolMatches = isLikelySymbolQuery(query)
|
|
783
|
+
? parseGrepResults(
|
|
784
|
+
exec(["grep", "-n", "-I", "--full-name", "-w", "-e", query, "--", ...trackedFiles], { allowFailure: true })
|
|
785
|
+
).map((entry) => ({
|
|
786
|
+
filePath: entry.filePath,
|
|
787
|
+
score: 700 + Math.max(40 - entry.lineNumber, 0),
|
|
788
|
+
matchType: "symbol",
|
|
789
|
+
matchedLines: [entry],
|
|
790
|
+
}))
|
|
791
|
+
: [];
|
|
792
|
+
|
|
793
|
+
const definitionMatches = isLikelySymbolQuery(query)
|
|
794
|
+
? parseGrepResults(
|
|
795
|
+
exec(["grep", "-n", "-I", "--full-name", "-E", "-e", buildDefinitionRegex(query), "--", ...trackedFiles], { allowFailure: true })
|
|
796
|
+
).map((entry) => ({
|
|
797
|
+
filePath: entry.filePath,
|
|
798
|
+
score: 920 + Math.max(30 - entry.lineNumber, 0),
|
|
799
|
+
matchType: "definition",
|
|
800
|
+
matchedLines: [entry],
|
|
801
|
+
}))
|
|
802
|
+
: [];
|
|
803
|
+
|
|
804
|
+
return pathMatches.concat(contentMatches, symbolMatches, definitionMatches);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function collectRenameHistory({ targetPath, follow, range, exec }) {
|
|
808
|
+
const args = ["log", "--name-status", "--format=commit\t%H", "--find-renames"];
|
|
809
|
+
if (follow) {
|
|
810
|
+
args.push("--follow");
|
|
811
|
+
}
|
|
812
|
+
if (range) {
|
|
813
|
+
args.push(range);
|
|
814
|
+
}
|
|
815
|
+
args.push("--", targetPath);
|
|
816
|
+
|
|
817
|
+
return parseNameStatusLog(exec(args, { allowFailure: true }))
|
|
818
|
+
.filter((entry) => entry.status.startsWith("R") && entry.toPath)
|
|
819
|
+
.map((entry) => ({
|
|
820
|
+
commit: entry.commit,
|
|
821
|
+
fromPath: entry.fromPath,
|
|
822
|
+
toPath: entry.toPath,
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function listScopedTrackedFiles(targetPath, exec) {
|
|
827
|
+
return exec(["ls-files", "--", targetPath], { allowFailure: true })
|
|
828
|
+
.split(/\r?\n/)
|
|
829
|
+
.map((line) => line.trim())
|
|
830
|
+
.filter(Boolean);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function resolvePathKind(cwd, targetPath, scopedTrackedFiles) {
|
|
834
|
+
try {
|
|
835
|
+
const absolute = path.resolve(cwd, targetPath);
|
|
836
|
+
if (fs.existsSync(absolute)) {
|
|
837
|
+
return fs.statSync(absolute).isDirectory() ? "directory" : "file";
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
return inferPathKind(targetPath, scopedTrackedFiles);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return inferPathKind(targetPath, scopedTrackedFiles);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function inferPathKind(targetPath, scopedTrackedFiles) {
|
|
847
|
+
const normalizedTarget = normalizeGitPath(targetPath);
|
|
848
|
+
const prefix = normalizedTarget ? `${normalizedTarget}/` : "";
|
|
849
|
+
|
|
850
|
+
if (scopedTrackedFiles.some((filePath) => normalizeGitPath(filePath).startsWith(prefix))) {
|
|
851
|
+
return "directory";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (scopedTrackedFiles.length === 1 && normalizeGitPath(scopedTrackedFiles[0]) === normalizedTarget) {
|
|
855
|
+
return "file";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return path.basename(targetPath).includes(".") ? "file" : "directory";
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function summarizeDirectoryStats(fileStats, targetPath) {
|
|
862
|
+
const normalizedTarget = normalizeGitPath(targetPath);
|
|
863
|
+
const directoryBuckets = new Map();
|
|
864
|
+
|
|
865
|
+
for (const file of fileStats) {
|
|
866
|
+
const directoryPath = resolveDirectoryBucket(normalizedTarget, file.filePath);
|
|
867
|
+
if (!directoryPath) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const current = directoryBuckets.get(directoryPath) ?? {
|
|
872
|
+
directoryPath,
|
|
873
|
+
fileCount: 0,
|
|
874
|
+
touchedCommits: 0,
|
|
875
|
+
additions: 0,
|
|
876
|
+
deletions: 0,
|
|
877
|
+
score: 0,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
current.fileCount += 1;
|
|
881
|
+
current.touchedCommits += file.touchedCommits;
|
|
882
|
+
current.additions += file.additions;
|
|
883
|
+
current.deletions += file.deletions;
|
|
884
|
+
current.score += file.score;
|
|
885
|
+
directoryBuckets.set(directoryPath, current);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return Array.from(directoryBuckets.values())
|
|
889
|
+
.sort((left, right) => right.score - left.score || right.touchedCommits - left.touchedCommits || left.directoryPath.localeCompare(right.directoryPath));
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function resolveDirectoryBucket(normalizedTarget, filePath) {
|
|
893
|
+
const normalizedFile = normalizeGitPath(filePath);
|
|
894
|
+
const relative = normalizedTarget && normalizedFile.startsWith(`${normalizedTarget}/`)
|
|
895
|
+
? normalizedFile.slice(normalizedTarget.length + 1)
|
|
896
|
+
: normalizedFile;
|
|
897
|
+
|
|
898
|
+
if (!relative.includes("/")) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const [firstSegment] = relative.split("/");
|
|
903
|
+
return normalizedTarget ? `${normalizedTarget}/${firstSegment}` : firstSegment;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function normalizeGitPath(value) {
|
|
907
|
+
return value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function buildReadingOrder(params) {
|
|
911
|
+
const entries = [];
|
|
912
|
+
|
|
913
|
+
entries.push({
|
|
914
|
+
path: params.targetPath,
|
|
915
|
+
reason: params.pathKind === "directory"
|
|
916
|
+
? `Start with the subsystem root. ${params.trackedFileCount} tracked files and ${params.commitCount} recent commits were found here.`
|
|
917
|
+
: "Start with the primary implementation file for this area.",
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
for (const directory of params.directoryStats) {
|
|
921
|
+
entries.push({
|
|
922
|
+
path: directory.directoryPath,
|
|
923
|
+
reason: `Highest churn subdirectory: ${directory.fileCount} files touched across ${directory.touchedCommits} commits.`,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
for (const file of params.fileOwners) {
|
|
928
|
+
entries.push({
|
|
929
|
+
path: file.filePath,
|
|
930
|
+
reason: `Frequently changed file. Primary owner ${file.primaryOwner} drove ${file.ownerCommits} commits here.`,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
for (const file of params.topFiles) {
|
|
935
|
+
entries.push({
|
|
936
|
+
path: file.filePath,
|
|
937
|
+
reason: `High-activity file with ${file.touchedCommits} commits and score ${file.score}.`,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
for (const rename of params.renameHistory) {
|
|
942
|
+
entries.push({
|
|
943
|
+
path: rename.toPath,
|
|
944
|
+
reason: `Recently renamed from ${rename.fromPath}; review it to understand how the area moved.`,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return dedupeReadingOrder(entries).slice(0, params.limit);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function dedupeReadingOrder(entries) {
|
|
952
|
+
const seen = new Set();
|
|
953
|
+
const deduped = [];
|
|
954
|
+
|
|
955
|
+
for (const entry of entries) {
|
|
956
|
+
if (!entry.path || seen.has(entry.path)) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
seen.add(entry.path);
|
|
961
|
+
deduped.push(entry);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return deduped;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function buildStartHereSummary(params) {
|
|
968
|
+
const scope = params.pathKind === "directory"
|
|
969
|
+
? `${params.trackedFileCount} tracked files`
|
|
970
|
+
: "the primary implementation file";
|
|
971
|
+
const ownerSummary = params.topOwner
|
|
972
|
+
? `${params.topOwner.author} is the most frequent recent contributor`
|
|
973
|
+
: "ownership is spread across multiple contributors";
|
|
974
|
+
const fileSummary = params.topFile
|
|
975
|
+
? `${params.topFile.filePath} is the highest-churn entry point`
|
|
976
|
+
: params.targetPath;
|
|
977
|
+
|
|
978
|
+
return `${params.targetPath} spans ${scope}, with ${params.commitCount} recent commits from ${params.authorCount} authors. ${ownerSummary}, and ${fileSummary} is the best first stop.`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function buildWhyChangedSummary(params) {
|
|
982
|
+
const parts = [];
|
|
983
|
+
|
|
984
|
+
if (params.commitThemes.length > 0) {
|
|
985
|
+
parts.push(`Recent work clusters around ${params.commitThemes.slice(0, 2).map((theme) => theme.label.toLowerCase()).join(" and ")}.`);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (params.renameHistory.length > 0) {
|
|
989
|
+
const latestRename = params.renameHistory[0];
|
|
990
|
+
parts.push(`The area was recently moved from ${latestRename.fromPath} to ${latestRename.toPath}.`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (params.topOwner) {
|
|
994
|
+
parts.push(`${params.topOwner.author} drove the highest share of recent changes.`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (params.pathKind === "directory" && params.topDirectory) {
|
|
998
|
+
parts.push(`Churn is concentrated in ${params.topDirectory.directoryPath}.`);
|
|
999
|
+
} else if (params.topFile) {
|
|
1000
|
+
parts.push(`${params.topFile.filePath} is the highest-churn file in this scope.`);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return parts.join(" ");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function extractThemeTokens(subject) {
|
|
1007
|
+
return subject
|
|
1008
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
1009
|
+
.toLowerCase()
|
|
1010
|
+
.split(/[^a-z0-9]+/)
|
|
1011
|
+
.filter((token) => token.length > 2 && !STOP_WORDS.has(token));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function toTitleCase(value) {
|
|
1015
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const STOP_WORDS = new Set([
|
|
1019
|
+
"add",
|
|
1020
|
+
"adds",
|
|
1021
|
+
"added",
|
|
1022
|
+
"and",
|
|
1023
|
+
"area",
|
|
1024
|
+
"build",
|
|
1025
|
+
"change",
|
|
1026
|
+
"changes",
|
|
1027
|
+
"cleanup",
|
|
1028
|
+
"create",
|
|
1029
|
+
"fix",
|
|
1030
|
+
"flow",
|
|
1031
|
+
"initial",
|
|
1032
|
+
"into",
|
|
1033
|
+
"move",
|
|
1034
|
+
"moves",
|
|
1035
|
+
"moving",
|
|
1036
|
+
"refactor",
|
|
1037
|
+
"refine",
|
|
1038
|
+
"rename",
|
|
1039
|
+
"support",
|
|
1040
|
+
"update",
|
|
1041
|
+
"wire",
|
|
1042
|
+
]);
|
|
1043
|
+
|
|
1044
|
+
function shouldFollowPathHistory(cwd, targetPath) {
|
|
1045
|
+
try {
|
|
1046
|
+
const absolute = path.resolve(cwd, targetPath);
|
|
1047
|
+
if (!fs.existsSync(absolute)) {
|
|
1048
|
+
return !targetPath.endsWith(path.sep) && path.basename(targetPath).includes(".");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return fs.statSync(absolute).isFile();
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function shouldReadBlamePath(cwd, targetPath) {
|
|
1058
|
+
try {
|
|
1059
|
+
const absolute = path.resolve(cwd, targetPath);
|
|
1060
|
+
return fs.existsSync(absolute) && fs.statSync(absolute).isFile();
|
|
1061
|
+
} catch {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
}
|