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.
- package/LICENSE +21 -0
- package/README.md +330 -0
- package/dist/analyzer.d.ts +37 -0
- package/dist/analyzer.js +357 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +383 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/reporter.d.ts +60 -0
- package/dist/reporter.js +372 -0
- package/dist/scanners/aider.d.ts +35 -0
- package/dist/scanners/aider.js +194 -0
- package/dist/scanners/base.d.ts +56 -0
- package/dist/scanners/base.js +88 -0
- package/dist/scanners/claude.d.ts +30 -0
- package/dist/scanners/claude.js +203 -0
- package/dist/scanners/codex.d.ts +54 -0
- package/dist/scanners/codex.js +311 -0
- package/dist/scanners/gemini.d.ts +35 -0
- package/dist/scanners/gemini.js +318 -0
- package/dist/scanners/index.d.ts +6 -0
- package/dist/scanners/index.js +6 -0
- package/dist/scanners/opencode.d.ts +40 -0
- package/dist/scanners/opencode.js +210 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.js +11 -0
- package/package.json +46 -0
package/dist/analyzer.js
ADDED
|
@@ -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