ai-credit 1.0.0 โ 1.0.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.
- package/README.md +6 -7
- package/dist/analyzer.d.ts +19 -3
- package/dist/analyzer.js +118 -114
- package/dist/cli.js +3 -6
- package/dist/reporter.js +24 -18
- package/dist/scanners/aider.js +11 -0
- package/dist/scanners/base.d.ts +16 -0
- package/dist/scanners/base.js +77 -0
- package/dist/scanners/claude.js +10 -0
- package/dist/scanners/codex.js +31 -2
- package/dist/scanners/gemini.js +10 -0
- package/dist/scanners/index.d.ts +0 -1
- package/dist/scanners/index.js +0 -1
- package/dist/scanners/opencode.js +3 -1
- package/dist/types.d.ts +1 -3
- package/dist/types.js +0 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ai-credit)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
A command-line tool to track and analyze AI coding assistants' contributions in your codebase. Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **
|
|
6
|
+
A command-line tool to track and analyze AI coding assistants' contributions in your codebase. Supports **Claude Code**, **Codex CLI**, **Gemini CLI**, and **Opencode**.
|
|
7
7
|
|
|
8
8
|
## Quick Start
|
|
9
9
|
|
|
@@ -20,7 +20,7 @@ ai-credit
|
|
|
20
20
|
|
|
21
21
|
- ๐ **Auto-detection**: Automatically finds AI tool session data on your system
|
|
22
22
|
- ๐ **Detailed Statistics**: Lines of code, files modified, contribution ratios
|
|
23
|
-
- ๐ค **Multi-tool Support**: Claude Code, Codex CLI, Gemini CLI,
|
|
23
|
+
- ๐ค **Multi-tool Support**: Claude Code, Codex CLI, Gemini CLI, Opencode
|
|
24
24
|
- ๐ **Visual Reports**: Console, JSON, and Markdown output formats
|
|
25
25
|
- ๐
**Timeline View**: Track AI contributions over time
|
|
26
26
|
- ๐ **File-level Analysis**: See which files have the most AI contributions
|
|
@@ -54,7 +54,7 @@ npx ai-credit [path]
|
|
|
54
54
|
# Options:
|
|
55
55
|
# -f, --format Output format (console/json/markdown)
|
|
56
56
|
# -o, --output Output file path
|
|
57
|
-
# -t, --tools AI tools to analyze (claude,codex,gemini,
|
|
57
|
+
# -t, --tools AI tools to analyze (claude,codex,gemini,opencode,all)
|
|
58
58
|
# -v, --verbose Show detailed output
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -72,7 +72,6 @@ Shows which AI tools have data available on your system:
|
|
|
72
72
|
Claude Code ~/.claude/projects/ โ Available
|
|
73
73
|
Codex CLI ~/.codex/sessions/ โ Available
|
|
74
74
|
Gemini CLI ~/.gemini/tmp/ โ Not found
|
|
75
|
-
Aider .aider.chat.history.md โ Not found
|
|
76
75
|
```
|
|
77
76
|
|
|
78
77
|
### File-level Analysis
|
|
@@ -123,14 +122,14 @@ Lists all AI sessions for the repository.
|
|
|
123
122
|
โกโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฉ
|
|
124
123
|
โ Claude Code โ 15 โ 30 โ +2500 โ
|
|
125
124
|
โ Codex CLI โ 10 โ 20 โ +1000 โ
|
|
126
|
-
โ
|
|
125
|
+
โ Opencode โ 3 โ 5 โ +250 โ
|
|
127
126
|
โโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโ
|
|
128
127
|
|
|
129
128
|
๐ Contribution Distribution
|
|
130
129
|
|
|
131
130
|
Claude Code โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 66.7%
|
|
132
131
|
Codex CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 26.7%
|
|
133
|
-
|
|
132
|
+
Opencode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 6.7%
|
|
134
133
|
```
|
|
135
134
|
|
|
136
135
|
## Supported AI Tools
|
|
@@ -140,7 +139,7 @@ Lists all AI sessions for the repository.
|
|
|
140
139
|
| Claude Code | `~/.claude/projects/<path>/` | JSONL |
|
|
141
140
|
| Codex CLI | `~/.codex/sessions/YYYY/MM/DD/` | JSONL |
|
|
142
141
|
| Gemini CLI | `~/.gemini/tmp/<hash>/chats/` | JSON |
|
|
143
|
-
|
|
|
142
|
+
| Opencode | `~/.local/share/opencode/` | JSON |
|
|
144
143
|
|
|
145
144
|
## How It Works: The JSON Parsing Logic
|
|
146
145
|
|
package/dist/analyzer.d.ts
CHANGED
|
@@ -23,15 +23,31 @@ export declare class ContributionAnalyzer {
|
|
|
23
23
|
*/
|
|
24
24
|
private getRepoFiles;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* Build a file index for verification and totals
|
|
27
27
|
*/
|
|
28
|
-
private
|
|
28
|
+
private buildRepoFileIndex;
|
|
29
|
+
/**
|
|
30
|
+
* Sum total lines from the repository index
|
|
31
|
+
*/
|
|
32
|
+
private sumRepoLines;
|
|
33
|
+
/**
|
|
34
|
+
* Split content into non-empty lines
|
|
35
|
+
*/
|
|
36
|
+
private splitNonEmptyLines;
|
|
37
|
+
/**
|
|
38
|
+
* Get added lines from a change, falling back to content
|
|
39
|
+
*/
|
|
40
|
+
private getAddedLines;
|
|
41
|
+
/**
|
|
42
|
+
* Count verified added lines that still exist in the repo file
|
|
43
|
+
*/
|
|
44
|
+
private countVerifiedAddedLines;
|
|
29
45
|
/**
|
|
30
46
|
* Compute statistics by AI tool
|
|
31
47
|
*/
|
|
32
48
|
private computeToolStats;
|
|
33
49
|
/**
|
|
34
|
-
* Compute statistics by file
|
|
50
|
+
* Compute statistics by file
|
|
35
51
|
*/
|
|
36
52
|
private computeFileStats;
|
|
37
53
|
}
|
package/dist/analyzer.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
|
-
import {
|
|
5
|
-
import { ClaudeScanner, CodexScanner, GeminiScanner, AiderScanner, OpencodeScanner, } from './scanners/index.js';
|
|
4
|
+
import { ClaudeScanner, CodexScanner, GeminiScanner, OpencodeScanner, } from './scanners/index.js';
|
|
6
5
|
/**
|
|
7
6
|
* Main analyzer that coordinates all scanners and computes statistics
|
|
8
7
|
*/
|
|
@@ -15,7 +14,6 @@ export class ContributionAnalyzer {
|
|
|
15
14
|
new ClaudeScanner(),
|
|
16
15
|
new CodexScanner(),
|
|
17
16
|
new GeminiScanner(),
|
|
18
|
-
new AiderScanner(),
|
|
19
17
|
new OpencodeScanner(),
|
|
20
18
|
];
|
|
21
19
|
}
|
|
@@ -29,13 +27,6 @@ export class ContributionAnalyzer {
|
|
|
29
27
|
available.push(scanner.tool);
|
|
30
28
|
}
|
|
31
29
|
}
|
|
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
30
|
return available;
|
|
40
31
|
}
|
|
41
32
|
/**
|
|
@@ -66,31 +57,20 @@ export class ContributionAnalyzer {
|
|
|
66
57
|
const sessions = this.scanAllSessions(tools);
|
|
67
58
|
// Get repository file stats
|
|
68
59
|
const repoFiles = this.getRepoFiles();
|
|
69
|
-
const
|
|
60
|
+
const repoFileIndex = this.buildRepoFileIndex(repoFiles);
|
|
61
|
+
const totalLines = this.sumRepoLines(repoFileIndex);
|
|
70
62
|
// Compute statistics
|
|
71
|
-
const byTool = this.computeToolStats(sessions);
|
|
72
|
-
const byFile = this.computeFileStats(sessions,
|
|
63
|
+
const byTool = this.computeToolStats(sessions, repoFileIndex);
|
|
64
|
+
const byFile = this.computeFileStats(sessions, repoFileIndex);
|
|
73
65
|
// Count AI-touched files and lines (only count files that exist in repo)
|
|
74
66
|
let aiTouchedFiles = 0;
|
|
75
67
|
let aiContributedLines = 0;
|
|
76
|
-
for (const [, stats] of byFile) {
|
|
77
|
-
|
|
68
|
+
for (const [filePath, stats] of byFile) {
|
|
69
|
+
// Only count files that exist in the repo
|
|
70
|
+
if (stats.aiContributedLines > 0 && repoFiles.includes(filePath)) {
|
|
78
71
|
aiTouchedFiles++;
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}
|
|
72
|
+
// Cap contribution at actual file lines to avoid >100%
|
|
73
|
+
aiContributedLines += Math.min(stats.aiContributedLines, stats.totalLines);
|
|
94
74
|
}
|
|
95
75
|
}
|
|
96
76
|
return {
|
|
@@ -158,25 +138,81 @@ export class ContributionAnalyzer {
|
|
|
158
138
|
}
|
|
159
139
|
}
|
|
160
140
|
/**
|
|
161
|
-
*
|
|
141
|
+
* Build a file index for verification and totals
|
|
162
142
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
143
|
+
buildRepoFileIndex(files) {
|
|
144
|
+
const index = new Map();
|
|
165
145
|
for (const file of files) {
|
|
166
146
|
try {
|
|
167
147
|
const content = fs.readFileSync(path.join(this.projectPath, file), 'utf-8');
|
|
168
|
-
|
|
148
|
+
const totalLines = content.split('\n').length;
|
|
149
|
+
const normalizedLines = content.split(/\r?\n/);
|
|
150
|
+
const nonEmptyLines = normalizedLines.filter(line => line.length > 0).length;
|
|
151
|
+
const lineSet = new Set(normalizedLines.filter(line => line.length > 0));
|
|
152
|
+
index.set(file, { totalLines, nonEmptyLines, lineSet });
|
|
169
153
|
}
|
|
170
154
|
catch {
|
|
171
|
-
|
|
155
|
+
index.set(file, { totalLines: 0, nonEmptyLines: 0, lineSet: new Set() });
|
|
172
156
|
}
|
|
173
157
|
}
|
|
158
|
+
return index;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Sum total lines from the repository index
|
|
162
|
+
*/
|
|
163
|
+
sumRepoLines(repoFileIndex) {
|
|
164
|
+
let total = 0;
|
|
165
|
+
for (const info of repoFileIndex.values()) {
|
|
166
|
+
total += info.totalLines;
|
|
167
|
+
}
|
|
174
168
|
return total;
|
|
175
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Split content into non-empty lines
|
|
172
|
+
*/
|
|
173
|
+
splitNonEmptyLines(content) {
|
|
174
|
+
if (!content)
|
|
175
|
+
return [];
|
|
176
|
+
return content.split(/\r?\n/).filter(line => line.length > 0);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get added lines from a change, falling back to content
|
|
180
|
+
*/
|
|
181
|
+
getAddedLines(change) {
|
|
182
|
+
if (change.addedLines && change.addedLines.length > 0) {
|
|
183
|
+
return change.addedLines;
|
|
184
|
+
}
|
|
185
|
+
if (change.content) {
|
|
186
|
+
return this.splitNonEmptyLines(change.content);
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Count verified added lines that still exist in the repo file
|
|
192
|
+
*/
|
|
193
|
+
countVerifiedAddedLines(change, fileInfo) {
|
|
194
|
+
if (!fileInfo)
|
|
195
|
+
return 0;
|
|
196
|
+
const addedLines = this.getAddedLines(change);
|
|
197
|
+
if (addedLines.length === 0)
|
|
198
|
+
return 0;
|
|
199
|
+
let matched = 0;
|
|
200
|
+
for (const line of addedLines) {
|
|
201
|
+
if (line.length === 0)
|
|
202
|
+
continue;
|
|
203
|
+
if (fileInfo.lineSet.has(line)) {
|
|
204
|
+
matched++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (change.linesAdded > 0) {
|
|
208
|
+
return Math.min(matched, change.linesAdded);
|
|
209
|
+
}
|
|
210
|
+
return Math.min(matched, addedLines.length);
|
|
211
|
+
}
|
|
176
212
|
/**
|
|
177
213
|
* Compute statistics by AI tool
|
|
178
214
|
*/
|
|
179
|
-
computeToolStats(sessions) {
|
|
215
|
+
computeToolStats(sessions, repoFileIndex) {
|
|
180
216
|
const stats = new Map();
|
|
181
217
|
// Track unique files per tool across all sessions
|
|
182
218
|
const filesByTool = new Map();
|
|
@@ -194,7 +230,6 @@ export class ContributionAnalyzer {
|
|
|
194
230
|
linesAdded: 0,
|
|
195
231
|
linesRemoved: 0,
|
|
196
232
|
netLines: 0,
|
|
197
|
-
verifiedLines: 0,
|
|
198
233
|
byModel: new Map(),
|
|
199
234
|
};
|
|
200
235
|
stats.set(session.tool, toolStats);
|
|
@@ -204,7 +239,9 @@ export class ContributionAnalyzer {
|
|
|
204
239
|
const toolFiles = filesByTool.get(session.tool);
|
|
205
240
|
for (const change of session.changes) {
|
|
206
241
|
toolFiles.add(change.filePath);
|
|
207
|
-
|
|
242
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
243
|
+
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
244
|
+
toolStats.linesAdded += verifiedAdded;
|
|
208
245
|
toolStats.linesRemoved += change.linesRemoved;
|
|
209
246
|
if (change.changeType === 'create') {
|
|
210
247
|
toolStats.filesCreated++;
|
|
@@ -218,21 +255,20 @@ export class ContributionAnalyzer {
|
|
|
218
255
|
if (!modelStats) {
|
|
219
256
|
modelStats = {
|
|
220
257
|
model: modelName,
|
|
221
|
-
sessionsCount: 0,
|
|
258
|
+
sessionsCount: 0, // Will be counted below (approximated by session presence)
|
|
222
259
|
filesCreated: 0,
|
|
223
260
|
filesModified: 0,
|
|
224
261
|
totalFiles: 0,
|
|
225
262
|
linesAdded: 0,
|
|
226
263
|
linesRemoved: 0,
|
|
227
264
|
netLines: 0,
|
|
228
|
-
verifiedLines: 0,
|
|
229
265
|
};
|
|
230
266
|
toolStats.byModel.set(modelName, modelStats);
|
|
231
267
|
filesByModel.set(`${session.tool}:${modelName}`, new Set());
|
|
232
268
|
}
|
|
233
269
|
const modelFiles = filesByModel.get(`${session.tool}:${modelName}`);
|
|
234
270
|
modelFiles.add(change.filePath);
|
|
235
|
-
modelStats.linesAdded +=
|
|
271
|
+
modelStats.linesAdded += verifiedAdded;
|
|
236
272
|
modelStats.linesRemoved += change.linesRemoved;
|
|
237
273
|
if (change.changeType === 'create') {
|
|
238
274
|
modelStats.filesCreated++;
|
|
@@ -241,7 +277,10 @@ export class ContributionAnalyzer {
|
|
|
241
277
|
modelStats.filesModified++;
|
|
242
278
|
}
|
|
243
279
|
}
|
|
244
|
-
// Count sessions per model
|
|
280
|
+
// Count sessions per model (if any change in session is attributed to model)
|
|
281
|
+
// This is a bit tricky if a session has mixed models, but usually it's one per session
|
|
282
|
+
const sessionModel = session.model || 'unknown';
|
|
283
|
+
// Use a set to track which models appeared in this session to avoid double counting if mixed
|
|
245
284
|
const modelsInSession = new Set();
|
|
246
285
|
if (session.model)
|
|
247
286
|
modelsInSession.add(session.model);
|
|
@@ -270,87 +309,52 @@ export class ContributionAnalyzer {
|
|
|
270
309
|
return stats;
|
|
271
310
|
}
|
|
272
311
|
/**
|
|
273
|
-
* Compute statistics by file
|
|
312
|
+
* Compute statistics by file
|
|
274
313
|
*/
|
|
275
|
-
computeFileStats(sessions,
|
|
314
|
+
computeFileStats(sessions, repoFileIndex) {
|
|
276
315
|
const stats = new Map();
|
|
277
|
-
//
|
|
278
|
-
const
|
|
316
|
+
// Initialize stats for all repo files
|
|
317
|
+
for (const [file, info] of repoFileIndex) {
|
|
318
|
+
stats.set(file, {
|
|
319
|
+
filePath: file,
|
|
320
|
+
totalLines: info.nonEmptyLines,
|
|
321
|
+
aiContributedLines: 0,
|
|
322
|
+
aiContributionRatio: 0,
|
|
323
|
+
contributions: new Map(),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// Accumulate AI contributions
|
|
279
327
|
for (const session of sessions) {
|
|
280
328
|
for (const change of session.changes) {
|
|
281
|
-
|
|
282
|
-
|
|
329
|
+
let fileStats = stats.get(change.filePath);
|
|
330
|
+
if (!fileStats) {
|
|
331
|
+
// File might have been deleted or renamed - still track it
|
|
332
|
+
fileStats = {
|
|
333
|
+
filePath: change.filePath,
|
|
334
|
+
totalLines: 0,
|
|
335
|
+
aiContributedLines: 0,
|
|
336
|
+
aiContributionRatio: 0,
|
|
337
|
+
contributions: new Map(),
|
|
338
|
+
};
|
|
339
|
+
stats.set(change.filePath, fileStats);
|
|
283
340
|
}
|
|
284
|
-
|
|
341
|
+
const fileInfo = repoFileIndex.get(change.filePath);
|
|
342
|
+
const verifiedAdded = this.countVerifiedAddedLines(change, fileInfo);
|
|
343
|
+
fileStats.aiContributedLines += verifiedAdded;
|
|
344
|
+
const currentToolContrib = fileStats.contributions.get(session.tool) || 0;
|
|
345
|
+
fileStats.contributions.set(session.tool, currentToolContrib + verifiedAdded);
|
|
285
346
|
}
|
|
286
347
|
}
|
|
287
|
-
//
|
|
288
|
-
for (const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
content = fs.readFileSync(fullPath, 'utf-8');
|
|
348
|
+
// Calculate ratios - cap at 100%
|
|
349
|
+
for (const [, fileStats] of stats) {
|
|
350
|
+
if (fileStats.totalLines > 0) {
|
|
351
|
+
// Cap the ratio at 1.0 (100%)
|
|
352
|
+
fileStats.aiContributionRatio = Math.min(fileStats.aiContributedLines / fileStats.totalLines, 1.0);
|
|
293
353
|
}
|
|
294
|
-
|
|
295
|
-
|
|
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);
|
|
354
|
+
else if (fileStats.aiContributedLines > 0) {
|
|
355
|
+
// File was deleted but had AI contributions (cannot verify)
|
|
356
|
+
fileStats.aiContributionRatio = 0;
|
|
310
357
|
}
|
|
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
358
|
}
|
|
355
359
|
return stats;
|
|
356
360
|
}
|
package/dist/cli.js
CHANGED
|
@@ -135,14 +135,12 @@ const TOOL_MAP = {
|
|
|
135
135
|
claude: AITool.CLAUDE_CODE,
|
|
136
136
|
codex: AITool.CODEX,
|
|
137
137
|
gemini: AITool.GEMINI,
|
|
138
|
-
aider: AITool.AIDER,
|
|
139
138
|
opencode: AITool.OPENCODE,
|
|
140
139
|
};
|
|
141
140
|
const TOOL_INFO = {
|
|
142
141
|
[AITool.CLAUDE_CODE]: { name: 'Claude Code', path: '~/.claude/projects/' },
|
|
143
142
|
[AITool.CODEX]: { name: 'Codex CLI', path: '~/.codex/sessions/' },
|
|
144
143
|
[AITool.GEMINI]: { name: 'Gemini CLI', path: '~/.gemini/tmp/' },
|
|
145
|
-
[AITool.AIDER]: { name: 'Aider', path: '.aider.chat.history.md' },
|
|
146
144
|
[AITool.OPENCODE]: { name: 'Opencode', path: '~/.local/share/opencode/' },
|
|
147
145
|
};
|
|
148
146
|
/**
|
|
@@ -163,8 +161,8 @@ function parseTools(toolStr) {
|
|
|
163
161
|
}
|
|
164
162
|
const program = new Command();
|
|
165
163
|
program
|
|
166
|
-
.name('ai-
|
|
167
|
-
.description('
|
|
164
|
+
.name('ai-contrib')
|
|
165
|
+
.description('CLI tool to track and analyze AI coding assistants\' contributions in your codebase')
|
|
168
166
|
.version('1.0.0');
|
|
169
167
|
// Main scan command
|
|
170
168
|
program
|
|
@@ -172,7 +170,7 @@ program
|
|
|
172
170
|
.description('Scan repository for AI contributions')
|
|
173
171
|
.option('-f, --format <format>', 'Output format (console, json, markdown)', 'console')
|
|
174
172
|
.option('-o, --output <file>', 'Output file path (for json/markdown formats)')
|
|
175
|
-
.option('-t, --tools <tools>', 'AI tools to analyze (claude,codex,gemini,
|
|
173
|
+
.option('-t, --tools <tools>', 'AI tools to analyze (claude,codex,gemini,opencode or all)', 'all')
|
|
176
174
|
.option('-v, --verbose', 'Show detailed output including files and timeline')
|
|
177
175
|
.action(async (repoPath = '.', options) => {
|
|
178
176
|
const resolvedPath = path.resolve(repoPath);
|
|
@@ -301,7 +299,6 @@ program
|
|
|
301
299
|
[AITool.CLAUDE_CODE]: chalk.hex('#D97757'),
|
|
302
300
|
[AITool.CODEX]: chalk.hex('#00A67E'),
|
|
303
301
|
[AITool.GEMINI]: chalk.hex('#4796E3'),
|
|
304
|
-
[AITool.AIDER]: chalk.hex('#D93B3B'),
|
|
305
302
|
[AITool.OPENCODE]: chalk.yellow,
|
|
306
303
|
};
|
|
307
304
|
// Show last 20 sessions
|
package/dist/reporter.js
CHANGED
|
@@ -9,7 +9,6 @@ const TOOL_NAMES = {
|
|
|
9
9
|
[AITool.CLAUDE_CODE]: 'Claude Code',
|
|
10
10
|
[AITool.CODEX]: 'Codex CLI',
|
|
11
11
|
[AITool.GEMINI]: 'Gemini CLI',
|
|
12
|
-
[AITool.AIDER]: 'Aider',
|
|
13
12
|
[AITool.OPENCODE]: 'Opencode',
|
|
14
13
|
};
|
|
15
14
|
/**
|
|
@@ -19,7 +18,6 @@ const TOOL_COLORS = {
|
|
|
19
18
|
[AITool.CLAUDE_CODE]: chalk.hex('#D97757'),
|
|
20
19
|
[AITool.CODEX]: chalk.hex('#00A67E'),
|
|
21
20
|
[AITool.GEMINI]: chalk.hex('#4796E3'),
|
|
22
|
-
[AITool.AIDER]: chalk.hex('#D93B3B'),
|
|
23
21
|
[AITool.OPENCODE]: chalk.yellow,
|
|
24
22
|
};
|
|
25
23
|
/**
|
|
@@ -43,6 +41,7 @@ export class ConsoleReporter {
|
|
|
43
41
|
const title = 'AI Contribution Analysis';
|
|
44
42
|
const repoLine = `Repository: ${stats.repoPath}`;
|
|
45
43
|
const timeLine = `Scan time: ${stats.scanTime.toLocaleString()}`;
|
|
44
|
+
console.log(chalk.dim('Leave a ๐ star if you like it: https://github.com/debugtheworldbot/ai-credit'));
|
|
46
45
|
console.log();
|
|
47
46
|
console.log(chalk.cyan('โญ' + 'โ'.repeat(boxWidth) + 'โฎ'));
|
|
48
47
|
console.log(chalk.cyan('โ') + ' ' + chalk.bold(title.padEnd(boxWidth - 1)) + chalk.cyan('โ'));
|
|
@@ -55,7 +54,7 @@ export class ConsoleReporter {
|
|
|
55
54
|
* Print overview statistics
|
|
56
55
|
*/
|
|
57
56
|
printOverview(stats) {
|
|
58
|
-
console.log(chalk.bold('
|
|
57
|
+
console.log(chalk.bold('๐ Overview'));
|
|
59
58
|
const table = new Table({
|
|
60
59
|
head: ['Metric', 'Value', 'AI Contribution'].map(h => chalk.bold(h)),
|
|
61
60
|
style: { head: [], border: [] },
|
|
@@ -78,14 +77,16 @@ export class ConsoleReporter {
|
|
|
78
77
|
console.log(chalk.yellow('No AI contributions found.'));
|
|
79
78
|
return;
|
|
80
79
|
}
|
|
81
|
-
console.log(chalk.bold('
|
|
80
|
+
console.log(chalk.bold('๐ค Contribution by AI Tool'));
|
|
82
81
|
const table = new Table({
|
|
83
82
|
head: ['Tool / Model', 'Sessions', 'Files', 'Lines Added', 'Lines Removed', 'Share'].map(h => chalk.bold(h)),
|
|
84
83
|
style: { head: [], border: [] },
|
|
85
84
|
});
|
|
86
85
|
const totalLines = Array.from(stats.byTool.values())
|
|
87
86
|
.reduce((sum, t) => sum + t.linesAdded, 0);
|
|
88
|
-
|
|
87
|
+
const sortedTools = Array.from(stats.byTool.entries())
|
|
88
|
+
.sort((a, b) => b[1].linesAdded - a[1].linesAdded);
|
|
89
|
+
for (const [tool, toolStats] of sortedTools) {
|
|
89
90
|
const share = totalLines > 0
|
|
90
91
|
? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
|
|
91
92
|
: '0.0';
|
|
@@ -134,20 +135,25 @@ export class ConsoleReporter {
|
|
|
134
135
|
console.log(chalk.bold('๐ Contribution Distribution'));
|
|
135
136
|
console.log();
|
|
136
137
|
// Build slices: proportion each AI tool's share of repo lines + Unknown/Human
|
|
137
|
-
|
|
138
|
+
const totalAILinesAdded = Array.from(stats.byTool.values())
|
|
139
|
+
.reduce((sum, t) => sum + t.linesAdded, 0);
|
|
138
140
|
const slices = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
const sortedTools = Array.from(stats.byTool.entries())
|
|
142
|
+
.sort((a, b) => b[1].linesAdded - a[1].linesAdded);
|
|
143
|
+
for (const [tool, toolStats] of sortedTools) {
|
|
144
|
+
const toolRepoLines = totalAILinesAdded > 0
|
|
145
|
+
? Math.round(stats.aiContributedLines * (toolStats.linesAdded / totalAILinesAdded))
|
|
146
|
+
: 0;
|
|
147
|
+
if (toolRepoLines > 0) {
|
|
141
148
|
const color = TOOL_COLORS[tool] || chalk.white;
|
|
142
|
-
slices.push({ label: TOOL_NAMES[tool], value:
|
|
149
|
+
slices.push({ label: TOOL_NAMES[tool], value: toolRepoLines, color: (s) => color(s) });
|
|
143
150
|
}
|
|
144
151
|
}
|
|
145
|
-
const humanLines =
|
|
152
|
+
const humanLines = stats.totalLines - stats.aiContributedLines;
|
|
146
153
|
if (humanLines > 0) {
|
|
147
154
|
slices.push({ label: 'Unknown/Human', value: humanLines, color: (s) => chalk.gray(s) });
|
|
148
155
|
}
|
|
149
156
|
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
157
|
if (total === 0)
|
|
152
158
|
return;
|
|
153
159
|
// Render stacked horizontal bar
|
|
@@ -161,14 +167,10 @@ export class ConsoleReporter {
|
|
|
161
167
|
// Adjust rounding so total width matches barWidth
|
|
162
168
|
const totalWidth = segments.reduce((s, seg) => s + seg.width, 0);
|
|
163
169
|
if (totalWidth !== barWidth && segments.length > 0) {
|
|
164
|
-
// Just subtract/add from the largest or last segment to make it fit
|
|
165
170
|
segments[segments.length - 1].width += barWidth - totalWidth;
|
|
166
171
|
}
|
|
167
172
|
for (const seg of segments) {
|
|
168
|
-
|
|
169
|
-
if (seg.width > 0) {
|
|
170
|
-
bar += seg.color('โ'.repeat(seg.width));
|
|
171
|
-
}
|
|
173
|
+
bar += seg.color('โ'.repeat(seg.width));
|
|
172
174
|
}
|
|
173
175
|
console.log(` ${bar}`);
|
|
174
176
|
console.log();
|
|
@@ -265,7 +267,9 @@ export class JsonReporter {
|
|
|
265
267
|
ai_line_ratio: stats.totalLines > 0 ? stats.aiContributedLines / stats.totalLines : 0,
|
|
266
268
|
total_sessions: stats.sessions.length,
|
|
267
269
|
},
|
|
268
|
-
by_tool: Object.fromEntries(Array.from(stats.byTool.entries())
|
|
270
|
+
by_tool: Object.fromEntries(Array.from(stats.byTool.entries())
|
|
271
|
+
.sort((a, b) => b[1].linesAdded - a[1].linesAdded)
|
|
272
|
+
.map(([tool, toolStats]) => [
|
|
269
273
|
tool,
|
|
270
274
|
{
|
|
271
275
|
sessions_count: toolStats.sessionsCount,
|
|
@@ -336,7 +340,9 @@ export class MarkdownReporter {
|
|
|
336
340
|
lines.push('|------|----------|-------|-------------|---------------|-------|');
|
|
337
341
|
const totalLines = Array.from(stats.byTool.values())
|
|
338
342
|
.reduce((sum, t) => sum + t.linesAdded, 0);
|
|
339
|
-
|
|
343
|
+
const sortedTools = Array.from(stats.byTool.entries())
|
|
344
|
+
.sort((a, b) => b[1].linesAdded - a[1].linesAdded);
|
|
345
|
+
for (const [tool, toolStats] of sortedTools) {
|
|
340
346
|
const share = totalLines > 0
|
|
341
347
|
? ((toolStats.linesAdded / totalLines) * 100).toFixed(1)
|
|
342
348
|
: '0.0';
|
package/dist/scanners/aider.js
CHANGED
|
@@ -114,6 +114,7 @@ export class AiderScanner extends BaseScanner {
|
|
|
114
114
|
timestamp: sessionTimestamp,
|
|
115
115
|
tool: this.tool,
|
|
116
116
|
content: code,
|
|
117
|
+
addedLines: this.extractNonEmptyLines(code),
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
120
|
}
|
|
@@ -134,6 +135,8 @@ export class AiderScanner extends BaseScanner {
|
|
|
134
135
|
changeType: 'modify',
|
|
135
136
|
timestamp: sessionTimestamp,
|
|
136
137
|
tool: this.tool,
|
|
138
|
+
content: replaceContent,
|
|
139
|
+
addedLines: this.extractNonEmptyLines(replaceContent),
|
|
137
140
|
});
|
|
138
141
|
}
|
|
139
142
|
}
|
|
@@ -155,6 +158,8 @@ export class AiderScanner extends BaseScanner {
|
|
|
155
158
|
changeType: 'modify',
|
|
156
159
|
timestamp: sessionTimestamp,
|
|
157
160
|
tool: this.tool,
|
|
161
|
+
content: replaceContent,
|
|
162
|
+
addedLines: this.extractNonEmptyLines(replaceContent),
|
|
158
163
|
});
|
|
159
164
|
}
|
|
160
165
|
}
|
|
@@ -187,6 +192,12 @@ export class AiderScanner extends BaseScanner {
|
|
|
187
192
|
// Accumulate lines
|
|
188
193
|
existing.linesAdded += change.linesAdded;
|
|
189
194
|
existing.linesRemoved += change.linesRemoved;
|
|
195
|
+
if (change.addedLines && change.addedLines.length > 0) {
|
|
196
|
+
if (!existing.addedLines) {
|
|
197
|
+
existing.addedLines = [];
|
|
198
|
+
}
|
|
199
|
+
existing.addedLines.push(...change.addedLines);
|
|
200
|
+
}
|
|
190
201
|
}
|
|
191
202
|
}
|
|
192
203
|
return Array.from(byFile.values()).filter(c => c.filePath !== 'unknown');
|
package/dist/scanners/base.d.ts
CHANGED
|
@@ -33,6 +33,22 @@ export declare abstract class BaseScanner {
|
|
|
33
33
|
* Count lines in a string
|
|
34
34
|
*/
|
|
35
35
|
protected countLines(content: string | undefined): number;
|
|
36
|
+
/**
|
|
37
|
+
* Split content into lines, preserving whitespace
|
|
38
|
+
*/
|
|
39
|
+
protected splitLines(content: string | undefined): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Extract non-empty lines from content
|
|
42
|
+
*/
|
|
43
|
+
protected extractNonEmptyLines(content: string | undefined): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Compute added lines using LCS diff (non-empty lines only)
|
|
46
|
+
*/
|
|
47
|
+
protected diffAddedLines(before: string | undefined, after: string | undefined): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Extract added lines from a unified diff
|
|
50
|
+
*/
|
|
51
|
+
protected extractAddedLinesFromDiff(diff: string | undefined): string[];
|
|
36
52
|
/**
|
|
37
53
|
* Normalize file path relative to project
|
|
38
54
|
*/
|
package/dist/scanners/base.js
CHANGED
|
@@ -30,6 +30,83 @@ export class BaseScanner {
|
|
|
30
30
|
return 0;
|
|
31
31
|
return content.split('\n').filter(line => line.length > 0).length;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Split content into lines, preserving whitespace
|
|
35
|
+
*/
|
|
36
|
+
splitLines(content) {
|
|
37
|
+
if (!content)
|
|
38
|
+
return [];
|
|
39
|
+
return content.split(/\r?\n/);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Extract non-empty lines from content
|
|
43
|
+
*/
|
|
44
|
+
extractNonEmptyLines(content) {
|
|
45
|
+
return this.splitLines(content).filter(line => line.length > 0);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compute added lines using LCS diff (non-empty lines only)
|
|
49
|
+
*/
|
|
50
|
+
diffAddedLines(before, after) {
|
|
51
|
+
const beforeLines = this.extractNonEmptyLines(before);
|
|
52
|
+
const afterLines = this.extractNonEmptyLines(after);
|
|
53
|
+
if (beforeLines.length === 0)
|
|
54
|
+
return afterLines;
|
|
55
|
+
if (afterLines.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
const m = beforeLines.length;
|
|
58
|
+
const n = afterLines.length;
|
|
59
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
60
|
+
for (let i = 1; i <= m; i++) {
|
|
61
|
+
for (let j = 1; j <= n; j++) {
|
|
62
|
+
if (beforeLines[i - 1] === afterLines[j - 1]) {
|
|
63
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const added = [];
|
|
71
|
+
let i = m;
|
|
72
|
+
let j = n;
|
|
73
|
+
while (i > 0 && j > 0) {
|
|
74
|
+
if (beforeLines[i - 1] === afterLines[j - 1]) {
|
|
75
|
+
i--;
|
|
76
|
+
j--;
|
|
77
|
+
}
|
|
78
|
+
else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
79
|
+
i--;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
added.push(afterLines[j - 1]);
|
|
83
|
+
j--;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
while (j > 0) {
|
|
87
|
+
added.push(afterLines[j - 1]);
|
|
88
|
+
j--;
|
|
89
|
+
}
|
|
90
|
+
return added.reverse();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Extract added lines from a unified diff
|
|
94
|
+
*/
|
|
95
|
+
extractAddedLinesFromDiff(diff) {
|
|
96
|
+
if (!diff)
|
|
97
|
+
return [];
|
|
98
|
+
const lines = diff.split(/\r?\n/);
|
|
99
|
+
const added = [];
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
102
|
+
const content = line.slice(1);
|
|
103
|
+
if (content.length > 0) {
|
|
104
|
+
added.push(content);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return added;
|
|
109
|
+
}
|
|
33
110
|
/**
|
|
34
111
|
* Normalize file path relative to project
|
|
35
112
|
*/
|
package/dist/scanners/claude.js
CHANGED
|
@@ -171,20 +171,29 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
171
171
|
let changeType = 'modify';
|
|
172
172
|
let linesAdded = 0;
|
|
173
173
|
let linesRemoved = 0;
|
|
174
|
+
let addedLines = [];
|
|
174
175
|
if (writeOps.includes(toolName)) {
|
|
175
176
|
changeType = oldContent ? 'modify' : 'create';
|
|
176
177
|
linesAdded = this.countLines(newContent);
|
|
177
178
|
linesRemoved = this.countLines(oldContent);
|
|
179
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
178
180
|
}
|
|
179
181
|
else if (editOps.includes(toolName)) {
|
|
180
182
|
changeType = 'modify';
|
|
181
183
|
linesAdded = this.countLines(newContent);
|
|
182
184
|
linesRemoved = this.countLines(oldContent);
|
|
185
|
+
if (oldContent && newContent) {
|
|
186
|
+
addedLines = this.diffAddedLines(oldContent, newContent);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
190
|
+
}
|
|
183
191
|
}
|
|
184
192
|
else {
|
|
185
193
|
// Unknown tool, try to extract what we can
|
|
186
194
|
if (newContent) {
|
|
187
195
|
linesAdded = this.countLines(newContent);
|
|
196
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
if (linesAdded === 0 && linesRemoved === 0)
|
|
@@ -197,6 +206,7 @@ export class ClaudeScanner extends BaseScanner {
|
|
|
197
206
|
timestamp: timestamp ? new Date(timestamp) : new Date(),
|
|
198
207
|
tool: this.tool,
|
|
199
208
|
content: newContent,
|
|
209
|
+
addedLines,
|
|
200
210
|
model,
|
|
201
211
|
};
|
|
202
212
|
}
|
package/dist/scanners/codex.js
CHANGED
|
@@ -53,6 +53,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
53
53
|
const changes = [];
|
|
54
54
|
let sessionTimestamp = null;
|
|
55
55
|
let sessionProjectPath = null;
|
|
56
|
+
let sessionModel;
|
|
56
57
|
for (const entry of entries) {
|
|
57
58
|
const payload = entry.payload || {};
|
|
58
59
|
// Extract timestamp
|
|
@@ -68,6 +69,19 @@ export class CodexScanner extends BaseScanner {
|
|
|
68
69
|
sessionProjectPath = payload.cwd;
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
// Extract model if available (turn_context/session_meta often include it)
|
|
73
|
+
if (!sessionModel) {
|
|
74
|
+
const rawModel = payload.model || payload.model_id || payload.modelId || payload.modelName;
|
|
75
|
+
if (typeof rawModel === 'string' && rawModel) {
|
|
76
|
+
sessionModel = rawModel;
|
|
77
|
+
}
|
|
78
|
+
else if (rawModel && typeof rawModel === 'object') {
|
|
79
|
+
const modelName = rawModel.name || rawModel.id || rawModel.model;
|
|
80
|
+
if (typeof modelName === 'string' && modelName) {
|
|
81
|
+
sessionModel = modelName;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
71
85
|
// Handle custom_tool_call (apply_patch) โ the primary way Codex writes files
|
|
72
86
|
if (entry.type === 'response_item' && payload.type === 'custom_tool_call') {
|
|
73
87
|
const patchChanges = this.parseApplyPatch(payload, projectPath, entry.timestamp);
|
|
@@ -129,6 +143,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
129
143
|
totalFilesChanged: new Set(changes.map(c => c.filePath)).size,
|
|
130
144
|
totalLinesAdded: changes.reduce((sum, c) => sum + c.linesAdded, 0),
|
|
131
145
|
totalLinesRemoved: changes.reduce((sum, c) => sum + c.linesRemoved, 0),
|
|
146
|
+
model: sessionModel,
|
|
132
147
|
};
|
|
133
148
|
}
|
|
134
149
|
/**
|
|
@@ -165,11 +180,12 @@ export class CodexScanner extends BaseScanner {
|
|
|
165
180
|
if (!input)
|
|
166
181
|
return [];
|
|
167
182
|
const changes = [];
|
|
168
|
-
const lines = input.split(
|
|
183
|
+
const lines = input.split(/\r?\n/);
|
|
169
184
|
let currentFile = null;
|
|
170
185
|
let changeType = 'modify';
|
|
171
186
|
let linesAdded = 0;
|
|
172
187
|
let linesRemoved = 0;
|
|
188
|
+
let addedLines = [];
|
|
173
189
|
const flushFile = () => {
|
|
174
190
|
if (currentFile && (linesAdded > 0 || linesRemoved > 0)) {
|
|
175
191
|
const filePath = this.normalizePath(currentFile, projectPath);
|
|
@@ -180,13 +196,15 @@ export class CodexScanner extends BaseScanner {
|
|
|
180
196
|
changeType,
|
|
181
197
|
timestamp: timestamp ? new Date(timestamp) : new Date(),
|
|
182
198
|
tool: this.tool,
|
|
183
|
-
content: '',
|
|
199
|
+
content: addedLines.join('\n'),
|
|
200
|
+
addedLines,
|
|
184
201
|
});
|
|
185
202
|
}
|
|
186
203
|
currentFile = null;
|
|
187
204
|
linesAdded = 0;
|
|
188
205
|
linesRemoved = 0;
|
|
189
206
|
changeType = 'modify';
|
|
207
|
+
addedLines = [];
|
|
190
208
|
};
|
|
191
209
|
for (const line of lines) {
|
|
192
210
|
if (line.startsWith('*** Update File:')) {
|
|
@@ -215,6 +233,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
215
233
|
else if (currentFile) {
|
|
216
234
|
if (line.startsWith('+')) {
|
|
217
235
|
linesAdded++;
|
|
236
|
+
addedLines.push(line.substring(1));
|
|
218
237
|
}
|
|
219
238
|
else if (line.startsWith('-')) {
|
|
220
239
|
linesRemoved++;
|
|
@@ -240,9 +259,11 @@ export class CodexScanner extends BaseScanner {
|
|
|
240
259
|
let changeType = 'modify';
|
|
241
260
|
let linesAdded = 0;
|
|
242
261
|
let linesRemoved = 0;
|
|
262
|
+
let addedLines = [];
|
|
243
263
|
if (writeOps.includes(funcName)) {
|
|
244
264
|
changeType = 'create';
|
|
245
265
|
linesAdded = this.countLines(newContent);
|
|
266
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
246
267
|
}
|
|
247
268
|
else if (editOps.includes(funcName)) {
|
|
248
269
|
changeType = 'modify';
|
|
@@ -252,6 +273,13 @@ export class CodexScanner extends BaseScanner {
|
|
|
252
273
|
const diffStats = this.parseDiff(args.diff);
|
|
253
274
|
linesAdded = diffStats.added;
|
|
254
275
|
linesRemoved = diffStats.removed;
|
|
276
|
+
addedLines = this.extractAddedLinesFromDiff(args.diff);
|
|
277
|
+
}
|
|
278
|
+
else if (oldContent && newContent) {
|
|
279
|
+
addedLines = this.diffAddedLines(oldContent, newContent);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
255
283
|
}
|
|
256
284
|
}
|
|
257
285
|
else {
|
|
@@ -267,6 +295,7 @@ export class CodexScanner extends BaseScanner {
|
|
|
267
295
|
timestamp: timestamp ? new Date(timestamp) : new Date(),
|
|
268
296
|
tool: this.tool,
|
|
269
297
|
content: newContent,
|
|
298
|
+
addedLines,
|
|
270
299
|
};
|
|
271
300
|
}
|
|
272
301
|
/**
|
package/dist/scanners/gemini.js
CHANGED
|
@@ -230,19 +230,28 @@ export class GeminiScanner extends BaseScanner {
|
|
|
230
230
|
// Calculate diff stats
|
|
231
231
|
let linesAdded = 0;
|
|
232
232
|
let linesRemoved = 0;
|
|
233
|
+
let addedLines = [];
|
|
233
234
|
if (writeOps.includes(funcName)) {
|
|
234
235
|
linesAdded = this.countLines(newContent);
|
|
235
236
|
linesRemoved = this.countLines(oldContent); // Usually 0 for write, unless overwriting
|
|
237
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
236
238
|
}
|
|
237
239
|
else if (editOps.includes(funcName)) {
|
|
238
240
|
// Use LCS for edits to be accurate
|
|
239
241
|
const stats = this.calculateDiffStats(oldContent, newContent);
|
|
240
242
|
linesAdded = stats.added;
|
|
241
243
|
linesRemoved = stats.removed;
|
|
244
|
+
if (oldContent && newContent) {
|
|
245
|
+
addedLines = this.diffAddedLines(oldContent, newContent);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
249
|
+
}
|
|
242
250
|
}
|
|
243
251
|
else {
|
|
244
252
|
if (newContent) {
|
|
245
253
|
linesAdded = this.countLines(newContent);
|
|
254
|
+
addedLines = this.extractNonEmptyLines(newContent);
|
|
246
255
|
}
|
|
247
256
|
}
|
|
248
257
|
if (linesAdded === 0 && linesRemoved === 0)
|
|
@@ -255,6 +264,7 @@ export class GeminiScanner extends BaseScanner {
|
|
|
255
264
|
timestamp: timestamp || new Date(),
|
|
256
265
|
tool: this.tool,
|
|
257
266
|
content: newContent,
|
|
267
|
+
addedLines,
|
|
258
268
|
model,
|
|
259
269
|
};
|
|
260
270
|
}
|
package/dist/scanners/index.d.ts
CHANGED
|
@@ -2,5 +2,4 @@ export { BaseScanner } from './base.js';
|
|
|
2
2
|
export { ClaudeScanner } from './claude.js';
|
|
3
3
|
export { CodexScanner } from './codex.js';
|
|
4
4
|
export { GeminiScanner } from './gemini.js';
|
|
5
|
-
export { AiderScanner } from './aider.js';
|
|
6
5
|
export { OpencodeScanner } from './opencode.js';
|
package/dist/scanners/index.js
CHANGED
|
@@ -2,5 +2,4 @@ export { BaseScanner } from './base.js';
|
|
|
2
2
|
export { ClaudeScanner } from './claude.js';
|
|
3
3
|
export { CodexScanner } from './codex.js';
|
|
4
4
|
export { GeminiScanner } from './gemini.js';
|
|
5
|
-
export { AiderScanner } from './aider.js';
|
|
6
5
|
export { OpencodeScanner } from './opencode.js';
|
|
@@ -160,7 +160,8 @@ export class OpencodeScanner extends BaseScanner {
|
|
|
160
160
|
: 'modify';
|
|
161
161
|
// Use opencode's provided diff stats if available (most accurate)
|
|
162
162
|
// additions/deletions are pre-calculated by opencode
|
|
163
|
-
const
|
|
163
|
+
const addedLines = this.diffAddedLines(beforeContent, afterContent);
|
|
164
|
+
const linesAdded = typeof diff.additions === 'number' ? diff.additions : addedLines.length;
|
|
164
165
|
const linesRemoved = typeof diff.deletions === 'number' ? diff.deletions : 0;
|
|
165
166
|
return {
|
|
166
167
|
filePath,
|
|
@@ -171,6 +172,7 @@ export class OpencodeScanner extends BaseScanner {
|
|
|
171
172
|
tool: this.tool,
|
|
172
173
|
model,
|
|
173
174
|
content: afterContent,
|
|
175
|
+
addedLines,
|
|
174
176
|
};
|
|
175
177
|
}
|
|
176
178
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -5,7 +5,6 @@ export declare enum AITool {
|
|
|
5
5
|
CLAUDE_CODE = "claude",
|
|
6
6
|
CODEX = "codex",
|
|
7
7
|
GEMINI = "gemini",
|
|
8
|
-
AIDER = "aider",
|
|
9
8
|
OPENCODE = "opencode"
|
|
10
9
|
}
|
|
11
10
|
/**
|
|
@@ -19,6 +18,7 @@ export interface FileChange {
|
|
|
19
18
|
timestamp: Date;
|
|
20
19
|
tool: AITool;
|
|
21
20
|
content?: string;
|
|
21
|
+
addedLines?: string[];
|
|
22
22
|
model?: string;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
@@ -47,7 +47,6 @@ export interface ModelStats {
|
|
|
47
47
|
linesAdded: number;
|
|
48
48
|
linesRemoved: number;
|
|
49
49
|
netLines: number;
|
|
50
|
-
verifiedLines: number;
|
|
51
50
|
}
|
|
52
51
|
/**
|
|
53
52
|
* Statistics for a single AI tool
|
|
@@ -61,7 +60,6 @@ export interface ToolStats {
|
|
|
61
60
|
linesAdded: number;
|
|
62
61
|
linesRemoved: number;
|
|
63
62
|
netLines: number;
|
|
64
|
-
verifiedLines: number;
|
|
65
63
|
byModel: Map<string, ModelStats>;
|
|
66
64
|
}
|
|
67
65
|
/**
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-credit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "CLI tool to track and analyze AI coding assistants' contributions in your codebase",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"build": "tsc",
|
|
12
12
|
"start": "node dist/cli.js",
|
|
13
13
|
"dev": "tsc && node dist/cli.js",
|
|
14
|
+
"pub": "npm run build && npm publish",
|
|
14
15
|
"prepublishOnly": "npm run build"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|