@expo/code-review-cli 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
package/README.md CHANGED
@@ -74,7 +74,7 @@ Options (most to least common):
74
74
  | --- | --- |
75
75
  | `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
76
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. |
77
+ | `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
78
78
  | `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
79
79
  | `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
80
80
  | `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
@@ -101,14 +101,132 @@ is a ready example to adapt.
101
101
  | Command | What it does |
102
102
  | --- | --- |
103
103
  | `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
104
+ | `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
105
+ | `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
104
106
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
107
+ | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
105
108
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
106
- | `ecr doctor` | Check environment, config, and model credentials. |
109
+ | `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
110
+
111
+ Extra flags for monorepos: `review`/`ci` `--config-dir <dir>` (load config from an
112
+ alternate dir; also `ECR_CONFIG_DIR`), `ci --scopes a,b` (limit the fan-out to
113
+ named scopes), `ci --comment single|per-scope` (override the manifest).
107
114
 
108
115
  (When developing this repo itself, use `bun run src/cli.ts <command>`.)
109
116
 
110
117
  ---
111
118
 
119
+ ## Monorepos (routing manifest)
120
+
121
+ A monorepo can route different subtrees to different reviewer rosters from a single
122
+ infra-owned manifest. There is still **one workflow, one `ecr ci` process** per PR:
123
+ it reads the changed files once, assigns each to exactly one scope, reviews each
124
+ active scope over only its files, and renders one comment. Because it is a single
125
+ writer in a single process there is no comment/check race and no locking.
126
+
127
+ ```
128
+ your-monorepo/
129
+ .expo-code-review/
130
+ routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
131
+ config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
132
+ shared.md coordinator.md agents/
133
+ server/
134
+ www/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # www team
135
+ website/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # website team
136
+ .github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
137
+ ```
138
+
139
+ ```jsonc
140
+ // .expo-code-review/routing.jsonc
141
+ {
142
+ // Central guardrails every scope inherits and CANNOT override.
143
+ "defaults": {
144
+ // The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
145
+ "auth": { "mode": "oauth", "provider": "anthropic", "tokenEnv": "ANTHROPIC_OAUTH_API_KEY" },
146
+ "enforceAgents": ["security"], // always runs on every scope, roster or not
147
+ "commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
148
+ },
149
+ "comment": "single", // "single" = one aggregated comment (default) | "per-scope"
150
+ // Ordered; the LAST matching scope wins per changed file (CODEOWNERS discipline).
151
+ "scopes": [
152
+ { "name": "default", "paths": ["**/*"], "config": "." },
153
+ { "name": "server-www", "paths": ["server/www/**"], "config": "server/www" },
154
+ { "name": "server-website", "paths": ["server/website/**"], "config": "server/website" }
155
+ ]
156
+ }
157
+ ```
158
+
159
+ ```jsonc
160
+ // server/www/.expo-code-review/config.jsonc (the www team owns this)
161
+ {
162
+ // NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
163
+ "model": "anthropic/claude-sonnet-5",
164
+ "policy": { "includeSuggestions": false },
165
+ "noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
166
+ // shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
167
+ }
168
+ ```
169
+
170
+ - **Path glob dialect** — `**` crosses `/`, `*` matches within a segment. Keep a
171
+ `**/*` catch-all scope so no changed file goes unreviewed (`ecr doctor` flags a
172
+ coverage gap otherwise). Scopes are ordered and the **last** match wins, so put
173
+ broad scopes first and specific ones after (CODEOWNERS/Renovate discipline).
174
+ - **Comment modes** — `single` posts one aggregated comment (a scope summary table
175
+ + a collapsed `<details>` per scope) under the existing marker; `per-scope` posts
176
+ one cleanly-namespaced comment per scope. A scope with zero matched files gets its
177
+ stale comment deleted.
178
+ - **Scoped flags** — `ecr ci --scopes a,b` limits the fan-out; `ecr ci --comment
179
+ single|per-scope` overrides the manifest; `ecr review --scope <name>` runs one
180
+ scope locally; `ecr review`/`ecr ci --config-dir <dir>` (or `ECR_CONFIG_DIR`)
181
+ load config from an alternate directory; `ecr doctor --list-scopes` prints the
182
+ scope table. **`--config-dir` designates an alternate ROOT config dir: both
183
+ `config.jsonc` and `routing.jsonc` are read from it, so the root config and its
184
+ manifest always travel together. Scope `config` paths stay repo-root-relative —
185
+ the override swaps the root config/manifest against the *real* scope tree, it
186
+ does not relocate the scopes themselves.**
187
+ - **Passes budget** — `defaults`-level `budget` bounds total review time:
188
+ `totalPassesMinutes` (default 32) is split across active scopes (which run
189
+ sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
190
+ single scope still gets a workable window. When enough scopes are active that the
191
+ floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
192
+ doctor` flags the worst case (`scopes × floor` vs total) — raise the workflow
193
+ `timeout-minutes` or trim scopes.
194
+ - **Adoption is incremental** — with no `routing.jsonc`, behavior is exactly as
195
+ before (single config). Add the manifest with just a default scope → still one
196
+ comment, identical behavior. Land per-team scope dirs one at a time; everything
197
+ else keeps hitting the default scope.
198
+
199
+ ### Security
200
+
201
+ - **auth is locked to the root.** `tokenEnv` (which env var becomes the model
202
+ credential) is honored in exactly one place: the root `config.jsonc` or
203
+ `routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`
204
+ **fails to parse** (Zod-level rejection), and the CI guard step independently
205
+ sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
206
+ to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
207
+ `ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
208
+ choose *which roster* reviews a file, never *which secret* is sent.
209
+ - **enforceAgents can't be weakened.** Agents listed in `defaults.enforceAgents`
210
+ (e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
211
+ root roster — a scope defining a same-id agent gets the root one, so a team can't
212
+ shadow the enforced reviewer with a weaker version on its own subtree.
213
+ - **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
214
+ auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
215
+ and routing globs for its own advisory review — that only steers what the
216
+ comment-only reviewer says about that PR. What a PR can **never** do is touch
217
+ credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
218
+ `ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
219
+ against whatever ref is checked out. A `/review`-command workflow that checks out
220
+ only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
221
+ resolving config from the base ref on the auto path as well is on the
222
+ [roadmap](./ROADMAP.md).
223
+
224
+ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
225
+ (the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
226
+ (each team owns only its own scope dir). Rerouting globs is gated behind infra review.
227
+
228
+ ---
229
+
112
230
  <details>
113
231
  <summary><b>How it works</b></summary>
114
232
 
@@ -135,6 +253,49 @@ and applies Anthropic prompt caching automatically.
135
253
 
136
254
  </details>
137
255
 
256
+ <details>
257
+ <summary><b>Tokens, cost &amp; prompt caching</b></summary>
258
+
259
+ Every run reports what it spent and how much of it was served from the prompt
260
+ cache, in three places:
261
+
262
+ - **Job log (stderr)** — one line at the end of the run:
263
+ `Token usage — input …, output …, cache read …, cache write … (cost $…)`.
264
+ - **GitHub Actions step summary** — a per-pass table (one row per agent, plus the
265
+ cross-cutting pass, coordinator, and verifier), the run's cache hit rate, and
266
+ the exact comment that was posted. The PR comment is updated in place on every
267
+ run, so the step summary is where past runs' comments remain readable.
268
+ - **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
269
+ a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
270
+ per-agent findings, coverage notes, and what the verifier dropped.
271
+
272
+ **How the caching works.** Anthropic prompt caching is a *prefix match*: the
273
+ provider caches the rendered prompt up to a marker, and any byte change anywhere
274
+ in that prefix invalidates everything after it. The reviewer is laid out so the
275
+ prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
276
+ byte-identical for every chunk an agent reviews, while the volatile parts (the
277
+ diff, file lists, PR metadata) travel in the user message *after* the prefix and
278
+ never touch it. The first call per agent **writes** the cache (`cache write`,
279
+ billed ~1.25× the input price); every later chunk in the run **reads** it
280
+ (`cache read`, ~0.1× the input price). Cache entries live ~5 minutes and are
281
+ refreshed on use, which comfortably covers a run's concurrent calls.
282
+
283
+ **Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
284
+ share of prompt tokens served from cache instead of being reprocessed at full
285
+ price. Multi-chunk reviews should show a high rate; single-chunk reviews mostly
286
+ show writes (there is nothing to re-read within the run).
287
+
288
+ **Keeping hits high:**
289
+
290
+ - Keep `shared.md` and `agents/*.md` stable. Any edit writes a new prefix — one
291
+ extra cache write per agent on the next run, then it is warm again. Never put
292
+ varying text (dates, PR numbers) into prompt files.
293
+ - Very short prompts may show `cache read 0`: prompts below the model's minimum
294
+ cacheable size (~1–4K tokens depending on the model) are silently not cached.
295
+ That is expected, not a bug.
296
+
297
+ </details>
298
+
138
299
  <details>
139
300
  <summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
140
301
 
@@ -176,6 +337,9 @@ per-repo `noise.additionalIgnores`.
176
337
  "policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
177
338
  "chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
178
339
  "noise": { "additionalIgnores": ["packages/*/build/**"] },
340
+ "review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
341
+ "label": "ai-review", // (default, except ai-review:skip) or
342
+ "skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
179
343
  "breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
180
344
  "commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
181
345
  "auth": { "mode": "oauth", "provider": "anthropic",
@@ -223,6 +387,14 @@ change which model reviewed your code. Use an explicit override instead.
223
387
  un-reducible pass reports a coverage gap — and it is always reported, never silent.
224
388
  - **Parse failures are retried** (same session, then once in a bounded fresh
225
389
  session) — separate from the timeout path.
390
+ - **Transient API errors are retried** (bounded backoff on 429/5xx/network) —
391
+ distinct from both the timeout path (abandon) and the parse path; a one-off blip
392
+ no longer drops an entire pass.
393
+ - **Auth failures surface once, and fail fast** — `ecr` checks the configured
394
+ provider's credential at startup and stops with one clear message if it's missing
395
+ (rather than failing every pass); a credential rejected mid-run (401/403)
396
+ collapses into a single actionable coverage note pointing at
397
+ `auth.tokenEnv`/`REVIEWER_MODEL`.
226
398
  - **A failed run never reads as "Approve"** — all passes fail → "could not
227
399
  complete"; some fail → never a clean approve, and coverage-reduced.
228
400
  - **The coordinator can't sink the run** — if consolidation fails, findings are
@@ -243,9 +415,12 @@ line: **comments = one-shot actions, labels = persistent configuration.**
243
415
  - **command workflow** — one-shot `/review` comments (maintainers): `/review`
244
416
  (router picks agents), `/review all`, `/review correctness security`. Never
245
417
  changes configuration.
246
- - **auto workflow** — continuous review, configured by **labels**: `ai-review`
247
- (router), `ai-review:all`, `ai-review:<agent>` (e.g. `ai-review:security`;
248
- combine to widen), `ai-review:skip` (opt-out).
418
+ - **auto workflow** — continuous review. **Which PRs get reviewed is set in
419
+ `config.jsonc` `review.trigger`**: `"all"` (default) reviews every PR except
420
+ those labeled `ai-review:skip`; `"label"` reviews only PRs labeled `ai-review`
421
+ (or `ai-review:<agent>` to scope agents). `ecr ci` self-gates on this policy;
422
+ the workflow's `if:` is an optional coarse gate layered on top. `ai-review:skip`
423
+ always wins and is write-gated, so a contributor can't opt their own PR out.
249
424
  - **dismiss workflow** — `/dismiss <id> [… -- reason]` / `/undismiss <id>`
250
425
  (maintainers). Each finding shows a short `` `id:…` ``. Dismissal is a **display
251
426
  filter only** — the reviewer still analyzes everything, and a `critical`/`secrets`
@@ -263,7 +438,9 @@ as the published package via `npx`, so no PR-controlled code is built.
263
438
  Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
264
439
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
265
440
  usage (incl. prompt-cache read/write counts) — for auditing and measuring
266
- cost/latency/cache reuse over time.
441
+ cost/latency/cache reuse over time. The same totals are printed as a one-line
442
+ summary to the terminal / CI job log at the end of each run, so cache reuse is
443
+ visible even in CI (where the run log is ephemeral).
267
444
 
268
445
  </details>
269
446
 
package/build/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { ciCommand } from './commands/ci.js';
3
- import { dismissCommand } from './commands/dismiss.js';
4
- import { doctorCommand } from './commands/doctor.js';
5
- import { initCommand } from './commands/init.js';
6
- import { reviewCommand } from './commands/review.js';
2
+ import { ciCommand } from "./commands/ci.js";
3
+ import { dismissCommand } from "./commands/dismiss.js";
4
+ import { doctorCommand } from "./commands/doctor.js";
5
+ import { initCommand } from "./commands/init.js";
6
+ import { reviewCommand } from "./commands/review.js";
7
+ import { verifyConfigCommand } from "./commands/verify-config.js";
7
8
  const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
8
9
 
9
10
  Usage:
@@ -11,41 +12,47 @@ Usage:
11
12
  ecr ci Review the current PR and post a comment (GitHub Actions).
12
13
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
13
14
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
14
- ecr init [--with-workflow] [--force] Scaffold .expo-code-review/ in this repo.
15
- ecr doctor Check environment, config, and credentials.
15
+ ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
16
+ ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
17
+ ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
16
18
 
17
19
  Agents live in each repo under .expo-code-review/. This CLI is the engine.
20
+
21
+ Monorepos: .expo-code-review/routing.jsonc routes paths to per-team scopes (see README).
18
22
  `;
19
23
  async function main() {
20
24
  const [, , sub, ...rest] = process.argv;
21
- if (sub === '-h' || sub === '--help' || sub === 'help') {
25
+ if (sub === "-h" || sub === "--help" || sub === "help") {
22
26
  process.stdout.write(USAGE);
23
27
  return;
24
28
  }
25
29
  // No subcommand (or a leading flag) defaults to `review`.
26
- if (!sub || sub.startsWith('-')) {
30
+ if (!sub || sub.startsWith("-")) {
27
31
  await reviewCommand(process.argv.slice(2));
28
32
  return;
29
33
  }
30
34
  switch (sub) {
31
- case 'review':
35
+ case "review":
32
36
  await reviewCommand(rest);
33
37
  break;
34
- case 'ci':
38
+ case "ci":
35
39
  await ciCommand(rest);
36
40
  break;
37
- case 'dismiss':
38
- await dismissCommand(rest, 'add');
41
+ case "dismiss":
42
+ await dismissCommand(rest, "add");
39
43
  break;
40
- case 'undismiss':
41
- await dismissCommand(rest, 'remove');
44
+ case "undismiss":
45
+ await dismissCommand(rest, "remove");
42
46
  break;
43
- case 'init':
47
+ case "init":
44
48
  await initCommand(rest);
45
49
  break;
46
- case 'doctor':
50
+ case "doctor":
47
51
  await doctorCommand(rest);
48
52
  break;
53
+ case "verify-config":
54
+ await verifyConfigCommand(rest);
55
+ break;
49
56
  default:
50
57
  process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
51
58
  process.exitCode = 2;