lumencode 1.1.0 → 1.3.0

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 CHANGED
@@ -2,6 +2,14 @@ import { execSync, exec as execCb } from 'child_process';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { aggregateAttribution, classifyAttribution } from './attribution.js';
5
+ import {
6
+ normalizeCommitFilePath,
7
+ normalizePathForGit,
8
+ projectMatches as projectMatchesFromGitPaths,
9
+ toRepoRelativePath,
10
+ } from './git-paths.js';
11
+ import { resolveAttributionOptions } from './git-attribution-options.js';
12
+ import { scoreSessionCandidate } from './git-attribution-candidates.js';
5
13
 
6
14
  // ── helpers ──
7
15
 
@@ -157,13 +165,6 @@ const AI_CONFIDENCE = {
157
165
  HIGH: 'high',
158
166
  };
159
167
 
160
- const CONFIDENCE_WEIGHTS = {
161
- [AI_CONFIDENCE.HIGH]: 1.0,
162
- [AI_CONFIDENCE.MEDIUM]: 0.7,
163
- [AI_CONFIDENCE.LOW]: 0.2,
164
- [AI_CONFIDENCE.NONE]: 0,
165
- };
166
-
167
168
  function isCountedAIConfidence(confidence) {
168
169
  return confidence === AI_CONFIDENCE.HIGH || confidence === AI_CONFIDENCE.MEDIUM;
169
170
  }
@@ -180,7 +181,7 @@ function resolveToolFromSignals(signals) {
180
181
  return null;
181
182
  }
182
183
 
183
- function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null } = {}) {
184
+ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null, negativeSignals = [] } = {}) {
184
185
  const normalizedSignals = [...new Set(signals)];
185
186
  return {
186
187
  isAI: isCountedAIConfidence(confidence),
@@ -191,6 +192,7 @@ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], at
191
192
  aiEvidence: normalizedSignals,
192
193
  attributionType,
193
194
  detectedTool,
195
+ negativeSignals,
194
196
  };
195
197
  }
196
198
 
@@ -221,6 +223,21 @@ function buildEvidenceDetails({
221
223
  };
222
224
  }
223
225
 
226
+ // 检测正则中可能导致回溯爆炸的危险模式
227
+ function isSafeRegex(pattern) {
228
+ // 限制最大长度
229
+ if (pattern.length > 200) return false;
230
+ // 嵌套量词:如 (a+)+, (a*){2,}, (a{1,3})+
231
+ if (/\([^)]*[+*{][^)]*\)[+*{]/.test(pattern)) return false;
232
+ // 交替+量词:如 (a|b)*, (foo|bar)+
233
+ if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false;
234
+ // 字符类后跟量词且字符类内含量词:如 [\w+]* — 模糊但潜在危险
235
+ if (/\[[^\]]+\][+*{]/.test(pattern) && /\[[^\]]*[*+]/.test(pattern)) return false;
236
+ // 重复量词:如 a** 或 a++ (有些引擎报错但不应依赖)
237
+ if (/[+*{]\s*[+*{]/.test(pattern)) return false;
238
+ return true;
239
+ }
240
+
224
241
  function loadCustomPatterns() {
225
242
  try {
226
243
  const configPath = join(process.cwd(), 'ai-patterns.json');
@@ -228,6 +245,13 @@ function loadCustomPatterns() {
228
245
  const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
229
246
  return raw
230
247
  .filter(p => typeof p.re === 'string' && typeof p.signal === 'string')
248
+ .filter(p => {
249
+ if (!isSafeRegex(p.re)) {
250
+ console.warn(`[git] 跳过不安全正则: ${p.re.slice(0, 50)}`);
251
+ return false;
252
+ }
253
+ return true;
254
+ })
231
255
  .map(p => ({ re: new RegExp(p.re, p.flags || 'i'), signal: p.signal }));
232
256
  }
233
257
  } catch { /* ignore */ }
@@ -248,6 +272,205 @@ function loadAttributionOverrides() {
248
272
  }
249
273
  }
250
274
 
275
+ // ── Style-based heuristic detection (fallback when no explicit signals) ──
276
+
277
+ const IMPERATIVE_VERBS = /^(?:add|fix|update|remove|refactor|implement|create|delete|replace|rename|move|extract|improve|optimize|simplify|restructure|rewrite|migrate|upgrade|downgrade|revert|bump|clean|format|lint|docs?|test|build|ci|chore|perf|style|init|setup|config|configure|enable|disable|support|handle|ensure|validate|verify|check|detect|parse|compute|merge|split|group|sort|filter|map|reduce|export|import|load|save|read|write|reset|toggle|switch|convert|transform|wrap|unwrap|escape|unescape|encode|decode|serialize|deserialize|compress|decompress|encrypt|decrypt|hash|sign|verify|auth|login|logout|register|unregister|subscribe|unsubscribe|connect|disconnect|bind|unbind|attach|detach|mount|unmount|open|close|show|hide|display|render|draw|paint|print|log|trace|debug|info|warn|error|fatal|throw|catch|retry|abort|cancel|timeout|expire|flush|clear|reset|restore|backup|archive|deploy|release|publish|install|uninstall)\b/i;
278
+
279
+ function detectStyleSignals(subject, body) {
280
+ const signals = [];
281
+ let score = 0;
282
+
283
+ // Bullet list: 3+ lines starting with "- " or "* "
284
+ const bulletLines = (body || '').split('\n').filter(l => /^\s*[-*]\s+\S/.test(l));
285
+ if (bulletLines.length >= 3) {
286
+ signals.push('styleBulletList');
287
+ score += 2;
288
+ }
289
+
290
+ // Conventional commit with scope: type(scope): subject
291
+ if (CONVENTIONAL_RE.test(subject)) {
292
+ const m = subject.match(CONVENTIONAL_RE);
293
+ if (m && m[2]) {
294
+ signals.push('styleConventionalScope');
295
+ score += 1;
296
+ }
297
+ }
298
+
299
+ // Long structured body: >150 chars with 3+ non-empty lines
300
+ const bodyLines = (body || '').split('\n').filter(l => l.trim());
301
+ if ((body || '').length > 150 && bodyLines.length >= 3) {
302
+ signals.push('styleLongStructuredBody');
303
+ score += 1;
304
+ }
305
+
306
+ // Technical detail: body contains file paths or parenthetical notes
307
+ if (/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.[a-z]{1,4}/.test(body) || /\([^)]{10,}\)/.test(body)) {
308
+ signals.push('styleTechnicalDetail');
309
+ score += 1;
310
+ }
311
+
312
+ // Imperative mood: 3+ body lines start with imperative verbs (strip bullet prefix first)
313
+ const imperativeCount = bodyLines.filter(l => IMPERATIVE_VERBS.test(l.trim().replace(/^[-*]\s+/, ''))).length;
314
+ if (imperativeCount >= 3) {
315
+ signals.push('styleImperativeMood');
316
+ score += 1;
317
+ }
318
+
319
+ return { signals, score };
320
+ }
321
+
322
+ // ── Negative signals (reduce false positives) ──
323
+
324
+ const WIP_RE = /\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i;
325
+ const MERGE_RE = /^Merge\s+(?:branch|pull|remote|tag)/i;
326
+
327
+ export function detectNegativeSignals(subject, body, linesAdded, linesDeleted, fileCount) {
328
+ const signals = [];
329
+ const trimmedBody = (body || '').trim();
330
+ const trimmedSubject = (subject || '').trim();
331
+ const totalLines = (linesAdded || 0) + (linesDeleted || 0);
332
+ const files = fileCount || 0;
333
+
334
+ // Informal: very short subject AND body, no conventional commit prefix
335
+ const isConventional = CONVENTIONAL_RE.test(trimmedSubject);
336
+ if (trimmedSubject.length <= 10 && trimmedBody.length <= 10 && !/\n/.test(trimmedBody) && !isConventional) {
337
+ signals.push('humanInformal');
338
+ }
339
+ if (MERGE_RE.test(trimmedSubject)) {
340
+ signals.push('humanMergeCommit');
341
+ }
342
+ if (totalLines <= 2 && files <= 1) {
343
+ signals.push('humanSmallScope');
344
+ }
345
+ if (WIP_RE.test(trimmedSubject)) {
346
+ signals.push('humanWIP');
347
+ }
348
+ return signals;
349
+ }
350
+
351
+ // ── Developer behavioral baseline ──
352
+
353
+ function computeAuthorBaseline(commitList) {
354
+ const authorStats = new Map();
355
+ for (const c of commitList || []) {
356
+ const email = c.author || 'unknown';
357
+ if (!authorStats.has(email)) {
358
+ authorStats.set(email, {
359
+ count: 0, totalSubjectLen: 0, totalBodyLen: 0,
360
+ bulletCount: 0, convCount: 0, totalFiles: 0, totalLines: 0,
361
+ });
362
+ }
363
+ const s = authorStats.get(email);
364
+ s.count++;
365
+ s.totalSubjectLen += (c.subject || '').length;
366
+ s.totalBodyLen += (c.body || '').length;
367
+ s.totalFiles += (c.files || []).length;
368
+ s.totalLines += (c.linesAdded || 0) + (c.linesDeleted || 0);
369
+ if (/^\s*[-*]\s+\S/m.test(c.body || '')) s.bulletCount++;
370
+ if (CONVENTIONAL_RE.test(c.subject)) s.convCount++;
371
+ }
372
+ const baselines = new Map();
373
+ for (const [email, s] of authorStats) {
374
+ if (s.count < 3) { baselines.set(email, null); continue; }
375
+ baselines.set(email, {
376
+ avgSubjectLen: s.totalSubjectLen / s.count,
377
+ avgBodyLen: s.totalBodyLen / s.count,
378
+ bulletRatio: s.bulletCount / s.count,
379
+ convRatio: s.convCount / s.count,
380
+ avgFiles: s.totalFiles / s.count,
381
+ avgLines: s.totalLines / s.count,
382
+ });
383
+ }
384
+ return baselines;
385
+ }
386
+
387
+ function computeBaselineDeviation(commit, baseline) {
388
+ if (!baseline) return 0;
389
+ let deviation = 0;
390
+ let factors = 0;
391
+ const body = commit.body || '';
392
+
393
+ if (baseline.avgBodyLen > 0) {
394
+ const ratio = body.length / baseline.avgBodyLen;
395
+ deviation += ratio > 2 ? 0.3 : (ratio > 1.5 ? 0.15 : 0);
396
+ factors++;
397
+ }
398
+ const hasBullets = /^\s*[-*]\s+\S/m.test(body);
399
+ if (baseline.bulletRatio < 0.1 && hasBullets) { deviation += 0.3; factors++; }
400
+ const fileCount = (commit.files || []).length;
401
+ if (baseline.avgFiles > 0) {
402
+ const ratio = fileCount / baseline.avgFiles;
403
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
404
+ factors++;
405
+ }
406
+ const lineCount = (commit.linesAdded || 0) + (commit.linesDeleted || 0);
407
+ if (baseline.avgLines > 0) {
408
+ const ratio = lineCount / baseline.avgLines;
409
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
410
+ factors++;
411
+ }
412
+
413
+ return factors > 0 ? Math.min(deviation / factors, 1) : 0;
414
+ }
415
+
416
+ // ── Composite continuous scoring ──
417
+
418
+ function computeContinuousScore(commit, attributionOptions) {
419
+ let score = 0;
420
+ const signals = new Set(commit.aiSignals || []);
421
+ const weights = attributionOptions.scoreWeights;
422
+
423
+ // Explicit signatures
424
+ if (signals.has('coAuthor') || signals.has('generatedWith') || signals.has('assistedBy')) score += weights.explicitSignature;
425
+ if (signals.has('coAuthorCopilot') || signals.has('coAuthorCursor') || signals.has('coAuthorCodex')) score += weights.explicitSignature;
426
+ if (signals.has('robotEmoji') || signals.has('coAuthorOpencode')) score += weights.explicitSignature;
427
+ if (signals.has('authorClaude') || signals.has('authorBot')) score += weights.explicitAuthor;
428
+ if (signals.has('generatedWithAider') || signals.has('aiderTag')) score += weights.explicitSignature;
429
+ if (signals.has('generatedWithCodex') || signals.has('coAuthorCodex')) score += weights.explicitSignature;
430
+ if (signals.has('coAuthorWindsurf') || signals.has('coAuthorAugment') || signals.has('coAuthorCline')) score += weights.explicitSignature;
431
+ if (signals.has('aiGenerated') || signals.has('generatedByAI') || signals.has('viaAI') || signals.has('aiTag')) score += weights.genericAISignature;
432
+
433
+ // Session signals
434
+ if (commit.sessionAttribution === 'strong') score += weights.sessionStrong;
435
+ else if (commit.sessionAttribution === 'cross-day') score += weights.sessionCrossDay;
436
+ else if (commit.sessionAttribution === 'weak') score += weights.sessionWeak;
437
+ else if (commit.sessionAttribution === 'cross-day-weak') score += weights.sessionCrossDayWeak;
438
+
439
+ // File overlap
440
+ const overlap = commit.aiEvidenceDetails?.fileOverlapRatio || 0;
441
+ score += overlap * weights.fileOverlap;
442
+
443
+ // Style heuristic
444
+ if (signals.has('styleBulletList')) score += weights.styleBulletList;
445
+ if (signals.has('styleConventionalScope')) score += weights.styleConventionalScope;
446
+ if (signals.has('styleImperativeMood')) score += weights.styleImperativeMood;
447
+ if (signals.has('styleLongStructuredBody')) score += weights.styleLongStructuredBody;
448
+
449
+ // Baseline deviation
450
+ if (signals.has('baselineDeviationHigh')) score += weights.baselineDeviationHigh;
451
+ else if (signals.has('baselineDeviationMedium')) score += weights.baselineDeviationMedium;
452
+
453
+ // Negative signals
454
+ const negSignals = new Set(commit.negativeSignals || []);
455
+ if (negSignals.has('humanMergeCommit')) score += weights.negativeMergeCommit;
456
+ if (negSignals.has('humanInformal')) score += weights.negativeInformal;
457
+ if (negSignals.has('humanSmallScope')) score += weights.negativeSmallScope;
458
+ if (negSignals.has('humanWIP')) score += weights.negativeWIP;
459
+
460
+ // Baseline match (human pattern)
461
+ if (signals.has('humanBaselineMatch')) score += weights.humanBaselineMatch;
462
+
463
+ return Math.max(0, Math.min(1, score));
464
+ }
465
+
466
+ function scoreToConfidence(score, attributionOptions) {
467
+ const thresholds = attributionOptions.confidenceThresholds;
468
+ if (score >= thresholds.high) return AI_CONFIDENCE.HIGH;
469
+ if (score >= thresholds.medium) return AI_CONFIDENCE.MEDIUM;
470
+ if (score >= thresholds.low) return AI_CONFIDENCE.LOW;
471
+ return AI_CONFIDENCE.NONE;
472
+ }
473
+
251
474
  export function detectAICommit(subject = '', author = '', body = '') {
252
475
  const haystack = `${subject}\n${body}`;
253
476
  const signals = [];
@@ -285,12 +508,38 @@ export function detectAICommit(subject = '', author = '', body = '') {
285
508
  detectedTool,
286
509
  });
287
510
  }
511
+
512
+ // Negative signals: skip style heuristic for obvious human patterns
513
+ // Only check message-level signals here (line/file counts not available)
514
+ const subjectNeg = (subject || '').trim();
515
+ const bodyNeg = (body || '').trim();
516
+ const negSignals = [];
517
+ const isConvSubject = CONVENTIONAL_RE.test(subjectNeg);
518
+ if (subjectNeg.length <= 10 && bodyNeg.length <= 10 && !/\n/.test(bodyNeg) && !isConvSubject) negSignals.push('humanInformal');
519
+ if (/^Merge\s+(?:branch|pull|remote|tag)/i.test(subjectNeg)) negSignals.push('humanMergeCommit');
520
+ if (/\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i.test(subjectNeg)) negSignals.push('humanWIP');
521
+ if (negSignals.length > 0) {
522
+ return createAIAttribution({ negativeSignals: negSignals });
523
+ }
524
+
525
+ // Fallback: style-based heuristic detection
526
+ const { signals: styleSignals, score } = detectStyleSignals(subject, body);
527
+ if (styleSignals.length > 0 && score >= 4) {
528
+ const confidence = score >= 6 ? AI_CONFIDENCE.MEDIUM : AI_CONFIDENCE.LOW;
529
+ return createAIAttribution({
530
+ confidence,
531
+ signals: styleSignals,
532
+ attributionType: score >= 6 ? 'style_heuristic_strong' : 'style_heuristic',
533
+ detectedTool: null,
534
+ });
535
+ }
536
+
288
537
  return createAIAttribution();
289
538
  }
290
539
 
291
540
  // ── 聚合函数 ──
292
541
 
293
- export function computeAIContribution(commits, toolFilter = null) {
542
+ export function computeAIContribution(commits, toolFilter = null, options = {}) {
294
543
  let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
295
544
  let possibleAICommits = 0, possibleAILinesAdded = 0, possibleAILinesDeleted = 0;
296
545
  let weightedAILinesAdded = 0, weightedAILinesDeleted = 0;
@@ -298,6 +547,8 @@ export function computeAIContribution(commits, toolFilter = null) {
298
547
  let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
299
548
  let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
300
549
  let totalLinesAdded = 0, totalLinesDeleted = 0;
550
+ const attributionOptions = resolveAttributionOptions(options.attribution || options);
551
+ const confidenceWeights = attributionOptions.confidenceWeights;
301
552
  const allCommits = commits || [];
302
553
  for (const c of allCommits) {
303
554
  totalLinesAdded += c.linesAdded || 0;
@@ -328,6 +579,12 @@ export function computeAIContribution(commits, toolFilter = null) {
328
579
  fileDeleted = c.linesDeleted || 0;
329
580
  }
330
581
 
582
+ // Step blame override: use precise line-level attribution when available
583
+ if (c.lineBlame) {
584
+ fileAdded = c.lineBlame.aiLines || 0;
585
+ fileDeleted = c.lineBlame.aiDeletedLines || 0;
586
+ }
587
+
331
588
  if (isCountedAIConfidence(confidence)) {
332
589
  aiCommits++;
333
590
  aiCommitLinesAdded += c.linesAdded || 0;
@@ -341,7 +598,7 @@ export function computeAIContribution(commits, toolFilter = null) {
341
598
  }
342
599
 
343
600
  // 加权计算:所有归因的 commit 都参与(包括 LOW)
344
- const weight = CONFIDENCE_WEIGHTS[confidence] || 0;
601
+ const weight = confidenceWeights[confidence] || 0;
345
602
  if (weight > 0) {
346
603
  weightedAILinesAdded += fileAdded * weight;
347
604
  weightedAILinesDeleted += fileDeleted * weight;
@@ -428,7 +685,9 @@ export function parseGitLogOutput(output, repo = '') {
428
685
 
429
686
  const flush = () => {
430
687
  if (!current) return;
431
- const dateKey = current.date.slice(0, 10);
688
+ // 使用本地日期做 daily stats key(用户期望看到的日期),
689
+ // UTC 日期(current.date)仅用于与 session 时间戳比较
690
+ const dateKey = current.dateLocal || current.date.slice(0, 10);
432
691
  // 注入 conventional 类型 + AI 信号
433
692
  const conv = parseConventional(current.subject);
434
693
  const ai = detectAICommit(current.subject, current.author, current.body || '');
@@ -442,6 +701,7 @@ export function parseGitLogOutput(output, repo = '') {
442
701
  current.aiEvidence = ai.aiEvidence;
443
702
  current.attributionType = ai.attributionType;
444
703
  current.detectedTool = ai.detectedTool || null;
704
+ current.negativeSignals = ai.negativeSignals || [];
445
705
  current.sessionId = null; // 由 finalize 阶段填充
446
706
 
447
707
  result.commits++;
@@ -474,13 +734,21 @@ export function parseGitLogOutput(output, repo = '') {
474
734
  const header = line.slice(COMMIT_SENTINEL.length);
475
735
  const parts = header.split('|');
476
736
  const hash = parts[0] || '';
477
- const date = (parts[1] || '').slice(0, 19);
737
+ const dateRaw = (parts[1] || '');
738
+ // 保留本地日期用于 commitsByDate(用户看到的日期)
739
+ const dateLocal = dateRaw.slice(0, 10) || '';
740
+ // Normalize to UTC ISO for consistent comparison with session timestamps
741
+ const dateMs = Date.parse(dateRaw);
742
+ const date = Number.isFinite(dateMs)
743
+ ? new Date(dateMs).toISOString().slice(0, 19) + 'Z'
744
+ : dateRaw.slice(0, 19);
478
745
  const author = parts[2] || '';
479
746
  const subject = parts.slice(3).join('|');
480
747
  current = {
481
748
  repo,
482
749
  hash,
483
750
  date,
751
+ dateLocal,
484
752
  author,
485
753
  subject,
486
754
  body: '',
@@ -539,15 +807,14 @@ function sanitizeArg(s) {
539
807
  return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
540
808
  }
541
809
 
542
- function buildGitArgs(since, until, author) {
543
- const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
544
- const safeSince = sanitizeArg(sinceFull);
545
- const safeUntil = sanitizeArg(until);
546
- const authorArg = author ? ` --author="${sanitizeArg(author)}"` : '';
547
- // 格式:哨兵行(subject) body 行(可多行) → ENDBODY 行 → numstat 行
548
- const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
549
- return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"${authorArg}`;
550
- }
810
+ function buildGitArgs(since, until) {
811
+ const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
812
+ const safeSince = sanitizeArg(sinceFull);
813
+ const safeUntil = sanitizeArg(until);
814
+ // 格式:哨兵行(subject) body (可多行) ENDBODY 行 → numstat 行
815
+ const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
816
+ return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"`;
817
+ }
551
818
 
552
819
  function mergeGitStats(target, source) {
553
820
  target.commits += source.commits;
@@ -570,37 +837,105 @@ function mergeGitStats(target, source) {
570
837
  // filesChanged 在 merge 完后由 finalize 重新计算(跨 repo 去重)
571
838
  }
572
839
 
573
- function recomputeFilesChanged(stats) {
574
- const set = new Set();
575
- for (const c of stats.commitList || []) {
576
- for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
577
- }
578
- stats.filesChanged = set.size;
579
- }
840
+ function recomputeFilesChanged(stats) {
841
+ const set = new Set();
842
+ for (const c of stats.commitList || []) {
843
+ for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
844
+ }
845
+ stats.filesChanged = set.size;
846
+ }
847
+
848
+ function recomputeStatsFromCommitList(stats) {
849
+ stats.commits = 0;
850
+ stats.filesChanged = 0;
851
+ stats.linesAdded = 0;
852
+ stats.linesDeleted = 0;
853
+ stats.commitsByDate = {};
854
+ stats.linesByDate = {};
855
+
856
+ for (const c of stats.commitList || []) {
857
+ const dateKey = c.dateLocal || (c.date || '').slice(0, 10);
858
+ stats.commits++;
859
+ stats.commitsByDate[dateKey] = (stats.commitsByDate[dateKey] || 0) + 1;
860
+ if (!stats.linesByDate[dateKey]) stats.linesByDate[dateKey] = { added: 0, deleted: 0, files: 0 };
861
+ stats.linesByDate[dateKey].added += c.linesAdded || 0;
862
+ stats.linesByDate[dateKey].deleted += c.linesDeleted || 0;
863
+ stats.linesByDate[dateKey].files += (c.files || []).length;
864
+ stats.linesAdded += c.linesAdded || 0;
865
+ stats.linesDeleted += c.linesDeleted || 0;
866
+ }
867
+ recomputeFilesChanged(stats);
868
+ }
869
+
870
+ function markAuthorOwnership(stats, expectedAuthor) {
871
+ const normalizedExpected = (expectedAuthor || '').toLowerCase();
872
+ for (const c of stats.commitList || []) {
873
+ c.expectedAuthor = expectedAuthor || null;
874
+ c.authorMatchesConfig = normalizedExpected
875
+ ? (c.author || '').toLowerCase() === normalizedExpected
876
+ : null;
877
+ }
878
+ }
879
+
880
+ function hasLocalSessionEvidence(commit) {
881
+ if (!commit.sessionId) return false;
882
+ if (commit.sessionAttribution === 'strong') return true;
883
+ return (commit.aiEvidenceDetails?.matchedFileCount || 0) > 0;
884
+ }
885
+
886
+ function filterCommitsForUser(stats) {
887
+ const commits = stats.commitList || [];
888
+ const hasAuthorOwnershipMetadata = commits.some(c => c.expectedAuthor || c.authorMatchesConfig !== undefined);
889
+
890
+ for (const c of commits) {
891
+ c.countedForUser = !hasAuthorOwnershipMetadata
892
+ || c.authorMatchesConfig === true
893
+ || hasLocalSessionEvidence(c);
894
+ }
895
+
896
+ if (!hasAuthorOwnershipMetadata) return;
897
+ stats.commitList = commits.filter(c => c.countedForUser);
898
+ recomputeStatsFromCommitList(stats);
899
+ }
580
900
 
581
901
  // ── async versions (server) with cache ──
582
902
 
583
903
  const gitCache = new Map();
904
+ const GIT_CACHE_MAX = 500;
905
+ const GIT_CACHE_TTL = 60_000;
584
906
  const CACHE_VERSION = 'v3';
585
907
 
586
- async function getGitStatsAsync(repoPath, since, until, author = null) {
587
- const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
588
- const cached = gitCache.get(cacheKey);
589
- if (cached && Date.now() - cached.ts < 60_000) return cached.stats;
908
+ function evictGitCache() {
909
+ const now = Date.now();
910
+ for (const [key, val] of gitCache) {
911
+ if (now - val.ts > GIT_CACHE_TTL) gitCache.delete(key);
912
+ }
913
+ while (gitCache.size > GIT_CACHE_MAX) {
914
+ const oldest = gitCache.keys().next().value;
915
+ gitCache.delete(oldest);
916
+ }
917
+ }
918
+
919
+ async function getGitStatsAsync(repoPath, since, until, author = null) {
920
+ const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
921
+ const cached = gitCache.get(cacheKey);
922
+ if (cached && Date.now() - cached.ts < GIT_CACHE_TTL) return cached.stats;
590
923
 
591
924
  try {
592
925
  await execAsync('git rev-parse --git-dir', { cwd: repoPath });
593
926
  } catch {
594
927
  return emptyResult();
595
928
  }
596
-
597
- try {
598
- const output = await execAsync(`git log ${buildGitArgs(since, until, author)}`, {
599
- cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
600
- });
601
- const stats = parseGitLogOutput(output, repoPath);
602
- gitCache.set(cacheKey, { stats, ts: Date.now() });
603
- return stats;
929
+
930
+ try {
931
+ const output = await execAsync(`git log ${buildGitArgs(since, until)}`, {
932
+ cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
933
+ });
934
+ const stats = parseGitLogOutput(output, repoPath);
935
+ markAuthorOwnership(stats, author);
936
+ gitCache.set(cacheKey, { stats, ts: Date.now() });
937
+ evictGitCache();
938
+ return stats;
604
939
  } catch {
605
940
  return emptyResult();
606
941
  }
@@ -637,31 +972,6 @@ export function invalidateGitCache() {
637
972
 
638
973
  // ── Session ↔ Commit 关联 ──
639
974
 
640
- function normalizePath(p) {
641
- if (!p) return '';
642
- return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase();
643
- }
644
-
645
- function toRelativeRepoPath(filePath, repoPath) {
646
- const fileN = normalizePath(filePath);
647
- const repoN = normalizePath(repoPath);
648
- if (!fileN) return '';
649
- if (!repoN) return normalizeCommitFilePath(fileN.replace(/^[a-z]:\//i, ''));
650
- if (fileN === repoN) return '';
651
- if (fileN.startsWith(repoN + '/')) return fileN.slice(repoN.length + 1);
652
- const repoTail = repoN.split('/').filter(Boolean).pop();
653
- if (repoTail) {
654
- const marker = `/${repoTail}/`;
655
- const idx = fileN.indexOf(marker);
656
- if (idx >= 0) return fileN.slice(idx + marker.length);
657
- }
658
- return fileN;
659
- }
660
-
661
- function normalizeCommitFilePath(filePath) {
662
- return normalizePath(filePath).replace(/^\.?\//, '');
663
- }
664
-
665
975
  function looksLikeFilePath(value) {
666
976
  if (typeof value !== 'string') return false;
667
977
  const v = value.trim();
@@ -754,21 +1064,43 @@ function extractTouchedFilesFromSession(session) {
754
1064
  const repoPath = session.project || '';
755
1065
  const files = new Set();
756
1066
  for (const tc of session.toolSequence || []) {
757
- // Write/Edit/NotebookEdit/MultiEdit 工具
758
- if (['Write', 'Edit', 'NotebookEdit', 'MultiEdit'].includes(tc.name)) {
759
- const rawPaths = collectFilePaths(tc.input);
1067
+ const name = tc.name || '';
1068
+ const input = tc.input || {};
1069
+
1070
+ // Claude 内置工具:Write/Edit/NotebookEdit/MultiEdit
1071
+ if (name === 'Write' || name === 'Edit' || name === 'NotebookEdit' || name === 'MultiEdit') {
1072
+ const rawPaths = collectFilePaths(input);
1073
+ for (const rawPath of rawPaths) {
1074
+ const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
1075
+ if (relative) files.add(relative);
1076
+ }
1077
+ continue;
1078
+ }
1079
+
1080
+ // MCP Serena 工具:replace_content, replace_symbol_body, insert_before/after_symbol 等
1081
+ // 以及其他带 relative_path/file_path 的 MCP 工具
1082
+ if (name.startsWith('mcp__serena') || name.startsWith('mcp__')) {
1083
+ // Serena 使用 relative_path,其他 MCP 工具可能使用 file_path/path
1084
+ const filePath = input.relative_path || input.file_path || input.path || '';
1085
+ if (filePath && typeof filePath === 'string') {
1086
+ const relative = normalizeCommitFilePath(toRepoRelativePath(filePath, repoPath));
1087
+ if (relative) files.add(relative);
1088
+ }
1089
+ // 部分 MCP 工具在 input 中嵌套了目标文件
1090
+ const rawPaths = collectFilePaths(input);
760
1091
  for (const rawPath of rawPaths) {
761
- const relative = normalizeCommitFilePath(toRelativeRepoPath(rawPath, repoPath));
1092
+ const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
762
1093
  if (relative) files.add(relative);
763
1094
  }
764
1095
  continue;
765
1096
  }
1097
+
766
1098
  // Bash 工具 — 从命令中提取文件路径
767
- if (tc.name === 'Bash') {
768
- const cmd = tc.input?.command || '';
1099
+ if (name === 'Bash') {
1100
+ const cmd = input.command || '';
769
1101
  const rawPaths = extractFilePathsFromBashCommand(cmd);
770
1102
  for (const rawPath of rawPaths) {
771
- const relative = normalizeCommitFilePath(toRelativeRepoPath(rawPath, repoPath));
1103
+ const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
772
1104
  if (relative) files.add(relative);
773
1105
  }
774
1106
  }
@@ -792,34 +1124,45 @@ function computeFileOverlap(sessionTouchedFiles, commitFiles) {
792
1124
  });
793
1125
  }
794
1126
 
795
- // 用于 commit.repo 与 session.project 之间宽松对齐:
796
- // decodeProjectName `-` 解码为 `/`(D--foo-bar → D://foo/bar),
797
- // 所以将 `-` `_` 统一转为 `/`,再以 `/` 为分隔符保留路径语义。
798
- // 这样 d:/foo-bar 和 d:/foo/bar 匹配(同一项目的解码差异),
799
- // 但 d:/foobar 和 d:/foo/bar 不匹配(不同项目)。
800
- function projectKey(p) {
801
- return normalizePath(p).replace(/[-_]/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').replace(/[^a-z0-9/]/g, '');
802
- }
803
-
804
- // 精确路径包含:parent 是 child 的前缀,且后面紧跟 '/' 或完全匹配
805
- function pathContains(parent, child) {
806
- if (parent === child) return true;
807
- return child.startsWith(parent + '/');
808
- }
809
-
810
- function projectMatches(commitRepoN, sessionProjectN) {
811
- if (!commitRepoN || !sessionProjectN) return true;
812
- // 精确路径匹配(双向:commit repo 可能是 session project 的子目录或反之)
813
- if (pathContains(commitRepoN, sessionProjectN) || pathContains(sessionProjectN, commitRepoN)) return true;
814
- // 宽松 key 对比(兜底:处理路径解码差异)
815
- const a = projectKey(commitRepoN);
816
- const b = projectKey(sessionProjectN);
817
- return a && b && (a === b);
1127
+ function sortAttributionCandidates(candidates) {
1128
+ return candidates.sort((a, b) => {
1129
+ if (b.score !== a.score) return b.score - a.score;
1130
+ return a.distanceMs - b.distanceMs;
1131
+ });
818
1132
  }
819
1133
 
820
- const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
821
- const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
822
- const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
1134
+ function candidateFromSession(commit, session, distanceMs) {
1135
+ const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
1136
+ return scoreSessionCandidate(commit, session, {
1137
+ distanceMs,
1138
+ fileOverlapRatio: overlap.fileOverlapRatio,
1139
+ matchedFiles: overlap.matchedFiles,
1140
+ projectMatches: true,
1141
+ });
1142
+ }
1143
+
1144
+ function getStepSessionIdCandidates(sessionId, session) {
1145
+ if (!sessionId) return [];
1146
+ const candidates = [sessionId];
1147
+ if (sessionId.includes(':')) return candidates;
1148
+
1149
+ const originByTool = {
1150
+ claude: 'claude_code',
1151
+ codex: 'codex_cli',
1152
+ };
1153
+ const origin = originByTool[session?.primaryTool];
1154
+ if (origin) {
1155
+ candidates.push(`${origin}:${sessionId}`);
1156
+ } else {
1157
+ candidates.push(`claude_code:${sessionId}`, `codex_cli:${sessionId}`);
1158
+ }
1159
+
1160
+ return [...new Set(candidates)];
1161
+ }
1162
+
1163
+ const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
1164
+ const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
1165
+ const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
823
1166
 
824
1167
  function toMs(iso) {
825
1168
  if (!iso) return NaN;
@@ -841,18 +1184,22 @@ function extractCommitBashTimestamps(session) {
841
1184
  return ts;
842
1185
  }
843
1186
 
844
- export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 * 60 * 1000 } = {}) {
1187
+ export function attributeCommitsToSessions(commits, sessions, options = {}) {
845
1188
  const result = { sessionCommitMap: {} };
846
1189
  if (!commits?.length || !sessions?.length) return result;
1190
+ const attributionOptions = resolveAttributionOptions(options.attribution || options);
1191
+ const bufferMs = options.bufferMs ?? attributionOptions.windows.weakWindowMinutes * 60 * 1000;
1192
+ const crossDayMs = attributionOptions.windows.crossDayWindowDays * 24 * 3600 * 1000;
847
1193
 
848
1194
  // 预计算每个 session 的 ms 范围 + 项目归一化 + bash commit 时间戳
849
1195
  const sIndex = sessions.map(s => ({
850
1196
  id: s.id,
851
- projectN: normalizePath(s.project || ''),
1197
+ projectN: normalizePathForGit(s.project || ''),
852
1198
  startMs: toMs(s.startTime),
853
1199
  endMs: toMs(s.endTime),
854
1200
  bashTs: extractCommitBashTimestamps(s),
855
1201
  touchedFiles: extractTouchedFilesFromSession(s),
1202
+ primaryTool: s.primaryTool,
856
1203
  }));
857
1204
 
858
1205
  // 阶段 1:重置 + 强信号匹配(Bash git commit)
@@ -860,13 +1207,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
860
1207
  c.sessionId = null;
861
1208
  c.sessionAttribution = null;
862
1209
  const commitMs = toMs(c.date);
863
- const commitRepoN = normalizePath(c.repo || '');
1210
+ const commitRepoN = normalizePathForGit(c.repo || '');
864
1211
  if (!Number.isFinite(commitMs)) continue;
865
1212
 
866
1213
  let matched = null;
867
1214
  for (const s of sIndex) {
868
1215
  if (!s.bashTs.length) continue;
869
- if (!projectMatches(commitRepoN, s.projectN)) continue;
1216
+ if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
870
1217
  for (const bts of s.bashTs) {
871
1218
  if (commitMs >= bts - STRONG_WINDOW_BEFORE_MS && commitMs <= bts + STRONG_WINDOW_AFTER_MS) {
872
1219
  matched = s;
@@ -899,14 +1246,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
899
1246
  for (const c of commits) {
900
1247
  if (c.sessionAttribution) continue;
901
1248
  const commitMs = toMs(c.date);
902
- const commitRepoN = normalizePath(c.repo || '');
1249
+ const commitRepoN = normalizePathForGit(c.repo || '');
903
1250
  if (!Number.isFinite(commitMs)) continue;
904
1251
 
905
- let best = null;
906
- let bestDist = Infinity;
1252
+ const candidates = [];
907
1253
  for (const s of sIndex) {
908
1254
  if (!Number.isFinite(s.startMs) || !Number.isFinite(s.endMs)) continue;
909
- if (!projectMatches(commitRepoN, s.projectN)) continue;
1255
+ if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
910
1256
 
911
1257
  // author 一致性校验:session 有已知 author 时,commit author 必须匹配
912
1258
  const knownAuthors = sessionAuthors.get(s.id);
@@ -917,13 +1263,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
917
1263
  if (commitMs < lo || commitMs > hi) continue;
918
1264
  const mid = (s.startMs + s.endMs) / 2;
919
1265
  const dist = Math.abs(commitMs - mid);
920
- if (dist < bestDist) {
921
- best = s;
922
- bestDist = dist;
923
- }
1266
+ candidates.push(candidateFromSession(c, s, dist));
924
1267
  }
925
1268
 
926
- if (best) {
1269
+ if (candidates.length) {
1270
+ const ranked = sortAttributionCandidates(candidates);
1271
+ const best = sIndex.find(s => s.id === ranked[0].sessionId);
1272
+ c.attributionCandidates = ranked.slice(0, 3);
927
1273
  c.sessionId = best.id;
928
1274
  c.sessionAttribution = 'weak';
929
1275
  if (!result.sessionCommitMap[best.id]) result.sessionCommitMap[best.id] = [];
@@ -936,29 +1282,28 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
936
1282
  for (const c of commits) {
937
1283
  if (c.sessionAttribution) continue;
938
1284
  const commitMs = toMs(c.date);
939
- const commitRepoN = normalizePath(c.repo || '');
1285
+ const commitRepoN = normalizePathForGit(c.repo || '');
940
1286
  if (!Number.isFinite(commitMs)) continue;
941
1287
 
942
- let best = null;
943
- let bestDist = Infinity;
1288
+ const candidates = [];
944
1289
  for (const s of sIndex) {
945
- if (!projectMatches(commitRepoN, s.projectN)) continue;
1290
+ if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
946
1291
  if (!Number.isFinite(s.endMs)) continue;
947
1292
  // commit 必须在 session 结束之后(不能是之前漏掉的)
948
1293
  if (commitMs < s.endMs) continue;
949
1294
  const dist = commitMs - s.endMs;
950
1295
  // 最多跨 3 天
951
- if (dist > 3 * 24 * 3600 * 1000) continue;
1296
+ if (dist > crossDayMs) continue;
952
1297
  // author 校验:session 有已知 author 时,commit author 必须匹配
953
1298
  const knownAuthors = sessionAuthors.get(s.id);
954
1299
  if (knownAuthors?.size && c.author && !knownAuthors.has(c.author.toLowerCase())) continue;
955
- if (dist < bestDist) {
956
- best = s;
957
- bestDist = dist;
958
- }
1300
+ candidates.push(candidateFromSession(c, s, dist));
959
1301
  }
960
1302
 
961
- if (best) {
1303
+ if (candidates.length) {
1304
+ const ranked = sortAttributionCandidates(candidates);
1305
+ const best = sIndex.find(s => s.id === ranked[0].sessionId);
1306
+ c.attributionCandidates = ranked.slice(0, 3);
962
1307
  // 文件交集前置检查:无交集时标记为 cross-day-weak
963
1308
  const commitFiles = (c.files || []).map(f => (f.path || '').replace(/\\/g, '/'));
964
1309
  const sessionFiles = best.touchedFiles || [];
@@ -1001,15 +1346,17 @@ export function attachCommitsToSessions(sessions, commitList) {
1001
1346
  }
1002
1347
 
1003
1348
  // 一次性收尾:跑 attribution + 三个聚合
1004
- export function finalizeGitStats(merged, sessions = [], options = {}) {
1005
- if (!merged) return merged;
1006
- const fileOverrides = loadAttributionOverrides();
1349
+ export async function finalizeGitStats(merged, sessions = [], options = {}) {
1350
+ if (!merged) return merged;
1351
+ const attributionOptions = resolveAttributionOptions(options.attribution || options);
1352
+ const stepTrackingOptions = options.stepTracking || {};
1353
+ const fileOverrides = loadAttributionOverrides();
1007
1354
  const inputOverrides = options.overrides || {};
1008
1355
  const mergedOverrides = {
1009
1356
  commits: { ...fileOverrides.commits, ...(inputOverrides.commits || {}) },
1010
1357
  files: { ...fileOverrides.files, ...(inputOverrides.files || {}) },
1011
1358
  };
1012
- const { sessionCommitMap } = attributeCommitsToSessions(merged.commitList, sessions);
1359
+ const { sessionCommitMap } = attributeCommitsToSessions(merged.commitList, sessions, { attribution: attributionOptions });
1013
1360
  merged.sessionCommitMap = sessionCommitMap;
1014
1361
  const sessionsById = new Map((sessions || []).map(s => [s.id, s]));
1015
1362
  for (const s of sessions || []) {
@@ -1031,6 +1378,73 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1031
1378
  }
1032
1379
  }
1033
1380
 
1381
+ // Step 1.5: Enrich commits with line-level step blame when available
1382
+ const stepTrackers = new Map();
1383
+ if (stepTrackingOptions.enabled !== false) try {
1384
+ const { StepTracker } = await import('./step-tracker.js');
1385
+ const projectRoots = [...new Set((sessions || []).map(s => s.project).filter(Boolean))];
1386
+ // Also check repo paths from commits
1387
+ for (const c of merged.commitList || []) {
1388
+ if (c.repo) projectRoots.push(c.repo);
1389
+ }
1390
+ for (const root of [...new Set(projectRoots.map(normalizePathForGit))]) {
1391
+ if (!root) continue;
1392
+ const tracker = new StepTracker(root, {
1393
+ dbPath: stepTrackingOptions.dbPath,
1394
+ maxFileSize: stepTrackingOptions.maxFileSize,
1395
+ });
1396
+ if (await tracker.isAvailableAsync()) {
1397
+ await tracker.open();
1398
+ stepTrackers.set(root, tracker);
1399
+ }
1400
+ }
1401
+ } catch {
1402
+ for (const tracker of stepTrackers.values()) tracker.close();
1403
+ stepTrackers.clear();
1404
+ }
1405
+
1406
+ if (stepTrackers.size > 0) {
1407
+ for (const c of merged.commitList || []) {
1408
+ if (!c.sessionId) continue;
1409
+ const candidateRoots = [
1410
+ c.repo,
1411
+ sessionsById.get(c.sessionId)?.project,
1412
+ ].filter(Boolean);
1413
+ let stepTracker = null;
1414
+ for (const candidateRoot of candidateRoots) {
1415
+ const normalizedCandidate = normalizePathForGit(candidateRoot);
1416
+ for (const [root, tracker] of stepTrackers.entries()) {
1417
+ if (projectMatchesFromGitPaths(root, normalizedCandidate)) {
1418
+ stepTracker = tracker;
1419
+ break;
1420
+ }
1421
+ }
1422
+ if (stepTracker) break;
1423
+ }
1424
+ if (!stepTracker && stepTrackers.size === 1) {
1425
+ stepTracker = stepTrackers.values().next().value;
1426
+ }
1427
+ if (!stepTracker) continue;
1428
+ try {
1429
+ const session = sessionsById.get(c.sessionId);
1430
+ for (const stepSessionId of getStepSessionIdCandidates(c.sessionId, session)) {
1431
+ const lineBlame = stepTracker.getLineAttributionForCommit({
1432
+ ...c,
1433
+ sessionId: stepSessionId,
1434
+ });
1435
+ if (lineBlame) {
1436
+ c.lineBlame = lineBlame;
1437
+ break;
1438
+ }
1439
+ }
1440
+ } catch { /* best effort */ }
1441
+ }
1442
+ for (const tracker of stepTrackers.values()) tracker.close();
1443
+ }
1444
+
1445
+ // Step 1.6: compute developer behavioral baselines
1446
+ const authorBaselines = computeAuthorBaseline(merged.commitList);
1447
+
1034
1448
  // Step 2: 信心度评估(保持现有逻辑)
1035
1449
  for (const c of merged.commitList || []) {
1036
1450
  if (!c.sessionId || c.attributionType === 'explicit') continue;
@@ -1096,6 +1510,30 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1096
1510
  signals.push('strongWithoutFileOverlap');
1097
1511
  }
1098
1512
 
1513
+ // Developer baseline deviation
1514
+ const baseline = authorBaselines.get(c.author);
1515
+ const deviation = computeBaselineDeviation(c, baseline);
1516
+ if (deviation >= 0.4) {
1517
+ signals.push('baselineDeviationHigh');
1518
+ if (confidence === AI_CONFIDENCE.MEDIUM) confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
1519
+ } else if (deviation >= 0.2) {
1520
+ signals.push('baselineDeviationMedium');
1521
+ } else if (deviation <= 0.05 && baseline && c.attributionType !== 'explicit') {
1522
+ signals.push('humanBaselineMatch');
1523
+ }
1524
+
1525
+ // Negative signals: downgrade confidence
1526
+ const negSignals = detectNegativeSignals(c.subject, c.body, c.linesAdded, c.linesDeleted, (c.files || []).length);
1527
+ if (negSignals.includes('humanMergeCommit')) {
1528
+ confidence = AI_CONFIDENCE.NONE;
1529
+ attributionType = 'human_merge';
1530
+ } else if (negSignals.length > 0) {
1531
+ if (confidence === AI_CONFIDENCE.HIGH) confidence = AI_CONFIDENCE.MEDIUM;
1532
+ else if (confidence === AI_CONFIDENCE.MEDIUM) confidence = AI_CONFIDENCE.LOW;
1533
+ else if (confidence === AI_CONFIDENCE.LOW) confidence = AI_CONFIDENCE.NONE;
1534
+ c.negativeSignals = negSignals;
1535
+ }
1536
+
1099
1537
  const ai = createAIAttribution({
1100
1538
  confidence,
1101
1539
  signals,
@@ -1109,8 +1547,22 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1109
1547
  c.attributionType = ai.attributionType;
1110
1548
  }
1111
1549
 
1112
- const attributionItems = [];
1113
- for (const c of merged.commitList || []) {
1550
+ // Step 2.5: composite continuous scoring for all commits
1551
+ for (const c of merged.commitList || []) {
1552
+ c.aiScore = computeContinuousScore(c, attributionOptions);
1553
+ const mappedConfidence = scoreToConfidence(c.aiScore, attributionOptions);
1554
+ // Only override if no explicit signature and continuous score disagrees
1555
+ if (c.attributionType !== 'explicit') {
1556
+ c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
1557
+ c.isAI = isCountedAIConfidence(c.aiConfidence);
1558
+ c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
1559
+ }
1560
+ }
1561
+
1562
+ filterCommitsForUser(merged);
1563
+
1564
+ const attributionItems = [];
1565
+ for (const c of merged.commitList || []) {
1114
1566
  const commitOverride = mergedOverrides.commits[c.hash] || null;
1115
1567
  const fileOverride = (c.files || []).find(f => mergedOverrides.files[`${c.hash}:${f.path}`]);
1116
1568
  const fileOverrideValue = fileOverride ? mergedOverrides.files[`${c.hash}:${fileOverride.path}`] : null;
@@ -1140,7 +1592,7 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1140
1592
  }
1141
1593
 
1142
1594
  // Step 3: 全局 + 按工具聚合
1143
- merged.aiContribution = computeAIContribution(merged.commitList);
1595
+ merged.aiContribution = computeAIContribution(merged.commitList, null, attributionOptions);
1144
1596
  // 动态收集所有出现的 attributedTool,确保新工具自动覆盖
1145
1597
  const toolSet = new Set();
1146
1598
  for (const c of merged.commitList || []) {
@@ -1150,7 +1602,7 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1150
1602
  for (const t of ['claude', 'codex', 'opencode', 'generic-ai']) toolSet.add(t);
1151
1603
  merged.aiContributionByTool = {};
1152
1604
  for (const tool of toolSet) {
1153
- merged.aiContributionByTool[tool] = computeAIContribution(merged.commitList, tool);
1605
+ merged.aiContributionByTool[tool] = computeAIContribution(merged.commitList, tool, attributionOptions);
1154
1606
  }
1155
1607
  merged.attributionSummary = aggregateAttribution(attributionItems);
1156
1608
  merged.commitTypes = computeCommitTypes(merged.commitList);