commit-report 1.0.0 → 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,357 +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 dailyCounts = /* @__PURE__ */ new Map();
204
- for (const commit of sorted) {
205
- const hour = commit.date.getHours();
206
- hourlyDistribution[hour]++;
207
- const dateKey = formatDateKey(commit.date);
208
- dailyHeatmap[dateKey] = (dailyHeatmap[dateKey] || 0) + 1;
209
- dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
210
- const authorKey = commit.email.toLowerCase();
211
- let authorStat = authorMap.get(authorKey);
212
- if (!authorStat) {
213
- authorStat = {
214
- name: commit.author,
215
- email: commit.email,
216
- commits: 0,
217
- linesAdded: 0,
218
- linesDeleted: 0,
219
- lastActiveDate: commit.date
220
- };
221
- 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
+ }
222
286
  }
223
- authorStat.commits++;
224
- authorStat.lastActiveDate = commit.date;
225
287
  for (const file of commit.files) {
226
- totalLinesAdded += file.added;
227
- totalLinesDeleted += file.deleted;
228
- allFilePaths.add(file.path);
229
- authorStat.linesAdded += file.added;
230
- authorStat.linesDeleted += file.deleted;
231
- const ext = extname(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
232
- let ftStat = fileTypeMap.get(ext);
233
- if (!ftStat) {
234
- ftStat = { extension: ext, added: 0, deleted: 0, fileCount: 0 };
235
- fileTypeMap.set(ext, ftStat);
236
- }
237
- ftStat.added += file.added;
238
- ftStat.deleted += file.deleted;
239
- const topDir = getTopDirectory(file.path);
240
- let dirStat = directoryMap.get(topDir);
241
- if (!dirStat) {
242
- dirStat = { path: topDir, commits: 0, linesChanged: 0 };
243
- directoryMap.set(topDir, dirStat);
244
- 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
+ });
245
295
  }
246
- dirStat.linesChanged += file.added + file.deleted;
247
- directoryCommitSet.get(topDir).add(commit.hash);
248
296
  }
249
297
  }
250
- for (const [dir, commitSet] of directoryCommitSet) {
251
- const dirStat = directoryMap.get(dir);
252
- if (dirStat) {
253
- 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 {
254
315
  }
255
316
  }
256
- const fileCountByExt = /* @__PURE__ */ new Map();
257
- for (const filePath of allFilePaths) {
258
- const ext = extname(filePath).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
259
- if (!fileCountByExt.has(ext)) {
260
- 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++;
261
380
  }
262
- fileCountByExt.get(ext).add(filePath);
263
381
  }
264
- for (const [ext, files] of fileCountByExt) {
265
- const ftStat = fileTypeMap.get(ext);
266
- if (ftStat) {
267
- ftStat.fileCount = files.size;
268
- }
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
+ };
269
395
  }
270
- let busiestDay = { date: "", count: 0 };
271
- for (const [date, count] of dailyCounts) {
272
- if (count > busiestDay.count) {
273
- 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
+ });
423
+ }
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
473
+ });
274
474
  }
275
475
  }
276
- const authors = Array.from(authorMap.values()).sort(
277
- (a, b) => b.commits - a.commits
278
- );
279
- const fileTypes = Array.from(fileTypeMap.values()).sort(
280
- (a, b) => b.added + b.deleted - (a.added + a.deleted)
281
- );
282
- const directories = Array.from(directoryMap.values()).sort((a, b) => b.linesChanged - a.linesChanged).slice(0, 10);
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));
283
497
  return {
284
- totalCommits: sorted.length,
285
- linesAdded: totalLinesAdded,
286
- linesDeleted: totalLinesDeleted,
287
- filesChanged: allFilePaths.size,
288
- firstCommitDate: sorted[0].date,
289
- lastCommitDate: sorted[sorted.length - 1].date,
290
- busiestDay,
291
- authors,
292
- fileTypes,
293
- directories,
294
- hourlyDistribution,
295
- dailyHeatmap,
296
- quality: calculateQualityMetrics(sorted),
297
- timePatterns: calculateTimePatterns(sorted),
298
- trends: calculateTrends(sorted),
299
- collaboration: calculateCollaboration(sorted),
300
- messageStats: calculateMessageStats(sorted),
301
- authorFileTypeContributions: calculateAuthorFileTypeContributions(sorted)
498
+ aiMetrics,
499
+ authorAIStats: authorAIStats.sort((a, b) => b.aiPercentage - a.aiPercentage),
500
+ directoryAIStats: directoryAIStats.sort((a, b) => b.aiPercentage - a.aiPercentage),
501
+ aiTrends
302
502
  };
303
503
  }
304
- function mergeStats(statsList) {
305
- if (statsList.length === 0) return emptyStats();
306
- if (statsList.length === 1) return statsList[0];
307
- const merged = emptyStats();
308
- for (const stats of statsList) {
309
- merged.totalCommits += stats.totalCommits;
310
- merged.linesAdded += stats.linesAdded;
311
- merged.linesDeleted += stats.linesDeleted;
312
- merged.filesChanged += stats.filesChanged;
313
- if (!merged.firstCommitDate || stats.firstCommitDate < merged.firstCommitDate) {
314
- merged.firstCommitDate = stats.firstCommitDate;
315
- }
316
- if (!merged.lastCommitDate || stats.lastCommitDate > merged.lastCommitDate) {
317
- merged.lastCommitDate = stats.lastCommitDate;
318
- }
319
- for (let i = 0; i < 24; i++) {
320
- merged.hourlyDistribution[i] += stats.hourlyDistribution[i];
321
- }
322
- for (const [date, count] of Object.entries(stats.dailyHeatmap)) {
323
- merged.dailyHeatmap[date] = (merged.dailyHeatmap[date] || 0) + count;
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) {
533
+ return {
534
+ buckets: emptyBuckets(),
535
+ avgChangeSize: 0,
536
+ medianChangeSize: 0,
537
+ p95ChangeSize: 0,
538
+ largeCommits: []
539
+ };
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);
577
+ return {
578
+ buckets,
579
+ avgChangeSize,
580
+ medianChangeSize,
581
+ p95ChangeSize,
582
+ largeCommits
583
+ };
584
+ }
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);
324
598
  }
325
- for (const author of stats.authors) {
326
- const existing = merged.authors.find(
327
- (a) => a.email.toLowerCase() === author.email.toLowerCase()
328
- );
329
- if (existing) {
330
- existing.commits += author.commits;
331
- existing.linesAdded += author.linesAdded;
332
- existing.linesDeleted += author.linesDeleted;
333
- if (author.lastActiveDate > existing.lastActiveDate) {
334
- existing.lastActiveDate = author.lastActiveDate;
335
- }
336
- } else {
337
- 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);
338
605
  }
339
606
  }
340
- for (const ft of stats.fileTypes) {
341
- const existing = merged.fileTypes.find(
342
- (f) => f.extension === ft.extension
343
- );
344
- if (existing) {
345
- existing.added += ft.added;
346
- existing.deleted += ft.deleted;
347
- 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 });
348
620
  } else {
349
- 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 });
350
623
  }
351
624
  }
352
- for (const dir of stats.directories) {
353
- const existing = merged.directories.find((d) => d.path === dir.path);
354
- if (existing) {
355
- existing.commits += dir.commits;
356
- existing.linesChanged += dir.linesChanged;
357
- } else {
358
- merged.directories.push({ ...dir });
359
- }
360
- }
361
- merged.quality.avgFilesPerCommit += stats.quality.avgFilesPerCommit;
362
- merged.quality.avgLinesPerCommit += stats.quality.avgLinesPerCommit;
363
- merged.quality.churnRate += stats.quality.churnRate;
364
- for (const hf of stats.quality.hotFiles) {
365
- const existing = merged.quality.hotFiles.find((h) => h.path === hf.path);
366
- if (existing) {
367
- existing.modifyCount += hf.modifyCount;
368
- for (const author of hf.authors) {
369
- if (!existing.authors.includes(author)) {
370
- existing.authors.push(author);
371
- }
372
- }
373
- } else {
374
- merged.quality.hotFiles.push({ ...hf, authors: [...hf.authors] });
375
- }
376
- }
377
- for (let i = 0; i < 7; i++) {
378
- merged.timePatterns.weekdayDistribution[i] += stats.timePatterns.weekdayDistribution[i];
379
- }
380
- for (const wp of stats.trends.weeklyTrend) {
381
- const existing = merged.trends.weeklyTrend.find((w) => w.week === wp.week);
382
- if (existing) {
383
- existing.commits += wp.commits;
384
- existing.linesAdded += wp.linesAdded;
385
- existing.linesDeleted += wp.linesDeleted;
386
- } else {
387
- merged.trends.weeklyTrend.push({ ...wp });
388
- }
389
- }
390
- for (const cp of stats.trends.cumulativeLines) {
391
- const existing = merged.trends.cumulativeLines.find(
392
- (c) => c.date === cp.date
393
- );
394
- if (existing) {
395
- existing.netLines += cp.netLines;
396
- } else {
397
- merged.trends.cumulativeLines.push({ ...cp });
398
- }
399
- }
400
- for (const sf of stats.collaboration.soloFiles) {
401
- const existing = merged.collaboration.soloFiles.find(
402
- (s) => s.path === sf.path
403
- );
404
- if (existing) {
405
- existing.commits += sf.commits;
406
- } else {
407
- merged.collaboration.soloFiles.push({ ...sf });
408
- }
409
- }
410
- for (const ch of stats.collaboration.collaborationHotspots) {
411
- const existing = merged.collaboration.collaborationHotspots.find(
412
- (c) => c.path === ch.path
413
- );
414
- if (existing) {
415
- existing.totalCommits += ch.totalCommits;
416
- existing.authorCount = Math.max(existing.authorCount, ch.authorCount);
417
- } else {
418
- merged.collaboration.collaborationHotspots.push({ ...ch });
419
- }
420
- }
421
- for (const [type, count] of Object.entries(stats.messageStats.typeDistribution)) {
422
- merged.messageStats.typeDistribution[type] = (merged.messageStats.typeDistribution[type] || 0) + count;
423
- }
424
- merged.messageStats.avgMessageLength += stats.messageStats.avgMessageLength;
425
- }
426
- let busiestDay = { date: "", count: 0 };
427
- for (const [date, count] of Object.entries(merged.dailyHeatmap)) {
428
- if (count > busiestDay.count) {
429
- busiestDay = { date, count };
430
- }
431
- }
432
- merged.busiestDay = busiestDay;
433
- merged.authors.sort((a, b) => b.commits - a.commits);
434
- merged.fileTypes.sort(
435
- (a, b) => b.added + b.deleted - (a.added + a.deleted)
436
- );
437
- merged.directories.sort((a, b) => b.linesChanged - a.linesChanged);
438
- merged.directories = merged.directories.slice(0, 10);
439
- const repoCount = statsList.length;
440
- merged.quality.avgFilesPerCommit /= repoCount;
441
- merged.quality.avgLinesPerCommit /= repoCount;
442
- merged.quality.churnRate /= repoCount;
443
- merged.quality.hotFiles.sort((a, b) => b.modifyCount - a.modifyCount);
444
- merged.quality.hotFiles = merged.quality.hotFiles.slice(0, 10);
445
- const totalWeekdayCommits = merged.timePatterns.weekdayDistribution.reduce(
446
- (a, b) => a + b,
447
- 0
448
- );
449
- if (totalWeekdayCommits > 0) {
450
- merged.timePatterns.weekendCommits = (merged.timePatterns.weekdayDistribution[5] + merged.timePatterns.weekdayDistribution[6]) / totalWeekdayCommits;
451
- }
452
- merged.trends.weeklyTrend.sort((a, b) => a.week.localeCompare(b.week));
453
- merged.trends.cumulativeLines.sort((a, b) => a.date.localeCompare(b.date));
454
- let cumulative = 0;
455
- for (const point of merged.trends.cumulativeLines) {
456
- cumulative += point.netLines;
457
- point.netLines = cumulative;
458
- }
459
- merged.collaboration.soloFiles.sort((a, b) => b.commits - a.commits);
460
- merged.collaboration.soloFiles = merged.collaboration.soloFiles.slice(0, 10);
461
- merged.collaboration.collaborationHotspots.sort(
462
- (a, b) => b.totalCommits - a.totalCommits
463
- );
464
- merged.collaboration.collaborationHotspots = merged.collaboration.collaborationHotspots.slice(0, 10);
465
- merged.messageStats.avgMessageLength /= repoCount;
466
- const contributionMap = /* @__PURE__ */ new Map();
467
- for (const stats of statsList) {
468
- for (const contrib of stats.authorFileTypeContributions) {
469
- const key = `${contrib.email.toLowerCase()}|||${contrib.extension}`;
470
- const existing = contributionMap.get(key);
471
- if (existing) {
472
- existing.linesAdded += contrib.linesAdded;
473
- existing.linesDeleted += contrib.linesDeleted;
474
- existing.commits += contrib.commits;
475
- existing.fileCount += contrib.fileCount;
476
- } else {
477
- contributionMap.set(key, { ...contrib });
478
- }
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);
479
659
  }
480
660
  }
481
- merged.authorFileTypeContributions = Array.from(contributionMap.values()).sort((a, b) => {
482
- const totalA = a.linesAdded + a.linesDeleted;
483
- const totalB = b.linesAdded + b.linesDeleted;
484
- return totalB - totalA;
485
- }).slice(0, 20);
486
- return merged;
487
- }
488
- 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);
489
687
  return {
490
- totalCommits: 0,
491
- linesAdded: 0,
492
- linesDeleted: 0,
493
- filesChanged: 0,
494
- firstCommitDate: /* @__PURE__ */ new Date(),
495
- lastCommitDate: /* @__PURE__ */ new Date(),
496
- busiestDay: { date: "", count: 0 },
497
- authors: [],
498
- fileTypes: [],
499
- directories: [],
500
- hourlyDistribution: new Array(24).fill(0),
501
- dailyHeatmap: {},
502
- quality: emptyQualityMetrics(),
503
- timePatterns: emptyTimePatterns(),
504
- trends: emptyTrendData(),
505
- collaboration: emptyCollaborationMetrics(),
506
- messageStats: emptyMessageStats(),
507
- authorFileTypeContributions: []
688
+ files: files.slice(0, 20),
689
+ scatter: scatter.slice(0, 500),
690
+ // 限制散点数
691
+ summary: { highAIHighChurn, highAILowChurn, lowAIHighChurn, lowAILowChurn }
508
692
  };
509
693
  }
510
- function getTopDirectory(filePath) {
511
- const parts = filePath.split("/");
512
- 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
+ ];
513
702
  }
514
- function formatDateKey(date) {
515
- const y = date.getFullYear();
516
- const m = String(date.getMonth() + 1).padStart(2, "0");
517
- const d = String(date.getDate()).padStart(2, "0");
518
- 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
+ });
519
729
  }
520
730
  function calculateQualityMetrics(commits) {
521
731
  if (commits.length === 0) {
522
732
  return emptyQualityMetrics();
523
733
  }
524
- const totalFiles = commits.reduce((sum, c) => sum + c.files.length, 0);
734
+ const totalFiles = commits.reduce((sum, commit) => sum + commit.files.length, 0);
525
735
  const avgFilesPerCommit = totalFiles / commits.length;
526
736
  const totalLines = commits.reduce(
527
- (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),
528
738
  0
529
739
  );
530
740
  const avgLinesPerCommit = totalLines / commits.length;
531
741
  const totalAdded = commits.reduce(
532
- (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),
533
743
  0
534
744
  );
535
745
  const totalDeleted = commits.reduce(
536
- (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),
537
747
  0
538
748
  );
539
749
  const churnRate = totalAdded > 0 ? totalDeleted / totalAdded : 0;
@@ -558,10 +768,16 @@ function calculateTimePatterns(commits) {
558
768
  return emptyTimePatterns();
559
769
  }
560
770
  const weekdayDistribution = new Array(7).fill(0);
771
+ const weekdayByAuthor = /* @__PURE__ */ new Map();
561
772
  for (const commit of commits) {
562
773
  const day = commit.date.getDay();
563
774
  const idx = day === 0 ? 6 : day - 1;
564
775
  weekdayDistribution[idx]++;
776
+ if (!weekdayByAuthor.has(idx)) {
777
+ weekdayByAuthor.set(idx, /* @__PURE__ */ new Map());
778
+ }
779
+ const dayAuthors = weekdayByAuthor.get(idx);
780
+ dayAuthors.set(commit.author, (dayAuthors.get(commit.author) || 0) + 1);
565
781
  }
566
782
  const weekendCommits = (weekdayDistribution[5] + weekdayDistribution[6]) / commits.length;
567
783
  const sorted = [...commits].sort(
@@ -573,85 +789,35 @@ function calculateTimePatterns(commits) {
573
789
  }
574
790
  const avgCommitInterval = sorted.length > 1 ? totalInterval / (sorted.length - 1) / 36e5 : 0;
575
791
  const { longestStreak, currentStreak } = calculateStreaks(sorted);
792
+ const weekdayByAuthorArray = Array.from({ length: 7 }, (_, day) => {
793
+ const authorMap = weekdayByAuthor.get(day);
794
+ const authors = {};
795
+ if (authorMap) {
796
+ authorMap.forEach((count, author) => {
797
+ authors[author] = count;
798
+ });
799
+ }
800
+ return {
801
+ count: weekdayDistribution[day],
802
+ authors
803
+ };
804
+ });
576
805
  return {
577
806
  weekdayDistribution,
578
807
  weekendCommits,
579
808
  avgCommitInterval,
580
809
  longestStreak,
581
- currentStreak
810
+ currentStreak,
811
+ weekdayByAuthor: weekdayByAuthorArray
582
812
  };
583
813
  }
584
- function calculateStreaks(sortedCommits) {
585
- if (sortedCommits.length === 0) {
586
- return { longestStreak: 0, currentStreak: 0 };
587
- }
588
- const uniqueDates = /* @__PURE__ */ new Set();
589
- for (const commit of sortedCommits) {
590
- uniqueDates.add(formatDateKey(commit.date));
591
- }
592
- const sortedDates = Array.from(uniqueDates).sort();
593
- if (sortedDates.length === 0) {
594
- return { longestStreak: 0, currentStreak: 0 };
595
- }
596
- let longestStreak = 1;
597
- let currentStreakCount = 1;
598
- let tempStreak = 1;
599
- for (let i = 1; i < sortedDates.length; i++) {
600
- const prevDate = new Date(sortedDates[i - 1]);
601
- const currDate = new Date(sortedDates[i]);
602
- const diffDays = Math.round(
603
- (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
604
- );
605
- if (diffDays === 1) {
606
- tempStreak++;
607
- } else {
608
- tempStreak = 1;
609
- }
610
- longestStreak = Math.max(longestStreak, tempStreak);
611
- }
612
- const today = formatDateKey(/* @__PURE__ */ new Date());
613
- const lastCommitDate = sortedDates[sortedDates.length - 1];
614
- const lastDate = new Date(lastCommitDate);
615
- const todayDate = new Date(today);
616
- const daysSinceLastCommit = Math.round(
617
- (todayDate.getTime() - lastDate.getTime()) / (1e3 * 60 * 60 * 24)
618
- );
619
- if (daysSinceLastCommit <= 1) {
620
- currentStreakCount = 1;
621
- for (let i = sortedDates.length - 2; i >= 0; i--) {
622
- const currDate = new Date(sortedDates[i + 1]);
623
- const prevDate = new Date(sortedDates[i]);
624
- const diffDays = Math.round(
625
- (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
626
- );
627
- if (diffDays === 1) {
628
- currentStreakCount++;
629
- } else {
630
- break;
631
- }
632
- }
633
- } else {
634
- currentStreakCount = 0;
635
- }
636
- return { longestStreak, currentStreak: currentStreakCount };
637
- }
638
- function getWeekKey(date) {
639
- const d = new Date(date);
640
- d.setHours(0, 0, 0, 0);
641
- d.setDate(d.getDate() + 4 - (d.getDay() || 7));
642
- const yearStart = new Date(d.getFullYear(), 0, 1);
643
- const weekNo = Math.ceil(
644
- ((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7
645
- );
646
- return `${d.getFullYear()}-W${String(weekNo).padStart(2, "0")}`;
647
- }
648
814
  function calculateTrends(commits) {
649
815
  if (commits.length === 0) {
650
816
  return emptyTrendData();
651
817
  }
652
818
  const weekMap = /* @__PURE__ */ new Map();
653
819
  for (const commit of commits) {
654
- const week = getWeekKey(commit.date);
820
+ const week = getWeekKey2(commit.date);
655
821
  const entry = weekMap.get(week) || {
656
822
  week,
657
823
  commits: 0,
@@ -671,7 +837,7 @@ function calculateTrends(commits) {
671
837
  const dailyNet = /* @__PURE__ */ new Map();
672
838
  for (const commit of commits) {
673
839
  const dateKey = formatDateKey(commit.date);
674
- 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);
675
841
  dailyNet.set(dateKey, (dailyNet.get(dateKey) || 0) + net);
676
842
  }
677
843
  let cumulative = 0;
@@ -777,53 +943,545 @@ function calculateAuthorFileTypeContributions(commits) {
777
943
  if (!commitCountMap.has(key)) {
778
944
  commitCountMap.set(key, /* @__PURE__ */ new Set());
779
945
  }
780
- 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
+ }
781
1409
  }
782
1410
  }
783
- for (const [key, contribution] of contributionMap) {
784
- contribution.commits = commitCountMap.get(key)?.size || 0;
785
- contribution.fileCount = uniqueFilesMap.get(key)?.size || 0;
786
- }
787
- return Array.from(contributionMap.values()).sort((a, b) => {
1411
+ merged.authorFileTypeContributions = Array.from(contributionMap.values()).sort((a, b) => {
788
1412
  const totalA = a.linesAdded + a.linesDeleted;
789
1413
  const totalB = b.linesAdded + b.linesDeleted;
790
1414
  return totalB - totalA;
791
1415
  }).slice(0, 20);
1416
+ return merged;
792
1417
  }
793
- function emptyQualityMetrics() {
794
- return {
795
- avgFilesPerCommit: 0,
796
- avgLinesPerCommit: 0,
797
- churnRate: 0,
798
- hotFiles: []
799
- };
800
- }
801
- function emptyTimePatterns() {
802
- return {
803
- weekdayDistribution: new Array(7).fill(0),
804
- weekendCommits: 0,
805
- avgCommitInterval: 0,
806
- longestStreak: 0,
807
- currentStreak: 0
808
- };
809
- }
810
- function emptyTrendData() {
811
- return {
812
- weeklyTrend: [],
813
- cumulativeLines: []
814
- };
815
- }
816
- function emptyCollaborationMetrics() {
817
- return {
818
- soloFiles: [],
819
- 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)
820
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));
821
1482
  }
822
- function emptyMessageStats() {
823
- return {
824
- typeDistribution: {},
825
- avgMessageLength: 0
826
- };
1483
+ function calculatePercentage(part, total) {
1484
+ return total > 0 ? part / total * 100 : 0;
827
1485
  }
828
1486
 
829
1487
  // src/analyzer/advanced/team-health.ts
@@ -952,7 +1610,7 @@ function calculateStability(commits) {
952
1610
  const dirStats = /* @__PURE__ */ new Map();
953
1611
  for (const commit of commits) {
954
1612
  for (const file of commit.files) {
955
- const dir = getTopDirectory2(file.path);
1613
+ const dir = getTopDirectory3(file.path);
956
1614
  const stat2 = dirStats.get(dir) || { added: 0, deleted: 0, files: /* @__PURE__ */ new Set() };
957
1615
  stat2.added += file.added;
958
1616
  stat2.deleted += file.deleted;
@@ -983,7 +1641,7 @@ function calculateStability(commits) {
983
1641
  stabilityScore
984
1642
  };
985
1643
  }
986
- function getTopDirectory2(filePath) {
1644
+ function getTopDirectory3(filePath) {
987
1645
  const parts = filePath.split("/");
988
1646
  return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
989
1647
  }
@@ -1310,6 +1968,658 @@ function calculateAdvancedStats(commits) {
1310
1968
  };
1311
1969
  }
1312
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
+
1313
2623
  // src/analyzer/index.ts
1314
2624
  async function analyzeRepos(options) {
1315
2625
  const { repos, timeRange, author } = options;
@@ -1328,10 +2638,30 @@ async function analyzeRepos(options) {
1328
2638
  }
1329
2639
  const stats = calculateStats(commits);
1330
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
+ }
1331
2649
  const fullStats = {
1332
2650
  ...stats,
1333
- ...advancedStats
2651
+ ...advancedStats,
2652
+ ...techDebt && { techDebt },
2653
+ ...engineering && { engineering }
1334
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
+ });
1335
2665
  allStats.push(fullStats);
1336
2666
  } catch (error) {
1337
2667
  spinner.warn(
@@ -1357,11 +2687,32 @@ import ora3 from "ora";
1357
2687
  import open from "open";
1358
2688
 
1359
2689
  // src/reporter/html-builder.ts
1360
- import { readFile as readFile2 } from "fs/promises";
2690
+ import { readFile as readFile4 } from "fs/promises";
1361
2691
  import { resolve, dirname } from "path";
1362
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
+ ];
1363
2712
  async function buildHtml(stats, options) {
1364
- const template = await loadTemplate();
2713
+ const template = await loadTemplateFile("report.html");
2714
+ const reportSections = await loadTemplateParts(REPORT_SECTION_FILES);
2715
+ const reportScript = await loadReportScript();
1365
2716
  const reportData = {
1366
2717
  stats: serializeStats(stats),
1367
2718
  generatedAt: (/* @__PURE__ */ new Date()).toLocaleString("zh-CN"),
@@ -1372,7 +2723,7 @@ async function buildHtml(stats, options) {
1372
2723
  repos: options.repoNames
1373
2724
  };
1374
2725
  const jsonData = JSON.stringify(reportData).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
1375
- return template.replace("__REPORT_DATA__", jsonData);
2726
+ return template.replace("__REPORT_DATA__", jsonData).replace("__REPORT_SECTIONS__", reportSections).replace("__REPORT_SCRIPT__", reportScript);
1376
2727
  }
1377
2728
  function serializeStats(stats) {
1378
2729
  const serializeAuthorDetail = (author) => ({
@@ -1387,6 +2738,10 @@ function serializeStats(stats) {
1387
2738
  ...a,
1388
2739
  lastActiveDate: a.lastActiveDate.toISOString()
1389
2740
  })),
2741
+ commitDetails: stats.commitDetails.map((commit) => ({
2742
+ ...commit,
2743
+ date: commit.date.toISOString()
2744
+ })),
1390
2745
  contributorChurn: stats.contributorChurn ? {
1391
2746
  ...stats.contributorChurn,
1392
2747
  active: stats.contributorChurn.active.map(serializeAuthorDetail),
@@ -1394,23 +2749,39 @@ function serializeStats(stats) {
1394
2749
  dormant: stats.contributorChurn.dormant.map(serializeAuthorDetail),
1395
2750
  lost: stats.contributorChurn.lost.map(serializeAuthorDetail),
1396
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
+ }))
1397
2759
  } : void 0
1398
2760
  };
1399
2761
  }
1400
- 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) {
1401
2772
  const currentDir = dirname(fileURLToPath(import.meta.url));
1402
2773
  const possiblePaths = [
1403
- resolve(currentDir, "../templates/report.html"),
1404
- resolve(currentDir, "../../templates/report.html"),
1405
- resolve(currentDir, "../../../templates/report.html")
2774
+ resolve(currentDir, "../templates", fileName),
2775
+ resolve(currentDir, "../../templates", fileName),
2776
+ resolve(currentDir, "../../../templates", fileName)
1406
2777
  ];
1407
2778
  for (const templatePath of possiblePaths) {
1408
2779
  try {
1409
- return await readFile2(templatePath, "utf-8");
2780
+ return await readFile4(templatePath, "utf-8");
1410
2781
  } catch {
1411
2782
  }
1412
2783
  }
1413
- 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}`);
1414
2785
  }
1415
2786
 
1416
2787
  // src/reporter/index.ts
@@ -1483,7 +2854,7 @@ function resolveTimeRange(opts) {
1483
2854
 
1484
2855
  // src/cli/index.ts
1485
2856
  var program = new Command();
1486
- 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) => {
1487
2858
  try {
1488
2859
  await run(directory, opts);
1489
2860
  } catch (error) {
@@ -1553,9 +2924,9 @@ async function run(directory, opts) {
1553
2924
  });
1554
2925
  }
1555
2926
  async function checkGitInstalled() {
1556
- const { execSync: execSync3 } = await import("child_process");
2927
+ const { execSync: execSync2 } = await import("child_process");
1557
2928
  try {
1558
- execSync3("git --version", { stdio: "ignore" });
2929
+ execSync2("git --version", { stdio: "ignore" });
1559
2930
  } catch {
1560
2931
  console.error(chalk3.red("\u8BF7\u5148\u5B89\u88C5 Git"));
1561
2932
  process.exit(1);