ai-credit 1.0.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.
@@ -0,0 +1,372 @@
1
+ import * as fs from 'fs';
2
+ import chalk from 'chalk';
3
+ import Table from 'cli-table3';
4
+ import { AITool } from './types.js';
5
+ /**
6
+ * Tool display names
7
+ */
8
+ const TOOL_NAMES = {
9
+ [AITool.CLAUDE_CODE]: 'Claude Code',
10
+ [AITool.CODEX]: 'Codex CLI',
11
+ [AITool.GEMINI]: 'Gemini CLI',
12
+ [AITool.AIDER]: 'Aider',
13
+ [AITool.OPENCODE]: 'Opencode',
14
+ };
15
+ /**
16
+ * Tool colors for console output
17
+ */
18
+ const TOOL_COLORS = {
19
+ [AITool.CLAUDE_CODE]: chalk.hex('#D97757'),
20
+ [AITool.CODEX]: chalk.hex('#00A67E'),
21
+ [AITool.GEMINI]: chalk.hex('#4796E3'),
22
+ [AITool.AIDER]: chalk.hex('#D93B3B'),
23
+ [AITool.OPENCODE]: chalk.yellow,
24
+ };
25
+ /**
26
+ * Console reporter for terminal output
27
+ */
28
+ export class ConsoleReporter {
29
+ /**
30
+ * Print the full summary report
31
+ */
32
+ printSummary(stats) {
33
+ this.printHeader(stats);
34
+ this.printOverview(stats);
35
+ this.printToolBreakdown(stats);
36
+ this.printDistributionBar(stats);
37
+ }
38
+ /**
39
+ * Print report header
40
+ */
41
+ printHeader(stats) {
42
+ const boxWidth = 50;
43
+ const title = 'AI Contribution Analysis';
44
+ const repoLine = `Repository: ${stats.repoPath}`;
45
+ const timeLine = `Scan time: ${stats.scanTime.toLocaleString()}`;
46
+ console.log();
47
+ console.log(chalk.cyan('╭' + '─'.repeat(boxWidth) + '╮'));
48
+ console.log(chalk.cyan('│') + ' ' + chalk.bold(title.padEnd(boxWidth - 1)) + chalk.cyan('│'));
49
+ console.log(chalk.cyan('│') + ' ' + repoLine.substring(0, boxWidth - 1).padEnd(boxWidth - 1) + chalk.cyan('│'));
50
+ console.log(chalk.cyan('│') + ' ' + timeLine.padEnd(boxWidth - 1) + chalk.cyan('│'));
51
+ console.log(chalk.cyan('╰' + '─'.repeat(boxWidth) + '╯'));
52
+ console.log();
53
+ }
54
+ /**
55
+ * Print overview statistics
56
+ */
57
+ printOverview(stats) {
58
+ console.log(chalk.bold(' 📊 Overview'));
59
+ const table = new Table({
60
+ head: ['Metric', 'Value', 'AI Contribution'].map(h => chalk.bold(h)),
61
+ style: { head: [], border: [] },
62
+ });
63
+ const fileRatio = stats.totalFiles > 0
64
+ ? ((stats.aiTouchedFiles / stats.totalFiles) * 100).toFixed(1)
65
+ : '0.0';
66
+ const lineRatio = stats.totalLines > 0
67
+ ? ((stats.aiContributedLines / stats.totalLines) * 100).toFixed(1)
68
+ : '0.0';
69
+ table.push(['Total Files', stats.totalFiles.toString(), `${stats.aiTouchedFiles} (${fileRatio}%)`], ['Total Lines', stats.totalLines.toString(), `${stats.aiContributedLines} (${lineRatio}%)`], ['AI Sessions', stats.sessions.length.toString(), '-']);
70
+ console.log(table.toString());
71
+ console.log();
72
+ }
73
+ /**
74
+ * Print breakdown by AI tool
75
+ */
76
+ printToolBreakdown(stats) {
77
+ if (stats.byTool.size === 0) {
78
+ console.log(chalk.yellow('No AI contributions found.'));
79
+ return;
80
+ }
81
+ console.log(chalk.bold(' 🤖 Contribution by AI Tool'));
82
+ const table = new Table({
83
+ head: ['Tool / Model', 'Sessions', 'Files', 'Lines Added', 'Lines Removed', 'Share'].map(h => chalk.bold(h)),
84
+ style: { head: [], border: [] },
85
+ });
86
+ const totalLines = Array.from(stats.byTool.values())
87
+ .reduce((sum, t) => sum + t.linesAdded, 0);
88
+ for (const [tool, toolStats] of stats.byTool) {
89
+ const share = totalLines > 0
90
+ ? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
91
+ : '0.0';
92
+ const color = TOOL_COLORS[tool] || chalk.white;
93
+ // Add tool row
94
+ table.push([
95
+ color(TOOL_NAMES[tool]),
96
+ toolStats.sessionsCount.toString(),
97
+ toolStats.totalFiles.toString(),
98
+ chalk.green(`+${toolStats.linesAdded}`),
99
+ chalk.red(`-${toolStats.linesRemoved}`),
100
+ `${share}%`,
101
+ ]);
102
+ // Add model rows (if known and more than just "unknown" or if explicitly wanted)
103
+ if (toolStats.byModel.size > 0) {
104
+ // Sort models by lines added
105
+ const sortedModels = Array.from(toolStats.byModel.entries())
106
+ .sort((a, b) => b[1].linesAdded - a[1].linesAdded);
107
+ for (const [modelName, modelStats] of sortedModels) {
108
+ // Skip if model is 'unknown' and it's the only one (redundant)
109
+ if (modelName === 'unknown' && toolStats.byModel.size === 1)
110
+ continue;
111
+ const modelShare = toolStats.linesAdded > 0
112
+ ? ((modelStats.linesAdded / toolStats.linesAdded) * 100).toFixed(1)
113
+ : '0.0';
114
+ table.push([
115
+ chalk.dim(` └─ ${modelName}`),
116
+ chalk.dim(modelStats.sessionsCount.toString()),
117
+ chalk.dim(modelStats.totalFiles.toString()),
118
+ chalk.dim(`+${modelStats.linesAdded}`),
119
+ chalk.dim(`-${modelStats.linesRemoved}`),
120
+ chalk.dim(`${modelShare}% (of tool)`),
121
+ ]);
122
+ }
123
+ }
124
+ }
125
+ console.log(table.toString());
126
+ console.log();
127
+ }
128
+ /**
129
+ * Print distribution pie chart showing all code proportions
130
+ */
131
+ printDistributionBar(stats) {
132
+ if (stats.totalLines === 0)
133
+ return;
134
+ console.log(chalk.bold('📈 Contribution Distribution'));
135
+ console.log();
136
+ // Build slices: proportion each AI tool's share of repo lines + Unknown/Human
137
+ // Now using Verified Lines (Verified Existence logic)
138
+ const slices = [];
139
+ for (const [tool, toolStats] of stats.byTool) {
140
+ if (toolStats.verifiedLines > 0) {
141
+ const color = TOOL_COLORS[tool] || chalk.white;
142
+ slices.push({ label: TOOL_NAMES[tool], value: toolStats.verifiedLines, color: (s) => color(s) });
143
+ }
144
+ }
145
+ const humanLines = Math.max(0, stats.totalLines - stats.aiContributedLines);
146
+ if (humanLines > 0) {
147
+ slices.push({ label: 'Unknown/Human', value: humanLines, color: (s) => chalk.gray(s) });
148
+ }
149
+ const total = slices.reduce((sum, s) => sum + s.value, 0);
150
+ // Note: total should be approx stats.totalLines (or equal if we assume aiContributedLines is sum of verifiedLines)
151
+ if (total === 0)
152
+ return;
153
+ // Render stacked horizontal bar
154
+ const barWidth = 60;
155
+ let bar = '';
156
+ const segments = [];
157
+ for (const slice of slices) {
158
+ const width = Math.max(1, Math.round((slice.value / total) * barWidth));
159
+ segments.push({ width, color: slice.color });
160
+ }
161
+ // Adjust rounding so total width matches barWidth
162
+ const totalWidth = segments.reduce((s, seg) => s + seg.width, 0);
163
+ if (totalWidth !== barWidth && segments.length > 0) {
164
+ // Just subtract/add from the largest or last segment to make it fit
165
+ segments[segments.length - 1].width += barWidth - totalWidth;
166
+ }
167
+ for (const seg of segments) {
168
+ // Ensure width is not negative if adjustment failed heavily
169
+ if (seg.width > 0) {
170
+ bar += seg.color('█'.repeat(seg.width));
171
+ }
172
+ }
173
+ console.log(` ${bar}`);
174
+ console.log();
175
+ // Legend with percentage bars per slice
176
+ for (const slice of slices) {
177
+ const pct = (slice.value / total) * 100;
178
+ const dot = slice.color('●');
179
+ console.log(` ${dot} ${slice.label.padEnd(14)} ${pct.toFixed(1).padStart(5)}% (${slice.value} lines)`);
180
+ }
181
+ console.log();
182
+ }
183
+ /**
184
+ * Print file-level statistics
185
+ */
186
+ printFiles(stats, limit = 20) {
187
+ console.log(chalk.bold(' 📁 Top AI-Contributed Files'));
188
+ const table = new Table({
189
+ head: ['File', 'Total Lines', 'AI Lines', 'AI Ratio', 'Contributors'].map(h => chalk.bold(h)),
190
+ style: { head: [], border: [] },
191
+ colWidths: [30, 12, 10, 10, 15],
192
+ });
193
+ // Sort files by AI contribution ratio
194
+ const sortedFiles = Array.from(stats.byFile.entries())
195
+ .filter(([, s]) => s.aiContributedLines > 0)
196
+ .sort((a, b) => b[1].aiContributionRatio - a[1].aiContributionRatio)
197
+ .slice(0, limit);
198
+ for (const [filePath, fileStats] of sortedFiles) {
199
+ const ratio = (fileStats.aiContributionRatio * 100).toFixed(1) + '%';
200
+ const contributors = Array.from(fileStats.contributions.keys())
201
+ .map(t => TOOL_NAMES[t])
202
+ .join(', ');
203
+ // Truncate long file paths
204
+ const displayPath = filePath.length > 27
205
+ ? '...' + filePath.slice(-24)
206
+ : filePath;
207
+ table.push([
208
+ displayPath,
209
+ fileStats.totalLines.toString(),
210
+ fileStats.aiContributedLines.toString(),
211
+ ratio,
212
+ contributors.length > 12 ? contributors.slice(0, 12) + '...' : contributors,
213
+ ]);
214
+ }
215
+ console.log(table.toString());
216
+ console.log();
217
+ }
218
+ /**
219
+ * Print timeline of AI activity
220
+ */
221
+ printTimeline(stats, limit = 20) {
222
+ console.log(chalk.bold(' 📅 Recent AI Activity'));
223
+ const table = new Table({
224
+ head: ['Date', 'Tool', 'Files', 'Changes'].map(h => chalk.bold(h)),
225
+ style: { head: [], border: [] },
226
+ });
227
+ const recentSessions = stats.sessions.slice(-limit).reverse();
228
+ for (const session of recentSessions) {
229
+ const date = session.timestamp.toLocaleString('en-US', {
230
+ year: 'numeric',
231
+ month: '2-digit',
232
+ day: '2-digit',
233
+ hour: '2-digit',
234
+ minute: '2-digit',
235
+ });
236
+ const color = TOOL_COLORS[session.tool] || chalk.white;
237
+ table.push([
238
+ chalk.dim(date),
239
+ color(TOOL_NAMES[session.tool]),
240
+ session.totalFilesChanged.toString(),
241
+ chalk.green(`+${session.totalLinesAdded}`) + ' ' + chalk.red(`-${session.totalLinesRemoved}`),
242
+ ]);
243
+ }
244
+ console.log(table.toString());
245
+ console.log();
246
+ }
247
+ }
248
+ /**
249
+ * JSON reporter for structured output
250
+ */
251
+ export class JsonReporter {
252
+ /**
253
+ * Generate JSON report
254
+ */
255
+ generate(stats) {
256
+ const output = {
257
+ repo_path: stats.repoPath,
258
+ scan_time: stats.scanTime.toISOString(),
259
+ overview: {
260
+ total_files: stats.totalFiles,
261
+ total_lines: stats.totalLines,
262
+ ai_touched_files: stats.aiTouchedFiles,
263
+ ai_contributed_lines: stats.aiContributedLines,
264
+ ai_file_ratio: stats.totalFiles > 0 ? stats.aiTouchedFiles / stats.totalFiles : 0,
265
+ ai_line_ratio: stats.totalLines > 0 ? stats.aiContributedLines / stats.totalLines : 0,
266
+ total_sessions: stats.sessions.length,
267
+ },
268
+ by_tool: Object.fromEntries(Array.from(stats.byTool.entries()).map(([tool, toolStats]) => [
269
+ tool,
270
+ {
271
+ sessions_count: toolStats.sessionsCount,
272
+ files_created: toolStats.filesCreated,
273
+ files_modified: toolStats.filesModified,
274
+ total_files: toolStats.totalFiles,
275
+ lines_added: toolStats.linesAdded,
276
+ lines_removed: toolStats.linesRemoved,
277
+ net_lines: toolStats.netLines,
278
+ },
279
+ ])),
280
+ by_file: Object.fromEntries(Array.from(stats.byFile.entries())
281
+ .filter(([, s]) => s.aiContributedLines > 0)
282
+ .map(([filePath, fileStats]) => [
283
+ filePath,
284
+ {
285
+ total_lines: fileStats.totalLines,
286
+ ai_contributed_lines: fileStats.aiContributedLines,
287
+ ai_contribution_ratio: fileStats.aiContributionRatio,
288
+ contributions: Object.fromEntries(fileStats.contributions),
289
+ },
290
+ ])),
291
+ };
292
+ return JSON.stringify(output, null, 2);
293
+ }
294
+ /**
295
+ * Save JSON report to file
296
+ */
297
+ save(stats, outputPath) {
298
+ const json = this.generate(stats);
299
+ fs.writeFileSync(outputPath, json, 'utf-8');
300
+ }
301
+ }
302
+ /**
303
+ * Markdown reporter for documentation
304
+ */
305
+ export class MarkdownReporter {
306
+ /**
307
+ * Generate Markdown report
308
+ */
309
+ generate(stats) {
310
+ const lines = [];
311
+ lines.push('# AI Contribution Report');
312
+ lines.push('');
313
+ lines.push(`**Repository:** \`${stats.repoPath}\``);
314
+ lines.push(`**Generated:** ${stats.scanTime.toLocaleString()}`);
315
+ lines.push('');
316
+ // Overview
317
+ lines.push('## Overview');
318
+ lines.push('');
319
+ lines.push('| Metric | Total | AI Contribution |');
320
+ lines.push('|--------|-------|-----------------|');
321
+ const fileRatio = stats.totalFiles > 0
322
+ ? ((stats.aiTouchedFiles / stats.totalFiles) * 100).toFixed(1)
323
+ : '0.0';
324
+ const lineRatio = stats.totalLines > 0
325
+ ? ((stats.aiContributedLines / stats.totalLines) * 100).toFixed(1)
326
+ : '0.0';
327
+ lines.push(`| Files | ${stats.totalFiles} | ${stats.aiTouchedFiles} (${fileRatio}%) |`);
328
+ lines.push(`| Lines | ${stats.totalLines} | ${stats.aiContributedLines} (${lineRatio}%) |`);
329
+ lines.push(`| Sessions | ${stats.sessions.length} | - |`);
330
+ lines.push('');
331
+ // By Tool
332
+ if (stats.byTool.size > 0) {
333
+ lines.push('## Contribution by AI Tool');
334
+ lines.push('');
335
+ lines.push('| Tool | Sessions | Files | Lines Added | Lines Removed | Share |');
336
+ lines.push('|------|----------|-------|-------------|---------------|-------|');
337
+ const totalLines = Array.from(stats.byTool.values())
338
+ .reduce((sum, t) => sum + t.linesAdded, 0);
339
+ for (const [tool, toolStats] of stats.byTool) {
340
+ const share = totalLines > 0
341
+ ? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
342
+ : '0.0';
343
+ lines.push(`| ${TOOL_NAMES[tool]} | ${toolStats.sessionsCount} | ${toolStats.totalFiles} | +${toolStats.linesAdded} | -${toolStats.linesRemoved} | ${share}% |`);
344
+ }
345
+ lines.push('');
346
+ }
347
+ // Top Files
348
+ const topFiles = Array.from(stats.byFile.entries())
349
+ .filter(([, s]) => s.aiContributedLines > 0)
350
+ .sort((a, b) => b[1].aiContributionRatio - a[1].aiContributionRatio)
351
+ .slice(0, 10);
352
+ if (topFiles.length > 0) {
353
+ lines.push('## Top AI-Contributed Files');
354
+ lines.push('');
355
+ lines.push('| File | Total Lines | AI Lines | AI Ratio |');
356
+ lines.push('|------|-------------|----------|----------|');
357
+ for (const [filePath, fileStats] of topFiles) {
358
+ const ratio = (fileStats.aiContributionRatio * 100).toFixed(1) + '%';
359
+ lines.push(`| \`${filePath}\` | ${fileStats.totalLines} | ${fileStats.aiContributedLines} | ${ratio} |`);
360
+ }
361
+ lines.push('');
362
+ }
363
+ return lines.join('\n');
364
+ }
365
+ /**
366
+ * Save Markdown report to file
367
+ */
368
+ save(stats, outputPath) {
369
+ const markdown = this.generate(stats);
370
+ fs.writeFileSync(outputPath, markdown, 'utf-8');
371
+ }
372
+ }
@@ -0,0 +1,35 @@
1
+ import { AISession, AITool } from '../types.js';
2
+ import { BaseScanner } from './base.js';
3
+ /**
4
+ * Scanner for Aider sessions
5
+ *
6
+ * Aider stores chat history in:
7
+ * <project>/.aider.chat.history.md
8
+ * <project>/.aider.input.history
9
+ * <project>/.aider/
10
+ *
11
+ * The file contains markdown-formatted conversation with
12
+ * code blocks that show file changes.
13
+ */
14
+ export declare class AiderScanner extends BaseScanner {
15
+ get tool(): AITool;
16
+ get storagePath(): string;
17
+ /**
18
+ * For Aider, storage is project-local
19
+ */
20
+ protected resolveStoragePath(): string;
21
+ scan(projectPath: string): AISession[];
22
+ /**
23
+ * Check if Aider history exists in the project
24
+ */
25
+ isAvailable(): boolean;
26
+ /**
27
+ * Check if Aider history exists for a specific project
28
+ */
29
+ isAvailableForProject(projectPath: string): boolean;
30
+ parseSessionFile(filePath: string, projectPath: string): AISession | null;
31
+ /**
32
+ * Deduplicate changes, keeping the latest for each file
33
+ */
34
+ private deduplicateChanges;
35
+ }
@@ -0,0 +1,194 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { glob } from 'glob';
4
+ import { AITool } from '../types.js';
5
+ import { BaseScanner } from './base.js';
6
+ /**
7
+ * Scanner for Aider sessions
8
+ *
9
+ * Aider stores chat history in:
10
+ * <project>/.aider.chat.history.md
11
+ * <project>/.aider.input.history
12
+ * <project>/.aider/
13
+ *
14
+ * The file contains markdown-formatted conversation with
15
+ * code blocks that show file changes.
16
+ */
17
+ export class AiderScanner extends BaseScanner {
18
+ get tool() {
19
+ return AITool.AIDER;
20
+ }
21
+ get storagePath() {
22
+ return '.aider.chat.history.md';
23
+ }
24
+ /**
25
+ * For Aider, storage is project-local
26
+ */
27
+ resolveStoragePath() {
28
+ return this.storagePath;
29
+ }
30
+ scan(projectPath) {
31
+ const sessions = [];
32
+ // Check for main history file
33
+ const historyFile = path.join(projectPath, '.aider.chat.history.md');
34
+ if (fs.existsSync(historyFile)) {
35
+ const session = this.parseSessionFile(historyFile, projectPath);
36
+ if (session && session.changes.length > 0) {
37
+ sessions.push(session);
38
+ }
39
+ }
40
+ // Also check .aider directory for additional history
41
+ const aiderDir = path.join(projectPath, '.aider');
42
+ if (fs.existsSync(aiderDir)) {
43
+ try {
44
+ const files = glob.sync('**/*.md', { cwd: aiderDir });
45
+ for (const file of files) {
46
+ if (file.includes('history') || file.includes('chat')) {
47
+ const session = this.parseSessionFile(path.join(aiderDir, file), projectPath);
48
+ if (session && session.changes.length > 0) {
49
+ sessions.push(session);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // Ignore errors
56
+ }
57
+ }
58
+ return sessions;
59
+ }
60
+ /**
61
+ * Check if Aider history exists in the project
62
+ */
63
+ isAvailable() {
64
+ // For Aider, we can't check globally - it's project-specific
65
+ return true;
66
+ }
67
+ /**
68
+ * Check if Aider history exists for a specific project
69
+ */
70
+ isAvailableForProject(projectPath) {
71
+ const historyFile = path.join(projectPath, this.storagePath);
72
+ const aiderDir = path.join(projectPath, '.aider');
73
+ return fs.existsSync(historyFile) || fs.existsSync(aiderDir);
74
+ }
75
+ parseSessionFile(filePath, projectPath) {
76
+ let content;
77
+ try {
78
+ content = fs.readFileSync(filePath, 'utf-8');
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const changes = [];
84
+ const fileStats = fs.statSync(filePath);
85
+ const sessionTimestamp = fileStats.mtime;
86
+ // Parse the markdown content to find file changes
87
+ // Aider uses various patterns:
88
+ // Pattern 1: ```language path/to/file.py
89
+ const codeBlockRegex = /```(\w+)?\s+([^\n`]+)\n([\s\S]*?)```/g;
90
+ let match;
91
+ while ((match = codeBlockRegex.exec(content)) !== null) {
92
+ const [, language, filePathRaw, code] = match;
93
+ // Skip if it looks like a diff or command output
94
+ if (filePathRaw.startsWith('>') || filePathRaw.startsWith('$') || filePathRaw.startsWith('#')) {
95
+ continue;
96
+ }
97
+ // Clean up the file path
98
+ const cleanPath = filePathRaw.trim();
99
+ // Skip if path contains spaces (likely not a real path) or is too long
100
+ if (!cleanPath || cleanPath.includes(' ') || cleanPath.length > 200) {
101
+ continue;
102
+ }
103
+ // Skip common non-file patterns
104
+ if (cleanPath.match(/^(bash|shell|console|output|diff|patch|error|warning|note|example)/i)) {
105
+ continue;
106
+ }
107
+ const linesAdded = this.countLines(code);
108
+ if (linesAdded > 0) {
109
+ changes.push({
110
+ filePath: this.normalizePath(cleanPath, projectPath),
111
+ linesAdded,
112
+ linesRemoved: 0,
113
+ changeType: 'modify',
114
+ timestamp: sessionTimestamp,
115
+ tool: this.tool,
116
+ content: code,
117
+ });
118
+ }
119
+ }
120
+ // Pattern 2: SEARCH/REPLACE blocks (Aider's edit format)
121
+ // Look for file context before SEARCH/REPLACE
122
+ const fileEditRegex = /(?:^|\n)([^\n]+\.[a-zA-Z]+)\n```[^\n]*\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
123
+ while ((match = fileEditRegex.exec(content)) !== null) {
124
+ const [, filePathRaw, searchContent, replaceContent] = match;
125
+ const cleanPath = filePathRaw.trim();
126
+ if (cleanPath && !cleanPath.includes(' ')) {
127
+ const linesRemoved = this.countLines(searchContent);
128
+ const linesAdded = this.countLines(replaceContent);
129
+ if (linesAdded > 0 || linesRemoved > 0) {
130
+ changes.push({
131
+ filePath: this.normalizePath(cleanPath, projectPath),
132
+ linesAdded,
133
+ linesRemoved,
134
+ changeType: 'modify',
135
+ timestamp: sessionTimestamp,
136
+ tool: this.tool,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ // Pattern 3: Standalone SEARCH/REPLACE without file context
142
+ const standaloneEditRegex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
143
+ while ((match = standaloneEditRegex.exec(content)) !== null) {
144
+ const [fullMatch, searchContent, replaceContent] = match;
145
+ // Skip if already captured by fileEditRegex
146
+ if (content.indexOf(fullMatch) !== match.index)
147
+ continue;
148
+ const linesRemoved = this.countLines(searchContent);
149
+ const linesAdded = this.countLines(replaceContent);
150
+ if (linesAdded > 0 || linesRemoved > 0) {
151
+ changes.push({
152
+ filePath: 'unknown',
153
+ linesAdded,
154
+ linesRemoved,
155
+ changeType: 'modify',
156
+ timestamp: sessionTimestamp,
157
+ tool: this.tool,
158
+ });
159
+ }
160
+ }
161
+ // Deduplicate changes by file path
162
+ const uniqueChanges = this.deduplicateChanges(changes);
163
+ if (uniqueChanges.length === 0)
164
+ return null;
165
+ return {
166
+ id: this.generateSessionId(filePath),
167
+ tool: this.tool,
168
+ timestamp: sessionTimestamp,
169
+ projectPath,
170
+ changes: uniqueChanges,
171
+ totalFilesChanged: new Set(uniqueChanges.map(c => c.filePath)).size,
172
+ totalLinesAdded: uniqueChanges.reduce((sum, c) => sum + c.linesAdded, 0),
173
+ totalLinesRemoved: uniqueChanges.reduce((sum, c) => sum + c.linesRemoved, 0),
174
+ };
175
+ }
176
+ /**
177
+ * Deduplicate changes, keeping the latest for each file
178
+ */
179
+ deduplicateChanges(changes) {
180
+ const byFile = new Map();
181
+ for (const change of changes) {
182
+ const existing = byFile.get(change.filePath);
183
+ if (!existing) {
184
+ byFile.set(change.filePath, { ...change });
185
+ }
186
+ else {
187
+ // Accumulate lines
188
+ existing.linesAdded += change.linesAdded;
189
+ existing.linesRemoved += change.linesRemoved;
190
+ }
191
+ }
192
+ return Array.from(byFile.values()).filter(c => c.filePath !== 'unknown');
193
+ }
194
+ }
@@ -0,0 +1,56 @@
1
+ import { AISession, AITool } from '../types.js';
2
+ /**
3
+ * Base class for AI tool scanners
4
+ */
5
+ export declare abstract class BaseScanner {
6
+ protected homeDir: string;
7
+ constructor();
8
+ /**
9
+ * The AI tool this scanner handles
10
+ */
11
+ abstract get tool(): AITool;
12
+ /**
13
+ * The storage path for this tool's session data
14
+ */
15
+ abstract get storagePath(): string;
16
+ /**
17
+ * Check if this tool has data available
18
+ */
19
+ isAvailable(): boolean;
20
+ /**
21
+ * Resolve the full storage path
22
+ */
23
+ protected resolveStoragePath(): string;
24
+ /**
25
+ * Scan for sessions related to a specific project
26
+ */
27
+ abstract scan(projectPath: string): AISession[];
28
+ /**
29
+ * Parse a session file and extract file changes
30
+ */
31
+ abstract parseSessionFile(filePath: string, projectPath: string): AISession | null;
32
+ /**
33
+ * Count lines in a string
34
+ */
35
+ protected countLines(content: string | undefined): number;
36
+ /**
37
+ * Normalize file path relative to project
38
+ */
39
+ protected normalizePath(filePath: string, projectPath: string): string;
40
+ /**
41
+ * Check if a file path belongs to the project
42
+ */
43
+ protected isProjectFile(filePath: string, projectPath: string): boolean;
44
+ /**
45
+ * Read and parse JSON file safely
46
+ */
47
+ protected readJsonFile(filePath: string): any;
48
+ /**
49
+ * Read and parse JSONL file safely
50
+ */
51
+ protected readJsonlFile(filePath: string): any[];
52
+ /**
53
+ * Generate a unique session ID
54
+ */
55
+ protected generateSessionId(filePath: string): string;
56
+ }