@expo/code-review-cli 0.11.0 → 0.12.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 +92 -3
- package/build/cli.js +5 -0
- package/build/commands/ci.js +5 -1
- package/build/commands/post-review.js +147 -0
- package/build/commands/review.js +46 -14
- package/build/config/load.js +11 -0
- package/build/config/schema.js +34 -1
- package/build/core/deferred-review.js +119 -0
- package/build/core/prompts.js +30 -2
- package/build/core/render.js +4 -1
- package/build/core/research.js +498 -0
- package/build/core/review.js +22 -2
- package/build/research-mcp/apple-docc.js +122 -0
- package/build/research-mcp/cli.js +83 -0
- package/build/research-mcp/crawler.js +194 -0
- package/build/research-mcp/expo-algolia.js +95 -0
- package/build/research-mcp/html.js +132 -0
- package/build/research-mcp/markdown.js +32 -0
- package/build/research-mcp/paths.js +4 -0
- package/build/research-mcp/providers.js +322 -0
- package/build/research-mcp/response.js +24 -0
- package/build/research-mcp/search-index.js +131 -0
- package/build/research-mcp/server.js +118 -0
- package/build/research-mcp/types.js +28 -0
- package/build/research-mcp/youtrack.js +57 -0
- package/package.json +14 -3
- package/research/sources.json +283 -0
- package/templates/config.jsonc +16 -0
package/README.md
CHANGED
|
@@ -71,6 +71,9 @@ ecr review
|
|
|
71
71
|
ecr review --pr 123
|
|
72
72
|
# …and post it as the PR comment
|
|
73
73
|
ecr review --pr 123 --post
|
|
74
|
+
# Preview once, save the exact result, and post it later without another model run
|
|
75
|
+
ecr review --repo owner/repo --pr 123 --save-review --json
|
|
76
|
+
ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
|
|
74
77
|
```
|
|
75
78
|
|
|
76
79
|
Options (most to least common):
|
|
@@ -79,6 +82,7 @@ Options (most to least common):
|
|
|
79
82
|
| --- | --- |
|
|
80
83
|
| `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
|
|
81
84
|
| `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
|
|
85
|
+
| `--save-review` | With explicit `--repo` + `--pr`, save the exact preview as a private postable artifact. Mutually exclusive with `--post`. |
|
|
82
86
|
| `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
|
|
83
87
|
| `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
|
|
84
88
|
| `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
|
|
@@ -111,6 +115,7 @@ is a ready example to adapt.
|
|
|
111
115
|
| `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. |
|
|
112
116
|
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
113
117
|
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
|
118
|
+
| `ecr post-review --artifact <path> --repo <owner/repo> --pr <n>` | Post an exact saved PR preview without re-running models; refuses target, head, config, or break-glass drift. |
|
|
114
119
|
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
115
120
|
| `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
|
|
116
121
|
| `ecr feedback [--repo <owner/repo>]` | Report which findings PR authors pushed back on, across history. See below. |
|
|
@@ -168,6 +173,83 @@ Two run points:
|
|
|
168
173
|
|
|
169
174
|
---
|
|
170
175
|
|
|
176
|
+
## Providing context and research capabilities
|
|
177
|
+
|
|
178
|
+
`@expo/code-review-cli` includes `review-research-mcp` and can run it as a trusted
|
|
179
|
+
host-side prepass. This is deliberately not an agent-visible MCP: Claude Code
|
|
180
|
+
keeps `--safe-mode`, `--strict-mcp-config`, path-scoped read tools, and its
|
|
181
|
+
execution/network/write deny list. Before model startup, ECR extracts only short
|
|
182
|
+
API identifiers from added native code, asks the documentation MCP for bounded evidence,
|
|
183
|
+
and appends the results to reviewer and cross-file prompts as explicitly untrusted
|
|
184
|
+
reference text.
|
|
185
|
+
|
|
186
|
+
Enable it only in the root config, which CI loads from the PR's trusted base:
|
|
187
|
+
|
|
188
|
+
```jsonc
|
|
189
|
+
{
|
|
190
|
+
"research": {
|
|
191
|
+
"enabled": true,
|
|
192
|
+
"indexPath": "/opt/expo-review/docs-index.json",
|
|
193
|
+
"maxQueries": 8,
|
|
194
|
+
"resultsPerQuery": 2,
|
|
195
|
+
"timeoutMs": 15000
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
ECR resolves the MCP entry point inside its own installed package and starts it
|
|
201
|
+
with the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
|
|
202
|
+
either component. Build the index in a separate networked job and mount it read-only
|
|
203
|
+
in review jobs. The child runs from the OS temp directory with a minimal environment, a 2 MB output
|
|
204
|
+
cap, and a hard timeout. It receives no model credentials, source snippets, string
|
|
205
|
+
literals, comments, removed lines, or repository paths—only normalized identifiers,
|
|
206
|
+
the platform, and named provider filters. MCP failures are visible in the job log
|
|
207
|
+
but fail open to an ordinary review; they never skip or weaken review passes.
|
|
208
|
+
|
|
209
|
+
Research is root-only in routed monorepos because it starts a host process; scope
|
|
210
|
+
configs cannot change its index path or limits. Result-cache reuse is disabled while
|
|
211
|
+
research is enabled because an index can change at the same mounted path. For CI,
|
|
212
|
+
pin the ECR package/Node version and verify a signed index checksum before invoking
|
|
213
|
+
ECR. A simpler initial deployment may build the index in an earlier, secretless
|
|
214
|
+
workflow step using the same pinned published package, with no PR code executed and
|
|
215
|
+
failure allowed so review can continue without research. Keep `update` out of the
|
|
216
|
+
credential-bearing `ecr ci` process itself; the review pipeline always starts `serve`.
|
|
217
|
+
|
|
218
|
+
The built-in query router recognizes Apple/Android APIs plus Media3, Glide, OkHttp,
|
|
219
|
+
Kotlin coroutines, Gradle/AGP, Swift concurrency/evolution, platform release/API
|
|
220
|
+
availability, Expo, React Native, Reanimated, Gesture Handler, Screens, and Worklets.
|
|
221
|
+
Queries are short exact symbols plus at most one useful member or behavior term. For
|
|
222
|
+
example, `CameraView barcodeScannerSettings` is useful; a source snippet, import path,
|
|
223
|
+
or natural-language question is not. The MCP publishes the same guidance in its tool
|
|
224
|
+
metadata for direct clients. An empty result stays empty; it is not replaced with a
|
|
225
|
+
loose semantic guess.
|
|
226
|
+
|
|
227
|
+
For a query routed to the `expo` provider, `serve` POSTs the already-sanitized query
|
|
228
|
+
directly to Expo's public Algolia search endpoint and prefers the returned canonical
|
|
229
|
+
`docs.expo.dev` hits.
|
|
230
|
+
The endpoint, application id, and browser-visible search-only key are fixed in the
|
|
231
|
+
package; redirects are rejected; response size, timeout, hit count, and returned URL
|
|
232
|
+
host are bounded. Algolia receives the query text; a failed request falls back to the
|
|
233
|
+
mounted local index. The local index is still required for that fallback and every
|
|
234
|
+
other provider. Direct MCP clients can omit the `expo` provider; ECR installations
|
|
235
|
+
requiring a fully offline review should leave research disabled until provider
|
|
236
|
+
selection becomes installation-configurable.
|
|
237
|
+
|
|
238
|
+
The MCP and its trusted updater ship with ECR. From this repository, build an index
|
|
239
|
+
with `bun run research:update`; an installed package exposes the equivalent
|
|
240
|
+
`review-research-mcp update`. The built-in seed catalog lives in
|
|
241
|
+
`research/sources.json`, while the generated `research/data/` directory is ignored
|
|
242
|
+
and is not published. `seedUrls` are deterministic starting pages for the bounded
|
|
243
|
+
crawler; ordinary link extraction, parsing, indexing, and searching use no LLM.
|
|
244
|
+
Installation-specific provider configuration is intentionally
|
|
245
|
+
deferred: when added, it should follow the trusted root-config model used for agents
|
|
246
|
+
without permitting PR-controlled URLs, commands, or executable parsers. Expo skills
|
|
247
|
+
are complementary, not another search corpus: their pinned procedural guidance can
|
|
248
|
+
later be supplied to review agents as separately labeled trusted context, while
|
|
249
|
+
documentation search continues to return citable API evidence. Dynamic skills or
|
|
250
|
+
instructions retrieved from documentation must never become executable reviewer
|
|
251
|
+
instructions.
|
|
252
|
+
|
|
171
253
|
## Monorepos (routing manifest)
|
|
172
254
|
|
|
173
255
|
A monorepo can route different subtrees to different reviewer rosters from a single
|
|
@@ -250,10 +332,11 @@ your-monorepo/
|
|
|
250
332
|
|
|
251
333
|
### Security
|
|
252
334
|
|
|
253
|
-
- **auth
|
|
335
|
+
- **auth and research are locked to the root.** `tokenEnv` (which env var becomes the model
|
|
254
336
|
credential) is honored in exactly one place: the root `config.jsonc` or
|
|
255
|
-
`routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`
|
|
256
|
-
**fails to parse** (Zod-level rejection)
|
|
337
|
+
`routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`/`research`
|
|
338
|
+
**fails to parse** (Zod-level rejection). A scope declaring `research` also fails,
|
|
339
|
+
so PR-controlled routing cannot select a different host index. The CI guard step independently
|
|
257
340
|
sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
|
|
258
341
|
to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
|
|
259
342
|
`ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
|
|
@@ -661,6 +744,12 @@ that the PR comment embeds for machine consumers. The same totals are printed as
|
|
|
661
744
|
one-line summary to the terminal / CI job log at the end of each run, so cache reuse
|
|
662
745
|
is visible even in CI (where the run log is ephemeral).
|
|
663
746
|
|
|
747
|
+
`ecr review --save-review` additionally writes a versioned artifact under
|
|
748
|
+
`.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
|
|
749
|
+
verified final review and bounded feedback metadata, but no credential. `ecr
|
|
750
|
+
post-review` schema-validates it and refuses to post if its explicit repo/PR, live
|
|
751
|
+
head commit, or local comment-policy fingerprint no longer matches.
|
|
752
|
+
|
|
664
753
|
</details>
|
|
665
754
|
|
|
666
755
|
<a id="other-providers"></a>
|
package/build/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dismissCommand } from "./commands/dismiss.js";
|
|
|
5
5
|
import { doctorCommand } from "./commands/doctor.js";
|
|
6
6
|
import { feedbackCommand } from "./commands/feedback.js";
|
|
7
7
|
import { initCommand } from "./commands/init.js";
|
|
8
|
+
import { postReviewCommand } from "./commands/post-review.js";
|
|
8
9
|
import { refCheckCommand } from "./commands/ref-check.js";
|
|
9
10
|
import { reviewCommand } from "./commands/review.js";
|
|
10
11
|
import { setupAuthCommand } from "./commands/setup-auth.js";
|
|
@@ -13,6 +14,7 @@ const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
16
|
ecr review [options] Review local changes (default). See \`ecr review --help\`.
|
|
17
|
+
ecr post-review --artifact <path> --repo <owner/repo> --pr <n> Post an exact saved preview.
|
|
16
18
|
ecr ci Review the current PR and post a comment (GitHub Actions).
|
|
17
19
|
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
|
18
20
|
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
|
@@ -43,6 +45,9 @@ async function main() {
|
|
|
43
45
|
case "review":
|
|
44
46
|
await reviewCommand(rest);
|
|
45
47
|
break;
|
|
48
|
+
case "post-review":
|
|
49
|
+
await postReviewCommand(rest);
|
|
50
|
+
break;
|
|
46
51
|
case "ci":
|
|
47
52
|
await ciCommand(rest);
|
|
48
53
|
break;
|
package/build/commands/ci.js
CHANGED
|
@@ -463,7 +463,10 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
463
463
|
// Dynamic stack context and model-backed reply adjudication have inputs outside
|
|
464
464
|
// the scoped diff. Keep those paths fresh until their inputs join the cache key.
|
|
465
465
|
// A maintainer's explicit /review is also always a real rerun.
|
|
466
|
-
|
|
466
|
+
// Research output depends on the mounted index contents, not merely its configured
|
|
467
|
+
// path. Until a signed index digest joins the cache key, a researched review must
|
|
468
|
+
// run fresh rather than reuse evidence from an older artifact at the same path.
|
|
469
|
+
const cacheAllowed = !bypassTriggerGate && !stack && !feedback && !config.research.enabled && metadata !== undefined;
|
|
467
470
|
let inputHash;
|
|
468
471
|
try {
|
|
469
472
|
if (cacheAllowed) {
|
|
@@ -701,6 +704,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
701
704
|
const cacheAllowed = !bypassTriggerGate &&
|
|
702
705
|
!stackWalk &&
|
|
703
706
|
!feedbackNeedsRunSeam(rootConfig.feedback) &&
|
|
707
|
+
!rootConfig.research.enabled &&
|
|
704
708
|
metadata !== undefined;
|
|
705
709
|
let cacheReadRoot;
|
|
706
710
|
if (cacheAllowed) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @ref LLP 0007#deferred-review-posting [implements] — no-model post of an exact, target-bound preview artifact
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadReviewConfig } from "../config/load.js";
|
|
4
|
+
import { assertDeferredReviewCurrent, readDeferredReviewArtifact, reviewPostingConfigFingerprint, } from "../core/deferred-review.js";
|
|
5
|
+
import { repoRoot } from "../core/exec.js";
|
|
6
|
+
import { errorMessage } from "../core/util.js";
|
|
7
|
+
import { GitHubReporter } from "../reporters/github.js";
|
|
8
|
+
import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
|
|
9
|
+
const USAGE = `ecr post-review — post an exact saved PR review without re-running models
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
ecr post-review --artifact <path> --repo <owner/repo> --pr <n>
|
|
13
|
+
|
|
14
|
+
The artifact must come from \`ecr review --save-review --repo <owner/repo> --pr <n>\`.
|
|
15
|
+
Before writing to GitHub, this command verifies the explicit repo/PR, the live PR
|
|
16
|
+
head commit, the local posting policy, and the PR's break-glass marker.
|
|
17
|
+
`;
|
|
18
|
+
function requireValue(flag, value) {
|
|
19
|
+
if (value === undefined || value.startsWith("--")) {
|
|
20
|
+
throw new Error(`${flag} requires a value`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function parsePostReviewArgs(argv) {
|
|
25
|
+
const args = { help: false };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
switch (arg) {
|
|
29
|
+
case "--artifact":
|
|
30
|
+
args.artifact = requireValue(arg, argv[++i]);
|
|
31
|
+
break;
|
|
32
|
+
case "--repo":
|
|
33
|
+
args.repo = requireValue(arg, argv[++i]);
|
|
34
|
+
break;
|
|
35
|
+
case "--pr": {
|
|
36
|
+
const value = requireValue(arg, argv[++i]);
|
|
37
|
+
const number = Number(value);
|
|
38
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
39
|
+
throw new Error(`--pr requires a positive safe integer (got "${value}")`);
|
|
40
|
+
}
|
|
41
|
+
args.pr = number;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "-h":
|
|
45
|
+
case "--help":
|
|
46
|
+
args.help = true;
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
function validatePostReviewArgs(args) {
|
|
55
|
+
if (!args.artifact || !args.repo || args.pr == null) {
|
|
56
|
+
throw new Error("--artifact, --repo, and --pr are all required");
|
|
57
|
+
}
|
|
58
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(args.repo)) {
|
|
59
|
+
throw new Error(`--repo requires owner/repo (got "${args.repo}")`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Break-glass errors fail closed: no report call occurs unless the check says false. */
|
|
63
|
+
export async function publishDeferredReview(poster, artifact, assertReadyToPost = async () => { }) {
|
|
64
|
+
if (await poster.checkBreakGlass()) {
|
|
65
|
+
return "break-glass";
|
|
66
|
+
}
|
|
67
|
+
await assertReadyToPost();
|
|
68
|
+
await poster.report(artifact.review, artifact.feedback);
|
|
69
|
+
return "posted";
|
|
70
|
+
}
|
|
71
|
+
export async function postReviewCommand(argv) {
|
|
72
|
+
let args;
|
|
73
|
+
try {
|
|
74
|
+
args = parsePostReviewArgs(argv);
|
|
75
|
+
if (args.help) {
|
|
76
|
+
process.stdout.write(USAGE);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
validatePostReviewArgs(args);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
|
|
83
|
+
process.exitCode = 2;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Resolve before chdir so a relative artifact path keeps the caller's meaning.
|
|
87
|
+
const artifactPath = path.resolve(process.cwd(), args.artifact);
|
|
88
|
+
try {
|
|
89
|
+
const root = await repoRoot();
|
|
90
|
+
if (root && root !== process.cwd()) {
|
|
91
|
+
process.chdir(root);
|
|
92
|
+
}
|
|
93
|
+
const cwd = process.cwd();
|
|
94
|
+
const [artifact, config] = await Promise.all([
|
|
95
|
+
readDeferredReviewArtifact(artifactPath),
|
|
96
|
+
loadReviewConfig(cwd),
|
|
97
|
+
]);
|
|
98
|
+
const source = new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd });
|
|
99
|
+
const headSha = (await source.getMetadata()).headOid;
|
|
100
|
+
if (!isCommitOid(headSha)) {
|
|
101
|
+
throw new Error(`could not resolve a full head commit for ${args.repo}#${args.pr}`);
|
|
102
|
+
}
|
|
103
|
+
assertDeferredReviewCurrent(artifact, {
|
|
104
|
+
repo: args.repo,
|
|
105
|
+
pr: args.pr,
|
|
106
|
+
headSha,
|
|
107
|
+
configFingerprint: reviewPostingConfigFingerprint(config),
|
|
108
|
+
});
|
|
109
|
+
const reporter = new GitHubReporter({
|
|
110
|
+
prNumber: args.pr,
|
|
111
|
+
repo: args.repo,
|
|
112
|
+
commentTag: config.commentTag,
|
|
113
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
114
|
+
cwd,
|
|
115
|
+
feedback: config.feedback,
|
|
116
|
+
headSha,
|
|
117
|
+
});
|
|
118
|
+
const result = await publishDeferredReview(reporter, artifact, async () => {
|
|
119
|
+
// Re-fetch rather than reusing GitHubPRSource's memoized metadata, and reload
|
|
120
|
+
// config after the break-glass API call. This narrows the unavoidable remote
|
|
121
|
+
// TOCTOU window and prevents a head/policy change during setup from reaching
|
|
122
|
+
// the reporter.
|
|
123
|
+
const [latestMetadata, latestConfig] = await Promise.all([
|
|
124
|
+
new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd }).getMetadata(),
|
|
125
|
+
loadReviewConfig(cwd),
|
|
126
|
+
]);
|
|
127
|
+
if (!isCommitOid(latestMetadata.headOid)) {
|
|
128
|
+
throw new Error(`could not re-resolve a full head commit for ${args.repo}#${args.pr}`);
|
|
129
|
+
}
|
|
130
|
+
assertDeferredReviewCurrent(artifact, {
|
|
131
|
+
repo: args.repo,
|
|
132
|
+
pr: args.pr,
|
|
133
|
+
headSha: latestMetadata.headOid,
|
|
134
|
+
configFingerprint: reviewPostingConfigFingerprint(latestConfig),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
if (result === "break-glass") {
|
|
138
|
+
process.stderr.write(`Not posting: ${config.breakGlassMarker} is set on ${args.repo}#${args.pr}.\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
process.stderr.write(`Posted saved review to ${args.repo}#${args.pr}.\n`);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
process.stderr.write(`Saved review was not posted: ${errorMessage(error)}\n`);
|
|
145
|
+
process.exitCode = 2;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/build/commands/review.js
CHANGED
|
@@ -4,10 +4,11 @@ import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/
|
|
|
4
4
|
import { repoRoot, resolveRepo } from "../core/exec.js";
|
|
5
5
|
import { errorMessage } from "../core/util.js";
|
|
6
6
|
import { readContextFile } from "../core/context-file.js";
|
|
7
|
+
import { writeDeferredReviewArtifact } from "../core/deferred-review.js";
|
|
7
8
|
import { feedbackNeedsRunSeam } from "../core/adjudicate.js";
|
|
8
9
|
import { runReview } from "../core/review.js";
|
|
9
10
|
import { LocalGitSource } from "../sources/local-git.js";
|
|
10
|
-
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
11
|
+
import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
|
|
11
12
|
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
|
|
12
13
|
import { TerminalReporter } from "../reporters/terminal.js";
|
|
13
14
|
import { GitHubReporter } from "../reporters/github.js";
|
|
@@ -15,7 +16,8 @@ const USAGE = `ecr review — AI code review, printed to your terminal
|
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
17
18
|
ecr review [options] review local changes
|
|
18
|
-
ecr review --pr <n> [--post
|
|
19
|
+
ecr review --pr <n> [--post | --save-review]
|
|
20
|
+
review a GitHub PR by number
|
|
19
21
|
|
|
20
22
|
Source (pick one):
|
|
21
23
|
(default) diff the working tree against the merge-base
|
|
@@ -31,6 +33,8 @@ Options:
|
|
|
31
33
|
--post with --pr: also post the result as the PR comment (needs
|
|
32
34
|
\`gh\` auth). Omit to only preview here; re-run with --post
|
|
33
35
|
to publish.
|
|
36
|
+
--save-review with explicit --repo + --pr: save this exact preview as a
|
|
37
|
+
postable artifact for a later \`ecr post-review\` command.
|
|
34
38
|
--agents <a,b> run only these agents (comma-separated ids); default: all
|
|
35
39
|
--route let the router pick relevant agents from the diff
|
|
36
40
|
--scope <name> review only this routing scope (needs a routing.jsonc);
|
|
@@ -60,10 +64,11 @@ function requireValue(flag, value) {
|
|
|
60
64
|
}
|
|
61
65
|
return value;
|
|
62
66
|
}
|
|
63
|
-
function
|
|
67
|
+
export function parseReviewArgs(argv) {
|
|
64
68
|
const args = {
|
|
65
69
|
staged: false,
|
|
66
70
|
post: false,
|
|
71
|
+
saveReview: false,
|
|
67
72
|
route: false,
|
|
68
73
|
stackAware: false,
|
|
69
74
|
json: false,
|
|
@@ -85,8 +90,8 @@ function parseArgs(argv) {
|
|
|
85
90
|
case "--pr": {
|
|
86
91
|
const value = requireValue(arg, argv[++i]);
|
|
87
92
|
const number = Number(value);
|
|
88
|
-
if (!Number.
|
|
89
|
-
throw new Error(`--pr requires a positive
|
|
93
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
94
|
+
throw new Error(`--pr requires a positive safe integer (got "${value}")`);
|
|
90
95
|
}
|
|
91
96
|
args.pr = number;
|
|
92
97
|
break;
|
|
@@ -97,6 +102,9 @@ function parseArgs(argv) {
|
|
|
97
102
|
case "--post":
|
|
98
103
|
args.post = true;
|
|
99
104
|
break;
|
|
105
|
+
case "--save-review":
|
|
106
|
+
args.saveReview = true;
|
|
107
|
+
break;
|
|
100
108
|
case "--agents":
|
|
101
109
|
args.agents = requireValue(arg, argv[++i])
|
|
102
110
|
.split(",")
|
|
@@ -137,7 +145,7 @@ function parseArgs(argv) {
|
|
|
137
145
|
export async function reviewCommand(argv) {
|
|
138
146
|
let args;
|
|
139
147
|
try {
|
|
140
|
-
args =
|
|
148
|
+
args = parseReviewArgs(argv);
|
|
141
149
|
}
|
|
142
150
|
catch (error) {
|
|
143
151
|
process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
|
|
@@ -149,7 +157,7 @@ export async function reviewCommand(argv) {
|
|
|
149
157
|
return;
|
|
150
158
|
}
|
|
151
159
|
try {
|
|
152
|
-
|
|
160
|
+
validateReviewArgs(args);
|
|
153
161
|
}
|
|
154
162
|
catch (error) {
|
|
155
163
|
process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
|
|
@@ -292,11 +300,13 @@ export async function reviewCommand(argv) {
|
|
|
292
300
|
}
|
|
293
301
|
const config = await loadReviewConfig(cwd, { configDir: args.configDir });
|
|
294
302
|
const source = makeSource();
|
|
295
|
-
// Build the PR reporter up front when posting
|
|
303
|
+
// Build the PR reporter up front when posting OR saving a postable preview, so
|
|
304
|
+
// adjudicate mode can judge the
|
|
296
305
|
// PR's replies against the source before the result is rendered. Feedback only
|
|
297
306
|
// has replies to match when reviewing a PR and posting (the terminal preview never
|
|
298
|
-
// renders annotations), so it is wired only
|
|
299
|
-
const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
|
|
307
|
+
// renders annotations), so it is wired only when a later/current post is possible.
|
|
308
|
+
const { repo: postRepo, error: postRepoError } = await resolvePostRepo({ ...args, post: args.post || args.saveReview }, cwd);
|
|
309
|
+
const headSha = postRepo != null && args.pr != null ? await reviewedHeadSha(source) : undefined;
|
|
300
310
|
const reporter = postRepo != null && args.pr != null
|
|
301
311
|
? new GitHubReporter({
|
|
302
312
|
prNumber: args.pr,
|
|
@@ -305,7 +315,7 @@ export async function reviewCommand(argv) {
|
|
|
305
315
|
breakGlassMarker: config.breakGlassMarker,
|
|
306
316
|
cwd,
|
|
307
317
|
feedback: config.feedback,
|
|
308
|
-
headSha
|
|
318
|
+
headSha,
|
|
309
319
|
})
|
|
310
320
|
: null;
|
|
311
321
|
const review = await runReview(source, {
|
|
@@ -325,6 +335,19 @@ export async function reviewCommand(argv) {
|
|
|
325
335
|
});
|
|
326
336
|
// Always print the result here first.
|
|
327
337
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
338
|
+
if (args.saveReview) {
|
|
339
|
+
if (postRepo == null || args.pr == null || !isCommitOid(headSha)) {
|
|
340
|
+
throw new Error(`could not save a postable review: ${postRepoError ? errorMessage(postRepoError) : "the PR head commit could not be resolved"}`);
|
|
341
|
+
}
|
|
342
|
+
const artifactPath = await writeDeferredReviewArtifact(config, {
|
|
343
|
+
repo: postRepo,
|
|
344
|
+
pr: args.pr,
|
|
345
|
+
headSha,
|
|
346
|
+
review,
|
|
347
|
+
feedback: review.feedback,
|
|
348
|
+
});
|
|
349
|
+
process.stderr.write(`\nSaved postable review artifact: ${artifactPath}\n`);
|
|
350
|
+
}
|
|
328
351
|
// Then, only if asked, publish the same result to the PR.
|
|
329
352
|
if (args.pr != null && args.post) {
|
|
330
353
|
if (reporter) {
|
|
@@ -403,12 +426,21 @@ export async function resolvePostRepo(args, cwd, resolve = resolveRepo) {
|
|
|
403
426
|
}
|
|
404
427
|
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — mutually exclusive flags rejected outright, never silently ignored
|
|
405
428
|
/** Reject flag combinations that don't make sense together. */
|
|
406
|
-
function
|
|
429
|
+
export function validateReviewArgs(args) {
|
|
407
430
|
if (args.pr != null && (args.base || args.head || args.staged)) {
|
|
408
431
|
throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
|
|
409
432
|
}
|
|
410
|
-
if (args.pr == null && (args.repo || args.post)) {
|
|
411
|
-
throw new Error("--repo/--post only apply together with --pr.");
|
|
433
|
+
if (args.pr == null && (args.repo || args.post || args.saveReview)) {
|
|
434
|
+
throw new Error("--repo/--post/--save-review only apply together with --pr.");
|
|
435
|
+
}
|
|
436
|
+
if (args.saveReview && !args.repo) {
|
|
437
|
+
throw new Error("--save-review requires explicit --repo owner/repo.");
|
|
438
|
+
}
|
|
439
|
+
if (args.saveReview && args.post) {
|
|
440
|
+
throw new Error("--save-review and --post are mutually exclusive.");
|
|
441
|
+
}
|
|
442
|
+
if (args.saveReview && (args.scope || args.configDir)) {
|
|
443
|
+
throw new Error("--save-review does not support --scope or --config-dir.");
|
|
412
444
|
}
|
|
413
445
|
// Same rule as --repo/--post: the stack walk needs a PR to walk from, so a bare
|
|
414
446
|
// --stack-aware would be silently ignored — reject it instead.
|
package/build/config/load.js
CHANGED
|
@@ -26,6 +26,13 @@ const FEEDBACK_CONFIG_DEFAULTS = {
|
|
|
26
26
|
protectedCategories: ["secrets", "security"],
|
|
27
27
|
maxAdjudications: 10,
|
|
28
28
|
};
|
|
29
|
+
/** Research defaults for a scope load (where `research` is schema-rejected). */
|
|
30
|
+
const RESEARCH_CONFIG_DEFAULTS = {
|
|
31
|
+
enabled: false,
|
|
32
|
+
maxQueries: 8,
|
|
33
|
+
resultsPerQuery: 2,
|
|
34
|
+
timeoutMs: 15_000,
|
|
35
|
+
};
|
|
29
36
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
30
37
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
31
38
|
export function configDirFor(repoRoot) {
|
|
@@ -136,6 +143,9 @@ async function loadConfigDir(dir, schema) {
|
|
|
136
143
|
policy: parsed.policy,
|
|
137
144
|
chunk: parsed.chunk,
|
|
138
145
|
noise: parsed.noise,
|
|
146
|
+
// Root-only: scope schemas reject research configuration, so an untrusted
|
|
147
|
+
// subtree cannot select the index or alter the network-facing runtime.
|
|
148
|
+
research: parsed.research ?? RESEARCH_CONFIG_DEFAULTS,
|
|
139
149
|
// parsed.breakGlass/auth are always present for the root schema (defaults) and
|
|
140
150
|
// absent for the scope schema; loadScopeConfig overrides both afterwards.
|
|
141
151
|
breakGlassMarker: parsed.breakGlass?.marker ?? "/skip-review",
|
|
@@ -286,6 +296,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
286
296
|
// run the default policy instead of the repo's real one.
|
|
287
297
|
stack: rootConfig.stack,
|
|
288
298
|
feedback: rootConfig.feedback,
|
|
299
|
+
research: rootConfig.research,
|
|
289
300
|
scopeName: scope.name,
|
|
290
301
|
};
|
|
291
302
|
}
|
package/build/config/schema.js
CHANGED
|
@@ -58,6 +58,33 @@ export const ReviewConfigSchema = z.object({
|
|
|
58
58
|
additionalMarkers: z.array(z.string()).default([]),
|
|
59
59
|
})
|
|
60
60
|
.default({ additionalIgnores: [], additionalMarkers: [] }),
|
|
61
|
+
research: z
|
|
62
|
+
.object({
|
|
63
|
+
enabled: z.boolean().default(false),
|
|
64
|
+
indexPath: z
|
|
65
|
+
.string()
|
|
66
|
+
.min(1)
|
|
67
|
+
.refine((value) => path.isAbsolute(value), "research.indexPath must be an absolute path")
|
|
68
|
+
.optional(),
|
|
69
|
+
maxQueries: z.number().int().min(1).max(20).default(8),
|
|
70
|
+
resultsPerQuery: z.number().int().min(1).max(3).default(2),
|
|
71
|
+
timeoutMs: z.number().int().min(1000).max(60_000).default(15_000),
|
|
72
|
+
})
|
|
73
|
+
.superRefine((value, context) => {
|
|
74
|
+
if (value.enabled && !value.indexPath) {
|
|
75
|
+
context.addIssue({
|
|
76
|
+
code: "custom",
|
|
77
|
+
path: ["indexPath"],
|
|
78
|
+
message: "research.indexPath is required when research.enabled is true",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.default({
|
|
83
|
+
enabled: false,
|
|
84
|
+
maxQueries: 8,
|
|
85
|
+
resultsPerQuery: 2,
|
|
86
|
+
timeoutMs: 15_000,
|
|
87
|
+
}),
|
|
61
88
|
breakGlass: z
|
|
62
89
|
.object({ marker: z.string().default("/skip-review") })
|
|
63
90
|
.default({ marker: "/skip-review" }),
|
|
@@ -278,7 +305,7 @@ export const RoutingManifestSchema = z
|
|
|
278
305
|
* Scope config = root config MINUS the centrally locked keys. Allowlist of
|
|
279
306
|
* scope-overridable keys (Turborepo-style, graft 6): model, policy, chunk,
|
|
280
307
|
* noise (+ the prompt files living beside it: shared.md, coordinator.md,
|
|
281
|
-
* agents/). NEVER auth or
|
|
308
|
+
* agents/). NEVER auth, breakGlass, or research — declaring one fails parsing at the
|
|
282
309
|
* Zod level so IDE/doctor catch it before CI. commentTag is also locked: a
|
|
283
310
|
* scope's comment marker is always DERIVED (`<rootTag>:<scope>`; the default
|
|
284
311
|
* scope keeps the root tag) so `ecr ci`'s post/clear/reconcile paths and a
|
|
@@ -292,6 +319,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
292
319
|
commentTag: true,
|
|
293
320
|
stack: true,
|
|
294
321
|
feedback: true,
|
|
322
|
+
research: true,
|
|
295
323
|
}).extend({
|
|
296
324
|
auth: z
|
|
297
325
|
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
@@ -312,4 +340,9 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
312
340
|
error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
|
|
313
341
|
})
|
|
314
342
|
.optional(),
|
|
343
|
+
research: z
|
|
344
|
+
.never({
|
|
345
|
+
error: "research is locked to the root config because it starts a trusted host process; remove it from this scope config",
|
|
346
|
+
})
|
|
347
|
+
.optional(),
|
|
315
348
|
});
|