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/dist/index.js CHANGED
@@ -94,31 +94,34 @@ import ora2 from "ora";
94
94
  import chalk from "chalk";
95
95
 
96
96
  // src/analyzer/git-log-parser.ts
97
- import { execSync as execSync2 } from "child_process";
97
+ import { execFileSync } from "child_process";
98
98
  import { readFile } from "fs/promises";
99
99
  import { join as join2 } from "path";
100
100
  import ig from "ignore";
101
101
  var COMMIT_SEPARATOR = "---COMMITX_SEP---";
102
+ var COMMIT_END = "---COMMITX_END---";
102
103
  var FIELD_SEPARATOR = "|";
103
- var FORMAT = `${COMMIT_SEPARATOR}%H${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%s`;
104
+ var FORMAT = `${COMMIT_SEPARATOR}%H${FIELD_SEPARATOR}%P${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%s%n%B${COMMIT_END}`;
104
105
  async function parseGitLog(repoPath, timeRange, author) {
105
106
  const ignoreFilter = await loadGitignore(repoPath);
106
107
  const args = [
107
- "git",
108
108
  "log",
109
- `--format="${FORMAT}"`,
110
- "--numstat"
109
+ `--format=${FORMAT}`,
110
+ "--raw",
111
+ "--numstat",
112
+ "--find-renames",
113
+ "--find-copies"
111
114
  ];
112
115
  if (timeRange) {
113
- args.push(`--since="${timeRange.from.toISOString()}"`);
114
- args.push(`--until="${timeRange.to.toISOString()}"`);
116
+ args.push(`--since=${timeRange.from.toISOString()}`);
117
+ args.push(`--until=${timeRange.to.toISOString()}`);
115
118
  }
116
119
  if (author) {
117
- args.push(`--author="${author}"`);
120
+ args.push(`--author=${author}`);
118
121
  }
119
122
  let output;
120
123
  try {
121
- output = execSync2(args.join(" "), {
124
+ output = execFileSync("git", args, {
122
125
  cwd: repoPath,
123
126
  encoding: "utf-8",
124
127
  maxBuffer: 100 * 1024 * 1024,
@@ -137,16 +140,23 @@ function parseOutput(output, ignoreFilter) {
137
140
  const commits = [];
138
141
  const blocks = output.split(COMMIT_SEPARATOR).filter((b) => b.trim());
139
142
  for (const block of blocks) {
140
- const lines = block.trim().split("\n");
141
- if (lines.length === 0) continue;
142
- const headerLine = lines[0].replace(/^"|"$/g, "");
143
+ const endIndex = block.indexOf(COMMIT_END);
144
+ const metadataText = endIndex >= 0 ? block.slice(0, endIndex) : block;
145
+ const changesText = endIndex >= 0 ? block.slice(endIndex + COMMIT_END.length) : "";
146
+ const metadataLines = metadataText.trim().split("\n");
147
+ if (metadataLines.length === 0) continue;
148
+ const headerLine = metadataLines[0].replace(/^"|"$/g, "");
143
149
  const parts = headerLine.split(FIELD_SEPARATOR);
144
- if (parts.length < 5) continue;
145
- const [hash, authorName, email, dateStr, ...messageParts] = parts;
150
+ if (parts.length < 6) continue;
151
+ const [hash, parentHashText, authorName, email, dateStr, ...messageParts] = parts;
146
152
  const message = messageParts.join(FIELD_SEPARATOR);
153
+ const body = metadataLines.slice(1).join("\n").trim();
154
+ const parentHashes = parentHashText.trim() ? parentHashText.trim().split(/\s+/) : [];
155
+ const changeLines = changesText.trim().split("\n");
156
+ const statusByPath = parseFileStatuses(changeLines);
147
157
  const files = [];
148
- for (let i = 1; i < lines.length; i++) {
149
- const line = lines[i].trim();
158
+ for (const rawLine of changeLines) {
159
+ const line = rawLine.trim();
150
160
  if (!line) continue;
151
161
  const tabParts = line.split(" ");
152
162
  if (tabParts.length !== 3) continue;
@@ -154,7 +164,13 @@ function parseOutput(output, ignoreFilter) {
154
164
  const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) || 0;
155
165
  const deleted = deletedStr === "-" ? 0 : parseInt(deletedStr, 10) || 0;
156
166
  if (ignoreFilter.ignores(filePath)) continue;
157
- files.push({ added, deleted, path: filePath });
167
+ const status = statusByPath.get(filePath);
168
+ files.push({
169
+ added,
170
+ deleted,
171
+ path: filePath,
172
+ ...status && { status }
173
+ });
158
174
  }
159
175
  commits.push({
160
176
  hash,
@@ -162,11 +178,38 @@ function parseOutput(output, ignoreFilter) {
162
178
  email,
163
179
  date: new Date(dateStr),
164
180
  message,
181
+ body,
182
+ parentHashes,
165
183
  files
166
184
  });
167
185
  }
168
186
  return commits;
169
187
  }
188
+ function parseFileStatuses(lines) {
189
+ const statusByPath = /* @__PURE__ */ new Map();
190
+ for (const line of lines) {
191
+ if (!line.startsWith(":")) continue;
192
+ const parts = line.trim().split(" ");
193
+ if (parts.length < 2) continue;
194
+ const metadata = parts[0].split(/\s+/);
195
+ const statusToken = metadata[4] || "";
196
+ const status = normalizeStatus(statusToken);
197
+ const path = status === "renamed" || status === "copied" ? parts[2] || parts[1] : parts[1];
198
+ if (path) {
199
+ statusByPath.set(path, status);
200
+ }
201
+ }
202
+ return statusByPath;
203
+ }
204
+ function normalizeStatus(statusToken) {
205
+ const type = statusToken[0];
206
+ if (type === "A") return "added";
207
+ if (type === "M") return "modified";
208
+ if (type === "D") return "deleted";
209
+ if (type === "R") return "renamed";
210
+ if (type === "C") return "copied";
211
+ return "unknown";
212
+ }
170
213
  async function loadGitignore(repoPath) {
171
214
  const ignoreInstance = ig();
172
215
  try {
@@ -183,377 +226,524 @@ async function loadGitignore(repoPath) {
183
226
  }
184
227
 
185
228
  // src/analyzer/stats-calculator.ts
186
- import { extname } from "path";
187
- function calculateStats(commits) {
188
- if (commits.length === 0) {
189
- return emptyStats();
190
- }
191
- const sorted = [...commits].sort(
192
- (a, b) => a.date.getTime() - b.date.getTime()
193
- );
194
- let totalLinesAdded = 0;
195
- let totalLinesDeleted = 0;
196
- const allFilePaths = /* @__PURE__ */ new Set();
197
- const authorMap = /* @__PURE__ */ new Map();
198
- const fileTypeMap = /* @__PURE__ */ new Map();
199
- const directoryMap = /* @__PURE__ */ new Map();
200
- const directoryCommitSet = /* @__PURE__ */ new Map();
201
- const hourlyDistribution = new Array(24).fill(0);
202
- const dailyHeatmap = {};
203
- const hourlyByAuthor = /* @__PURE__ */ new Map();
204
- const dailyCounts = /* @__PURE__ */ new Map();
205
- for (const commit of sorted) {
206
- const hour = commit.date.getHours();
207
- hourlyDistribution[hour]++;
208
- if (!hourlyByAuthor.has(hour)) {
209
- hourlyByAuthor.set(hour, /* @__PURE__ */ new Map());
210
- }
211
- const hourAuthors = hourlyByAuthor.get(hour);
212
- hourAuthors.set(commit.author, (hourAuthors.get(commit.author) || 0) + 1);
213
- const dateKey = formatDateKey(commit.date);
214
- dailyHeatmap[dateKey] = (dailyHeatmap[dateKey] || 0) + 1;
215
- dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
216
- const authorKey = commit.email.toLowerCase();
217
- let authorStat = authorMap.get(authorKey);
218
- if (!authorStat) {
219
- authorStat = {
220
- name: commit.author,
221
- email: commit.email,
222
- commits: 0,
223
- linesAdded: 0,
224
- linesDeleted: 0,
225
- lastActiveDate: commit.date
226
- };
227
- authorMap.set(authorKey, authorStat);
229
+ import { extname as extname2 } from "path";
230
+
231
+ // src/analyzer/tech-debt/ai-detector.ts
232
+ import { readFile as readFile2 } from "fs/promises";
233
+ import { join as join3 } from "path";
234
+ import { existsSync } from "fs";
235
+ function calculateAIScore(commit) {
236
+ return evaluateAIScore(commit).score;
237
+ }
238
+ function evaluateAIScore(commit) {
239
+ let score = 0;
240
+ const reasons = [];
241
+ const totalLines = commit.files.reduce((sum, f) => sum + f.added, 0);
242
+ const addedFiles = commit.files.filter((file) => file.status === "added").length;
243
+ if (mentionsAITool(commit.message) || mentionsAITool(commit.body || "")) {
244
+ score += 55;
245
+ reasons.push("AI \u5DE5\u5177\u5173\u952E\u8BCD");
246
+ }
247
+ if (mentionsGeneratedOutput(commit.message) || mentionsGeneratedOutput(commit.body || "")) {
248
+ score += 15;
249
+ reasons.push("\u751F\u6210\u4EA7\u7269\u5173\u952E\u8BCD");
250
+ }
251
+ if (totalLines > 100 && commit.files.length >= 3 && addedFiles / commit.files.length > 0.6) {
252
+ score += 20;
253
+ reasons.push("\u5927\u6279\u91CF\u65B0\u589E\u6587\u4EF6");
254
+ }
255
+ if (totalLines > 80 && commit.files.reduce((sum, f) => sum + f.deleted, 0) <= totalLines * 0.05) {
256
+ score += 15;
257
+ reasons.push("\u4F4E\u5220\u9664\u7387\u5927\u65B0\u589E");
258
+ }
259
+ if (totalLines > 1e3 && commit.files.length > 10 && isGenericMessage(commit.message)) {
260
+ score += 40;
261
+ reasons.push("\u5927\u578B\u901A\u7528\u63D0\u4EA4");
262
+ } else if (totalLines > 500) {
263
+ score += 20;
264
+ reasons.push("\u5927\u578B\u63D0\u4EA4");
265
+ }
266
+ const anomalousFiles = commit.files.filter((f) => hasAnomalousNaming(f.path));
267
+ if (anomalousFiles.length > 0) {
268
+ score += Math.min(30, anomalousFiles.length / commit.files.length * 30);
269
+ reasons.push("\u5F02\u5E38\u547D\u540D");
270
+ }
271
+ return { score: Math.min(100, score), reasons };
272
+ }
273
+ async function detectAICode(commits, repoPath) {
274
+ const suspiciousFiles = [];
275
+ for (const commit of commits) {
276
+ const totalLines = commit.files.reduce((sum, f) => sum + f.added, 0);
277
+ if (totalLines > 1e3 && commit.files.length > 10) {
278
+ if (isGenericMessage(commit.message)) {
279
+ suspiciousFiles.push({
280
+ commit: commit.hash,
281
+ reason: "large-commit-generic-message",
282
+ score: 80,
283
+ description: `\u5927\u578B\u63D0\u4EA4\uFF08${totalLines}\u884C\uFF09\u914D\u5408\u901A\u7528\u63D0\u4EA4\u4FE1\u606F`
284
+ });
285
+ }
228
286
  }
229
- authorStat.commits++;
230
- authorStat.lastActiveDate = commit.date;
231
287
  for (const file of commit.files) {
232
- totalLinesAdded += file.added;
233
- totalLinesDeleted += file.deleted;
234
- allFilePaths.add(file.path);
235
- authorStat.linesAdded += file.added;
236
- authorStat.linesDeleted += file.deleted;
237
- const ext = extname(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
238
- let ftStat = fileTypeMap.get(ext);
239
- if (!ftStat) {
240
- ftStat = { extension: ext, added: 0, deleted: 0, fileCount: 0 };
241
- fileTypeMap.set(ext, ftStat);
242
- }
243
- ftStat.added += file.added;
244
- ftStat.deleted += file.deleted;
245
- const topDir = getTopDirectory(file.path);
246
- let dirStat = directoryMap.get(topDir);
247
- if (!dirStat) {
248
- dirStat = { path: topDir, commits: 0, linesChanged: 0 };
249
- directoryMap.set(topDir, dirStat);
250
- directoryCommitSet.set(topDir, /* @__PURE__ */ new Set());
288
+ if (hasAnomalousNaming(file.path)) {
289
+ suspiciousFiles.push({
290
+ file: file.path,
291
+ reason: "anomalous-naming",
292
+ score: 60,
293
+ description: "\u547D\u540D\u6A21\u5F0F\u5F02\u5E38\uFF08\u5982 function1, temp1\uFF09"
294
+ });
251
295
  }
252
- dirStat.linesChanged += file.added + file.deleted;
253
- directoryCommitSet.get(topDir).add(commit.hash);
254
296
  }
255
297
  }
256
- for (const [dir, commitSet] of directoryCommitSet) {
257
- const dirStat = directoryMap.get(dir);
258
- if (dirStat) {
259
- dirStat.commits = commitSet.size;
298
+ const uniqueFiles = new Set(commits.flatMap((c) => c.files.map((f) => f.path)));
299
+ const filesToCheck = Array.from(uniqueFiles).slice(0, 50);
300
+ for (const filePath of filesToCheck) {
301
+ const fullPath = join3(repoPath, filePath);
302
+ if (!existsSync(fullPath)) continue;
303
+ try {
304
+ const content = await readFile2(fullPath, "utf-8");
305
+ const commentDensity = calculateCommentDensity(content);
306
+ if (commentDensity > 0.3) {
307
+ suspiciousFiles.push({
308
+ file: filePath,
309
+ reason: "excessive-comments",
310
+ score: 70,
311
+ description: `\u6CE8\u91CA\u5BC6\u5EA6\u8FC7\u9AD8\uFF08${(commentDensity * 100).toFixed(1)}%\uFF09`
312
+ });
313
+ }
314
+ } catch {
260
315
  }
261
316
  }
262
- const fileCountByExt = /* @__PURE__ */ new Map();
263
- for (const filePath of allFilePaths) {
264
- const ext = extname(filePath).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
265
- if (!fileCountByExt.has(ext)) {
266
- fileCountByExt.set(ext, /* @__PURE__ */ new Set());
317
+ const uniqueSuspicious = Array.from(
318
+ new Map(suspiciousFiles.map((f) => [f.file || f.commit, f])).values()
319
+ );
320
+ return {
321
+ suspiciousFiles: uniqueSuspicious.sort((a, b) => b.score - a.score).slice(0, 20),
322
+ totalSuspicious: uniqueSuspicious.length
323
+ };
324
+ }
325
+ function isGenericMessage(message) {
326
+ const genericPatterns = [
327
+ /^fix$/i,
328
+ /^update$/i,
329
+ /^refactor$/i,
330
+ /^fix bug$/i,
331
+ /^update code$/i,
332
+ /^bug fix$/i,
333
+ /^minor fix$/i,
334
+ /^wip$/i,
335
+ /^temp$/i,
336
+ /^test$/i
337
+ ];
338
+ const normalized = message.trim().toLowerCase();
339
+ return genericPatterns.some((pattern) => pattern.test(normalized));
340
+ }
341
+ function mentionsAITool(text) {
342
+ const aiPatterns = [
343
+ /\bai\b/i,
344
+ /\bcopilot\b/i,
345
+ /\bclaude\b/i,
346
+ /\bcursor\b/i,
347
+ /\bchatgpt\b/i,
348
+ /\bgpt[-\s]?\d*\b/i
349
+ ];
350
+ return aiPatterns.some((pattern) => pattern.test(text));
351
+ }
352
+ function mentionsGeneratedOutput(text) {
353
+ return /\bgenerated\b|\bcodegen\b|\bauto[-\s]?generated\b/i.test(text);
354
+ }
355
+ function hasAnomalousNaming(path) {
356
+ const anomalousPatterns = [
357
+ /function\d+/i,
358
+ /temp\d+/i,
359
+ /test\d+/i,
360
+ /file\d+/i,
361
+ /component\d+/i,
362
+ /generated/i,
363
+ /auto[-_]?generated/i,
364
+ /untitled/i,
365
+ /copy\d*/i
366
+ ];
367
+ return anomalousPatterns.some((pattern) => pattern.test(path));
368
+ }
369
+ function calculateCommentDensity(content) {
370
+ const lines = content.split("\n");
371
+ let commentLines = 0;
372
+ let codeLines = 0;
373
+ for (const line of lines) {
374
+ const trimmed = line.trim();
375
+ if (!trimmed) continue;
376
+ if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("#")) {
377
+ commentLines++;
378
+ } else {
379
+ codeLines++;
267
380
  }
268
- fileCountByExt.get(ext).add(filePath);
269
381
  }
270
- for (const [ext, files] of fileCountByExt) {
271
- const ftStat = fileTypeMap.get(ext);
272
- if (ftStat) {
273
- ftStat.fileCount = files.size;
274
- }
382
+ const totalLines = commentLines + codeLines;
383
+ return totalLines > 0 ? commentLines / totalLines : 0;
384
+ }
385
+
386
+ // src/analyzer/ai-stats-calculator.ts
387
+ function calculateAIMetrics(commits) {
388
+ if (commits.length === 0) {
389
+ return {
390
+ aiMetrics: { totalAILines: 0, totalLines: 0, aiPercentage: 0, suspiciousCommits: 0, highAICommits: [] },
391
+ authorAIStats: [],
392
+ directoryAIStats: [],
393
+ aiTrends: []
394
+ };
275
395
  }
276
- let busiestDay = { date: "", count: 0 };
277
- for (const [date, count] of dailyCounts) {
278
- if (count > busiestDay.count) {
279
- busiestDay = { date, count };
396
+ const aiCommits = [];
397
+ const authorAIMap = /* @__PURE__ */ new Map();
398
+ const directoryAIMap = /* @__PURE__ */ new Map();
399
+ const weeklyAIMap = /* @__PURE__ */ new Map();
400
+ let totalAILines = 0;
401
+ let totalLines = 0;
402
+ let suspiciousCommits = 0;
403
+ for (const commit of commits) {
404
+ const aiEvaluation = evaluateAIScore(commit);
405
+ const aiScore = aiEvaluation.score;
406
+ const commitLines = commit.files.reduce((sum, f) => sum + f.added, 0);
407
+ const estimatedAILines = Math.round(commitLines * aiScore / 100);
408
+ totalLines += commitLines;
409
+ totalAILines += estimatedAILines;
410
+ if (aiScore > 50) {
411
+ suspiciousCommits++;
412
+ aiCommits.push({
413
+ hash: commit.hash,
414
+ author: commit.author,
415
+ date: commit.date,
416
+ aiScore,
417
+ estimatedAILines,
418
+ linesAdded: commitLines,
419
+ filesCount: commit.files.length,
420
+ message: commit.message,
421
+ reasons: aiEvaluation.reasons
422
+ });
280
423
  }
281
- }
282
- const authors = Array.from(authorMap.values()).sort(
283
- (a, b) => b.commits - a.commits
284
- );
285
- const fileTypes = Array.from(fileTypeMap.values()).sort(
286
- (a, b) => b.added + b.deleted - (a.added + a.deleted)
287
- );
288
- const directories = Array.from(directoryMap.values()).sort((a, b) => b.linesChanged - a.linesChanged).slice(0, 10);
289
- const hourlyByAuthorArray = Array.from({ length: 24 }, (_, hour) => {
290
- const authorMap2 = hourlyByAuthor.get(hour);
291
- const authors2 = {};
292
- if (authorMap2) {
293
- authorMap2.forEach((count, author) => {
294
- authors2[author] = count;
424
+ const authorKey = commit.email.toLowerCase();
425
+ const authorData = authorAIMap.get(authorKey) || { aiLines: 0, totalLines: 0 };
426
+ authorData.aiLines += estimatedAILines;
427
+ authorData.totalLines += commitLines;
428
+ authorAIMap.set(authorKey, authorData);
429
+ for (const file of commit.files) {
430
+ const dir = getTopDirectory(file.path);
431
+ const fileAILines = Math.round(file.added * aiScore / 100);
432
+ const dirData = directoryAIMap.get(dir) || {
433
+ aiLines: 0,
434
+ totalLines: 0,
435
+ commits: /* @__PURE__ */ new Set(),
436
+ lastModified: commit.date
437
+ };
438
+ dirData.aiLines += fileAILines;
439
+ dirData.totalLines += file.added;
440
+ dirData.commits.add(commit.hash);
441
+ if (commit.date > dirData.lastModified) {
442
+ dirData.lastModified = commit.date;
443
+ }
444
+ directoryAIMap.set(dir, dirData);
445
+ }
446
+ const week = getWeekKey(commit.date);
447
+ const weekData = weeklyAIMap.get(week) || { aiLines: 0, totalLines: 0 };
448
+ weekData.aiLines += estimatedAILines;
449
+ weekData.totalLines += commitLines;
450
+ weeklyAIMap.set(week, weekData);
451
+ }
452
+ const aiMetrics = {
453
+ totalAILines,
454
+ totalLines,
455
+ aiPercentage: totalLines > 0 ? totalAILines / totalLines * 100 : 0,
456
+ suspiciousCommits,
457
+ highAICommits: aiCommits.sort((a, b) => b.aiScore - a.aiScore).slice(0, 20)
458
+ };
459
+ const authorAIStats = [];
460
+ const authorMap = /* @__PURE__ */ new Map();
461
+ for (const commit of commits) {
462
+ authorMap.set(commit.email.toLowerCase(), { name: commit.author, email: commit.email });
463
+ }
464
+ for (const [authorKey, data] of authorAIMap) {
465
+ const author = authorMap.get(authorKey);
466
+ if (author) {
467
+ authorAIStats.push({
468
+ author: author.name,
469
+ email: author.email,
470
+ aiLines: data.aiLines,
471
+ totalLines: data.totalLines,
472
+ aiPercentage: data.totalLines > 0 ? data.aiLines / data.totalLines * 100 : 0
295
473
  });
296
474
  }
475
+ }
476
+ const directoryAIStats = [];
477
+ for (const [path, data] of directoryAIMap) {
478
+ const aiPercentage = data.totalLines > 0 ? data.aiLines / data.totalLines * 100 : 0;
479
+ const isHighRisk = data.commits.size > 50 && aiPercentage > 60;
480
+ directoryAIStats.push({
481
+ path,
482
+ displayPath: path,
483
+ commits: data.commits.size,
484
+ aiLines: data.aiLines,
485
+ totalLines: data.totalLines,
486
+ aiPercentage,
487
+ lastModified: data.lastModified,
488
+ isHighRisk
489
+ });
490
+ }
491
+ const aiTrends = Array.from(weeklyAIMap.entries()).map(([week, data]) => ({
492
+ week,
493
+ aiLines: data.aiLines,
494
+ totalLines: data.totalLines,
495
+ aiPercentage: data.totalLines > 0 ? data.aiLines / data.totalLines * 100 : 0
496
+ })).sort((a, b) => a.week.localeCompare(b.week));
497
+ return {
498
+ aiMetrics,
499
+ authorAIStats: authorAIStats.sort((a, b) => b.aiPercentage - a.aiPercentage),
500
+ directoryAIStats: directoryAIStats.sort((a, b) => b.aiPercentage - a.aiPercentage),
501
+ aiTrends
502
+ };
503
+ }
504
+ function getTopDirectory(filePath) {
505
+ const parts = filePath.split("/");
506
+ return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
507
+ }
508
+ function getWeekKey(date) {
509
+ const year = date.getFullYear();
510
+ const startOfYear = new Date(year, 0, 1);
511
+ const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1e3));
512
+ const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
513
+ return `${year}-W${week.toString().padStart(2, "0")}`;
514
+ }
515
+
516
+ // src/analyzer/stats-utils.ts
517
+ function getTopDirectory2(filePath) {
518
+ const parts = filePath.split("/");
519
+ return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
520
+ }
521
+ function formatDateKey(date) {
522
+ const y = date.getFullYear();
523
+ const m = String(date.getMonth() + 1).padStart(2, "0");
524
+ const d = String(date.getDate()).padStart(2, "0");
525
+ return `${y}-${m}-${d}`;
526
+ }
527
+
528
+ // src/analyzer/extended-stats.ts
529
+ var MAX_FILES_PER_COMMIT_FOR_PAIRS = 80;
530
+ var MAX_DIRS_FOR_MATRIX = 12;
531
+ function calculateChangeSizeDistribution(commits) {
532
+ if (commits.length === 0) {
297
533
  return {
298
- count: hourlyDistribution[hour],
299
- authors: authors2
534
+ buckets: emptyBuckets(),
535
+ avgChangeSize: 0,
536
+ medianChangeSize: 0,
537
+ p95ChangeSize: 0,
538
+ largeCommits: []
300
539
  };
301
- });
540
+ }
541
+ const sizes = [];
542
+ const commitInfo = [];
543
+ for (const commit of commits) {
544
+ let total2 = 0;
545
+ for (const f of commit.files) total2 += f.added + f.deleted;
546
+ sizes.push(total2);
547
+ commitInfo.push({
548
+ hash: commit.hash,
549
+ author: commit.author,
550
+ date: commit.date,
551
+ message: commit.message,
552
+ totalLines: total2,
553
+ filesCount: commit.files.length
554
+ });
555
+ }
556
+ const counts = { XS: 0, S: 0, M: 0, L: 0, XL: 0 };
557
+ for (const size of sizes) {
558
+ if (size < 10) counts.XS++;
559
+ else if (size < 50) counts.S++;
560
+ else if (size < 200) counts.M++;
561
+ else if (size < 1e3) counts.L++;
562
+ else counts.XL++;
563
+ }
564
+ const total = sizes.length;
565
+ const buckets = [
566
+ { label: "XS", range: "<10", count: counts.XS, percentage: pct(counts.XS, total) },
567
+ { label: "S", range: "10-49", count: counts.S, percentage: pct(counts.S, total) },
568
+ { label: "M", range: "50-199", count: counts.M, percentage: pct(counts.M, total) },
569
+ { label: "L", range: "200-999", count: counts.L, percentage: pct(counts.L, total) },
570
+ { label: "XL", range: "\u22651000", count: counts.XL, percentage: pct(counts.XL, total) }
571
+ ];
572
+ const sorted = [...sizes].sort((a, b) => a - b);
573
+ const avgChangeSize = sizes.reduce((a, b) => a + b, 0) / sizes.length;
574
+ const medianChangeSize = sorted[Math.floor(sorted.length / 2)] ?? 0;
575
+ const p95ChangeSize = sorted[Math.floor(sorted.length * 0.95)] ?? 0;
576
+ const largeCommits = commitInfo.sort((a, b) => b.totalLines - a.totalLines).slice(0, 20);
302
577
  return {
303
- totalCommits: sorted.length,
304
- linesAdded: totalLinesAdded,
305
- linesDeleted: totalLinesDeleted,
306
- filesChanged: allFilePaths.size,
307
- firstCommitDate: sorted[0].date,
308
- lastCommitDate: sorted[sorted.length - 1].date,
309
- busiestDay,
310
- authors,
311
- fileTypes,
312
- directories,
313
- hourlyDistribution,
314
- dailyHeatmap,
315
- hourlyByAuthor: hourlyByAuthorArray,
316
- quality: calculateQualityMetrics(sorted),
317
- timePatterns: calculateTimePatterns(sorted),
318
- trends: calculateTrends(sorted),
319
- collaboration: calculateCollaboration(sorted),
320
- messageStats: calculateMessageStats(sorted),
321
- authorFileTypeContributions: calculateAuthorFileTypeContributions(sorted)
578
+ buckets,
579
+ avgChangeSize,
580
+ medianChangeSize,
581
+ p95ChangeSize,
582
+ largeCommits
322
583
  };
323
584
  }
324
- function mergeStats(statsList) {
325
- if (statsList.length === 0) return emptyStats();
326
- if (statsList.length === 1) return statsList[0];
327
- const merged = emptyStats();
328
- for (const stats of statsList) {
329
- merged.totalCommits += stats.totalCommits;
330
- merged.linesAdded += stats.linesAdded;
331
- merged.linesDeleted += stats.linesDeleted;
332
- merged.filesChanged += stats.filesChanged;
333
- if (!merged.firstCommitDate || stats.firstCommitDate < merged.firstCommitDate) {
334
- merged.firstCommitDate = stats.firstCommitDate;
335
- }
336
- if (!merged.lastCommitDate || stats.lastCommitDate > merged.lastCommitDate) {
337
- merged.lastCommitDate = stats.lastCommitDate;
338
- }
339
- for (let i = 0; i < 24; i++) {
340
- merged.hourlyDistribution[i] += stats.hourlyDistribution[i];
341
- }
342
- for (const [date, count] of Object.entries(stats.dailyHeatmap)) {
343
- merged.dailyHeatmap[date] = (merged.dailyHeatmap[date] || 0) + count;
585
+ function calculateDirectoryCoupling(commits) {
586
+ if (commits.length === 0) {
587
+ return { pairs: [], matrix: [], directories: [] };
588
+ }
589
+ const dirCommitCount = /* @__PURE__ */ new Map();
590
+ const pairMap = /* @__PURE__ */ new Map();
591
+ for (const commit of commits) {
592
+ const files = commit.files.slice(0, MAX_FILES_PER_COMMIT_FOR_PAIRS);
593
+ const dirs = /* @__PURE__ */ new Set();
594
+ for (const f of files) dirs.add(getTopDirectory2(f.path));
595
+ const dirArr = Array.from(dirs).sort();
596
+ for (const d of dirArr) {
597
+ dirCommitCount.set(d, (dirCommitCount.get(d) || 0) + 1);
344
598
  }
345
- for (const author of stats.authors) {
346
- const existing = merged.authors.find(
347
- (a) => a.email.toLowerCase() === author.email.toLowerCase()
348
- );
349
- if (existing) {
350
- existing.commits += author.commits;
351
- existing.linesAdded += author.linesAdded;
352
- existing.linesDeleted += author.linesDeleted;
353
- if (author.lastActiveDate > existing.lastActiveDate) {
354
- existing.lastActiveDate = author.lastActiveDate;
355
- }
356
- } else {
357
- merged.authors.push({ ...author });
599
+ for (let i = 0; i < dirArr.length; i++) {
600
+ for (let j = i + 1; j < dirArr.length; j++) {
601
+ const key = `${dirArr[i]}|||${dirArr[j]}`;
602
+ const existing = pairMap.get(key) || { dir1: dirArr[i], dir2: dirArr[j], count: 0 };
603
+ existing.count++;
604
+ pairMap.set(key, existing);
358
605
  }
359
606
  }
360
- for (const ft of stats.fileTypes) {
361
- const existing = merged.fileTypes.find(
362
- (f) => f.extension === ft.extension
363
- );
364
- if (existing) {
365
- existing.added += ft.added;
366
- existing.deleted += ft.deleted;
367
- existing.fileCount += ft.fileCount;
607
+ }
608
+ const pairs = Array.from(pairMap.values()).map(({ dir1, dir2, count }) => {
609
+ const c1 = dirCommitCount.get(dir1) || 1;
610
+ const c2 = dirCommitCount.get(dir2) || 1;
611
+ const coupling = count / Math.min(c1, c2);
612
+ return { dir1, dir2, coOccurrence: count, coupling };
613
+ }).filter((p) => p.coOccurrence >= 2).sort((a, b) => b.coupling - a.coupling).slice(0, 30);
614
+ const topDirs = Array.from(dirCommitCount.entries()).sort((a, b) => b[1] - a[1]).slice(0, MAX_DIRS_FOR_MATRIX).map(([d]) => d);
615
+ const matrix = [];
616
+ for (const d1 of topDirs) {
617
+ for (const d2 of topDirs) {
618
+ if (d1 === d2) {
619
+ matrix.push({ dir1: d1, dir2: d2, value: dirCommitCount.get(d1) || 0 });
368
620
  } else {
369
- merged.fileTypes.push({ ...ft });
621
+ const key = d1 < d2 ? `${d1}|||${d2}` : `${d2}|||${d1}`;
622
+ matrix.push({ dir1: d1, dir2: d2, value: pairMap.get(key)?.count || 0 });
370
623
  }
371
624
  }
372
- for (const dir of stats.directories) {
373
- const existing = merged.directories.find((d) => d.path === dir.path);
374
- if (existing) {
375
- existing.commits += dir.commits;
376
- existing.linesChanged += dir.linesChanged;
377
- } else {
378
- merged.directories.push({ ...dir });
379
- }
625
+ }
626
+ return {
627
+ pairs,
628
+ matrix,
629
+ directories: topDirs
630
+ };
631
+ }
632
+ function calculateAIQualityRisk(commits) {
633
+ if (commits.length === 0) {
634
+ return {
635
+ files: [],
636
+ scatter: [],
637
+ summary: { highAIHighChurn: 0, highAILowChurn: 0, lowAIHighChurn: 0, lowAILowChurn: 0 }
638
+ };
639
+ }
640
+ const fileMap = /* @__PURE__ */ new Map();
641
+ for (const commit of commits) {
642
+ const aiScore = calculateAIScore(commit);
643
+ for (const file of commit.files) {
644
+ const lines = file.added + file.deleted;
645
+ const entry = fileMap.get(file.path) || {
646
+ added: 0,
647
+ deleted: 0,
648
+ modifyCount: 0,
649
+ weightedAI: 0,
650
+ weight: 0
651
+ };
652
+ entry.added += file.added;
653
+ entry.deleted += file.deleted;
654
+ entry.modifyCount++;
655
+ const w = Math.max(lines, 1);
656
+ entry.weightedAI += aiScore * w;
657
+ entry.weight += w;
658
+ fileMap.set(file.path, entry);
380
659
  }
381
- merged.quality.avgFilesPerCommit += stats.quality.avgFilesPerCommit;
382
- merged.quality.avgLinesPerCommit += stats.quality.avgLinesPerCommit;
383
- merged.quality.churnRate += stats.quality.churnRate;
384
- for (const hf of stats.quality.hotFiles) {
385
- const existing = merged.quality.hotFiles.find((h) => h.path === hf.path);
386
- if (existing) {
387
- existing.modifyCount += hf.modifyCount;
388
- for (const author of hf.authors) {
389
- if (!existing.authors.includes(author)) {
390
- existing.authors.push(author);
391
- }
392
- }
393
- } else {
394
- merged.quality.hotFiles.push({ ...hf, authors: [...hf.authors] });
395
- }
396
- }
397
- for (let i = 0; i < 7; i++) {
398
- merged.timePatterns.weekdayDistribution[i] += stats.timePatterns.weekdayDistribution[i];
399
- }
400
- for (const wp of stats.trends.weeklyTrend) {
401
- const existing = merged.trends.weeklyTrend.find((w) => w.week === wp.week);
402
- if (existing) {
403
- existing.commits += wp.commits;
404
- existing.linesAdded += wp.linesAdded;
405
- existing.linesDeleted += wp.linesDeleted;
406
- } else {
407
- merged.trends.weeklyTrend.push({ ...wp });
408
- }
409
- }
410
- for (const cp of stats.trends.cumulativeLines) {
411
- const existing = merged.trends.cumulativeLines.find(
412
- (c) => c.date === cp.date
413
- );
414
- if (existing) {
415
- existing.netLines += cp.netLines;
416
- } else {
417
- merged.trends.cumulativeLines.push({ ...cp });
418
- }
419
- }
420
- for (const sf of stats.collaboration.soloFiles) {
421
- const existing = merged.collaboration.soloFiles.find(
422
- (s) => s.path === sf.path
423
- );
424
- if (existing) {
425
- existing.commits += sf.commits;
426
- } else {
427
- merged.collaboration.soloFiles.push({ ...sf });
428
- }
429
- }
430
- for (const ch of stats.collaboration.collaborationHotspots) {
431
- const existing = merged.collaboration.collaborationHotspots.find(
432
- (c) => c.path === ch.path
433
- );
434
- if (existing) {
435
- existing.totalCommits += ch.totalCommits;
436
- existing.authorCount = Math.max(existing.authorCount, ch.authorCount);
437
- } else {
438
- merged.collaboration.collaborationHotspots.push({ ...ch });
439
- }
440
- }
441
- for (const [type, count] of Object.entries(stats.messageStats.typeDistribution)) {
442
- merged.messageStats.typeDistribution[type] = (merged.messageStats.typeDistribution[type] || 0) + count;
443
- }
444
- merged.messageStats.avgMessageLength += stats.messageStats.avgMessageLength;
445
- }
446
- let busiestDay = { date: "", count: 0 };
447
- for (const [date, count] of Object.entries(merged.dailyHeatmap)) {
448
- if (count > busiestDay.count) {
449
- busiestDay = { date, count };
450
- }
451
- }
452
- merged.busiestDay = busiestDay;
453
- merged.authors.sort((a, b) => b.commits - a.commits);
454
- merged.fileTypes.sort(
455
- (a, b) => b.added + b.deleted - (a.added + a.deleted)
456
- );
457
- merged.directories.sort((a, b) => b.linesChanged - a.linesChanged);
458
- merged.directories = merged.directories.slice(0, 10);
459
- const repoCount = statsList.length;
460
- merged.quality.avgFilesPerCommit /= repoCount;
461
- merged.quality.avgLinesPerCommit /= repoCount;
462
- merged.quality.churnRate /= repoCount;
463
- merged.quality.hotFiles.sort((a, b) => b.modifyCount - a.modifyCount);
464
- merged.quality.hotFiles = merged.quality.hotFiles.slice(0, 10);
465
- const totalWeekdayCommits = merged.timePatterns.weekdayDistribution.reduce(
466
- (a, b) => a + b,
467
- 0
468
- );
469
- if (totalWeekdayCommits > 0) {
470
- merged.timePatterns.weekendCommits = (merged.timePatterns.weekdayDistribution[5] + merged.timePatterns.weekdayDistribution[6]) / totalWeekdayCommits;
471
- }
472
- merged.trends.weeklyTrend.sort((a, b) => a.week.localeCompare(b.week));
473
- merged.trends.cumulativeLines.sort((a, b) => a.date.localeCompare(b.date));
474
- let cumulative = 0;
475
- for (const point of merged.trends.cumulativeLines) {
476
- cumulative += point.netLines;
477
- point.netLines = cumulative;
478
660
  }
479
- merged.collaboration.soloFiles.sort((a, b) => b.commits - a.commits);
480
- merged.collaboration.soloFiles = merged.collaboration.soloFiles.slice(0, 10);
481
- merged.collaboration.collaborationHotspots.sort(
482
- (a, b) => b.totalCommits - a.totalCommits
483
- );
484
- merged.collaboration.collaborationHotspots = merged.collaboration.collaborationHotspots.slice(0, 10);
485
- merged.messageStats.avgMessageLength /= repoCount;
486
- const contributionMap = /* @__PURE__ */ new Map();
487
- for (const stats of statsList) {
488
- for (const contrib of stats.authorFileTypeContributions) {
489
- const key = `${contrib.email.toLowerCase()}|||${contrib.extension}`;
490
- const existing = contributionMap.get(key);
491
- if (existing) {
492
- existing.linesAdded += contrib.linesAdded;
493
- existing.linesDeleted += contrib.linesDeleted;
494
- existing.commits += contrib.commits;
495
- existing.fileCount += contrib.fileCount;
496
- } else {
497
- contributionMap.set(key, { ...contrib });
498
- }
499
- }
500
- }
501
- merged.authorFileTypeContributions = Array.from(contributionMap.values()).sort((a, b) => {
502
- const totalA = a.linesAdded + a.linesDeleted;
503
- const totalB = b.linesAdded + b.linesDeleted;
504
- return totalB - totalA;
505
- }).slice(0, 20);
506
- return merged;
507
- }
508
- function emptyStats() {
661
+ const files = [];
662
+ const scatter = [];
663
+ let highAIHighChurn = 0, highAILowChurn = 0, lowAIHighChurn = 0, lowAILowChurn = 0;
664
+ for (const [path, data] of fileMap) {
665
+ const avgAI = data.weight > 0 ? data.weightedAI / data.weight : 0;
666
+ const churnRate = data.added > 0 ? data.deleted / data.added : 0;
667
+ const totalLines = data.added + data.deleted;
668
+ if (data.modifyCount < 2 && totalLines < 50) continue;
669
+ const riskScore = avgAI / 100 * Math.min(churnRate, 2) * Math.log2(data.modifyCount + 1) * 50;
670
+ files.push({
671
+ path,
672
+ aiScore: avgAI,
673
+ churnRate,
674
+ modifyCount: data.modifyCount,
675
+ totalLines,
676
+ riskScore
677
+ });
678
+ scatter.push({ path, aiScore: avgAI, churnRate, modifyCount: data.modifyCount });
679
+ const highAI = avgAI > 50;
680
+ const highChurn = churnRate > 0.5;
681
+ if (highAI && highChurn) highAIHighChurn++;
682
+ else if (highAI) highAILowChurn++;
683
+ else if (highChurn) lowAIHighChurn++;
684
+ else lowAILowChurn++;
685
+ }
686
+ files.sort((a, b) => b.riskScore - a.riskScore);
509
687
  return {
510
- totalCommits: 0,
511
- linesAdded: 0,
512
- linesDeleted: 0,
513
- filesChanged: 0,
514
- firstCommitDate: /* @__PURE__ */ new Date(),
515
- lastCommitDate: /* @__PURE__ */ new Date(),
516
- busiestDay: { date: "", count: 0 },
517
- authors: [],
518
- fileTypes: [],
519
- directories: [],
520
- hourlyDistribution: new Array(24).fill(0),
521
- dailyHeatmap: {},
522
- quality: emptyQualityMetrics(),
523
- timePatterns: emptyTimePatterns(),
524
- trends: emptyTrendData(),
525
- collaboration: emptyCollaborationMetrics(),
526
- messageStats: emptyMessageStats(),
527
- authorFileTypeContributions: []
688
+ files: files.slice(0, 20),
689
+ scatter: scatter.slice(0, 500),
690
+ // 限制散点数
691
+ summary: { highAIHighChurn, highAILowChurn, lowAIHighChurn, lowAILowChurn }
528
692
  };
529
693
  }
530
- function getTopDirectory(filePath) {
531
- const parts = filePath.split("/");
532
- return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
694
+ function emptyBuckets() {
695
+ return [
696
+ { label: "XS", range: "<10", count: 0, percentage: 0 },
697
+ { label: "S", range: "10-49", count: 0, percentage: 0 },
698
+ { label: "M", range: "50-199", count: 0, percentage: 0 },
699
+ { label: "L", range: "200-999", count: 0, percentage: 0 },
700
+ { label: "XL", range: "\u22651000", count: 0, percentage: 0 }
701
+ ];
533
702
  }
534
- function formatDateKey(date) {
535
- const y = date.getFullYear();
536
- const m = String(date.getMonth() + 1).padStart(2, "0");
537
- const d = String(date.getDate()).padStart(2, "0");
538
- return `${y}-${m}-${d}`;
703
+ function pct(part, total) {
704
+ return total > 0 ? part / total * 100 : 0;
705
+ }
706
+
707
+ // src/analyzer/stats-metrics.ts
708
+ import { extname } from "path";
709
+ function calculateCommitDetails(commits) {
710
+ return commits.map((commit) => {
711
+ let linesAdded = 0;
712
+ let linesDeleted = 0;
713
+ for (const file of commit.files) {
714
+ linesAdded += file.added;
715
+ linesDeleted += file.deleted;
716
+ }
717
+ return {
718
+ hash: commit.hash,
719
+ author: commit.author,
720
+ email: commit.email,
721
+ repoName: "",
722
+ date: commit.date,
723
+ message: commit.message,
724
+ linesAdded,
725
+ linesDeleted,
726
+ files: commit.files.map((file) => ({ ...file }))
727
+ };
728
+ });
539
729
  }
540
730
  function calculateQualityMetrics(commits) {
541
731
  if (commits.length === 0) {
542
732
  return emptyQualityMetrics();
543
733
  }
544
- const totalFiles = commits.reduce((sum, c) => sum + c.files.length, 0);
734
+ const totalFiles = commits.reduce((sum, commit) => sum + commit.files.length, 0);
545
735
  const avgFilesPerCommit = totalFiles / commits.length;
546
736
  const totalLines = commits.reduce(
547
- (sum, c) => sum + c.files.reduce((s, f) => s + f.added + f.deleted, 0),
737
+ (sum, commit) => sum + commit.files.reduce((s, file) => s + file.added + file.deleted, 0),
548
738
  0
549
739
  );
550
740
  const avgLinesPerCommit = totalLines / commits.length;
551
741
  const totalAdded = commits.reduce(
552
- (sum, c) => sum + c.files.reduce((s, f) => s + f.added, 0),
742
+ (sum, commit) => sum + commit.files.reduce((s, file) => s + file.added, 0),
553
743
  0
554
744
  );
555
745
  const totalDeleted = commits.reduce(
556
- (sum, c) => sum + c.files.reduce((s, f) => s + f.deleted, 0),
746
+ (sum, commit) => sum + commit.files.reduce((s, file) => s + file.deleted, 0),
557
747
  0
558
748
  );
559
749
  const churnRate = totalAdded > 0 ? totalDeleted / totalAdded : 0;
@@ -621,77 +811,13 @@ function calculateTimePatterns(commits) {
621
811
  weekdayByAuthor: weekdayByAuthorArray
622
812
  };
623
813
  }
624
- function calculateStreaks(sortedCommits) {
625
- if (sortedCommits.length === 0) {
626
- return { longestStreak: 0, currentStreak: 0 };
627
- }
628
- const uniqueDates = /* @__PURE__ */ new Set();
629
- for (const commit of sortedCommits) {
630
- uniqueDates.add(formatDateKey(commit.date));
631
- }
632
- const sortedDates = Array.from(uniqueDates).sort();
633
- if (sortedDates.length === 0) {
634
- return { longestStreak: 0, currentStreak: 0 };
635
- }
636
- let longestStreak = 1;
637
- let currentStreakCount = 1;
638
- let tempStreak = 1;
639
- for (let i = 1; i < sortedDates.length; i++) {
640
- const prevDate = new Date(sortedDates[i - 1]);
641
- const currDate = new Date(sortedDates[i]);
642
- const diffDays = Math.round(
643
- (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
644
- );
645
- if (diffDays === 1) {
646
- tempStreak++;
647
- } else {
648
- tempStreak = 1;
649
- }
650
- longestStreak = Math.max(longestStreak, tempStreak);
651
- }
652
- const today = formatDateKey(/* @__PURE__ */ new Date());
653
- const lastCommitDate = sortedDates[sortedDates.length - 1];
654
- const lastDate = new Date(lastCommitDate);
655
- const todayDate = new Date(today);
656
- const daysSinceLastCommit = Math.round(
657
- (todayDate.getTime() - lastDate.getTime()) / (1e3 * 60 * 60 * 24)
658
- );
659
- if (daysSinceLastCommit <= 1) {
660
- currentStreakCount = 1;
661
- for (let i = sortedDates.length - 2; i >= 0; i--) {
662
- const currDate = new Date(sortedDates[i + 1]);
663
- const prevDate = new Date(sortedDates[i]);
664
- const diffDays = Math.round(
665
- (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
666
- );
667
- if (diffDays === 1) {
668
- currentStreakCount++;
669
- } else {
670
- break;
671
- }
672
- }
673
- } else {
674
- currentStreakCount = 0;
675
- }
676
- return { longestStreak, currentStreak: currentStreakCount };
677
- }
678
- function getWeekKey(date) {
679
- const d = new Date(date);
680
- d.setHours(0, 0, 0, 0);
681
- d.setDate(d.getDate() + 4 - (d.getDay() || 7));
682
- const yearStart = new Date(d.getFullYear(), 0, 1);
683
- const weekNo = Math.ceil(
684
- ((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7
685
- );
686
- return `${d.getFullYear()}-W${String(weekNo).padStart(2, "0")}`;
687
- }
688
814
  function calculateTrends(commits) {
689
815
  if (commits.length === 0) {
690
816
  return emptyTrendData();
691
817
  }
692
818
  const weekMap = /* @__PURE__ */ new Map();
693
819
  for (const commit of commits) {
694
- const week = getWeekKey(commit.date);
820
+ const week = getWeekKey2(commit.date);
695
821
  const entry = weekMap.get(week) || {
696
822
  week,
697
823
  commits: 0,
@@ -711,7 +837,7 @@ function calculateTrends(commits) {
711
837
  const dailyNet = /* @__PURE__ */ new Map();
712
838
  for (const commit of commits) {
713
839
  const dateKey = formatDateKey(commit.date);
714
- const net = commit.files.reduce((sum, f) => sum + f.added - f.deleted, 0);
840
+ const net = commit.files.reduce((sum, file) => sum + file.added - file.deleted, 0);
715
841
  dailyNet.set(dateKey, (dailyNet.get(dateKey) || 0) + net);
716
842
  }
717
843
  let cumulative = 0;
@@ -817,53 +943,545 @@ function calculateAuthorFileTypeContributions(commits) {
817
943
  if (!commitCountMap.has(key)) {
818
944
  commitCountMap.set(key, /* @__PURE__ */ new Set());
819
945
  }
820
- commitCountMap.get(key).add(commit.hash);
946
+ commitCountMap.get(key).add(commit.hash);
947
+ }
948
+ }
949
+ for (const [key, contribution] of contributionMap) {
950
+ contribution.commits = commitCountMap.get(key)?.size || 0;
951
+ contribution.fileCount = uniqueFilesMap.get(key)?.size || 0;
952
+ }
953
+ return Array.from(contributionMap.values()).sort((a, b) => {
954
+ const totalA = a.linesAdded + a.linesDeleted;
955
+ const totalB = b.linesAdded + b.linesDeleted;
956
+ return totalB - totalA;
957
+ }).slice(0, 20);
958
+ }
959
+ function emptyQualityMetrics() {
960
+ return {
961
+ avgFilesPerCommit: 0,
962
+ avgLinesPerCommit: 0,
963
+ churnRate: 0,
964
+ hotFiles: []
965
+ };
966
+ }
967
+ function emptyTimePatterns() {
968
+ return {
969
+ weekdayDistribution: new Array(7).fill(0),
970
+ weekendCommits: 0,
971
+ avgCommitInterval: 0,
972
+ longestStreak: 0,
973
+ currentStreak: 0
974
+ };
975
+ }
976
+ function emptyTrendData() {
977
+ return {
978
+ weeklyTrend: [],
979
+ cumulativeLines: []
980
+ };
981
+ }
982
+ function emptyCollaborationMetrics() {
983
+ return {
984
+ soloFiles: [],
985
+ collaborationHotspots: []
986
+ };
987
+ }
988
+ function emptyMessageStats() {
989
+ return {
990
+ typeDistribution: {},
991
+ avgMessageLength: 0
992
+ };
993
+ }
994
+ function calculateStreaks(sortedCommits) {
995
+ if (sortedCommits.length === 0) {
996
+ return { longestStreak: 0, currentStreak: 0 };
997
+ }
998
+ const uniqueDates = /* @__PURE__ */ new Set();
999
+ for (const commit of sortedCommits) {
1000
+ uniqueDates.add(formatDateKey(commit.date));
1001
+ }
1002
+ const sortedDates = Array.from(uniqueDates).sort();
1003
+ if (sortedDates.length === 0) {
1004
+ return { longestStreak: 0, currentStreak: 0 };
1005
+ }
1006
+ let longestStreak = 1;
1007
+ let currentStreakCount = 1;
1008
+ let tempStreak = 1;
1009
+ for (let i = 1; i < sortedDates.length; i++) {
1010
+ const prevDate = new Date(sortedDates[i - 1]);
1011
+ const currDate = new Date(sortedDates[i]);
1012
+ const diffDays = Math.round(
1013
+ (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
1014
+ );
1015
+ if (diffDays === 1) {
1016
+ tempStreak++;
1017
+ } else {
1018
+ tempStreak = 1;
1019
+ }
1020
+ longestStreak = Math.max(longestStreak, tempStreak);
1021
+ }
1022
+ const today = formatDateKey(/* @__PURE__ */ new Date());
1023
+ const lastCommitDate = sortedDates[sortedDates.length - 1];
1024
+ const lastDate = new Date(lastCommitDate);
1025
+ const todayDate = new Date(today);
1026
+ const daysSinceLastCommit = Math.round(
1027
+ (todayDate.getTime() - lastDate.getTime()) / (1e3 * 60 * 60 * 24)
1028
+ );
1029
+ if (daysSinceLastCommit <= 1) {
1030
+ currentStreakCount = 1;
1031
+ for (let i = sortedDates.length - 2; i >= 0; i--) {
1032
+ const currDate = new Date(sortedDates[i + 1]);
1033
+ const prevDate = new Date(sortedDates[i]);
1034
+ const diffDays = Math.round(
1035
+ (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
1036
+ );
1037
+ if (diffDays === 1) {
1038
+ currentStreakCount++;
1039
+ } else {
1040
+ break;
1041
+ }
1042
+ }
1043
+ } else {
1044
+ currentStreakCount = 0;
1045
+ }
1046
+ return { longestStreak, currentStreak: currentStreakCount };
1047
+ }
1048
+ function getWeekKey2(date) {
1049
+ const d = new Date(date);
1050
+ d.setHours(0, 0, 0, 0);
1051
+ d.setDate(d.getDate() + 4 - (d.getDay() || 7));
1052
+ const yearStart = new Date(d.getFullYear(), 0, 1);
1053
+ const weekNo = Math.ceil(
1054
+ ((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7
1055
+ );
1056
+ return `${d.getFullYear()}-W${String(weekNo).padStart(2, "0")}`;
1057
+ }
1058
+
1059
+ // src/analyzer/stats-empty.ts
1060
+ function emptyStats() {
1061
+ return {
1062
+ totalCommits: 0,
1063
+ linesAdded: 0,
1064
+ linesDeleted: 0,
1065
+ filesChanged: 0,
1066
+ firstCommitDate: /* @__PURE__ */ new Date(),
1067
+ lastCommitDate: /* @__PURE__ */ new Date(),
1068
+ busiestDay: { date: "", count: 0 },
1069
+ authors: [],
1070
+ fileTypes: [],
1071
+ directories: [],
1072
+ hourlyDistribution: new Array(24).fill(0),
1073
+ dailyHeatmap: {},
1074
+ quality: emptyQualityMetrics(),
1075
+ timePatterns: emptyTimePatterns(),
1076
+ trends: emptyTrendData(),
1077
+ collaboration: emptyCollaborationMetrics(),
1078
+ messageStats: emptyMessageStats(),
1079
+ authorFileTypeContributions: [],
1080
+ commitDetails: []
1081
+ };
1082
+ }
1083
+
1084
+ // src/analyzer/stats-calculator.ts
1085
+ function calculateStats(commits) {
1086
+ if (commits.length === 0) {
1087
+ return emptyStats();
1088
+ }
1089
+ const sorted = [...commits].sort(
1090
+ (a, b) => a.date.getTime() - b.date.getTime()
1091
+ );
1092
+ let totalLinesAdded = 0;
1093
+ let totalLinesDeleted = 0;
1094
+ const allFilePaths = /* @__PURE__ */ new Set();
1095
+ const authorMap = /* @__PURE__ */ new Map();
1096
+ const fileTypeMap = /* @__PURE__ */ new Map();
1097
+ const directoryMap = /* @__PURE__ */ new Map();
1098
+ const directoryCommitSet = /* @__PURE__ */ new Map();
1099
+ const hourlyDistribution = new Array(24).fill(0);
1100
+ const dailyHeatmap = {};
1101
+ const hourlyByAuthor = /* @__PURE__ */ new Map();
1102
+ const dailyCounts = /* @__PURE__ */ new Map();
1103
+ for (const commit of sorted) {
1104
+ const hour = commit.date.getHours();
1105
+ hourlyDistribution[hour]++;
1106
+ if (!hourlyByAuthor.has(hour)) {
1107
+ hourlyByAuthor.set(hour, /* @__PURE__ */ new Map());
1108
+ }
1109
+ const hourAuthors = hourlyByAuthor.get(hour);
1110
+ hourAuthors.set(commit.author, (hourAuthors.get(commit.author) || 0) + 1);
1111
+ const dateKey = formatDateKey(commit.date);
1112
+ dailyHeatmap[dateKey] = (dailyHeatmap[dateKey] || 0) + 1;
1113
+ dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
1114
+ const authorKey = commit.email.toLowerCase();
1115
+ let authorStat = authorMap.get(authorKey);
1116
+ if (!authorStat) {
1117
+ authorStat = {
1118
+ name: commit.author,
1119
+ email: commit.email,
1120
+ commits: 0,
1121
+ linesAdded: 0,
1122
+ linesDeleted: 0,
1123
+ lastActiveDate: commit.date
1124
+ };
1125
+ authorMap.set(authorKey, authorStat);
1126
+ }
1127
+ authorStat.commits++;
1128
+ authorStat.lastActiveDate = commit.date;
1129
+ for (const file of commit.files) {
1130
+ totalLinesAdded += file.added;
1131
+ totalLinesDeleted += file.deleted;
1132
+ allFilePaths.add(file.path);
1133
+ authorStat.linesAdded += file.added;
1134
+ authorStat.linesDeleted += file.deleted;
1135
+ const ext = extname2(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
1136
+ let ftStat = fileTypeMap.get(ext);
1137
+ if (!ftStat) {
1138
+ ftStat = { extension: ext, added: 0, deleted: 0, fileCount: 0 };
1139
+ fileTypeMap.set(ext, ftStat);
1140
+ }
1141
+ ftStat.added += file.added;
1142
+ ftStat.deleted += file.deleted;
1143
+ const topDir = getTopDirectory2(file.path);
1144
+ let dirStat = directoryMap.get(topDir);
1145
+ if (!dirStat) {
1146
+ dirStat = { path: topDir, commits: 0, linesChanged: 0 };
1147
+ directoryMap.set(topDir, dirStat);
1148
+ directoryCommitSet.set(topDir, /* @__PURE__ */ new Set());
1149
+ }
1150
+ dirStat.linesChanged += file.added + file.deleted;
1151
+ directoryCommitSet.get(topDir).add(commit.hash);
1152
+ }
1153
+ }
1154
+ for (const [dir, commitSet] of directoryCommitSet) {
1155
+ const dirStat = directoryMap.get(dir);
1156
+ if (dirStat) {
1157
+ dirStat.commits = commitSet.size;
1158
+ }
1159
+ }
1160
+ const fileCountByExt = /* @__PURE__ */ new Map();
1161
+ for (const filePath of allFilePaths) {
1162
+ const ext = extname2(filePath).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
1163
+ if (!fileCountByExt.has(ext)) {
1164
+ fileCountByExt.set(ext, /* @__PURE__ */ new Set());
1165
+ }
1166
+ fileCountByExt.get(ext).add(filePath);
1167
+ }
1168
+ for (const [ext, files] of fileCountByExt) {
1169
+ const ftStat = fileTypeMap.get(ext);
1170
+ if (ftStat) {
1171
+ ftStat.fileCount = files.size;
1172
+ }
1173
+ }
1174
+ let busiestDay = { date: "", count: 0 };
1175
+ for (const [date, count] of dailyCounts) {
1176
+ if (count > busiestDay.count) {
1177
+ busiestDay = { date, count };
1178
+ }
1179
+ }
1180
+ const authors = Array.from(authorMap.values()).sort(
1181
+ (a, b) => b.commits - a.commits
1182
+ );
1183
+ const fileTypes = Array.from(fileTypeMap.values()).sort(
1184
+ (a, b) => b.added + b.deleted - (a.added + a.deleted)
1185
+ );
1186
+ const directories = Array.from(directoryMap.values()).sort((a, b) => b.linesChanged - a.linesChanged).slice(0, 10);
1187
+ const hourlyByAuthorArray = Array.from({ length: 24 }, (_, hour) => {
1188
+ const authorMap2 = hourlyByAuthor.get(hour);
1189
+ const authors2 = {};
1190
+ if (authorMap2) {
1191
+ authorMap2.forEach((count, author) => {
1192
+ authors2[author] = count;
1193
+ });
1194
+ }
1195
+ return {
1196
+ count: hourlyDistribution[hour],
1197
+ authors: authors2
1198
+ };
1199
+ });
1200
+ const aiStats = calculateAIMetrics(sorted);
1201
+ return {
1202
+ totalCommits: sorted.length,
1203
+ linesAdded: totalLinesAdded,
1204
+ linesDeleted: totalLinesDeleted,
1205
+ filesChanged: allFilePaths.size,
1206
+ firstCommitDate: sorted[0].date,
1207
+ lastCommitDate: sorted[sorted.length - 1].date,
1208
+ busiestDay,
1209
+ authors,
1210
+ fileTypes,
1211
+ directories,
1212
+ hourlyDistribution,
1213
+ dailyHeatmap,
1214
+ hourlyByAuthor: hourlyByAuthorArray,
1215
+ quality: calculateQualityMetrics(sorted),
1216
+ timePatterns: calculateTimePatterns(sorted),
1217
+ trends: calculateTrends(sorted),
1218
+ collaboration: calculateCollaboration(sorted),
1219
+ messageStats: calculateMessageStats(sorted),
1220
+ authorFileTypeContributions: calculateAuthorFileTypeContributions(sorted),
1221
+ commitDetails: calculateCommitDetails(sorted),
1222
+ aiMetrics: aiStats.aiMetrics,
1223
+ authorAIStats: aiStats.authorAIStats,
1224
+ directoryAIStats: aiStats.directoryAIStats,
1225
+ aiTrends: aiStats.aiTrends,
1226
+ changeSizeDistribution: calculateChangeSizeDistribution(sorted),
1227
+ directoryCoupling: calculateDirectoryCoupling(sorted),
1228
+ aiQualityRisk: calculateAIQualityRisk(sorted)
1229
+ };
1230
+ }
1231
+ function mergeStats(statsList) {
1232
+ if (statsList.length === 0) return emptyStats();
1233
+ if (statsList.length === 1) return statsList[0];
1234
+ const merged = emptyStats();
1235
+ for (const stats of statsList) {
1236
+ merged.totalCommits += stats.totalCommits;
1237
+ merged.linesAdded += stats.linesAdded;
1238
+ merged.linesDeleted += stats.linesDeleted;
1239
+ merged.filesChanged += stats.filesChanged;
1240
+ if (!merged.firstCommitDate || stats.firstCommitDate < merged.firstCommitDate) {
1241
+ merged.firstCommitDate = stats.firstCommitDate;
1242
+ }
1243
+ if (!merged.lastCommitDate || stats.lastCommitDate > merged.lastCommitDate) {
1244
+ merged.lastCommitDate = stats.lastCommitDate;
1245
+ }
1246
+ for (let i = 0; i < 24; i++) {
1247
+ merged.hourlyDistribution[i] += stats.hourlyDistribution[i];
1248
+ }
1249
+ for (const [date, count] of Object.entries(stats.dailyHeatmap)) {
1250
+ merged.dailyHeatmap[date] = (merged.dailyHeatmap[date] || 0) + count;
1251
+ }
1252
+ for (const author of stats.authors) {
1253
+ const existing = merged.authors.find(
1254
+ (a) => a.email.toLowerCase() === author.email.toLowerCase()
1255
+ );
1256
+ if (existing) {
1257
+ existing.commits += author.commits;
1258
+ existing.linesAdded += author.linesAdded;
1259
+ existing.linesDeleted += author.linesDeleted;
1260
+ if (author.lastActiveDate > existing.lastActiveDate) {
1261
+ existing.lastActiveDate = author.lastActiveDate;
1262
+ }
1263
+ } else {
1264
+ merged.authors.push({ ...author });
1265
+ }
1266
+ }
1267
+ for (const ft of stats.fileTypes) {
1268
+ const existing = merged.fileTypes.find(
1269
+ (f) => f.extension === ft.extension
1270
+ );
1271
+ if (existing) {
1272
+ existing.added += ft.added;
1273
+ existing.deleted += ft.deleted;
1274
+ existing.fileCount += ft.fileCount;
1275
+ } else {
1276
+ merged.fileTypes.push({ ...ft });
1277
+ }
1278
+ }
1279
+ for (const dir of stats.directories) {
1280
+ const existing = merged.directories.find((d) => d.path === dir.path);
1281
+ if (existing) {
1282
+ existing.commits += dir.commits;
1283
+ existing.linesChanged += dir.linesChanged;
1284
+ } else {
1285
+ merged.directories.push({ ...dir });
1286
+ }
1287
+ }
1288
+ merged.quality.avgFilesPerCommit += stats.quality.avgFilesPerCommit;
1289
+ merged.quality.avgLinesPerCommit += stats.quality.avgLinesPerCommit;
1290
+ merged.quality.churnRate += stats.quality.churnRate;
1291
+ for (const hf of stats.quality.hotFiles) {
1292
+ const existing = merged.quality.hotFiles.find((h) => h.path === hf.path);
1293
+ if (existing) {
1294
+ existing.modifyCount += hf.modifyCount;
1295
+ for (const author of hf.authors) {
1296
+ if (!existing.authors.includes(author)) {
1297
+ existing.authors.push(author);
1298
+ }
1299
+ }
1300
+ } else {
1301
+ merged.quality.hotFiles.push({ ...hf, authors: [...hf.authors] });
1302
+ }
1303
+ }
1304
+ for (let i = 0; i < 7; i++) {
1305
+ merged.timePatterns.weekdayDistribution[i] += stats.timePatterns.weekdayDistribution[i];
1306
+ }
1307
+ for (const wp of stats.trends.weeklyTrend) {
1308
+ const existing = merged.trends.weeklyTrend.find((w) => w.week === wp.week);
1309
+ if (existing) {
1310
+ existing.commits += wp.commits;
1311
+ existing.linesAdded += wp.linesAdded;
1312
+ existing.linesDeleted += wp.linesDeleted;
1313
+ } else {
1314
+ merged.trends.weeklyTrend.push({ ...wp });
1315
+ }
1316
+ }
1317
+ for (const cp of stats.trends.cumulativeLines) {
1318
+ const existing = merged.trends.cumulativeLines.find(
1319
+ (c) => c.date === cp.date
1320
+ );
1321
+ if (existing) {
1322
+ existing.netLines += cp.netLines;
1323
+ } else {
1324
+ merged.trends.cumulativeLines.push({ ...cp });
1325
+ }
1326
+ }
1327
+ for (const sf of stats.collaboration.soloFiles) {
1328
+ const existing = merged.collaboration.soloFiles.find(
1329
+ (s) => s.path === sf.path
1330
+ );
1331
+ if (existing) {
1332
+ existing.commits += sf.commits;
1333
+ } else {
1334
+ merged.collaboration.soloFiles.push({ ...sf });
1335
+ }
1336
+ }
1337
+ for (const ch of stats.collaboration.collaborationHotspots) {
1338
+ const existing = merged.collaboration.collaborationHotspots.find(
1339
+ (c) => c.path === ch.path
1340
+ );
1341
+ if (existing) {
1342
+ existing.totalCommits += ch.totalCommits;
1343
+ existing.authorCount = Math.max(existing.authorCount, ch.authorCount);
1344
+ } else {
1345
+ merged.collaboration.collaborationHotspots.push({ ...ch });
1346
+ }
1347
+ }
1348
+ for (const [type, count] of Object.entries(stats.messageStats.typeDistribution)) {
1349
+ merged.messageStats.typeDistribution[type] = (merged.messageStats.typeDistribution[type] || 0) + count;
1350
+ }
1351
+ merged.messageStats.avgMessageLength += stats.messageStats.avgMessageLength;
1352
+ merged.commitDetails.push(...stats.commitDetails);
1353
+ }
1354
+ let busiestDay = { date: "", count: 0 };
1355
+ for (const [date, count] of Object.entries(merged.dailyHeatmap)) {
1356
+ if (count > busiestDay.count) {
1357
+ busiestDay = { date, count };
1358
+ }
1359
+ }
1360
+ merged.busiestDay = busiestDay;
1361
+ merged.authors.sort((a, b) => b.commits - a.commits);
1362
+ merged.fileTypes.sort(
1363
+ (a, b) => b.added + b.deleted - (a.added + a.deleted)
1364
+ );
1365
+ merged.directories.sort((a, b) => b.linesChanged - a.linesChanged);
1366
+ merged.directories = merged.directories.slice(0, 10);
1367
+ const repoCount = statsList.length;
1368
+ merged.quality.avgFilesPerCommit /= repoCount;
1369
+ merged.quality.avgLinesPerCommit /= repoCount;
1370
+ merged.quality.churnRate /= repoCount;
1371
+ merged.quality.hotFiles.sort((a, b) => b.modifyCount - a.modifyCount);
1372
+ merged.quality.hotFiles = merged.quality.hotFiles.slice(0, 10);
1373
+ const totalWeekdayCommits = merged.timePatterns.weekdayDistribution.reduce(
1374
+ (a, b) => a + b,
1375
+ 0
1376
+ );
1377
+ if (totalWeekdayCommits > 0) {
1378
+ merged.timePatterns.weekendCommits = (merged.timePatterns.weekdayDistribution[5] + merged.timePatterns.weekdayDistribution[6]) / totalWeekdayCommits;
1379
+ }
1380
+ merged.trends.weeklyTrend.sort((a, b) => a.week.localeCompare(b.week));
1381
+ merged.trends.cumulativeLines.sort((a, b) => a.date.localeCompare(b.date));
1382
+ let cumulative = 0;
1383
+ for (const point of merged.trends.cumulativeLines) {
1384
+ cumulative += point.netLines;
1385
+ point.netLines = cumulative;
1386
+ }
1387
+ merged.collaboration.soloFiles.sort((a, b) => b.commits - a.commits);
1388
+ merged.collaboration.soloFiles = merged.collaboration.soloFiles.slice(0, 10);
1389
+ merged.collaboration.collaborationHotspots.sort(
1390
+ (a, b) => b.totalCommits - a.totalCommits
1391
+ );
1392
+ merged.collaboration.collaborationHotspots = merged.collaboration.collaborationHotspots.slice(0, 10);
1393
+ merged.messageStats.avgMessageLength /= repoCount;
1394
+ merged.commitDetails.sort((a, b) => a.date.getTime() - b.date.getTime());
1395
+ mergeAIStats(merged, statsList);
1396
+ const contributionMap = /* @__PURE__ */ new Map();
1397
+ for (const stats of statsList) {
1398
+ for (const contrib of stats.authorFileTypeContributions) {
1399
+ const key = `${contrib.email.toLowerCase()}|||${contrib.extension}`;
1400
+ const existing = contributionMap.get(key);
1401
+ if (existing) {
1402
+ existing.linesAdded += contrib.linesAdded;
1403
+ existing.linesDeleted += contrib.linesDeleted;
1404
+ existing.commits += contrib.commits;
1405
+ existing.fileCount += contrib.fileCount;
1406
+ } else {
1407
+ contributionMap.set(key, { ...contrib });
1408
+ }
821
1409
  }
822
1410
  }
823
- for (const [key, contribution] of contributionMap) {
824
- contribution.commits = commitCountMap.get(key)?.size || 0;
825
- contribution.fileCount = uniqueFilesMap.get(key)?.size || 0;
826
- }
827
- return Array.from(contributionMap.values()).sort((a, b) => {
1411
+ merged.authorFileTypeContributions = Array.from(contributionMap.values()).sort((a, b) => {
828
1412
  const totalA = a.linesAdded + a.linesDeleted;
829
1413
  const totalB = b.linesAdded + b.linesDeleted;
830
1414
  return totalB - totalA;
831
1415
  }).slice(0, 20);
1416
+ return merged;
832
1417
  }
833
- function emptyQualityMetrics() {
834
- return {
835
- avgFilesPerCommit: 0,
836
- avgLinesPerCommit: 0,
837
- churnRate: 0,
838
- hotFiles: []
839
- };
840
- }
841
- function emptyTimePatterns() {
842
- return {
843
- weekdayDistribution: new Array(7).fill(0),
844
- weekendCommits: 0,
845
- avgCommitInterval: 0,
846
- longestStreak: 0,
847
- currentStreak: 0
848
- };
849
- }
850
- function emptyTrendData() {
851
- return {
852
- weeklyTrend: [],
853
- cumulativeLines: []
854
- };
855
- }
856
- function emptyCollaborationMetrics() {
857
- return {
858
- soloFiles: [],
859
- collaborationHotspots: []
1418
+ function mergeAIStats(merged, statsList) {
1419
+ const aiStatsList = statsList.filter((stats) => stats.aiMetrics);
1420
+ if (aiStatsList.length === 0) return;
1421
+ const highAICommits = [];
1422
+ const authorMap = /* @__PURE__ */ new Map();
1423
+ const directoryMap = /* @__PURE__ */ new Map();
1424
+ const trendMap = /* @__PURE__ */ new Map();
1425
+ let totalAILines = 0;
1426
+ let totalLines = 0;
1427
+ let suspiciousCommits = 0;
1428
+ for (const stats of aiStatsList) {
1429
+ const metrics = stats.aiMetrics;
1430
+ totalAILines += metrics.totalAILines;
1431
+ totalLines += metrics.totalLines;
1432
+ suspiciousCommits += metrics.suspiciousCommits;
1433
+ highAICommits.push(...metrics.highAICommits);
1434
+ for (const author of stats.authorAIStats || []) {
1435
+ const key = author.email.toLowerCase();
1436
+ const existing = authorMap.get(key);
1437
+ if (existing) {
1438
+ existing.aiLines += author.aiLines;
1439
+ existing.totalLines += author.totalLines;
1440
+ existing.aiPercentage = calculatePercentage(existing.aiLines, existing.totalLines);
1441
+ } else {
1442
+ authorMap.set(key, { ...author });
1443
+ }
1444
+ }
1445
+ for (const directory of stats.directoryAIStats || []) {
1446
+ const key = directory.repoName ? `${directory.repoName}|||${directory.path}` : directory.path;
1447
+ const displayPath = directory.repoName ? `${directory.repoName} / ${directory.path}` : directory.displayPath || directory.path;
1448
+ const existing = directoryMap.get(key);
1449
+ if (existing) {
1450
+ existing.commits += directory.commits;
1451
+ existing.aiLines += directory.aiLines;
1452
+ existing.totalLines += directory.totalLines;
1453
+ existing.aiPercentage = calculatePercentage(existing.aiLines, existing.totalLines);
1454
+ existing.displayPath = displayPath;
1455
+ existing.lastModified = new Date(directory.lastModified) > new Date(existing.lastModified) ? directory.lastModified : existing.lastModified;
1456
+ existing.isHighRisk = existing.commits > 50 && existing.aiPercentage > 60;
1457
+ } else {
1458
+ directoryMap.set(key, { ...directory, displayPath });
1459
+ }
1460
+ }
1461
+ for (const trend of stats.aiTrends || []) {
1462
+ const existing = trendMap.get(trend.week);
1463
+ if (existing) {
1464
+ existing.aiLines += trend.aiLines;
1465
+ existing.totalLines += trend.totalLines;
1466
+ existing.aiPercentage = calculatePercentage(existing.aiLines, existing.totalLines);
1467
+ } else {
1468
+ trendMap.set(trend.week, { ...trend });
1469
+ }
1470
+ }
1471
+ }
1472
+ merged.aiMetrics = {
1473
+ totalAILines,
1474
+ totalLines,
1475
+ aiPercentage: calculatePercentage(totalAILines, totalLines),
1476
+ suspiciousCommits,
1477
+ highAICommits: highAICommits.sort((a, b) => b.aiScore - a.aiScore).slice(0, 20)
860
1478
  };
1479
+ merged.authorAIStats = Array.from(authorMap.values()).sort((a, b) => b.aiPercentage - a.aiPercentage);
1480
+ merged.directoryAIStats = Array.from(directoryMap.values()).sort((a, b) => b.aiPercentage - a.aiPercentage);
1481
+ merged.aiTrends = Array.from(trendMap.values()).sort((a, b) => a.week.localeCompare(b.week));
861
1482
  }
862
- function emptyMessageStats() {
863
- return {
864
- typeDistribution: {},
865
- avgMessageLength: 0
866
- };
1483
+ function calculatePercentage(part, total) {
1484
+ return total > 0 ? part / total * 100 : 0;
867
1485
  }
868
1486
 
869
1487
  // src/analyzer/advanced/team-health.ts
@@ -992,7 +1610,7 @@ function calculateStability(commits) {
992
1610
  const dirStats = /* @__PURE__ */ new Map();
993
1611
  for (const commit of commits) {
994
1612
  for (const file of commit.files) {
995
- const dir = getTopDirectory2(file.path);
1613
+ const dir = getTopDirectory3(file.path);
996
1614
  const stat2 = dirStats.get(dir) || { added: 0, deleted: 0, files: /* @__PURE__ */ new Set() };
997
1615
  stat2.added += file.added;
998
1616
  stat2.deleted += file.deleted;
@@ -1023,7 +1641,7 @@ function calculateStability(commits) {
1023
1641
  stabilityScore
1024
1642
  };
1025
1643
  }
1026
- function getTopDirectory2(filePath) {
1644
+ function getTopDirectory3(filePath) {
1027
1645
  const parts = filePath.split("/");
1028
1646
  return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
1029
1647
  }
@@ -1350,6 +1968,658 @@ function calculateAdvancedStats(commits) {
1350
1968
  };
1351
1969
  }
1352
1970
 
1971
+ // src/analyzer/tech-debt/risk-scorer.ts
1972
+ function calculateRiskScores(commits) {
1973
+ if (commits.length === 0) return [];
1974
+ const fileMap = /* @__PURE__ */ new Map();
1975
+ for (const commit of commits) {
1976
+ for (const file of commit.files) {
1977
+ if (!fileMap.has(file.path)) {
1978
+ fileMap.set(file.path, {
1979
+ path: file.path,
1980
+ commits: [],
1981
+ authors: /* @__PURE__ */ new Set(),
1982
+ totalAdded: 0,
1983
+ totalDeleted: 0,
1984
+ lastModified: commit.date
1985
+ });
1986
+ }
1987
+ const data = fileMap.get(file.path);
1988
+ data.commits.push(commit);
1989
+ data.authors.add(commit.author);
1990
+ data.totalAdded += file.added;
1991
+ data.totalDeleted += file.deleted;
1992
+ if (commit.date > data.lastModified) {
1993
+ data.lastModified = commit.date;
1994
+ }
1995
+ }
1996
+ }
1997
+ const riskFiles = [];
1998
+ for (const [path, data] of fileMap) {
1999
+ const complexity = calculateComplexity(data);
2000
+ const churnRate = calculateChurnRate(data, commits.length);
2001
+ const testCoverage = estimateTestCoverage(path, fileMap);
2002
+ const knowledgeRisk = calculateKnowledgeRisk(data);
2003
+ const riskScore = complexity * 0.3 + churnRate * 100 * 0.25 + (100 - testCoverage) * 0.2 + knowledgeRisk * 0.15 + (data.totalAdded > 500 ? 10 : 0);
2004
+ riskFiles.push({
2005
+ path,
2006
+ riskScore: Math.min(riskScore, 100),
2007
+ complexity,
2008
+ churnRate,
2009
+ testCoverage,
2010
+ knowledgeRisk,
2011
+ primaryAuthor: getMostActiveAuthor(data),
2012
+ lastModified: data.lastModified
2013
+ });
2014
+ }
2015
+ return riskFiles.sort((a, b) => b.riskScore - a.riskScore);
2016
+ }
2017
+ function calculateComplexity(data) {
2018
+ const linesOfCode = data.totalAdded;
2019
+ if (linesOfCode > 1e3) return 90;
2020
+ if (linesOfCode > 500) return 70;
2021
+ if (linesOfCode > 300) return 50;
2022
+ if (linesOfCode > 100) return 30;
2023
+ return 10;
2024
+ }
2025
+ function calculateChurnRate(data, totalCommits) {
2026
+ return data.commits.length / totalCommits;
2027
+ }
2028
+ function estimateTestCoverage(path, fileMap) {
2029
+ const testPatterns = [".test.", ".spec.", "__tests__", "/test/", "/tests/"];
2030
+ const isTestFile = testPatterns.some((pattern) => path.includes(pattern));
2031
+ if (isTestFile) return 100;
2032
+ const baseName = path.replace(/\.(ts|js|tsx|jsx)$/, "");
2033
+ const possibleTestFiles = [
2034
+ `${baseName}.test.ts`,
2035
+ `${baseName}.test.js`,
2036
+ `${baseName}.spec.ts`,
2037
+ `${baseName}.spec.js`
2038
+ ];
2039
+ for (const testFile of possibleTestFiles) {
2040
+ if (fileMap.has(testFile)) {
2041
+ return 80;
2042
+ }
2043
+ }
2044
+ const dir = path.split("/").slice(0, -1).join("/");
2045
+ for (const [filePath] of fileMap) {
2046
+ if (filePath.startsWith(dir) && testPatterns.some((p) => filePath.includes(p))) {
2047
+ return 40;
2048
+ }
2049
+ }
2050
+ return 0;
2051
+ }
2052
+ function calculateKnowledgeRisk(data) {
2053
+ const authorCount = data.authors.size;
2054
+ if (authorCount === 1) return 100;
2055
+ if (authorCount === 2) return 60;
2056
+ if (authorCount === 3) return 30;
2057
+ return 10;
2058
+ }
2059
+ function getMostActiveAuthor(data) {
2060
+ const authorCommits = /* @__PURE__ */ new Map();
2061
+ for (const commit of data.commits) {
2062
+ authorCommits.set(commit.author, (authorCommits.get(commit.author) || 0) + 1);
2063
+ }
2064
+ let maxAuthor = "";
2065
+ let maxCommits = 0;
2066
+ for (const [author, count] of authorCommits) {
2067
+ if (count > maxCommits) {
2068
+ maxCommits = count;
2069
+ maxAuthor = author;
2070
+ }
2071
+ }
2072
+ return maxAuthor;
2073
+ }
2074
+
2075
+ // src/analyzer/tech-debt/duplication.ts
2076
+ import { readFile as readFile3 } from "fs/promises";
2077
+ import { join as join4 } from "path";
2078
+ import { readdirSync, statSync } from "fs";
2079
+ import { createHash } from "crypto";
2080
+ async function detectDuplication(repoPath) {
2081
+ const codeFiles = findCodeFiles(repoPath);
2082
+ const fileHashes = /* @__PURE__ */ new Map();
2083
+ const fileSizes = /* @__PURE__ */ new Map();
2084
+ for (const file of codeFiles.slice(0, 100)) {
2085
+ try {
2086
+ const content = await readFile3(file, "utf-8");
2087
+ const normalized = normalizeCode(content);
2088
+ const hash = simpleHash(normalized);
2089
+ if (!fileHashes.has(hash)) {
2090
+ fileHashes.set(hash, []);
2091
+ }
2092
+ fileHashes.get(hash).push(file.replace(repoPath, "").replace(/^[/\\]/, ""));
2093
+ fileSizes.set(file, content.split("\n").length);
2094
+ } catch {
2095
+ }
2096
+ }
2097
+ const clusters = [];
2098
+ for (const [hash, files] of fileHashes) {
2099
+ if (files.length > 1) {
2100
+ const lines = fileSizes.get(join4(repoPath, files[0])) || 0;
2101
+ clusters.push({
2102
+ files,
2103
+ similarity: 100,
2104
+ lines
2105
+ });
2106
+ }
2107
+ }
2108
+ const fileScores = /* @__PURE__ */ new Map();
2109
+ for (const cluster of clusters) {
2110
+ for (const file of cluster.files) {
2111
+ fileScores.set(file, (fileScores.get(file) || 0) + cluster.lines);
2112
+ }
2113
+ }
2114
+ const sortedScores = Array.from(fileScores.entries()).map(([file, score]) => ({ file, score })).sort((a, b) => b.score - a.score);
2115
+ return {
2116
+ clusters: clusters.sort((a, b) => b.lines - a.lines).slice(0, 10),
2117
+ fileScores: sortedScores.slice(0, 20)
2118
+ };
2119
+ }
2120
+ function findCodeFiles(dir, maxDepth = 3, currentDepth = 0) {
2121
+ if (currentDepth > maxDepth) return [];
2122
+ const files = [];
2123
+ const ignorePatterns = ["node_modules", ".git", "dist", "build", "coverage", ".next"];
2124
+ try {
2125
+ const entries = readdirSync(dir);
2126
+ for (const entry of entries) {
2127
+ if (ignorePatterns.includes(entry)) continue;
2128
+ const fullPath = join4(dir, entry);
2129
+ try {
2130
+ const stat2 = statSync(fullPath);
2131
+ if (stat2.isDirectory()) {
2132
+ files.push(...findCodeFiles(fullPath, maxDepth, currentDepth + 1));
2133
+ } else if (stat2.isFile() && isCodeFile(entry)) {
2134
+ files.push(fullPath);
2135
+ }
2136
+ } catch {
2137
+ }
2138
+ }
2139
+ } catch {
2140
+ }
2141
+ return files;
2142
+ }
2143
+ function isCodeFile(filename) {
2144
+ const codeExtensions = [".ts", ".js", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c"];
2145
+ return codeExtensions.some((ext) => filename.endsWith(ext));
2146
+ }
2147
+ function normalizeCode(content) {
2148
+ return content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
2149
+ }
2150
+ function simpleHash(content) {
2151
+ return createHash("md5").update(content).digest("hex");
2152
+ }
2153
+
2154
+ // src/analyzer/tech-debt/prioritizer.ts
2155
+ function prioritizeActions(riskFiles) {
2156
+ const actionItems = riskFiles.map((file) => {
2157
+ const impact = calculateImpact(file);
2158
+ const effort = estimateEffort(file);
2159
+ const priority = file.riskScore * impact / Math.max(effort, 1);
2160
+ return {
2161
+ file: file.path,
2162
+ riskLevel: getRiskLevel(file.riskScore),
2163
+ impact,
2164
+ effort,
2165
+ priority,
2166
+ suggestedAction: generateSuggestion(file),
2167
+ owner: file.primaryAuthor
2168
+ };
2169
+ });
2170
+ return actionItems.sort((a, b) => b.priority - a.priority).slice(0, 10);
2171
+ }
2172
+ function calculateImpact(file) {
2173
+ let impact = 50;
2174
+ if (file.churnRate > 0.5) impact += 20;
2175
+ if (file.knowledgeRisk > 70) impact += 15;
2176
+ if (file.testCoverage < 30) impact += 15;
2177
+ return Math.min(impact, 100);
2178
+ }
2179
+ function estimateEffort(file) {
2180
+ let effort = 1;
2181
+ if (file.complexity > 70) effort += 2;
2182
+ if (file.complexity > 50) effort += 1;
2183
+ if (file.knowledgeRisk > 70) effort += 1;
2184
+ return effort;
2185
+ }
2186
+ function getRiskLevel(score) {
2187
+ if (score >= 80) return "critical";
2188
+ if (score >= 60) return "high";
2189
+ if (score >= 40) return "medium";
2190
+ return "low";
2191
+ }
2192
+ function generateSuggestion(file) {
2193
+ const suggestions = [];
2194
+ if (file.complexity > 70) {
2195
+ suggestions.push("\u62C6\u5206\u4E3A\u591A\u4E2A\u5C0F\u6587\u4EF6");
2196
+ }
2197
+ if (file.testCoverage < 30) {
2198
+ suggestions.push("\u589E\u52A0\u5355\u5143\u6D4B\u8BD5\u8986\u76D6");
2199
+ }
2200
+ if (file.knowledgeRisk > 70) {
2201
+ suggestions.push("\u8FDB\u884C\u77E5\u8BC6\u5206\u4EAB\u548C\u4EE3\u7801\u5BA1\u67E5");
2202
+ }
2203
+ if (file.churnRate > 0.5) {
2204
+ suggestions.push("\u91CD\u6784\u4EE5\u63D0\u9AD8\u7A33\u5B9A\u6027");
2205
+ }
2206
+ if (suggestions.length === 0) {
2207
+ suggestions.push("\u4EE3\u7801\u5BA1\u67E5\u548C\u6587\u6863\u5B8C\u5584");
2208
+ }
2209
+ return suggestions.join("\uFF1B");
2210
+ }
2211
+
2212
+ // src/analyzer/tech-debt/index.ts
2213
+ async function calculateTechDebt(commits, repoPath) {
2214
+ if (commits.length === 0) {
2215
+ return emptyTechDebt();
2216
+ }
2217
+ const riskFiles = calculateRiskScores(commits);
2218
+ const aiDetection = await detectAICode(commits, repoPath);
2219
+ const duplication = await detectDuplication(repoPath);
2220
+ const actionItems = prioritizeActions(riskFiles);
2221
+ const radar = calculateRadarDimensions(riskFiles, aiDetection, duplication);
2222
+ const trends = calculateTrends2(commits);
2223
+ return {
2224
+ radar,
2225
+ highRiskFiles: riskFiles.slice(0, 10),
2226
+ aiDetection,
2227
+ duplication,
2228
+ trends,
2229
+ actionItems
2230
+ };
2231
+ }
2232
+ function calculateRadarDimensions(riskFiles, aiDetection, duplication) {
2233
+ const avgComplexity = riskFiles.length > 0 ? riskFiles.reduce((sum, f) => sum + f.complexity, 0) / riskFiles.length : 0;
2234
+ const avgDuplication = duplication.fileScores.length > 0 ? duplication.fileScores.reduce((sum, file) => sum + file.score, 0) / duplication.fileScores.length : 0;
2235
+ const avgTestCoverage = riskFiles.length > 0 ? riskFiles.reduce((sum, f) => sum + f.testCoverage, 0) / riskFiles.length : 100;
2236
+ const avgKnowledgeRisk = riskFiles.length > 0 ? riskFiles.reduce((sum, f) => sum + f.knowledgeRisk, 0) / riskFiles.length : 0;
2237
+ const avgChurnRate = riskFiles.length > 0 ? riskFiles.reduce((sum, f) => sum + f.churnRate, 0) / riskFiles.length : 0;
2238
+ return [
2239
+ {
2240
+ dimension: "Complexity",
2241
+ score: Math.min(avgComplexity, 100),
2242
+ riskLevel: avgComplexity > 70 ? "high" : avgComplexity > 40 ? "medium" : "low",
2243
+ description: "\u4EE3\u7801\u590D\u6742\u5EA6",
2244
+ affectedFiles: riskFiles.filter((f) => f.complexity > 70).length
2245
+ },
2246
+ {
2247
+ dimension: "Duplication",
2248
+ score: Math.min(avgDuplication / 10, 100),
2249
+ riskLevel: avgDuplication > 700 ? "high" : avgDuplication > 400 ? "medium" : "low",
2250
+ description: "\u4EE3\u7801\u91CD\u590D\u5EA6",
2251
+ affectedFiles: duplication.fileScores.filter((file) => file.score > 100).length
2252
+ },
2253
+ {
2254
+ dimension: "Test Coverage",
2255
+ score: 100 - avgTestCoverage,
2256
+ riskLevel: avgTestCoverage < 30 ? "high" : avgTestCoverage < 60 ? "medium" : "low",
2257
+ description: "\u6D4B\u8BD5\u8986\u76D6\u7387",
2258
+ affectedFiles: riskFiles.filter((f) => f.testCoverage < 30).length
2259
+ },
2260
+ {
2261
+ dimension: "Documentation",
2262
+ score: aiDetection.suspiciousFiles.filter((file) => file.reason === "excessive-comments").length * 10,
2263
+ riskLevel: aiDetection.suspiciousFiles.length > 10 ? "high" : aiDetection.suspiciousFiles.length > 5 ? "medium" : "low",
2264
+ description: "\u6587\u6863\u5B8C\u6574\u6027",
2265
+ affectedFiles: aiDetection.suspiciousFiles.length
2266
+ },
2267
+ {
2268
+ dimension: "Stability",
2269
+ score: avgChurnRate * 100,
2270
+ riskLevel: avgChurnRate > 0.7 ? "high" : avgChurnRate > 0.4 ? "medium" : "low",
2271
+ description: "\u4EE3\u7801\u7A33\u5B9A\u6027",
2272
+ affectedFiles: riskFiles.filter((f) => f.churnRate > 0.7).length
2273
+ },
2274
+ {
2275
+ dimension: "Knowledge Risk",
2276
+ score: avgKnowledgeRisk,
2277
+ riskLevel: avgKnowledgeRisk > 70 ? "high" : avgKnowledgeRisk > 40 ? "medium" : "low",
2278
+ description: "\u77E5\u8BC6\u96C6\u4E2D\u5EA6",
2279
+ affectedFiles: riskFiles.filter((f) => f.knowledgeRisk > 70).length
2280
+ }
2281
+ ];
2282
+ }
2283
+ function calculateTrends2(commits) {
2284
+ const sorted = [...commits].sort((a, b) => a.date.getTime() - b.date.getTime());
2285
+ const trends = [];
2286
+ const interval = Math.max(1, Math.floor(sorted.length / 20));
2287
+ for (let i = 0; i < sorted.length; i += interval) {
2288
+ const chunk = sorted.slice(Math.max(0, i - interval), i + 1);
2289
+ const debt = chunk.reduce((sum, c) => {
2290
+ const largeFiles = c.files.filter((f) => f.added > 100).length;
2291
+ return sum + largeFiles;
2292
+ }, 0);
2293
+ trends.push({
2294
+ date: sorted[i].date.toISOString().split("T")[0],
2295
+ debt
2296
+ });
2297
+ }
2298
+ return trends;
2299
+ }
2300
+ function emptyTechDebt() {
2301
+ return {
2302
+ radar: [],
2303
+ highRiskFiles: [],
2304
+ aiDetection: { suspiciousFiles: [], totalSuspicious: 0 },
2305
+ duplication: { clusters: [], fileScores: [] },
2306
+ trends: [],
2307
+ actionItems: []
2308
+ };
2309
+ }
2310
+
2311
+ // src/analyzer/engineering-metrics.ts
2312
+ import { execFileSync as execFileSync2 } from "child_process";
2313
+ var CONVENTIONAL_RE = /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([^)]+\))?!?:\s+\S/i;
2314
+ var SCOPED_CONVENTIONAL_RE = /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)\([^)]+\)!?:\s+\S/i;
2315
+ var FIX_RE = /^fix(\([^)]+\))?!?:\s+\S/i;
2316
+ var REVIEW_TRAILER_RE = /^(Co-authored-by|Signed-off-by):\s*(.*?)\s*<([^<>]+)>$/gim;
2317
+ var DEFAULT_OWNERSHIP_FILE_LIMIT = 200;
2318
+ var OWNERSHIP_EXTENSIONS = /* @__PURE__ */ new Set([
2319
+ ".c",
2320
+ ".cpp",
2321
+ ".css",
2322
+ ".go",
2323
+ ".h",
2324
+ ".html",
2325
+ ".java",
2326
+ ".js",
2327
+ ".jsx",
2328
+ ".md",
2329
+ ".py",
2330
+ ".rs",
2331
+ ".scss",
2332
+ ".ts",
2333
+ ".tsx",
2334
+ ".vue"
2335
+ ]);
2336
+ function calculateEngineeringMetrics(commits, repoPath) {
2337
+ return {
2338
+ ...repoPath && {
2339
+ codeOwnership: calculateCodeOwnership(repoPath, {
2340
+ candidateFiles: selectOwnershipCandidates(commits)
2341
+ })
2342
+ },
2343
+ bugFixHotFiles: calculateBugFixHotFiles(commits),
2344
+ reviewQuality: calculateReviewQuality(commits),
2345
+ commitQuality: calculateCommitQuality(commits),
2346
+ changeMix: calculateChangeMix(commits)
2347
+ };
2348
+ }
2349
+ function calculateCodeOwnership(repoPath, options = {}) {
2350
+ const trackedFiles = listTrackedFiles(repoPath);
2351
+ const trackedSet = new Set(trackedFiles);
2352
+ const maxFiles = options.maxFiles ?? DEFAULT_OWNERSHIP_FILE_LIMIT;
2353
+ const candidates = options.candidateFiles ? options.candidateFiles.filter((file) => trackedSet.has(file)) : trackedFiles;
2354
+ const filesToBlame = candidates.filter(isOwnershipTargetFile).slice(0, maxFiles);
2355
+ const files = [];
2356
+ for (const filePath of filesToBlame) {
2357
+ const ownership = calculateFileOwnership(repoPath, filePath);
2358
+ if (ownership) {
2359
+ files.push(ownership);
2360
+ }
2361
+ }
2362
+ files.sort((a, b) => {
2363
+ if (b.ownerLines !== a.ownerLines) return b.ownerLines - a.ownerLines;
2364
+ return b.ownershipRatio - a.ownershipRatio;
2365
+ });
2366
+ return {
2367
+ totalFiles: files.length,
2368
+ files
2369
+ };
2370
+ }
2371
+ function calculateBugFixHotFiles(commits) {
2372
+ const hotFileMap = /* @__PURE__ */ new Map();
2373
+ let fixCommitCount = 0;
2374
+ for (const commit of commits) {
2375
+ if (!FIX_RE.test(commit.message)) continue;
2376
+ fixCommitCount++;
2377
+ for (const file of commit.files) {
2378
+ const entry = hotFileMap.get(file.path) || {
2379
+ fixCount: 0,
2380
+ lastFixDate: commit.date,
2381
+ fixAuthors: /* @__PURE__ */ new Set()
2382
+ };
2383
+ entry.fixCount++;
2384
+ entry.fixAuthors.add(commit.author);
2385
+ if (commit.date > entry.lastFixDate) {
2386
+ entry.lastFixDate = commit.date;
2387
+ }
2388
+ hotFileMap.set(file.path, entry);
2389
+ }
2390
+ }
2391
+ const hotFiles = Array.from(hotFileMap.entries()).map(([path, data]) => ({
2392
+ path,
2393
+ fixCount: data.fixCount,
2394
+ lastFixDate: data.lastFixDate,
2395
+ fixAuthors: Array.from(data.fixAuthors)
2396
+ })).sort((a, b) => {
2397
+ if (b.fixCount !== a.fixCount) return b.fixCount - a.fixCount;
2398
+ return b.lastFixDate.getTime() - a.lastFixDate.getTime();
2399
+ }).slice(0, 20);
2400
+ return { fixCommitCount, hotFiles };
2401
+ }
2402
+ function calculateReviewQuality(commits) {
2403
+ const reviewerMap = /* @__PURE__ */ new Map();
2404
+ let mergeCommitCount = 0;
2405
+ let reviewedMergeCount = 0;
2406
+ let order = 0;
2407
+ for (const commit of commits) {
2408
+ if (!isMergeCommit(commit)) continue;
2409
+ mergeCommitCount++;
2410
+ const reviewers2 = parseReviewers(commit.body || commit.message);
2411
+ if (reviewers2.length > 0) {
2412
+ reviewedMergeCount++;
2413
+ }
2414
+ for (const reviewer of reviewers2) {
2415
+ const key = reviewer.email.toLowerCase();
2416
+ const existing = reviewerMap.get(key);
2417
+ if (existing) {
2418
+ existing.commits++;
2419
+ } else {
2420
+ reviewerMap.set(key, {
2421
+ ...reviewer,
2422
+ commits: 1,
2423
+ firstSeenIndex: order
2424
+ });
2425
+ order++;
2426
+ }
2427
+ }
2428
+ }
2429
+ const reviewers = Array.from(reviewerMap.values()).sort((a, b) => {
2430
+ if (b.commits !== a.commits) return b.commits - a.commits;
2431
+ return a.firstSeenIndex - b.firstSeenIndex;
2432
+ }).map(({ firstSeenIndex, ...reviewer }) => reviewer);
2433
+ return {
2434
+ mergeCommitCount,
2435
+ reviewedMergeCount,
2436
+ reviewParticipationRate: mergeCommitCount > 0 ? roundRatio(reviewedMergeCount / mergeCommitCount) : 0,
2437
+ reviewers
2438
+ };
2439
+ }
2440
+ function calculateCommitQuality(commits) {
2441
+ if (commits.length === 0) {
2442
+ return {
2443
+ score: 0,
2444
+ conventionalRate: 0,
2445
+ scopeCoverageRate: 0,
2446
+ averageMessageLength: 0,
2447
+ typeDistribution: {}
2448
+ };
2449
+ }
2450
+ let conventionalCount = 0;
2451
+ let scopedCount = 0;
2452
+ let totalLength = 0;
2453
+ const typeDistribution = {};
2454
+ for (const commit of commits) {
2455
+ totalLength += commit.message.length;
2456
+ const conventionalMatch = commit.message.match(CONVENTIONAL_RE);
2457
+ if (conventionalMatch) {
2458
+ conventionalCount++;
2459
+ const type = conventionalMatch[1].toLowerCase();
2460
+ typeDistribution[type] = (typeDistribution[type] || 0) + 1;
2461
+ } else {
2462
+ typeDistribution.other = (typeDistribution.other || 0) + 1;
2463
+ }
2464
+ if (SCOPED_CONVENTIONAL_RE.test(commit.message)) {
2465
+ scopedCount++;
2466
+ }
2467
+ }
2468
+ const averageMessageLength = roundTo(totalLength / commits.length, 1);
2469
+ const conventionalRate = roundRatio(conventionalCount / commits.length);
2470
+ const scopeCoverageRate = roundRatio(scopedCount / commits.length);
2471
+ const messageLengthScore = Math.min(averageMessageLength / 24, 1);
2472
+ const score = Math.round(
2473
+ conventionalRate * 60 + scopeCoverageRate * 25 + messageLengthScore * 15
2474
+ );
2475
+ return {
2476
+ score,
2477
+ conventionalRate,
2478
+ scopeCoverageRate,
2479
+ averageMessageLength,
2480
+ typeDistribution
2481
+ };
2482
+ }
2483
+ function calculateChangeMix(commits) {
2484
+ let createdFiles = 0;
2485
+ let deletedFiles = 0;
2486
+ let modifiedFiles = 0;
2487
+ for (const commit of commits) {
2488
+ for (const file of commit.files) {
2489
+ if (file.status === "added" || file.status === "copied") {
2490
+ createdFiles++;
2491
+ } else if (file.status === "deleted") {
2492
+ deletedFiles++;
2493
+ } else {
2494
+ modifiedFiles++;
2495
+ }
2496
+ }
2497
+ }
2498
+ const total = createdFiles + deletedFiles + modifiedFiles;
2499
+ return {
2500
+ createdFiles,
2501
+ deletedFiles,
2502
+ modifiedFiles,
2503
+ featureRatio: total > 0 ? roundRatio((createdFiles + deletedFiles) / total) : 0,
2504
+ refactorRatio: total > 0 ? roundRatio(modifiedFiles / total) : 0
2505
+ };
2506
+ }
2507
+ function selectOwnershipCandidates(commits) {
2508
+ const fileCounts = /* @__PURE__ */ new Map();
2509
+ for (const commit of commits) {
2510
+ for (const file of commit.files) {
2511
+ fileCounts.set(file.path, (fileCounts.get(file.path) || 0) + 1);
2512
+ }
2513
+ }
2514
+ return Array.from(fileCounts.entries()).sort((a, b) => {
2515
+ if (b[1] !== a[1]) return b[1] - a[1];
2516
+ return a[0].localeCompare(b[0]);
2517
+ }).map(([file]) => file);
2518
+ }
2519
+ function listTrackedFiles(repoPath) {
2520
+ let output = "";
2521
+ try {
2522
+ output = execFileSync2("git", ["ls-files", "-z"], {
2523
+ cwd: repoPath,
2524
+ encoding: "utf-8",
2525
+ maxBuffer: 100 * 1024 * 1024,
2526
+ stdio: ["pipe", "pipe", "ignore"]
2527
+ });
2528
+ } catch {
2529
+ return [];
2530
+ }
2531
+ return output.split("\0").filter((path) => path && !isIgnoredOwnershipFile(path));
2532
+ }
2533
+ function calculateFileOwnership(repoPath, filePath) {
2534
+ let output = "";
2535
+ try {
2536
+ output = execFileSync2("git", ["blame", "HEAD", "--line-porcelain", "--", filePath], {
2537
+ cwd: repoPath,
2538
+ encoding: "utf-8",
2539
+ maxBuffer: 100 * 1024 * 1024,
2540
+ stdio: ["pipe", "pipe", "ignore"]
2541
+ });
2542
+ } catch {
2543
+ return null;
2544
+ }
2545
+ const contributors = parseBlameContributors(output);
2546
+ const totalLines = Array.from(contributors.values()).reduce(
2547
+ (sum, contributor) => sum + contributor.lines,
2548
+ 0
2549
+ );
2550
+ if (totalLines === 0) return null;
2551
+ const sortedContributors = Array.from(
2552
+ contributors.values()
2553
+ ).map((contributor) => ({
2554
+ ...contributor,
2555
+ ratio: contributor.lines / totalLines
2556
+ })).sort((a, b) => b.lines - a.lines);
2557
+ const owner = sortedContributors[0];
2558
+ return {
2559
+ path: filePath,
2560
+ ownerName: owner.name,
2561
+ ownerEmail: owner.email,
2562
+ ownerLines: owner.lines,
2563
+ totalLines,
2564
+ ownershipRatio: owner.ratio,
2565
+ contributors: sortedContributors
2566
+ };
2567
+ }
2568
+ function parseBlameContributors(output) {
2569
+ const contributors = /* @__PURE__ */ new Map();
2570
+ let currentName = "";
2571
+ for (const line of output.split("\n")) {
2572
+ if (line.startsWith("author ")) {
2573
+ currentName = line.slice("author ".length);
2574
+ } else if (line.startsWith("author-mail ")) {
2575
+ const email = line.slice("author-mail ".length).replace(/^<|>$/g, "").toLowerCase();
2576
+ const key = email || currentName;
2577
+ const existing = contributors.get(key) || {
2578
+ name: currentName || email,
2579
+ email,
2580
+ lines: 0
2581
+ };
2582
+ existing.lines++;
2583
+ contributors.set(key, existing);
2584
+ }
2585
+ }
2586
+ return contributors;
2587
+ }
2588
+ function parseReviewers(body) {
2589
+ const reviewers = [];
2590
+ const seen = /* @__PURE__ */ new Set();
2591
+ REVIEW_TRAILER_RE.lastIndex = 0;
2592
+ let match = REVIEW_TRAILER_RE.exec(body);
2593
+ while (match) {
2594
+ const name = match[2].trim();
2595
+ const email = match[3].trim().toLowerCase();
2596
+ if (!seen.has(email)) {
2597
+ reviewers.push({ name, email, commits: 0 });
2598
+ seen.add(email);
2599
+ }
2600
+ match = REVIEW_TRAILER_RE.exec(body);
2601
+ }
2602
+ return reviewers;
2603
+ }
2604
+ function isMergeCommit(commit) {
2605
+ return (commit.parentHashes?.length || 0) > 1 || /^Merge\b/i.test(commit.message);
2606
+ }
2607
+ function isIgnoredOwnershipFile(filePath) {
2608
+ return /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$/.test(filePath);
2609
+ }
2610
+ function isOwnershipTargetFile(filePath) {
2611
+ if (isIgnoredOwnershipFile(filePath)) return false;
2612
+ const extension = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")).toLowerCase() : "";
2613
+ return OWNERSHIP_EXTENSIONS.has(extension);
2614
+ }
2615
+ function roundRatio(value) {
2616
+ return roundTo(value, 2);
2617
+ }
2618
+ function roundTo(value, digits) {
2619
+ const factor = 10 ** digits;
2620
+ return Math.round(value * factor) / factor;
2621
+ }
2622
+
1353
2623
  // src/analyzer/index.ts
1354
2624
  async function analyzeRepos(options) {
1355
2625
  const { repos, timeRange, author } = options;
@@ -1368,10 +2638,30 @@ async function analyzeRepos(options) {
1368
2638
  }
1369
2639
  const stats = calculateStats(commits);
1370
2640
  const advancedStats = calculateAdvancedStats(commits);
2641
+ let techDebt;
2642
+ let engineering;
2643
+ if (repos.length === 1) {
2644
+ spinner.text = `\u5206\u6790\u6280\u672F\u503A - ${repo.name}`;
2645
+ techDebt = await calculateTechDebt(commits, repo.path);
2646
+ spinner.text = `\u5206\u6790\u5DE5\u7A0B\u8D28\u91CF - ${repo.name}`;
2647
+ engineering = calculateEngineeringMetrics(commits, repo.path);
2648
+ }
1371
2649
  const fullStats = {
1372
2650
  ...stats,
1373
- ...advancedStats
2651
+ ...advancedStats,
2652
+ ...techDebt && { techDebt },
2653
+ ...engineering && { engineering }
1374
2654
  };
2655
+ fullStats.commitDetails.forEach((commit) => {
2656
+ commit.repoName = repo.name;
2657
+ });
2658
+ fullStats.aiMetrics?.highAICommits.forEach((commit) => {
2659
+ commit.repoName = repo.name;
2660
+ });
2661
+ fullStats.directoryAIStats?.forEach((directory) => {
2662
+ directory.repoName = repo.name;
2663
+ directory.displayPath = repos.length > 1 ? `${repo.name} / ${directory.path}` : directory.path;
2664
+ });
1375
2665
  allStats.push(fullStats);
1376
2666
  } catch (error) {
1377
2667
  spinner.warn(
@@ -1397,11 +2687,32 @@ import ora3 from "ora";
1397
2687
  import open from "open";
1398
2688
 
1399
2689
  // src/reporter/html-builder.ts
1400
- import { readFile as readFile2 } from "fs/promises";
2690
+ import { readFile as readFile4 } from "fs/promises";
1401
2691
  import { resolve, dirname } from "path";
1402
2692
  import { fileURLToPath } from "url";
2693
+ var REPORT_SCRIPT_FILES = [
2694
+ "report-scripts/01-core.html",
2695
+ "report-scripts/00-filter-state.html",
2696
+ "report-scripts/00-advanced-derived.html",
2697
+ "report-scripts/00-report-controls.html",
2698
+ "report-scripts/02-commit-details.html",
2699
+ "report-scripts/03-basic-charts.html",
2700
+ "report-scripts/04-trend-charts.html",
2701
+ "report-scripts/05-tables-team-stability.html",
2702
+ "report-scripts/06-pressure-churn.html",
2703
+ "report-scripts/08-engineering.html",
2704
+ "report-scripts/09-extensions.html",
2705
+ "report-scripts/07-collab-debt-ai.html",
2706
+ "report-scripts/10-runtime.html"
2707
+ ];
2708
+ var REPORT_SECTION_FILES = [
2709
+ "report-sections/01-overview.html",
2710
+ "report-sections/02-advanced.html"
2711
+ ];
1403
2712
  async function buildHtml(stats, options) {
1404
- const template = await loadTemplate();
2713
+ const template = await loadTemplateFile("report.html");
2714
+ const reportSections = await loadTemplateParts(REPORT_SECTION_FILES);
2715
+ const reportScript = await loadReportScript();
1405
2716
  const reportData = {
1406
2717
  stats: serializeStats(stats),
1407
2718
  generatedAt: (/* @__PURE__ */ new Date()).toLocaleString("zh-CN"),
@@ -1412,7 +2723,7 @@ async function buildHtml(stats, options) {
1412
2723
  repos: options.repoNames
1413
2724
  };
1414
2725
  const jsonData = JSON.stringify(reportData).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
1415
- return template.replace("__REPORT_DATA__", jsonData);
2726
+ return template.replace("__REPORT_DATA__", jsonData).replace("__REPORT_SECTIONS__", reportSections).replace("__REPORT_SCRIPT__", reportScript);
1416
2727
  }
1417
2728
  function serializeStats(stats) {
1418
2729
  const serializeAuthorDetail = (author) => ({
@@ -1427,6 +2738,10 @@ function serializeStats(stats) {
1427
2738
  ...a,
1428
2739
  lastActiveDate: a.lastActiveDate.toISOString()
1429
2740
  })),
2741
+ commitDetails: stats.commitDetails.map((commit) => ({
2742
+ ...commit,
2743
+ date: commit.date.toISOString()
2744
+ })),
1430
2745
  contributorChurn: stats.contributorChurn ? {
1431
2746
  ...stats.contributorChurn,
1432
2747
  active: stats.contributorChurn.active.map(serializeAuthorDetail),
@@ -1434,23 +2749,39 @@ function serializeStats(stats) {
1434
2749
  dormant: stats.contributorChurn.dormant.map(serializeAuthorDetail),
1435
2750
  lost: stats.contributorChurn.lost.map(serializeAuthorDetail),
1436
2751
  newJoiners: stats.contributorChurn.newJoiners.map(serializeAuthorDetail)
2752
+ } : void 0,
2753
+ changeSizeDistribution: stats.changeSizeDistribution ? {
2754
+ ...stats.changeSizeDistribution,
2755
+ largeCommits: stats.changeSizeDistribution.largeCommits.map((c) => ({
2756
+ ...c,
2757
+ date: c.date.toISOString()
2758
+ }))
1437
2759
  } : void 0
1438
2760
  };
1439
2761
  }
1440
- async function loadTemplate() {
2762
+ async function loadReportScript() {
2763
+ return loadTemplateParts(REPORT_SCRIPT_FILES);
2764
+ }
2765
+ async function loadTemplateParts(fileNames) {
2766
+ const parts = await Promise.all(
2767
+ fileNames.map((fileName) => loadTemplateFile(fileName))
2768
+ );
2769
+ return parts.join("\n");
2770
+ }
2771
+ async function loadTemplateFile(fileName) {
1441
2772
  const currentDir = dirname(fileURLToPath(import.meta.url));
1442
2773
  const possiblePaths = [
1443
- resolve(currentDir, "../templates/report.html"),
1444
- resolve(currentDir, "../../templates/report.html"),
1445
- resolve(currentDir, "../../../templates/report.html")
2774
+ resolve(currentDir, "../templates", fileName),
2775
+ resolve(currentDir, "../../templates", fileName),
2776
+ resolve(currentDir, "../../../templates", fileName)
1446
2777
  ];
1447
2778
  for (const templatePath of possiblePaths) {
1448
2779
  try {
1449
- return await readFile2(templatePath, "utf-8");
2780
+ return await readFile4(templatePath, "utf-8");
1450
2781
  } catch {
1451
2782
  }
1452
2783
  }
1453
- throw new Error("\u65E0\u6CD5\u627E\u5230 HTML \u6A21\u677F\u6587\u4EF6");
2784
+ throw new Error(`\u65E0\u6CD5\u627E\u5230 HTML \u6A21\u677F\u6587\u4EF6: ${fileName}`);
1454
2785
  }
1455
2786
 
1456
2787
  // src/reporter/index.ts
@@ -1523,7 +2854,7 @@ function resolveTimeRange(opts) {
1523
2854
 
1524
2855
  // src/cli/index.ts
1525
2856
  var program = new Command();
1526
- program.name("commit-report").description("Git \u63D0\u4EA4\u7EDF\u8BA1\u5DE5\u5177\uFF0C\u751F\u6210\u53EF\u89C6\u5316 HTML \u62A5\u544A").version("1.0.0").argument("[directory]", "\u8981\u626B\u63CF\u7684\u76EE\u5F55\u8DEF\u5F84", process.cwd()).option("-p, --period <period>", "\u65F6\u95F4\u9884\u8BBE (7d/1m/3m/6m/1y/all)", "all").option("-f, --from <date>", "\u8D77\u59CB\u65E5\u671F (YYYY-MM-DD)").option("-t, --to <date>", "\u7ED3\u675F\u65E5\u671F (YYYY-MM-DD)").option("-a, --author <name>", "\u8FC7\u6EE4\u4F5C\u8005").option("-o, --output <file>", "\u8F93\u51FA\u6587\u4EF6\u540D", "commit-report.html").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").option("-d, --depth <number>", "\u6700\u5927\u626B\u63CF\u6DF1\u5EA6", "20").action(async (directory, opts) => {
2857
+ program.name("commit-report").description("Git \u63D0\u4EA4\u7EDF\u8BA1\u5DE5\u5177\uFF0C\u751F\u6210\u53EF\u89C6\u5316 HTML \u62A5\u544A").version("1.0.1").argument("[directory]", "\u8981\u626B\u63CF\u7684\u76EE\u5F55\u8DEF\u5F84", process.cwd()).option("-p, --period <period>", "\u65F6\u95F4\u9884\u8BBE (7d/1m/3m/6m/1y/all)", "all").option("-f, --from <date>", "\u8D77\u59CB\u65E5\u671F (YYYY-MM-DD)").option("-t, --to <date>", "\u7ED3\u675F\u65E5\u671F (YYYY-MM-DD)").option("-a, --author <name>", "\u8FC7\u6EE4\u4F5C\u8005").option("-o, --output <file>", "\u8F93\u51FA\u6587\u4EF6\u540D", "commit-report.html").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").option("-d, --depth <number>", "\u6700\u5927\u626B\u63CF\u6DF1\u5EA6", "20").action(async (directory, opts) => {
1527
2858
  try {
1528
2859
  await run(directory, opts);
1529
2860
  } catch (error) {
@@ -1593,9 +2924,9 @@ async function run(directory, opts) {
1593
2924
  });
1594
2925
  }
1595
2926
  async function checkGitInstalled() {
1596
- const { execSync: execSync3 } = await import("child_process");
2927
+ const { execSync: execSync2 } = await import("child_process");
1597
2928
  try {
1598
- execSync3("git --version", { stdio: "ignore" });
2929
+ execSync2("git --version", { stdio: "ignore" });
1599
2930
  } catch {
1600
2931
  console.error(chalk3.red("\u8BF7\u5148\u5B89\u88C5 Git"));
1601
2932
  process.exit(1);