lumencode 1.1.0 → 1.3.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/README.md +41 -0
- package/hooks/claude-post-tool-batch.js +51 -0
- package/hooks/codex-hook.js +56 -0
- package/hooks/init-steps.js +10 -0
- package/hooks/install-codex.js +9 -0
- package/hooks/install.js +14 -0
- package/hooks/opencode-hook.js +45 -0
- package/hooks/post-tool-use.js +42 -0
- package/index.js +236 -22
- package/lib/aggregate.js +27 -9
- package/lib/attribution.js +13 -0
- package/lib/capture-recorder.js +141 -0
- package/lib/config.js +26 -2
- package/lib/git-attribution-candidates.js +37 -0
- package/lib/git-attribution-options.js +105 -0
- package/lib/git-paths.js +41 -0
- package/lib/git.js +581 -129
- package/lib/hooks-manager.js +379 -0
- package/lib/line-blame.js +140 -0
- package/lib/parser.js +40 -18
- package/lib/parsers/base.js +69 -67
- package/lib/parsers/claude.js +51 -53
- package/lib/parsers/codex.js +21 -9
- package/lib/parsers/index.js +153 -151
- package/lib/parsers/opencode.js +28 -20
- package/lib/report.js +3 -3
- package/lib/server.js +242 -29
- package/lib/step-schema.js +217 -0
- package/lib/step-tracker.js +323 -0
- package/package.json +8 -2
- package/public/api.js +21 -0
- package/public/app.js +127 -2
- package/public/config.js +2 -0
- package/public/git-insights.js +19 -0
- package/public/index.html +69 -0
- package/public/style.css +85 -1
package/lib/git.js
CHANGED
|
@@ -2,6 +2,14 @@ import { execSync, exec as execCb } from 'child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { aggregateAttribution, classifyAttribution } from './attribution.js';
|
|
5
|
+
import {
|
|
6
|
+
normalizeCommitFilePath,
|
|
7
|
+
normalizePathForGit,
|
|
8
|
+
projectMatches as projectMatchesFromGitPaths,
|
|
9
|
+
toRepoRelativePath,
|
|
10
|
+
} from './git-paths.js';
|
|
11
|
+
import { resolveAttributionOptions } from './git-attribution-options.js';
|
|
12
|
+
import { scoreSessionCandidate } from './git-attribution-candidates.js';
|
|
5
13
|
|
|
6
14
|
// ── helpers ──
|
|
7
15
|
|
|
@@ -157,13 +165,6 @@ const AI_CONFIDENCE = {
|
|
|
157
165
|
HIGH: 'high',
|
|
158
166
|
};
|
|
159
167
|
|
|
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
|
-
|
|
167
168
|
function isCountedAIConfidence(confidence) {
|
|
168
169
|
return confidence === AI_CONFIDENCE.HIGH || confidence === AI_CONFIDENCE.MEDIUM;
|
|
169
170
|
}
|
|
@@ -180,7 +181,7 @@ function resolveToolFromSignals(signals) {
|
|
|
180
181
|
return null;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null } = {}) {
|
|
184
|
+
function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], attributionType = null, detectedTool = null, negativeSignals = [] } = {}) {
|
|
184
185
|
const normalizedSignals = [...new Set(signals)];
|
|
185
186
|
return {
|
|
186
187
|
isAI: isCountedAIConfidence(confidence),
|
|
@@ -191,6 +192,7 @@ function createAIAttribution({ confidence = AI_CONFIDENCE.NONE, signals = [], at
|
|
|
191
192
|
aiEvidence: normalizedSignals,
|
|
192
193
|
attributionType,
|
|
193
194
|
detectedTool,
|
|
195
|
+
negativeSignals,
|
|
194
196
|
};
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -221,6 +223,21 @@ function buildEvidenceDetails({
|
|
|
221
223
|
};
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
// 检测正则中可能导致回溯爆炸的危险模式
|
|
227
|
+
function isSafeRegex(pattern) {
|
|
228
|
+
// 限制最大长度
|
|
229
|
+
if (pattern.length > 200) return false;
|
|
230
|
+
// 嵌套量词:如 (a+)+, (a*){2,}, (a{1,3})+
|
|
231
|
+
if (/\([^)]*[+*{][^)]*\)[+*{]/.test(pattern)) return false;
|
|
232
|
+
// 交替+量词:如 (a|b)*, (foo|bar)+
|
|
233
|
+
if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false;
|
|
234
|
+
// 字符类后跟量词且字符类内含量词:如 [\w+]* — 模糊但潜在危险
|
|
235
|
+
if (/\[[^\]]+\][+*{]/.test(pattern) && /\[[^\]]*[*+]/.test(pattern)) return false;
|
|
236
|
+
// 重复量词:如 a** 或 a++ (有些引擎报错但不应依赖)
|
|
237
|
+
if (/[+*{]\s*[+*{]/.test(pattern)) return false;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
224
241
|
function loadCustomPatterns() {
|
|
225
242
|
try {
|
|
226
243
|
const configPath = join(process.cwd(), 'ai-patterns.json');
|
|
@@ -228,6 +245,13 @@ function loadCustomPatterns() {
|
|
|
228
245
|
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
229
246
|
return raw
|
|
230
247
|
.filter(p => typeof p.re === 'string' && typeof p.signal === 'string')
|
|
248
|
+
.filter(p => {
|
|
249
|
+
if (!isSafeRegex(p.re)) {
|
|
250
|
+
console.warn(`[git] 跳过不安全正则: ${p.re.slice(0, 50)}`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
})
|
|
231
255
|
.map(p => ({ re: new RegExp(p.re, p.flags || 'i'), signal: p.signal }));
|
|
232
256
|
}
|
|
233
257
|
} catch { /* ignore */ }
|
|
@@ -248,6 +272,205 @@ function loadAttributionOverrides() {
|
|
|
248
272
|
}
|
|
249
273
|
}
|
|
250
274
|
|
|
275
|
+
// ── Style-based heuristic detection (fallback when no explicit signals) ──
|
|
276
|
+
|
|
277
|
+
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;
|
|
278
|
+
|
|
279
|
+
function detectStyleSignals(subject, body) {
|
|
280
|
+
const signals = [];
|
|
281
|
+
let score = 0;
|
|
282
|
+
|
|
283
|
+
// Bullet list: 3+ lines starting with "- " or "* "
|
|
284
|
+
const bulletLines = (body || '').split('\n').filter(l => /^\s*[-*]\s+\S/.test(l));
|
|
285
|
+
if (bulletLines.length >= 3) {
|
|
286
|
+
signals.push('styleBulletList');
|
|
287
|
+
score += 2;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Conventional commit with scope: type(scope): subject
|
|
291
|
+
if (CONVENTIONAL_RE.test(subject)) {
|
|
292
|
+
const m = subject.match(CONVENTIONAL_RE);
|
|
293
|
+
if (m && m[2]) {
|
|
294
|
+
signals.push('styleConventionalScope');
|
|
295
|
+
score += 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Long structured body: >150 chars with 3+ non-empty lines
|
|
300
|
+
const bodyLines = (body || '').split('\n').filter(l => l.trim());
|
|
301
|
+
if ((body || '').length > 150 && bodyLines.length >= 3) {
|
|
302
|
+
signals.push('styleLongStructuredBody');
|
|
303
|
+
score += 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Technical detail: body contains file paths or parenthetical notes
|
|
307
|
+
if (/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.[a-z]{1,4}/.test(body) || /\([^)]{10,}\)/.test(body)) {
|
|
308
|
+
signals.push('styleTechnicalDetail');
|
|
309
|
+
score += 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Imperative mood: 3+ body lines start with imperative verbs (strip bullet prefix first)
|
|
313
|
+
const imperativeCount = bodyLines.filter(l => IMPERATIVE_VERBS.test(l.trim().replace(/^[-*]\s+/, ''))).length;
|
|
314
|
+
if (imperativeCount >= 3) {
|
|
315
|
+
signals.push('styleImperativeMood');
|
|
316
|
+
score += 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { signals, score };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Negative signals (reduce false positives) ──
|
|
323
|
+
|
|
324
|
+
const WIP_RE = /\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i;
|
|
325
|
+
const MERGE_RE = /^Merge\s+(?:branch|pull|remote|tag)/i;
|
|
326
|
+
|
|
327
|
+
export function detectNegativeSignals(subject, body, linesAdded, linesDeleted, fileCount) {
|
|
328
|
+
const signals = [];
|
|
329
|
+
const trimmedBody = (body || '').trim();
|
|
330
|
+
const trimmedSubject = (subject || '').trim();
|
|
331
|
+
const totalLines = (linesAdded || 0) + (linesDeleted || 0);
|
|
332
|
+
const files = fileCount || 0;
|
|
333
|
+
|
|
334
|
+
// Informal: very short subject AND body, no conventional commit prefix
|
|
335
|
+
const isConventional = CONVENTIONAL_RE.test(trimmedSubject);
|
|
336
|
+
if (trimmedSubject.length <= 10 && trimmedBody.length <= 10 && !/\n/.test(trimmedBody) && !isConventional) {
|
|
337
|
+
signals.push('humanInformal');
|
|
338
|
+
}
|
|
339
|
+
if (MERGE_RE.test(trimmedSubject)) {
|
|
340
|
+
signals.push('humanMergeCommit');
|
|
341
|
+
}
|
|
342
|
+
if (totalLines <= 2 && files <= 1) {
|
|
343
|
+
signals.push('humanSmallScope');
|
|
344
|
+
}
|
|
345
|
+
if (WIP_RE.test(trimmedSubject)) {
|
|
346
|
+
signals.push('humanWIP');
|
|
347
|
+
}
|
|
348
|
+
return signals;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Developer behavioral baseline ──
|
|
352
|
+
|
|
353
|
+
function computeAuthorBaseline(commitList) {
|
|
354
|
+
const authorStats = new Map();
|
|
355
|
+
for (const c of commitList || []) {
|
|
356
|
+
const email = c.author || 'unknown';
|
|
357
|
+
if (!authorStats.has(email)) {
|
|
358
|
+
authorStats.set(email, {
|
|
359
|
+
count: 0, totalSubjectLen: 0, totalBodyLen: 0,
|
|
360
|
+
bulletCount: 0, convCount: 0, totalFiles: 0, totalLines: 0,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const s = authorStats.get(email);
|
|
364
|
+
s.count++;
|
|
365
|
+
s.totalSubjectLen += (c.subject || '').length;
|
|
366
|
+
s.totalBodyLen += (c.body || '').length;
|
|
367
|
+
s.totalFiles += (c.files || []).length;
|
|
368
|
+
s.totalLines += (c.linesAdded || 0) + (c.linesDeleted || 0);
|
|
369
|
+
if (/^\s*[-*]\s+\S/m.test(c.body || '')) s.bulletCount++;
|
|
370
|
+
if (CONVENTIONAL_RE.test(c.subject)) s.convCount++;
|
|
371
|
+
}
|
|
372
|
+
const baselines = new Map();
|
|
373
|
+
for (const [email, s] of authorStats) {
|
|
374
|
+
if (s.count < 3) { baselines.set(email, null); continue; }
|
|
375
|
+
baselines.set(email, {
|
|
376
|
+
avgSubjectLen: s.totalSubjectLen / s.count,
|
|
377
|
+
avgBodyLen: s.totalBodyLen / s.count,
|
|
378
|
+
bulletRatio: s.bulletCount / s.count,
|
|
379
|
+
convRatio: s.convCount / s.count,
|
|
380
|
+
avgFiles: s.totalFiles / s.count,
|
|
381
|
+
avgLines: s.totalLines / s.count,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return baselines;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function computeBaselineDeviation(commit, baseline) {
|
|
388
|
+
if (!baseline) return 0;
|
|
389
|
+
let deviation = 0;
|
|
390
|
+
let factors = 0;
|
|
391
|
+
const body = commit.body || '';
|
|
392
|
+
|
|
393
|
+
if (baseline.avgBodyLen > 0) {
|
|
394
|
+
const ratio = body.length / baseline.avgBodyLen;
|
|
395
|
+
deviation += ratio > 2 ? 0.3 : (ratio > 1.5 ? 0.15 : 0);
|
|
396
|
+
factors++;
|
|
397
|
+
}
|
|
398
|
+
const hasBullets = /^\s*[-*]\s+\S/m.test(body);
|
|
399
|
+
if (baseline.bulletRatio < 0.1 && hasBullets) { deviation += 0.3; factors++; }
|
|
400
|
+
const fileCount = (commit.files || []).length;
|
|
401
|
+
if (baseline.avgFiles > 0) {
|
|
402
|
+
const ratio = fileCount / baseline.avgFiles;
|
|
403
|
+
deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
|
|
404
|
+
factors++;
|
|
405
|
+
}
|
|
406
|
+
const lineCount = (commit.linesAdded || 0) + (commit.linesDeleted || 0);
|
|
407
|
+
if (baseline.avgLines > 0) {
|
|
408
|
+
const ratio = lineCount / baseline.avgLines;
|
|
409
|
+
deviation += ratio > 3 ? 0.2 : (ratio > 2 ? 0.1 : 0);
|
|
410
|
+
factors++;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return factors > 0 ? Math.min(deviation / factors, 1) : 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Composite continuous scoring ──
|
|
417
|
+
|
|
418
|
+
function computeContinuousScore(commit, attributionOptions) {
|
|
419
|
+
let score = 0;
|
|
420
|
+
const signals = new Set(commit.aiSignals || []);
|
|
421
|
+
const weights = attributionOptions.scoreWeights;
|
|
422
|
+
|
|
423
|
+
// Explicit signatures
|
|
424
|
+
if (signals.has('coAuthor') || signals.has('generatedWith') || signals.has('assistedBy')) score += weights.explicitSignature;
|
|
425
|
+
if (signals.has('coAuthorCopilot') || signals.has('coAuthorCursor') || signals.has('coAuthorCodex')) score += weights.explicitSignature;
|
|
426
|
+
if (signals.has('robotEmoji') || signals.has('coAuthorOpencode')) score += weights.explicitSignature;
|
|
427
|
+
if (signals.has('authorClaude') || signals.has('authorBot')) score += weights.explicitAuthor;
|
|
428
|
+
if (signals.has('generatedWithAider') || signals.has('aiderTag')) score += weights.explicitSignature;
|
|
429
|
+
if (signals.has('generatedWithCodex') || signals.has('coAuthorCodex')) score += weights.explicitSignature;
|
|
430
|
+
if (signals.has('coAuthorWindsurf') || signals.has('coAuthorAugment') || signals.has('coAuthorCline')) score += weights.explicitSignature;
|
|
431
|
+
if (signals.has('aiGenerated') || signals.has('generatedByAI') || signals.has('viaAI') || signals.has('aiTag')) score += weights.genericAISignature;
|
|
432
|
+
|
|
433
|
+
// Session signals
|
|
434
|
+
if (commit.sessionAttribution === 'strong') score += weights.sessionStrong;
|
|
435
|
+
else if (commit.sessionAttribution === 'cross-day') score += weights.sessionCrossDay;
|
|
436
|
+
else if (commit.sessionAttribution === 'weak') score += weights.sessionWeak;
|
|
437
|
+
else if (commit.sessionAttribution === 'cross-day-weak') score += weights.sessionCrossDayWeak;
|
|
438
|
+
|
|
439
|
+
// File overlap
|
|
440
|
+
const overlap = commit.aiEvidenceDetails?.fileOverlapRatio || 0;
|
|
441
|
+
score += overlap * weights.fileOverlap;
|
|
442
|
+
|
|
443
|
+
// Style heuristic
|
|
444
|
+
if (signals.has('styleBulletList')) score += weights.styleBulletList;
|
|
445
|
+
if (signals.has('styleConventionalScope')) score += weights.styleConventionalScope;
|
|
446
|
+
if (signals.has('styleImperativeMood')) score += weights.styleImperativeMood;
|
|
447
|
+
if (signals.has('styleLongStructuredBody')) score += weights.styleLongStructuredBody;
|
|
448
|
+
|
|
449
|
+
// Baseline deviation
|
|
450
|
+
if (signals.has('baselineDeviationHigh')) score += weights.baselineDeviationHigh;
|
|
451
|
+
else if (signals.has('baselineDeviationMedium')) score += weights.baselineDeviationMedium;
|
|
452
|
+
|
|
453
|
+
// Negative signals
|
|
454
|
+
const negSignals = new Set(commit.negativeSignals || []);
|
|
455
|
+
if (negSignals.has('humanMergeCommit')) score += weights.negativeMergeCommit;
|
|
456
|
+
if (negSignals.has('humanInformal')) score += weights.negativeInformal;
|
|
457
|
+
if (negSignals.has('humanSmallScope')) score += weights.negativeSmallScope;
|
|
458
|
+
if (negSignals.has('humanWIP')) score += weights.negativeWIP;
|
|
459
|
+
|
|
460
|
+
// Baseline match (human pattern)
|
|
461
|
+
if (signals.has('humanBaselineMatch')) score += weights.humanBaselineMatch;
|
|
462
|
+
|
|
463
|
+
return Math.max(0, Math.min(1, score));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function scoreToConfidence(score, attributionOptions) {
|
|
467
|
+
const thresholds = attributionOptions.confidenceThresholds;
|
|
468
|
+
if (score >= thresholds.high) return AI_CONFIDENCE.HIGH;
|
|
469
|
+
if (score >= thresholds.medium) return AI_CONFIDENCE.MEDIUM;
|
|
470
|
+
if (score >= thresholds.low) return AI_CONFIDENCE.LOW;
|
|
471
|
+
return AI_CONFIDENCE.NONE;
|
|
472
|
+
}
|
|
473
|
+
|
|
251
474
|
export function detectAICommit(subject = '', author = '', body = '') {
|
|
252
475
|
const haystack = `${subject}\n${body}`;
|
|
253
476
|
const signals = [];
|
|
@@ -285,12 +508,38 @@ export function detectAICommit(subject = '', author = '', body = '') {
|
|
|
285
508
|
detectedTool,
|
|
286
509
|
});
|
|
287
510
|
}
|
|
511
|
+
|
|
512
|
+
// Negative signals: skip style heuristic for obvious human patterns
|
|
513
|
+
// Only check message-level signals here (line/file counts not available)
|
|
514
|
+
const subjectNeg = (subject || '').trim();
|
|
515
|
+
const bodyNeg = (body || '').trim();
|
|
516
|
+
const negSignals = [];
|
|
517
|
+
const isConvSubject = CONVENTIONAL_RE.test(subjectNeg);
|
|
518
|
+
if (subjectNeg.length <= 10 && bodyNeg.length <= 10 && !/\n/.test(bodyNeg) && !isConvSubject) negSignals.push('humanInformal');
|
|
519
|
+
if (/^Merge\s+(?:branch|pull|remote|tag)/i.test(subjectNeg)) negSignals.push('humanMergeCommit');
|
|
520
|
+
if (/\b(?:wip|draft|todo|temp|tmp|hack|quick)\b/i.test(subjectNeg)) negSignals.push('humanWIP');
|
|
521
|
+
if (negSignals.length > 0) {
|
|
522
|
+
return createAIAttribution({ negativeSignals: negSignals });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Fallback: style-based heuristic detection
|
|
526
|
+
const { signals: styleSignals, score } = detectStyleSignals(subject, body);
|
|
527
|
+
if (styleSignals.length > 0 && score >= 4) {
|
|
528
|
+
const confidence = score >= 6 ? AI_CONFIDENCE.MEDIUM : AI_CONFIDENCE.LOW;
|
|
529
|
+
return createAIAttribution({
|
|
530
|
+
confidence,
|
|
531
|
+
signals: styleSignals,
|
|
532
|
+
attributionType: score >= 6 ? 'style_heuristic_strong' : 'style_heuristic',
|
|
533
|
+
detectedTool: null,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
288
537
|
return createAIAttribution();
|
|
289
538
|
}
|
|
290
539
|
|
|
291
540
|
// ── 聚合函数 ──
|
|
292
541
|
|
|
293
|
-
export function computeAIContribution(commits, toolFilter = null) {
|
|
542
|
+
export function computeAIContribution(commits, toolFilter = null, options = {}) {
|
|
294
543
|
let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
|
|
295
544
|
let possibleAICommits = 0, possibleAILinesAdded = 0, possibleAILinesDeleted = 0;
|
|
296
545
|
let weightedAILinesAdded = 0, weightedAILinesDeleted = 0;
|
|
@@ -298,6 +547,8 @@ export function computeAIContribution(commits, toolFilter = null) {
|
|
|
298
547
|
let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
|
|
299
548
|
let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
|
|
300
549
|
let totalLinesAdded = 0, totalLinesDeleted = 0;
|
|
550
|
+
const attributionOptions = resolveAttributionOptions(options.attribution || options);
|
|
551
|
+
const confidenceWeights = attributionOptions.confidenceWeights;
|
|
301
552
|
const allCommits = commits || [];
|
|
302
553
|
for (const c of allCommits) {
|
|
303
554
|
totalLinesAdded += c.linesAdded || 0;
|
|
@@ -328,6 +579,12 @@ export function computeAIContribution(commits, toolFilter = null) {
|
|
|
328
579
|
fileDeleted = c.linesDeleted || 0;
|
|
329
580
|
}
|
|
330
581
|
|
|
582
|
+
// Step blame override: use precise line-level attribution when available
|
|
583
|
+
if (c.lineBlame) {
|
|
584
|
+
fileAdded = c.lineBlame.aiLines || 0;
|
|
585
|
+
fileDeleted = c.lineBlame.aiDeletedLines || 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
331
588
|
if (isCountedAIConfidence(confidence)) {
|
|
332
589
|
aiCommits++;
|
|
333
590
|
aiCommitLinesAdded += c.linesAdded || 0;
|
|
@@ -341,7 +598,7 @@ export function computeAIContribution(commits, toolFilter = null) {
|
|
|
341
598
|
}
|
|
342
599
|
|
|
343
600
|
// 加权计算:所有归因的 commit 都参与(包括 LOW)
|
|
344
|
-
const weight =
|
|
601
|
+
const weight = confidenceWeights[confidence] || 0;
|
|
345
602
|
if (weight > 0) {
|
|
346
603
|
weightedAILinesAdded += fileAdded * weight;
|
|
347
604
|
weightedAILinesDeleted += fileDeleted * weight;
|
|
@@ -428,7 +685,9 @@ export function parseGitLogOutput(output, repo = '') {
|
|
|
428
685
|
|
|
429
686
|
const flush = () => {
|
|
430
687
|
if (!current) return;
|
|
431
|
-
|
|
688
|
+
// 使用本地日期做 daily stats key(用户期望看到的日期),
|
|
689
|
+
// UTC 日期(current.date)仅用于与 session 时间戳比较
|
|
690
|
+
const dateKey = current.dateLocal || current.date.slice(0, 10);
|
|
432
691
|
// 注入 conventional 类型 + AI 信号
|
|
433
692
|
const conv = parseConventional(current.subject);
|
|
434
693
|
const ai = detectAICommit(current.subject, current.author, current.body || '');
|
|
@@ -442,6 +701,7 @@ export function parseGitLogOutput(output, repo = '') {
|
|
|
442
701
|
current.aiEvidence = ai.aiEvidence;
|
|
443
702
|
current.attributionType = ai.attributionType;
|
|
444
703
|
current.detectedTool = ai.detectedTool || null;
|
|
704
|
+
current.negativeSignals = ai.negativeSignals || [];
|
|
445
705
|
current.sessionId = null; // 由 finalize 阶段填充
|
|
446
706
|
|
|
447
707
|
result.commits++;
|
|
@@ -474,13 +734,21 @@ export function parseGitLogOutput(output, repo = '') {
|
|
|
474
734
|
const header = line.slice(COMMIT_SENTINEL.length);
|
|
475
735
|
const parts = header.split('|');
|
|
476
736
|
const hash = parts[0] || '';
|
|
477
|
-
const
|
|
737
|
+
const dateRaw = (parts[1] || '');
|
|
738
|
+
// 保留本地日期用于 commitsByDate(用户看到的日期)
|
|
739
|
+
const dateLocal = dateRaw.slice(0, 10) || '';
|
|
740
|
+
// Normalize to UTC ISO for consistent comparison with session timestamps
|
|
741
|
+
const dateMs = Date.parse(dateRaw);
|
|
742
|
+
const date = Number.isFinite(dateMs)
|
|
743
|
+
? new Date(dateMs).toISOString().slice(0, 19) + 'Z'
|
|
744
|
+
: dateRaw.slice(0, 19);
|
|
478
745
|
const author = parts[2] || '';
|
|
479
746
|
const subject = parts.slice(3).join('|');
|
|
480
747
|
current = {
|
|
481
748
|
repo,
|
|
482
749
|
hash,
|
|
483
750
|
date,
|
|
751
|
+
dateLocal,
|
|
484
752
|
author,
|
|
485
753
|
subject,
|
|
486
754
|
body: '',
|
|
@@ -539,15 +807,14 @@ function sanitizeArg(s) {
|
|
|
539
807
|
return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
|
|
540
808
|
}
|
|
541
809
|
|
|
542
|
-
function buildGitArgs(since, until
|
|
543
|
-
const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
|
|
544
|
-
const safeSince = sanitizeArg(sinceFull);
|
|
545
|
-
const safeUntil = sanitizeArg(until);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
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
|
+
}
|
|
551
818
|
|
|
552
819
|
function mergeGitStats(target, source) {
|
|
553
820
|
target.commits += source.commits;
|
|
@@ -570,37 +837,105 @@ function mergeGitStats(target, source) {
|
|
|
570
837
|
// filesChanged 在 merge 完后由 finalize 重新计算(跨 repo 去重)
|
|
571
838
|
}
|
|
572
839
|
|
|
573
|
-
function recomputeFilesChanged(stats) {
|
|
574
|
-
const set = new Set();
|
|
575
|
-
for (const c of stats.commitList || []) {
|
|
576
|
-
for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
|
|
577
|
-
}
|
|
578
|
-
stats.filesChanged = set.size;
|
|
579
|
-
}
|
|
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
|
+
}
|
|
580
900
|
|
|
581
901
|
// ── async versions (server) with cache ──
|
|
582
902
|
|
|
583
903
|
const gitCache = new Map();
|
|
904
|
+
const GIT_CACHE_MAX = 500;
|
|
905
|
+
const GIT_CACHE_TTL = 60_000;
|
|
584
906
|
const CACHE_VERSION = 'v3';
|
|
585
907
|
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
|
|
908
|
+
function evictGitCache() {
|
|
909
|
+
const now = Date.now();
|
|
910
|
+
for (const [key, val] of gitCache) {
|
|
911
|
+
if (now - val.ts > GIT_CACHE_TTL) gitCache.delete(key);
|
|
912
|
+
}
|
|
913
|
+
while (gitCache.size > GIT_CACHE_MAX) {
|
|
914
|
+
const oldest = gitCache.keys().next().value;
|
|
915
|
+
gitCache.delete(oldest);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
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;
|
|
590
923
|
|
|
591
924
|
try {
|
|
592
925
|
await execAsync('git rev-parse --git-dir', { cwd: repoPath });
|
|
593
926
|
} catch {
|
|
594
927
|
return emptyResult();
|
|
595
928
|
}
|
|
596
|
-
|
|
597
|
-
try {
|
|
598
|
-
const output = await execAsync(`git log ${buildGitArgs(since, until
|
|
599
|
-
cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
|
|
600
|
-
});
|
|
601
|
-
const stats = parseGitLogOutput(output, repoPath);
|
|
602
|
-
|
|
603
|
-
|
|
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;
|
|
604
939
|
} catch {
|
|
605
940
|
return emptyResult();
|
|
606
941
|
}
|
|
@@ -637,31 +972,6 @@ export function invalidateGitCache() {
|
|
|
637
972
|
|
|
638
973
|
// ── Session ↔ Commit 关联 ──
|
|
639
974
|
|
|
640
|
-
function normalizePath(p) {
|
|
641
|
-
if (!p) return '';
|
|
642
|
-
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase();
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function toRelativeRepoPath(filePath, repoPath) {
|
|
646
|
-
const fileN = normalizePath(filePath);
|
|
647
|
-
const repoN = normalizePath(repoPath);
|
|
648
|
-
if (!fileN) return '';
|
|
649
|
-
if (!repoN) return normalizeCommitFilePath(fileN.replace(/^[a-z]:\//i, ''));
|
|
650
|
-
if (fileN === repoN) return '';
|
|
651
|
-
if (fileN.startsWith(repoN + '/')) return fileN.slice(repoN.length + 1);
|
|
652
|
-
const repoTail = repoN.split('/').filter(Boolean).pop();
|
|
653
|
-
if (repoTail) {
|
|
654
|
-
const marker = `/${repoTail}/`;
|
|
655
|
-
const idx = fileN.indexOf(marker);
|
|
656
|
-
if (idx >= 0) return fileN.slice(idx + marker.length);
|
|
657
|
-
}
|
|
658
|
-
return fileN;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function normalizeCommitFilePath(filePath) {
|
|
662
|
-
return normalizePath(filePath).replace(/^\.?\//, '');
|
|
663
|
-
}
|
|
664
|
-
|
|
665
975
|
function looksLikeFilePath(value) {
|
|
666
976
|
if (typeof value !== 'string') return false;
|
|
667
977
|
const v = value.trim();
|
|
@@ -754,21 +1064,43 @@ function extractTouchedFilesFromSession(session) {
|
|
|
754
1064
|
const repoPath = session.project || '';
|
|
755
1065
|
const files = new Set();
|
|
756
1066
|
for (const tc of session.toolSequence || []) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1067
|
+
const name = tc.name || '';
|
|
1068
|
+
const input = tc.input || {};
|
|
1069
|
+
|
|
1070
|
+
// Claude 内置工具:Write/Edit/NotebookEdit/MultiEdit
|
|
1071
|
+
if (name === 'Write' || name === 'Edit' || name === 'NotebookEdit' || name === 'MultiEdit') {
|
|
1072
|
+
const rawPaths = collectFilePaths(input);
|
|
1073
|
+
for (const rawPath of rawPaths) {
|
|
1074
|
+
const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
|
|
1075
|
+
if (relative) files.add(relative);
|
|
1076
|
+
}
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// MCP Serena 工具:replace_content, replace_symbol_body, insert_before/after_symbol 等
|
|
1081
|
+
// 以及其他带 relative_path/file_path 的 MCP 工具
|
|
1082
|
+
if (name.startsWith('mcp__serena') || name.startsWith('mcp__')) {
|
|
1083
|
+
// Serena 使用 relative_path,其他 MCP 工具可能使用 file_path/path
|
|
1084
|
+
const filePath = input.relative_path || input.file_path || input.path || '';
|
|
1085
|
+
if (filePath && typeof filePath === 'string') {
|
|
1086
|
+
const relative = normalizeCommitFilePath(toRepoRelativePath(filePath, repoPath));
|
|
1087
|
+
if (relative) files.add(relative);
|
|
1088
|
+
}
|
|
1089
|
+
// 部分 MCP 工具在 input 中嵌套了目标文件
|
|
1090
|
+
const rawPaths = collectFilePaths(input);
|
|
760
1091
|
for (const rawPath of rawPaths) {
|
|
761
|
-
const relative = normalizeCommitFilePath(
|
|
1092
|
+
const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
|
|
762
1093
|
if (relative) files.add(relative);
|
|
763
1094
|
}
|
|
764
1095
|
continue;
|
|
765
1096
|
}
|
|
1097
|
+
|
|
766
1098
|
// Bash 工具 — 从命令中提取文件路径
|
|
767
|
-
if (
|
|
768
|
-
const cmd =
|
|
1099
|
+
if (name === 'Bash') {
|
|
1100
|
+
const cmd = input.command || '';
|
|
769
1101
|
const rawPaths = extractFilePathsFromBashCommand(cmd);
|
|
770
1102
|
for (const rawPath of rawPaths) {
|
|
771
|
-
const relative = normalizeCommitFilePath(
|
|
1103
|
+
const relative = normalizeCommitFilePath(toRepoRelativePath(rawPath, repoPath));
|
|
772
1104
|
if (relative) files.add(relative);
|
|
773
1105
|
}
|
|
774
1106
|
}
|
|
@@ -792,34 +1124,45 @@ function computeFileOverlap(sessionTouchedFiles, commitFiles) {
|
|
|
792
1124
|
});
|
|
793
1125
|
}
|
|
794
1126
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
function projectKey(p) {
|
|
801
|
-
return normalizePath(p).replace(/[-_]/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').replace(/[^a-z0-9/]/g, '');
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// 精确路径包含:parent 是 child 的前缀,且后面紧跟 '/' 或完全匹配
|
|
805
|
-
function pathContains(parent, child) {
|
|
806
|
-
if (parent === child) return true;
|
|
807
|
-
return child.startsWith(parent + '/');
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function projectMatches(commitRepoN, sessionProjectN) {
|
|
811
|
-
if (!commitRepoN || !sessionProjectN) return true;
|
|
812
|
-
// 精确路径匹配(双向:commit repo 可能是 session project 的子目录或反之)
|
|
813
|
-
if (pathContains(commitRepoN, sessionProjectN) || pathContains(sessionProjectN, commitRepoN)) return true;
|
|
814
|
-
// 宽松 key 对比(兜底:处理路径解码差异)
|
|
815
|
-
const a = projectKey(commitRepoN);
|
|
816
|
-
const b = projectKey(sessionProjectN);
|
|
817
|
-
return a && b && (a === b);
|
|
1127
|
+
function sortAttributionCandidates(candidates) {
|
|
1128
|
+
return candidates.sort((a, b) => {
|
|
1129
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
1130
|
+
return a.distanceMs - b.distanceMs;
|
|
1131
|
+
});
|
|
818
1132
|
}
|
|
819
1133
|
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
1134
|
+
function candidateFromSession(commit, session, distanceMs) {
|
|
1135
|
+
const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
|
|
1136
|
+
return scoreSessionCandidate(commit, session, {
|
|
1137
|
+
distanceMs,
|
|
1138
|
+
fileOverlapRatio: overlap.fileOverlapRatio,
|
|
1139
|
+
matchedFiles: overlap.matchedFiles,
|
|
1140
|
+
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
|
|
823
1166
|
|
|
824
1167
|
function toMs(iso) {
|
|
825
1168
|
if (!iso) return NaN;
|
|
@@ -841,18 +1184,22 @@ function extractCommitBashTimestamps(session) {
|
|
|
841
1184
|
return ts;
|
|
842
1185
|
}
|
|
843
1186
|
|
|
844
|
-
export function attributeCommitsToSessions(commits, sessions,
|
|
1187
|
+
export function attributeCommitsToSessions(commits, sessions, options = {}) {
|
|
845
1188
|
const result = { sessionCommitMap: {} };
|
|
846
1189
|
if (!commits?.length || !sessions?.length) return result;
|
|
1190
|
+
const attributionOptions = resolveAttributionOptions(options.attribution || options);
|
|
1191
|
+
const bufferMs = options.bufferMs ?? attributionOptions.windows.weakWindowMinutes * 60 * 1000;
|
|
1192
|
+
const crossDayMs = attributionOptions.windows.crossDayWindowDays * 24 * 3600 * 1000;
|
|
847
1193
|
|
|
848
1194
|
// 预计算每个 session 的 ms 范围 + 项目归一化 + bash commit 时间戳
|
|
849
1195
|
const sIndex = sessions.map(s => ({
|
|
850
1196
|
id: s.id,
|
|
851
|
-
projectN:
|
|
1197
|
+
projectN: normalizePathForGit(s.project || ''),
|
|
852
1198
|
startMs: toMs(s.startTime),
|
|
853
1199
|
endMs: toMs(s.endTime),
|
|
854
1200
|
bashTs: extractCommitBashTimestamps(s),
|
|
855
1201
|
touchedFiles: extractTouchedFilesFromSession(s),
|
|
1202
|
+
primaryTool: s.primaryTool,
|
|
856
1203
|
}));
|
|
857
1204
|
|
|
858
1205
|
// 阶段 1:重置 + 强信号匹配(Bash git commit)
|
|
@@ -860,13 +1207,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
|
|
|
860
1207
|
c.sessionId = null;
|
|
861
1208
|
c.sessionAttribution = null;
|
|
862
1209
|
const commitMs = toMs(c.date);
|
|
863
|
-
const commitRepoN =
|
|
1210
|
+
const commitRepoN = normalizePathForGit(c.repo || '');
|
|
864
1211
|
if (!Number.isFinite(commitMs)) continue;
|
|
865
1212
|
|
|
866
1213
|
let matched = null;
|
|
867
1214
|
for (const s of sIndex) {
|
|
868
1215
|
if (!s.bashTs.length) continue;
|
|
869
|
-
if (!
|
|
1216
|
+
if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
|
|
870
1217
|
for (const bts of s.bashTs) {
|
|
871
1218
|
if (commitMs >= bts - STRONG_WINDOW_BEFORE_MS && commitMs <= bts + STRONG_WINDOW_AFTER_MS) {
|
|
872
1219
|
matched = s;
|
|
@@ -899,14 +1246,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
|
|
|
899
1246
|
for (const c of commits) {
|
|
900
1247
|
if (c.sessionAttribution) continue;
|
|
901
1248
|
const commitMs = toMs(c.date);
|
|
902
|
-
const commitRepoN =
|
|
1249
|
+
const commitRepoN = normalizePathForGit(c.repo || '');
|
|
903
1250
|
if (!Number.isFinite(commitMs)) continue;
|
|
904
1251
|
|
|
905
|
-
|
|
906
|
-
let bestDist = Infinity;
|
|
1252
|
+
const candidates = [];
|
|
907
1253
|
for (const s of sIndex) {
|
|
908
1254
|
if (!Number.isFinite(s.startMs) || !Number.isFinite(s.endMs)) continue;
|
|
909
|
-
if (!
|
|
1255
|
+
if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
|
|
910
1256
|
|
|
911
1257
|
// author 一致性校验:session 有已知 author 时,commit author 必须匹配
|
|
912
1258
|
const knownAuthors = sessionAuthors.get(s.id);
|
|
@@ -917,13 +1263,13 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
|
|
|
917
1263
|
if (commitMs < lo || commitMs > hi) continue;
|
|
918
1264
|
const mid = (s.startMs + s.endMs) / 2;
|
|
919
1265
|
const dist = Math.abs(commitMs - mid);
|
|
920
|
-
|
|
921
|
-
best = s;
|
|
922
|
-
bestDist = dist;
|
|
923
|
-
}
|
|
1266
|
+
candidates.push(candidateFromSession(c, s, dist));
|
|
924
1267
|
}
|
|
925
1268
|
|
|
926
|
-
if (
|
|
1269
|
+
if (candidates.length) {
|
|
1270
|
+
const ranked = sortAttributionCandidates(candidates);
|
|
1271
|
+
const best = sIndex.find(s => s.id === ranked[0].sessionId);
|
|
1272
|
+
c.attributionCandidates = ranked.slice(0, 3);
|
|
927
1273
|
c.sessionId = best.id;
|
|
928
1274
|
c.sessionAttribution = 'weak';
|
|
929
1275
|
if (!result.sessionCommitMap[best.id]) result.sessionCommitMap[best.id] = [];
|
|
@@ -936,29 +1282,28 @@ export function attributeCommitsToSessions(commits, sessions, { bufferMs = 30 *
|
|
|
936
1282
|
for (const c of commits) {
|
|
937
1283
|
if (c.sessionAttribution) continue;
|
|
938
1284
|
const commitMs = toMs(c.date);
|
|
939
|
-
const commitRepoN =
|
|
1285
|
+
const commitRepoN = normalizePathForGit(c.repo || '');
|
|
940
1286
|
if (!Number.isFinite(commitMs)) continue;
|
|
941
1287
|
|
|
942
|
-
|
|
943
|
-
let bestDist = Infinity;
|
|
1288
|
+
const candidates = [];
|
|
944
1289
|
for (const s of sIndex) {
|
|
945
|
-
if (!
|
|
1290
|
+
if (!projectMatchesFromGitPaths(commitRepoN, s.projectN)) continue;
|
|
946
1291
|
if (!Number.isFinite(s.endMs)) continue;
|
|
947
1292
|
// commit 必须在 session 结束之后(不能是之前漏掉的)
|
|
948
1293
|
if (commitMs < s.endMs) continue;
|
|
949
1294
|
const dist = commitMs - s.endMs;
|
|
950
1295
|
// 最多跨 3 天
|
|
951
|
-
if (dist >
|
|
1296
|
+
if (dist > crossDayMs) continue;
|
|
952
1297
|
// author 校验:session 有已知 author 时,commit author 必须匹配
|
|
953
1298
|
const knownAuthors = sessionAuthors.get(s.id);
|
|
954
1299
|
if (knownAuthors?.size && c.author && !knownAuthors.has(c.author.toLowerCase())) continue;
|
|
955
|
-
|
|
956
|
-
best = s;
|
|
957
|
-
bestDist = dist;
|
|
958
|
-
}
|
|
1300
|
+
candidates.push(candidateFromSession(c, s, dist));
|
|
959
1301
|
}
|
|
960
1302
|
|
|
961
|
-
if (
|
|
1303
|
+
if (candidates.length) {
|
|
1304
|
+
const ranked = sortAttributionCandidates(candidates);
|
|
1305
|
+
const best = sIndex.find(s => s.id === ranked[0].sessionId);
|
|
1306
|
+
c.attributionCandidates = ranked.slice(0, 3);
|
|
962
1307
|
// 文件交集前置检查:无交集时标记为 cross-day-weak
|
|
963
1308
|
const commitFiles = (c.files || []).map(f => (f.path || '').replace(/\\/g, '/'));
|
|
964
1309
|
const sessionFiles = best.touchedFiles || [];
|
|
@@ -1001,15 +1346,17 @@ export function attachCommitsToSessions(sessions, commitList) {
|
|
|
1001
1346
|
}
|
|
1002
1347
|
|
|
1003
1348
|
// 一次性收尾:跑 attribution + 三个聚合
|
|
1004
|
-
export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
1005
|
-
if (!merged) return merged;
|
|
1006
|
-
const
|
|
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();
|
|
1007
1354
|
const inputOverrides = options.overrides || {};
|
|
1008
1355
|
const mergedOverrides = {
|
|
1009
1356
|
commits: { ...fileOverrides.commits, ...(inputOverrides.commits || {}) },
|
|
1010
1357
|
files: { ...fileOverrides.files, ...(inputOverrides.files || {}) },
|
|
1011
1358
|
};
|
|
1012
|
-
const { sessionCommitMap } = attributeCommitsToSessions(merged.commitList, sessions);
|
|
1359
|
+
const { sessionCommitMap } = attributeCommitsToSessions(merged.commitList, sessions, { attribution: attributionOptions });
|
|
1013
1360
|
merged.sessionCommitMap = sessionCommitMap;
|
|
1014
1361
|
const sessionsById = new Map((sessions || []).map(s => [s.id, s]));
|
|
1015
1362
|
for (const s of sessions || []) {
|
|
@@ -1031,6 +1378,73 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1031
1378
|
}
|
|
1032
1379
|
}
|
|
1033
1380
|
|
|
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
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Step 1.6: compute developer behavioral baselines
|
|
1446
|
+
const authorBaselines = computeAuthorBaseline(merged.commitList);
|
|
1447
|
+
|
|
1034
1448
|
// Step 2: 信心度评估(保持现有逻辑)
|
|
1035
1449
|
for (const c of merged.commitList || []) {
|
|
1036
1450
|
if (!c.sessionId || c.attributionType === 'explicit') continue;
|
|
@@ -1096,6 +1510,30 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1096
1510
|
signals.push('strongWithoutFileOverlap');
|
|
1097
1511
|
}
|
|
1098
1512
|
|
|
1513
|
+
// Developer baseline deviation
|
|
1514
|
+
const baseline = authorBaselines.get(c.author);
|
|
1515
|
+
const deviation = computeBaselineDeviation(c, baseline);
|
|
1516
|
+
if (deviation >= 0.4) {
|
|
1517
|
+
signals.push('baselineDeviationHigh');
|
|
1518
|
+
if (confidence === AI_CONFIDENCE.MEDIUM) confidence = pickHigherConfidence(confidence, AI_CONFIDENCE.HIGH);
|
|
1519
|
+
} else if (deviation >= 0.2) {
|
|
1520
|
+
signals.push('baselineDeviationMedium');
|
|
1521
|
+
} else if (deviation <= 0.05 && baseline && c.attributionType !== 'explicit') {
|
|
1522
|
+
signals.push('humanBaselineMatch');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Negative signals: downgrade confidence
|
|
1526
|
+
const negSignals = detectNegativeSignals(c.subject, c.body, c.linesAdded, c.linesDeleted, (c.files || []).length);
|
|
1527
|
+
if (negSignals.includes('humanMergeCommit')) {
|
|
1528
|
+
confidence = AI_CONFIDENCE.NONE;
|
|
1529
|
+
attributionType = 'human_merge';
|
|
1530
|
+
} else if (negSignals.length > 0) {
|
|
1531
|
+
if (confidence === AI_CONFIDENCE.HIGH) confidence = AI_CONFIDENCE.MEDIUM;
|
|
1532
|
+
else if (confidence === AI_CONFIDENCE.MEDIUM) confidence = AI_CONFIDENCE.LOW;
|
|
1533
|
+
else if (confidence === AI_CONFIDENCE.LOW) confidence = AI_CONFIDENCE.NONE;
|
|
1534
|
+
c.negativeSignals = negSignals;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1099
1537
|
const ai = createAIAttribution({
|
|
1100
1538
|
confidence,
|
|
1101
1539
|
signals,
|
|
@@ -1109,8 +1547,22 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1109
1547
|
c.attributionType = ai.attributionType;
|
|
1110
1548
|
}
|
|
1111
1549
|
|
|
1112
|
-
|
|
1113
|
-
for (const c of merged.commitList || []) {
|
|
1550
|
+
// 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') {
|
|
1556
|
+
c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
|
|
1557
|
+
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 || []) {
|
|
1114
1566
|
const commitOverride = mergedOverrides.commits[c.hash] || null;
|
|
1115
1567
|
const fileOverride = (c.files || []).find(f => mergedOverrides.files[`${c.hash}:${f.path}`]);
|
|
1116
1568
|
const fileOverrideValue = fileOverride ? mergedOverrides.files[`${c.hash}:${fileOverride.path}`] : null;
|
|
@@ -1140,7 +1592,7 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1140
1592
|
}
|
|
1141
1593
|
|
|
1142
1594
|
// Step 3: 全局 + 按工具聚合
|
|
1143
|
-
merged.aiContribution = computeAIContribution(merged.commitList);
|
|
1595
|
+
merged.aiContribution = computeAIContribution(merged.commitList, null, attributionOptions);
|
|
1144
1596
|
// 动态收集所有出现的 attributedTool,确保新工具自动覆盖
|
|
1145
1597
|
const toolSet = new Set();
|
|
1146
1598
|
for (const c of merged.commitList || []) {
|
|
@@ -1150,7 +1602,7 @@ export function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1150
1602
|
for (const t of ['claude', 'codex', 'opencode', 'generic-ai']) toolSet.add(t);
|
|
1151
1603
|
merged.aiContributionByTool = {};
|
|
1152
1604
|
for (const tool of toolSet) {
|
|
1153
|
-
merged.aiContributionByTool[tool] = computeAIContribution(merged.commitList, tool);
|
|
1605
|
+
merged.aiContributionByTool[tool] = computeAIContribution(merged.commitList, tool, attributionOptions);
|
|
1154
1606
|
}
|
|
1155
1607
|
merged.attributionSummary = aggregateAttribution(attributionItems);
|
|
1156
1608
|
merged.commitTypes = computeCommitTypes(merged.commitList);
|