claude-attribution 1.6.0 → 1.9.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/README.md +96 -40
- package/package.json +2 -2
- package/src/__tests__/bulk-update.test.ts +100 -0
- package/src/__tests__/checkpoint.test.ts +66 -0
- 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 +168 -0
- package/src/__tests__/installed-repos.test.ts +68 -0
- package/src/__tests__/integration-helpers.ts +143 -0
- package/src/__tests__/integration.test.ts +640 -0
- package/src/__tests__/minimap-bulk.test.ts +88 -0
- package/src/__tests__/notes-sync.test.ts +38 -0
- package/src/__tests__/runtime.test.ts +44 -0
- package/src/__tests__/session-metrics-helpers.test.ts +65 -0
- package/src/__tests__/session-metrics.test.ts +68 -0
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +118 -21
- package/src/attribution/differ.ts +98 -20
- package/src/attribution/git-notes.ts +52 -1
- package/src/attribution/notes-sync.ts +113 -0
- package/src/attribution/runtime.ts +35 -0
- package/src/cli.ts +7 -3
- package/src/commands/note-ai-commit.ts +3 -12
- package/src/commands/update.ts +84 -0
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +288 -187
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +179 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/install.ts +18 -30
- package/src/setup/installed-repos.ts +142 -0
- package/src/setup/uninstall.ts +16 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
buildAllAiResult,
|
|
6
|
+
committedContent,
|
|
7
|
+
committedContentAt,
|
|
8
|
+
currentBranch,
|
|
9
|
+
filesInCommit,
|
|
10
|
+
filesInCommitAt,
|
|
11
|
+
getBranchCommitShas,
|
|
12
|
+
getCommitMeta,
|
|
13
|
+
headSha,
|
|
14
|
+
isKnownAiActorCommit,
|
|
15
|
+
listNotes,
|
|
16
|
+
readNote,
|
|
17
|
+
writeNote,
|
|
18
|
+
} from "../attribution/git-notes.ts";
|
|
19
|
+
import {
|
|
20
|
+
commitAll,
|
|
21
|
+
createTempContext,
|
|
22
|
+
currentSha,
|
|
23
|
+
initGitRepo,
|
|
24
|
+
runCommand,
|
|
25
|
+
} from "./integration-helpers.ts";
|
|
26
|
+
|
|
27
|
+
describe("git-notes", () => {
|
|
28
|
+
test("reads repo metadata and writes attribution notes", async () => {
|
|
29
|
+
const ctx = await createTempContext("claude-attribution-git-notes");
|
|
30
|
+
try {
|
|
31
|
+
await initGitRepo(ctx.repo);
|
|
32
|
+
await writeFile(join(ctx.repo, "baseline.txt"), "baseline");
|
|
33
|
+
await commitAll(ctx.repo, "baseline");
|
|
34
|
+
const initialSha = await currentSha(ctx.repo);
|
|
35
|
+
|
|
36
|
+
await writeFile(join(ctx.repo, "ai.txt"), "alpha\n\nbeta");
|
|
37
|
+
await writeFile(
|
|
38
|
+
join(ctx.repo, "binary.bin"),
|
|
39
|
+
Buffer.from([0, 1, 2, 3]),
|
|
40
|
+
);
|
|
41
|
+
await commitAll(ctx.repo, "add AI-attributed files");
|
|
42
|
+
const sha = await currentSha(ctx.repo);
|
|
43
|
+
|
|
44
|
+
expect(await headSha(ctx.repo)).toBe(sha);
|
|
45
|
+
expect(await currentBranch(ctx.repo)).not.toBeNull();
|
|
46
|
+
|
|
47
|
+
const changedFiles = await filesInCommit(ctx.repo);
|
|
48
|
+
expect(changedFiles.includes("ai.txt")).toBe(true);
|
|
49
|
+
expect(changedFiles.includes("binary.bin")).toBe(true);
|
|
50
|
+
|
|
51
|
+
const changedFilesAtSha = await filesInCommitAt(ctx.repo, sha);
|
|
52
|
+
expect(changedFilesAtSha.includes("ai.txt")).toBe(true);
|
|
53
|
+
expect(changedFilesAtSha.includes("binary.bin")).toBe(true);
|
|
54
|
+
|
|
55
|
+
expect(await committedContent(ctx.repo, "ai.txt")).toBe("alpha\n\nbeta");
|
|
56
|
+
expect(await committedContent(ctx.repo, "missing.txt")).toBeNull();
|
|
57
|
+
expect(await committedContentAt(ctx.repo, sha, "ai.txt")).toBe(
|
|
58
|
+
"alpha\n\nbeta",
|
|
59
|
+
);
|
|
60
|
+
expect(await committedContentAt(ctx.repo, sha, "missing.txt")).toBeNull();
|
|
61
|
+
|
|
62
|
+
expect(await readNote(ctx.repo, sha)).toBeNull();
|
|
63
|
+
expect(await listNotes(ctx.repo)).toEqual([]);
|
|
64
|
+
|
|
65
|
+
const result = await buildAllAiResult(ctx.repo, sha);
|
|
66
|
+
expect(result.commit).toBe(sha);
|
|
67
|
+
expect(result.files).toHaveLength(1);
|
|
68
|
+
expect(result.files[0]).toMatchObject({
|
|
69
|
+
path: "ai.txt",
|
|
70
|
+
ai: 2,
|
|
71
|
+
human: 1,
|
|
72
|
+
total: 3,
|
|
73
|
+
pctAi: 67,
|
|
74
|
+
});
|
|
75
|
+
expect(result.totals).toMatchObject({ ai: 2, human: 1, total: 3, pctAi: 67 });
|
|
76
|
+
|
|
77
|
+
const meta = await getCommitMeta(ctx.repo, sha);
|
|
78
|
+
expect(meta.authorName).toBe("Test User");
|
|
79
|
+
expect(meta.authorEmail).toBe("test@example.com");
|
|
80
|
+
expect(meta.message).toBe("add AI-attributed files");
|
|
81
|
+
expect(meta.timestamp).toMatch(/T/);
|
|
82
|
+
|
|
83
|
+
await writeNote(result, ctx.repo, sha);
|
|
84
|
+
const stored = await readNote(ctx.repo, sha);
|
|
85
|
+
expect(stored?.totals).toEqual(result.totals);
|
|
86
|
+
expect(await listNotes(ctx.repo)).toEqual([sha]);
|
|
87
|
+
|
|
88
|
+
const branchShas = await getBranchCommitShas(ctx.repo);
|
|
89
|
+
expect(branchShas.includes(sha)).toBe(true);
|
|
90
|
+
expect(branchShas.includes(initialSha)).toBe(true);
|
|
91
|
+
} finally {
|
|
92
|
+
await ctx.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns null for detached HEAD branches", async () => {
|
|
97
|
+
const ctx = await createTempContext("claude-attribution-detached-head");
|
|
98
|
+
try {
|
|
99
|
+
await initGitRepo(ctx.repo);
|
|
100
|
+
await writeFile(join(ctx.repo, "file.txt"), "content");
|
|
101
|
+
await commitAll(ctx.repo, "initial");
|
|
102
|
+
const sha = await currentSha(ctx.repo);
|
|
103
|
+
|
|
104
|
+
await runCommand("git", ["checkout", sha], { cwd: ctx.repo });
|
|
105
|
+
expect(await currentBranch(ctx.repo)).toBeNull();
|
|
106
|
+
} finally {
|
|
107
|
+
await ctx.cleanup();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("detects known AI actor commit metadata", () => {
|
|
112
|
+
expect(
|
|
113
|
+
isKnownAiActorCommit({
|
|
114
|
+
authorName: "github-actions[bot]",
|
|
115
|
+
authorEmail: "41898282+github-actions[bot]@users.noreply.github.com",
|
|
116
|
+
message: "Automated commit",
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
}),
|
|
119
|
+
).toBe(true);
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
isKnownAiActorCommit({
|
|
123
|
+
authorName: "Test User",
|
|
124
|
+
authorEmail: "test@example.com",
|
|
125
|
+
message:
|
|
126
|
+
"feat: add change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
}),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
isKnownAiActorCommit({
|
|
133
|
+
authorName: "Test User",
|
|
134
|
+
authorEmail: "test@example.com",
|
|
135
|
+
message: "Regular human commit",
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
}),
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("buildAllAiResult tags hosted Copilot commits with assistant runtime", async () => {
|
|
142
|
+
const ctx = await createTempContext("claude-attribution-copilot-bot");
|
|
143
|
+
try {
|
|
144
|
+
await initGitRepo(ctx.repo);
|
|
145
|
+
await runCommand("git", ["config", "user.name", "copilot[bot]"], { cwd: ctx.repo });
|
|
146
|
+
await runCommand(
|
|
147
|
+
"git",
|
|
148
|
+
["config", "user.email", "000000+copilot[bot]@users.noreply.github.com"],
|
|
149
|
+
{ cwd: ctx.repo },
|
|
150
|
+
);
|
|
151
|
+
await writeFile(join(ctx.repo, "agent.ts"), "export const generated = true;\n");
|
|
152
|
+
await commitAll(
|
|
153
|
+
ctx.repo,
|
|
154
|
+
"feat: add hosted change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
155
|
+
);
|
|
156
|
+
const sha = await currentSha(ctx.repo);
|
|
157
|
+
|
|
158
|
+
const result = await buildAllAiResult(ctx.repo, sha);
|
|
159
|
+
|
|
160
|
+
expect(result.assistantRuntime).toEqual({
|
|
161
|
+
vendor: "copilot",
|
|
162
|
+
client: "GitHub Copilot",
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
await ctx.cleanup();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
installedRepoRegistryPath,
|
|
5
|
+
pruneMissingInstalledRepos,
|
|
6
|
+
readInstalledRepoRegistry,
|
|
7
|
+
registerInstalledRepo,
|
|
8
|
+
unregisterInstalledRepo,
|
|
9
|
+
writeInstalledRepoRegistry,
|
|
10
|
+
} from "../setup/installed-repos.ts";
|
|
11
|
+
import { createTempContext, initGitRepo } from "./integration-helpers.ts";
|
|
12
|
+
|
|
13
|
+
describe("installed repo registry", () => {
|
|
14
|
+
test("registers, updates, prunes, and unregisters repos", async () => {
|
|
15
|
+
const ctx = await createTempContext("claude-attribution-installed-repos");
|
|
16
|
+
try {
|
|
17
|
+
const repo1 = ctx.repo;
|
|
18
|
+
const repo2 = `${ctx.root}/second-repo`;
|
|
19
|
+
await mkdir(repo2, { recursive: true });
|
|
20
|
+
await initGitRepo(repo1);
|
|
21
|
+
await initGitRepo(repo2);
|
|
22
|
+
|
|
23
|
+
await registerInstalledRepo(repo1, "1.6.0", ctx.home);
|
|
24
|
+
await registerInstalledRepo(repo2, "1.6.0", ctx.home);
|
|
25
|
+
await registerInstalledRepo(repo1, "1.7.0", ctx.home);
|
|
26
|
+
|
|
27
|
+
const registryPath = installedRepoRegistryPath(ctx.home);
|
|
28
|
+
expect(registryPath).toContain(".claude/claude-attribution/installed-repos.json");
|
|
29
|
+
|
|
30
|
+
const records = await readInstalledRepoRegistry(ctx.home);
|
|
31
|
+
expect(records).toHaveLength(2);
|
|
32
|
+
expect(records.map((record) => record.path)).toEqual([repo1, repo2].sort());
|
|
33
|
+
expect(records.find((record) => record.path === repo1)?.installedVersion).toBe(
|
|
34
|
+
"1.7.0",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(await unregisterInstalledRepo(repo2, ctx.home)).toBe(true);
|
|
38
|
+
expect(await readInstalledRepoRegistry(ctx.home)).toHaveLength(1);
|
|
39
|
+
expect(await unregisterInstalledRepo(repo2, ctx.home)).toBe(false);
|
|
40
|
+
|
|
41
|
+
await writeInstalledRepoRegistry(
|
|
42
|
+
[
|
|
43
|
+
{
|
|
44
|
+
path: repo1,
|
|
45
|
+
firstInstalledAt: new Date(0).toISOString(),
|
|
46
|
+
lastUpdatedAt: new Date().toISOString(),
|
|
47
|
+
installedVersion: "1.7.0",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
path: `${ctx.root}/missing-repo`,
|
|
51
|
+
firstInstalledAt: new Date(0).toISOString(),
|
|
52
|
+
lastUpdatedAt: new Date().toISOString(),
|
|
53
|
+
installedVersion: "1.0.0",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
ctx.home,
|
|
57
|
+
);
|
|
58
|
+
const pruned = await pruneMissingInstalledRepos(ctx.home);
|
|
59
|
+
expect(pruned.kept).toHaveLength(1);
|
|
60
|
+
expect(pruned.removed).toHaveLength(1);
|
|
61
|
+
expect((await readInstalledRepoRegistry(ctx.home)).map((record) => record.path)).toEqual(
|
|
62
|
+
[repo1],
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await ctx.cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
realpath,
|
|
6
|
+
readFile,
|
|
7
|
+
rm,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "fs/promises";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { dirname, join, resolve } from "path";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
import { claudeProjectKey } from "../metrics/claude-projects.ts";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
export const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
|
18
|
+
export const CLI_BIN = join(REPO_ROOT, "bin", "claude-attribution");
|
|
19
|
+
|
|
20
|
+
export interface CommandResult {
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TempTestContext {
|
|
27
|
+
root: string;
|
|
28
|
+
home: string;
|
|
29
|
+
repo: string;
|
|
30
|
+
cleanup: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runCommand(
|
|
34
|
+
command: string,
|
|
35
|
+
args: string[],
|
|
36
|
+
options: {
|
|
37
|
+
cwd?: string;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
allowFailure?: boolean;
|
|
40
|
+
} = {},
|
|
41
|
+
): Promise<CommandResult> {
|
|
42
|
+
try {
|
|
43
|
+
const result = await execFileAsync(command, args, {
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
env: { ...process.env, ...options.env },
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
stdout: result.stdout.trimEnd(),
|
|
49
|
+
stderr: result.stderr?.trimEnd() ?? "",
|
|
50
|
+
exitCode: 0,
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const execError = error as Error & {
|
|
54
|
+
stdout?: string;
|
|
55
|
+
stderr?: string;
|
|
56
|
+
code?: number;
|
|
57
|
+
};
|
|
58
|
+
if (!options.allowFailure) throw error;
|
|
59
|
+
return {
|
|
60
|
+
stdout: execError.stdout?.trimEnd() ?? "",
|
|
61
|
+
stderr: execError.stderr?.trimEnd() ?? "",
|
|
62
|
+
exitCode: execError.code ?? 1,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createTempContext(
|
|
68
|
+
prefix: string,
|
|
69
|
+
): Promise<TempTestContext> {
|
|
70
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), `${prefix}-`)));
|
|
71
|
+
const home = join(root, "home");
|
|
72
|
+
const repo = join(root, "repo");
|
|
73
|
+
await mkdir(home, { recursive: true });
|
|
74
|
+
await mkdir(join(home, ".claude", "projects"), { recursive: true });
|
|
75
|
+
await mkdir(repo, { recursive: true });
|
|
76
|
+
return {
|
|
77
|
+
root,
|
|
78
|
+
home,
|
|
79
|
+
repo,
|
|
80
|
+
cleanup: async () => {
|
|
81
|
+
await rm(root, { recursive: true, force: true });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function initGitRepo(repo: string): Promise<void> {
|
|
87
|
+
await runCommand("git", ["init"], { cwd: repo });
|
|
88
|
+
await configureGitIdentity(repo);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function configureGitIdentity(repo: string): Promise<void> {
|
|
92
|
+
await runCommand("git", ["config", "user.name", "Test User"], { cwd: repo });
|
|
93
|
+
await runCommand("git", ["config", "user.email", "test@example.com"], {
|
|
94
|
+
cwd: repo,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function commitAll(repo: string, message: string): Promise<void> {
|
|
99
|
+
await runCommand("git", ["add", "."], { cwd: repo });
|
|
100
|
+
await runCommand("git", ["commit", "-m", message], { cwd: repo });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function currentSha(repo: string): Promise<string> {
|
|
104
|
+
const result = await runCommand("git", ["rev-parse", "HEAD"], { cwd: repo });
|
|
105
|
+
return result.stdout.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function writeJsonl(
|
|
109
|
+
path: string,
|
|
110
|
+
entries: unknown[],
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await mkdir(dirname(path), { recursive: true });
|
|
113
|
+
await writeFile(path, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function transcriptDirFor(home: string, repo: string): string {
|
|
117
|
+
return join(home, ".claude", "projects", claudeProjectKey(repo));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function writeTranscript(
|
|
121
|
+
home: string,
|
|
122
|
+
repo: string,
|
|
123
|
+
sessionId: string,
|
|
124
|
+
entries: unknown[],
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const dir = transcriptDirFor(home, repo);
|
|
127
|
+
await mkdir(dir, { recursive: true });
|
|
128
|
+
await writeJsonl(join(dir, `${sessionId}.jsonl`), entries);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function writeCopilotSession(
|
|
132
|
+
home: string,
|
|
133
|
+
sessionId: string,
|
|
134
|
+
entries: unknown[],
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const dir = join(home, ".copilot", "session-state", sessionId);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
await writeJsonl(join(dir, "events.jsonl"), entries);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function readJson(path: string): Promise<unknown> {
|
|
142
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
143
|
+
}
|