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.
- package/README.md +2 -2
- package/dist/index.js +1794 -463
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/templates/report-scripts/00-advanced-derived.html +462 -0
- package/templates/report-scripts/00-filter-state.html +374 -0
- package/templates/report-scripts/00-report-controls.html +272 -0
- package/templates/report-scripts/01-core.html +255 -0
- package/templates/report-scripts/02-commit-details.html +275 -0
- package/templates/report-scripts/03-basic-charts.html +378 -0
- package/templates/report-scripts/04-trend-charts.html +309 -0
- package/templates/report-scripts/05-tables-team-stability.html +372 -0
- package/templates/report-scripts/06-pressure-churn.html +339 -0
- package/templates/report-scripts/07-collab-debt-ai.html +534 -0
- package/templates/report-scripts/08-engineering.html +200 -0
- package/templates/report-scripts/09-extensions.html +313 -0
- package/templates/report-scripts/10-runtime.html +54 -0
- package/templates/report-sections/01-overview.html +342 -0
- package/templates/report-sections/02-advanced.html +406 -0
- package/templates/report.html +40 -1998
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commit-report",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Git 提交统计工具,生成可视化 HTML 报告",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsup",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test:build": "tsup src/analyzer/stats-calculator.ts src/analyzer/git-log-parser.ts src/analyzer/engineering-metrics.ts --format esm --target node18 --outDir .test-dist/analyzer --splitting false --sourcemap false --no-dts",
|
|
17
|
+
"test": "pnpm typecheck && pnpm test:build && node --test tests/*.test.mjs",
|
|
15
18
|
"dev": "tsup --watch",
|
|
16
19
|
"start": "node dist/cli/index.js"
|
|
17
20
|
},
|
|
@@ -41,8 +44,7 @@
|
|
|
41
44
|
"commander": "^14.0.3",
|
|
42
45
|
"ignore": "^7.0.5",
|
|
43
46
|
"open": "^11.0.0",
|
|
44
|
-
"ora": "^9.3.0"
|
|
45
|
-
"simple-git": "^3.30.0"
|
|
47
|
+
"ora": "^9.3.0"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^25.2.2",
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
function deriveTeamHealth(commits) {
|
|
2
|
+
if (commits.length === 0) return undefined;
|
|
3
|
+
|
|
4
|
+
const fileAuthors = new Map();
|
|
5
|
+
commits.forEach(commit => getCommitFiles(commit).forEach(file => {
|
|
6
|
+
if (!fileAuthors.has(file.path)) fileAuthors.set(file.path, new Map());
|
|
7
|
+
const authors = fileAuthors.get(file.path);
|
|
8
|
+
const key = String(commit.email || commit.author || 'unknown').toLowerCase();
|
|
9
|
+
const current = authors.get(key) || { name: commit.author || 'Unknown', email: commit.email || '', commits: 0 };
|
|
10
|
+
current.commits++;
|
|
11
|
+
authors.set(key, current);
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const authorScores = new Map();
|
|
15
|
+
fileAuthors.forEach((authors, path) => {
|
|
16
|
+
const ranked = [...authors.values()].sort((a, b) => b.commits - a.commits);
|
|
17
|
+
const total = ranked.reduce((sum, item) => sum + item.commits, 0) || 1;
|
|
18
|
+
const dominant = ranked[0];
|
|
19
|
+
const scoreItem = authorScores.get(dominant.email.toLowerCase()) || {
|
|
20
|
+
name: dominant.name,
|
|
21
|
+
email: dominant.email,
|
|
22
|
+
uniqueFiles: [],
|
|
23
|
+
dominantFiles: [],
|
|
24
|
+
knowledgeScore: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (ranked.length === 1) scoreItem.uniqueFiles.push(path);
|
|
28
|
+
if (dominant.commits / total >= 0.6) scoreItem.dominantFiles.push(path);
|
|
29
|
+
scoreItem.knowledgeScore += ranked.length === 1 ? 2 : dominant.commits / total;
|
|
30
|
+
authorScores.set(dominant.email.toLowerCase(), scoreItem);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const criticalAuthors = [...authorScores.values()]
|
|
34
|
+
.sort((a, b) => b.knowledgeScore - a.knowledgeScore)
|
|
35
|
+
.slice(0, 10);
|
|
36
|
+
const totalScore = criticalAuthors.reduce((sum, item) => sum + item.knowledgeScore, 0) || 1;
|
|
37
|
+
let covered = 0;
|
|
38
|
+
let busFactor = 0;
|
|
39
|
+
for (const author of criticalAuthors) {
|
|
40
|
+
covered += author.knowledgeScore;
|
|
41
|
+
busFactor++;
|
|
42
|
+
if (covered / totalScore >= 0.5) break;
|
|
43
|
+
}
|
|
44
|
+
const topShare = criticalAuthors[0] ? criticalAuthors[0].knowledgeScore / totalScore : 0;
|
|
45
|
+
const riskLevel = busFactor <= 1 ? 'high' : busFactor === 2 ? 'medium' : 'low';
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
busFactor,
|
|
49
|
+
criticalAuthors,
|
|
50
|
+
knowledgeDistribution: Math.max(0, Math.min(1, 1 - topShare)),
|
|
51
|
+
riskLevel,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deriveStability(commits) {
|
|
56
|
+
if (commits.length === 0) return undefined;
|
|
57
|
+
|
|
58
|
+
const files = new Map();
|
|
59
|
+
const directories = new Map();
|
|
60
|
+
let revertCommits = 0;
|
|
61
|
+
let fixCommits = 0;
|
|
62
|
+
|
|
63
|
+
commits.forEach(commit => {
|
|
64
|
+
const message = commit.message || '';
|
|
65
|
+
if (/revert/i.test(message)) revertCommits++;
|
|
66
|
+
if (/^(fix|bugfix|hotfix)(\(.+\))?:|fix|bug/i.test(message)) fixCommits++;
|
|
67
|
+
|
|
68
|
+
getCommitFiles(commit).forEach(file => {
|
|
69
|
+
const current = files.get(file.path) || { path: file.path, added: 0, deleted: 0, modifyCount: 0 };
|
|
70
|
+
current.added += file.added || 0;
|
|
71
|
+
current.deleted += file.deleted || 0;
|
|
72
|
+
current.modifyCount++;
|
|
73
|
+
files.set(file.path, current);
|
|
74
|
+
|
|
75
|
+
const dir = getPathDirectory(file.path);
|
|
76
|
+
const currentDir = directories.get(dir) || { path: dir, added: 0, deleted: 0, totalChanges: 0, files: new Set() };
|
|
77
|
+
currentDir.added += file.added || 0;
|
|
78
|
+
currentDir.deleted += file.deleted || 0;
|
|
79
|
+
currentDir.totalChanges += (file.added || 0) + (file.deleted || 0);
|
|
80
|
+
currentDir.files.add(file.path);
|
|
81
|
+
directories.set(dir, currentDir);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const fileChurnRate = [...files.values()].map(file => {
|
|
86
|
+
const churnRate = file.added > 0 ? file.deleted / file.added : file.deleted > 0 ? 1 : 0;
|
|
87
|
+
return {
|
|
88
|
+
...file,
|
|
89
|
+
churnRate,
|
|
90
|
+
isUnstable: churnRate > 0.5 && file.modifyCount >= 3,
|
|
91
|
+
};
|
|
92
|
+
}).sort((a, b) => (b.churnRate * b.modifyCount) - (a.churnRate * a.modifyCount));
|
|
93
|
+
|
|
94
|
+
const directoryChurnRate = [...directories.values()].map(dir => ({
|
|
95
|
+
path: dir.path,
|
|
96
|
+
churnRate: dir.added > 0 ? dir.deleted / dir.added : dir.deleted > 0 ? 1 : 0,
|
|
97
|
+
totalChanges: dir.totalChanges,
|
|
98
|
+
fileCount: dir.files.size,
|
|
99
|
+
})).sort((a, b) => b.churnRate - a.churnRate);
|
|
100
|
+
|
|
101
|
+
const revertRate = revertCommits / commits.length;
|
|
102
|
+
const fixCommitRate = fixCommits / commits.length;
|
|
103
|
+
const unstableRate = fileChurnRate.length
|
|
104
|
+
? fileChurnRate.filter(file => file.isUnstable).length / fileChurnRate.length
|
|
105
|
+
: 0;
|
|
106
|
+
const stabilityScore = Math.max(0, Math.round(100 - revertRate * 40 - fixCommitRate * 20 - unstableRate * 40));
|
|
107
|
+
|
|
108
|
+
return { fileChurnRate, directoryChurnRate, revertRate, fixCommitRate, stabilityScore };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function deriveWorkPressure(commits) {
|
|
112
|
+
if (commits.length === 0) return undefined;
|
|
113
|
+
|
|
114
|
+
let lateNightCommits = 0;
|
|
115
|
+
let earlyMorningCommits = 0;
|
|
116
|
+
let weekendCommits = 0;
|
|
117
|
+
commits.forEach(commit => {
|
|
118
|
+
const date = new Date(commit.date);
|
|
119
|
+
const hour = date.getHours();
|
|
120
|
+
const day = date.getDay();
|
|
121
|
+
if (hour >= 23 || hour < 2) lateNightCommits++;
|
|
122
|
+
if (hour >= 2 && hour < 6) earlyMorningCommits++;
|
|
123
|
+
if (day === 0 || day === 6) weekendCommits++;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const offHours = lateNightCommits + earlyMorningCommits + weekendCommits;
|
|
127
|
+
const offHoursRate = offHours / commits.length;
|
|
128
|
+
const pressureScore = Math.min(100, Math.round(offHoursRate * 100));
|
|
129
|
+
return { lateNightCommits, earlyMorningCommits, weekendCommits, holidayCommits: [], pressureScore, offHoursRate };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function deriveContributorChurn(commits) {
|
|
133
|
+
if (commits.length === 0) return undefined;
|
|
134
|
+
|
|
135
|
+
const authors = new Map();
|
|
136
|
+
commits.forEach(commit => {
|
|
137
|
+
const key = String(commit.email || commit.author || 'unknown').toLowerCase();
|
|
138
|
+
const current = authors.get(key) || {
|
|
139
|
+
name: commit.author || 'Unknown',
|
|
140
|
+
email: commit.email || '',
|
|
141
|
+
firstCommitDate: commit.date,
|
|
142
|
+
lastCommitDate: commit.date,
|
|
143
|
+
totalCommits: 0,
|
|
144
|
+
};
|
|
145
|
+
current.totalCommits++;
|
|
146
|
+
if (new Date(commit.date) < new Date(current.firstCommitDate)) current.firstCommitDate = commit.date;
|
|
147
|
+
if (new Date(commit.date) > new Date(current.lastCommitDate)) current.lastCommitDate = commit.date;
|
|
148
|
+
authors.set(key, current);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const lastDate = Math.max(...commits.map(commit => new Date(commit.date).getTime()));
|
|
152
|
+
const active = [];
|
|
153
|
+
const occasional = [];
|
|
154
|
+
const dormant = [];
|
|
155
|
+
const lost = [];
|
|
156
|
+
const newJoiners = [];
|
|
157
|
+
|
|
158
|
+
authors.forEach(author => {
|
|
159
|
+
const daysSinceLastCommit = Math.floor((lastDate - new Date(author.lastCommitDate).getTime()) / 86400000);
|
|
160
|
+
const detail = { ...author, daysSinceLastCommit };
|
|
161
|
+
delete detail.firstCommitDate;
|
|
162
|
+
if (daysSinceLastCommit < 30) active.push(detail);
|
|
163
|
+
else if (daysSinceLastCommit < 90) occasional.push(detail);
|
|
164
|
+
else if (daysSinceLastCommit < 180) dormant.push(detail);
|
|
165
|
+
else lost.push(detail);
|
|
166
|
+
if (Math.floor((lastDate - new Date(author.firstCommitDate).getTime()) / 86400000) < 30) newJoiners.push(detail);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const total = authors.size || 1;
|
|
170
|
+
return {
|
|
171
|
+
active,
|
|
172
|
+
occasional,
|
|
173
|
+
dormant,
|
|
174
|
+
lost,
|
|
175
|
+
newJoiners,
|
|
176
|
+
churnRate: lost.length / total,
|
|
177
|
+
retentionRate: (active.length + occasional.length) / total,
|
|
178
|
+
growthRate: newJoiners.length / total,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getPairKey(a, b) {
|
|
183
|
+
return a < b ? `${a}::${b}` : `${b}::${a}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function deriveAdvancedCollaboration(commits) {
|
|
187
|
+
if (commits.length === 0) return undefined;
|
|
188
|
+
|
|
189
|
+
const fileCounts = new Map();
|
|
190
|
+
const pairCounts = new Map();
|
|
191
|
+
const fileAuthors = new Map();
|
|
192
|
+
|
|
193
|
+
commits.forEach(commit => {
|
|
194
|
+
const files = [...new Set(getCommitFiles(commit).map(file => file.path))].slice(0, 30);
|
|
195
|
+
files.forEach(file => {
|
|
196
|
+
fileCounts.set(file, (fileCounts.get(file) || 0) + 1);
|
|
197
|
+
if (!fileAuthors.has(file)) fileAuthors.set(file, new Set());
|
|
198
|
+
fileAuthors.get(file).add(commit.author || 'Unknown');
|
|
199
|
+
});
|
|
200
|
+
for (let i = 0; i < files.length; i++) {
|
|
201
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
202
|
+
const key = getPairKey(files[i], files[j]);
|
|
203
|
+
pairCounts.set(key, (pairCounts.get(key) || 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const tightCoupling = [...pairCounts.entries()].map(([key, coOccurrence]) => {
|
|
209
|
+
const [file1, file2] = key.split('::');
|
|
210
|
+
const denominator = Math.max(1, Math.min(fileCounts.get(file1) || 1, fileCounts.get(file2) || 1));
|
|
211
|
+
return { file1, file2, coOccurrence, coupling: coOccurrence / denominator };
|
|
212
|
+
}).sort((a, b) => b.coupling - a.coupling).slice(0, 20);
|
|
213
|
+
|
|
214
|
+
const authorPairs = new Map();
|
|
215
|
+
fileAuthors.forEach((authors, file) => {
|
|
216
|
+
const list = [...authors].sort();
|
|
217
|
+
for (let i = 0; i < list.length; i++) {
|
|
218
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
219
|
+
const key = getPairKey(list[i], list[j]);
|
|
220
|
+
const current = authorPairs.get(key) || { author1: list[i], author2: list[j], sharedFiles: [], collaborationCount: 0 };
|
|
221
|
+
current.sharedFiles.push(file);
|
|
222
|
+
current.collaborationCount++;
|
|
223
|
+
authorPairs.set(key, current);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const pairProgramming = [...authorPairs.values()]
|
|
229
|
+
.filter(pair => pair.sharedFiles.length >= 3)
|
|
230
|
+
.sort((a, b) => b.collaborationCount - a.collaborationCount)
|
|
231
|
+
.slice(0, 20);
|
|
232
|
+
const highCoupling = tightCoupling.filter(pair => pair.coupling > 0.7).length;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
tightCoupling,
|
|
236
|
+
frequentPairs: tightCoupling,
|
|
237
|
+
pairProgramming,
|
|
238
|
+
couplingScore: tightCoupling.length ? Math.min(100, Math.round((highCoupling / tightCoupling.length) * 100)) : 0,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function deriveChangeSizeDistribution(commits) {
|
|
243
|
+
if (commits.length === 0) return undefined;
|
|
244
|
+
|
|
245
|
+
const buckets = [
|
|
246
|
+
{ label: 'XS', range: '<10', count: 0, max: 9 },
|
|
247
|
+
{ label: 'S', range: '10-49', count: 0, max: 49 },
|
|
248
|
+
{ label: 'M', range: '50-199', count: 0, max: 199 },
|
|
249
|
+
{ label: 'L', range: '200-999', count: 0, max: 999 },
|
|
250
|
+
{ label: 'XL', range: '>=1000', count: 0, max: Infinity },
|
|
251
|
+
];
|
|
252
|
+
const sizes = commits.map(getCommitLines).sort((a, b) => a - b);
|
|
253
|
+
sizes.forEach(size => {
|
|
254
|
+
buckets.find(bucket => size <= bucket.max).count++;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const largeCommits = [...commits]
|
|
258
|
+
.map(commit => ({
|
|
259
|
+
hash: commit.hash,
|
|
260
|
+
author: commit.author,
|
|
261
|
+
date: commit.date,
|
|
262
|
+
message: commit.message,
|
|
263
|
+
totalLines: getCommitLines(commit),
|
|
264
|
+
filesCount: getCommitFiles(commit).length,
|
|
265
|
+
}))
|
|
266
|
+
.sort((a, b) => b.totalLines - a.totalLines)
|
|
267
|
+
.slice(0, 20);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
buckets: buckets.map(({ max, ...bucket }) => ({
|
|
271
|
+
...bucket,
|
|
272
|
+
percentage: commits.length ? (bucket.count / commits.length) * 100 : 0,
|
|
273
|
+
})),
|
|
274
|
+
avgChangeSize: sizes.reduce((sum, size) => sum + size, 0) / sizes.length,
|
|
275
|
+
medianChangeSize: sizes[Math.floor(sizes.length / 2)] || 0,
|
|
276
|
+
p95ChangeSize: sizes[Math.floor(sizes.length * 0.95)] || 0,
|
|
277
|
+
largeCommits,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deriveDirectoryCoupling(commits) {
|
|
282
|
+
if (commits.length === 0) return undefined;
|
|
283
|
+
|
|
284
|
+
const dirCounts = new Map();
|
|
285
|
+
const pairCounts = new Map();
|
|
286
|
+
commits.forEach(commit => {
|
|
287
|
+
const dirs = [...new Set(getCommitFiles(commit).map(file => getPathDirectory(file.path)))].slice(0, 20);
|
|
288
|
+
dirs.forEach(dir => dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1));
|
|
289
|
+
for (let i = 0; i < dirs.length; i++) {
|
|
290
|
+
for (let j = i + 1; j < dirs.length; j++) {
|
|
291
|
+
const key = getPairKey(dirs[i], dirs[j]);
|
|
292
|
+
pairCounts.set(key, (pairCounts.get(key) || 0) + 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const directories = [...dirCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([dir]) => dir);
|
|
298
|
+
const matrix = [];
|
|
299
|
+
directories.forEach(dir1 => directories.forEach(dir2 => {
|
|
300
|
+
const value = dir1 === dir2 ? dirCounts.get(dir1) || 0 : pairCounts.get(getPairKey(dir1, dir2)) || 0;
|
|
301
|
+
matrix.push({ dir1, dir2, value });
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
const pairs = [...pairCounts.entries()].map(([key, coOccurrence]) => {
|
|
305
|
+
const [dir1, dir2] = key.split('::');
|
|
306
|
+
const denominator = Math.max(1, Math.min(dirCounts.get(dir1) || 1, dirCounts.get(dir2) || 1));
|
|
307
|
+
return { dir1, dir2, coOccurrence, coupling: coOccurrence / denominator };
|
|
308
|
+
}).sort((a, b) => b.coOccurrence - a.coOccurrence).slice(0, 20);
|
|
309
|
+
|
|
310
|
+
return { pairs, matrix, directories };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function deriveEngineering(commits) {
|
|
314
|
+
if (commits.length === 0) return undefined;
|
|
315
|
+
|
|
316
|
+
const messageStats = deriveMessageStats(commits);
|
|
317
|
+
const conventionalCount = Object.entries(messageStats.typeDistribution)
|
|
318
|
+
.filter(([type]) => type !== 'other')
|
|
319
|
+
.reduce((sum, [, count]) => sum + count, 0);
|
|
320
|
+
const scopeCount = commits.filter(commit => /^[a-zA-Z]+\(.+\):/.test(commit.message || '')).length;
|
|
321
|
+
const mergeCommits = commits.filter(commit => /^merge\b/i.test(commit.message || ''));
|
|
322
|
+
const bugFiles = new Map();
|
|
323
|
+
let createdFiles = 0;
|
|
324
|
+
let deletedFiles = 0;
|
|
325
|
+
let modifiedFiles = 0;
|
|
326
|
+
|
|
327
|
+
commits.forEach(commit => {
|
|
328
|
+
const isFix = /^(fix|bugfix|hotfix)(\(.+\))?:|fix|bug/i.test(commit.message || '');
|
|
329
|
+
getCommitFiles(commit).forEach(file => {
|
|
330
|
+
if (file.status === 'added') createdFiles++;
|
|
331
|
+
else if (file.status === 'deleted') deletedFiles++;
|
|
332
|
+
else modifiedFiles++;
|
|
333
|
+
|
|
334
|
+
if (isFix) {
|
|
335
|
+
const current = bugFiles.get(file.path) || { path: file.path, fixCount: 0, lastFixDate: commit.date, fixAuthors: new Set() };
|
|
336
|
+
current.fixCount++;
|
|
337
|
+
if (new Date(commit.date) > new Date(current.lastFixDate)) current.lastFixDate = commit.date;
|
|
338
|
+
current.fixAuthors.add(commit.author || 'Unknown');
|
|
339
|
+
bugFiles.set(file.path, current);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const totalFileOps = createdFiles + deletedFiles + modifiedFiles || 1;
|
|
345
|
+
const conventionalRate = commits.length ? conventionalCount / commits.length : 0;
|
|
346
|
+
const scopeCoverageRate = commits.length ? scopeCount / commits.length : 0;
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
bugFixHotFiles: {
|
|
350
|
+
fixCommitCount: commits.filter(commit => /^(fix|bugfix|hotfix)(\(.+\))?:|fix|bug/i.test(commit.message || '')).length,
|
|
351
|
+
hotFiles: [...bugFiles.values()]
|
|
352
|
+
.map(file => ({ ...file, fixAuthors: [...file.fixAuthors] }))
|
|
353
|
+
.sort((a, b) => b.fixCount - a.fixCount)
|
|
354
|
+
.slice(0, 20),
|
|
355
|
+
},
|
|
356
|
+
reviewQuality: {
|
|
357
|
+
mergeCommitCount: mergeCommits.length,
|
|
358
|
+
reviewedMergeCount: 0,
|
|
359
|
+
reviewParticipationRate: 0,
|
|
360
|
+
reviewers: [],
|
|
361
|
+
},
|
|
362
|
+
commitQuality: {
|
|
363
|
+
score: Math.round((conventionalRate * 0.65 + scopeCoverageRate * 0.35) * 100),
|
|
364
|
+
conventionalRate,
|
|
365
|
+
scopeCoverageRate,
|
|
366
|
+
averageMessageLength: messageStats.avgMessageLength,
|
|
367
|
+
typeDistribution: messageStats.typeDistribution,
|
|
368
|
+
},
|
|
369
|
+
changeMix: {
|
|
370
|
+
createdFiles,
|
|
371
|
+
deletedFiles,
|
|
372
|
+
modifiedFiles,
|
|
373
|
+
featureRatio: createdFiles / totalFileOps,
|
|
374
|
+
refactorRatio: modifiedFiles / totalFileOps,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function deriveTechDebt(derivedStats) {
|
|
380
|
+
const stability = derivedStats.stability;
|
|
381
|
+
if (!stability) return undefined;
|
|
382
|
+
|
|
383
|
+
const highRiskFiles = stability.fileChurnRate.slice(0, 10).map(file => ({
|
|
384
|
+
path: file.path,
|
|
385
|
+
riskScore: Math.min(100, Math.round(file.churnRate * 60 + file.modifyCount * 4)),
|
|
386
|
+
complexity: Math.min(100, file.modifyCount * 10),
|
|
387
|
+
churnRate: file.churnRate,
|
|
388
|
+
testCoverage: 0,
|
|
389
|
+
knowledgeRisk: 0,
|
|
390
|
+
primaryAuthor: '',
|
|
391
|
+
lastModified: derivedStats.lastCommitDate,
|
|
392
|
+
}));
|
|
393
|
+
const avgRisk = highRiskFiles.length
|
|
394
|
+
? highRiskFiles.reduce((sum, file) => sum + file.riskScore, 0) / highRiskFiles.length
|
|
395
|
+
: 0;
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
radar: [
|
|
399
|
+
{ dimension: '流失率', score: Math.min(100, Math.round((derivedStats.quality.churnRate || 0) * 100)), riskLevel: 'medium', description: '删除/新增比例', affectedFiles: highRiskFiles.length },
|
|
400
|
+
{ dimension: '大提交', score: derivedStats.changeSizeDistribution?.buckets.find(b => b.label === 'XL')?.percentage || 0, riskLevel: 'medium', description: 'XL 提交占比', affectedFiles: 0 },
|
|
401
|
+
{ dimension: '热点文件', score: avgRisk, riskLevel: avgRisk > 60 ? 'high' : avgRisk > 30 ? 'medium' : 'low', description: '频繁修改文件', affectedFiles: highRiskFiles.length },
|
|
402
|
+
],
|
|
403
|
+
highRiskFiles,
|
|
404
|
+
aiDetection: { suspiciousFiles: [], totalSuspicious: 0 },
|
|
405
|
+
duplication: { clusters: [], fileScores: highRiskFiles.map(file => ({ file: file.path, score: file.riskScore })) },
|
|
406
|
+
trends: derivedStats.trends.weeklyTrend.map(point => ({ date: point.week, debt: point.linesDeleted })),
|
|
407
|
+
actionItems: [],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function applyFilteredAIStats(derived, commits) {
|
|
412
|
+
const hashes = new Set(commits.map(commit => commit.hash));
|
|
413
|
+
const totalLines = derived.linesAdded + derived.linesDeleted;
|
|
414
|
+
|
|
415
|
+
if (baseStats.aiMetrics) {
|
|
416
|
+
const highAICommits = (baseStats.aiMetrics.highAICommits || [])
|
|
417
|
+
.filter(commit => hashes.has(commit.hash));
|
|
418
|
+
const totalAILines = highAICommits.reduce((sum, commit) => {
|
|
419
|
+
const estimated = Number(commit.estimatedAILines);
|
|
420
|
+
return sum + (Number.isFinite(estimated) ? estimated : 0);
|
|
421
|
+
}, 0);
|
|
422
|
+
derived.aiMetrics = {
|
|
423
|
+
...baseStats.aiMetrics,
|
|
424
|
+
highAICommits,
|
|
425
|
+
suspiciousCommits: highAICommits.length,
|
|
426
|
+
totalAILines,
|
|
427
|
+
totalLines,
|
|
428
|
+
aiPercentage: totalLines > 0 ? (totalAILines / totalLines) * 100 : 0,
|
|
429
|
+
};
|
|
430
|
+
} else {
|
|
431
|
+
derived.aiMetrics = undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
derived.authorAIStats = (baseStats.authorAIStats || []).filter(item =>
|
|
435
|
+
!reportState.author || String(item.email || '').toLowerCase() === reportState.author
|
|
436
|
+
);
|
|
437
|
+
derived.directoryAIStats = (baseStats.directoryAIStats || []).filter(item =>
|
|
438
|
+
!reportState.directory || item.path === reportState.directory || String(item.path || '').startsWith(reportState.directory + '/')
|
|
439
|
+
);
|
|
440
|
+
derived.aiTrends = isReportFiltered() ? [] : baseStats.aiTrends;
|
|
441
|
+
derived.aiQualityRisk = filterAIQualityRisk();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function filterAIQualityRisk() {
|
|
445
|
+
const data = baseStats.aiQualityRisk;
|
|
446
|
+
if (!data) return undefined;
|
|
447
|
+
|
|
448
|
+
const files = (data.files || []).filter(file =>
|
|
449
|
+
!reportState.directory || getPathDirectory(file.path) === reportState.directory || String(file.path || '').startsWith(reportState.directory + '/')
|
|
450
|
+
);
|
|
451
|
+
const scatter = (data.scatter || []).filter(point =>
|
|
452
|
+
!reportState.directory || getPathDirectory(point.path) === reportState.directory || String(point.path || '').startsWith(reportState.directory + '/')
|
|
453
|
+
);
|
|
454
|
+
const summary = { highAIHighChurn: 0, highAILowChurn: 0, lowAIHighChurn: 0, lowAILowChurn: 0 };
|
|
455
|
+
scatter.forEach(point => {
|
|
456
|
+
if (point.aiScore > 50 && point.churnRate > 0.5) summary.highAIHighChurn++;
|
|
457
|
+
else if (point.aiScore > 50) summary.highAILowChurn++;
|
|
458
|
+
else if (point.churnRate > 0.5) summary.lowAIHighChurn++;
|
|
459
|
+
else summary.lowAILowChurn++;
|
|
460
|
+
});
|
|
461
|
+
return { files, scatter, summary };
|
|
462
|
+
}
|