lumencode 1.3.1 → 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.
- package/index.js +348 -343
- package/lib/aggregate.js +58 -3
- package/lib/attribution.js +8 -0
- package/lib/config.js +21 -8
- package/lib/git.js +195 -192
- package/lib/path-utils.js +18 -0
- package/lib/report.js +974 -54
- package/lib/scenario.js +29 -4
- package/lib/server.js +331 -316
- package/package.json +1 -1
- package/public/app.js +1170 -952
- package/public/config.js +1 -0
- package/public/export.js +11 -7
- package/public/index.html +77 -16
- package/public/style.css +248 -1
- package/public/utils.js +218 -0
package/lib/aggregate.js
CHANGED
|
@@ -238,6 +238,8 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
|
|
|
238
238
|
projects: {},
|
|
239
239
|
dailyStats: {},
|
|
240
240
|
toolBreakdown: {}, // 新增:各工具数据分布
|
|
241
|
+
skills: {},
|
|
242
|
+
mcpTools: {},
|
|
241
243
|
};
|
|
242
244
|
|
|
243
245
|
for (const r of records) {
|
|
@@ -308,10 +310,51 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
|
|
|
308
310
|
}
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
// Tools (toolCalls) -
|
|
313
|
+
// Tools (toolCalls) - calls: 总调用次数, uses: 使用次数(同一 record 内同名工具只算一次)
|
|
312
314
|
const toolCalls = r.metadata?.toolCalls || r.toolCalls || [];
|
|
313
315
|
for (const tc of toolCalls) {
|
|
314
|
-
stats.tools[tc.name]
|
|
316
|
+
if (!stats.tools[tc.name]) stats.tools[tc.name] = { calls: 0, uses: 0 };
|
|
317
|
+
stats.tools[tc.name].calls++;
|
|
318
|
+
if (projectName && stats.projects[projectName]) {
|
|
319
|
+
if (!stats.projects[projectName].tools) stats.projects[projectName].tools = {};
|
|
320
|
+
if (!stats.projects[projectName].tools[tc.name]) stats.projects[projectName].tools[tc.name] = { calls: 0, uses: 0 };
|
|
321
|
+
stats.projects[projectName].tools[tc.name].calls++;
|
|
322
|
+
}
|
|
323
|
+
// Skill 细分采集
|
|
324
|
+
if (tc.name === 'Skill' && tc.input?.skill) {
|
|
325
|
+
const sk = tc.input.skill;
|
|
326
|
+
if (!stats.skills[sk]) stats.skills[sk] = { calls: 0, uses: 0 };
|
|
327
|
+
stats.skills[sk].calls++;
|
|
328
|
+
}
|
|
329
|
+
// MCP 细分采集
|
|
330
|
+
if (tc.name.startsWith('mcp__')) {
|
|
331
|
+
if (!stats.mcpTools[tc.name]) stats.mcpTools[tc.name] = { calls: 0, uses: 0 };
|
|
332
|
+
stats.mcpTools[tc.name].calls++;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const uniqueToolNames = new Set(toolCalls.map(tc => tc.name));
|
|
336
|
+
for (const name of uniqueToolNames) {
|
|
337
|
+
if (!stats.tools[name]) stats.tools[name] = { calls: 0, uses: 0 };
|
|
338
|
+
stats.tools[name].uses++;
|
|
339
|
+
if (projectName && stats.projects[projectName] && stats.projects[projectName].tools[name]) {
|
|
340
|
+
stats.projects[projectName].tools[name].uses++;
|
|
341
|
+
}
|
|
342
|
+
// 同步更新 skills / mcpTools 的 uses
|
|
343
|
+
if (name === 'Skill') {
|
|
344
|
+
const skillNames = new Set(
|
|
345
|
+
toolCalls
|
|
346
|
+
.filter(tc => tc.name === 'Skill' && tc.input?.skill)
|
|
347
|
+
.map(tc => tc.input.skill)
|
|
348
|
+
);
|
|
349
|
+
for (const sk of skillNames) {
|
|
350
|
+
if (!stats.skills[sk]) stats.skills[sk] = { calls: 0, uses: 0 };
|
|
351
|
+
stats.skills[sk].uses++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (name.startsWith('mcp__')) {
|
|
355
|
+
if (!stats.mcpTools[name]) stats.mcpTools[name] = { calls: 0, uses: 0 };
|
|
356
|
+
stats.mcpTools[name].uses++;
|
|
357
|
+
}
|
|
315
358
|
}
|
|
316
359
|
|
|
317
360
|
// Daily stats
|
|
@@ -406,6 +449,7 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
|
|
|
406
449
|
cacheCreate: p.cacheCreate || 0,
|
|
407
450
|
estimatedCost: p.estimatedCost || 0,
|
|
408
451
|
models: p.models || {},
|
|
452
|
+
tools: p.tools || {},
|
|
409
453
|
};
|
|
410
454
|
}
|
|
411
455
|
|
|
@@ -488,12 +532,23 @@ export function computeTrendData(allRecords, period, refDate) {
|
|
|
488
532
|
if (!r.timestamp) continue;
|
|
489
533
|
const date = r.timestamp.slice(0, 10);
|
|
490
534
|
if (date < trendStart || date > trendEnd) continue;
|
|
491
|
-
if (!dailyStats[date]) dailyStats[date] = { requests: 0, inputTokens: 0, outputTokens: 0 };
|
|
535
|
+
if (!dailyStats[date]) dailyStats[date] = { requests: 0, inputTokens: 0, outputTokens: 0, tools: {} };
|
|
492
536
|
if (isAssistantRecord(r)) {
|
|
493
537
|
dailyStats[date].requests++;
|
|
494
538
|
dailyStats[date].inputTokens += getInputTokens(r);
|
|
495
539
|
dailyStats[date].outputTokens += getOutputTokens(r);
|
|
496
540
|
}
|
|
541
|
+
const toolCalls = r.metadata?.toolCalls || r.toolCalls || [];
|
|
542
|
+
for (const tc of toolCalls) {
|
|
543
|
+
if (tc.name === 'Skill' || tc.name?.startsWith('mcp__')) continue;
|
|
544
|
+
if (!dailyStats[date].tools[tc.name]) dailyStats[date].tools[tc.name] = { calls: 0, uses: 0 };
|
|
545
|
+
dailyStats[date].tools[tc.name].calls++;
|
|
546
|
+
}
|
|
547
|
+
const uniqueNames = new Set(toolCalls.map(tc => tc.name).filter(n => n !== 'Skill' && !n?.startsWith('mcp__')));
|
|
548
|
+
for (const name of uniqueNames) {
|
|
549
|
+
if (!dailyStats[date].tools[name]) dailyStats[date].tools[name] = { calls: 0, uses: 0 };
|
|
550
|
+
dailyStats[date].tools[name].uses++;
|
|
551
|
+
}
|
|
497
552
|
}
|
|
498
553
|
|
|
499
554
|
return { dailyStats, start: trendStart, end: trendEnd };
|
package/lib/attribution.js
CHANGED
|
@@ -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/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { DEFAULT_ATTRIBUTION_OPTIONS } from './git-attribution-options.js';
|
|
5
|
+
import { parseRepoPaths } from './path-utils.js';
|
|
5
6
|
|
|
6
7
|
const CONFIG_LOCATIONS = [
|
|
7
8
|
join(homedir(), '.lumencode.json'),
|
|
@@ -19,12 +20,13 @@ const DEFAULT_CONFIG = {
|
|
|
19
20
|
blockQuota: null, // 5h 计费窗口 token 上限(Max Pro=1000000, Max=450000 等),null=不限
|
|
20
21
|
costMode: 'auto', // 'auto' | 'calculate' | 'display'
|
|
21
22
|
scenarioKeywords: {
|
|
22
|
-
coding: ['实现', '功能', '开发', '添加', '修改代码', 'implement', 'feature', '
|
|
23
|
-
testing: ['测试', 'test', 'spec', '覆盖率', 'coverage', '单元测试', 'unit test', 'jest', 'vitest', 'mocha'],
|
|
24
|
-
debugging: ['修复', 'bug', 'debug', 'fix', '报错', '错误', '异常', 'error', '
|
|
25
|
-
documentation: ['文档', '
|
|
26
|
-
review: ['review', '审查', '
|
|
27
|
-
planning: ['计划', 'plan', '设计', '架构', '方案', 'design', 'architect'],
|
|
23
|
+
coding: ['实现', '功能', '开发', '添加', '修改代码', 'implement', 'feature', '组件', 'component', '编写', 'write code'],
|
|
24
|
+
testing: ['测试', 'test', 'spec', '覆盖率', 'coverage', '单元测试', 'unit test', 'jest', 'vitest', 'mocha', 'cypress', 'playwright'],
|
|
25
|
+
debugging: ['修复', 'bug', 'debug', 'fix', '报错', '错误', '异常', 'error', '排查', '堆栈', 'trace', 'stack trace', 'crash'],
|
|
26
|
+
documentation: ['文档', 'readme.md', '注释', '说明', '指南', 'guide', 'wiki', '手册', 'api doc'],
|
|
27
|
+
review: ['review', '审查', '代码审查', '/review', 'pr', 'pull request', 'approve', 'approval', 'reject', '走查', '代码走查'],
|
|
28
|
+
planning: ['计划', 'plan', '设计', '架构', '方案', 'design', 'architect', 'roadmap', '规划'],
|
|
29
|
+
refactoring: ['重构', 'refactor', '重写', 'rewrite', '清理代码', 'clean up', '简化', 'simplify', '提取', 'extract'],
|
|
28
30
|
},
|
|
29
31
|
aiAttribution: DEFAULT_ATTRIBUTION_OPTIONS,
|
|
30
32
|
stepTracking: {
|
|
@@ -51,6 +53,17 @@ function deepMerge(target, source) {
|
|
|
51
53
|
return result;
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
function normalizeConfig(config) {
|
|
57
|
+
// 支持 repos / excludeProjects 为字符串(逗号或换行分隔)
|
|
58
|
+
if (config.repos !== undefined) {
|
|
59
|
+
config.repos = parseRepoPaths(config.repos);
|
|
60
|
+
}
|
|
61
|
+
if (config.excludeProjects !== undefined) {
|
|
62
|
+
config.excludeProjects = parseRepoPaths(config.excludeProjects);
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
export function loadConfig(configPath) {
|
|
55
68
|
let config = { ...DEFAULT_CONFIG };
|
|
56
69
|
|
|
@@ -60,7 +73,7 @@ export function loadConfig(configPath) {
|
|
|
60
73
|
try {
|
|
61
74
|
const userConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
62
75
|
config = deepMerge(config, userConfig);
|
|
63
|
-
return config;
|
|
76
|
+
return normalizeConfig(config);
|
|
64
77
|
} catch (e) {
|
|
65
78
|
console.error(`配置文件读取失败: ${configPath}`, e.message);
|
|
66
79
|
// 文件存在但解析失败,返回默认值
|
|
@@ -75,7 +88,7 @@ export function loadConfig(configPath) {
|
|
|
75
88
|
try {
|
|
76
89
|
const userConfig = JSON.parse(readFileSync(p, 'utf-8'));
|
|
77
90
|
config = deepMerge(config, userConfig);
|
|
78
|
-
return config;
|
|
91
|
+
return normalizeConfig(config);
|
|
79
92
|
} catch (e) {
|
|
80
93
|
console.error(`配置文件读取失败: ${p}`, e.message);
|
|
81
94
|
}
|
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;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析仓库路径字符串,支持多种分隔符:
|
|
3
|
+
* - 英文逗号 ,
|
|
4
|
+
* - 中文逗号 ,
|
|
5
|
+
* - 换行 \n / \r\n
|
|
6
|
+
* - 多余空白自动 trim
|
|
7
|
+
*
|
|
8
|
+
* @param {string|Array} input - 路径字符串或已解析的数组
|
|
9
|
+
* @returns {string[]} 解析后的路径数组
|
|
10
|
+
*/
|
|
11
|
+
export function parseRepoPaths(input) {
|
|
12
|
+
if (Array.isArray(input)) return input.map(s => String(s || '').trim()).filter(Boolean);
|
|
13
|
+
if (typeof input !== 'string') return [];
|
|
14
|
+
return input
|
|
15
|
+
.split(/[,,\n\r]+/)
|
|
16
|
+
.map(s => s.trim())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|