lumencode 1.0.0 → 1.2.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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadConfig, initConfig, getConfigPath } from './lib/config.js';
3
3
  import { collectAllRecords, computeUsageStats, filterRecordsByPeriod, normalizeProjectPath, computeTrendData, computePrevPeriodRange, groupBySessions } from './lib/aggregate.js';
4
- import { getGitStatsForMultipleReposAsync, getPerRepoGitStats, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
4
+ import { getGitStatsForMultipleReposAsync, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
5
5
  import { invalidateFileCache } from './lib/cache.js';
6
6
  import { generateReport, generateWorkReport } from './lib/report.js';
7
7
  import { startServer } from './lib/server.js';
@@ -97,54 +97,60 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
97
97
  return null;
98
98
  }
99
99
 
100
- // 其余逻辑保持不变
101
100
  const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
102
- const usageStats = computeUsageStats(filtered, config.scenarioKeywords, config.costMode);
103
- const sessions = groupBySessions(filtered);
101
+ const reposConfigured = !!(config.repos && config.repos.length > 0);
104
102
 
105
- let gitStats = null;
106
- if (config.repos && config.repos.length > 0) {
107
- // 按工具过滤后,只统计该工具覆盖的项目对应的 repos
103
+ // ── 第一层并发:三个独立的同步计算 ──
104
+ const [usageStats, sessions, billingBlocks] = [
105
+ computeUsageStats(filtered, config.scenarioKeywords, config.costMode),
106
+ groupBySessions(filtered),
107
+ identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode),
108
+ ];
109
+
110
+ // ── 第二层并发:gitStats(async) + trendData + prevStats ──
111
+ const gitStatsPromise = (async () => {
112
+ if (!reposConfigured) return null;
108
113
  const coveredBases = new Set(filtered.map(r => {
109
114
  const p = r.project || '';
110
115
  return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
111
116
  }).filter(Boolean));
112
- const toolRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
113
- if (toolRepos.length > 0) {
114
- // 扩展 git 查询窗口 +2 天,以匹配 session 跨天的延迟提交
115
- const extendedEnd = new Date(end);
116
- extendedEnd.setDate(extendedEnd.getDate() + 2);
117
- const extendedEndStr = extendedEnd.toISOString().slice(0, 10) + 'T23:59:59';
118
- gitStats = await getGitStatsForMultipleReposAsync(toolRepos, start, extendedEndStr);
119
- gitStats = finalizeGitStats(gitStats, sessions);
120
- // 归因已完成,过滤 commitList 到原始窗口,重算基础统计
121
- if (gitStats.commitList) {
122
- const windowStart = start;
123
- const windowEnd = end + 'T23:59:59';
124
- const inWindow = gitStats.commitList.filter(c => (c.date || '') >= windowStart && (c.date || '') <= windowEnd);
125
- gitStats.commits = inWindow.length;
126
- gitStats.linesAdded = inWindow.reduce((s, c) => s + (c.linesAdded || 0), 0);
127
- gitStats.linesDeleted = inWindow.reduce((s, c) => s + (c.linesDeleted || 0), 0);
128
- gitStats.filesChanged = new Set(inWindow.flatMap(c => (c.files || []).map(f => f.path))).size;
129
- // commitList 保留全部(含跨天),前端 drill-down 需要
130
- // 但提交类型和热点只基于窗口内
131
- gitStats.commitTypes = computeCommitTypes(inWindow);
132
- gitStats.fileHotspots = computeFileHotspots(inWindow, 10);
133
- }
117
+ let toolRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
118
+ if (toolRepos.length === 0) toolRepos = config.repos;
119
+ if (toolRepos.length === 0) return null;
120
+ const extendedEnd = new Date(end);
121
+ extendedEnd.setDate(extendedEnd.getDate() + 2);
122
+ const extendedEndStr = extendedEnd.toISOString().slice(0, 10) + 'T23:59:59';
123
+ let gs = await getGitStatsForMultipleReposAsync(toolRepos, start, extendedEndStr);
124
+ gs = finalizeGitStats(gs, sessions);
125
+ if (gs.commitList) {
126
+ const windowStart = start;
127
+ const windowEnd = end + 'T23:59:59';
128
+ const inWindow = gs.commitList.filter(c => (c.date || '') >= windowStart && (c.date || '') <= windowEnd);
129
+ gs.commits = inWindow.length;
130
+ gs.linesAdded = inWindow.reduce((s, c) => s + (c.linesAdded || 0), 0);
131
+ gs.linesDeleted = inWindow.reduce((s, c) => s + (c.linesDeleted || 0), 0);
132
+ gs.filesChanged = new Set(inWindow.flatMap(c => (c.files || []).map(f => f.path))).size;
133
+ gs.commitTypes = computeCommitTypes(inWindow);
134
+ gs.fileHotspots = computeFileHotspots(inWindow, 10);
134
135
  }
135
- }
136
+ return gs;
137
+ })();
136
138
 
137
- const trendData = computeTrendData(toolRecords, period, dateArg);
139
+ const trendDataPromise = Promise.resolve(computeTrendData(toolRecords, period, dateArg));
138
140
 
139
- // Previous period stats
140
- const prevRange = computePrevPeriodRange(period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
141
- const prevFiltered = toolRecords.filter(r => {
142
- if (!r.timestamp) return false;
143
- const date = r.timestamp.slice(0, 10);
144
- return date >= prevRange.start && date <= prevRange.end;
145
- });
146
- const prevStats = prevFiltered.length > 0 ? computeUsageStats(prevFiltered, config.scenarioKeywords, config.costMode) : null;
141
+ const prevStatsPromise = (async () => {
142
+ const prevRange = computePrevPeriodRange(period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
143
+ const prevFiltered = toolRecords.filter(r => {
144
+ if (!r.timestamp) return false;
145
+ const date = r.timestamp.slice(0, 10);
146
+ return date >= prevRange.start && date <= prevRange.end;
147
+ });
148
+ return prevFiltered.length > 0 ? computeUsageStats(prevFiltered, config.scenarioKeywords, config.costMode) : null;
149
+ })();
150
+
151
+ const [gitStats, trendData, prevStats] = await Promise.all([gitStatsPromise, trendDataPromise, prevStatsPromise]);
147
152
 
153
+ // ── 第三层:依赖 usageStats 的同步派生 ──
148
154
  const slimSessions = sessions.map(s => ({
149
155
  id: s.id,
150
156
  project: s.project,
@@ -154,14 +160,8 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
154
160
  commits: s.commits || [],
155
161
  }));
156
162
 
157
- const billingBlocks = identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode);
158
-
159
- const reposConfigured = !!(config.repos && config.repos.length > 0);
160
-
161
- // 合并 toolBreakdown:usageStats 内含 token 粒度数据,parsers 提供 sessionCount
162
163
  const statsTB = usageStats.toolBreakdown || {};
163
164
  const mergedBreakdown = {};
164
- // 以 parsers toolBreakdown 为基础(包含所有已启用工具),补充 stats 中的 token 数据
165
165
  for (const [name, base] of Object.entries(toolBreakdown)) {
166
166
  const s = statsTB[name] || {};
167
167
  mergedBreakdown[name] = {
@@ -173,7 +173,6 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
173
173
  sessionCount: base.sessionCount || 0,
174
174
  };
175
175
  }
176
- // 补充 stats 中有但 parsers 无的极端情况
177
176
  for (const [name, data] of Object.entries(statsTB)) {
178
177
  if (!mergedBreakdown[name]) {
179
178
  mergedBreakdown[name] = {
@@ -187,31 +186,43 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
187
186
  }
188
187
  }
189
188
 
190
- // Per-project details
189
+ // ── 第四层:projectDetails(从 commitList 按 repo 分组派生,无需再次 git 调用)──
191
190
  const projectDetails = {};
192
191
  const projEntries = Object.entries(usageStats.projects || {}).sort((a, b) => b[1].requests - a[1].requests);
193
- if (reposConfigured && gitStats) {
194
- const repoMap = await getPerRepoGitStats(
195
- config.repos.filter(r => projEntries.some(([name]) => {
196
- const base = r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
197
- return base === name;
198
- })),
199
- start, end + 'T23:59:59'
200
- );
192
+ if (reposConfigured && gitStats?.commitList?.length) {
193
+ const windowEnd = end + 'T23:59:59';
194
+ const inWindow = gitStats.commitList.filter(c => (c.date || '') >= start && (c.date || '') <= windowEnd);
195
+ const repoGroups = new Map();
196
+ for (const c of inWindow) {
197
+ const base = (c.repo || '').replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
198
+ if (!base) continue;
199
+ if (!repoGroups.has(base)) repoGroups.set(base, []);
200
+ repoGroups.get(base).push(c);
201
+ }
201
202
  for (const [projName, projStats] of projEntries) {
202
- const matchedRepo = [...repoMap.keys()].find(r => r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() === projName);
203
- const repoGit = matchedRepo ? repoMap.get(matchedRepo) : null;
204
- const topCommits = (repoGit?.commitList || [])
203
+ const repoCommits = repoGroups.get(projName) || [];
204
+ if (repoCommits.length === 0) {
205
+ projectDetails[projName] = { usage: projStats, git: null, topCommits: [] };
206
+ continue;
207
+ }
208
+ const uniqueFiles = new Set();
209
+ let linesAdded = 0, linesDeleted = 0;
210
+ for (const c of repoCommits) {
211
+ linesAdded += c.linesAdded || 0;
212
+ linesDeleted += c.linesDeleted || 0;
213
+ for (const f of c.files || []) uniqueFiles.add(f.path);
214
+ }
215
+ const topCommits = repoCommits
205
216
  .filter(c => c.type === 'feat' || c.type === 'fix')
206
217
  .slice(0, 5)
207
218
  .map(c => ({ type: c.type, subject: c.subject, scope: c.scope }));
208
219
  projectDetails[projName] = {
209
220
  usage: projStats,
210
- git: repoGit ? {
211
- commits: repoGit.commits, linesAdded: repoGit.linesAdded, linesDeleted: repoGit.linesDeleted,
212
- filesChanged: repoGit.filesChanged,
213
- fileHotspots: (repoGit.fileHotspots || []).slice(0, 5),
214
- } : null,
221
+ git: {
222
+ commits: repoCommits.length, linesAdded, linesDeleted,
223
+ filesChanged: uniqueFiles.size,
224
+ fileHotspots: computeFileHotspots(repoCommits, 5),
225
+ },
215
226
  topCommits,
216
227
  };
217
228
  }
package/lib/git.js CHANGED
@@ -157,6 +157,13 @@ const AI_CONFIDENCE = {
157
157
  HIGH: 'high',
158
158
  };
159
159
 
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
+
160
167
  function isCountedAIConfidence(confidence) {
161
168
  return confidence === AI_CONFIDENCE.HIGH || confidence === AI_CONFIDENCE.MEDIUM;
162
169
  }
@@ -173,7 +180,7 @@ function resolveToolFromSignals(signals) {
173
180
  return null;
174
181
  }
175
182
 
176
- function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null } = {}) {
183
+ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null, negativeSignals = [] } = {}) {
177
184
  const normalizedSignals = [...new Set(signals)];
178
185
  return {
179
186
  isAI: isCountedAIConfidence(confidence),
@@ -184,6 +191,7 @@ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], at
184
191
  aiEvidence: normalizedSignals,
185
192
  attributionType,
186
193
  detectedTool,
194
+ negativeSignals,
187
195
  };
188
196
  }
189
197
 
@@ -241,6 +249,203 @@ function loadAttributionOverrides() {
241
249
  }
242
250
  }
243
251
 
252
+ // ── Style-based heuristic detection (fallback when no explicit signals) ──
253
+
254
+ 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;
255
+
256
+ function detectStyleSignals(subject, body) {
257
+ const signals = [];
258
+ let score = 0;
259
+
260
+ // Bullet list: 3+ lines starting with "- " or "* "
261
+ const bulletLines = (body || '').split('\n').filter(l => /^\s*[-*]\s+\S/.test(l));
262
+ if (bulletLines.length >= 3) {
263
+ signals.push('styleBulletList');
264
+ score += 2;
265
+ }
266
+
267
+ // Conventional commit with scope: type(scope): subject
268
+ if (CONVENTIONAL_RE.test(subject)) {
269
+ const m = subject.match(CONVENTIONAL_RE);
270
+ if (m && m[2]) {
271
+ signals.push('styleConventionalScope');
272
+ score += 1;
273
+ }
274
+ }
275
+
276
+ // Long structured body: >150 chars with 3+ non-empty lines
277
+ const bodyLines = (body || '').split('\n').filter(l => l.trim());
278
+ if ((body || '').length > 150 && bodyLines.length >= 3) {
279
+ signals.push('styleLongStructuredBody');
280
+ score += 1;
281
+ }
282
+
283
+ // Technical detail: body contains file paths or parenthetical notes
284
+ if (/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.[a-z]{1,4}/.test(body) || /\([^)]{10,}\)/.test(body)) {
285
+ signals.push('styleTechnicalDetail');
286
+ score += 1;
287
+ }
288
+
289
+ // Imperative mood: 3+ body lines start with imperative verbs (strip bullet prefix first)
290
+ const imperativeCount = bodyLines.filter(l => IMPERATIVE_VERBS.test(l.trim().replace(/^[-*]\s+/, ''))).length;
291
+ if (imperativeCount >= 3) {
292
+ signals.push('styleImperativeMood');
293
+ score += 1;
294
+ }
295
+
296
+ return { signals, score };
297
+ }
298
+
299
+ // ── Negative signals (reduce false positives) ──
300
+
301
+ const WIP_RE = /\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i;
302
+ const MERGE_RE = /^Merge\s+(?:branch|pull|remote|tag)/i;
303
+
304
+ export function detectNegativeSignals(subject, body, linesAdded, linesDeleted, fileCount) {
305
+ const signals = [];
306
+ const trimmedBody = (body || '').trim();
307
+ const trimmedSubject = (subject || '').trim();
308
+ const totalLines = (linesAdded || 0) + (linesDeleted || 0);
309
+ const files = fileCount || 0;
310
+
311
+ // Informal: very short subject AND body, no conventional commit prefix
312
+ const isConventional = CONVENTIONAL_RE.test(trimmedSubject);
313
+ if (trimmedSubject.length <= 10 && trimmedBody.length <= 10 && !/\n/.test(trimmedBody) && !isConventional) {
314
+ signals.push('humanInformal');
315
+ }
316
+ if (MERGE_RE.test(trimmedSubject)) {
317
+ signals.push('humanMergeCommit');
318
+ }
319
+ if (totalLines <= 2 && files <= 1) {
320
+ signals.push('humanSmallScope');
321
+ }
322
+ if (WIP_RE.test(trimmedSubject)) {
323
+ signals.push('humanWIP');
324
+ }
325
+ return signals;
326
+ }
327
+
328
+ // ── Developer behavioral baseline ──
329
+
330
+ function computeAuthorBaseline(commitList) {
331
+ const authorStats = new Map();
332
+ for (const c of commitList || []) {
333
+ const email = c.author || 'unknown';
334
+ if (!authorStats.has(email)) {
335
+ authorStats.set(email, {
336
+ count: 0, totalSubjectLen: 0, totalBodyLen: 0,
337
+ bulletCount: 0, convCount: 0, totalFiles: 0, totalLines: 0,
338
+ });
339
+ }
340
+ const s = authorStats.get(email);
341
+ s.count++;
342
+ s.totalSubjectLen += (c.subject || '').length;
343
+ s.totalBodyLen += (c.body || '').length;
344
+ s.totalFiles += (c.files || []).length;
345
+ s.totalLines += (c.linesAdded || 0) + (c.linesDeleted || 0);
346
+ if (/^\s*[-*]\s+\S/m.test(c.body || '')) s.bulletCount++;
347
+ if (CONVENTIONAL_RE.test(c.subject)) s.convCount++;
348
+ }
349
+ const baselines = new Map();
350
+ for (const [email, s] of authorStats) {
351
+ if (s.count < 3) { baselines.set(email, null); continue; }
352
+ baselines.set(email, {
353
+ avgSubjectLen: s.totalSubjectLen / s.count,
354
+ avgBodyLen: s.totalBodyLen / s.count,
355
+ bulletRatio: s.bulletCount / s.count,
356
+ convRatio: s.convCount / s.count,
357
+ avgFiles: s.totalFiles / s.count,
358
+ avgLines: s.totalLines / s.count,
359
+ });
360
+ }
361
+ return baselines;
362
+ }
363
+
364
+ function computeBaselineDeviation(commit, baseline) {
365
+ if (!baseline) return 0;
366
+ let deviation = 0;
367
+ let factors = 0;
368
+ const body = commit.body || '';
369
+
370
+ if (baseline.avgBodyLen > 0) {
371
+ const ratio = body.length / baseline.avgBodyLen;
372
+ deviation += ratio > 2 ? 0.3 : (ratio > 1.5 ? 0.15 : 0);
373
+ factors++;
374
+ }
375
+ const hasBullets = /^\s*[-*]\s+\S/m.test(body);
376
+ if (baseline.bulletRatio < 0.1 && hasBullets) { deviation += 0.3; factors++; }
377
+ const fileCount = (commit.files || []).length;
378
+ if (baseline.avgFiles > 0) {
379
+ const ratio = fileCount / baseline.avgFiles;
380
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
381
+ factors++;
382
+ }
383
+ const lineCount = (commit.linesAdded || 0) + (commit.linesDeleted || 0);
384
+ if (baseline.avgLines > 0) {
385
+ const ratio = lineCount / baseline.avgLines;
386
+ deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
387
+ factors++;
388
+ }
389
+
390
+ return factors > 0 ? Math.min(deviation / factors, 1) : 0;
391
+ }
392
+
393
+ // ── Composite continuous scoring ──
394
+
395
+ function computeContinuousScore(commit) {
396
+ let score = 0;
397
+ const signals = new Set(commit.aiSignals || []);
398
+
399
+ // Explicit signatures
400
+ if (signals.has('coAuthor') || signals.has('generatedWith') || signals.has('assistedBy')) score += 0.85;
401
+ if (signals.has('coAuthorCopilot') || signals.has('coAuthorCursor') || signals.has('coAuthorCodex')) score += 0.85;
402
+ if (signals.has('robotEmoji') || signals.has('coAuthorOpencode')) score += 0.85;
403
+ if (signals.has('authorClaude') || signals.has('authorBot')) score += 0.80;
404
+ if (signals.has('generatedWithAider') || signals.has('aiderTag')) score += 0.85;
405
+ if (signals.has('generatedWithCodex') || signals.has('coAuthorCodex')) score += 0.85;
406
+ if (signals.has('coAuthorWindsurf') || signals.has('coAuthorAugment') || signals.has('coAuthorCline')) score += 0.85;
407
+ if (signals.has('aiGenerated') || signals.has('generatedByAI') || signals.has('viaAI') || signals.has('aiTag')) score += 0.70;
408
+
409
+ // Session signals
410
+ if (commit.sessionAttribution === 'strong') score += 0.40;
411
+ else if (commit.sessionAttribution === 'cross-day') score += 0.25;
412
+ else if (commit.sessionAttribution === 'weak') score += 0.15;
413
+ else if (commit.sessionAttribution === 'cross-day-weak') score += 0.10;
414
+
415
+ // File overlap
416
+ const overlap = commit.aiEvidenceDetails?.fileOverlapRatio || 0;
417
+ score += overlap * 0.30;
418
+
419
+ // Style heuristic
420
+ if (signals.has('styleBulletList')) score += 0.15;
421
+ if (signals.has('styleConventionalScope')) score += 0.05;
422
+ if (signals.has('styleImperativeMood')) score += 0.10;
423
+ if (signals.has('styleLongStructuredBody')) score += 0.05;
424
+
425
+ // Baseline deviation
426
+ if (signals.has('baselineDeviationHigh')) score += 0.15;
427
+ else if (signals.has('baselineDeviationMedium')) score += 0.08;
428
+
429
+ // Negative signals
430
+ const negSignals = new Set(commit.negativeSignals || []);
431
+ if (negSignals.has('humanMergeCommit')) score -= 0.50;
432
+ if (negSignals.has('humanInformal')) score -= 0.20;
433
+ if (negSignals.has('humanSmallScope')) score -= 0.15;
434
+ if (negSignals.has('humanWIP')) score -= 0.15;
435
+
436
+ // Baseline match (human pattern)
437
+ if (signals.has('humanBaselineMatch')) score -= 0.10;
438
+
439
+ return Math.max(0, Math.min(1, score));
440
+ }
441
+
442
+ function scoreToConfidence(score) {
443
+ if (score >= 0.75) return AI_CONFIDENCE.HIGH;
444
+ if (score >= 0.45) return AI_CONFIDENCE.MEDIUM;
445
+ if (score >= 0.20) return AI_CONFIDENCE.LOW;
446
+ return AI_CONFIDENCE.NONE;
447
+ }
448
+
244
449
  export function detectAICommit(subject = '', author = '', body = '') {
245
450
  const haystack = `${subject}\n${body}`;
246
451
  const signals = [];
@@ -278,6 +483,32 @@ export function detectAICommit(subject = '', author = '', body = '') {
278
483
  detectedTool,
279
484
  });
280
485
  }
486
+
487
+ // Negative signals: skip style heuristic for obvious human patterns
488
+ // Only check message-level signals here (line/file counts not available)
489
+ const subjectNeg = (subject || '').trim();
490
+ const bodyNeg = (body || '').trim();
491
+ const negSignals = [];
492
+ const isConvSubject = CONVENTIONAL_RE.test(subjectNeg);
493
+ if (subjectNeg.length <= 10 && bodyNeg.length <= 10 && !/\n/.test(bodyNeg) && !isConvSubject) negSignals.push('humanInformal');
494
+ if (/^Merge\s+(?:branch|pull|remote|tag)/i.test(subjectNeg)) negSignals.push('humanMergeCommit');
495
+ if (/\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i.test(subjectNeg)) negSignals.push('humanWIP');
496
+ if (negSignals.length > 0) {
497
+ return createAIAttribution({ negativeSignals: negSignals });
498
+ }
499
+
500
+ // Fallback: style-based heuristic detection
501
+ const { signals: styleSignals, score } = detectStyleSignals(subject, body);
502
+ if (styleSignals.length > 0 && score >= 4) {
503
+ const confidence = score >= 6 ? AI_CONFIDENCE.MEDIUM : AI_CONFIDENCE.LOW;
504
+ return createAIAttribution({
505
+ confidence,
506
+ signals: styleSignals,
507
+ attributionType: score >= 6 ? 'style_heuristic_strong' : 'style_heuristic',
508
+ detectedTool: null,
509
+ });
510
+ }
511
+
281
512
  return createAIAttribution();
282
513
  }
283
514
 
@@ -285,6 +516,8 @@ export function detectAICommit(subject = '', author = '', body = '') {
285
516
 
286
517
  export function computeAIContribution(commits, toolFilter = null) {
287
518
  let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
519
+ let possibleAICommits = 0, possibleAILinesAdded = 0, possibleAILinesDeleted = 0;
520
+ let weightedAILinesAdded = 0, weightedAILinesDeleted = 0;
288
521
  let aiCommitLinesAdded = 0, aiCommitLinesDeleted = 0;
289
522
  let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
290
523
  let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
@@ -303,27 +536,39 @@ export function computeAIContribution(commits, toolFilter = null) {
303
536
  else if (confidence === AI_CONFIDENCE.MEDIUM) mediumConfidenceCommits++;
304
537
  else if (confidence === AI_CONFIDENCE.LOW) lowConfidenceCommits++;
305
538
 
539
+ // 计算文件级行数(用于 HIGH/MEDIUM/LOW 各自统计)
540
+ const matchedFiles = new Set((c.aiEvidenceDetails?.matchedFiles || []).map(normalizeCommitFilePath));
541
+ const useMatchedFiles = matchedFiles.size > 0;
542
+ let fileAdded = 0;
543
+ let fileDeleted = 0;
544
+ for (const f of c.files || []) {
545
+ const filePath = normalizeCommitFilePath(f.path);
546
+ if (useMatchedFiles && !matchedFiles.has(filePath)) continue;
547
+ fileAdded += f.added || 0;
548
+ fileDeleted += f.deleted || 0;
549
+ }
550
+ if (!useMatchedFiles && (c.attributionType === 'explicit' || c.attributionType?.startsWith('session_'))) {
551
+ fileAdded = c.linesAdded || 0;
552
+ fileDeleted = c.linesDeleted || 0;
553
+ }
554
+
306
555
  if (isCountedAIConfidence(confidence)) {
307
556
  aiCommits++;
308
557
  aiCommitLinesAdded += c.linesAdded || 0;
309
558
  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
559
  aiFileLinesAdded += fileAdded;
326
560
  aiFileLinesDeleted += fileDeleted;
561
+ } else if (confidence === AI_CONFIDENCE.LOW) {
562
+ possibleAICommits++;
563
+ possibleAILinesAdded += fileAdded;
564
+ possibleAILinesDeleted += fileDeleted;
565
+ }
566
+
567
+ // 加权计算:所有归因的 commit 都参与(包括 LOW)
568
+ const weight = CONFIDENCE_WEIGHTS[confidence] || 0;
569
+ if (weight > 0) {
570
+ weightedAILinesAdded += fileAdded * weight;
571
+ weightedAILinesDeleted += fileDeleted * weight;
327
572
  }
328
573
  }
329
574
  aiLinesAdded = aiFileLinesAdded;
@@ -331,20 +576,32 @@ export function computeAIContribution(commits, toolFilter = null) {
331
576
  const total = allCommits.length;
332
577
  const totalLinesChanged = totalLinesAdded + totalLinesDeleted;
333
578
  const aiLinesChanged = aiLinesAdded + aiLinesDeleted;
579
+ const possibleAILinesChanged = possibleAILinesAdded + possibleAILinesDeleted;
580
+ const weightedAILinesChanged = Math.round(weightedAILinesAdded + weightedAILinesDeleted);
334
581
  return {
335
582
  aiCommits,
336
- nonToolCommits: total - aiCommits,
337
- humanCommits: total - aiCommits,
583
+ possibleAICommits,
584
+ nonToolCommits: total - aiCommits - possibleAICommits,
585
+ humanCommits: total - aiCommits - possibleAICommits,
338
586
  aiCommitRatio: total > 0 ? aiCommits / total : 0,
587
+ possibleAICommitRatio: total > 0 ? possibleAICommits / total : 0,
339
588
  aiRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
589
+ aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
590
+ possibleAILineRatio: totalLinesChanged > 0 ? possibleAILinesChanged / totalLinesChanged : 0,
591
+ weightedAILineRatio: totalLinesChanged > 0 ? weightedAILinesChanged / totalLinesChanged : 0,
340
592
  toolFilter: toolFilter || null,
341
593
  aiLinesAdded,
342
594
  aiLinesDeleted,
343
595
  aiLinesChanged,
596
+ possibleAILinesAdded,
597
+ possibleAILinesDeleted,
598
+ possibleAILinesChanged,
599
+ weightedAILinesAdded: Math.round(weightedAILinesAdded),
600
+ weightedAILinesDeleted: Math.round(weightedAILinesDeleted),
601
+ weightedAILinesChanged,
344
602
  totalLinesAdded,
345
603
  totalLinesDeleted,
346
604
  totalLinesChanged,
347
- aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
348
605
  aiCommitLinesAdded,
349
606
  aiCommitLinesDeleted,
350
607
  aiFileLinesAdded,
@@ -409,6 +666,7 @@ export function parseGitLogOutput(output, repo = '') {
409
666
  current.aiEvidence = ai.aiEvidence;
410
667
  current.attributionType = ai.attributionType;
411
668
  current.detectedTool = ai.detectedTool || null;
669
+ current.negativeSignals = ai.negativeSignals || [];
412
670
  current.sessionId = null; // 由 finalize 阶段填充
413
671
 
414
672
  result.commits++;
@@ -441,7 +699,12 @@ export function parseGitLogOutput(output, repo = '') {
441
699
  const header = line.slice(COMMIT_SENTINEL.length);
442
700
  const parts = header.split('|');
443
701
  const hash = parts[0] || '';
444
- const date = (parts[1] || '').slice(0, 19);
702
+ const dateRaw = (parts[1] || '');
703
+ // Normalize to UTC ISO for consistent comparison with session dates
704
+ const dateMs = Date.parse(dateRaw);
705
+ const date = Number.isFinite(dateMs)
706
+ ? new Date(dateMs).toISOString().slice(0, 19) + 'Z'
707
+ : dateRaw.slice(0, 19);
445
708
  const author = parts[2] || '';
446
709
  const subject = parts.slice(3).join('|');
447
710
  current = {
@@ -998,6 +1261,9 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
998
1261
  }
999
1262
  }
1000
1263
 
1264
+ // Step 1.5: compute developer behavioral baselines
1265
+ const authorBaselines = computeAuthorBaseline(merged.commitList);
1266
+
1001
1267
  // Step 2: 信心度评估(保持现有逻辑)
1002
1268
  for (const c of merged.commitList || []) {
1003
1269
  if (!c.sessionId || c.attributionType === 'explicit') continue;
@@ -1063,6 +1329,30 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1063
1329
  signals.push('strongWithoutFileOverlap');
1064
1330
  }
1065
1331
 
1332
+ // Developer baseline deviation
1333
+ const baseline = authorBaselines.get(c.author);
1334
+ const deviation = computeBaselineDeviation(c, baseline);
1335
+ if (deviation >= 0.4) {
1336
+ signals.push('baselineDeviationHigh');
1337
+ if (confidence === AI_CONFIDENCE.MEDIUM) confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
1338
+ } else if (deviation >= 0.2) {
1339
+ signals.push('baselineDeviationMedium');
1340
+ } else if (deviation <= 0.05 && baseline && c.attributionType !== 'explicit') {
1341
+ signals.push('humanBaselineMatch');
1342
+ }
1343
+
1344
+ // Negative signals: downgrade confidence
1345
+ const negSignals = detectNegativeSignals(c.subject, c.body, c.linesAdded, c.linesDeleted, (c.files || []).length);
1346
+ if (negSignals.includes('humanMergeCommit')) {
1347
+ confidence = AI_CONFIDENCE.NONE;
1348
+ attributionType = 'human_merge';
1349
+ } else if (negSignals.length > 0) {
1350
+ if (confidence === AI_CONFIDENCE.HIGH) confidence = AI_CONFIDENCE.MEDIUM;
1351
+ else if (confidence === AI_CONFIDENCE.MEDIUM) confidence = AI_CONFIDENCE.LOW;
1352
+ else if (confidence === AI_CONFIDENCE.LOW) confidence = AI_CONFIDENCE.NONE;
1353
+ c.negativeSignals = negSignals;
1354
+ }
1355
+
1066
1356
  const ai = createAIAttribution({
1067
1357
  confidence,
1068
1358
  signals,
@@ -1076,6 +1366,18 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
1076
1366
  c.attributionType = ai.attributionType;
1077
1367
  }
1078
1368
 
1369
+ // Step 2.5: composite continuous scoring for all commits
1370
+ for (const c of merged.commitList || []) {
1371
+ c.aiScore = computeContinuousScore(c);
1372
+ const mappedConfidence = scoreToConfidence(c.aiScore);
1373
+ // Only override if no explicit signature and continuous score disagrees
1374
+ if (c.attributionType !== 'explicit') {
1375
+ c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
1376
+ c.isAI = isCountedAIConfidence(c.aiConfidence);
1377
+ c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
1378
+ }
1379
+ }
1380
+
1079
1381
  const attributionItems = [];
1080
1382
  for (const c of merged.commitList || []) {
1081
1383
  const commitOverride = mergedOverrides.commits[c.hash] || null;