claude-attribution 1.8.0 → 1.9.4
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 +115 -38
- package/package.json +2 -2
- package/src/__tests__/claude-projects.test.ts +42 -0
- package/src/__tests__/copilot-session.test.ts +141 -0
- package/src/__tests__/differ.test.ts +14 -2
- package/src/__tests__/git-notes.test.ts +28 -0
- package/src/__tests__/installed-repos.test.ts +2 -1
- package/src/__tests__/integration-helpers.ts +12 -1
- package/src/__tests__/integration.test.ts +396 -8
- package/src/__tests__/minimap-bulk.test.ts +17 -0
- package/src/__tests__/minimap.test.ts +7 -7
- package/src/__tests__/runtime.test.ts +12 -1
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +77 -39
- package/src/attribution/differ.ts +76 -20
- package/src/attribution/git-notes.ts +35 -5
- package/src/attribution/minimap.ts +18 -5
- package/src/attribution/runtime.ts +8 -7
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +333 -75
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +22 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/installed-repos.ts +4 -4
- package/src/setup/uninstall.ts +7 -2
|
@@ -2,10 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { saveCheckpoint } from "../attribution/checkpoint.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { hashLine, type AttributionResult } from "../attribution/differ.ts";
|
|
6
6
|
import { NOTES_REF, writeNote } from "../attribution/git-notes.ts";
|
|
7
7
|
import { pushNotesRef } from "../attribution/notes-sync.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
hashSetToString,
|
|
10
|
+
writeMinimap,
|
|
11
|
+
type MinimapResult,
|
|
12
|
+
} from "../attribution/minimap.ts";
|
|
9
13
|
import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
|
|
10
14
|
import {
|
|
11
15
|
CLI_BIN,
|
|
@@ -17,6 +21,7 @@ import {
|
|
|
17
21
|
initGitRepo,
|
|
18
22
|
readJson,
|
|
19
23
|
runCommand,
|
|
24
|
+
writeCopilotSession,
|
|
20
25
|
writeJsonl,
|
|
21
26
|
writeTranscript,
|
|
22
27
|
} from "./integration-helpers.ts";
|
|
@@ -93,8 +98,9 @@ describe("integration", () => {
|
|
|
93
98
|
cwd: ctx.repo,
|
|
94
99
|
env: {
|
|
95
100
|
HOME: ctx.home,
|
|
96
|
-
COPILOT_CLI: "
|
|
97
|
-
COPILOT_CLI_BINARY_VERSION: "
|
|
101
|
+
COPILOT_CLI: "",
|
|
102
|
+
COPILOT_CLI_BINARY_VERSION: "",
|
|
103
|
+
ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
98
104
|
},
|
|
99
105
|
});
|
|
100
106
|
|
|
@@ -116,9 +122,9 @@ describe("integration", () => {
|
|
|
116
122
|
expect(note.sessionMetrics?.humanPromptCount).toBe(1);
|
|
117
123
|
expect(note.sessionMetrics?.activeMinutes).toBe(2);
|
|
118
124
|
expect(note.assistantRuntime).toEqual({
|
|
119
|
-
vendor: "
|
|
120
|
-
client: "
|
|
121
|
-
|
|
125
|
+
vendor: "claude",
|
|
126
|
+
client: "Claude Code",
|
|
127
|
+
modelFamily: "claude-sonnet-4-6",
|
|
122
128
|
});
|
|
123
129
|
} finally {
|
|
124
130
|
await ctx.cleanup();
|
|
@@ -207,7 +213,389 @@ describe("integration", () => {
|
|
|
207
213
|
expect(metrics.stdout).toContain("**Skills:** /pr");
|
|
208
214
|
expect(metrics.stdout).toContain("**Agents:** code-review");
|
|
209
215
|
expect(metrics.stdout).toContain("**External tools:** WebSearch ×2");
|
|
210
|
-
expect(metrics.stdout).toContain("**Estimated cost:**");
|
|
216
|
+
expect(metrics.stdout).toContain("**Estimated cost:** unavailable");
|
|
217
|
+
} finally {
|
|
218
|
+
await ctx.cleanup();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("metrics headline uses diff-scoped PR stats instead of full touched file totals", async () => {
|
|
223
|
+
const ctx = await createTempContext("claude-attribution-pr-diff");
|
|
224
|
+
try {
|
|
225
|
+
await initGitRepo(ctx.repo);
|
|
226
|
+
await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
|
|
227
|
+
|
|
228
|
+
const remote = join(ctx.root, "origin.git");
|
|
229
|
+
await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
|
|
230
|
+
await runCommand("git", ["remote", "add", "origin", remote], {
|
|
231
|
+
cwd: ctx.repo,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const baseContent =
|
|
235
|
+
Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
|
|
236
|
+
await writeFile(join(ctx.repo, "README.md"), baseContent);
|
|
237
|
+
await commitAll(ctx.repo, "initial");
|
|
238
|
+
const baseSha = await currentSha(ctx.repo);
|
|
239
|
+
await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
|
|
240
|
+
|
|
241
|
+
const baseMinimap: MinimapResult = {
|
|
242
|
+
commit: baseSha,
|
|
243
|
+
timestamp: "2026-03-01T10:00:00.000Z",
|
|
244
|
+
files: [
|
|
245
|
+
{
|
|
246
|
+
path: "README.md",
|
|
247
|
+
ai_hashes: "",
|
|
248
|
+
ai: 0,
|
|
249
|
+
human: 100,
|
|
250
|
+
total: 100,
|
|
251
|
+
pctAi: 0,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
totals: { ai: 0, human: 100, total: 100, pctAi: 0 },
|
|
255
|
+
};
|
|
256
|
+
await writeMinimap(baseMinimap, ctx.repo, baseSha);
|
|
257
|
+
|
|
258
|
+
await runCommand("git", ["checkout", "-b", "feature/diff-metrics"], {
|
|
259
|
+
cwd: ctx.repo,
|
|
260
|
+
});
|
|
261
|
+
await writeFile(
|
|
262
|
+
join(ctx.repo, "README.md"),
|
|
263
|
+
`${baseContent}AI-added line 1\nAI-added line 2\n`,
|
|
264
|
+
);
|
|
265
|
+
await commitAll(ctx.repo, "feature work");
|
|
266
|
+
const headSha = await currentSha(ctx.repo);
|
|
267
|
+
|
|
268
|
+
const note: AttributionResult = {
|
|
269
|
+
commit: headSha,
|
|
270
|
+
session: "session-pr-diff-1",
|
|
271
|
+
branch: "feature/diff-metrics",
|
|
272
|
+
timestamp: "2026-03-01T10:05:00.000Z",
|
|
273
|
+
files: [
|
|
274
|
+
{
|
|
275
|
+
path: "README.md",
|
|
276
|
+
ai: 2,
|
|
277
|
+
human: 100,
|
|
278
|
+
mixed: 0,
|
|
279
|
+
total: 102,
|
|
280
|
+
pctAi: 2,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
totals: { ai: 2, human: 100, mixed: 0, total: 102, pctAi: 2 },
|
|
284
|
+
};
|
|
285
|
+
await writeNote(note, ctx.repo, headSha);
|
|
286
|
+
|
|
287
|
+
const aiHashes = hashSetToString(
|
|
288
|
+
new Set([hashLine("AI-added line 1"), hashLine("AI-added line 2")]),
|
|
289
|
+
);
|
|
290
|
+
const headMinimap: MinimapResult = {
|
|
291
|
+
commit: headSha,
|
|
292
|
+
timestamp: "2026-03-01T10:05:00.000Z",
|
|
293
|
+
files: [
|
|
294
|
+
{
|
|
295
|
+
path: "README.md",
|
|
296
|
+
ai_hashes: aiHashes,
|
|
297
|
+
ai: 2,
|
|
298
|
+
human: 100,
|
|
299
|
+
total: 102,
|
|
300
|
+
pctAi: 2,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
totals: { ai: 2, human: 100, total: 102, pctAi: 2 },
|
|
304
|
+
};
|
|
305
|
+
await writeMinimap(headMinimap, ctx.repo, headSha);
|
|
306
|
+
|
|
307
|
+
const metrics = await runCommand(CLI_BIN, ["metrics"], {
|
|
308
|
+
cwd: ctx.repo,
|
|
309
|
+
env: { HOME: ctx.home },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(metrics.stdout).toContain(
|
|
313
|
+
"**This PR:** 2 lines changed (2% of codebase) · 100% AI edits · 2 AI-attributed changed lines",
|
|
314
|
+
);
|
|
315
|
+
expect(metrics.stdout).not.toContain("**This PR:** 102 lines changed");
|
|
316
|
+
} finally {
|
|
317
|
+
await ctx.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("post-commit falls back to Copilot session-state metadata", async () => {
|
|
322
|
+
const ctx = await createTempContext("copilot-attribution-commit");
|
|
323
|
+
try {
|
|
324
|
+
await initGitRepo(ctx.repo);
|
|
325
|
+
const filePath = join(ctx.repo, "src.ts");
|
|
326
|
+
await writeFile(filePath, "const value = 1;\n");
|
|
327
|
+
await commitAll(ctx.repo, "initial");
|
|
328
|
+
|
|
329
|
+
await writeFile(filePath, "const value = 1;\nconst nextValue = 2;\n");
|
|
330
|
+
await commitAll(ctx.repo, "feature work");
|
|
331
|
+
|
|
332
|
+
const branch = (
|
|
333
|
+
await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
334
|
+
cwd: ctx.repo,
|
|
335
|
+
})
|
|
336
|
+
).stdout.trim();
|
|
337
|
+
const sessionId = "copilot-session-commit-1";
|
|
338
|
+
await writeCopilotSession(ctx.home, sessionId, [
|
|
339
|
+
{
|
|
340
|
+
type: "session.start",
|
|
341
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
342
|
+
data: {
|
|
343
|
+
context: {
|
|
344
|
+
cwd: ctx.repo,
|
|
345
|
+
gitRoot: ctx.repo,
|
|
346
|
+
branch,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
type: "user.message",
|
|
352
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
353
|
+
data: { content: "start work" },
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
type: "tool.execution_start",
|
|
357
|
+
timestamp: "2026-04-01T10:00:05.000Z",
|
|
358
|
+
data: { toolName: "web_search" },
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
type: "tool.execution_complete",
|
|
362
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
363
|
+
data: { model: "gpt-5.4" },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
type: "assistant.message",
|
|
367
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
368
|
+
data: { outputTokens: 240 },
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
type: "assistant.turn_end",
|
|
372
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
await runCommand(CLI_BIN, ["hook", "post-commit"], {
|
|
377
|
+
cwd: ctx.repo,
|
|
378
|
+
env: {
|
|
379
|
+
HOME: ctx.home,
|
|
380
|
+
COPILOT_CLI: "1",
|
|
381
|
+
COPILOT_CLI_BINARY_VERSION: "1.0.15",
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const note = JSON.parse(
|
|
386
|
+
(
|
|
387
|
+
await runCommand(
|
|
388
|
+
"git",
|
|
389
|
+
["notes", "--ref", "claude-attribution", "show", "HEAD"],
|
|
390
|
+
{ cwd: ctx.repo },
|
|
391
|
+
)
|
|
392
|
+
).stdout,
|
|
393
|
+
) as AttributionResult;
|
|
394
|
+
|
|
395
|
+
expect(note.session).toBe(sessionId);
|
|
396
|
+
expect(note.totals.ai).toBeGreaterThan(0);
|
|
397
|
+
expect(note.modelUsage).toEqual([
|
|
398
|
+
{
|
|
399
|
+
modelFull: "gpt-5.4",
|
|
400
|
+
modelShort: "Unknown",
|
|
401
|
+
calls: 1,
|
|
402
|
+
inputTokens: 0,
|
|
403
|
+
outputTokens: 240,
|
|
404
|
+
cacheCreationTokens: 0,
|
|
405
|
+
cacheReadTokens: 0,
|
|
406
|
+
},
|
|
407
|
+
]);
|
|
408
|
+
expect(note.sessionMetrics?.toolCounts).toEqual({ web_search: 1 });
|
|
409
|
+
expect(note.sessionMetrics?.humanPromptCount).toBe(1);
|
|
410
|
+
expect(note.assistantRuntime).toEqual({
|
|
411
|
+
vendor: "copilot",
|
|
412
|
+
client: "GitHub Copilot CLI",
|
|
413
|
+
clientVersion: "1.0.15",
|
|
414
|
+
modelFamily: "gpt-5.4",
|
|
415
|
+
});
|
|
416
|
+
} finally {
|
|
417
|
+
await ctx.cleanup();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("Copilot sessions win over stale Claude session markers and transcripts", async () => {
|
|
422
|
+
const ctx = await createTempContext("copilot-session-precedence");
|
|
423
|
+
try {
|
|
424
|
+
await initGitRepo(ctx.repo);
|
|
425
|
+
const filePath = join(ctx.repo, "src.ts");
|
|
426
|
+
await writeFile(filePath, "const value = 1;\n");
|
|
427
|
+
await commitAll(ctx.repo, "initial");
|
|
428
|
+
|
|
429
|
+
const staleClaudeSession = "claude-session-stale-1";
|
|
430
|
+
await mkdir(join(ctx.repo, ".claude", "attribution-state"), {
|
|
431
|
+
recursive: true,
|
|
432
|
+
});
|
|
433
|
+
await writeFile(
|
|
434
|
+
join(ctx.repo, ".claude", "attribution-state", "current-session"),
|
|
435
|
+
staleClaudeSession,
|
|
436
|
+
);
|
|
437
|
+
await writeJsonl(join(ctx.repo, ".claude", "logs", "tool-usage.jsonl"), [
|
|
438
|
+
{
|
|
439
|
+
timestamp: "2026-03-30T01:09:44.678Z",
|
|
440
|
+
session: staleClaudeSession,
|
|
441
|
+
tool: "Bash",
|
|
442
|
+
},
|
|
443
|
+
]);
|
|
444
|
+
await writeTranscript(ctx.home, ctx.repo, staleClaudeSession, [
|
|
445
|
+
{
|
|
446
|
+
type: "user",
|
|
447
|
+
timestamp: "2026-03-30T01:00:00.000Z",
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
type: "assistant",
|
|
451
|
+
timestamp: "2026-03-30T01:01:00.000Z",
|
|
452
|
+
message: {
|
|
453
|
+
model: "claude-sonnet-4-6",
|
|
454
|
+
usage: {
|
|
455
|
+
input_tokens: 2000,
|
|
456
|
+
output_tokens: 500,
|
|
457
|
+
cache_creation_input_tokens: 100,
|
|
458
|
+
cache_read_input_tokens: 800,
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
await writeFile(filePath, "const value = 1;\nconst nextValue = 2;\n");
|
|
465
|
+
await commitAll(ctx.repo, "feature work");
|
|
466
|
+
|
|
467
|
+
const branch = (
|
|
468
|
+
await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
469
|
+
cwd: ctx.repo,
|
|
470
|
+
})
|
|
471
|
+
).stdout.trim();
|
|
472
|
+
const copilotSession = "copilot-session-precedence-1";
|
|
473
|
+
await writeCopilotSession(ctx.home, copilotSession, [
|
|
474
|
+
{
|
|
475
|
+
type: "session.start",
|
|
476
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
477
|
+
data: {
|
|
478
|
+
context: {
|
|
479
|
+
cwd: ctx.repo,
|
|
480
|
+
gitRoot: ctx.repo,
|
|
481
|
+
branch,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
type: "user.message",
|
|
487
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
488
|
+
data: { content: "start work" },
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
type: "tool.execution_complete",
|
|
492
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
493
|
+
data: { model: "gpt-5.4" },
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
type: "assistant.message",
|
|
497
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
498
|
+
data: { outputTokens: 240 },
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
type: "assistant.turn_end",
|
|
502
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
|
|
506
|
+
await runCommand(CLI_BIN, ["hook", "post-commit"], {
|
|
507
|
+
cwd: ctx.repo,
|
|
508
|
+
env: {
|
|
509
|
+
HOME: ctx.home,
|
|
510
|
+
COPILOT_CLI: "1",
|
|
511
|
+
COPILOT_CLI_BINARY_VERSION: "1.0.15",
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const note = JSON.parse(
|
|
516
|
+
(
|
|
517
|
+
await runCommand(
|
|
518
|
+
"git",
|
|
519
|
+
["notes", "--ref", "claude-attribution", "show", "HEAD"],
|
|
520
|
+
{ cwd: ctx.repo },
|
|
521
|
+
)
|
|
522
|
+
).stdout,
|
|
523
|
+
) as AttributionResult;
|
|
524
|
+
|
|
525
|
+
expect(note.session).toBe(copilotSession);
|
|
526
|
+
expect(note.assistantRuntime).toEqual({
|
|
527
|
+
vendor: "copilot",
|
|
528
|
+
client: "GitHub Copilot CLI",
|
|
529
|
+
clientVersion: "1.0.15",
|
|
530
|
+
modelFamily: "gpt-5.4",
|
|
531
|
+
});
|
|
532
|
+
expect(note.modelUsage).toEqual([
|
|
533
|
+
{
|
|
534
|
+
modelFull: "gpt-5.4",
|
|
535
|
+
modelShort: "Unknown",
|
|
536
|
+
calls: 1,
|
|
537
|
+
inputTokens: 0,
|
|
538
|
+
outputTokens: 240,
|
|
539
|
+
cacheCreationTokens: 0,
|
|
540
|
+
cacheReadTokens: 0,
|
|
541
|
+
},
|
|
542
|
+
]);
|
|
543
|
+
|
|
544
|
+
const metrics = await runCommand(CLI_BIN, ["metrics"], {
|
|
545
|
+
cwd: ctx.repo,
|
|
546
|
+
env: {
|
|
547
|
+
HOME: ctx.home,
|
|
548
|
+
COPILOT_CLI: "1",
|
|
549
|
+
COPILOT_CLI_BINARY_VERSION: "1.0.15",
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
expect(metrics.stdout).toContain(
|
|
554
|
+
"**Assistant runtime:** GitHub Copilot CLI (v1.0.15 · gpt-5.4)",
|
|
555
|
+
);
|
|
556
|
+
expect(metrics.stdout).toContain("| Model | Calls | Known Tokens |");
|
|
557
|
+
expect(metrics.stdout).toContain("| gpt-5.4 | 1 | 240 |");
|
|
558
|
+
expect(metrics.stdout).toContain("**Estimated cost:** unavailable");
|
|
559
|
+
expect(metrics.stdout).not.toContain("claude-sonnet-4-6");
|
|
560
|
+
expect(metrics.stdout).not.toContain("**Estimated cost:** ~$");
|
|
561
|
+
} finally {
|
|
562
|
+
await ctx.cleanup();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("metrics synthesizes hosted Copilot bot commits without local notes", async () => {
|
|
567
|
+
const ctx = await createTempContext("copilot-hosted-metrics");
|
|
568
|
+
try {
|
|
569
|
+
await initGitRepo(ctx.repo);
|
|
570
|
+
await writeFile(join(ctx.repo, "baseline.ts"), "export const baseline = true;\n");
|
|
571
|
+
await commitAll(ctx.repo, "initial");
|
|
572
|
+
await runCommand("git", ["config", "user.name", "copilot[bot]"], {
|
|
573
|
+
cwd: ctx.repo,
|
|
574
|
+
});
|
|
575
|
+
await runCommand(
|
|
576
|
+
"git",
|
|
577
|
+
["config", "user.email", "000000+copilot[bot]@users.noreply.github.com"],
|
|
578
|
+
{ cwd: ctx.repo },
|
|
579
|
+
);
|
|
580
|
+
await writeFile(
|
|
581
|
+
join(ctx.repo, "agent.ts"),
|
|
582
|
+
"export const generated = true;\n",
|
|
583
|
+
);
|
|
584
|
+
await commitAll(
|
|
585
|
+
ctx.repo,
|
|
586
|
+
"feat: hosted change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const metrics = await runCommand(CLI_BIN, ["metrics"], {
|
|
590
|
+
cwd: ctx.repo,
|
|
591
|
+
env: { HOME: ctx.home },
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
expect(metrics.stdout).toContain("## AI Coding Metrics");
|
|
595
|
+
expect(metrics.stdout).toContain("**AI contribution: ~50%**");
|
|
596
|
+
expect(metrics.stdout).toContain("**Assistant runtime:** GitHub Copilot");
|
|
597
|
+
expect(metrics.stdout).toContain("`agent.ts` — 50% AI (1 lines)");
|
|
598
|
+
expect(metrics.stdout).not.toContain("**Estimated cost:**");
|
|
211
599
|
} finally {
|
|
212
600
|
await ctx.cleanup();
|
|
213
601
|
}
|
|
@@ -47,6 +47,23 @@ await ctx.cleanup();
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("all-AI baseline records blank lines as AI-preserving hashes", async () => {
|
|
51
|
+
const ctx = await createTempContext("claude-attribution-minimap-blank-carry");
|
|
52
|
+
try {
|
|
53
|
+
await initGitRepo(ctx.repo);
|
|
54
|
+
await writeFile(join(ctx.repo, "doc.md"), "alpha\n\nbeta\n");
|
|
55
|
+
await runCommand("git", ["add", "."], { cwd: ctx.repo });
|
|
56
|
+
await runCommand("git", ["commit", "-m", "baseline"], { cwd: ctx.repo });
|
|
57
|
+
|
|
58
|
+
const baseline = await buildAllAiMinimap(ctx.repo);
|
|
59
|
+
const prevEntry = baseline.files.find((file) => file.path === "doc.md");
|
|
60
|
+
expect(prevEntry).toMatchObject({ ai: 3, human: 0, total: 3, pctAi: 100 });
|
|
61
|
+
expect(prevEntry?.ai_hashes.length).toBeGreaterThanOrEqual(16);
|
|
62
|
+
} finally {
|
|
63
|
+
await ctx.cleanup();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
test("marks only recently changed files as AI in ai-since minimaps", async () => {
|
|
51
68
|
const ctx = await createTempContext("claude-attribution-minimap-ai-since");
|
|
52
69
|
try {
|
|
@@ -99,7 +99,7 @@ describe("computeMinimapFile", () => {
|
|
|
99
99
|
expect(hashSetFromString(result.ai_hashes).has(hash)).toBe(false);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
test("blank line
|
|
102
|
+
test("blank line stays AI when the blank-line marker is present", () => {
|
|
103
103
|
const aiHash = hashLine("");
|
|
104
104
|
const result = computeMinimapFile(
|
|
105
105
|
"foo.ts",
|
|
@@ -107,12 +107,12 @@ describe("computeMinimapFile", () => {
|
|
|
107
107
|
new Set([aiHash]),
|
|
108
108
|
new Set([aiHash]),
|
|
109
109
|
);
|
|
110
|
-
expect(result.ai).toBe(
|
|
111
|
-
expect(result.human).toBe(
|
|
112
|
-
expect(result.ai_hashes).toBe(
|
|
110
|
+
expect(result.ai).toBe(1);
|
|
111
|
+
expect(result.human).toBe(0);
|
|
112
|
+
expect(result.ai_hashes).toBe(aiHash);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
test("whitespace-only line treated as blank
|
|
115
|
+
test("whitespace-only line treated as blank and can carry AI attribution", () => {
|
|
116
116
|
const line = " ";
|
|
117
117
|
const aiHash = hashLine(line);
|
|
118
118
|
const result = computeMinimapFile(
|
|
@@ -121,8 +121,8 @@ describe("computeMinimapFile", () => {
|
|
|
121
121
|
new Set([aiHash]),
|
|
122
122
|
new Set([aiHash]),
|
|
123
123
|
);
|
|
124
|
-
expect(result.ai).toBe(
|
|
125
|
-
expect(result.human).toBe(
|
|
124
|
+
expect(result.ai).toBe(1);
|
|
125
|
+
expect(result.human).toBe(0);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
test("MIXED line — not in currentAiHashes → human even if in prevAiHashSet", () => {
|
|
@@ -15,11 +15,22 @@ describe("detectAssistantRuntime", () => {
|
|
|
15
15
|
});
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test("
|
|
18
|
+
test("detects Claude runtime without treating alias defaults as the active model", () => {
|
|
19
19
|
expect(
|
|
20
20
|
detectAssistantRuntime({
|
|
21
21
|
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6",
|
|
22
22
|
}),
|
|
23
|
+
).toEqual({
|
|
24
|
+
vendor: "claude",
|
|
25
|
+
client: "Claude Code",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uses ANTHROPIC_MODEL when the active model is explicit", () => {
|
|
30
|
+
expect(
|
|
31
|
+
detectAssistantRuntime({
|
|
32
|
+
ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
33
|
+
}),
|
|
23
34
|
).toEqual({
|
|
24
35
|
vendor: "claude",
|
|
25
36
|
client: "Claude Code",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTempContext, writeTranscript } from "./integration-helpers.ts";
|
|
3
|
+
import { parseTranscript } from "../metrics/transcript.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseTranscript", () => {
|
|
6
|
+
test("counts real user prompts and ignores tool_result rows", async () => {
|
|
7
|
+
const ctx = await createTempContext("transcript-parser");
|
|
8
|
+
const originalHome = process.env.HOME;
|
|
9
|
+
try {
|
|
10
|
+
process.env.HOME = ctx.home;
|
|
11
|
+
const repo = "/Users/alice.smith/Code/my-repo";
|
|
12
|
+
const sessionId = "session-1";
|
|
13
|
+
await writeTranscript(ctx.home, repo, sessionId, [
|
|
14
|
+
{
|
|
15
|
+
type: "user",
|
|
16
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
17
|
+
message: { role: "user", content: "Add a helper" },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: "assistant",
|
|
21
|
+
timestamp: "2026-04-01T10:00:05.000Z",
|
|
22
|
+
message: {
|
|
23
|
+
role: "assistant",
|
|
24
|
+
model: "claude-sonnet-4-6",
|
|
25
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
26
|
+
content: [{ type: "tool_use", name: "WebSearch", input: {} }],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "user",
|
|
31
|
+
timestamp: "2026-04-01T10:00:07.000Z",
|
|
32
|
+
toolUseResult: { tool: "WebSearch" },
|
|
33
|
+
message: {
|
|
34
|
+
role: "user",
|
|
35
|
+
content: [{ type: "tool_result" }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: "assistant",
|
|
40
|
+
timestamp: "2026-04-01T10:00:12.000Z",
|
|
41
|
+
message: {
|
|
42
|
+
role: "assistant",
|
|
43
|
+
model: "claude-sonnet-4-6",
|
|
44
|
+
usage: { input_tokens: 12, output_tokens: 8 },
|
|
45
|
+
content: [{ type: "text" }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const result = await parseTranscript(sessionId, repo);
|
|
51
|
+
expect(result).not.toBeNull();
|
|
52
|
+
expect(result?.humanPromptCount).toBe(1);
|
|
53
|
+
expect(result?.toolCounts).toEqual({ WebSearch: 1 });
|
|
54
|
+
expect(result?.totals.totalCalls).toBe(2);
|
|
55
|
+
expect(result?.aiMinutes).toBe(0);
|
|
56
|
+
} finally {
|
|
57
|
+
process.env.HOME = originalHome;
|
|
58
|
+
await ctx.cleanup();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|