diffcop 0.0.0-stage → 0.1.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 +141 -2
- package/package.json +66 -4
- package/src/agent/claude-code/claude-code-agent.ts +174 -0
- package/src/agent/explored.ts +93 -0
- package/src/agent/reviewer-agent.ts +51 -0
- package/src/cli.ts +21 -0
- package/src/commands/review.ts +220 -0
- package/src/context/git/git-repository.ts +74 -0
- package/src/context/github/pull-request-context.ts +81 -0
- package/src/context/github/pull-request-data.ts +80 -0
- package/src/context/github/pull-request-fetcher.ts +149 -0
- package/src/context/repository.ts +16 -0
- package/src/context/review-context-collector.ts +106 -0
- package/src/github/github-client.ts +23 -0
- package/src/github/unwrap-response.ts +15 -0
- package/src/prompt/github/pull-request-formatter.ts +96 -0
- package/src/prompt/github/sanitize-content.ts +108 -0
- package/src/prompt/review-prompt-builder.ts +206 -0
- package/src/reporter/console/console-reporter.ts +72 -0
- package/src/reporter/console/json-reporter.ts +37 -0
- package/src/reporter/github/github-reporter.ts +93 -0
- package/src/reporter/github/review-comment-formatter.ts +144 -0
- package/src/reporter/github/sticky-comment.ts +181 -0
- package/src/reporter/progress.ts +12 -0
- package/src/reporter/review-layout.ts +359 -0
- package/src/reporter/review-reporter.ts +19 -0
- package/src/reporter/stats.ts +41 -0
- package/src/review/change-size-limit.ts +92 -0
- package/src/review/code-reviewer.ts +120 -0
- package/src/review/findings.ts +125 -0
- package/src/util/errors.ts +7 -0
- package/src/util/json.ts +7 -0
- package/tsconfig.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brian Estrada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
|
|
1
|
+
<!--
|
|
2
|
+
README strategy: this file is for humans deciding whether to use diffcop and getting it running.
|
|
3
|
+
- Open with what it is and why in one or two sentences, then a quick start that works when you paste it.
|
|
4
|
+
- Show usage by example, not by listing every flag. `diffcop review --help` holds the full reference.
|
|
5
|
+
- Give contributors only the single entry point (`bun run check`). Scripts and config files document themselves.
|
|
6
|
+
- Agent instructions go in AGENTS.md. Don't duplicate them here.
|
|
7
|
+
- If a section grows past a screen, move it to its own doc and link it.
|
|
8
|
+
-->
|
|
2
9
|
|
|
3
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
<picture>
|
|
12
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/branding/lockup-dark.svg">
|
|
13
|
+
<img src="docs/branding/lockup.svg" alt="diffcop" width="324">
|
|
14
|
+
</picture>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
# diffcop
|
|
18
|
+
|
|
19
|
+
An opinionated, local-first AI code reviewer that runs on the coding agent you already use.
|
|
20
|
+
|
|
21
|
+
diffcop reviews your branch from the terminal through an agent harness, using the subscription
|
|
22
|
+
you already pay for, so a review doesn't have to cost you API tokens. It supports Claude Code
|
|
23
|
+
today, and support for [Pi](https://github.com/earendil-works/pi),
|
|
24
|
+
[Codex](https://github.com/openai/codex) and [OpenCode](https://opencode.ai) is planned.
|
|
25
|
+
|
|
26
|
+
The agent reviews your diff with read-only tools, a second pass double-checks every finding, and
|
|
27
|
+
the same review can run on every pull request as a [GitHub Action](#github-action).
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Requires [Bun](https://bun.com) and [Claude Code](https://claude.com/product/claude-code) logged in
|
|
32
|
+
with your subscription. Where you can't log in, run `claude setup-token` to get a long-lived token
|
|
33
|
+
for your subscription and set it as `CLAUDE_CODE_OAUTH_TOKEN`. To pay per token instead, set
|
|
34
|
+
`ANTHROPIC_API_KEY`.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd your-repo
|
|
38
|
+
bunx diffcop review "review this code"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To keep `diffcop` on your PATH, install it with `bun add --global diffcop`. It runs on Bun, so
|
|
42
|
+
`npx diffcop` works only where Bun is installed.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
diffcop review "focus on error handling"
|
|
48
|
+
diffcop review "review this code" --base origin/develop # default: origin/HEAD, then main
|
|
49
|
+
diffcop review "review this code" --cwd ../other-repo
|
|
50
|
+
diffcop review "quick pass" --effort low # faster, shallower review
|
|
51
|
+
diffcop review "review this code" --reporter json | jq .findings
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`diffcop review --help` lists every option.
|
|
55
|
+
|
|
56
|
+
diffcop warns when a diff is too big to review well (over 1,000 changed lines or 20 files, not
|
|
57
|
+
counting lockfiles) and suggests splitting it. Change the limits with `--maxLines` and
|
|
58
|
+
`--maxFiles`.
|
|
59
|
+
|
|
60
|
+
## GitHub Action
|
|
61
|
+
|
|
62
|
+
The action reviews each pull request and keeps the result in a single PR comment.
|
|
63
|
+
|
|
64
|
+
Run `claude setup-token` to get a token for your Claude subscription and add it as a
|
|
65
|
+
`CLAUDE_CODE_OAUTH_TOKEN` repo secret, or add an `ANTHROPIC_API_KEY` secret to pay per token. Then
|
|
66
|
+
add this workflow:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# .github/workflows/review.yml
|
|
70
|
+
name: Review
|
|
71
|
+
|
|
72
|
+
on:
|
|
73
|
+
pull_request:
|
|
74
|
+
# edited covers a retargeted base; other edits are skipped below.
|
|
75
|
+
types: [opened, synchronize, reopened, ready_for_review, edited]
|
|
76
|
+
|
|
77
|
+
concurrency:
|
|
78
|
+
# A title or body edit gets its own group so it can't cancel a running review.
|
|
79
|
+
group: review-${{ github.event.action == 'edited' && !github.event.changes.base && github.run_id || github.event.pull_request.number }}
|
|
80
|
+
cancel-in-progress: true
|
|
81
|
+
|
|
82
|
+
permissions:
|
|
83
|
+
contents: read
|
|
84
|
+
pull-requests: write
|
|
85
|
+
|
|
86
|
+
jobs:
|
|
87
|
+
review:
|
|
88
|
+
# Fork PRs don't get secrets, so there's nothing to authenticate with.
|
|
89
|
+
if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository && (github.event.action != 'edited' || github.event.changes.base) }}
|
|
90
|
+
runs-on: ubuntu-latest
|
|
91
|
+
timeout-minutes: 20
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v7
|
|
94
|
+
with:
|
|
95
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
96
|
+
fetch-depth: 0 # diffcop needs the merge base with the PR's base branch
|
|
97
|
+
persist-credentials: false
|
|
98
|
+
|
|
99
|
+
- uses: BrianEstrada/diffcop@main
|
|
100
|
+
with:
|
|
101
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
102
|
+
claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Input | Default | Description |
|
|
106
|
+
| ------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------- |
|
|
107
|
+
| `anthropic-api-key` | | Set this or `claude-code-oauth-token`. |
|
|
108
|
+
| `claude-code-oauth-token` | | From `claude setup-token`, for Claude subscriptions. |
|
|
109
|
+
| `github-token` | `github.token` | Reads the PR and posts the comment. |
|
|
110
|
+
| `prompt` | Review this pull request for bugs, security issues, and regressions. | What to ask the reviewer. |
|
|
111
|
+
| `effort` | `high` | `low` to `max`. Empty uses Claude Code's default. |
|
|
112
|
+
| `debug` | `false` | Also log the reviewer's tool calls and thinking time. |
|
|
113
|
+
|
|
114
|
+
The action installs Bun, Node and pnpm on the runner, so later steps in the same job see those
|
|
115
|
+
versions.
|
|
116
|
+
|
|
117
|
+
## How a review works
|
|
118
|
+
|
|
119
|
+
The first pass looks for bugs the diff introduced, plus a few nits, and on a PR checks that the
|
|
120
|
+
description matches the code. A second, fresh session then tries to disprove each finding, and
|
|
121
|
+
only what survives is reported.
|
|
122
|
+
|
|
123
|
+
The review opens with one headline:
|
|
124
|
+
|
|
125
|
+
- 🔴 **N issues found**: high or medium bugs the diff introduced.
|
|
126
|
+
- 🟡 **Description doesn't match the diff**: the PR claims something the code doesn't do.
|
|
127
|
+
- 🟡 **Only nits**: nothing serious.
|
|
128
|
+
- ✅ **All good**: nothing found.
|
|
129
|
+
|
|
130
|
+
Below it, collapsible sections list the findings, bugs that predate the diff, what verification
|
|
131
|
+
dropped and why, and the files the reviewer read.
|
|
132
|
+
|
|
133
|
+
On a PR, the reviewer also sees the title, description, labels, comments and reviews, sanitized
|
|
134
|
+
before they reach the prompt. It treats the description as the author's claims, not as proof.
|
|
135
|
+
|
|
136
|
+
## Contributing
|
|
137
|
+
|
|
138
|
+
`bun install` also sets up the git hooks. Run `bun run check` (typecheck, lint, format, tests)
|
|
139
|
+
before you open a PR. CI runs the same check on every PR.
|
|
140
|
+
|
|
141
|
+
Commit messages follow [Conventional Commits](https://www.conventionalcommits.org), e.g.
|
|
142
|
+
`feat(cli): add --json flag`. The commit-msg hook runs commitlint and rejects anything else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diffcop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An opinionated, local-first AI code reviewer that runs on the coding agent you already use.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"cli",
|
|
9
|
+
"code-review"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/BrianEstrada/diffcop#readme",
|
|
12
|
+
"bugs": "https://github.com/BrianEstrada/diffcop/issues",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Brian Estrada",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/BrianEstrada/diffcop.git"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"diffcop": "src/cli.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"!src/**/*.test.ts",
|
|
25
|
+
"tsconfig.json"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"imports": {
|
|
29
|
+
"#package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"diffcop": "bun src/cli.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"fmt": "oxfmt",
|
|
35
|
+
"fmt:check": "oxfmt --check",
|
|
36
|
+
"lint": "oxlint",
|
|
37
|
+
"lint:fix": "oxlint --fix",
|
|
38
|
+
"check": "bun run typecheck && bun run lint && bun run fmt:check && bun test --pass-with-no-tests",
|
|
39
|
+
"changelog": "if grep -qF \"## [$npm_package_version]\" CHANGELOG.md; then echo \"CHANGELOG.md already has $npm_package_version\"; exit 1; fi; git-cliff --unreleased --tag v$npm_package_version --prepend CHANGELOG.md && oxfmt CHANGELOG.md",
|
|
40
|
+
"release-notes": "git-cliff --unreleased --strip all"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@ai-sdk/harness": "1.0.148",
|
|
44
|
+
"@ai-sdk/harness-claude-code": "1.0.152",
|
|
45
|
+
"@ai-sdk/provider-utils": "5.0.58",
|
|
46
|
+
"@octokit/core": "7.0.8",
|
|
47
|
+
"@octokit/plugin-paginate-rest": "16.0.0",
|
|
48
|
+
"@octokit/plugin-retry": "8.1.1",
|
|
49
|
+
"@octokit/request-error": "7.1.2",
|
|
50
|
+
"ai": "7.0.137",
|
|
51
|
+
"ai-sdk-sandbox-local": "0.1.2",
|
|
52
|
+
"citty": "0.2.2",
|
|
53
|
+
"ws": "8.22.0",
|
|
54
|
+
"zod": "4.6.5"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@commitlint/cli": "21.2.3",
|
|
58
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
59
|
+
"@types/bun": "1.4.2",
|
|
60
|
+
"git-cliff": "2.14.2",
|
|
61
|
+
"lefthook": "2.2.1",
|
|
62
|
+
"oxfmt": "0.72.0",
|
|
63
|
+
"oxlint": "1.87.0",
|
|
64
|
+
"oxlint-plugin-deslopify": "0.5.0",
|
|
65
|
+
"oxlint-tsgolint": "7.0.2004",
|
|
66
|
+
"typescript": "7.0.2"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { HarnessAgent } from "@ai-sdk/harness/agent";
|
|
2
|
+
import { createClaudeCode } from "@ai-sdk/harness-claude-code";
|
|
3
|
+
import { Output } from "ai";
|
|
4
|
+
import { createLocalSandbox } from "ai-sdk-sandbox-local";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ExplorationLog, explorationCall } from "@/agent/explored";
|
|
7
|
+
import type {
|
|
8
|
+
AgentResult,
|
|
9
|
+
AgentRun,
|
|
10
|
+
ReviewerAgent,
|
|
11
|
+
RunStats,
|
|
12
|
+
} from "@/agent/reviewer-agent";
|
|
13
|
+
|
|
14
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
15
|
+
|
|
16
|
+
export type Effort = (typeof EFFORTS)[number];
|
|
17
|
+
|
|
18
|
+
export interface ClaudeCodeReviewerAgentOptions {
|
|
19
|
+
model?: string;
|
|
20
|
+
maxTurns?: number;
|
|
21
|
+
effort?: Effort;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function leaseLocalPort() {
|
|
25
|
+
const listener = Bun.listen({
|
|
26
|
+
hostname: "127.0.0.1",
|
|
27
|
+
port: 0,
|
|
28
|
+
socket: { data: function () {} },
|
|
29
|
+
});
|
|
30
|
+
const { port } = listener;
|
|
31
|
+
listener.stop(true);
|
|
32
|
+
return port;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Claude Code reports its cost estimate only on the turn's `finish` part, which
|
|
36
|
+
// the harness sends with `providerMetadata` though the AI SDK type leaves it
|
|
37
|
+
// out. The non-deprecated `finalStep.providerMetadata` never has it.
|
|
38
|
+
export const finishCost = z
|
|
39
|
+
.object({
|
|
40
|
+
providerMetadata: z.object({
|
|
41
|
+
"claude-code": z.object({ costUsd: z.number() }),
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
.transform((part) => {
|
|
45
|
+
return part.providerMetadata["claude-code"].costUsd;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Read-only by design: never add write or shell tools here. Under
|
|
49
|
+
// "allow-reads" any other tool waits for an approval nothing grants.
|
|
50
|
+
export const REVIEWER_TOOLS = ["read", "glob", "grep"] as const;
|
|
51
|
+
|
|
52
|
+
// diffcop never answers approval requests, so the run would end without a
|
|
53
|
+
// review; fail instead of posting an empty one.
|
|
54
|
+
export function approvalRequestError(toolName: string) {
|
|
55
|
+
return new Error(
|
|
56
|
+
`The reviewer asked to use ${toolName}, which needs an approval diffcop can't give. Only ${REVIEWER_TOOLS.join(", ")} are allowed.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ClaudeCodeReviewerAgent implements ReviewerAgent {
|
|
61
|
+
readonly #options: ClaudeCodeReviewerAgentOptions;
|
|
62
|
+
|
|
63
|
+
constructor(options: ClaudeCodeReviewerAgentOptions = {}) {
|
|
64
|
+
this.#options = options;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async run<T>({
|
|
68
|
+
cwd,
|
|
69
|
+
prompt,
|
|
70
|
+
output,
|
|
71
|
+
reporter,
|
|
72
|
+
abortSignal,
|
|
73
|
+
}: AgentRun<T>): Promise<AgentResult<T>> {
|
|
74
|
+
const startedAt = performance.now();
|
|
75
|
+
const port = leaseLocalPort();
|
|
76
|
+
const agent = new HarnessAgent({
|
|
77
|
+
harness: createClaudeCode({
|
|
78
|
+
maxTurns: this.#options.maxTurns,
|
|
79
|
+
effort: this.#options.effort,
|
|
80
|
+
// The local sandbox predates `getPortEndpoint`, so pass the bridge port
|
|
81
|
+
// and endpoint explicitly.
|
|
82
|
+
port: port,
|
|
83
|
+
portEndpoint: { url: `ws://127.0.0.1:${port}` },
|
|
84
|
+
}),
|
|
85
|
+
model: this.#options.model,
|
|
86
|
+
instructions: prompt.instructions,
|
|
87
|
+
permissionMode: "allow-reads",
|
|
88
|
+
activeTools: [...REVIEWER_TOOLS],
|
|
89
|
+
// Root the session at `cwd` itself, not a per-session subdirectory.
|
|
90
|
+
sandboxConfig: { workDir: "." },
|
|
91
|
+
// Claude Code's `outputFormat`: the final answer is a schema-checked object.
|
|
92
|
+
output: Output.object({ schema: output }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const sandboxSession = await createLocalSandbox({
|
|
96
|
+
cwd: cwd,
|
|
97
|
+
}).createSession();
|
|
98
|
+
const session = await agent.createSession({
|
|
99
|
+
sandboxSession: sandboxSession,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await agent.stream({
|
|
104
|
+
session: session,
|
|
105
|
+
abortSignal: abortSignal,
|
|
106
|
+
messages: [
|
|
107
|
+
{
|
|
108
|
+
role: "user",
|
|
109
|
+
content: prompt.content.map((text) => {
|
|
110
|
+
return {
|
|
111
|
+
type: "text",
|
|
112
|
+
text: text,
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Text before the structured answer is narration, so only progress is reported.
|
|
120
|
+
let toolCalls = 0;
|
|
121
|
+
let thinkingSince: number | undefined;
|
|
122
|
+
let costUsd: number | undefined;
|
|
123
|
+
const exploration = new ExplorationLog(cwd);
|
|
124
|
+
for await (const part of result.stream) {
|
|
125
|
+
switch (part.type) {
|
|
126
|
+
case "tool-call": {
|
|
127
|
+
toolCalls++;
|
|
128
|
+
const call = explorationCall.safeParse(part);
|
|
129
|
+
if (call.success) {
|
|
130
|
+
exploration.record(call.data);
|
|
131
|
+
}
|
|
132
|
+
reporter.toolCall(part.toolName, JSON.stringify(part.input));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "reasoning-start":
|
|
136
|
+
thinkingSince = performance.now();
|
|
137
|
+
break;
|
|
138
|
+
case "reasoning-end":
|
|
139
|
+
if (thinkingSince !== undefined) {
|
|
140
|
+
reporter.thinking(performance.now() - thinkingSince);
|
|
141
|
+
thinkingSince = undefined;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "tool-approval-request":
|
|
145
|
+
throw approvalRequestError(part.toolCall.toolName);
|
|
146
|
+
case "finish":
|
|
147
|
+
costUsd = finishCost.safeParse(part).data;
|
|
148
|
+
break;
|
|
149
|
+
case "error":
|
|
150
|
+
throw part.error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const [usage, answer] = await Promise.all([result.usage, result.output]);
|
|
155
|
+
const stats: RunStats = {
|
|
156
|
+
durationMs: performance.now() - startedAt,
|
|
157
|
+
toolCalls: toolCalls,
|
|
158
|
+
inputTokens: usage.inputTokens,
|
|
159
|
+
cacheReadTokens: usage.inputTokenDetails.cacheReadTokens,
|
|
160
|
+
outputTokens: usage.outputTokens,
|
|
161
|
+
costUsd: costUsd,
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
// Model output, so checked here too rather than trusting the harness's check.
|
|
165
|
+
output: output.parse(answer),
|
|
166
|
+
stats: stats,
|
|
167
|
+
explored: exploration.explored(),
|
|
168
|
+
};
|
|
169
|
+
} finally {
|
|
170
|
+
await session.destroy();
|
|
171
|
+
await sandboxSession.destroy();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export interface Search {
|
|
5
|
+
pattern: string;
|
|
6
|
+
path?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Explored {
|
|
10
|
+
filesRead: string[];
|
|
11
|
+
searches: Search[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const searchInput = z.object({
|
|
15
|
+
pattern: z.string(),
|
|
16
|
+
path: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Claude Code's read, glob and grep tool calls, as they come off the stream.
|
|
20
|
+
export const explorationCall = z.discriminatedUnion("toolName", [
|
|
21
|
+
z.object({
|
|
22
|
+
toolName: z.literal("read"),
|
|
23
|
+
input: z.object({ file_path: z.string() }),
|
|
24
|
+
}),
|
|
25
|
+
z.object({
|
|
26
|
+
toolName: z.enum(["glob", "grep"]),
|
|
27
|
+
input: searchInput,
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export type ExplorationCall = z.infer<typeof explorationCall>;
|
|
32
|
+
|
|
33
|
+
function searchKey({ pattern, path }: Search) {
|
|
34
|
+
return JSON.stringify([pattern, path]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function uniqueSearches(searches: Search[]) {
|
|
38
|
+
const byKey = new Map<string, Search>();
|
|
39
|
+
for (const search of searches) {
|
|
40
|
+
if (!byKey.has(searchKey(search))) {
|
|
41
|
+
byKey.set(searchKey(search), search);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...byKey.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mergeExplored(a: Explored, b: Explored): Explored {
|
|
48
|
+
return {
|
|
49
|
+
filesRead: [...new Set([...a.filesRead, ...b.filesRead])],
|
|
50
|
+
searches: uniqueSearches([...a.searches, ...b.searches]),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Collects what a run read and searched from its tool calls, in first-seen order.
|
|
55
|
+
export class ExplorationLog {
|
|
56
|
+
readonly #cwd: string;
|
|
57
|
+
readonly #filesRead: string[] = [];
|
|
58
|
+
readonly #searches: Search[] = [];
|
|
59
|
+
|
|
60
|
+
constructor(cwd: string) {
|
|
61
|
+
this.#cwd = cwd;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
record(call: ExplorationCall) {
|
|
65
|
+
if (call.toolName === "read") {
|
|
66
|
+
this.#filesRead.push(this.#relative(call.input.file_path));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.#searches.push(this.#search(call.input));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
explored(): Explored {
|
|
73
|
+
return {
|
|
74
|
+
filesRead: [...new Set(this.#filesRead)],
|
|
75
|
+
searches: uniqueSearches(this.#searches),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#search({ pattern, path }: Search): Search {
|
|
80
|
+
const relativePath = path === undefined ? "" : this.#relative(path);
|
|
81
|
+
if (!relativePath) {
|
|
82
|
+
return { pattern: pattern };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
pattern: pattern,
|
|
86
|
+
path: relativePath,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#relative(path: string) {
|
|
91
|
+
return relative(this.#cwd, resolve(this.#cwd, path));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { ReviewPrompt } from "@/prompt/review-prompt-builder";
|
|
3
|
+
import type { ReviewReporter } from "@/reporter/review-reporter";
|
|
4
|
+
import type { Explored } from "@/agent/explored";
|
|
5
|
+
|
|
6
|
+
export interface RunStats {
|
|
7
|
+
durationMs: number;
|
|
8
|
+
toolCalls: number;
|
|
9
|
+
inputTokens?: number;
|
|
10
|
+
cacheReadTokens?: number;
|
|
11
|
+
outputTokens?: number;
|
|
12
|
+
costUsd?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentRun<T> {
|
|
16
|
+
cwd: string;
|
|
17
|
+
prompt: ReviewPrompt;
|
|
18
|
+
// The agent answers with an object matching this schema.
|
|
19
|
+
output: z.ZodType<T>;
|
|
20
|
+
reporter: ReviewReporter;
|
|
21
|
+
abortSignal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AgentResult<T> {
|
|
25
|
+
output: T;
|
|
26
|
+
stats: RunStats;
|
|
27
|
+
explored: Explored;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReviewerAgent {
|
|
31
|
+
run<T>(run: AgentRun<T>): Promise<AgentResult<T>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A count only one run reported is left out rather than understated.
|
|
35
|
+
function sumKnown(a: number | undefined, b: number | undefined) {
|
|
36
|
+
if (a === undefined || b === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return a + b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sumStats(a: RunStats, b: RunStats): RunStats {
|
|
43
|
+
return {
|
|
44
|
+
durationMs: a.durationMs + b.durationMs,
|
|
45
|
+
toolCalls: a.toolCalls + b.toolCalls,
|
|
46
|
+
inputTokens: sumKnown(a.inputTokens, b.inputTokens),
|
|
47
|
+
cacheReadTokens: sumKnown(a.cacheReadTokens, b.cacheReadTokens),
|
|
48
|
+
outputTokens: sumKnown(a.outputTokens, b.outputTokens),
|
|
49
|
+
costUsd: sumKnown(a.costUsd, b.costUsd),
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import pkg from "#package.json";
|
|
4
|
+
|
|
5
|
+
const main = defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "diffcop",
|
|
8
|
+
version: pkg.version,
|
|
9
|
+
description: "Local AI code reviewer",
|
|
10
|
+
},
|
|
11
|
+
subCommands: {
|
|
12
|
+
review: () => {
|
|
13
|
+
return import("@/commands/review").then((m) => {
|
|
14
|
+
return m.default;
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// runMain catches its own errors and exits non-zero, so there's nothing to await.
|
|
21
|
+
void runMain(main);
|