lumencode 0.4.3

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/lib/git.js ADDED
@@ -0,0 +1,1106 @@
1
+ import { execSync, exec as execCb } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { aggregateAttribution, classifyAttribution } from './attribution.js';
5
+
6
+ // ── helpers ──
7
+
8
+ function execAsync(command, options) {
9
+ return new Promise((resolve, reject) => {
10
+ execCb(command, options, (error, stdout) => {
11
+ if (error) reject(error);
12
+ else resolve(stdout);
13
+ });
14
+ });
15
+ }
16
+
17
+ function getGitAuthor(repoPath) {
18
+ try {
19
+ return execSync('git config user.email', { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
20
+ } catch {
21
+ try {
22
+ return execSync('git config user.name', { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ }
28
+
29
+ function emptyResult() {
30
+ return {
31
+ commits: 0,
32
+ filesChanged: 0,
33
+ linesAdded: 0,
34
+ linesDeleted: 0,
35
+ commitsByDate: {},
36
+ linesByDate: {},
37
+ commitList: [],
38
+ };
39
+ }
40
+
41
+ export const COMMIT_SENTINEL = '§§§';
42
+
43
+ // ── Conventional Commit 解析 ──
44
+
45
+ const CONVENTIONAL_TYPES = [
46
+ 'feat', 'fix', 'refactor', 'docs', 'test', 'chore',
47
+ 'perf', 'style', 'ci', 'build', 'revert',
48
+ ];
49
+ const CONVENTIONAL_RE = /^(feat|fix|refactor|docs|test|chore|perf|style|ci|build|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/i;
50
+
51
+ export function parseConventional(subject) {
52
+ if (!subject) return { type: 'other', scope: null, isBreaking: false };
53
+ const m = subject.match(CONVENTIONAL_RE);
54
+ if (m) {
55
+ return {
56
+ type: m[1].toLowerCase(),
57
+ scope: m[2] || null,
58
+ isBreaking: m[3] === '!' || /BREAKING\s+CHANGE/i.test(subject),
59
+ };
60
+ }
61
+ return {
62
+ type: 'other',
63
+ scope: null,
64
+ isBreaking: /BREAKING\s+CHANGE/i.test(subject),
65
+ };
66
+ }
67
+
68
+ // ── AI commit 检测 ──
69
+
70
+ const BODY_END = '@@ENDBODY@@';
71
+
72
+ const DEFAULT_AI_PATTERNS = [
73
+ // Claude
74
+ { re: /Co-Authored-By:\s*Claude/i, signal: 'coAuthor' },
75
+ { re: /Generated\s+with[\s\S]*Claude/i, signal: 'generatedWith' },
76
+ { re: /🤖\s*Generated/i, signal: 'robotEmoji' },
77
+ { re: /Assisted-By:\s*Claude/i, signal: 'assistedBy' },
78
+ // GitHub Copilot
79
+ { re: /Co-Authored-By:\s*Copilot/i, signal: 'coAuthorCopilot' },
80
+ { re: /Co-Authored-By:\s*GitHub Copilot/i, signal: 'coAuthorCopilot' },
81
+ // Cursor
82
+ { re: /Co-Authored-By:\s*Cursor/i, signal: 'coAuthorCursor' },
83
+ // Aider
84
+ { re: /Generated\s+with[\s\S]*Aider/i, signal: 'generatedWithAider' },
85
+ { re: /\(aider\)/i, signal: 'aiderTag' },
86
+ { re: /\[Aider\]/i, signal: 'aiderTag' },
87
+ // Codex
88
+ { re: /Co-Authored-By:\s*Codex/i, signal: 'coAuthorCodex' },
89
+ { re: /Generated\s+with[\s\S]*Codex/i, signal: 'generatedWithCodex' },
90
+ // OpenCode
91
+ { re: /Co-Authored-By:\s*OpenCode/i, signal: 'coAuthorOpencode' },
92
+ // Windsurf
93
+ { re: /Co-Authored-By:\s*Windsurf/i, signal: 'coAuthorWindsurf' },
94
+ // Augment
95
+ { re: /Co-Authored-By:\s*Augment/i, signal: 'coAuthorAugment' },
96
+ // Cline / Roo Code
97
+ { re: /Co-Authored-By:\s*Cline/i, signal: 'coAuthorCline' },
98
+ { re: /Co-Authored-By:\s*Roo\s*Code/i, signal: 'coAuthorRooCode' },
99
+ { re: /\(cline\)/i, signal: 'clineTag' },
100
+ { re: /\[cline\]/i, signal: 'clineTag' },
101
+ // JetBrains AI
102
+ { re: /Co-Authored-By:\s*JetBrains\s*AI/i, signal: 'coAuthorJetbrains' },
103
+ // Generic AI markers
104
+ { re: /\bAI[\s_-]?generated\b/i, signal: 'aiGenerated' },
105
+ { re: /\bgenerated\s+by\s+(AI|Claude|GPT|LLM)\b/i, signal: 'generatedByAI' },
106
+ { re: /\bvia\s+(Claude|GPT|AI|Copilot)\b/i, signal: 'viaAI' },
107
+ { re: /\[AI\]/i, signal: 'aiTag' },
108
+ { re: /\(AI\)/i, signal: 'aiTag' },
109
+ ];
110
+
111
+ // 信号 → 工具归属映射
112
+ const SIGNAL_TO_TOOL = {
113
+ // Claude
114
+ coAuthor: 'claude',
115
+ generatedWith: 'claude',
116
+ robotEmoji: 'claude',
117
+ assistedBy: 'claude',
118
+ authorClaude: 'claude',
119
+ // Copilot
120
+ coAuthorCopilot: 'copilot',
121
+ authorCopilot: 'copilot',
122
+ // Cursor
123
+ coAuthorCursor: 'cursor',
124
+ // Codex
125
+ coAuthorCodex: 'codex',
126
+ generatedWithCodex: 'codex',
127
+ // OpenCode
128
+ coAuthorOpencode: 'opencode',
129
+ // Aider
130
+ generatedWithAider: 'aider',
131
+ aiderTag: 'aider',
132
+ authorAider: 'aider',
133
+ // Windsurf
134
+ coAuthorWindsurf: 'windsurf',
135
+ // Augment
136
+ coAuthorAugment: 'augment',
137
+ // Cline / Roo Code
138
+ coAuthorCline: 'cline',
139
+ coAuthorRooCode: 'roo-code',
140
+ clineTag: 'cline',
141
+ // JetBrains AI
142
+ coAuthorJetbrains: 'jetbrains-ai',
143
+ // Generic AI
144
+ aiGenerated: 'generic-ai',
145
+ generatedByAI: 'generic-ai',
146
+ viaAI: 'generic-ai',
147
+ aiTag: 'generic-ai',
148
+ authorAI: 'generic-ai',
149
+ };
150
+
151
+ const AI_PATTERNS = [...DEFAULT_AI_PATTERNS, ...loadCustomPatterns()];
152
+
153
+ const AI_CONFIDENCE = {
154
+ NONE: 'none',
155
+ LOW: 'low',
156
+ MEDIUM: 'medium',
157
+ HIGH: 'high',
158
+ };
159
+
160
+ function isCountedAIConfidence(confidence) {
161
+ return confidence === AI_CONFIDENCE.HIGH || confidence === AI_CONFIDENCE.MEDIUM;
162
+ }
163
+
164
+ // 从信号列表推导工具归属:优先具体工具,其次 generic-ai
165
+ function resolveToolFromSignals(signals) {
166
+ for (const sig of signals) {
167
+ const tool = SIGNAL_TO_TOOL[sig];
168
+ if (tool && tool !== 'generic-ai') return tool;
169
+ }
170
+ for (const sig of signals) {
171
+ if (SIGNAL_TO_TOOL[sig]) return SIGNAL_TO_TOOL[sig];
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null } = {}) {
177
+ const normalizedSignals = [...new Set(signals)];
178
+ return {
179
+ isAI: isCountedAIConfidence(confidence),
180
+ aiAssisted: confidence !== AI_CONFIDENCE.NONE,
181
+ aiConfidence: confidence,
182
+ signals: normalizedSignals,
183
+ aiSignals: normalizedSignals,
184
+ aiEvidence: normalizedSignals,
185
+ attributionType,
186
+ detectedTool,
187
+ };
188
+ }
189
+
190
+ function pickHigherConfidence(a, b) {
191
+ const order = {
192
+ [AI_CONFIDENCE.NONE]: 0,
193
+ [AI_CONFIDENCE.LOW]: 1,
194
+ [AI_CONFIDENCE.MEDIUM]: 2,
195
+ [AI_CONFIDENCE.HIGH]: 3,
196
+ };
197
+ return (order[b] || 0) > (order[a] || 0) ? b : a;
198
+ }
199
+
200
+ function buildEvidenceDetails({
201
+ matchedFiles = [],
202
+ touchedFiles = [],
203
+ fileOverlapRatio = 0,
204
+ commitFileCount = 0,
205
+ touchedFileCount = 0,
206
+ } = {}) {
207
+ return {
208
+ matchedFiles,
209
+ matchedFileCount: matchedFiles.length,
210
+ touchedFileCount,
211
+ commitFileCount,
212
+ fileOverlapRatio,
213
+ touchedFiles,
214
+ };
215
+ }
216
+
217
+ function loadCustomPatterns() {
218
+ try {
219
+ const configPath = join(process.cwd(), 'ai-patterns.json');
220
+ if (existsSync(configPath)) {
221
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
222
+ return raw
223
+ .filter(p => typeof p.re === 'string' && typeof p.signal === 'string')
224
+ .map(p => ({ re: new RegExp(p.re, p.flags || 'i'), signal: p.signal }));
225
+ }
226
+ } catch { /* ignore */ }
227
+ return [];
228
+ }
229
+
230
+ function loadAttributionOverrides() {
231
+ try {
232
+ const overridePath = join(process.cwd(), '.ccusage', 'attribution-overrides.json');
233
+ if (!existsSync(overridePath)) return { commits: {}, files: {} };
234
+ const raw = JSON.parse(readFileSync(overridePath, 'utf-8'));
235
+ return {
236
+ commits: raw.commits && typeof raw.commits === 'object' ? raw.commits : {},
237
+ files: raw.files && typeof raw.files === 'object' ? raw.files : {},
238
+ };
239
+ } catch {
240
+ return { commits: {}, files: {} };
241
+ }
242
+ }
243
+
244
+ export function detectAICommit(subject = '', author = '', body = '') {
245
+ const haystack = `${subject}\n${body}`;
246
+ const signals = [];
247
+ for (const { re, signal } of AI_PATTERNS) {
248
+ if (re.test(haystack)) signals.push(signal);
249
+ }
250
+ const authorLower = (author || '').toLowerCase();
251
+ if (authorLower.includes('claude') || authorLower.includes('noreply@anthropic')) {
252
+ signals.push('authorClaude');
253
+ }
254
+ if (authorLower.includes('copilot')) {
255
+ signals.push('authorCopilot');
256
+ }
257
+ if (authorLower.includes('github-actions') || authorLower.includes('dependabot')) {
258
+ signals.push('authorBot');
259
+ }
260
+ if (authorLower.includes('aider')) {
261
+ signals.push('authorAider');
262
+ }
263
+ if (authorLower.includes('codeium') || authorLower.includes('tabnine')) {
264
+ signals.push('authorAI');
265
+ }
266
+ if (authorLower.includes('augment')) {
267
+ signals.push('coAuthorAugment');
268
+ }
269
+ if (authorLower.includes('cline')) {
270
+ signals.push('coAuthorCline');
271
+ }
272
+ if (signals.length > 0) {
273
+ const detectedTool = resolveToolFromSignals(signals);
274
+ return createAIAttribution({
275
+ confidence: AI_CONFIDENCE.HIGH,
276
+ signals,
277
+ attributionType: 'explicit',
278
+ detectedTool,
279
+ });
280
+ }
281
+ return createAIAttribution();
282
+ }
283
+
284
+ // ── 聚合函数 ──
285
+
286
+ export function computeAIContribution(commits, toolFilter = null) {
287
+ let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
288
+ let aiCommitLinesAdded = 0, aiCommitLinesDeleted = 0;
289
+ let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
290
+ let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
291
+ let totalLinesAdded = 0, totalLinesDeleted = 0;
292
+ const allCommits = commits || [];
293
+ for (const c of allCommits) {
294
+ totalLinesAdded += c.linesAdded || 0;
295
+ totalLinesDeleted += c.linesDeleted || 0;
296
+ }
297
+ const filteredCommits = toolFilter
298
+ ? allCommits.filter(c => c.attributedTool === toolFilter)
299
+ : allCommits;
300
+ for (const c of filteredCommits) {
301
+ const confidence = c.aiConfidence || (c.isAI ? AI_CONFIDENCE.HIGH : AI_CONFIDENCE.NONE);
302
+ if (confidence === AI_CONFIDENCE.HIGH) highConfidenceCommits++;
303
+ else if (confidence === AI_CONFIDENCE.MEDIUM) mediumConfidenceCommits++;
304
+ else if (confidence === AI_CONFIDENCE.LOW) lowConfidenceCommits++;
305
+
306
+ if (isCountedAIConfidence(confidence)) {
307
+ aiCommits++;
308
+ aiCommitLinesAdded += c.linesAdded || 0;
309
+ aiCommitLinesDeleted += c.linesDeleted || 0;
310
+
311
+ const matchedFiles = new Set((c.aiEvidenceDetails?.matchedFiles || []).map(normalizeCommitFilePath));
312
+ const useMatchedFiles = matchedFiles.size > 0;
313
+ let fileAdded = 0;
314
+ let fileDeleted = 0;
315
+ for (const f of c.files || []) {
316
+ const filePath = normalizeCommitFilePath(f.path);
317
+ if (useMatchedFiles && !matchedFiles.has(filePath)) continue;
318
+ fileAdded += f.added || 0;
319
+ fileDeleted += f.deleted || 0;
320
+ }
321
+ if (!useMatchedFiles && (c.attributionType === 'explicit' || c.attributionType?.startsWith('session_'))) {
322
+ fileAdded = c.linesAdded || 0;
323
+ fileDeleted = c.linesDeleted || 0;
324
+ }
325
+ aiFileLinesAdded += fileAdded;
326
+ aiFileLinesDeleted += fileDeleted;
327
+ }
328
+ }
329
+ aiLinesAdded = aiFileLinesAdded;
330
+ aiLinesDeleted = aiFileLinesDeleted;
331
+ const total = allCommits.length;
332
+ const totalLinesChanged = totalLinesAdded + totalLinesDeleted;
333
+ const aiLinesChanged = aiLinesAdded + aiLinesDeleted;
334
+ return {
335
+ aiCommits,
336
+ nonToolCommits: total - aiCommits,
337
+ humanCommits: total - aiCommits,
338
+ aiCommitRatio: total > 0 ? aiCommits / total : 0,
339
+ aiRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
340
+ toolFilter: toolFilter || null,
341
+ aiLinesAdded,
342
+ aiLinesDeleted,
343
+ aiLinesChanged,
344
+ totalLinesAdded,
345
+ totalLinesDeleted,
346
+ totalLinesChanged,
347
+ aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
348
+ aiCommitLinesAdded,
349
+ aiCommitLinesDeleted,
350
+ aiFileLinesAdded,
351
+ aiFileLinesDeleted,
352
+ highConfidenceCommits,
353
+ mediumConfidenceCommits,
354
+ lowConfidenceCommits,
355
+ };
356
+ }
357
+
358
+ export function computeCommitTypes(commits) {
359
+ const types = {};
360
+ for (const t of CONVENTIONAL_TYPES) types[t] = 0;
361
+ types.other = 0;
362
+ for (const c of commits || []) {
363
+ const t = c.type || 'other';
364
+ types[t] = (types[t] || 0) + 1;
365
+ }
366
+ return types;
367
+ }
368
+
369
+ export function computeFileHotspots(commits, topN = 10) {
370
+ const map = new Map();
371
+ for (const c of commits || []) {
372
+ for (const f of c.files || []) {
373
+ if (!map.has(f.path)) {
374
+ map.set(f.path, { path: f.path, touches: 0, added: 0, deleted: 0 });
375
+ }
376
+ const e = map.get(f.path);
377
+ e.touches++;
378
+ e.added += f.added || 0;
379
+ e.deleted += f.deleted || 0;
380
+ }
381
+ }
382
+ return [...map.values()]
383
+ .sort((a, b) => b.touches - a.touches || (b.added + b.deleted) - (a.added + a.deleted))
384
+ .slice(0, topN);
385
+ }
386
+
387
+ // 哨兵格式(v2 含 body):§§§hash|isoDate|email|subject → body 行 → @@ENDBODY@@ → numstat 行
388
+ // 哨兵格式(v1 无 body):§§§hash|isoDate|email|subject → numstat 行
389
+ // numstat(added\tdeleted\tpath),其中 binary 文件为 -\t-\tpath
390
+ export function parseGitLogOutput(output, repo = '') {
391
+ const result = emptyResult();
392
+ const uniqueFiles = new Set();
393
+ let current = null;
394
+ let inBody = false;
395
+
396
+ const flush = () => {
397
+ if (!current) return;
398
+ const dateKey = current.date.slice(0, 10);
399
+ // 注入 conventional 类型 + AI 信号
400
+ const conv = parseConventional(current.subject);
401
+ const ai = detectAICommit(current.subject, current.author, current.body || '');
402
+ current.type = conv.type;
403
+ current.scope = conv.scope;
404
+ current.isBreaking = conv.isBreaking;
405
+ current.isAI = ai.isAI;
406
+ current.aiAssisted = ai.aiAssisted;
407
+ current.aiConfidence = ai.aiConfidence;
408
+ current.aiSignals = ai.signals;
409
+ current.aiEvidence = ai.aiEvidence;
410
+ current.attributionType = ai.attributionType;
411
+ current.detectedTool = ai.detectedTool || null;
412
+ current.sessionId = null; // 由 finalize 阶段填充
413
+
414
+ result.commits++;
415
+ result.commitsByDate[dateKey] = (result.commitsByDate[dateKey] || 0) + 1;
416
+ if (!result.linesByDate[dateKey]) {
417
+ result.linesByDate[dateKey] = { added: 0, deleted: 0, files: 0 };
418
+ }
419
+ result.linesByDate[dateKey].added += current.linesAdded;
420
+ result.linesByDate[dateKey].deleted += current.linesDeleted;
421
+ result.linesByDate[dateKey].files += current.files.length;
422
+ result.linesAdded += current.linesAdded;
423
+ result.linesDeleted += current.linesDeleted;
424
+ for (const f of current.files) uniqueFiles.add(f.path);
425
+ result.commitList.push(current);
426
+ current = null;
427
+ inBody = false;
428
+ };
429
+
430
+ for (const rawLine of output.split('\n')) {
431
+ const line = rawLine.replace(/\r$/, '');
432
+
433
+ // body 结束标记
434
+ if (line.trim() === BODY_END) {
435
+ inBody = false;
436
+ continue;
437
+ }
438
+
439
+ if (line.startsWith(COMMIT_SENTINEL)) {
440
+ flush();
441
+ const header = line.slice(COMMIT_SENTINEL.length);
442
+ const parts = header.split('|');
443
+ const hash = parts[0] || '';
444
+ const date = (parts[1] || '').slice(0, 19);
445
+ const author = parts[2] || '';
446
+ const subject = parts.slice(3).join('|');
447
+ current = {
448
+ repo,
449
+ hash,
450
+ date,
451
+ author,
452
+ subject,
453
+ body: '',
454
+ linesAdded: 0,
455
+ linesDeleted: 0,
456
+ files: [],
457
+ };
458
+ // v2 格式:body 段会由 BODY_END 标记结束
459
+ // v1 格式:没有 BODY_END,inBody 保持 false,numstat 直接解析
460
+ inBody = false;
461
+ continue;
462
+ }
463
+
464
+ if (!current) continue;
465
+
466
+ // numstat 行
467
+ const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
468
+ if (m) {
469
+ // 如果之前没有见过 BODY_END,说明是 v1 格式(无 body),直接解析 numstat
470
+ // 如果已经过了 BODY_END(inBody=false),也直接解析
471
+ if (!inBody) {
472
+ const added = m[1] === '-' ? 0 : parseInt(m[1], 10);
473
+ const deleted = m[2] === '-' ? 0 : parseInt(m[2], 10);
474
+ const binary = m[1] === '-' && m[2] === '-';
475
+ const path = m[3];
476
+ current.files.push({ path, added, deleted, binary });
477
+ current.linesAdded += added;
478
+ current.linesDeleted += deleted;
479
+ } else {
480
+ // numstat 格式的行出现在 body 段内(不太可能,但安全处理)
481
+ if (line.trim()) {
482
+ current.body += (current.body ? '\n' : '') + line;
483
+ }
484
+ }
485
+ } else if (inBody) {
486
+ // body 内容
487
+ if (line.trim()) {
488
+ current.body += (current.body ? '\n' : '') + line;
489
+ }
490
+ } else {
491
+ // v2 格式:非 numstat、非哨兵、非 BODY_END → 进入 body 段
492
+ if (line.trim()) {
493
+ current.body += (current.body ? '\n' : '') + line;
494
+ inBody = true;
495
+ }
496
+ }
497
+ }
498
+ flush();
499
+
500
+ result.filesChanged = uniqueFiles.size;
501
+ return result;
502
+ }
503
+
504
+ function buildGitArgs(since, until, author) {
505
+ const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
506
+ const authorArg = author ? ` --author="${author}"` : '';
507
+ // 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
508
+ const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
509
+ return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${sinceFull}" --until="${until}"${authorArg}`;
510
+ }
511
+
512
+ function mergeGitStats(target, source) {
513
+ target.commits += source.commits;
514
+ target.linesAdded += source.linesAdded;
515
+ target.linesDeleted += source.linesDeleted;
516
+ for (const [d, c] of Object.entries(source.commitsByDate)) {
517
+ target.commitsByDate[d] = (target.commitsByDate[d] || 0) + c;
518
+ }
519
+ if (source.linesByDate) {
520
+ for (const [d, v] of Object.entries(source.linesByDate)) {
521
+ if (!target.linesByDate[d]) target.linesByDate[d] = { added: 0, deleted: 0, files: 0 };
522
+ target.linesByDate[d].added += v.added;
523
+ target.linesByDate[d].deleted += v.deleted;
524
+ target.linesByDate[d].files += v.files;
525
+ }
526
+ }
527
+ if (source.commitList?.length) {
528
+ target.commitList.push(...source.commitList);
529
+ }
530
+ // filesChanged 在 merge 完后由 finalize 重新计算(跨 repo 去重)
531
+ }
532
+
533
+ function recomputeFilesChanged(stats) {
534
+ const set = new Set();
535
+ for (const c of stats.commitList || []) {
536
+ for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
537
+ }
538
+ stats.filesChanged = set.size;
539
+ }
540
+
541
+ // ── async versions (server) with cache ──
542
+
543
+ const gitCache = new Map();
544
+ const CACHE_VERSION = 'v3';
545
+
546
+ async function getGitStatsAsync(repoPath, since, until, author = null) {
547
+ const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
548
+ const cached = gitCache.get(cacheKey);
549
+ if (cached && Date.now() - cached.ts < 60_000) return cached.stats;
550
+
551
+ try {
552
+ await execAsync('git rev-parse --git-dir', { cwd: repoPath });
553
+ } catch {
554
+ return emptyResult();
555
+ }
556
+
557
+ try {
558
+ const output = await execAsync(`git log ${buildGitArgs(since, until, author)}`, {
559
+ cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
560
+ });
561
+ const stats = parseGitLogOutput(output, repoPath);
562
+ gitCache.set(cacheKey, { stats, ts: Date.now() });
563
+ return stats;
564
+ } catch {
565
+ return emptyResult();
566
+ }
567
+ }
568
+
569
+ export async function getGitStatsForMultipleReposAsync(repos, since, until) {
570
+ const results = await Promise.all(
571
+ repos.map(repo => getGitStatsAsync(repo, since, until, getGitAuthor(repo)))
572
+ );
573
+ const merged = emptyResult();
574
+ for (const stats of results) mergeGitStats(merged, stats);
575
+ recomputeFilesChanged(merged);
576
+ return merged;
577
+ }
578
+
579
+ export function invalidateGitCache() {
580
+ gitCache.clear();
581
+ }
582
+
583
+ // ── Session ↔ Commit 关联 ──
584
+
585
+ function normalizePath(p) {
586
+ if (!p) return '';
587
+ return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase();
588
+ }
589
+
590
+ function toRelativeRepoPath(filePath, repoPath) {
591
+ const fileN = normalizePath(filePath);
592
+ const repoN = normalizePath(repoPath);
593
+ if (!fileN) return '';
594
+ if (!repoN) return normalizeCommitFilePath(fileN.replace(/^[a-z]:\//i, ''));
595
+ if (fileN === repoN) return '';
596
+ if (fileN.startsWith(repoN + '/')) return fileN.slice(repoN.length + 1);
597
+ const repoTail = repoN.split('/').filter(Boolean).pop();
598
+ if (repoTail) {
599
+ const marker = `/${repoTail}/`;
600
+ const idx = fileN.indexOf(marker);
601
+ if (idx >= 0) return fileN.slice(idx + marker.length);
602
+ }
603
+ return fileN;
604
+ }
605
+
606
+ function normalizeCommitFilePath(filePath) {
607
+ return normalizePath(filePath).replace(/^\.?\//, '');
608
+ }
609
+
610
+ function looksLikeFilePath(value) {
611
+ if (typeof value !== 'string') return false;
612
+ const v = value.trim();
613
+ if (!v || v.length < 3) return false;
614
+ if (/\s{2,}/.test(v)) return false;
615
+ if (/^(https?:|file:|data:)/i.test(v)) return false;
616
+ return /^[a-zA-Z]:[\\/]/.test(v)
617
+ || v.startsWith('/')
618
+ || v.startsWith('./')
619
+ || v.startsWith('../')
620
+ || /[\\/]/.test(v)
621
+ || /^[^\\/\s]+\.[a-z0-9]+$/i.test(v)
622
+ || /^(dockerfile|makefile|license|readme(?:\.[a-z0-9]+)?)$/i.test(v);
623
+ }
624
+
625
+ function collectFilePaths(value, out = new Set(), seen = new Set(), parentKey = '') {
626
+ if (value == null) return out;
627
+ if (typeof value === 'string') {
628
+ if (/(?:^|_)(?:file|path|paths|filename|filepath)s?$/i.test(parentKey) && looksLikeFilePath(value)) {
629
+ out.add(value.trim());
630
+ }
631
+ return out;
632
+ }
633
+ if (typeof value !== 'object') return out;
634
+ if (seen.has(value)) return out;
635
+ seen.add(value);
636
+
637
+ if (Array.isArray(value)) {
638
+ for (const item of value) collectFilePaths(item, out, seen, parentKey);
639
+ return out;
640
+ }
641
+
642
+ for (const [key, val] of Object.entries(value)) {
643
+ if (typeof val === 'string' && /(?:^|_)(?:file|path|paths|filename|filepath)s?$/i.test(key) && looksLikeFilePath(val)) {
644
+ out.add(val.trim());
645
+ continue;
646
+ }
647
+ collectFilePaths(val, out, seen, key);
648
+ }
649
+ return out;
650
+ }
651
+
652
+ // 从 Bash 命令中提取可能操作的文件路径
653
+ const BASH_FILE_COMMANDS = /\b(?:echo|cat|cp|mv|rm|touch|mkdir|sed|awk|grep|head|tail|tee|>|>>)\b/i;
654
+
655
+ function extractFilePathsFromBashCommand(command) {
656
+ if (!command || typeof command !== 'string') return [];
657
+ const paths = new Set();
658
+
659
+ // 匹配重定向操作符后的文件路径: > file, >> file
660
+ const redirectRe = /[12]?>>?\s+(['"]?)([^&|;\s<>$`'"\n]+)\1/g;
661
+ let m;
662
+ while ((m = redirectRe.exec(command)) !== null) {
663
+ const p = m[2].trim();
664
+ if (looksLikeFilePath(p)) paths.add(p);
665
+ }
666
+
667
+ // 匹配常见命令后的文件参数
668
+ // cp/mv/touch/rm/mkdir/sed/awk 后的参数
669
+ const words = command.split(/\s+/);
670
+ let skipFlags = false;
671
+ for (let i = 0; i < words.length; i++) {
672
+ const w = words[i];
673
+ // 跳过选项 (-flag, --flag)
674
+ if (skipFlags || w.startsWith('-')) {
675
+ if (/;|\||&&/.test(w)) skipFlags = false;
676
+ continue;
677
+ }
678
+ // 遇到命令分隔符重置
679
+ if (/^[;&|]$/.test(w) || w.endsWith(';')) {
680
+ skipFlags = false;
681
+ continue;
682
+ }
683
+ // 如果是命令名,跳过下一个词(通常是目标)之前的都是选项
684
+ // 简单启发式:如果当前词是已知命令,则后面的非选项词可能是文件
685
+ const cmdWords = ['cp', 'mv', 'touch', 'rm', 'mkdir', 'sed', 'awk', 'cat', 'tee', 'head', 'tail'];
686
+ if (cmdWords.includes(w.toLowerCase())) {
687
+ skipFlags = true;
688
+ continue;
689
+ }
690
+ if (looksLikeFilePath(w)) {
691
+ paths.add(w);
692
+ }
693
+ }
694
+
695
+ return [...paths];
696
+ }
697
+
698
+ function extractTouchedFilesFromSession(session) {
699
+ const repoPath = session.project || '';
700
+ const files = new Set();
701
+ for (const tc of session.toolSequence || []) {
702
+ // Write/Edit/NotebookEdit/MultiEdit 工具
703
+ if (['Write', 'Edit', 'NotebookEdit', 'MultiEdit'].includes(tc.name)) {
704
+ const rawPaths = collectFilePaths(tc.input);
705
+ for (const rawPath of rawPaths) {
706
+ const relative = normalizeCommitFilePath(toRelativeRepoPath(rawPath, repoPath));
707
+ if (relative) files.add(relative);
708
+ }
709
+ continue;
710
+ }
711
+ // Bash 工具 — 从命令中提取文件路径
712
+ if (tc.name === 'Bash') {
713
+ const cmd = tc.input?.command || '';
714
+ const rawPaths = extractFilePathsFromBashCommand(cmd);
715
+ for (const rawPath of rawPaths) {
716
+ const relative = normalizeCommitFilePath(toRelativeRepoPath(rawPath, repoPath));
717
+ if (relative) files.add(relative);
718
+ }
719
+ }
720
+ }
721
+ return [...files].sort();
722
+ }
723
+
724
+ function computeFileOverlap(sessionTouchedFiles, commitFiles) {
725
+ const touched = new Set((sessionTouchedFiles || []).map(normalizeCommitFilePath).filter(Boolean));
726
+ const commitPaths = (commitFiles || []).map(f => normalizeCommitFilePath(f.path)).filter(Boolean);
727
+ const matchedFiles = [...new Set(commitPaths.filter(p => touched.has(p)))];
728
+ const commitFileCount = commitPaths.length;
729
+ const touchedFileCount = touched.size;
730
+ const fileOverlapRatio = commitFileCount > 0 ? matchedFiles.length / commitFileCount : 0;
731
+ return buildEvidenceDetails({
732
+ matchedFiles,
733
+ touchedFiles: [...touched],
734
+ fileOverlapRatio,
735
+ commitFileCount,
736
+ touchedFileCount,
737
+ });
738
+ }
739
+
740
+ // 用于 commit.repo 与 session.project 之间宽松对齐:
741
+ // decodeProjectName 把 `-` 解码为 `/`(D--foo-bar → D://foo/bar),
742
+ // 所以将 `-` 和 `_` 统一转为 `/`,再以 `/` 为分隔符保留路径语义。
743
+ // 这样 d:/foo-bar 和 d:/foo/bar 匹配(同一项目的解码差异),
744
+ // 但 d:/foobar 和 d:/foo/bar 不匹配(不同项目)。
745
+ function projectKey(p) {
746
+ return normalizePath(p).replace(/[-_]/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').replace(/[^a-z0-9/]/g, '');
747
+ }
748
+
749
+ // 精确路径包含:parent 是 child 的前缀,且后面紧跟 '/' 或完全匹配
750
+ function pathContains(parent, child) {
751
+ if (parent === child) return true;
752
+ return child.startsWith(parent + '/');
753
+ }
754
+
755
+ function projectMatches(commitRepoN, sessionProjectN) {
756
+ if (!commitRepoN || !sessionProjectN) return true;
757
+ // 精确路径匹配(双向:commit repo 可能是 session project 的子目录或反之)
758
+ if (pathContains(commitRepoN, sessionProjectN) || pathContains(sessionProjectN, commitRepoN)) return true;
759
+ // 宽松 key 对比(兜底:处理路径解码差异)
760
+ const a = projectKey(commitRepoN);
761
+ const b = projectKey(sessionProjectN);
762
+ return a && b && (a === b);
763
+ }
764
+
765
+ const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
766
+ const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
767
+ const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
768
+
769
+ function toMs(iso) {
770
+ if (!iso) return NaN;
771
+ const t = Date.parse(iso);
772
+ return Number.isFinite(t) ? t : NaN;
773
+ }
774
+
775
+ // 从 session.toolSequence 提取所有 `git commit` Bash 调用时间戳
776
+ function extractCommitBashTimestamps(session) {
777
+ const ts = [];
778
+ for (const tc of session.toolSequence || []) {
779
+ if (tc.name !== 'Bash') continue;
780
+ const cmd = tc.input?.command || '';
781
+ if (BASH_GIT_COMMIT_RE.test(cmd)) {
782
+ const ms = toMs(tc.timestamp);
783
+ if (Number.isFinite(ms)) ts.push(ms);
784
+ }
785
+ }
786
+ return ts;
787
+ }
788
+
789
+ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 * 60 * 1000 } = {}) {
790
+ const result = { sessionCommitMap: {} };
791
+ if (!commits?.length || !sessions?.length) return result;
792
+
793
+ // 预计算每个 session 的 ms 范围 + 项目归一化 + bash commit 时间戳
794
+ const sIndex = sessions.map(s => ({
795
+ id: s.id,
796
+ projectN: normalizePath(s.project || ''),
797
+ startMs: toMs(s.startTime),
798
+ endMs: toMs(s.endTime),
799
+ bashTs: extractCommitBashTimestamps(s),
800
+ touchedFiles: extractTouchedFilesFromSession(s),
801
+ }));
802
+
803
+ // 阶段 1:重置 + 强信号匹配(Bash git commit)
804
+ for (const c of commits) {
805
+ c.sessionId = null;
806
+ c.sessionAttribution = null;
807
+ const commitMs = toMs(c.date);
808
+ const commitRepoN = normalizePath(c.repo || '');
809
+ if (!Number.isFinite(commitMs)) continue;
810
+
811
+ let matched = null;
812
+ for (const s of sIndex) {
813
+ if (!s.bashTs.length) continue;
814
+ if (!projectMatches(commitRepoN, s.projectN)) continue;
815
+ for (const bts of s.bashTs) {
816
+ if (commitMs >= bts - STRONG_WINDOW_BEFORE_MS && commitMs <= bts + STRONG_WINDOW_AFTER_MS) {
817
+ matched = s;
818
+ break;
819
+ }
820
+ }
821
+ if (matched) break;
822
+ }
823
+
824
+ if (matched) {
825
+ c.sessionId = matched.id;
826
+ c.sessionAttribution = 'strong';
827
+ if (!result.sessionCommitMap[matched.id]) result.sessionCommitMap[matched.id] = [];
828
+ result.sessionCommitMap[matched.id].push(c.hash);
829
+ }
830
+ }
831
+
832
+ // 从强信号匹配中收集每个 session 的已知 author 集合
833
+ const sessionAuthors = new Map();
834
+ for (const c of commits) {
835
+ if (c.sessionAttribution === 'strong' && c.author) {
836
+ const key = c.sessionId;
837
+ if (!sessionAuthors.has(key)) sessionAuthors.set(key, new Set());
838
+ sessionAuthors.get(key).add(c.author.toLowerCase());
839
+ }
840
+ }
841
+
842
+ // 阶段 2:弱信号匹配 — commit 落在 session 时间窗 ± buffer,按中点距离取近
843
+ // 如果 session 有已知 author(来自强信号匹配),则 commit author 必须一致
844
+ for (const c of commits) {
845
+ if (c.sessionAttribution) continue;
846
+ const commitMs = toMs(c.date);
847
+ const commitRepoN = normalizePath(c.repo || '');
848
+ if (!Number.isFinite(commitMs)) continue;
849
+
850
+ let best = null;
851
+ let bestDist = Infinity;
852
+ for (const s of sIndex) {
853
+ if (!Number.isFinite(s.startMs) || !Number.isFinite(s.endMs)) continue;
854
+ if (!projectMatches(commitRepoN, s.projectN)) continue;
855
+
856
+ // author 一致性校验:session 有已知 author 时,commit author 必须匹配
857
+ const knownAuthors = sessionAuthors.get(s.id);
858
+ if (knownAuthors?.size && c.author && !knownAuthors.has(c.author.toLowerCase())) continue;
859
+
860
+ const lo = s.startMs - bufferMs;
861
+ const hi = s.endMs + bufferMs;
862
+ if (commitMs < lo || commitMs > hi) continue;
863
+ const mid = (s.startMs + s.endMs) / 2;
864
+ const dist = Math.abs(commitMs - mid);
865
+ if (dist < bestDist) {
866
+ best = s;
867
+ bestDist = dist;
868
+ }
869
+ }
870
+
871
+ if (best) {
872
+ c.sessionId = best.id;
873
+ c.sessionAttribution = 'weak';
874
+ if (!result.sessionCommitMap[best.id]) result.sessionCommitMap[best.id] = [];
875
+ result.sessionCommitMap[best.id].push(c.hash);
876
+ }
877
+ }
878
+
879
+ // 阶段 3:跨天项目匹配 — 未匹配的 commit 关联到同项目最近的 session
880
+ // 场景:AI session 在 Day1,git commit 在 Day2+,时间窗无法覆盖
881
+ for (const c of commits) {
882
+ if (c.sessionAttribution) continue;
883
+ const commitMs = toMs(c.date);
884
+ const commitRepoN = normalizePath(c.repo || '');
885
+ if (!Number.isFinite(commitMs)) continue;
886
+
887
+ let best = null;
888
+ let bestDist = Infinity;
889
+ for (const s of sIndex) {
890
+ if (!projectMatches(commitRepoN, s.projectN)) continue;
891
+ if (!Number.isFinite(s.endMs)) continue;
892
+ // commit 必须在 session 结束之后(不能是之前漏掉的)
893
+ if (commitMs < s.endMs) continue;
894
+ const dist = commitMs - s.endMs;
895
+ // 最多跨 3 天
896
+ if (dist > 3 * 24 * 3600 * 1000) continue;
897
+ // author 校验:session 有已知 author 时,commit author 必须匹配
898
+ const knownAuthors = sessionAuthors.get(s.id);
899
+ if (knownAuthors?.size && c.author && !knownAuthors.has(c.author.toLowerCase())) continue;
900
+ if (dist < bestDist) {
901
+ best = s;
902
+ bestDist = dist;
903
+ }
904
+ }
905
+
906
+ if (best) {
907
+ // 文件交集前置检查:无交集时标记为 cross-day-weak
908
+ const commitFiles = (c.files || []).map(f => (f.path || '').replace(/\\/g, '/'));
909
+ const sessionFiles = best.touchedFiles || [];
910
+ const hasOverlap = sessionFiles.some(sf => commitFiles.some(cf => cf.endsWith(sf) || sf.endsWith(cf)));
911
+ c.sessionId = best.id;
912
+ c.sessionAttribution = hasOverlap ? 'cross-day' : 'cross-day-weak';
913
+ if (!result.sessionCommitMap[best.id]) result.sessionCommitMap[best.id] = [];
914
+ result.sessionCommitMap[best.id].push(c.hash);
915
+ }
916
+ }
917
+
918
+ return result;
919
+ }
920
+
921
+ // 把 commit 投射到 session.commits(精简字段)
922
+ export function attachCommitsToSessions(sessions, commitList) {
923
+ if (!sessions?.length) return sessions || [];
924
+ const byId = new Map(sessions.map(s => [s.id, s]));
925
+ for (const s of sessions) s.commits = [];
926
+ for (const c of commitList || []) {
927
+ if (!c.sessionId) continue;
928
+ const s = byId.get(c.sessionId);
929
+ if (!s) continue;
930
+ s.commits.push({
931
+ hash: c.hash,
932
+ subject: c.subject,
933
+ type: c.type,
934
+ isAI: c.isAI,
935
+ aiAssisted: c.aiAssisted,
936
+ aiConfidence: c.aiConfidence || AI_CONFIDENCE.NONE,
937
+ attributionType: c.attributionType || null,
938
+ aiEvidenceDetails: c.aiEvidenceDetails || null,
939
+ attributedTool: c.attributedTool || null,
940
+ linesAdded: c.linesAdded,
941
+ linesDeleted: c.linesDeleted,
942
+ date: c.date,
943
+ });
944
+ }
945
+ return sessions;
946
+ }
947
+
948
+ // 一次性收尾:跑 attribution + 三个聚合
949
+ export function finalizeGitStats(merged, sessions = [], options = {}) {
950
+ if (!merged) return merged;
951
+ const fileOverrides = loadAttributionOverrides();
952
+ const inputOverrides = options.overrides || {};
953
+ const mergedOverrides = {
954
+ commits: { ...fileOverrides.commits, ...(inputOverrides.commits || {}) },
955
+ files: { ...fileOverrides.files, ...(inputOverrides.files || {}) },
956
+ };
957
+ const { sessionCommitMap } = attributeCommitsToSessions(merged.commitList, sessions);
958
+ merged.sessionCommitMap = sessionCommitMap;
959
+ const sessionsById = new Map((sessions || []).map(s => [s.id, s]));
960
+ for (const s of sessions || []) {
961
+ s.touchedFiles = extractTouchedFilesFromSession(s);
962
+ }
963
+
964
+ // Step 1: 为每个 commit 标注 attributedTool
965
+ // 优先级:显式签名(detectedTool) > session primaryTool > null
966
+ // 注意:session 归属的 commit 即使是低置信度也会获得 attributedTool,
967
+ // 这是预期行为——低置信度意味着"不能确定是 AI",但工具归属仍然有价值
968
+ for (const c of merged.commitList || []) {
969
+ if (c.detectedTool) {
970
+ c.attributedTool = c.detectedTool;
971
+ } else if (c.sessionId) {
972
+ const session = sessionsById.get(c.sessionId);
973
+ c.attributedTool = session?.primaryTool || null;
974
+ } else {
975
+ c.attributedTool = null;
976
+ }
977
+ }
978
+
979
+ // Step 2: 信心度评估(保持现有逻辑)
980
+ for (const c of merged.commitList || []) {
981
+ if (!c.sessionId || c.attributionType === 'explicit') continue;
982
+ const session = sessionsById.get(c.sessionId);
983
+ const overlap = computeFileOverlap(session?.touchedFiles || [], c.files || []);
984
+ const nextSignals = [...(c.aiSignals || [])];
985
+ let confidence = AI_CONFIDENCE.LOW;
986
+ let attributionType = 'session_weak';
987
+ const signals = [...nextSignals];
988
+
989
+ c.aiEvidenceDetails = overlap;
990
+
991
+ if (overlap.matchedFileCount > 0) {
992
+ signals.push('fileOverlap');
993
+ if (overlap.fileOverlapRatio >= 0.5 || overlap.commitFileCount === 1) {
994
+ signals.push('fileOverlapHigh');
995
+ }
996
+ }
997
+
998
+ if (c.sessionAttribution === 'strong') {
999
+ confidence = AI_CONFIDENCE.MEDIUM;
1000
+ attributionType = 'session_strong';
1001
+ signals.push('sessionCommitBash');
1002
+ if (overlap.matchedFileCount > 0) {
1003
+ confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
1004
+ attributionType = 'session_strong_file_overlap';
1005
+ }
1006
+ } else if (c.sessionAttribution === 'cross-day') {
1007
+ confidence = AI_CONFIDENCE.MEDIUM;
1008
+ attributionType = 'session_cross_day';
1009
+ signals.push('crossDayProjectMatch');
1010
+ if (overlap.matchedFileCount > 0) {
1011
+ confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
1012
+ attributionType = 'session_cross_day_file_overlap';
1013
+ }
1014
+ } else if (c.sessionAttribution === 'cross-day-weak') {
1015
+ confidence = AI_CONFIDENCE.LOW;
1016
+ attributionType = 'session_cross_day_weak';
1017
+ signals.push('crossDayProjectMatch');
1018
+ if (overlap.matchedFileCount > 0) {
1019
+ confidence = AI_CONFIDENCE.MEDIUM;
1020
+ attributionType = 'session_cross_day_weak_file_overlap';
1021
+ }
1022
+ } else if (overlap.matchedFileCount > 0) {
1023
+ confidence = AI_CONFIDENCE.MEDIUM;
1024
+ attributionType = 'session_file_overlap';
1025
+ } else if (session?.primaryTool) {
1026
+ // session 有明确工具归属但无 file overlap(如 Codex 无 toolSequence 记录)
1027
+ confidence = AI_CONFIDENCE.MEDIUM;
1028
+ attributionType = 'session_tool_attributed';
1029
+ signals.push('sessionToolAttributed');
1030
+ } else {
1031
+ signals.push('sessionAttributed');
1032
+ }
1033
+
1034
+ if (confidence === AI_CONFIDENCE.MEDIUM && overlap.fileOverlapRatio >= 0.75 && overlap.commitFileCount > 1) {
1035
+ confidence = AI_CONFIDENCE.HIGH;
1036
+ signals.push('fileOverlapDominant');
1037
+ if (attributionType === 'session_file_overlap') attributionType = 'session_file_overlap_dominant';
1038
+ }
1039
+
1040
+ if (c.sessionAttribution === 'strong' && overlap.matchedFileCount === 0 && overlap.touchedFileCount > 0) {
1041
+ signals.push('strongWithoutFileOverlap');
1042
+ }
1043
+
1044
+ const ai = createAIAttribution({
1045
+ confidence,
1046
+ signals,
1047
+ attributionType,
1048
+ });
1049
+ c.isAI = ai.isAI;
1050
+ c.aiAssisted = ai.aiAssisted;
1051
+ c.aiConfidence = ai.aiConfidence;
1052
+ c.aiSignals = ai.aiSignals;
1053
+ c.aiEvidence = ai.aiEvidence;
1054
+ c.attributionType = ai.attributionType;
1055
+ }
1056
+
1057
+ const attributionItems = [];
1058
+ for (const c of merged.commitList || []) {
1059
+ const commitOverride = mergedOverrides.commits[c.hash] || null;
1060
+ const fileOverride = (c.files || []).find(f => mergedOverrides.files[`${c.hash}:${f.path}`]);
1061
+ const fileOverrideValue = fileOverride ? mergedOverrides.files[`${c.hash}:${fileOverride.path}`] : null;
1062
+ const classified = classifyAttribution({
1063
+ commitHash: c.hash,
1064
+ primaryTool: c.attributedTool || null,
1065
+ tools: c.attributedTool ? [c.attributedTool] : [],
1066
+ aiConfidence: c.aiConfidence,
1067
+ aiAssisted: c.aiAssisted,
1068
+ attributionType: c.attributionType,
1069
+ sessionAttribution: c.sessionAttribution,
1070
+ isAI: c.isAI,
1071
+ evidence: c.aiEvidenceDetails?.matchedFiles || [],
1072
+ override: fileOverrideValue || commitOverride || null,
1073
+ });
1074
+ attributionItems.push({
1075
+ commitHash: c.hash,
1076
+ classification: classified.classification,
1077
+ primaryTool: classified.primaryTool,
1078
+ tools: classified.tools,
1079
+ evidence: classified.evidence,
1080
+ source: classified.source,
1081
+ reason: classified.reason,
1082
+ added: c.linesAdded || 0,
1083
+ deleted: c.linesDeleted || 0,
1084
+ });
1085
+ }
1086
+
1087
+ // Step 3: 全局 + 按工具聚合
1088
+ merged.aiContribution = computeAIContribution(merged.commitList);
1089
+ // 动态收集所有出现的 attributedTool,确保新工具自动覆盖
1090
+ const toolSet = new Set();
1091
+ for (const c of merged.commitList || []) {
1092
+ if (c.attributedTool) toolSet.add(c.attributedTool);
1093
+ }
1094
+ // 始终包含基础三工具 + generic-ai,即使无数据
1095
+ for (const t of ['claude', 'codex', 'opencode', 'generic-ai']) toolSet.add(t);
1096
+ merged.aiContributionByTool = {};
1097
+ for (const tool of toolSet) {
1098
+ merged.aiContributionByTool[tool] = computeAIContribution(merged.commitList, tool);
1099
+ }
1100
+ merged.attributionSummary = aggregateAttribution(attributionItems);
1101
+ merged.commitTypes = computeCommitTypes(merged.commitList);
1102
+ merged.fileHotspots = computeFileHotspots(merged.commitList, 10);
1103
+ // 反向把 commits 挂到 sessions
1104
+ attachCommitsToSessions(sessions, merged.commitList);
1105
+ return merged;
1106
+ }