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 +74 -63
- package/lib/git.js +322 -20
- package/lib/parsers/claude.js +321 -316
- package/lib/parsers/codex.js +360 -316
- package/lib/parsers/opencode.js +236 -216
- package/lib/record-utils.js +36 -35
- package/lib/report.js +41 -4
- package/lib/server.js +608 -523
- package/package.json +1 -1
- package/public/app.js +827 -809
- package/public/style.css +3 -2
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,
|
|
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
|
|
103
|
-
const sessions = groupBySessions(filtered);
|
|
101
|
+
const reposConfigured = !!(config.repos && config.repos.length > 0);
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
if (toolRepos.length
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
139
|
+
const trendDataPromise = Promise.resolve(computeTrendData(toolRecords, period, dateArg));
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
211
|
-
commits:
|
|
212
|
-
filesChanged:
|
|
213
|
-
fileHotspots: (
|
|
214
|
-
}
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
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;
|