@expo/code-review-cli 0.1.0 → 0.2.1

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 CHANGED
@@ -1,26 +1,115 @@
1
- # expo-code-review
1
+ # @expo/code-review-cli
2
2
 
3
3
  A config-driven, multi-agent AI code reviewer. Specialist agents review a diff in
4
4
  parallel; a coordinator consolidates their findings into one structured review.
5
- Runs the same engine locally (advisory) and in CI (posts a PR comment).
5
+ The same engine runs locally (advisory) and in CI (posts one PR comment). The CLI
6
+ is the **engine** — each repo supplies its own agents and settings under
7
+ `.expo-code-review/`, so behavior is configured per-repo, not baked in.
6
8
 
7
- > **Status: experimental.** Phase 1 is **comment-only and non-blocking** — it
8
- > never blocks a merge and never auto-approves. The package is incubated inside
9
- > `eas-cli` for fast iteration and is intended to graduate into its own repo; see
10
- > [`ROADMAP.md`](./ROADMAP.md).
9
+ > **Status: experimental.** Comment-only and non-blocking — it never blocks a merge
10
+ > and never auto-approves. See [`ROADMAP.md`](./ROADMAP.md).
11
+
12
+ Inspired in part by Cloudflare's [_How we built our AI code review bot_](https://blog.cloudflare.com/ai-code-review/).
13
+
14
+ ```mermaid
15
+ flowchart TD
16
+ SRC["Source<br/>local git · GitHub PR (gh)"] --> FILTER["Noise filter<br/>drop lockfiles · generated · binary"]
17
+ FILTER --> CHUNK["Chunk<br/>by changed lines (large diffs only)"]
18
+ CHUNK --> AGENTS["Agents (parallel)<br/>each .md in agents/ · read·grep·glob·list"]
19
+ CHUNK --> XCUT["Cross-cutting pass<br/>multi-file issues (large diffs)"]
20
+ AGENTS --> COORD["Coordinator<br/>dedupe · re-judge · decide"]
21
+ XCUT --> COORD
22
+ COORD --> VERIFY["Verify<br/>quote-ground · adversarially verify criticals"]
23
+ VERIFY --> REPORT["Reporter<br/>one PR comment (CI) · terminal (local)"]
24
+ ```
11
25
 
12
- The CLI is the **engine**. Each repo supplies its own agents and settings under
13
- `.expo-code-review/`, so behavior is configured per-repo, not baked in.
26
+ ## Usage
27
+
28
+ Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
29
+ binary once installed).
30
+
31
+ Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
32
+ Everything else the reviewer needs (including the `opencode` runtime) ships with the
33
+ package.
34
+
35
+ ### First-time setup
36
+
37
+ Scaffold, add credentials, verify.
38
+
39
+ ```bash
40
+ # Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
41
+ npx @expo/code-review-cli init
42
+ ```
14
43
 
15
- ## How it works
44
+ Then give it model credentials. **Recommended: a Claude Pro/Max subscription** — the
45
+ scaffolded config uses OAuth by default, so just mint a token and export it under the
46
+ env var your `config.jsonc`'s `auth.tokenEnv` names:
16
47
 
48
+ ```bash
49
+ # Mint a Claude Pro/Max token (prints an sk-ant-oat… token)
50
+ claude setup-token
51
+ # Export it under the env var your config.jsonc's auth.tokenEnv names
52
+ export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
53
+ # Check env, config, and credentials
54
+ npx @expo/code-review-cli doctor
17
55
  ```
18
- diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coordinator ─▶ reporter
19
- (git / gh) drop lockfiles, by each agent reviews dedupe, one PR comment
20
- generated, changed every chunk + re-judge, (CI) or terminal
21
- binary files lines a cross-cutting pass decide output (local)
56
+
57
+ Prefer an Anthropic **API key**, or **OpenAI/GPT** or another provider? See
58
+ [Other providers & auth modes](#other-providers) at the bottom.
59
+
60
+ ### Reviewing (already configured)
61
+
62
+ ```bash
63
+ # Review working-tree changes; prints here, posts nothing
64
+ ecr review
65
+ # Review a GitHub PR by number (preview only)
66
+ ecr review --pr 4057
67
+ # …and post it as the PR comment
68
+ ecr review --pr 4057 --post
22
69
  ```
23
70
 
71
+ Options (most to least common):
72
+
73
+ | Flag | What it does |
74
+ | --- | --- |
75
+ | `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
76
+ | `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
77
+ | `--staged` | Review only staged changes. |
78
+ | `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
79
+ | `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
80
+ | `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
81
+ | `--route` | Let an LLM router pick the relevant agents from the diff. |
82
+ | `--repo <owner/repo>` | Repo for `--pr` (default: inferred from the current checkout). |
83
+ | `--json` | Emit machine-readable JSON on stdout. |
84
+ | `--no-fail` | Always exit 0 (otherwise a `request_changes` decision exits non-zero). |
85
+ | `-h`, `--help` | Show help. |
86
+
87
+ `--pr` uses the PR's diff (authoritative) but reads your checked-out files for
88
+ surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
89
+ `ecr review`.
90
+
91
+ In CI it runs automatically from the scaffolded workflows — by label or a `/review`
92
+ comment (see **CI usage**). From Claude Code (or another agent), add a slash command
93
+ that runs it; eas-cli's
94
+ [`/expo-review`](https://github.com/expo/eas-cli/blob/main/.claude/commands/expo-review.md)
95
+ is a ready example to adapt.
96
+
97
+ ### Command reference
98
+
99
+ | Command | What it does |
100
+ | --- | --- |
101
+ | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
102
+ | `ecr review [options]` | Review local changes and print an advisory review (default command). |
103
+ | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
104
+ | `ecr doctor` | Check environment, config, and model credentials. |
105
+
106
+ (When developing this repo itself, use `bun run src/cli.ts <command>`.)
107
+
108
+ ---
109
+
110
+ <details>
111
+ <summary><b>How it works</b></summary>
112
+
24
113
  - **Source** — local git (working tree, staged, or a ref range) or a GitHub PR
25
114
  (diff + metadata fetched over the `gh` API).
26
115
  - **Noise filter** — drops lockfiles, generated bundles/maps, snapshots, files
@@ -33,6 +122,8 @@ diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coo
33
122
  run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
34
123
  - **Coordinator** — a single pass that dedupes, re-judges severity, and produces
35
124
  the final `{ decision, findings, summary }`.
125
+ - **Verify** — quote-grounds every finding against the real file and adversarially
126
+ verifies criticals, so a confident-but-wrong finding doesn't ship.
36
127
  - **Reporter** — posts/updates a single fingerprinted PR comment (CI), or prints
37
128
  a grouped summary (local). Findings below the configured severity floor are
38
129
  suppressed.
@@ -40,48 +131,10 @@ diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coo
40
131
  Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
41
132
  and applies Anthropic prompt caching automatically.
42
133
 
43
- ## Commands
134
+ </details>
44
135
 
45
- Run via the workspace during incubation (`yarn workspace expo-code-review dev …`),
46
- or as the `ecr` / `expo-code-review` binary once built/installed.
47
-
48
- | Command | What it does |
49
- | --- | --- |
50
- | `ecr review [options]` | Review local changes and print an advisory review (default command). |
51
- | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
52
- | `ecr init [--with-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) in this repo. |
53
- | `ecr doctor` | Check environment, config, and model credentials. |
54
-
55
- ### `ecr review` options
56
-
57
- ```
58
- --base <ref> Base ref to diff against (default: merge-base with default branch)
59
- --head <ref> Head ref to diff (default: working tree, incl. uncommitted changes)
60
- --staged Review only staged changes
61
- --pr <n> Review GitHub PR #n by number (diff fetched via gh, no checkout);
62
- not combinable with --base/--head/--staged
63
- --repo <owner/repo> Repo for --pr (default: inferred from the current checkout)
64
- --post With --pr: also post the result as the PR comment (needs gh auth).
65
- Omit to preview only; re-run with --post to publish.
66
- --agents <a,b> Run only these agents (comma-separated ids); default: all
67
- --route Let an LLM router pick the relevant agents from the diff
68
- --json Emit machine-readable JSON on stdout
69
- --no-fail Always exit 0 (otherwise a request_changes decision exits non-zero)
70
- -h, --help Show help
71
- ```
72
-
73
- Reviewing a PR without checking it out — preview, then optionally post:
74
-
75
- ```bash
76
- ecr review --pr 4057 # print the review here; posts nothing
77
- ecr review --pr 4057 --post # re-run and post it as the PR comment
78
- ```
79
-
80
- `--pr` uses the PR's diff (authoritative) but reads your checked-out files for
81
- surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
82
- `ecr review`.
83
-
84
- ## Configuration — `.expo-code-review/`
136
+ <details>
137
+ <summary><b>Configuration <code>.expo-code-review/</code></b></summary>
85
138
 
86
139
  ```
87
140
  .expo-code-review/
@@ -101,14 +154,19 @@ surrounding context; for full fidelity, `gh pr checkout <n>` first and run a pla
101
154
  ---
102
155
  description: One line the router uses to decide relevance.
103
156
  alwaysRun: true # run even when the router would skip this agent
104
- model: anthropic/claude-sonnet-5 # override the default model (e.g. haiku for the coordinator)
157
+ model: anthropic/claude-sonnet-5 # override the default model
105
158
  temperature: 0.1
106
159
  ---
107
160
 
108
161
  # Agent instructions in Markdown…
109
162
  ```
110
163
 
111
- ### `config.jsonc`
164
+ For a real-world example, see eas-cli's
165
+ [`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
166
+ — correctness/security/consistency agents, Opus for security + the coordinator, and
167
+ per-repo `noise.additionalIgnores`.
168
+
169
+ `config.jsonc` (JSONC — comments + trailing commas supported):
112
170
 
113
171
  ```jsonc
114
172
  {
@@ -119,142 +177,115 @@ temperature: 0.1
119
177
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
120
178
  "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
121
179
  "auth": { "mode": "oauth", "provider": "anthropic",
122
- "tokenEnv": "DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY" }
180
+ "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" }
123
181
  }
124
182
  ```
125
183
 
126
- JSONC (comments + trailing commas) is supported.
127
-
128
- ## Authentication
129
-
130
- Model credentials come from OpenCode. Two modes, set in `config.auth`:
131
-
132
- - **`api-key`** — the token in `tokenEnv` is copied into the provider's API-key
133
- env var (e.g. `ANTHROPIC_API_KEY`).
134
- - **`oauth`** — a Claude Pro/Max token (from `claude setup-token`, an
135
- `sk-ant-oat…` token, *not* an x-api-key) is written into an isolated OpenCode
136
- `auth.json` as a bearer credential, so it uses the native subscription path.
137
-
138
- Set **`REVIEWER_MODEL`** to override the model for every agent and use your own
139
- OpenCode login instead of the repo's configured credentials — handy locally
140
- (e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`). There is no shared fallback key;
141
- if a run fails for lack of credentials, authenticate a provider in OpenCode.
184
+ </details>
142
185
 
143
- Run `ecr doctor` to diagnose setup.
186
+ <details>
187
+ <summary><b>Model selection</b></summary>
144
188
 
145
- ## Model selection
189
+ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatter
190
+ `model:`** → **`config.jsonc` `model`** (the default). So a repo can run a mixed
191
+ setup, and a developer can override everything locally.
146
192
 
147
- Models are resolved with this precedence: **`REVIEWER_MODEL` env** (global override)
148
- → per-file **frontmatter `model:`** **`config.jsonc` `model`** (the default). So a
149
- repo can run a mixed setup, and a developer can override everything locally.
150
-
151
- Rules of thumb for the reviewer's workload:
152
-
153
- - **Specialist agents** (correctness/security/consistency) do the real bug-finding
154
- and benefit from a reasoning-tier model — **Sonnet** is the quality/speed sweet
155
- spot (the default for correctness/consistency). **Opus** finds more but is slower
156
- and more rate-limited, which makes large-PR timeouts worse — so scope it to the
157
- one agent where the extra threat-model reasoning pays off most: **security runs on
158
- Opus** (set in `security.md` frontmatter), the rest on Sonnet. This keeps the
159
- latency/rate-limit cost to a single agent, and the timeout handling (subdivide +
160
- per-fetch deadline) keeps a slow Opus pass from hanging the run.
161
- - **The coordinator** only consolidates text (no repo tools), so a fast, cheap
162
- model — **Haiku** — fits well and keeps the serial tail short. Set it in
163
- `coordinator.md` frontmatter.
193
+ - **Specialist agents** (correctness/security/consistency) benefit from a
194
+ reasoning-tier model — **Sonnet** is the quality/speed sweet spot (default for
195
+ correctness/consistency). **Opus** finds more but is slower and more
196
+ rate-limited, so scope it to the highest-stakes agent: **security runs on Opus**
197
+ (set in `security.md` frontmatter), the rest on Sonnet.
198
+ - **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
199
+ strong model; set it in `coordinator.md` frontmatter.
164
200
  - If latency/timeouts dominate on big PRs, moving the specialists to a faster model
165
201
  is the most direct lever (a real recall tradeoff — measure it).
166
202
 
167
- Example mixed setup:
168
-
169
- ```jsonc
170
- // config.jsonc
171
- "model": "anthropic/claude-sonnet-5" // default: specialists + cross-file pass
172
- ```
173
- ```markdown
174
- <!-- security.md frontmatter --> → Opus for the highest-stakes agent
175
- ---
176
- model: anthropic/claude-opus-4-8
177
- ---
178
-
179
- <!-- coordinator.md frontmatter --> → Haiku for the text-only consolidation
180
- ---
181
- model: anthropic/claude-haiku-4-5-20251001
182
- ---
183
- ```
184
-
185
203
  There is no automatic cross-provider "equivalent" fallback — that would silently
186
204
  change which model reviewed your code. Use an explicit override instead.
187
205
 
188
- ## Reliability
206
+ </details>
189
207
 
190
- A review must never hang, silently produce nothing, or present an unreviewed
191
- change as "looks good":
208
+ <details>
209
+ <summary><b>Reliability</b> never hangs, never silently drops work</summary>
192
210
 
193
- - **Per-task time caps** — focused chunk passes get 15 min; the cross-cutting pass
194
- gets 25 min; the coordinator gets 10 min. A global passes budget (32 min) bounds
195
- all passes incl. the subdivision waves below, so everything fits inside the CI
196
- job's `timeout-minutes` (60), since the coordinator + verification run afterward.
211
+ - **Per-task time caps** — chunk passes 15 min; cross-cutting 25 min; coordinator
212
+ 10 min. A global passes budget (32 min) bounds all passes incl. the subdivision
213
+ waves, fitting inside the CI job's `timeout-minutes` (60).
197
214
  - **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
198
- finishing is *wandering*, not converging (the usual cause of a non-convergent
199
- timeout). Hitting the cap trips the same soft landing as the time cap.
215
+ finishing is *wandering*, not converging; hitting the cap trips the soft landing.
200
216
  - **Soft landing on timeout** — at either cap, the run is interrupted and the agent
201
217
  is asked to return the findings it already has, rather than discarding its work.
202
- - **Subdivide-on-timeout — the reviewer never silently drops work.** If a pass
203
- times out with nothing to show, its chunk is split in half and the halves are
204
- re-reviewed (recursively, down to a single file). A chunk that won't converge at
205
- 13 files almost always converges at 6. If even a single file won't converge, a
206
- fast **no-tools fallback** reviews just its inlined diff (a lighter review, but
207
- never nothing). Only if *that* can't finish inside the budget is a coverage gap
208
- reported — and it is always reported, never silent.
218
+ - **Subdivide-on-timeout**a pass that times out with nothing to show has its
219
+ chunk split in half and the halves re-reviewed (recursively, down to a single
220
+ file), then a fast **no-tools fallback** over the inlined diff. Only a genuinely
221
+ un-reducible pass reports a coverage gap and it is always reported, never silent.
209
222
  - **Parse failures are retried** (same session, then once in a bounded fresh
210
- session); that is separate from the timeout path above.
211
- - **A failed run never reads as "Approve"** — if every pass fails, the review says
212
- it could not complete (treat as unreviewed); if some passes fail, the decision
213
- is never a clean approve, and the coordinator is told coverage was reduced.
214
- - **The coordinator can't sink the run** — if the consolidation step fails, findings
215
- are merged deterministically and still posted, rather than thrown away.
216
- - **Coverage notes**passes that timed out or failed are listed so a real
217
- coverage gap is never silent (routine noise filtering is *not* flagged it's
218
- expected and stays in the run log).
219
- - **CI always gets a terminal state** — on any failure the PR gets a comment saying
220
- the reviewer didn't run, not a stuck reaction and silence.
221
-
222
- ## CI usage
223
-
224
- `ecr init --with-workflow` scaffolds a `pull_request` workflow. In this repo the
225
- reviewer runs via two workflows, split along a clean line: **comments = one-shot
226
- actions, labels = persistent configuration.**
227
-
228
- - **`expo-code-review-command.yml`** — one-shot `/review` comments (maintainers):
229
- - `/review` run once now; the router picks the agents
230
- - `/review all` — run once with every agent
231
- - `/review correctness security` run once with just those agents
232
-
233
- These never change configuration.
234
- - **`expo-code-review.yml`**continuous review, configured by **labels**:
235
- - `ai-review` auto-review every push; the router picks the agents
236
- - `ai-review:all`auto-review with every agent
237
- - `ai-review:<agent>` auto-review with only those agents (e.g.
238
- `ai-review:security`); combine several to widen the set
239
- - `ai-review:skip` — never auto-review this PR (opt-out)
240
- - **`expo-code-review-dismiss.yml`** dismiss/restore a finding on a PR (maintainers):
241
- - `/dismiss <id> [<id> …] [-- reason]` hide finding(s); they move to a collapsed
242
- "Dismissed" section and stay dismissed across re-reviews
243
- - `/undismiss <id> …` — restore them
244
-
245
- Each finding shows a short `` `id:…` `` in the comment. Dismissal is a **display
246
- filter only** — the reviewer still analyzes the code every run, and a `critical`
247
- or `secrets` finding can never be hidden this way. (Also: an inline
248
- `expo-code-review-ignore` comment on/above a line suppresses that line's findings,
249
- same critical/secrets carve-out.)
250
-
251
- These workflows are comment-only (they never fail the PR's checks). For security,
252
- they build/run only the trusted base ref (never the PR head) — see the comment at
253
- the top of each file.
254
-
255
- ## Run logs
223
+ session) separate from the timeout path.
224
+ - **A failed run never reads as "Approve"** — all passes fail "could not
225
+ complete"; some fail never a clean approve, and coverage-reduced.
226
+ - **The coordinator can't sink the run** if consolidation fails, findings are
227
+ merged deterministically and still posted.
228
+ - **Coverage notes** passes that timed out/failed are listed (routine noise
229
+ filtering is *not* flagged it's expected and stays in the run log).
230
+ - **CI always gets a terminal state** on any failure the PR gets a "didn't run"
231
+ comment, not a stuck reaction and silence.
232
+
233
+ </details>
234
+
235
+ <details>
236
+ <summary><b>CI usage</b></summary>
237
+
238
+ `ecr init --with-workflow` scaffolds a `pull_request` workflow. Split along a clean
239
+ line: **comments = one-shot actions, labels = persistent configuration.**
240
+
241
+ - **command workflow** — one-shot `/review` comments (maintainers): `/review`
242
+ (router picks agents), `/review all`, `/review correctness security`. Never
243
+ changes configuration.
244
+ - **auto workflow**continuous review, configured by **labels**: `ai-review`
245
+ (router), `ai-review:all`, `ai-review:<agent>` (e.g. `ai-review:security`;
246
+ combine to widen), `ai-review:skip` (opt-out).
247
+ - **dismiss workflow** `/dismiss <id> [… -- reason]` / `/undismiss <id>`
248
+ (maintainers). Each finding shows a short `` `id:…` ``. Dismissal is a **display
249
+ filter only**the reviewer still analyzes everything, and a `critical`/`secrets`
250
+ finding can never be hidden. (An inline `expo-code-review-ignore` comment on/above
251
+ a line does the same, with the same critical/secrets carve-out.)
252
+
253
+ These workflows are comment-only (they never fail the PR's checks). The engine runs
254
+ as the published package via `npx`, so no PR-controlled code is built.
255
+
256
+ </details>
257
+
258
+ <details>
259
+ <summary><b>Run logs</b></summary>
256
260
 
257
261
  Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
258
262
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
259
- usage (including prompt-cache read/write counts) — for auditing and measuring
263
+ usage (incl. prompt-cache read/write counts) — for auditing and measuring
260
264
  cost/latency/cache reuse over time.
265
+
266
+ </details>
267
+
268
+ <a id="other-providers"></a>
269
+ <details>
270
+ <summary><b>Other providers & auth modes</b></summary>
271
+
272
+ The recommended setup is a Claude Pro/Max subscription (OAuth) — see Usage above.
273
+ Alternatives, all set in `config.auth` (credentials come from OpenCode):
274
+
275
+ - **Anthropic API key** — set `auth.mode` to `"api-key"` and point `tokenEnv` at the
276
+ env var holding the key (e.g. `ANTHROPIC_API_KEY`); it's sent as `x-api-key`. Omit
277
+ the `auth` block entirely to fall back to OpenCode's own login / `ANTHROPIC_API_KEY`.
278
+ - **OAuth (Pro/Max)** — `tokenEnv` holds an `sk-ant-oat…` token from
279
+ `claude setup-token` (*not* an x-api-key); it's written to an isolated OpenCode
280
+ `auth.json` as a bearer credential, using the native subscription path.
281
+ - **OpenAI / GPT, or another provider** — the current path is the `REVIEWER_MODEL`
282
+ env override: `opencode auth login` once (pick the provider), then run with
283
+ e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`. It overrides every agent's model
284
+ and uses your OpenCode login, so no `auth` block is needed. *(First-class
285
+ per-provider config — Anthropic/OpenAI/others in `config.jsonc`, and mixing them
286
+ per agent — is on the [roadmap](./ROADMAP.md).)*
287
+
288
+ There is no shared fallback key; if a run fails for lack of credentials, authenticate
289
+ a provider in OpenCode. `ecr doctor` diagnoses setup.
290
+
291
+ </details>
@@ -9,12 +9,12 @@ const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/', import.meta.url)
9
9
  const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
10
10
 
11
11
  Usage:
12
- ecr init [--with-workflow] [--force]
12
+ ecr init [--no-workflow] [--force]
13
13
 
14
14
  Options:
15
- --with-workflow Also write .github/workflows/expo-code-review.yml
16
- --force Overwrite existing files
17
- -h, --help Show this help
15
+ --no-workflow Skip writing the CI workflow (.github/workflows/expo-code-review.yml)
16
+ --force Overwrite existing files
17
+ -h, --help Show this help
18
18
  `;
19
19
  export async function initCommand(argv) {
20
20
  if (argv.includes('-h') || argv.includes('--help')) {
@@ -32,7 +32,10 @@ export async function initCommand(argv) {
32
32
  /** Scaffold .expo-code-review/ (and optionally the CI workflow) into the repo. */
33
33
  async function scaffold(argv) {
34
34
  const force = argv.includes('--force');
35
- const withWorkflow = argv.includes('--with-workflow');
35
+ // The CI workflow is scaffolded by default (most repos adopting this want it);
36
+ // `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
37
+ // back-compat.
38
+ const withWorkflow = !argv.includes('--no-workflow');
36
39
  const root = (await repoRoot()) ?? process.cwd();
37
40
  const configDir = path.join(root, CONFIG_DIRNAME);
38
41
  // Create only the config dir; let copyInto create prompts/ so it reports
@@ -70,8 +73,8 @@ async function scaffold(argv) {
70
73
  ' 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).',
71
74
  ' 3. Run `ecr doctor`, then `ecr review`.',
72
75
  withWorkflow
73
- ? ' 4. Add the model-key secret referenced by the workflow.'
74
- : ' 4. Run `ecr init --with-workflow` to add the CI workflow.',
76
+ ? ' 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR.'
77
+ : ' 4. (No CI workflow written — re-run without `--no-workflow` to add it.)',
75
78
  '',
76
79
  ].join('\n'));
77
80
  }
@@ -182,25 +182,34 @@ export function buildCrossCuttingTask(allFiles, filtered = []) {
182
182
  export function buildVerifierSystem() {
183
183
  return [
184
184
  'You are a skeptical verifier of a single code-review finding. Your default is',
185
- 'DISTRUST. Using your read/grep tools, open the cited file, locate the code, and',
186
- 'confirm the finding against what the source ACTUALLY says.',
185
+ 'DISTRUST. Using your read/grep tools, open the cited file (search nearby files',
186
+ 'if the code is not exactly there), locate the relevant code, and judge whether',
187
+ 'the described PROBLEM is actually present in the source.',
188
+ '',
189
+ 'Judge the SUBSTANCE, not the wording. The finding\'s quoted "evidence" may be',
190
+ 'paraphrased, abbreviated, quoted across non-adjacent lines, or slightly',
191
+ 'misquoted, and its file/line may be approximate. None of that alone makes the',
192
+ 'finding false — verify against what the code actually does. Do NOT reject merely',
193
+ 'because the quoted snippet is not a verbatim match; reject only if the',
194
+ 'underlying problem is not real.',
187
195
  '',
188
196
  'Mark verified=false (reject) if any of these hold:',
189
- '- the code the finding describes or quotes is not actually present as claimed',
190
- ' (it misread or invented the code),',
191
- '- the described failure/exploit cannot actually occur,',
197
+ '- the described problem does not actually occur in the code (it misread or',
198
+ ' invented the behavior),',
199
+ '- the described failure/exploit cannot actually happen,',
192
200
  "- the claim is internally contradictory (e.g. asserts a type error in code that",
193
201
  ' compiles), or',
194
- '- you cannot substantiate it after reading the file.',
202
+ '- you cannot substantiate the underlying issue after reading the file.',
195
203
  '',
196
- 'Only mark verified=true when you have CONFIRMED, from the real source, that the',
197
- 'flagged code exists as described and the problem is genuine. When unsure, reject.',
204
+ 'Mark verified=true when you have CONFIRMED, from the real source, that the',
205
+ 'described problem genuinely exists. When genuinely unsure whether it is real,',
206
+ 'reject.',
198
207
  '',
199
208
  'Return ONLY this JSON object and nothing else:',
200
209
  '{"verified": true|false, "reason": "one concise sentence grounded in the file"}',
201
210
  ].join('\n');
202
211
  }
203
- export function buildVerifierTask(finding) {
212
+ export function buildVerifierTask(finding, opts = {}) {
204
213
  const lines = [
205
214
  'Verify this finding by reading the real source (do not trust its wording):',
206
215
  '',
@@ -214,6 +223,9 @@ export function buildVerifierTask(finding) {
214
223
  if (finding.evidence) {
215
224
  lines.push('- code the finding claims is present (UNTRUSTED — verify it against the file):', '<<<EVIDENCE', finding.evidence, 'EVIDENCE');
216
225
  }
226
+ if (opts.evidenceUngrounded) {
227
+ lines.push('', 'NOTE: the quoted evidence could NOT be located verbatim in the file. It may be', 'a paraphrase, an elision, or a slightly wrong location — do not reject on that', 'basis alone. Read the file (and nearby files) and judge whether the described', 'problem is genuinely present.');
228
+ }
217
229
  lines.push('', 'Open the file, find the relevant code, and return the single verdict JSON object.');
218
230
  return lines.join('\n');
219
231
  }
@@ -10,16 +10,46 @@ const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
10
10
  // Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
11
11
  const MIN_EVIDENCE_LEN = 12;
12
12
  /**
13
- * Deterministic quote-grounding: does the finding's `evidence` snippet actually
14
- * appear in the file? Returns `unknown` (don't judge) when there's too little
15
- * evidence or the file can't be read (e.g. a base-ref checkout that lacks a
16
- * PR-added file), so we never drop a finding we couldn't actually check.
13
+ * Break `evidence` into normalized, substantive fragments for fuzzy matching:
14
+ * split on newlines AND ellipses (the model often elides with `…`/`...`), strip
15
+ * leading diff markers (`+`/`-`) and comment markers (`//`, `#`, `*`) it may have
16
+ * copied along, normalize, and keep only fragments long enough to be meaningful.
17
17
  */
18
- async function evidencePresence(finding, cwd) {
19
- const evidence = normalizeCode(finding.evidence ?? '');
20
- if (evidence.length < MIN_EVIDENCE_LEN) {
18
+ export function evidenceFragments(evidence) {
19
+ return evidence
20
+ .split(/\r?\n|…|\.\.\./)
21
+ .map(line => line.replace(/^[+\-\s]*/, '').replace(/^(\/\/+|#+|\*+|\/\*)\s?/, ''))
22
+ .map(normalizeCode)
23
+ .filter(fragment => fragment.length >= MIN_EVIDENCE_LEN);
24
+ }
25
+ /**
26
+ * Does the finding's `evidence` correspond to code in the file?
27
+ * - exact (whitespace-normalized) substring → 'present'
28
+ * - else any substantive line/fragment present verbatim → 'present' (fuzzy: this
29
+ * rescues cross-line quotes, ellipsis elisions, and copied comment/diff markers)
30
+ * - a real quote that matches nothing → 'absent'
31
+ * Returns 'unknown' (don't judge) when evidence is too short to conclude anything
32
+ * or the file can't be read, so we never drop a finding we couldn't actually check.
33
+ * NOTE: 'absent' is NOT terminal — the caller escalates it to the LLM verifier
34
+ * rather than dropping, because an imperfect quote does not mean a false finding.
35
+ */
36
+ export function matchEvidence(evidence, content) {
37
+ const normEvidence = normalizeCode(evidence);
38
+ if (normEvidence.length < MIN_EVIDENCE_LEN) {
39
+ return 'unknown';
40
+ }
41
+ const normContent = normalizeCode(content);
42
+ if (normContent.includes(normEvidence)) {
43
+ return 'present';
44
+ }
45
+ const fragments = evidenceFragments(evidence);
46
+ if (fragments.length === 0) {
21
47
  return 'unknown';
22
48
  }
49
+ return fragments.some(fragment => normContent.includes(fragment)) ? 'present' : 'absent';
50
+ }
51
+ /** Read the cited file and grade the evidence against it (see matchEvidence). */
52
+ async function evidencePresence(finding, cwd) {
23
53
  let content;
24
54
  try {
25
55
  content = await readFile(path.resolve(cwd, finding.file), 'utf8');
@@ -27,67 +57,73 @@ async function evidencePresence(finding, cwd) {
27
57
  catch {
28
58
  return 'unknown';
29
59
  }
30
- return normalizeCode(content).includes(evidence) ? 'present' : 'absent';
60
+ return matchEvidence(finding.evidence ?? '', content);
31
61
  }
32
62
  /**
33
- * Guard against hallucinated findings before they're surfaced:
34
- * 1. Quote-grounding (deterministic, all findings): drop any whose quoted
35
- * `evidence` is definitively not in the file.
36
- * 2. Adversarial verify (LLM, criticals only): a skeptical pass re-reads the real
37
- * file and must confirm the critical is genuine; refuted criticals are dropped.
38
- * Fails OPEN if a verify call itself errors, the critical is kept (better a
39
- * possible false positive than hiding a real critical on an infra hiccup).
63
+ * Guard against hallucinated findings before they're surfaced, WITHOUT silently
64
+ * dropping real ones on an imperfect quote:
65
+ * 1. Quote-grounding (deterministic, all findings): grade each finding's `evidence`
66
+ * against the file (exact + fuzzy see matchEvidence).
67
+ * 2. LLM verify (adversarial, in parallel) runs for a finding when EITHER:
68
+ * - its evidence is `absent` (any severity) the quote isn't grounded, but
69
+ * that alone doesn't make the finding false, so the verifier re-reads the
70
+ * real file (and nearby files) and judges the underlying problem; or
71
+ * - it's a `critical` (even if grounded) — a skeptical double-check.
72
+ * A finding is dropped ONLY when the verifier refutes it. `present`/`unknown`
73
+ * non-criticals are kept without an LLM call (the fast, cheap path).
74
+ * Fails OPEN — if a verify call itself errors, the finding is kept (better a
75
+ * possible false positive than hiding a real finding on an infra hiccup).
76
+ *
77
+ * This replaces the old "absent evidence → hard drop" rule, which was suppressing
78
+ * real findings whose natural evidence (a structural/absence bug, a cross-line
79
+ * quote, a slightly-wrong location) wasn't a verbatim substring.
40
80
  */
41
81
  export async function verifyFindings(handle, findings, cwd, onProgress) {
42
82
  const dropped = [];
43
83
  let cost = 0;
44
84
  const tokens = {};
45
- // Phase 1 — quote-grounding for every finding.
85
+ // Phase 1 — deterministic quote-grounding for every finding.
46
86
  const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
47
- const survivors = [];
87
+ // Decide which findings need an LLM check vs. can be kept directly.
88
+ const verdicts = new Map();
89
+ const toVerify = [];
48
90
  for (const { finding, presence } of checked) {
49
- if (presence === 'absent') {
50
- dropped.push({ finding, reason: 'quoted code not found in file (likely hallucinated)' });
51
- onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — quoted code not in ${finding.file}`);
91
+ if (presence === 'absent' || finding.severity === 'critical') {
92
+ toVerify.push({ finding, presence });
52
93
  }
53
94
  else {
54
- survivors.push(finding);
95
+ verdicts.set(finding, 'keep'); // grounded (or uncheckable) non-critical
55
96
  }
56
97
  }
57
- // Phase 2 — adversarial verify for surviving criticals, in parallel.
58
- const refuted = new Set();
59
- await Promise.all(survivors
60
- .filter(finding => finding.severity === 'critical')
61
- .map(async (finding, index) => {
98
+ // Phase 2 — LLM verify (parallel). Refuted drop; verified or errored → keep.
99
+ await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
62
100
  try {
63
101
  const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
64
102
  agent: VERIFIER_AGENT,
65
103
  system: buildVerifierSystem(),
66
- text: buildVerifierTask(finding),
104
+ text: buildVerifierTask(finding, { evidenceUngrounded: presence === 'absent' }),
67
105
  title: `verify-${index}`,
68
106
  maxWaitMs: VERIFY_TIMEOUT_MS,
69
107
  finalizeOnTimeout: true,
70
108
  }, parseVerdict);
71
109
  cost += verifyCost;
72
110
  addTokenUsage(tokens, verifyTokens);
73
- if (!value.verified) {
74
- refuted.add(finding);
75
- onProgress?.(` verify: dropped critical "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
111
+ if (value.verified) {
112
+ verdicts.set(finding, 'keep');
113
+ }
114
+ else {
115
+ verdicts.set(finding, 'drop');
116
+ dropped.push({ finding, reason: value.reason || 'refuted by verifier' });
117
+ onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
76
118
  }
77
119
  }
78
120
  catch (error) {
79
- // Fail open: keep the critical if verification itself failed.
80
- onProgress?.(` verify: could not verify critical "${finding.title}" (${errorMessage(error)}); keeping it`);
121
+ // Fail open: keep the finding if verification itself failed.
122
+ verdicts.set(finding, 'keep');
123
+ onProgress?.(` verify: could not verify "${finding.title}" (${errorMessage(error)}); keeping it`);
81
124
  }
82
125
  }));
83
- const kept = [];
84
- for (const finding of survivors) {
85
- if (refuted.has(finding)) {
86
- dropped.push({ finding, reason: 'refuted by verifier' });
87
- }
88
- else {
89
- kept.push(finding);
90
- }
91
- }
126
+ // Preserve original order.
127
+ const kept = findings.filter(finding => verdicts.get(finding) === 'keep');
92
128
  return { kept, dropped, cost, tokens };
93
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "typecheck": "tsc --noEmit",
29
29
  "dev": "bun run src/cli.ts",
30
30
  "test:unit": "bun test",
31
+ "release": "bash scripts/release.sh",
31
32
  "prepublishOnly": "rimraf build && tsc -p tsconfig.build.json"
32
33
  },
33
34
  "dependencies": {
@@ -31,14 +31,16 @@
31
31
  // HTML marker used to find + update the single PR comment. Keep it stable.
32
32
  "commentTag": "expo-ai-code-reviewer",
33
33
 
34
- // How model credentials are provided.
35
- // "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
36
- // omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
34
+ // How model credentials are provided. Default: a Claude Pro/Max subscription.
37
35
  // "oauth": tokenEnv holds a Claude Pro/Max OAuth token (from
38
36
  // `claude setup-token`); it's injected as a bearer credential.
37
+ // "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
38
+ // omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
39
+ // For OpenAI/GPT or another provider, omit `auth` and set REVIEWER_MODEL after an
40
+ // `opencode auth login` for that provider.
39
41
  "auth": {
40
- "mode": "api-key",
42
+ "mode": "oauth",
41
43
  "provider": "anthropic",
42
- "tokenEnv": "ANTHROPIC_API_KEY"
44
+ "tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
43
45
  }
44
46
  }
@@ -65,7 +65,7 @@ Return **only** a single fenced ```json code block, an object of this shape:
65
65
  "line": 142,
66
66
  "title": "short one-line summary",
67
67
  "rationale": "why this is a problem, with the concrete failure/exploit path",
68
- "evidence": "the exact line(s) of code you are flagging, copied VERBATIM",
68
+ "evidence": "one contiguous line of the flagged code, copied VERBATIM",
69
69
  "suggestion": "optional concrete fix, or omit"
70
70
  }
71
71
  ]
@@ -73,7 +73,9 @@ Return **only** a single fenced ```json code block, an object of this shape:
73
73
  ```
74
74
 
75
75
  `line` is the start line in the new version of the file, or `null` if not
76
- line-specific. `evidence` MUST be the flagged code copied **verbatim** from the
77
- file it is used to verify the finding, and a finding whose evidence isn't found
78
- in the file is discarded (don't paraphrase or invent it). If you have nothing to
79
- report, return `{ "findings": [] }`. Emit no prose outside the JSON block.
76
+ line-specific. `evidence` is used to help verify the finding, so make it easy to
77
+ locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning
78
+ multiple lines, no `…` elisions, no paraphrasing). For a structural/"missing" issue,
79
+ quote the single most relevant real line (e.g. the early `return` that skips the
80
+ handling). If you have nothing to report, return `{ "findings": [] }`. Emit no prose
81
+ outside the JSON block.
@@ -2,12 +2,13 @@ name: AI code review
2
2
 
3
3
  on:
4
4
  pull_request:
5
- types: [opened, synchronize, reopened]
5
+ types: [opened, synchronize, reopened, labeled, unlabeled]
6
6
 
7
- # Comment-only: needs to read the repo and write PR comments.
7
+ # Comment-only: read the repo, write PR comments (issue comments API).
8
8
  permissions:
9
9
  contents: read
10
10
  pull-requests: write
11
+ issues: write
11
12
 
12
13
  concurrency:
13
14
  group: ai-code-review-${{ github.event.pull_request.number }}
@@ -16,28 +17,53 @@ concurrency:
16
17
  jobs:
17
18
  review:
18
19
  runs-on: ubuntu-latest
20
+ # Opt-in per PR: only run when the `ai-review` label is present. Remove this
21
+ # line to review every PR automatically.
22
+ if: contains(join(github.event.pull_request.labels.*.name, ','), 'ai-review')
23
+ # Backstop so a stalled review fails fast instead of hanging.
24
+ timeout-minutes: 60
19
25
  # A reviewer failure must never fail the PR's checks.
20
26
  continue-on-error: true
21
27
  steps:
22
- - uses: actions/checkout@v4
28
+ - uses: actions/checkout@v5
23
29
  with:
24
- fetch-depth: 0
30
+ # Shallow is enough — the reviewer gets the diff from the API (`gh`).
31
+ fetch-depth: 1
25
32
 
26
- - uses: actions/setup-node@v4
33
+ # SECURITY: this workflow checks out the PR's code, including
34
+ # .expo-code-review/config.jsonc, whose auth.tokenEnv names the env var the CLI
35
+ # forwards as the model credential. Refuse to run unless it's the expected value
36
+ # (below / repo var ECR_EXPECTED_TOKEN_ENV) so a PR can't repoint it at another
37
+ # secret in the runner. Keep this in sync with auth.tokenEnv in config.jsonc.
38
+ - name: Guard config.jsonc tokenEnv
39
+ env:
40
+ EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'ANTHROPIC_OAUTH_API_KEY' }}
41
+ run: |
42
+ values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
43
+ count=$(printf '%s\n' "$values" | grep -c .)
44
+ if [ "$count" != "1" ] || [ "$values" != "$EXPECTED" ]; then
45
+ echo "::error::.expo-code-review/config.jsonc auth.tokenEnv must be \"$EXPECTED\" (found: \"${values:-none}\"). Refusing to run so a PR can't redirect which secret is forwarded to the model provider."
46
+ exit 1
47
+ fi
48
+
49
+ - uses: actions/setup-node@v5
27
50
  with:
28
51
  node-version: 24
52
+ # The reviewer runs via npx and never installs with a package manager, so
53
+ # disable setup-node's auto package-manager cache (its post step would try
54
+ # to save an empty cache and error).
55
+ package-manager-cache: false
29
56
 
30
57
  - name: Run AI review
31
- # Requires the `expo-code-review` package to be published to npm. Until
32
- # then, vendor the CLI or run it from a checkout instead of via npx.
33
- # npx installs the CLI and its bundled `opencode` binary and puts them on
34
- # PATH for this process.
35
- run: npx --yes expo-code-review@latest ci
58
+ # npx installs @expo/code-review-cli and its bundled `opencode` binary and
59
+ # puts them on PATH for this process. Pin @latest to a version to freeze it.
60
+ run: npx --yes -p "@expo/code-review-cli@latest" ecr ci
36
61
  continue-on-error: true
37
62
  env:
38
63
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39
- # OpenCode reads ANTHROPIC_API_KEY. Source it from a dedicated,
40
- # code-review-scoped secret. Swap for your provider's key/var.
41
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_FOR_CODE_REVIEW }}
42
- # Optional: override the model for every agent.
64
+ # Claude Pro/Max OAuth token (from `claude setup-token`) — the env var
65
+ # named by auth.tokenEnv in config.jsonc. Store it as a repo secret.
66
+ # (For an API key instead, set auth.mode "api-key" and pass that key here.)
67
+ ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
68
+ # Optional: override the model for every agent (uses your OpenCode login).
43
69
  REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}