lumencode 1.0.0 → 1.1.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
  }
@@ -285,6 +292,8 @@ export function detectAICommit(subject = '', author = '', body = '') {
285
292
 
286
293
  export function computeAIContribution(commits, toolFilter = null) {
287
294
  let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
295
+ let possibleAICommits = 0, possibleAILinesAdded = 0, possibleAILinesDeleted = 0;
296
+ let weightedAILinesAdded = 0, weightedAILinesDeleted = 0;
288
297
  let aiCommitLinesAdded = 0, aiCommitLinesDeleted = 0;
289
298
  let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
290
299
  let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
@@ -303,27 +312,39 @@ export function computeAIContribution(commits, toolFilter = null) {
303
312
  else if (confidence === AI_CONFIDENCE.MEDIUM) mediumConfidenceCommits++;
304
313
  else if (confidence === AI_CONFIDENCE.LOW) lowConfidenceCommits++;
305
314
 
315
+ // 计算文件级行数(用于 HIGH/MEDIUM/LOW 各自统计)
316
+ const matchedFiles = new Set((c.aiEvidenceDetails?.matchedFiles || []).map(normalizeCommitFilePath));
317
+ const useMatchedFiles = matchedFiles.size > 0;
318
+ let fileAdded = 0;
319
+ let fileDeleted = 0;
320
+ for (const f of c.files || []) {
321
+ const filePath = normalizeCommitFilePath(f.path);
322
+ if (useMatchedFiles && !matchedFiles.has(filePath)) continue;
323
+ fileAdded += f.added || 0;
324
+ fileDeleted += f.deleted || 0;
325
+ }
326
+ if (!useMatchedFiles && (c.attributionType === 'explicit' || c.attributionType?.startsWith('session_'))) {
327
+ fileAdded = c.linesAdded || 0;
328
+ fileDeleted = c.linesDeleted || 0;
329
+ }
330
+
306
331
  if (isCountedAIConfidence(confidence)) {
307
332
  aiCommits++;
308
333
  aiCommitLinesAdded += c.linesAdded || 0;
309
334
  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
335
  aiFileLinesAdded += fileAdded;
326
336
  aiFileLinesDeleted += fileDeleted;
337
+ } else if (confidence === AI_CONFIDENCE.LOW) {
338
+ possibleAICommits++;
339
+ possibleAILinesAdded += fileAdded;
340
+ possibleAILinesDeleted += fileDeleted;
341
+ }
342
+
343
+ // 加权计算:所有归因的 commit 都参与(包括 LOW)
344
+ const weight = CONFIDENCE_WEIGHTS[confidence] || 0;
345
+ if (weight > 0) {
346
+ weightedAILinesAdded += fileAdded * weight;
347
+ weightedAILinesDeleted += fileDeleted * weight;
327
348
  }
328
349
  }
329
350
  aiLinesAdded = aiFileLinesAdded;
@@ -331,20 +352,32 @@ export function computeAIContribution(commits, toolFilter = null) {
331
352
  const total = allCommits.length;
332
353
  const totalLinesChanged = totalLinesAdded + totalLinesDeleted;
333
354
  const aiLinesChanged = aiLinesAdded + aiLinesDeleted;
355
+ const possibleAILinesChanged = possibleAILinesAdded + possibleAILinesDeleted;
356
+ const weightedAILinesChanged = Math.round(weightedAILinesAdded + weightedAILinesDeleted);
334
357
  return {
335
358
  aiCommits,
336
- nonToolCommits: total - aiCommits,
337
- humanCommits: total - aiCommits,
359
+ possibleAICommits,
360
+ nonToolCommits: total - aiCommits - possibleAICommits,
361
+ humanCommits: total - aiCommits - possibleAICommits,
338
362
  aiCommitRatio: total > 0 ? aiCommits / total : 0,
363
+ possibleAICommitRatio: total > 0 ? possibleAICommits / total : 0,
339
364
  aiRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
365
+ aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
366
+ possibleAILineRatio: totalLinesChanged > 0 ? possibleAILinesChanged / totalLinesChanged : 0,
367
+ weightedAILineRatio: totalLinesChanged > 0 ? weightedAILinesChanged / totalLinesChanged : 0,
340
368
  toolFilter: toolFilter || null,
341
369
  aiLinesAdded,
342
370
  aiLinesDeleted,
343
371
  aiLinesChanged,
372
+ possibleAILinesAdded,
373
+ possibleAILinesDeleted,
374
+ possibleAILinesChanged,
375
+ weightedAILinesAdded: Math.round(weightedAILinesAdded),
376
+ weightedAILinesDeleted: Math.round(weightedAILinesDeleted),
377
+ weightedAILinesChanged,
344
378
  totalLinesAdded,
345
379
  totalLinesDeleted,
346
380
  totalLinesChanged,
347
- aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
348
381
  aiCommitLinesAdded,
349
382
  aiCommitLinesDeleted,
350
383
  aiFileLinesAdded,