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,357 @@
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 { ClaudeScanner, CodexScanner, GeminiScanner, AiderScanner, OpencodeScanner, } from './scanners/index.js';
6
+ /**
7
+ * Main analyzer that coordinates all scanners and computes statistics
8
+ */
9
+ export class ContributionAnalyzer {
10
+ projectPath;
11
+ scanners;
12
+ constructor(projectPath) {
13
+ this.projectPath = path.resolve(projectPath);
14
+ this.scanners = [
15
+ new ClaudeScanner(),
16
+ new CodexScanner(),
17
+ new GeminiScanner(),
18
+ new AiderScanner(),
19
+ new OpencodeScanner(),
20
+ ];
21
+ }
22
+ /**
23
+ * Get list of available AI tools
24
+ */
25
+ getAvailableTools() {
26
+ const available = [];
27
+ for (const scanner of this.scanners) {
28
+ if (scanner.isAvailable()) {
29
+ available.push(scanner.tool);
30
+ }
31
+ }
32
+ // Special check for Aider (project-local)
33
+ const aiderScanner = this.scanners.find(s => s.tool === AITool.AIDER);
34
+ if (aiderScanner?.isAvailableForProject(this.projectPath)) {
35
+ if (!available.includes(AITool.AIDER)) {
36
+ available.push(AITool.AIDER);
37
+ }
38
+ }
39
+ return available;
40
+ }
41
+ /**
42
+ * Scan all sessions from all tools
43
+ */
44
+ scanAllSessions(tools) {
45
+ const sessions = [];
46
+ for (const scanner of this.scanners) {
47
+ if (tools && !tools.includes(scanner.tool)) {
48
+ continue;
49
+ }
50
+ try {
51
+ const toolSessions = scanner.scan(this.projectPath);
52
+ sessions.push(...toolSessions);
53
+ }
54
+ catch (error) {
55
+ // Silently ignore scanner errors
56
+ }
57
+ }
58
+ // Sort by timestamp
59
+ sessions.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
60
+ return sessions;
61
+ }
62
+ /**
63
+ * Analyze the repository and compute contribution statistics
64
+ */
65
+ analyze(tools) {
66
+ const sessions = this.scanAllSessions(tools);
67
+ // Get repository file stats
68
+ const repoFiles = this.getRepoFiles();
69
+ const totalLines = this.countTotalLines(repoFiles);
70
+ // Compute statistics
71
+ const byTool = this.computeToolStats(sessions);
72
+ const byFile = this.computeFileStats(sessions, repoFiles);
73
+ // Count AI-touched files and lines (only count files that exist in repo)
74
+ let aiTouchedFiles = 0;
75
+ let aiContributedLines = 0;
76
+ for (const [, stats] of byFile) {
77
+ if (stats.aiContributedLines > 0) {
78
+ aiTouchedFiles++;
79
+ aiContributedLines += stats.aiContributedLines;
80
+ // Aggregate verified lines to ToolStats and ModelStats
81
+ for (const [tool, count] of stats.contributions) {
82
+ const toolStats = byTool.get(tool);
83
+ if (toolStats) {
84
+ toolStats.verifiedLines += count;
85
+ // Distribute verified lines to models based on their activity share
86
+ if (toolStats.linesAdded > 0) {
87
+ for (const [, modelStats] of toolStats.byModel) {
88
+ const ratio = modelStats.linesAdded / toolStats.linesAdded;
89
+ modelStats.verifiedLines += Math.round(count * ratio);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return {
97
+ repoPath: this.projectPath,
98
+ scanTime: new Date(),
99
+ totalFiles: repoFiles.length,
100
+ totalLines,
101
+ aiTouchedFiles,
102
+ aiContributedLines,
103
+ sessions,
104
+ byTool,
105
+ byFile,
106
+ };
107
+ }
108
+ /**
109
+ * Get all files in the repository (excluding common ignore patterns)
110
+ */
111
+ getRepoFiles() {
112
+ const ignorePatterns = [
113
+ '**/node_modules/**',
114
+ '**/.git/**',
115
+ '**/dist/**',
116
+ '**/build/**',
117
+ '**/__pycache__/**',
118
+ '**/*.pyc',
119
+ '**/venv/**',
120
+ '**/.venv/**',
121
+ '**/coverage/**',
122
+ '**/.next/**',
123
+ '**/.nuxt/**',
124
+ '**/package-lock.json',
125
+ '**/pnpm-lock.yaml',
126
+ '**/yarn.lock',
127
+ ];
128
+ try {
129
+ const files = glob.sync('**/*', {
130
+ cwd: this.projectPath,
131
+ nodir: true,
132
+ ignore: ignorePatterns,
133
+ });
134
+ // Filter to only include text files
135
+ return files.filter(file => {
136
+ const ext = path.extname(file).toLowerCase();
137
+ const textExtensions = [
138
+ '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
139
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.scala',
140
+ '.c', '.cpp', '.h', '.hpp', '.cs',
141
+ '.html', '.css', '.scss', '.less', '.sass',
142
+ '.json', '.yaml', '.yml', '.toml', '.xml',
143
+ '.md', '.txt', '.rst',
144
+ '.sh', '.bash', '.zsh', '.fish',
145
+ '.sql', '.graphql',
146
+ '.vue', '.svelte',
147
+ '.php', '.swift', '.m',
148
+ '.r', '.R', '.jl',
149
+ '.ex', '.exs', '.erl', '.hrl',
150
+ '.hs', '.elm', '.clj', '.cljs',
151
+ '.dockerfile', '.tf', '.hcl',
152
+ ];
153
+ return textExtensions.includes(ext) || !ext;
154
+ });
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ }
160
+ /**
161
+ * Count total lines in all repository files
162
+ */
163
+ countTotalLines(files) {
164
+ let total = 0;
165
+ for (const file of files) {
166
+ try {
167
+ const content = fs.readFileSync(path.join(this.projectPath, file), 'utf-8');
168
+ total += content.split('\n').length;
169
+ }
170
+ catch {
171
+ // Ignore unreadable files
172
+ }
173
+ }
174
+ return total;
175
+ }
176
+ /**
177
+ * Compute statistics by AI tool
178
+ */
179
+ computeToolStats(sessions) {
180
+ const stats = new Map();
181
+ // Track unique files per tool across all sessions
182
+ const filesByTool = new Map();
183
+ // Track unique files per model across all sessions
184
+ const filesByModel = new Map();
185
+ for (const session of sessions) {
186
+ let toolStats = stats.get(session.tool);
187
+ if (!toolStats) {
188
+ toolStats = {
189
+ tool: session.tool,
190
+ sessionsCount: 0,
191
+ filesCreated: 0,
192
+ filesModified: 0,
193
+ totalFiles: 0,
194
+ linesAdded: 0,
195
+ linesRemoved: 0,
196
+ netLines: 0,
197
+ verifiedLines: 0,
198
+ byModel: new Map(),
199
+ };
200
+ stats.set(session.tool, toolStats);
201
+ filesByTool.set(session.tool, new Set());
202
+ }
203
+ toolStats.sessionsCount++;
204
+ const toolFiles = filesByTool.get(session.tool);
205
+ for (const change of session.changes) {
206
+ toolFiles.add(change.filePath);
207
+ toolStats.linesAdded += change.linesAdded;
208
+ toolStats.linesRemoved += change.linesRemoved;
209
+ if (change.changeType === 'create') {
210
+ toolStats.filesCreated++;
211
+ }
212
+ else {
213
+ toolStats.filesModified++;
214
+ }
215
+ // Aggregate by model
216
+ const modelName = change.model || session.model || 'unknown';
217
+ let modelStats = toolStats.byModel.get(modelName);
218
+ if (!modelStats) {
219
+ modelStats = {
220
+ model: modelName,
221
+ sessionsCount: 0,
222
+ filesCreated: 0,
223
+ filesModified: 0,
224
+ totalFiles: 0,
225
+ linesAdded: 0,
226
+ linesRemoved: 0,
227
+ netLines: 0,
228
+ verifiedLines: 0,
229
+ };
230
+ toolStats.byModel.set(modelName, modelStats);
231
+ filesByModel.set(`${session.tool}:${modelName}`, new Set());
232
+ }
233
+ const modelFiles = filesByModel.get(`${session.tool}:${modelName}`);
234
+ modelFiles.add(change.filePath);
235
+ modelStats.linesAdded += change.linesAdded;
236
+ modelStats.linesRemoved += change.linesRemoved;
237
+ if (change.changeType === 'create') {
238
+ modelStats.filesCreated++;
239
+ }
240
+ else {
241
+ modelStats.filesModified++;
242
+ }
243
+ }
244
+ // Count sessions per model
245
+ const modelsInSession = new Set();
246
+ if (session.model)
247
+ modelsInSession.add(session.model);
248
+ for (const change of session.changes) {
249
+ if (change.model)
250
+ modelsInSession.add(change.model);
251
+ }
252
+ if (modelsInSession.size === 0)
253
+ modelsInSession.add('unknown');
254
+ for (const modelName of modelsInSession) {
255
+ let modelStats = toolStats.byModel.get(modelName);
256
+ if (modelStats) {
257
+ modelStats.sessionsCount++;
258
+ }
259
+ }
260
+ }
261
+ // Update totalFiles with unique count
262
+ for (const [tool, toolStats] of stats) {
263
+ toolStats.totalFiles = filesByTool.get(tool)?.size || 0;
264
+ toolStats.netLines = toolStats.linesAdded - toolStats.linesRemoved;
265
+ for (const [modelName, modelStats] of toolStats.byModel) {
266
+ modelStats.totalFiles = filesByModel.get(`${tool}:${modelName}`)?.size || 0;
267
+ modelStats.netLines = modelStats.linesAdded - modelStats.linesRemoved;
268
+ }
269
+ }
270
+ return stats;
271
+ }
272
+ /**
273
+ * Compute statistics by file using Verified Existence logic
274
+ */
275
+ computeFileStats(sessions, repoFiles) {
276
+ const stats = new Map();
277
+ // 1. Group changes by file
278
+ const fileChanges = new Map();
279
+ for (const session of sessions) {
280
+ for (const change of session.changes) {
281
+ if (!fileChanges.has(change.filePath)) {
282
+ fileChanges.set(change.filePath, []);
283
+ }
284
+ fileChanges.get(change.filePath).push(change);
285
+ }
286
+ }
287
+ // 2. Process each file in the repository
288
+ for (const filePath of repoFiles) {
289
+ const fullPath = path.join(this.projectPath, filePath);
290
+ let content = '';
291
+ try {
292
+ content = fs.readFileSync(fullPath, 'utf-8');
293
+ }
294
+ catch {
295
+ continue;
296
+ }
297
+ const fileLines = content.split('\n');
298
+ const totalLines = fileLines.length;
299
+ // Track which lines in current file are accounted for (claimed)
300
+ // We use exact string matching on trimmed lines.
301
+ const contentMap = new Map();
302
+ for (let i = 0; i < totalLines; i++) {
303
+ const line = fileLines[i].trim();
304
+ if (!line)
305
+ continue; // Skip empty lines for matching
306
+ if (!contentMap.has(line)) {
307
+ contentMap.set(line, []);
308
+ }
309
+ contentMap.get(line).push(i);
310
+ }
311
+ const claimedIndices = new Set();
312
+ const contributions = new Map();
313
+ // Get changes for this file
314
+ const changes = fileChanges.get(filePath) || [];
315
+ // Sort changes: Newest First (Reverse Chronological)
316
+ // This ensures the most recent author gets credit for a line
317
+ changes.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
318
+ let aiContributedCount = 0;
319
+ for (const change of changes) {
320
+ if (!change.content)
321
+ continue;
322
+ const changeLines = change.content.split('\n');
323
+ let matchedForThisChange = 0;
324
+ for (const line of changeLines) {
325
+ const trimmed = line.trim();
326
+ if (!trimmed)
327
+ continue;
328
+ const indices = contentMap.get(trimmed);
329
+ if (indices) {
330
+ // Find the first unclaimed index for this content
331
+ for (const idx of indices) {
332
+ if (!claimedIndices.has(idx)) {
333
+ claimedIndices.add(idx);
334
+ matchedForThisChange++;
335
+ // Line is claimed by this tool
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ if (matchedForThisChange > 0) {
342
+ aiContributedCount += matchedForThisChange;
343
+ const toolTotal = contributions.get(change.tool) || 0;
344
+ contributions.set(change.tool, toolTotal + matchedForThisChange);
345
+ }
346
+ }
347
+ stats.set(filePath, {
348
+ filePath,
349
+ totalLines,
350
+ aiContributedLines: aiContributedCount,
351
+ aiContributionRatio: totalLines > 0 ? aiContributedCount / totalLines : 0,
352
+ contributions,
353
+ });
354
+ }
355
+ return stats;
356
+ }
357
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};