@expo/code-review-cli 0.14.0 → 0.15.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 +63 -22
- package/build/commands/ci.js +18 -3
- package/build/commands/dismiss.js +2 -0
- package/build/commands/doctor.js +3 -3
- package/build/commands/init.js +6 -6
- package/build/commands/post-review.js +1 -0
- package/build/commands/review.js +3 -0
- package/build/commands/verify-config.js +3 -5
- package/build/config/load.js +44 -9
- package/build/config/schema.js +20 -0
- package/build/core/config-refs.js +12 -4
- package/build/core/deferred-review.js +4 -0
- package/build/core/render.js +96 -0
- package/build/core/responses.js +35 -2
- package/build/core/schema.js +10 -1
- package/build/reporters/github.js +585 -7
- package/build/research-mcp/direct-fetch.js +16 -0
- package/build/research-mcp/providers.js +44 -0
- package/build/research-mcp/remote-search.js +32 -0
- package/build/research-mcp/server.js +2 -2
- package/build/research-mcp/types.js +9 -0
- package/package.json +1 -1
- package/templates/atlantis.yml +3 -1
- package/templates/command.yml +3 -1
- package/templates/config.jsonc +16 -0
- package/templates/workflow.yml +3 -1
package/README.md
CHANGED
|
@@ -4,7 +4,10 @@ A config-driven, multi-agent AI code reviewer. Specialist agents review a diff i
|
|
|
4
4
|
parallel; a coordinator consolidates their findings into one structured review.
|
|
5
5
|
The same engine runs locally (advisory) and in CI (posts one PR comment). The CLI
|
|
6
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.
|
|
7
|
+
`.expo-agents/code-review/`, so behavior is configured per-repo, not baked in.
|
|
8
|
+
(Repos set up before 0.15 keep their `.expo-code-review/` directory: every command
|
|
9
|
+
looks for the new name first and falls back to the old one, and `ecr init` reuses
|
|
10
|
+
an existing legacy directory rather than starting a second one.)
|
|
8
11
|
|
|
9
12
|
> **Status: experimental.** Comment-only and non-blocking — it never blocks a merge
|
|
10
13
|
> and never auto-approves. See [`ROADMAP.md`](./ROADMAP.md).
|
|
@@ -94,7 +97,7 @@ here ([LLP 0001](./llp/0001-trust-model.principles.md)) is built around that:
|
|
|
94
97
|
## Usage
|
|
95
98
|
|
|
96
99
|
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
|
|
97
|
-
binary once installed). On a repo that already has `.expo-code-review/` set up,
|
|
100
|
+
binary once installed). On a repo that already has `.expo-agents/code-review/` set up,
|
|
98
101
|
getting model credentials for local runs is one command:
|
|
99
102
|
|
|
100
103
|
```bash
|
|
@@ -108,7 +111,7 @@ package.
|
|
|
108
111
|
### First-time setup
|
|
109
112
|
|
|
110
113
|
```bash
|
|
111
|
-
# 1. Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
114
|
+
# 1. Scaffold .expo-agents/code-review/ + a CI workflow (--no-workflow to skip)
|
|
112
115
|
npx @expo/code-review-cli init
|
|
113
116
|
# 2. Get model credentials — guided; prints the export lines for your shell config
|
|
114
117
|
npx @expo/code-review-cli setup-auth
|
|
@@ -142,7 +145,7 @@ ecr review --pr 123
|
|
|
142
145
|
ecr review --pr 123 --post
|
|
143
146
|
# Preview once, save the exact result, and post it later without another model run
|
|
144
147
|
ecr review --repo owner/repo --pr 123 --save-review --json
|
|
145
|
-
ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
|
|
148
|
+
ecr post-review --artifact .expo-agents/code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
|
|
146
149
|
```
|
|
147
150
|
|
|
148
151
|
Options (most to least common):
|
|
@@ -178,7 +181,7 @@ is a ready example to adapt.
|
|
|
178
181
|
|
|
179
182
|
| Command | What it does |
|
|
180
183
|
| --- | --- |
|
|
181
|
-
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
184
|
+
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-agents/code-review/` (config, agents, prompts) + a CI workflow. |
|
|
182
185
|
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
|
183
186
|
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
|
|
184
187
|
| `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT/Claude sign-in and/or API keys), printing the `export` lines for your shell config. |
|
|
@@ -215,7 +218,7 @@ Markdown, `//` in JSONC):
|
|
|
215
218
|
|
|
216
219
|
A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
|
|
217
220
|
never a line number (line numbers rot without any signal). The check is strict on
|
|
218
|
-
purpose: any backticked token in `.expo-code-review/` that names something that
|
|
221
|
+
purpose: any backticked token in `.expo-agents/code-review/` that names something that
|
|
219
222
|
exists in the repo **must** be a ref, because stale citations are exactly the ones
|
|
220
223
|
nobody annotated. Mark the rare false positive once with
|
|
221
224
|
`<!-- @ref-ignore knex.raw() -->`. Refs are always repo-root-relative, including in
|
|
@@ -274,8 +277,8 @@ The boundary, in brief:
|
|
|
274
277
|
many discovery and page fetches, so each call reports its own request ledger and
|
|
275
278
|
the run reports totals.
|
|
276
279
|
- **Root-only in routed monorepos** (it starts a host process); scope configs
|
|
277
|
-
cannot alter it.
|
|
278
|
-
|
|
280
|
+
cannot alter it. Research results are fetched and audited per run; cached review
|
|
281
|
+
results remain keyed by the ordinary trusted review inputs and make no network call.
|
|
279
282
|
|
|
280
283
|
Full detail — providers, query grammar, `fetch_platform_doc` modes, provenance and
|
|
281
284
|
citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
|
|
@@ -291,18 +294,18 @@ and no locking.
|
|
|
291
294
|
|
|
292
295
|
```
|
|
293
296
|
your-monorepo/
|
|
294
|
-
.expo-code-review/
|
|
297
|
+
.expo-agents/code-review/
|
|
295
298
|
routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
|
|
296
299
|
config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
|
|
297
300
|
shared.md coordinator.md agents/
|
|
298
301
|
apps/
|
|
299
|
-
api/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
|
|
300
|
-
web/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
|
|
302
|
+
api/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # api team
|
|
303
|
+
web/.expo-agents/code-review/{config.jsonc(NO auth),coordinator.md,agents/} # web team
|
|
301
304
|
.github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
|
|
302
305
|
```
|
|
303
306
|
|
|
304
307
|
```jsonc
|
|
305
|
-
// .expo-code-review/routing.jsonc
|
|
308
|
+
// .expo-agents/code-review/routing.jsonc
|
|
306
309
|
{
|
|
307
310
|
// Central guardrails every scope inherits and CANNOT override.
|
|
308
311
|
"defaults": {
|
|
@@ -322,7 +325,7 @@ your-monorepo/
|
|
|
322
325
|
```
|
|
323
326
|
|
|
324
327
|
```jsonc
|
|
325
|
-
// apps/api/.expo-code-review/config.jsonc (the api team owns this)
|
|
328
|
+
// apps/api/.expo-agents/code-review/config.jsonc (the api team owns this)
|
|
326
329
|
{
|
|
327
330
|
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
|
328
331
|
"model": "anthropic/claude-sonnet-5",
|
|
@@ -378,8 +381,8 @@ Enforced in code and by an independent CI guard step, not by convention:
|
|
|
378
381
|
itself, so even a custom workflow that checks out the head still gets
|
|
379
382
|
base-commit configuration.
|
|
380
383
|
|
|
381
|
-
Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`,
|
|
382
|
-
`/apps/api/.expo-code-review/ @your-api-team`. Full detail:
|
|
384
|
+
Enforce ownership with CODEOWNERS: `/.expo-agents/code-review/routing.jsonc @your-infra`,
|
|
385
|
+
`/apps/api/.expo-agents/code-review/ @your-api-team`. Full detail:
|
|
383
386
|
[LLP 0006](./llp/0006-config-schema-loading-routing.explainer.md) (routing) and
|
|
384
387
|
[LLP 0001](./llp/0001-trust-model.principles.md) (trust model).
|
|
385
388
|
|
|
@@ -396,7 +399,7 @@ Enforce ownership with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra
|
|
|
396
399
|
- **Chunking** — small PRs run in a single pass; large PRs are split into chunks
|
|
397
400
|
bounded by changed lines, plus one combined **cross-cutting pass** that looks
|
|
398
401
|
for issues spanning multiple changed files across every agent's concern.
|
|
399
|
-
- **Agents** — every `.md` file in `.expo-code-review/agents/` is an agent. They
|
|
402
|
+
- **Agents** — every `.md` file in `.expo-agents/code-review/agents/` is an agent. They
|
|
400
403
|
run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
|
|
401
404
|
- **Coordinator** — a single pass that dedupes, re-judges severity, and produces
|
|
402
405
|
the final `{ decision, findings, summary }`.
|
|
@@ -426,7 +429,7 @@ Every run reports what it spent and how much was served from the prompt cache, i
|
|
|
426
429
|
three places: one `Token usage — …` line in the job log, a per-pass table + cache
|
|
427
430
|
hit rate in the GitHub Actions step summary (which also preserves each run's posted
|
|
428
431
|
comment, since the PR comment is updated in place), and one JSON line per run in
|
|
429
|
-
`.expo-code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
|
|
432
|
+
`.expo-agents/code-review/.runs/reviews.jsonl` (uploaded as a CI artifact) with per-pass
|
|
430
433
|
tokens, raw per-agent findings, bounded reviewer traces, and coverage notes.
|
|
431
434
|
|
|
432
435
|
Each reviewer can also return a compact trace (up to three concrete checks and two
|
|
@@ -452,10 +455,10 @@ cacheable size (~1–4K tokens) show `cache read 0` — expected, not a bug.
|
|
|
452
455
|
</details>
|
|
453
456
|
|
|
454
457
|
<details>
|
|
455
|
-
<summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
|
|
458
|
+
<summary><b>Configuration — <code>.expo-agents/code-review/</code></b></summary>
|
|
456
459
|
|
|
457
460
|
```
|
|
458
|
-
.expo-code-review/
|
|
461
|
+
.expo-agents/code-review/
|
|
459
462
|
config.jsonc # model, policy, noise, auth, break-glass, comment tag
|
|
460
463
|
shared.md # instructions prepended to every agent (optional)
|
|
461
464
|
coordinator.md # the consolidation prompt (required)
|
|
@@ -480,7 +483,7 @@ temperature: 0.1
|
|
|
480
483
|
```
|
|
481
484
|
|
|
482
485
|
For a real-world example, see eas-cli's
|
|
483
|
-
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
486
|
+
[`.expo-agents/code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
484
487
|
— correctness/security/consistency agents, a stronger model for security, and
|
|
485
488
|
per-repo `noise.additionalIgnores`.
|
|
486
489
|
|
|
@@ -651,6 +654,44 @@ the defaults are asymmetric: [LLP 0011](./llp/0011-author-feedback.explainer.md)
|
|
|
651
654
|
|
|
652
655
|
</details>
|
|
653
656
|
|
|
657
|
+
<details>
|
|
658
|
+
<summary><b>Inline PR comments (findings on the diff line)</b></summary>
|
|
659
|
+
|
|
660
|
+
Off by default. When enabled, each finding anchored to a line that is actually in
|
|
661
|
+
the PR's diff is posted as an inline review comment on that line, carrying the
|
|
662
|
+
full rationale, sources, and suggestion. The main comment still lists every
|
|
663
|
+
finding, but an inlined one renders short — title, links, category, id, and a
|
|
664
|
+
one-line rationale — plus a `💬 inline comment` link to the thread. The main
|
|
665
|
+
comment remains the single durable state store: `/dismiss`, embedded state, and
|
|
666
|
+
reply adjudication are unchanged.
|
|
667
|
+
|
|
668
|
+
```jsonc
|
|
669
|
+
"inline": {
|
|
670
|
+
"enabled": false,
|
|
671
|
+
"maxComments": 20 // per PR (single comment mode) / per scope (per-scope mode)
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Behavior notes:
|
|
676
|
+
|
|
677
|
+
- **Threads converge, never churn.** Each inline comment carries a per-finding
|
|
678
|
+
marker and is patched in place across re-reviews. The cap is sticky: a live
|
|
679
|
+
thread is never evicted to create a new one. A finding that leaves the set is
|
|
680
|
+
stubbed if humans replied in its thread (never deleted), deleted only when bare.
|
|
681
|
+
- **Fail-safe teardown.** A failed run, a `--scopes` partial run, or a truncated
|
|
682
|
+
aggregate never tears threads down — teardown only runs when the target set is
|
|
683
|
+
provably complete. Creates are skipped (and logged) when the PR head moved
|
|
684
|
+
during the review.
|
|
685
|
+
- **Replies on inline threads count as author feedback.** Replying in a thread
|
|
686
|
+
matches the reply to that finding structurally; clearing still requires the
|
|
687
|
+
`id:…` token in the replier's own words, same as main-comment replies.
|
|
688
|
+
- **Notification fan-out.** The first enabled run on a PR sends one notification
|
|
689
|
+
per created inline comment; `maxComments` bounds it.
|
|
690
|
+
- **Disabling later** leaves existing threads up (the sync no longer runs); a
|
|
691
|
+
comment-mode switch — or the reporter's clear path — stubs/removes them.
|
|
692
|
+
|
|
693
|
+
</details>
|
|
694
|
+
|
|
654
695
|
<details>
|
|
655
696
|
<summary><b>External context (--context-file)</b></summary>
|
|
656
697
|
|
|
@@ -680,7 +721,7 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
|
|
|
680
721
|
<details>
|
|
681
722
|
<summary><b>Run logs</b></summary>
|
|
682
723
|
|
|
683
|
-
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
724
|
+
Each run appends a JSON line to `.expo-agents/code-review/.runs/reviews.jsonl` with the
|
|
684
725
|
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
685
726
|
usage (incl. prompt-cache read/write counts) — for auditing and measuring
|
|
686
727
|
cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
|
|
@@ -689,7 +730,7 @@ one-line summary to the terminal / CI job log at the end of each run, so cache r
|
|
|
689
730
|
is visible even in CI (where the run log is ephemeral).
|
|
690
731
|
|
|
691
732
|
`ecr review --save-review` additionally writes a versioned artifact under
|
|
692
|
-
`.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
|
|
733
|
+
`.expo-agents/code-review/.runs/deferred/` with owner-only permissions. It contains the
|
|
693
734
|
verified final review and bounded feedback metadata, but no credential. `ecr
|
|
694
735
|
post-review` schema-validates it and refuses to post if its explicit repo/PR, live
|
|
695
736
|
head commit, or local comment-policy fingerprint no longer matches.
|
package/build/commands/ci.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ref LLP 0007#ecr-ci-the-trusted-root-run — fails CLOSED: trusted-root materialization failure never falls back to reading the checkout
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { configDirFor, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
|
|
5
5
|
import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
6
6
|
import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
|
|
7
7
|
import { errorMessage, publicFailureReason } from "../core/util.js";
|
|
@@ -390,7 +390,7 @@ function resolveStackConfirm(stack, noStackAware) {
|
|
|
390
390
|
* `.expo-code-review/.runs/reviews.jsonl` from the workspace as an artifact.
|
|
391
391
|
*/
|
|
392
392
|
function workspaceRunsDir(cwd) {
|
|
393
|
-
return path.join(cwd,
|
|
393
|
+
return path.join(configDirFor(cwd), ".runs");
|
|
394
394
|
}
|
|
395
395
|
/**
|
|
396
396
|
* The pre-routing single-config path. Kept byte-for-byte equivalent so that with no
|
|
@@ -446,6 +446,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
446
446
|
breakGlassMarker: config.breakGlassMarker,
|
|
447
447
|
cwd,
|
|
448
448
|
feedback: config.feedback,
|
|
449
|
+
inline: config.inline,
|
|
449
450
|
headSha,
|
|
450
451
|
});
|
|
451
452
|
try {
|
|
@@ -554,6 +555,10 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
554
555
|
findings: [],
|
|
555
556
|
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${publicFailureReason(error)}`,
|
|
556
557
|
incomplete: [],
|
|
558
|
+
// A failed run's empty findings list proves nothing: this flag is what keeps
|
|
559
|
+
// the inline sync additive-only (no thread teardown) — and it renders the
|
|
560
|
+
// accurate "No review" decision label.
|
|
561
|
+
couldNotComplete: true,
|
|
557
562
|
});
|
|
558
563
|
}
|
|
559
564
|
catch (postError) {
|
|
@@ -568,6 +573,10 @@ function failureReview(scopeName, reason) {
|
|
|
568
573
|
findings: [],
|
|
569
574
|
summary: `⚠️ The AI reviewer failed to run for scope **${scopeName}**, so those changes were **not** reviewed:\n\n> ${reason}`,
|
|
570
575
|
incomplete: [],
|
|
576
|
+
// Empty-but-not-clean: keeps the inline sync additive-only for the whole
|
|
577
|
+
// aggregate (a failed scope's findings may still be live on the PR) and renders
|
|
578
|
+
// the accurate "No review" label in the scope table.
|
|
579
|
+
couldNotComplete: true,
|
|
571
580
|
};
|
|
572
581
|
}
|
|
573
582
|
// @ref LLP 0007#routed-ci-fan-out [implements] — root tag wins; sequential per-scope budgets; partial --scopes merges prior state
|
|
@@ -704,6 +713,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
704
713
|
// Root-only feedback config (see loadScopeConfig): lets a reporter posting with
|
|
705
714
|
// no explicit records match replies itself (annotate mode) at report time.
|
|
706
715
|
feedback: rootConfig.feedback,
|
|
716
|
+
inline: rootConfig.inline,
|
|
707
717
|
headSha,
|
|
708
718
|
});
|
|
709
719
|
// comment:'single' mode: every active scope's feedback seam AND the final
|
|
@@ -900,7 +910,12 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
900
910
|
const aggFeedback = feedbackNeedsRunSeam(rootConfig.feedback)
|
|
901
911
|
? mergeAggregateFeedback(results, finalResults, aggState?.feedback ?? [], rootConfig.feedback, headSha, aggState?.pins)
|
|
902
912
|
: undefined;
|
|
903
|
-
|
|
913
|
+
// A --scopes partial run carries the other scopes out of (possibly truncated)
|
|
914
|
+
// embedded state, so its target set is incomplete: inline teardown must not
|
|
915
|
+
// run (failed scopes are handled inside reportAggregate itself).
|
|
916
|
+
await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback, {
|
|
917
|
+
inlineTeardown: !scopesFilter,
|
|
918
|
+
});
|
|
904
919
|
}
|
|
905
920
|
// Clean up any per-scope comments from a previous per-scope run. A partial run
|
|
906
921
|
// only ever touches the named scopes' comments.
|
|
@@ -84,6 +84,8 @@ export async function dismissCommand(argv, mode) {
|
|
|
84
84
|
// feedback policy in force now — without it a reply-cleared finding would be
|
|
85
85
|
// un-hidden by an unrelated /dismiss.
|
|
86
86
|
feedback: config.feedback,
|
|
87
|
+
// Lets the dismissal stub/revive the finding's inline thread (additive-only).
|
|
88
|
+
inline: config.inline,
|
|
87
89
|
});
|
|
88
90
|
const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
|
|
89
91
|
if (mode === "add") {
|
package/build/commands/doctor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ref LLP 0007#doctor-and-setup-auth — a preflight that must never disagree with a real run
|
|
2
|
-
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
|
|
2
|
+
import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, CONFIG_DIRNAME, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
|
|
3
3
|
import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { setupAuthCommand } from "./setup-auth.js";
|
|
@@ -198,7 +198,7 @@ export async function doctorCommand(argv = []) {
|
|
|
198
198
|
info("gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)");
|
|
199
199
|
}
|
|
200
200
|
if (!hasConfig(root)) {
|
|
201
|
-
line(false, `no ${
|
|
201
|
+
line(false, `no ${CONFIG_DIRNAME}/config.jsonc (run \`ecr init\`)`);
|
|
202
202
|
}
|
|
203
203
|
else {
|
|
204
204
|
try {
|
|
@@ -394,7 +394,7 @@ async function listScopes(root) {
|
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
396
|
if (!manifest) {
|
|
397
|
-
process.stdout.write(`No ${
|
|
397
|
+
process.stdout.write(`No ${CONFIG_DIRNAME}/routing.jsonc — run \`ecr init --monorepo\`.\n`);
|
|
398
398
|
process.exitCode = 0;
|
|
399
399
|
return;
|
|
400
400
|
}
|
package/build/commands/init.js
CHANGED
|
@@ -3,14 +3,14 @@ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { CONFIG_DIRNAME } from "../config/load.js";
|
|
6
|
+
import { CONFIG_DIRNAME, configDirFor } from "../config/load.js";
|
|
7
7
|
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
8
8
|
import { RoutingScopeSchema } from "../config/schema.js";
|
|
9
9
|
import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
|
|
10
10
|
import { repoRoot } from "../core/exec.js";
|
|
11
11
|
import { errorMessage } from "../core/util.js";
|
|
12
12
|
const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
|
|
13
|
-
const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
|
|
13
|
+
const USAGE = `ecr init — scaffold .expo-agents/code-review/ in the current repo (a legacy .expo-code-review/ is reused)
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
16
|
ecr init [--no-workflow] [--force] Scaffold the root config (+ CI workflow)
|
|
@@ -19,8 +19,8 @@ Usage:
|
|
|
19
19
|
register it in the root routing.jsonc
|
|
20
20
|
|
|
21
21
|
Options:
|
|
22
|
-
--monorepo Also write
|
|
23
|
-
--scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
|
|
22
|
+
--monorepo Also write routing.jsonc in the setup dir (routing manifest)
|
|
23
|
+
--scope <dir> Scaffold <dir>/.expo-agents/code-review/ (no auth) + add a scope entry
|
|
24
24
|
--no-workflow Skip writing the CI workflows (review, command, and dismiss
|
|
25
25
|
under .github/workflows/)
|
|
26
26
|
--token-env <name[,name…]>
|
|
@@ -76,7 +76,7 @@ async function scaffold(argv) {
|
|
|
76
76
|
throw new Error("--token-env customizes the CI workflows; drop it or remove --no-workflow");
|
|
77
77
|
}
|
|
78
78
|
const root = (await repoRoot()) ?? process.cwd();
|
|
79
|
-
const configDir =
|
|
79
|
+
const configDir = configDirFor(root); // an existing legacy .expo-code-review/ is kept, never doubled
|
|
80
80
|
// A non-default --token-env only takes effect through the review workflows,
|
|
81
81
|
// but existing workflow files are skipped (not rewritten) without --force —
|
|
82
82
|
// the flag would silently never reach CI while the next steps claim the
|
|
@@ -201,7 +201,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
|
|
|
201
201
|
throw new Error(`scope dir "${scopeDir}" yields an invalid scope entry (${parsed.error.issues[0]?.message}); ` +
|
|
202
202
|
`rename the directory or add the scope to ${CONFIG_DIRNAME}/${ROUTING_FILENAME} manually`);
|
|
203
203
|
}
|
|
204
|
-
const configDir = path.join(root, scopeDir
|
|
204
|
+
const configDir = configDirFor(path.join(root, scopeDir));
|
|
205
205
|
await mkdir(configDir, { recursive: true });
|
|
206
206
|
const created = [];
|
|
207
207
|
const skipped = [];
|
|
@@ -113,6 +113,7 @@ export async function postReviewCommand(argv) {
|
|
|
113
113
|
breakGlassMarker: config.breakGlassMarker,
|
|
114
114
|
cwd,
|
|
115
115
|
feedback: config.feedback,
|
|
116
|
+
inline: config.inline,
|
|
116
117
|
headSha,
|
|
117
118
|
});
|
|
118
119
|
const result = await publishDeferredReview(reporter, artifact, async () => {
|
package/build/commands/review.js
CHANGED
|
@@ -242,6 +242,8 @@ export async function reviewCommand(argv) {
|
|
|
242
242
|
cwd,
|
|
243
243
|
// Root-only feedback config (loadScopeConfig inherits it from the root).
|
|
244
244
|
feedback: config.feedback,
|
|
245
|
+
// Root-only too (loadScopeConfig inherits it from the root).
|
|
246
|
+
inline: config.inline,
|
|
245
247
|
headSha: await reviewedHeadSha(source),
|
|
246
248
|
})
|
|
247
249
|
: null;
|
|
@@ -315,6 +317,7 @@ export async function reviewCommand(argv) {
|
|
|
315
317
|
breakGlassMarker: config.breakGlassMarker,
|
|
316
318
|
cwd,
|
|
317
319
|
feedback: config.feedback,
|
|
320
|
+
inline: config.inline,
|
|
318
321
|
headSha,
|
|
319
322
|
})
|
|
320
323
|
: null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ref LLP 0007#verify-config-the-config-guard — the CI trust guard that runs before the loaders are trusted, so it deliberately does not use them
|
|
2
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
4
|
+
import { CONFIG_DIRNAME, configDirFor, isConfigDirPath, stripJsonComments, stripTrailingCommas, } from "../config/load.js";
|
|
5
5
|
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
6
6
|
import { repoRoot } from "../core/exec.js";
|
|
7
7
|
import { errorMessage } from "../core/util.js";
|
|
@@ -52,9 +52,7 @@ async function discoverConfigFiles(root) {
|
|
|
52
52
|
}
|
|
53
53
|
await walk(path.join(dir, entry.name));
|
|
54
54
|
}
|
|
55
|
-
else if (entry.isFile() &&
|
|
56
|
-
path.basename(dir) === CONFIG_DIRNAME &&
|
|
57
|
-
CONFIG_FILENAMES.has(entry.name)) {
|
|
55
|
+
else if (entry.isFile() && isConfigDirPath(dir) && CONFIG_FILENAMES.has(entry.name)) {
|
|
58
56
|
found.push(path.join(dir, entry.name));
|
|
59
57
|
}
|
|
60
58
|
}
|
|
@@ -112,7 +110,7 @@ function extractFacts(file, parsed) {
|
|
|
112
110
|
*/
|
|
113
111
|
export async function verifyConfig(root, options = {}) {
|
|
114
112
|
const findings = [];
|
|
115
|
-
const rootConfigDir =
|
|
113
|
+
const rootConfigDir = configDirFor(root);
|
|
116
114
|
const rel = (file) => path.relative(root, file) || path.basename(file);
|
|
117
115
|
const files = await discoverConfigFiles(root);
|
|
118
116
|
const tokenEnvOccurrences = [];
|
package/build/config/load.js
CHANGED
|
@@ -7,7 +7,22 @@ import { existsSync } from "node:fs";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
|
|
9
9
|
import { toolMap } from "../core/tools.js";
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The setup directory. `.expo-agents/code-review/` is the home from 0.15.0 —
|
|
12
|
+
* one `.expo-agents/` directory holds every Expo agent tool's per-repo files
|
|
13
|
+
* (code-review, verify) instead of one dot directory per tool at the root.
|
|
14
|
+
* The pre-0.15 `.expo-code-review/` keeps working unchanged: every lookup
|
|
15
|
+
* takes whichever of the two exists (new first), and scaffolding writes into
|
|
16
|
+
* an existing legacy directory rather than starting a second one.
|
|
17
|
+
*/
|
|
18
|
+
export const CONFIG_DIRNAME = path.join(".expo-agents", "code-review");
|
|
19
|
+
export const LEGACY_CONFIG_DIRNAME = ".expo-code-review";
|
|
20
|
+
export const CONFIG_DIRNAMES = [CONFIG_DIRNAME, LEGACY_CONFIG_DIRNAME];
|
|
21
|
+
/** Does this directory path name a setup dir (either spelling)? */
|
|
22
|
+
export function isConfigDirPath(dir) {
|
|
23
|
+
const normalized = path.normalize(dir);
|
|
24
|
+
return CONFIG_DIRNAMES.some((name) => normalized === name || normalized.endsWith(path.sep + name));
|
|
25
|
+
}
|
|
11
26
|
/** Stack config for a scope load (where `stack` is schema-rejected and absent). */
|
|
12
27
|
const STACK_CONFIG_DEFAULTS = {
|
|
13
28
|
enabled: false,
|
|
@@ -26,6 +41,11 @@ const FEEDBACK_CONFIG_DEFAULTS = {
|
|
|
26
41
|
protectedCategories: ["secrets", "security"],
|
|
27
42
|
maxAdjudications: 10,
|
|
28
43
|
};
|
|
44
|
+
/** Inline-comment config for a scope load (where `inline` is schema-rejected). */
|
|
45
|
+
const INLINE_CONFIG_DEFAULTS = {
|
|
46
|
+
enabled: false,
|
|
47
|
+
maxComments: 20,
|
|
48
|
+
};
|
|
29
49
|
/** Research defaults for a scope load (where `research` is schema-rejected). */
|
|
30
50
|
const RESEARCH_CONFIG_DEFAULTS = {
|
|
31
51
|
enabled: false,
|
|
@@ -35,21 +55,33 @@ const RESEARCH_CONFIG_DEFAULTS = {
|
|
|
35
55
|
};
|
|
36
56
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
37
57
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The setup dir under `base`: `.expo-agents/code-review/` when it exists, else
|
|
60
|
+
* the legacy `.expo-code-review/` when THAT exists, else the new name (for
|
|
61
|
+
* messages and for scaffolding a fresh repo). Works for the repo root and for
|
|
62
|
+
* scope directories alike.
|
|
63
|
+
*/
|
|
64
|
+
export function configDirFor(base) {
|
|
65
|
+
for (const name of CONFIG_DIRNAMES) {
|
|
66
|
+
const candidate = path.join(base, name);
|
|
67
|
+
if (existsSync(candidate)) {
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return path.join(base, CONFIG_DIRNAME);
|
|
40
72
|
}
|
|
41
73
|
/**
|
|
42
74
|
* Resolve the config directory: an explicit override (absolute or repo-relative)
|
|
43
|
-
* → the ECR_CONFIG_DIR env var → the repo's default
|
|
44
|
-
* the escape hatch (graft 1); with neither override present the
|
|
45
|
-
* `configDirFor(repoRoot)
|
|
75
|
+
* → the ECR_CONFIG_DIR env var → the repo's default setup dir (new name, then
|
|
76
|
+
* legacy). This is the escape hatch (graft 1); with neither override present the
|
|
77
|
+
* result is exactly `configDirFor(repoRoot)`.
|
|
46
78
|
*/
|
|
47
79
|
export function resolveConfigDir(repoRoot, override) {
|
|
48
80
|
const chosen = override ?? process.env.ECR_CONFIG_DIR;
|
|
49
81
|
if (chosen) {
|
|
50
82
|
return path.isAbsolute(chosen) ? chosen : path.join(repoRoot, chosen);
|
|
51
83
|
}
|
|
52
|
-
return
|
|
84
|
+
return configDirFor(repoRoot);
|
|
53
85
|
}
|
|
54
86
|
export function hasConfig(repoRoot, options = {}) {
|
|
55
87
|
// Resolve the same way loadReviewConfig does (incl. the ECR_CONFIG_DIR escape
|
|
@@ -162,6 +194,8 @@ async function loadConfigDir(dir, schema) {
|
|
|
162
194
|
// for a scope config and the defaults stand in (unused — the command layer
|
|
163
195
|
// reads the ROOT config's feedback values; the comment lifecycle is global).
|
|
164
196
|
feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
|
|
197
|
+
// Root-only, same reasoning as feedback.
|
|
198
|
+
inline: parsed.inline ?? INLINE_CONFIG_DEFAULTS,
|
|
165
199
|
};
|
|
166
200
|
return { config, raw: rawObject };
|
|
167
201
|
}
|
|
@@ -231,7 +265,7 @@ export function hasScopeConfig(root, scope) {
|
|
|
231
265
|
if (scope.config === ".") {
|
|
232
266
|
return true;
|
|
233
267
|
}
|
|
234
|
-
const dir = path.join(root, scope.config
|
|
268
|
+
const dir = configDirFor(path.join(root, scope.config));
|
|
235
269
|
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
|
|
236
270
|
}
|
|
237
271
|
/**
|
|
@@ -258,7 +292,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
258
292
|
if (resolvedScope !== resolvedRoot && !resolvedScope.startsWith(resolvedRoot + path.sep)) {
|
|
259
293
|
throw new Error(`scope "${scope.name}": config "${scope.config}" resolves outside the repo checkout`);
|
|
260
294
|
}
|
|
261
|
-
const dir = path.join(root, scope.config
|
|
295
|
+
const dir = configDirFor(path.join(root, scope.config));
|
|
262
296
|
const { config } = await loadConfigDir(dir, ScopeReviewConfigSchema);
|
|
263
297
|
base = config;
|
|
264
298
|
// Non-default scopes never carry their own marker (the scope schema rejects
|
|
@@ -296,6 +330,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
296
330
|
// run the default policy instead of the repo's real one.
|
|
297
331
|
stack: rootConfig.stack,
|
|
298
332
|
feedback: rootConfig.feedback,
|
|
333
|
+
inline: rootConfig.inline,
|
|
299
334
|
research: rootConfig.research,
|
|
300
335
|
scopeName: scope.name,
|
|
301
336
|
};
|
package/build/config/schema.js
CHANGED
|
@@ -216,6 +216,20 @@ export const ReviewConfigSchema = z.object({
|
|
|
216
216
|
protectedCategories: ["secrets", "security"],
|
|
217
217
|
maxAdjudications: 10,
|
|
218
218
|
}),
|
|
219
|
+
// Inline PR review comments for findings anchored to a line in the diff: the
|
|
220
|
+
// main comment stays the durable state store and renders such findings short,
|
|
221
|
+
// linking to the inline thread. ROOT-ONLY (the comment lifecycle is global) and
|
|
222
|
+
// off by default — a feature that mutates N comments per run earns trust with
|
|
223
|
+
// field data first. Turning it off later leaves existing threads up (documented);
|
|
224
|
+
// clear() still sweeps them on comment-mode switches.
|
|
225
|
+
inline: z
|
|
226
|
+
.object({
|
|
227
|
+
enabled: z.boolean().default(false),
|
|
228
|
+
// Cap on inline comments PER REPORTER: per PR in single/legacy comment mode,
|
|
229
|
+
// per scope in per-scope mode. Bounds create-notification fan-out and API use.
|
|
230
|
+
maxComments: z.number().int().positive().default(20),
|
|
231
|
+
})
|
|
232
|
+
.default({ enabled: false, maxComments: 20 }),
|
|
219
233
|
});
|
|
220
234
|
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
221
235
|
export const RoutingScopeSchema = z.object({
|
|
@@ -317,6 +331,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
317
331
|
stack: true,
|
|
318
332
|
feedback: true,
|
|
319
333
|
research: true,
|
|
334
|
+
inline: true,
|
|
320
335
|
}).extend({
|
|
321
336
|
auth: z
|
|
322
337
|
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
@@ -342,4 +357,9 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
342
357
|
error: "research is locked to the root config because it starts a trusted host process; remove it from this scope config",
|
|
343
358
|
})
|
|
344
359
|
.optional(),
|
|
360
|
+
inline: z
|
|
361
|
+
.never({
|
|
362
|
+
error: "inline is locked to the root config (the comment lifecycle is global); remove it from this scope config",
|
|
363
|
+
})
|
|
364
|
+
.optional(),
|
|
345
365
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// @ref LLP 0012#unannotated-citations-are-broken-refs — a path-like backtick token that is not a ref fails the check
|
|
4
4
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
6
|
+
import { CONFIG_DIRNAME, LEGACY_CONFIG_DIRNAME, configDirFor, stripJsonComments, stripTrailingCommas, } from "../config/load.js";
|
|
7
7
|
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
8
8
|
import { git, pathInside } from "./exec.js";
|
|
9
9
|
import { matchesIgnore } from "./noise.js";
|
|
@@ -448,10 +448,18 @@ export async function discoverSetupDirs(root) {
|
|
|
448
448
|
continue;
|
|
449
449
|
}
|
|
450
450
|
const child = path.join(dir, entry.name);
|
|
451
|
-
if (entry.name ===
|
|
451
|
+
if (entry.name === LEGACY_CONFIG_DIRNAME) {
|
|
452
452
|
found.push(child);
|
|
453
453
|
continue; // a setup dir never nests another
|
|
454
454
|
}
|
|
455
|
+
if (entry.name === ".expo-agents") {
|
|
456
|
+
// The shared Expo agent-tools dir: only its code-review/ child is ours.
|
|
457
|
+
const setup = path.join(dir, CONFIG_DIRNAME);
|
|
458
|
+
if ((await pathKind(setup)) === "dir") {
|
|
459
|
+
found.push(setup);
|
|
460
|
+
}
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
455
463
|
// Other dot dirs are skipped wholesale: `.claude/worktrees/` holds checkouts of
|
|
456
464
|
// this same repo, whose setup dirs would otherwise be swept as if they were scopes.
|
|
457
465
|
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
@@ -542,7 +550,7 @@ async function checkRoutingManifest(file, index, rootAgentIds) {
|
|
|
542
550
|
}
|
|
543
551
|
const configDir = typeof entry.config === "string" ? entry.config : null;
|
|
544
552
|
if (configDir) {
|
|
545
|
-
const setupDir = path.resolve(index.root, configDir
|
|
553
|
+
const setupDir = configDirFor(path.resolve(index.root, configDir));
|
|
546
554
|
if (!pathInside(setupDir, index.root)) {
|
|
547
555
|
problems.push({
|
|
548
556
|
file: relative,
|
|
@@ -556,7 +564,7 @@ async function checkRoutingManifest(file, index, rootAgentIds) {
|
|
|
556
564
|
file: relative,
|
|
557
565
|
line: 1,
|
|
558
566
|
kind: "structural",
|
|
559
|
-
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ directory`,
|
|
567
|
+
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ (or ${LEGACY_CONFIG_DIRNAME}/) directory`,
|
|
560
568
|
});
|
|
561
569
|
}
|
|
562
570
|
}
|
|
@@ -33,6 +33,10 @@ export function reviewPostingConfigFingerprint(config) {
|
|
|
33
33
|
commentTag: config.commentTag,
|
|
34
34
|
breakGlassMarker: config.breakGlassMarker,
|
|
35
35
|
feedback: config.feedback,
|
|
36
|
+
// Inline comments change what posting does (N review-comment mutations), so a
|
|
37
|
+
// saved review must not post under a different inline policy than it was
|
|
38
|
+
// saved with.
|
|
39
|
+
inline: config.inline,
|
|
36
40
|
}))
|
|
37
41
|
.digest("hex");
|
|
38
42
|
}
|