commit-report 1.0.1 → 1.0.2

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,374 @@
1
+ function getPathDirectory(path) {
2
+ if (!path) return '(根目录)';
3
+ const parts = String(path).split('/').filter(Boolean);
4
+ return parts.length > 1 ? parts[0] : '(根目录)';
5
+ }
6
+
7
+ function getFileExtension(path) {
8
+ const fileName = String(path || '').split('/').pop() || '';
9
+ const dotIndex = fileName.lastIndexOf('.');
10
+ if (dotIndex <= 0 || dotIndex === fileName.length - 1) return '无扩展';
11
+ return fileName.slice(dotIndex).toLowerCase();
12
+ }
13
+
14
+ function getCommitLines(commit) {
15
+ return (commit.linesAdded || 0) + (commit.linesDeleted || 0);
16
+ }
17
+
18
+ function getCommitFiles(commit) {
19
+ return Array.isArray(commit.files) ? commit.files : [];
20
+ }
21
+
22
+ function isReportFiltered() {
23
+ return Boolean(reportState.author || reportState.directory);
24
+ }
25
+
26
+ function commitMatchesReportFilters(commit) {
27
+ return Boolean(normalizeFilteredCommit(commit));
28
+ }
29
+
30
+ function getFilteredSourceCommits() {
31
+ const commits = baseStats.commitDetails || [];
32
+ if (!isReportFiltered()) return commits;
33
+ return commits
34
+ .map(normalizeFilteredCommit)
35
+ .filter(Boolean);
36
+ }
37
+
38
+ function normalizeFilteredCommit(commit) {
39
+ const authorMatches = !reportState.author || String(commit.email || '').toLowerCase() === reportState.author;
40
+ if (!authorMatches) return null;
41
+
42
+ if (!reportState.directory) return commit;
43
+
44
+ const files = getCommitFiles(commit).filter(file => {
45
+ const directory = getPathDirectory(file.path);
46
+ return directory === reportState.directory || String(file.path || '').startsWith(reportState.directory + '/');
47
+ });
48
+ if (files.length === 0) return null;
49
+
50
+ return {
51
+ ...commit,
52
+ files,
53
+ linesAdded: files.reduce((sum, file) => sum + (file.added || 0), 0),
54
+ linesDeleted: files.reduce((sum, file) => sum + (file.deleted || 0), 0),
55
+ };
56
+ }
57
+
58
+ function createTimeAuthorSlot() {
59
+ return { count: 0, authors: {} };
60
+ }
61
+
62
+ function addTimeAuthor(slot, author) {
63
+ slot.count++;
64
+ slot.authors[author] = (slot.authors[author] || 0) + 1;
65
+ }
66
+
67
+ function buildFilteredStats() {
68
+ if (!isReportFiltered()) return baseStats;
69
+
70
+ const commits = getFilteredSourceCommits();
71
+ const emptyDate = new Date().toISOString();
72
+ const sortedAsc = [...commits].sort((a, b) => new Date(a.date) - new Date(b.date));
73
+ const sortedDesc = [...commits].sort((a, b) => new Date(b.date) - new Date(a.date));
74
+ const totalCommits = commits.length;
75
+ const linesAdded = commits.reduce((sum, commit) => sum + (commit.linesAdded || 0), 0);
76
+ const linesDeleted = commits.reduce((sum, commit) => sum + (commit.linesDeleted || 0), 0);
77
+ const filesChanged = commits.reduce((sum, commit) => sum + getCommitFiles(commit).length, 0);
78
+ const firstCommitDate = sortedAsc[0]?.date || baseStats.firstCommitDate || emptyDate;
79
+ const lastCommitDate = sortedDesc[0]?.date || baseStats.lastCommitDate || emptyDate;
80
+
81
+ const derived = {
82
+ ...baseStats,
83
+ totalCommits,
84
+ linesAdded,
85
+ linesDeleted,
86
+ filesChanged,
87
+ firstCommitDate,
88
+ lastCommitDate,
89
+ busiestDay: deriveBusiestDay(commits),
90
+ authors: deriveAuthors(commits),
91
+ fileTypes: deriveFileTypes(commits),
92
+ directories: deriveDirectories(commits),
93
+ hourlyDistribution: deriveHourlyDistribution(commits),
94
+ dailyHeatmap: deriveDailyHeatmap(commits),
95
+ hourlyByAuthor: deriveHourlyByAuthor(commits),
96
+ quality: deriveQuality(commits, linesAdded, linesDeleted, filesChanged),
97
+ timePatterns: deriveTimePatterns(commits),
98
+ trends: deriveTrends(commits),
99
+ collaboration: deriveCollaboration(commits),
100
+ messageStats: deriveMessageStats(commits),
101
+ authorFileTypeContributions: deriveAuthorFileTypeContributions(commits),
102
+ commitDetails: sortedAsc,
103
+ };
104
+
105
+ derived.teamHealth = deriveTeamHealth(commits);
106
+ derived.stability = deriveStability(commits);
107
+ derived.workPressure = deriveWorkPressure(commits);
108
+ derived.contributorChurn = deriveContributorChurn(commits);
109
+ derived.advancedCollaboration = deriveAdvancedCollaboration(commits);
110
+ derived.changeSizeDistribution = deriveChangeSizeDistribution(commits);
111
+ derived.directoryCoupling = deriveDirectoryCoupling(commits);
112
+ derived.engineering = deriveEngineering(commits);
113
+ derived.techDebt = deriveTechDebt(derived);
114
+ applyFilteredAIStats(derived, commits);
115
+
116
+ return derived;
117
+ }
118
+
119
+ function deriveBusiestDay(commits) {
120
+ const daily = new Map();
121
+ commits.forEach(commit => {
122
+ const key = formatDateKeyLocal(commit.date);
123
+ daily.set(key, (daily.get(key) || 0) + 1);
124
+ });
125
+
126
+ const [date, count] = [...daily.entries()].sort((a, b) => b[1] - a[1])[0] || ['', 0];
127
+ return { date, count };
128
+ }
129
+
130
+ function deriveAuthors(commits) {
131
+ const map = new Map();
132
+ commits.forEach(commit => {
133
+ const email = String(commit.email || '').toLowerCase();
134
+ const current = map.get(email) || {
135
+ name: commit.author || 'Unknown',
136
+ email: commit.email || '',
137
+ commits: 0,
138
+ linesAdded: 0,
139
+ linesDeleted: 0,
140
+ lastActiveDate: commit.date,
141
+ };
142
+ current.commits++;
143
+ current.linesAdded += commit.linesAdded || 0;
144
+ current.linesDeleted += commit.linesDeleted || 0;
145
+ if (new Date(commit.date) > new Date(current.lastActiveDate)) current.lastActiveDate = commit.date;
146
+ map.set(email, current);
147
+ });
148
+
149
+ return [...map.values()].sort((a, b) => b.commits - a.commits);
150
+ }
151
+
152
+ function deriveFileTypes(commits) {
153
+ const map = new Map();
154
+ commits.forEach(commit => getCommitFiles(commit).forEach(file => {
155
+ const extension = getFileExtension(file.path);
156
+ const current = map.get(extension) || { extension, added: 0, deleted: 0, fileCount: 0 };
157
+ current.added += file.added || 0;
158
+ current.deleted += file.deleted || 0;
159
+ current.fileCount++;
160
+ map.set(extension, current);
161
+ }));
162
+
163
+ return [...map.values()].sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
164
+ }
165
+
166
+ function deriveDirectories(commits) {
167
+ const map = new Map();
168
+ commits.forEach(commit => {
169
+ const seen = new Set();
170
+ getCommitFiles(commit).forEach(file => {
171
+ const path = getPathDirectory(file.path);
172
+ const current = map.get(path) || { path, commits: 0, linesChanged: 0 };
173
+ current.linesChanged += (file.added || 0) + (file.deleted || 0);
174
+ if (!seen.has(path)) {
175
+ current.commits++;
176
+ seen.add(path);
177
+ }
178
+ map.set(path, current);
179
+ });
180
+ });
181
+
182
+ return [...map.values()].sort((a, b) => b.linesChanged - a.linesChanged);
183
+ }
184
+
185
+ function deriveHourlyDistribution(commits) {
186
+ const hours = new Array(24).fill(0);
187
+ commits.forEach(commit => {
188
+ hours[new Date(commit.date).getHours()]++;
189
+ });
190
+ return hours;
191
+ }
192
+
193
+ function deriveHourlyByAuthor(commits) {
194
+ const hours = Array.from({ length: 24 }, createTimeAuthorSlot);
195
+ commits.forEach(commit => addTimeAuthor(hours[new Date(commit.date).getHours()], commit.author || 'Unknown'));
196
+ return hours;
197
+ }
198
+
199
+ function deriveDailyHeatmap(commits) {
200
+ const daily = {};
201
+ commits.forEach(commit => {
202
+ const key = formatDateKeyLocal(commit.date);
203
+ daily[key] = (daily[key] || 0) + 1;
204
+ });
205
+ return daily;
206
+ }
207
+
208
+ function deriveQuality(commits, linesAdded, linesDeleted, filesChanged) {
209
+ const fileMap = new Map();
210
+ commits.forEach(commit => getCommitFiles(commit).forEach(file => {
211
+ const current = fileMap.get(file.path) || { path: file.path, modifyCount: 0, authors: new Set() };
212
+ current.modifyCount++;
213
+ current.authors.add(commit.author || 'Unknown');
214
+ fileMap.set(file.path, current);
215
+ }));
216
+
217
+ return {
218
+ avgFilesPerCommit: commits.length ? filesChanged / commits.length : 0,
219
+ avgLinesPerCommit: commits.length ? (linesAdded + linesDeleted) / commits.length : 0,
220
+ churnRate: linesAdded > 0 ? linesDeleted / linesAdded : 0,
221
+ hotFiles: [...fileMap.values()]
222
+ .map(file => ({ ...file, authors: [...file.authors] }))
223
+ .sort((a, b) => b.modifyCount - a.modifyCount)
224
+ .slice(0, 10),
225
+ };
226
+ }
227
+
228
+ function deriveTimePatterns(commits) {
229
+ const weekdayDistribution = new Array(7).fill(0);
230
+ const weekdayByAuthor = Array.from({ length: 7 }, createTimeAuthorSlot);
231
+ const dateSet = new Set();
232
+ let weekendCommits = 0;
233
+
234
+ commits.forEach(commit => {
235
+ const date = new Date(commit.date);
236
+ const index = (date.getDay() + 6) % 7;
237
+ weekdayDistribution[index]++;
238
+ addTimeAuthor(weekdayByAuthor[index], commit.author || 'Unknown');
239
+ dateSet.add(formatDateKeyLocal(commit.date));
240
+ if (index >= 5) weekendCommits++;
241
+ });
242
+
243
+ const days = [...dateSet].sort();
244
+ let longestStreak = 0;
245
+ let currentStreak = 0;
246
+ let previousTime = null;
247
+ days.forEach(day => {
248
+ const time = new Date(day).getTime();
249
+ currentStreak = previousTime !== null && time - previousTime === 86400000 ? currentStreak + 1 : 1;
250
+ longestStreak = Math.max(longestStreak, currentStreak);
251
+ previousTime = time;
252
+ });
253
+
254
+ const intervals = [...commits]
255
+ .sort((a, b) => new Date(a.date) - new Date(b.date))
256
+ .map(commit => new Date(commit.date).getTime());
257
+ const avgCommitInterval = intervals.length < 2
258
+ ? 0
259
+ : intervals.slice(1).reduce((sum, time, index) => sum + (time - intervals[index]), 0) / (intervals.length - 1);
260
+
261
+ return {
262
+ weekdayDistribution,
263
+ weekendCommits,
264
+ avgCommitInterval,
265
+ longestStreak,
266
+ currentStreak,
267
+ weekdayByAuthor,
268
+ };
269
+ }
270
+
271
+ function getWeekKey(dateValue) {
272
+ const date = new Date(dateValue);
273
+ const day = (date.getDay() + 6) % 7;
274
+ date.setDate(date.getDate() - day);
275
+ return formatDateKeyLocal(date);
276
+ }
277
+
278
+ function deriveTrends(commits) {
279
+ const weekly = new Map();
280
+ const daily = new Map();
281
+ [...commits].sort((a, b) => new Date(a.date) - new Date(b.date)).forEach(commit => {
282
+ const week = getWeekKey(commit.date);
283
+ const currentWeek = weekly.get(week) || { week, commits: 0, linesAdded: 0, linesDeleted: 0 };
284
+ currentWeek.commits++;
285
+ currentWeek.linesAdded += commit.linesAdded || 0;
286
+ currentWeek.linesDeleted += commit.linesDeleted || 0;
287
+ weekly.set(week, currentWeek);
288
+
289
+ const day = formatDateKeyLocal(commit.date);
290
+ daily.set(day, (daily.get(day) || 0) + (commit.linesAdded || 0) - (commit.linesDeleted || 0));
291
+ });
292
+
293
+ let netLines = 0;
294
+ const cumulativeLines = [...daily.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, delta]) => {
295
+ netLines += delta;
296
+ return { date, netLines };
297
+ });
298
+
299
+ return {
300
+ weeklyTrend: [...weekly.values()].sort((a, b) => a.week.localeCompare(b.week)),
301
+ cumulativeLines,
302
+ };
303
+ }
304
+
305
+ function deriveCollaboration(commits) {
306
+ const fileAuthors = new Map();
307
+ const fileCommits = new Map();
308
+ commits.forEach(commit => getCommitFiles(commit).forEach(file => {
309
+ if (!fileAuthors.has(file.path)) fileAuthors.set(file.path, new Set());
310
+ fileAuthors.get(file.path).add(commit.author || 'Unknown');
311
+ fileCommits.set(file.path, (fileCommits.get(file.path) || 0) + 1);
312
+ }));
313
+
314
+ const soloFiles = [];
315
+ const collaborationHotspots = [];
316
+ fileAuthors.forEach((authors, path) => {
317
+ if (authors.size === 1) {
318
+ soloFiles.push({ path, author: [...authors][0], commits: fileCommits.get(path) || 0 });
319
+ } else {
320
+ collaborationHotspots.push({ path, authorCount: authors.size, totalCommits: fileCommits.get(path) || 0 });
321
+ }
322
+ });
323
+
324
+ return {
325
+ soloFiles: soloFiles.sort((a, b) => b.commits - a.commits).slice(0, 20),
326
+ collaborationHotspots: collaborationHotspots.sort((a, b) => b.authorCount - a.authorCount).slice(0, 20),
327
+ };
328
+ }
329
+
330
+ function deriveMessageStats(commits) {
331
+ const typeDistribution = {};
332
+ let totalLength = 0;
333
+ commits.forEach(commit => {
334
+ const message = commit.message || '';
335
+ const match = message.match(/^([a-zA-Z]+)(\(.+\))?:/);
336
+ const type = match ? match[1].toLowerCase() : 'other';
337
+ typeDistribution[type] = (typeDistribution[type] || 0) + 1;
338
+ totalLength += message.length;
339
+ });
340
+
341
+ return {
342
+ typeDistribution,
343
+ avgMessageLength: commits.length ? totalLength / commits.length : 0,
344
+ };
345
+ }
346
+
347
+ function deriveAuthorFileTypeContributions(commits) {
348
+ const map = new Map();
349
+ commits.forEach(commit => getCommitFiles(commit).forEach(file => {
350
+ const extension = getFileExtension(file.path);
351
+ const key = `${commit.email || ''}::${extension}`;
352
+ const current = map.get(key) || {
353
+ author: commit.author || 'Unknown',
354
+ email: commit.email || '',
355
+ extension,
356
+ linesAdded: 0,
357
+ linesDeleted: 0,
358
+ commits: 0,
359
+ fileCount: 0,
360
+ commitHashes: new Set(),
361
+ };
362
+ current.linesAdded += file.added || 0;
363
+ current.linesDeleted += file.deleted || 0;
364
+ current.fileCount++;
365
+ current.commitHashes.add(commit.hash);
366
+ current.commits = current.commitHashes.size;
367
+ map.set(key, current);
368
+ }));
369
+
370
+ return [...map.values()]
371
+ .map(({ commitHashes, ...item }) => item)
372
+ .sort((a, b) => b.linesAdded - a.linesAdded);
373
+ }
374
+
@@ -0,0 +1,272 @@
1
+ function setupReportControls() {
2
+ populateGlobalFilters();
3
+ applyReportHash();
4
+
5
+ const authorFilter = document.getElementById('global-author-filter');
6
+ const directoryFilter = document.getElementById('global-directory-filter');
7
+ const resetButton = document.getElementById('global-filter-reset');
8
+ const jsonButton = document.getElementById('export-json');
9
+ const csvButton = document.getElementById('export-csv');
10
+
11
+ authorFilter?.addEventListener('change', () => {
12
+ reportState.author = authorFilter.value;
13
+ applyReportFilters();
14
+ });
15
+ directoryFilter?.addEventListener('change', () => {
16
+ reportState.directory = directoryFilter.value;
17
+ applyReportFilters();
18
+ });
19
+ resetButton?.addEventListener('click', () => {
20
+ reportState.author = '';
21
+ reportState.directory = '';
22
+ if (authorFilter) authorFilter.value = '';
23
+ if (directoryFilter) directoryFilter.value = '';
24
+ applyReportFilters();
25
+ });
26
+ jsonButton?.addEventListener('click', exportReportJson);
27
+ csvButton?.addEventListener('click', exportReportCsv);
28
+
29
+ reportState.hashReady = true;
30
+ }
31
+
32
+ function populateGlobalFilters() {
33
+ const authorFilter = document.getElementById('global-author-filter');
34
+ const directoryFilter = document.getElementById('global-directory-filter');
35
+ const commits = baseStats.commitDetails || [];
36
+ if (!authorFilter || !directoryFilter) return;
37
+
38
+ const authors = new Map();
39
+ const directories = new Set();
40
+ commits.forEach(commit => {
41
+ const email = String(commit.email || '').toLowerCase();
42
+ if (email && !authors.has(email)) authors.set(email, `${commit.author} <${commit.email}>`);
43
+ getCommitFiles(commit).forEach(file => directories.add(getPathDirectory(file.path)));
44
+ });
45
+
46
+ authorFilter.innerHTML = '<option value="">全部作者</option>' +
47
+ [...authors.entries()]
48
+ .sort((a, b) => a[1].localeCompare(b[1]))
49
+ .map(([email, label]) => `<option value="${escapeHtml(email)}">${escapeHtml(label)}</option>`)
50
+ .join('');
51
+ directoryFilter.innerHTML = '<option value="">全部目录</option>' +
52
+ [...directories]
53
+ .sort((a, b) => a.localeCompare(b))
54
+ .map(directory => `<option value="${escapeHtml(directory)}">${escapeHtml(directory === '(根目录)' ? '根目录' : directory)}</option>`)
55
+ .join('');
56
+ }
57
+
58
+ function applyReportHash() {
59
+ const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
60
+ const author = params.get('author') || '';
61
+ const directory = params.get('dir') || '';
62
+ const group = params.get('group') || reportState.advancedGroup;
63
+ const tab = params.get('tab') || reportState.advancedTab;
64
+
65
+ reportState.author = author;
66
+ reportState.directory = directory;
67
+ reportState.advancedGroup = group;
68
+ reportState.advancedTab = tab;
69
+
70
+ const authorFilter = document.getElementById('global-author-filter');
71
+ const directoryFilter = document.getElementById('global-directory-filter');
72
+ if (authorFilter) authorFilter.value = author;
73
+ if (directoryFilter) directoryFilter.value = directory;
74
+ stats = buildFilteredStats();
75
+ }
76
+
77
+ function applyReportFilters() {
78
+ stats = buildFilteredStats();
79
+ detailFiltersReady = false;
80
+ detailCurrentPage = 1;
81
+ detailExpandedCommits.clear();
82
+ updateReportHash();
83
+ renderAll();
84
+ }
85
+
86
+ function updateReportHash() {
87
+ if (!reportState.hashReady) return;
88
+
89
+ const params = new URLSearchParams();
90
+ if (reportState.advancedGroup) params.set('group', reportState.advancedGroup);
91
+ if (reportState.advancedTab) params.set('tab', reportState.advancedTab);
92
+ if (reportState.author) params.set('author', reportState.author);
93
+ if (reportState.directory) params.set('dir', reportState.directory);
94
+
95
+ const nextHash = params.toString();
96
+ if (window.location.hash.replace(/^#/, '') !== nextHash) {
97
+ window.history.replaceState(null, '', nextHash ? `#${nextHash}` : window.location.pathname + window.location.search);
98
+ }
99
+ }
100
+
101
+ function renderGlobalFilterMeta() {
102
+ const el = document.getElementById('global-filter-meta');
103
+ if (!el) return;
104
+
105
+ const total = baseStats.commitDetails?.length || 0;
106
+ const current = stats.commitDetails?.length || 0;
107
+ const active = [];
108
+ if (reportState.author) active.push('作者');
109
+ if (reportState.directory) active.push('目录');
110
+ el.textContent = active.length
111
+ ? `${active.join(' + ')}筛选:${formatNumber(current)} / ${formatNumber(total)} 次提交`
112
+ : `全量:${formatNumber(total)} 次提交`;
113
+ }
114
+
115
+ function renderHealthSummary() {
116
+ const gradeEl = document.getElementById('health-summary-grade');
117
+ if (!gradeEl) return;
118
+
119
+ const result = buildHealthSummary();
120
+ const gradeColor = result.score >= 80 ? 'text-emerald-500' :
121
+ result.score >= 60 ? 'text-amber-500' : 'text-rose-500';
122
+ const highlights = result.highlights.length ? result.highlights : ['当前筛选范围内没有明显异常信号'];
123
+ const risks = result.risks.length ? result.risks : ['未发现需要立即处理的高风险项'];
124
+
125
+ gradeEl.className = `text-5xl font-bold ${gradeColor}`;
126
+ gradeEl.textContent = result.grade;
127
+ document.getElementById('health-summary-score').textContent = `${result.score} / 100`;
128
+ document.getElementById('health-summary-label').textContent = result.label;
129
+ document.getElementById('health-summary-highlights').innerHTML = highlights
130
+ .slice(0, 4)
131
+ .map(item => `<li class="flex gap-2"><span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0"></span><span>${escapeHtml(item)}</span></li>`)
132
+ .join('');
133
+ document.getElementById('health-summary-risks').innerHTML = risks
134
+ .slice(0, 4)
135
+ .map(item => `<li class="flex gap-2"><span class="mt-1 h-1.5 w-1.5 rounded-full bg-rose-500 shrink-0"></span><span>${escapeHtml(item)}</span></li>`)
136
+ .join('');
137
+ }
138
+
139
+ function buildHealthSummary() {
140
+ let score = 100;
141
+ const highlights = [];
142
+ const risks = [];
143
+
144
+ if (stats.teamHealth) {
145
+ if (stats.teamHealth.riskLevel === 'high') {
146
+ score -= 18;
147
+ risks.push(`Bus Factor 为 ${stats.teamHealth.busFactor},关键人员依赖偏高`);
148
+ } else if (stats.teamHealth.riskLevel === 'medium') {
149
+ score -= 8;
150
+ risks.push(`Bus Factor 为 ${stats.teamHealth.busFactor},建议分散关键模块知识`);
151
+ } else {
152
+ highlights.push(`Bus Factor 为 ${stats.teamHealth.busFactor},知识分布相对稳健`);
153
+ }
154
+ }
155
+
156
+ if (stats.stability) {
157
+ if (stats.stability.stabilityScore < 60) {
158
+ score -= 18;
159
+ risks.push(`稳定性评分 ${stats.stability.stabilityScore.toFixed(0)},变更反复程度偏高`);
160
+ } else if (stats.stability.stabilityScore >= 80) {
161
+ highlights.push(`稳定性评分 ${stats.stability.stabilityScore.toFixed(0)},代码变更较平稳`);
162
+ }
163
+ }
164
+
165
+ if (stats.workPressure) {
166
+ if (stats.workPressure.pressureScore >= 60) {
167
+ score -= 14;
168
+ risks.push(`非工作时间提交占比 ${(stats.workPressure.offHoursRate * 100).toFixed(1)}%,交付压力偏高`);
169
+ } else if (stats.workPressure.pressureScore < 30) {
170
+ highlights.push('非工作时间提交占比较低,节奏健康');
171
+ }
172
+ }
173
+
174
+ if (stats.contributorChurn) {
175
+ if (stats.contributorChurn.churnRate > 0.3) {
176
+ score -= 12;
177
+ risks.push(`贡献者流失率 ${(stats.contributorChurn.churnRate * 100).toFixed(1)}%,需要关注交接`);
178
+ } else if (stats.contributorChurn.retentionRate >= 0.7) {
179
+ highlights.push(`贡献者留存率 ${(stats.contributorChurn.retentionRate * 100).toFixed(1)}%,团队连续性较好`);
180
+ }
181
+ }
182
+
183
+ if (stats.quality) {
184
+ if (stats.quality.churnRate > 0.5) {
185
+ score -= 10;
186
+ risks.push(`代码流失率 ${(stats.quality.churnRate * 100).toFixed(1)}%,可能存在反复返工`);
187
+ }
188
+ if (stats.quality.avgFilesPerCommit <= 6 && stats.quality.avgLinesPerCommit <= 300) {
189
+ highlights.push('平均提交尺寸较克制,便于 Review');
190
+ }
191
+ }
192
+
193
+ const xlBucket = stats.changeSizeDistribution?.buckets?.find(bucket => bucket.label === 'XL');
194
+ if (xlBucket && xlBucket.count > 0) {
195
+ score -= Math.min(12, xlBucket.count * 3);
196
+ risks.push(`存在 ${xlBucket.count} 个 XL 大提交,建议拆分评审`);
197
+ }
198
+
199
+ const aiRisk = stats.aiQualityRisk?.summary?.highAIHighChurn || 0;
200
+ if (aiRisk > 0) {
201
+ score -= Math.min(12, aiRisk * 3);
202
+ risks.push(`${aiRisk} 个文件处于高 AI + 高 Churn 区间,需要加强人审`);
203
+ }
204
+
205
+ score = Math.max(0, Math.min(100, Math.round(score)));
206
+ const grade = score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 55 ? 'C' : 'D';
207
+ const label = score >= 85 ? '整体健康' : score >= 70 ? '基本稳定' : score >= 55 ? '存在隐患' : '需要治理';
208
+
209
+ return { score, grade, label, highlights, risks };
210
+ }
211
+
212
+ function exportReportJson() {
213
+ downloadTextFile(
214
+ 'commit-report.json',
215
+ 'application/json;charset=utf-8',
216
+ JSON.stringify({ ...DATA, stats }, null, 2)
217
+ );
218
+ }
219
+
220
+ function exportReportCsv() {
221
+ const rows = [
222
+ ['repo', 'hash', 'author', 'email', 'date', 'message', 'files', 'linesAdded', 'linesDeleted'],
223
+ ...(stats.commitDetails || []).map(commit => [
224
+ commit.repoName || '',
225
+ commit.hash || '',
226
+ commit.author || '',
227
+ commit.email || '',
228
+ new Date(commit.date).toISOString(),
229
+ commit.message || '',
230
+ getCommitFiles(commit).map(file => file.path).join(';'),
231
+ commit.linesAdded || 0,
232
+ commit.linesDeleted || 0,
233
+ ]),
234
+ ];
235
+
236
+ downloadTextFile('commit-report.csv', 'text/csv;charset=utf-8', rows.map(row => row.map(csvEscape).join(',')).join('\n'));
237
+ }
238
+
239
+ function csvEscape(value) {
240
+ const text = String(value ?? '');
241
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
242
+ }
243
+
244
+ function downloadTextFile(filename, mimeType, content) {
245
+ const blob = new Blob([content], { type: mimeType });
246
+ const url = URL.createObjectURL(blob);
247
+ const link = document.createElement('a');
248
+ link.href = url;
249
+ link.download = filename;
250
+ document.body.appendChild(link);
251
+ link.click();
252
+ link.remove();
253
+ URL.revokeObjectURL(url);
254
+ }
255
+
256
+ function renderSingleRepoOnlyEmptyState(title, description = '该分析依赖单仓库的文件历史、blame 或跨文件关系。') {
257
+ const repoCount = DATA.repos?.length || 0;
258
+ const hint = repoCount > 1
259
+ ? `当前报告包含 ${repoCount} 个仓库。请重新选择单个仓库生成报告,必要时用 --depth 缩小扫描范围。`
260
+ : '请确认目标目录是单个 Git 仓库,并重新生成报告。';
261
+
262
+ return `
263
+ <div class="text-center py-12 text-slate-500 dark:text-slate-400">
264
+ <p class="text-lg mb-2">${escapeHtml(title)}</p>
265
+ <p class="text-sm mb-3">${escapeHtml(description)}</p>
266
+ <p class="text-xs text-slate-400 dark:text-slate-500">${escapeHtml(hint)}</p>
267
+ </div>
268
+ `;
269
+ }
270
+
271
+ // ============================================================
272
+ // 渲染入口