@apdesign/cursor-roi-tracker 0.5.2

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.
@@ -0,0 +1,659 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+
5
+ function loadAiEvents(repoRoot, branch, config) {
6
+ return loadAiEventsWithMeta(repoRoot, branch, config).events;
7
+ }
8
+
9
+ function loadAiEventsWithMeta(repoRoot, branch, config) {
10
+ const eventFiles = resolveEventFiles(repoRoot, branch, config.eventsDirectory);
11
+ if (!eventFiles.length) {
12
+ return {
13
+ events: [],
14
+ stats: {
15
+ filesRead: 0,
16
+ missingEventIdLines: 0,
17
+ },
18
+ };
19
+ }
20
+
21
+ const events = [];
22
+ const stats = {
23
+ filesRead: 0,
24
+ missingEventIdLines: 0,
25
+ };
26
+ for (const filePath of eventFiles) {
27
+ const text = fs.readFileSync(filePath, "utf8");
28
+ const parsed = parseEventsText(text, repoRoot, filePath);
29
+ events.push(...parsed.events);
30
+ stats.filesRead += 1;
31
+ stats.missingEventIdLines += parsed.missingEventIdLines;
32
+ }
33
+ return {
34
+ events: dedupeEvents(events),
35
+ stats,
36
+ };
37
+ }
38
+
39
+ function resolveEventFiles(repoRoot, branch, eventsDirectoryConfig) {
40
+ const roots = normalizeEventRoots(eventsDirectoryConfig);
41
+ const allCandidates = [];
42
+
43
+ for (const root of roots) {
44
+ const basePath = path.join(repoRoot, root);
45
+ allCandidates.push(...resolveCandidatesForRoot(basePath, branch));
46
+ }
47
+
48
+ return Array.from(new Set(allCandidates.filter((candidate) => fs.existsSync(candidate))));
49
+ }
50
+
51
+ function normalizeEventRoots(eventsDirectoryConfig) {
52
+ if (Array.isArray(eventsDirectoryConfig)) {
53
+ return eventsDirectoryConfig.map((item) => String(item || "").trim()).filter(Boolean);
54
+ }
55
+ const single = String(eventsDirectoryConfig || "").trim();
56
+ return single ? [single] : [];
57
+ }
58
+
59
+ function resolveCandidatesForRoot(basePath, branch) {
60
+ if (!fs.existsSync(basePath)) {
61
+ // Backward compatibility: allow file stem config
62
+ // (e.g. ".cursor/local-ai-events" -> ".cursor/local-ai-events.jsonl")
63
+ return [`${basePath}.jsonl`, `${basePath}.json`, basePath].filter((candidate) =>
64
+ isReadableFile(candidate)
65
+ );
66
+ }
67
+
68
+ if (fs.statSync(basePath).isFile()) {
69
+ return [basePath];
70
+ }
71
+
72
+ const explicitCandidates = [
73
+ path.join(basePath, `${branch}.jsonl`),
74
+ path.join(basePath, `${branch.replace(/[\\/]/g, "__")}.jsonl`),
75
+ path.join(basePath, "current.jsonl"),
76
+ path.join(basePath, `${branch}.json`),
77
+ path.join(basePath, `${branch.replace(/[\\/]/g, "__")}.json`),
78
+ path.join(basePath, "current.json"),
79
+ ];
80
+
81
+ const fallbackCandidates = fs
82
+ .readdirSync(basePath, { withFileTypes: true })
83
+ .filter((entry) => entry.isFile())
84
+ .map((entry) => entry.name)
85
+ .filter((name) => /\.(jsonl|json)$/i.test(name))
86
+ .map((name) => path.join(basePath, name));
87
+
88
+ return [...explicitCandidates, ...fallbackCandidates].filter((candidate) => isReadableFile(candidate));
89
+ }
90
+
91
+ function isReadableFile(filePath) {
92
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
93
+ }
94
+
95
+ function parseEventsText(text, repoRoot, sourceFile) {
96
+ const trimmed = String(text || "").trim();
97
+ if (!trimmed) {
98
+ return {
99
+ events: [],
100
+ missingEventIdLines: 0,
101
+ };
102
+ }
103
+
104
+ let missingEventIdLines = 0;
105
+ // Primary format: JSONL
106
+ const jsonlLines = trimmed.split("\n").map((item) => item.trim()).filter(Boolean);
107
+ const jsonlEvents = [];
108
+ for (const line of jsonlLines) {
109
+ try {
110
+ const normalized = normalizeEvent(JSON.parse(line), repoRoot, sourceFile);
111
+ if (!normalized) {
112
+ continue;
113
+ }
114
+ if (normalized.invalidReason === "missingEventId") {
115
+ missingEventIdLines += 1;
116
+ continue;
117
+ }
118
+ jsonlEvents.push(normalized);
119
+ } catch (_error) {
120
+ // ignore malformed line to keep parser fault-tolerant
121
+ }
122
+ }
123
+ if (jsonlLines.length > 0) {
124
+ return {
125
+ events: jsonlEvents,
126
+ missingEventIdLines,
127
+ };
128
+ }
129
+
130
+ // Fallback: JSON array / object wrapper
131
+ try {
132
+ const parsed = JSON.parse(trimmed);
133
+ if (Array.isArray(parsed)) {
134
+ const events = [];
135
+ for (const item of parsed) {
136
+ const normalized = normalizeEvent(item, repoRoot, sourceFile);
137
+ if (!normalized) continue;
138
+ if (normalized.invalidReason === "missingEventId") {
139
+ missingEventIdLines += 1;
140
+ continue;
141
+ }
142
+ events.push(normalized);
143
+ }
144
+ return {
145
+ events,
146
+ missingEventIdLines,
147
+ };
148
+ }
149
+ if (Array.isArray(parsed?.events)) {
150
+ const events = [];
151
+ for (const item of parsed.events) {
152
+ const normalized = normalizeEvent(item, repoRoot, sourceFile);
153
+ if (!normalized) continue;
154
+ if (normalized.invalidReason === "missingEventId") {
155
+ missingEventIdLines += 1;
156
+ continue;
157
+ }
158
+ events.push(normalized);
159
+ }
160
+ return {
161
+ events,
162
+ missingEventIdLines,
163
+ };
164
+ }
165
+ const single = normalizeEvent(parsed, repoRoot, sourceFile);
166
+ if (!single || single.invalidReason) {
167
+ return {
168
+ events: [],
169
+ missingEventIdLines: missingEventIdLines + (single?.invalidReason === "missingEventId" ? 1 : 0),
170
+ };
171
+ }
172
+ return {
173
+ events: [single],
174
+ missingEventIdLines,
175
+ };
176
+ } catch (_error) {
177
+ return {
178
+ events: [],
179
+ missingEventIdLines,
180
+ };
181
+ }
182
+ }
183
+
184
+ function normalizeEvent(raw, repoRoot, sourceFile) {
185
+ const eventType = String(raw.type || "").trim();
186
+ const isProbeBulkInsert = eventType === "agent_bulk_insert";
187
+ const isProbePureDelete = eventType === "agent_pure_delete";
188
+ const isProbeEvent = isProbeBulkInsert || isProbePureDelete;
189
+ const eventId = String(raw.event_id || raw.eventId || "").trim();
190
+ if (!eventId && isProbeEvent) {
191
+ return { invalidReason: "missingEventId" };
192
+ }
193
+ const rawPath = String(
194
+ raw.relative_path || raw.relativePath || raw.file_path || raw.filePath || raw.path || ""
195
+ ).trim();
196
+ const relativePath = normalizeEventPath(rawPath, repoRoot);
197
+ const score = Number.isFinite(Number(raw.score)) ? Number(raw.score) : isProbeEvent ? 1 : Number.NaN;
198
+ const legacyStartLine = Number(raw.start_line ?? raw.startLine);
199
+ const legacyEndLine = Number(raw.end_line ?? raw.endLine);
200
+ const insertStartLine = Number(raw.insert_start_line ?? raw.insertStartLine);
201
+ const insertEndLine = Number(raw.insert_end_line ?? raw.insertEndLine);
202
+ const deleteStartLine = Number(raw.delete_start_line ?? raw.deleteStartLine);
203
+ const deleteEndLine = Number(raw.delete_end_line ?? raw.deleteEndLine);
204
+ const insertRangesFromLegacyRange = toRangeArrayFromStartEnd(legacyStartLine, legacyEndLine);
205
+ const insertRangesFromProbeRange = toRangeArrayFromStartEnd(insertStartLine, insertEndLine);
206
+ const deleteRangesFromProbeRange = toRangeArrayFromStartEnd(deleteStartLine, deleteEndLine);
207
+ const insertRangesOfficial = normalizeRanges(raw.insert_ranges || raw.insertRanges);
208
+ const deleteRangesOfficial = normalizeRanges(raw.delete_ranges || raw.deleteRanges);
209
+ const insertRangesProbe = mergeRanges([...insertRangesFromProbeRange, ...insertRangesFromLegacyRange]);
210
+ const deleteRangesProbe = mergeRanges(deleteRangesFromProbeRange);
211
+ const insertRanges = mergeRanges([
212
+ ...insertRangesOfficial,
213
+ ...insertRangesProbe,
214
+ ]);
215
+ const deleteRanges = mergeRanges([...deleteRangesOfficial, ...deleteRangesProbe]);
216
+ const ts = asNullableNumber(raw.timestamp);
217
+ if (!eventId || !relativePath || !Number.isFinite(score)) {
218
+ return null;
219
+ }
220
+
221
+ return {
222
+ eventId,
223
+ sourceFile: sourceFile || null,
224
+ relativePath,
225
+ score,
226
+ branchAtEvent: raw.branch_at_event || raw.branchAtEvent || null,
227
+ tsStart: asNullableNumber(raw.ts_start ?? raw.tsStart) ?? ts,
228
+ tsEnd: asNullableNumber(raw.ts_end ?? raw.tsEnd) ?? ts,
229
+ type: eventType || "unknown",
230
+ insertRanges,
231
+ insertRangesOfficial,
232
+ insertRangesProbe,
233
+ deleteRanges,
234
+ deleteRangesOfficial,
235
+ deleteRangesProbe,
236
+ insertLines: asNullableNumber(raw.insert_lines ?? raw.insertLines) ?? 0,
237
+ deletedLines: asNullableNumber(raw.deleted_lines ?? raw.deletedLines) ?? 0,
238
+ deletedTextHashes: normalizeStringArray(raw.deleted_text_hashes || raw.deletedTextHashes),
239
+ anchorHash: raw.anchor_hash || raw.anchorHash || null,
240
+ insertTextHashes: normalizeStringArray(raw.insert_text_hashes || raw.insertTextHashes),
241
+ };
242
+ }
243
+
244
+ function toRangeArrayFromStartEnd(startLine, endLine) {
245
+ if (!Number.isFinite(startLine) || !Number.isFinite(endLine) || endLine < startLine) {
246
+ return [];
247
+ }
248
+ return [{ startLine, count: endLine - startLine + 1 }];
249
+ }
250
+
251
+ function normalizeEventPath(filePath, repoRoot) {
252
+ const normalized = normalizePath(filePath);
253
+ if (!normalized) {
254
+ return "";
255
+ }
256
+ if (!path.isAbsolute(normalized)) {
257
+ return normalized.replace(/^\.\//, "");
258
+ }
259
+ const relative = path.relative(repoRoot, normalized).replace(/\\/g, "/");
260
+ if (!relative || relative.startsWith("../")) {
261
+ return normalized;
262
+ }
263
+ return relative.replace(/^\.\//, "");
264
+ }
265
+
266
+ function normalizeRanges(input) {
267
+ if (!Array.isArray(input)) {
268
+ return [];
269
+ }
270
+ return input
271
+ .map((item) => {
272
+ const start = Number(item.startLine ?? item.start ?? item.line);
273
+ const count = Number(item.count ?? item.lines ?? item.lineCount ?? 0);
274
+ if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
275
+ return null;
276
+ }
277
+ return { startLine: start, count };
278
+ })
279
+ .filter(Boolean);
280
+ }
281
+
282
+ function normalizeStringArray(input) {
283
+ if (!Array.isArray(input)) {
284
+ return [];
285
+ }
286
+ return input
287
+ .map((item) => String(item || "").trim())
288
+ .filter(Boolean);
289
+ }
290
+
291
+ function asNullableNumber(value) {
292
+ const num = Number(value);
293
+ return Number.isFinite(num) ? num : null;
294
+ }
295
+
296
+ function buildAiMetricsFromIntersections(fileMetrics, events, threshold) {
297
+ const byPath = new Map(fileMetrics.map((item) => [normalizePath(item.filePath), item]));
298
+ const aiBlocks = [];
299
+ const touchedFiles = new Set();
300
+ const seenAddedLinesByFile = new Map();
301
+ const seenDeletedLinesByFile = new Map();
302
+ const seenAiBlocks = new Set();
303
+ const deletedSourcePriorityForReplace = ["probe", "official", "fallback"];
304
+ const deletedSourcePriorityForPureDelete = ["official", "probe", "fallback"];
305
+
306
+ for (const event of events) {
307
+ if (event.score < threshold) {
308
+ continue;
309
+ }
310
+ const normalizedEventPath = normalizePath(event.relativePath);
311
+ const fileMetric =
312
+ byPath.get(normalizedEventPath) ||
313
+ byPath.get(normalizedEventPath.replace(/^\.\//, "")) ||
314
+ byPath.get(path.basename(normalizedEventPath));
315
+ if (!fileMetric) {
316
+ continue;
317
+ }
318
+
319
+ const newSideHunks = fileMetric.hunks.map((hunk) => ({
320
+ startLine: hunk.newStart,
321
+ count: hunk.newCount,
322
+ }));
323
+ const oldSideHunks = fileMetric.hunks.map((hunk) => ({
324
+ startLine: hunk.oldStart,
325
+ count: hunk.oldCount,
326
+ }));
327
+
328
+ const addedIntersections = intersectEventRangesWithHunks(event.insertRanges, newSideHunks);
329
+ const deletedAttribution = resolveDeletedAttribution({
330
+ event,
331
+ fileHunks: fileMetric.hunks,
332
+ oldSideHunks,
333
+ isPureDeleteEvent: event.type === "agent_pure_delete",
334
+ sourcePriority:
335
+ event.type === "agent_pure_delete"
336
+ ? deletedSourcePriorityForPureDelete
337
+ : deletedSourcePriorityForReplace,
338
+ });
339
+ const deletedIntersections = deletedAttribution.intersections;
340
+
341
+ const fileKey = normalizePath(fileMetric.filePath);
342
+ const addedLineSet = getOrCreateLineSet(seenAddedLinesByFile, fileKey);
343
+ const deletedLineSet = getOrCreateLineSet(seenDeletedLinesByFile, fileKey);
344
+ const uniqueAddedLines = countUniqueIntersectionLines(addedIntersections, addedLineSet);
345
+ const uniqueDeletedLines = countUniqueIntersectionLines(deletedIntersections, deletedLineSet);
346
+
347
+ if (uniqueAddedLines > 0 || uniqueDeletedLines > 0) {
348
+ touchedFiles.add(normalizePath(event.relativePath));
349
+ fileMetric.aiAddedLines = (fileMetric.aiAddedLines || 0) + uniqueAddedLines;
350
+ fileMetric.aiDeletedLines = (fileMetric.aiDeletedLines || 0) + uniqueDeletedLines;
351
+ fileMetric.aiTouched = true;
352
+ fileMetric.deletedAttribution = fileMetric.deletedAttribution || {
353
+ probe: 0,
354
+ official: 0,
355
+ fallback: 0,
356
+ };
357
+ if (uniqueDeletedLines > 0) {
358
+ fileMetric.deletedAttribution[deletedAttribution.source] += uniqueDeletedLines;
359
+ }
360
+
361
+ for (const intersection of addedIntersections) {
362
+ const key = `added:${normalizePath(event.relativePath)}:${intersection.startLine}:${intersection.count}`;
363
+ if (seenAiBlocks.has(key)) continue;
364
+ seenAiBlocks.add(key);
365
+ aiBlocks.push(
366
+ createAiBlock({
367
+ kind: "added",
368
+ filePath: event.relativePath,
369
+ newStart: intersection.startLine,
370
+ newCount: intersection.count,
371
+ event,
372
+ })
373
+ );
374
+ }
375
+
376
+ for (const intersection of deletedIntersections) {
377
+ const key = `deleted:${normalizePath(event.relativePath)}:${intersection.startLine}:${intersection.count}`;
378
+ if (seenAiBlocks.has(key)) continue;
379
+ seenAiBlocks.add(key);
380
+ aiBlocks.push(
381
+ createAiBlock({
382
+ kind: "deleted",
383
+ filePath: event.relativePath,
384
+ oldStart: intersection.startLine,
385
+ oldCount: intersection.count,
386
+ event,
387
+ deletedAttributionSource: deletedAttribution.source,
388
+ })
389
+ );
390
+ }
391
+ }
392
+ }
393
+
394
+ let aiAddedLines = 0;
395
+ let aiDeletedLines = 0;
396
+ for (const item of fileMetrics) {
397
+ aiAddedLines += item.aiAddedLines || 0;
398
+ aiDeletedLines += item.aiDeletedLines || 0;
399
+ }
400
+
401
+ return {
402
+ aiAddedLines,
403
+ aiDeletedLines,
404
+ aiTouchedFilesCount: touchedFiles.size,
405
+ aiBlocks,
406
+ };
407
+ }
408
+
409
+ function resolveDeletedAttribution({ event, fileHunks, oldSideHunks, isPureDeleteEvent, sourcePriority }) {
410
+ const bySource = {
411
+ probe: mergeRanges(event.deleteRangesProbe || []),
412
+ official: mergeRanges(event.deleteRangesOfficial || []),
413
+ fallback: [],
414
+ };
415
+
416
+ if (!isPureDeleteEvent) {
417
+ bySource.fallback = inferDeletedIntersectionsFromTouchedHunks(fileHunks, event.insertRanges);
418
+ }
419
+
420
+ for (const source of sourcePriority) {
421
+ const sourceRanges = bySource[source];
422
+ if (!sourceRanges || sourceRanges.length === 0) {
423
+ continue;
424
+ }
425
+ const intersections = applyGitCeilingToRanges(sourceRanges, oldSideHunks);
426
+ if (intersections.length > 0) {
427
+ return {
428
+ source,
429
+ intersections,
430
+ };
431
+ }
432
+ }
433
+
434
+ return {
435
+ source: "fallback",
436
+ intersections: [],
437
+ };
438
+ }
439
+
440
+ function getOrCreateLineSet(map, key) {
441
+ if (!map.has(key)) {
442
+ map.set(key, new Set());
443
+ }
444
+ return map.get(key);
445
+ }
446
+
447
+ function countUniqueIntersectionLines(intersections, seenLineSet) {
448
+ let uniqueCount = 0;
449
+ for (const intersection of intersections) {
450
+ const start = Number(intersection.startLine);
451
+ const count = Number(intersection.count);
452
+ if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
453
+ continue;
454
+ }
455
+ for (let line = start; line < start + count; line += 1) {
456
+ if (seenLineSet.has(line)) continue;
457
+ seenLineSet.add(line);
458
+ uniqueCount += 1;
459
+ }
460
+ }
461
+ return uniqueCount;
462
+ }
463
+
464
+ function intersectEventRangesWithHunks(eventRanges, hunkRanges) {
465
+ const results = [];
466
+ const mergedEventRanges = mergeRanges(eventRanges || []);
467
+ const mergedHunks = mergeRanges((hunkRanges || []).filter((item) => Number(item.count) > 0));
468
+ for (const ev of mergedEventRanges) {
469
+ const evEnd = ev.startLine + ev.count;
470
+ for (const hunk of mergedHunks) {
471
+ const hunkEnd = hunk.startLine + hunk.count;
472
+ const overlapStart = Math.max(ev.startLine, hunk.startLine);
473
+ const overlapEnd = Math.min(evEnd, hunkEnd);
474
+ if (overlapStart < overlapEnd) {
475
+ results.push({ startLine: overlapStart, count: overlapEnd - overlapStart });
476
+ }
477
+ }
478
+ }
479
+ return mergeRanges(results);
480
+ }
481
+
482
+ function inferDeletedIntersectionsFromTouchedHunks(hunks, insertRanges) {
483
+ if (!Array.isArray(hunks) || !Array.isArray(insertRanges) || !insertRanges.length) {
484
+ return [];
485
+ }
486
+ const results = [];
487
+ for (const hunk of hunks) {
488
+ const newStart = Number(hunk.newStart);
489
+ const newCount = Number(hunk.newCount);
490
+ const oldStart = Number(hunk.oldStart);
491
+ const oldCount = Number(hunk.oldCount);
492
+ // Only modified hunks can contribute fallback deleted lines.
493
+ if (!Number.isFinite(oldStart) || !Number.isFinite(oldCount) || oldCount <= 0) continue;
494
+ if (!Number.isFinite(newStart) || !Number.isFinite(newCount) || newCount <= 0) continue;
495
+ const touchedRanges = intersectEventRangesWithHunks(insertRanges, [{ startLine: newStart, count: newCount }]);
496
+ if (!touchedRanges.length) continue;
497
+ for (const touched of touchedRanges) {
498
+ const mapped = mapTouchedNewRangeToOldRange({
499
+ touchedRange: touched,
500
+ newStart,
501
+ newCount,
502
+ oldStart,
503
+ oldCount,
504
+ });
505
+ if (mapped) {
506
+ results.push(mapped);
507
+ }
508
+ }
509
+ }
510
+ return mergeRanges(results);
511
+ }
512
+
513
+ function mapTouchedNewRangeToOldRange({ touchedRange, newStart, newCount, oldStart, oldCount }) {
514
+ const touchedStart = Number(touchedRange.startLine);
515
+ const touchedCount = Number(touchedRange.count);
516
+ if (!Number.isFinite(touchedStart) || !Number.isFinite(touchedCount) || touchedCount <= 0) {
517
+ return null;
518
+ }
519
+ const relativeStart = touchedStart - newStart;
520
+ const relativeRatio = relativeStart / Math.max(1, newCount);
521
+ const mappedStart = oldStart + Math.floor(relativeRatio * oldCount);
522
+ const mappedCount = Math.max(1, Math.round((touchedCount / Math.max(1, newCount)) * oldCount));
523
+ const maxOldEnd = oldStart + oldCount;
524
+ const clippedStart = Math.max(oldStart, Math.min(mappedStart, maxOldEnd - 1));
525
+ const clippedEnd = Math.min(maxOldEnd, clippedStart + mappedCount);
526
+ if (clippedEnd <= clippedStart) {
527
+ return null;
528
+ }
529
+ return {
530
+ startLine: clippedStart,
531
+ count: clippedEnd - clippedStart,
532
+ };
533
+ }
534
+
535
+ function mergeRanges(ranges) {
536
+ const normalized = (ranges || [])
537
+ .map((item) => {
538
+ const startLine = Number(item.startLine);
539
+ const count = Number(item.count);
540
+ if (!Number.isFinite(startLine) || !Number.isFinite(count) || count <= 0) {
541
+ return null;
542
+ }
543
+ return { startLine, count };
544
+ })
545
+ .filter(Boolean);
546
+ if (!normalized.length) return [];
547
+ const sorted = [...normalized].sort((a, b) => a.startLine - b.startLine);
548
+ const merged = [sorted[0]];
549
+ for (let i = 1; i < sorted.length; i += 1) {
550
+ const current = sorted[i];
551
+ const last = merged[merged.length - 1];
552
+ const lastEnd = last.startLine + last.count;
553
+ const currentEnd = current.startLine + current.count;
554
+ if (current.startLine <= lastEnd) {
555
+ last.count = Math.max(lastEnd, currentEnd) - last.startLine;
556
+ } else {
557
+ merged.push({ ...current });
558
+ }
559
+ }
560
+ return merged;
561
+ }
562
+
563
+ function applyGitCeilingToRanges(eventRanges, oldSideHunks) {
564
+ const intersections = intersectEventRangesWithHunks(eventRanges, oldSideHunks);
565
+ if (!intersections.length) {
566
+ return [];
567
+ }
568
+ const mergedIntersections = mergeRanges(intersections);
569
+ const sortedHunks = mergeRanges(oldSideHunks || []);
570
+ const capped = [];
571
+
572
+ for (const hunk of sortedHunks) {
573
+ let remaining = Number(hunk.count);
574
+ if (!Number.isFinite(remaining) || remaining <= 0) {
575
+ continue;
576
+ }
577
+ const overlaps = intersectEventRangesWithHunks(mergedIntersections, [hunk]);
578
+ for (const overlap of overlaps) {
579
+ if (remaining <= 0) {
580
+ break;
581
+ }
582
+ const keepCount = Math.min(overlap.count, remaining);
583
+ if (keepCount <= 0) {
584
+ continue;
585
+ }
586
+ capped.push({ startLine: overlap.startLine, count: keepCount });
587
+ remaining -= keepCount;
588
+ }
589
+ }
590
+
591
+ return mergeRanges(capped);
592
+ }
593
+
594
+ function sumIntersectionLines(intersections) {
595
+ let total = 0;
596
+ for (const item of intersections) {
597
+ total += item.count;
598
+ }
599
+ return total;
600
+ }
601
+
602
+ function createAiBlock({
603
+ kind,
604
+ filePath,
605
+ newStart,
606
+ newCount,
607
+ oldStart,
608
+ oldCount,
609
+ event,
610
+ deletedAttributionSource,
611
+ }) {
612
+ const hashInput = `${event.eventId}:${filePath}:${kind}:${newStart || oldStart}:${newCount || oldCount}`;
613
+ const blockId = crypto.createHash("sha256").update(hashInput).digest("hex");
614
+
615
+ return {
616
+ blockId,
617
+ kind,
618
+ filePath,
619
+ newStart: newStart ?? null,
620
+ newCount: newCount ?? null,
621
+ oldStart: oldStart ?? null,
622
+ oldCount: oldCount ?? null,
623
+ blockHash: event.insertTextHashes?.[0] || event.deletedTextHashes?.[0] || null,
624
+ anchorHash: event.anchorHash,
625
+ aiScore: event.score,
626
+ deletedAttributionSource: deletedAttributionSource ?? null,
627
+ sourceEventId: event.eventId,
628
+ originalCommitSha: null,
629
+ tsStart: event.tsStart,
630
+ tsEnd: event.tsEnd,
631
+ };
632
+ }
633
+
634
+ function normalizePath(filePath) {
635
+ return String(filePath || "")
636
+ .replace(/\\/g, "/")
637
+ .replace(/^\.\//, "")
638
+ .trim();
639
+ }
640
+
641
+ function dedupeEvents(events) {
642
+ const seen = new Set();
643
+ const output = [];
644
+ for (const event of events) {
645
+ const key = `${event.eventId}:${event.relativePath}:${event.tsStart ?? ""}:${event.tsEnd ?? ""}`;
646
+ if (seen.has(key)) {
647
+ continue;
648
+ }
649
+ seen.add(key);
650
+ output.push(event);
651
+ }
652
+ return output;
653
+ }
654
+
655
+ module.exports = {
656
+ loadAiEvents,
657
+ loadAiEventsWithMeta,
658
+ buildAiMetricsFromIntersections,
659
+ };