lumencode 1.3.2 → 1.3.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.
@@ -99,6 +99,8 @@ export function aggregateAttribution(items = []) {
99
99
  unknown: 0,
100
100
  human: 0,
101
101
  excluded: 0,
102
+ mergeCommits: 0,
103
+ mergeCommitLines: 0,
102
104
  confirmedAILines: 0,
103
105
  probableAILines: 0,
104
106
  possibleAILines: 0,
@@ -112,6 +114,12 @@ export function aggregateAttribution(items = []) {
112
114
 
113
115
  for (const item of items || []) {
114
116
  const classified = item?.classification ? item : classifyAttribution(item);
117
+ // 合并提交不计入统计,避免稀释 AI 占比
118
+ if (item?.reason === 'human_merge') {
119
+ summary.mergeCommits = (summary.mergeCommits || 0) + 1;
120
+ summary.mergeCommitLines = (summary.mergeCommitLines || 0) + (item?.added || item?.linesAdded || 0) + (item?.deleted || item?.linesDeleted || 0);
121
+ continue;
122
+ }
115
123
  const lines = (item?.added || item?.linesAdded || 0) + (item?.deleted || item?.linesDeleted || 0);
116
124
  summary.totalItems++;
117
125
  summary.totalLinesChanged += lines;
package/lib/git.js CHANGED
@@ -550,7 +550,10 @@ export function computeAIContribution(commits, toolFilter = null, options = {})
550
550
  const attributionOptions = resolveAttributionOptions(options.attribution || options);
551
551
  const confidenceWeights = attributionOptions.confidenceWeights;
552
552
  const allCommits = commits || [];
553
+ const isMergeCommit = c => c.attributionType === 'human_merge';
553
554
  for (const c of allCommits) {
555
+ // 合并提交不计入总行数分母,避免稀释 AI 占比
556
+ if (isMergeCommit(c)) continue;
554
557
  totalLinesAdded += c.linesAdded || 0;
555
558
  totalLinesDeleted += c.linesDeleted || 0;
556
559
  }
@@ -807,14 +810,14 @@ function sanitizeArg(s) {
807
810
  return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
808
811
  }
809
812
 
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
- }
813
+ function buildGitArgs(since, until) {
814
+ const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
815
+ const safeSince = sanitizeArg(sinceFull);
816
+ const safeUntil = sanitizeArg(until);
817
+ // 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
818
+ const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
819
+ return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"`;
820
+ }
818
821
 
819
822
  function mergeGitStats(target, source) {
820
823
  target.commits += source.commits;
@@ -837,66 +840,66 @@ function mergeGitStats(target, source) {
837
840
  // filesChanged 在 merge 完后由 finalize 重新计算(跨 repo 去重)
838
841
  }
839
842
 
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
- }
843
+ function recomputeFilesChanged(stats) {
844
+ const set = new Set();
845
+ for (const c of stats.commitList || []) {
846
+ for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
847
+ }
848
+ stats.filesChanged = set.size;
849
+ }
850
+
851
+ function recomputeStatsFromCommitList(stats) {
852
+ stats.commits = 0;
853
+ stats.filesChanged = 0;
854
+ stats.linesAdded = 0;
855
+ stats.linesDeleted = 0;
856
+ stats.commitsByDate = {};
857
+ stats.linesByDate = {};
858
+
859
+ for (const c of stats.commitList || []) {
860
+ const dateKey = c.dateLocal || (c.date || '').slice(0, 10);
861
+ stats.commits++;
862
+ stats.commitsByDate[dateKey] = (stats.commitsByDate[dateKey] || 0) + 1;
863
+ if (!stats.linesByDate[dateKey]) stats.linesByDate[dateKey] = { added: 0, deleted: 0, files: 0 };
864
+ stats.linesByDate[dateKey].added += c.linesAdded || 0;
865
+ stats.linesByDate[dateKey].deleted += c.linesDeleted || 0;
866
+ stats.linesByDate[dateKey].files += (c.files || []).length;
867
+ stats.linesAdded += c.linesAdded || 0;
868
+ stats.linesDeleted += c.linesDeleted || 0;
869
+ }
870
+ recomputeFilesChanged(stats);
871
+ }
872
+
873
+ function markAuthorOwnership(stats, expectedAuthor) {
874
+ const normalizedExpected = (expectedAuthor || '').toLowerCase();
875
+ for (const c of stats.commitList || []) {
876
+ c.expectedAuthor = expectedAuthor || null;
877
+ c.authorMatchesConfig = normalizedExpected
878
+ ? (c.author || '').toLowerCase() === normalizedExpected
879
+ : null;
880
+ }
881
+ }
882
+
883
+ function hasLocalSessionEvidence(commit) {
884
+ if (!commit.sessionId) return false;
885
+ if (commit.sessionAttribution === 'strong') return true;
886
+ return (commit.aiEvidenceDetails?.matchedFileCount || 0) > 0;
887
+ }
888
+
889
+ function filterCommitsForUser(stats) {
890
+ const commits = stats.commitList || [];
891
+ const hasAuthorOwnershipMetadata = commits.some(c => c.expectedAuthor || c.authorMatchesConfig !== undefined);
892
+
893
+ for (const c of commits) {
894
+ c.countedForUser = !hasAuthorOwnershipMetadata
895
+ || c.authorMatchesConfig === true
896
+ || hasLocalSessionEvidence(c);
897
+ }
898
+
899
+ if (!hasAuthorOwnershipMetadata) return;
900
+ stats.commitList = commits.filter(c => c.countedForUser);
901
+ recomputeStatsFromCommitList(stats);
902
+ }
900
903
 
901
904
  // ── async versions (server) with cache ──
902
905
 
@@ -916,26 +919,26 @@ function evictGitCache() {
916
919
  }
917
920
  }
918
921
 
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;
922
+ async function getGitStatsAsync(repoPath, since, until, author = null) {
923
+ const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
924
+ const cached = gitCache.get(cacheKey);
925
+ if (cached && Date.now() - cached.ts < GIT_CACHE_TTL) return cached.stats;
923
926
 
924
927
  try {
925
928
  await execAsync('git rev-parse --git-dir', { cwd: repoPath });
926
929
  } catch {
927
930
  return emptyResult();
928
931
  }
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;
932
+
933
+ try {
934
+ const output = await execAsync(`git log ${buildGitArgs(since, until)}`, {
935
+ cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
936
+ });
937
+ const stats = parseGitLogOutput(output, repoPath);
938
+ markAuthorOwnership(stats, author);
939
+ gitCache.set(cacheKey, { stats, ts: Date.now() });
940
+ evictGitCache();
941
+ return stats;
939
942
  } catch {
940
943
  return emptyResult();
941
944
  }
@@ -1131,38 +1134,38 @@ function sortAttributionCandidates(candidates) {
1131
1134
  });
1132
1135
  }
1133
1136
 
1134
- function candidateFromSession(commit, session, distanceMs) {
1135
- const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
1136
- return scoreSessionCandidate(commit, session, {
1137
- distanceMs,
1137
+ function candidateFromSession(commit, session, distanceMs) {
1138
+ const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
1139
+ return scoreSessionCandidate(commit, session, {
1140
+ distanceMs,
1138
1141
  fileOverlapRatio: overlap.fileOverlapRatio,
1139
1142
  matchedFiles: overlap.matchedFiles,
1140
1143
  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
1144
+ });
1145
+ }
1146
+
1147
+ function getStepSessionIdCandidates(sessionId, session) {
1148
+ if (!sessionId) return [];
1149
+ const candidates = [sessionId];
1150
+ if (sessionId.includes(':')) return candidates;
1151
+
1152
+ const originByTool = {
1153
+ claude: 'claude_code',
1154
+ codex: 'codex_cli',
1155
+ };
1156
+ const origin = originByTool[session?.primaryTool];
1157
+ if (origin) {
1158
+ candidates.push(`${origin}:${sessionId}`);
1159
+ } else {
1160
+ candidates.push(`claude_code:${sessionId}`, `codex_cli:${sessionId}`);
1161
+ }
1162
+
1163
+ return [...new Set(candidates)];
1164
+ }
1165
+
1166
+ const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
1167
+ const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
1168
+ const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
1166
1169
 
1167
1170
  function toMs(iso) {
1168
1171
  if (!iso) return NaN;
@@ -1346,11 +1349,11 @@ export function attachCommitsToSessions(sessions, commitList) {
1346
1349
  }
1347
1350
 
1348
1351
  // 一次性收尾:跑 attribution + 三个聚合
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();
1352
+ export async function finalizeGitStats(merged, sessions = [], options = {}) {
1353
+ if (!merged) return merged;
1354
+ const attributionOptions = resolveAttributionOptions(options.attribution || options);
1355
+ const stepTrackingOptions = options.stepTracking || {};
1356
+ const fileOverrides = loadAttributionOverrides();
1354
1357
  const inputOverrides = options.overrides || {};
1355
1358
  const mergedOverrides = {
1356
1359
  commits: { ...fileOverrides.commits, ...(inputOverrides.commits || {}) },
@@ -1378,69 +1381,69 @@ export async function finalizeGitStats(merged, sessions = [], options = {}) {
1378
1381
  }
1379
1382
  }
1380
1383
 
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
- }
1384
+ // Step 1.5: Enrich commits with line-level step blame when available
1385
+ const stepTrackers = new Map();
1386
+ if (stepTrackingOptions.enabled !== false) try {
1387
+ const { StepTracker } = await import('./step-tracker.js');
1388
+ const projectRoots = [...new Set((sessions || []).map(s => s.project).filter(Boolean))];
1389
+ // Also check repo paths from commits
1390
+ for (const c of merged.commitList || []) {
1391
+ if (c.repo) projectRoots.push(c.repo);
1392
+ }
1393
+ for (const root of [...new Set(projectRoots.map(normalizePathForGit))]) {
1394
+ if (!root) continue;
1395
+ const tracker = new StepTracker(root, {
1396
+ dbPath: stepTrackingOptions.dbPath,
1397
+ maxFileSize: stepTrackingOptions.maxFileSize,
1398
+ });
1399
+ if (await tracker.isAvailableAsync()) {
1400
+ await tracker.open();
1401
+ stepTrackers.set(root, tracker);
1402
+ }
1403
+ }
1404
+ } catch {
1405
+ for (const tracker of stepTrackers.values()) tracker.close();
1406
+ stepTrackers.clear();
1407
+ }
1408
+
1409
+ if (stepTrackers.size > 0) {
1410
+ for (const c of merged.commitList || []) {
1411
+ if (!c.sessionId) continue;
1412
+ const candidateRoots = [
1413
+ c.repo,
1414
+ sessionsById.get(c.sessionId)?.project,
1415
+ ].filter(Boolean);
1416
+ let stepTracker = null;
1417
+ for (const candidateRoot of candidateRoots) {
1418
+ const normalizedCandidate = normalizePathForGit(candidateRoot);
1419
+ for (const [root, tracker] of stepTrackers.entries()) {
1420
+ if (projectMatchesFromGitPaths(root, normalizedCandidate)) {
1421
+ stepTracker = tracker;
1422
+ break;
1423
+ }
1424
+ }
1425
+ if (stepTracker) break;
1426
+ }
1427
+ if (!stepTracker && stepTrackers.size === 1) {
1428
+ stepTracker = stepTrackers.values().next().value;
1429
+ }
1430
+ if (!stepTracker) continue;
1431
+ try {
1432
+ const session = sessionsById.get(c.sessionId);
1433
+ for (const stepSessionId of getStepSessionIdCandidates(c.sessionId, session)) {
1434
+ const lineBlame = stepTracker.getLineAttributionForCommit({
1435
+ ...c,
1436
+ sessionId: stepSessionId,
1437
+ });
1438
+ if (lineBlame) {
1439
+ c.lineBlame = lineBlame;
1440
+ break;
1441
+ }
1442
+ }
1443
+ } catch { /* best effort */ }
1444
+ }
1445
+ for (const tracker of stepTrackers.values()) tracker.close();
1446
+ }
1444
1447
 
1445
1448
  // Step 1.6: compute developer behavioral baselines
1446
1449
  const authorBaselines = computeAuthorBaseline(merged.commitList);
@@ -1548,21 +1551,21 @@ export async function finalizeGitStats(merged, sessions = [], options = {}) {
1548
1551
  }
1549
1552
 
1550
1553
  // 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') {
1554
+ for (const c of merged.commitList || []) {
1555
+ c.aiScore = computeContinuousScore(c, attributionOptions);
1556
+ const mappedConfidence = scoreToConfidence(c.aiScore, attributionOptions);
1557
+ // Only override if no explicit signature and continuous score disagrees
1558
+ if (c.attributionType !== 'explicit') {
1556
1559
  c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
1557
1560
  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 || []) {
1561
+ c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
1562
+ }
1563
+ }
1564
+
1565
+ filterCommitsForUser(merged);
1566
+
1567
+ const attributionItems = [];
1568
+ for (const c of merged.commitList || []) {
1566
1569
  const commitOverride = mergedOverrides.commits[c.hash] || null;
1567
1570
  const fileOverride = (c.files || []).find(f => mergedOverrides.files[`${c.hash}:${f.path}`]);
1568
1571
  const fileOverrideValue = fileOverride ? mergedOverrides.files[`${c.hash}:${fileOverride.path}`] : null;
package/lib/report.js CHANGED
@@ -167,7 +167,11 @@ function buildAttributionSummaryLine(summary) {
167
167
  const confirmedPct = Math.round((summary.confirmedAILines / total) * 100);
168
168
  const upperPct = Math.round(((summary.confirmedAILines + summary.probableAILines + summary.possibleAILines) / total) * 100);
169
169
  const unknownPct = Math.round((summary.unknownLines / total) * 100);
170
- return `AI 归因汇总:确认 AI ${confirmedPct}% / 可能 AI 上限 ${upperPct}% / 未知 ${unknownPct}%。`;
170
+ let line = `AI 归因汇总:确认 AI ${confirmedPct}% / 可能 AI 上限 ${upperPct}% / 未知 ${unknownPct}%。`;
171
+ if (summary.mergeCommits > 0) {
172
+ line += `(已排除 ${summary.mergeCommits} 个合并提交,共 ${formatInt(summary.mergeCommitLines)} 行)`;
173
+ }
174
+ return line;
171
175
  }
172
176
 
173
177
  function buildUnknownReasonLine(summary) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumencode",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
5
5
  "type": "module",
6
6
  "bin": {