@expo/code-review-cli 0.7.0 → 0.9.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 +161 -13
- package/build/cli.js +12 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/ref-check.js +84 -0
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/config-refs.js +772 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +268 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +307 -15
- package/build/core/schema.js +223 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +10 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +6 -1
- package/templates/agents/correctness.md +9 -1
- package/templates/agents/security.md +11 -1
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +99 -1
- package/templates/workflow.yml +5 -0
package/build/commands/review.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules — local runs: the person at the terminal is the trust principal, even with --pr
|
|
1
2
|
import { loadReviewConfig, loadScopeConfig } from "../config/load.js";
|
|
2
3
|
import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/routing.js";
|
|
3
4
|
import { repoRoot, resolveRepo } from "../core/exec.js";
|
|
4
5
|
import { errorMessage } from "../core/util.js";
|
|
6
|
+
import { readContextFile } from "../core/context-file.js";
|
|
7
|
+
import { feedbackNeedsRunSeam } from "../core/adjudicate.js";
|
|
5
8
|
import { runReview } from "../core/review.js";
|
|
6
9
|
import { LocalGitSource } from "../sources/local-git.js";
|
|
7
10
|
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
8
|
-
import { memoizeSource } from "../sources/source.js";
|
|
11
|
+
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
|
|
9
12
|
import { TerminalReporter } from "../reporters/terminal.js";
|
|
10
13
|
import { GitHubReporter } from "../reporters/github.js";
|
|
11
14
|
const USAGE = `ecr review — AI code review, printed to your terminal
|
|
@@ -34,6 +37,11 @@ Options:
|
|
|
34
37
|
runs its config over just that scope's changed files
|
|
35
38
|
--config-dir <dir> load config from <dir> instead of .expo-code-review/
|
|
36
39
|
(also ECR_CONFIG_DIR); can't combine with --scope
|
|
40
|
+
--context-file <p> inject <p>'s UTF-8 text into reviewer prompts as an
|
|
41
|
+
explicitly UNTRUSTED external-context block
|
|
42
|
+
--stack-aware with --pr: walk the open PRs stacked on top and let the
|
|
43
|
+
coordinator requalify absence-style findings a later PR
|
|
44
|
+
already addresses (off by default; rejected without --pr)
|
|
37
45
|
--json emit machine-readable JSON on stdout
|
|
38
46
|
--no-fail always exit 0, even on request-changes
|
|
39
47
|
-h, --help show this help
|
|
@@ -57,6 +65,7 @@ function parseArgs(argv) {
|
|
|
57
65
|
staged: false,
|
|
58
66
|
post: false,
|
|
59
67
|
route: false,
|
|
68
|
+
stackAware: false,
|
|
60
69
|
json: false,
|
|
61
70
|
noFail: false,
|
|
62
71
|
help: false,
|
|
@@ -103,6 +112,12 @@ function parseArgs(argv) {
|
|
|
103
112
|
case "--config-dir":
|
|
104
113
|
args.configDir = requireValue(arg, argv[++i]);
|
|
105
114
|
break;
|
|
115
|
+
case "--context-file":
|
|
116
|
+
args.contextFile = requireValue(arg, argv[++i]);
|
|
117
|
+
break;
|
|
118
|
+
case "--stack-aware":
|
|
119
|
+
args.stackAware = true;
|
|
120
|
+
break;
|
|
106
121
|
case "--json":
|
|
107
122
|
args.json = true;
|
|
108
123
|
break;
|
|
@@ -147,6 +162,29 @@ export async function reviewCommand(argv) {
|
|
|
147
162
|
if (root && root !== process.cwd()) {
|
|
148
163
|
process.chdir(root);
|
|
149
164
|
}
|
|
165
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — --context-file is orthogonal enrichment; local read errors fail loud (exit 2)
|
|
166
|
+
// Read the context file ONCE, in the command layer (routed review calls runReview
|
|
167
|
+
// per scope; reading inside would re-read the file N times). Local runs FAIL LOUD
|
|
168
|
+
// on a read error: the user typed the path, so a typo or oversized file is a
|
|
169
|
+
// mistake to surface, not to silently skip (unlike `ecr ci`, which warns and
|
|
170
|
+
// continues to keep the never-fail-checks invariant).
|
|
171
|
+
let contextText;
|
|
172
|
+
if (args.contextFile) {
|
|
173
|
+
try {
|
|
174
|
+
contextText = await readContextFile(args.contextFile);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
process.stderr.write(`--context-file: ${errorMessage(error)}\n`);
|
|
178
|
+
process.exitCode = 2;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Empty/whitespace-only file: warn (a typo'd or unwritten path) but do not fail
|
|
182
|
+
// — there is simply no context to add.
|
|
183
|
+
if (!contextText.trim()) {
|
|
184
|
+
process.stderr.write(`--context-file: ${args.contextFile} is empty; no context added.\n`);
|
|
185
|
+
contextText = undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
150
188
|
try {
|
|
151
189
|
const cwd = process.cwd();
|
|
152
190
|
const makeSource = () => args.pr != null
|
|
@@ -176,46 +214,74 @@ export async function reviewCommand(argv) {
|
|
|
176
214
|
process.stdout.write(`No changed files in scope ${args.scope}.\n`);
|
|
177
215
|
return;
|
|
178
216
|
}
|
|
217
|
+
// Build the PR reporter up front when posting, so adjudicate mode can judge the
|
|
218
|
+
// replies against the source before the result is rendered (see the non-scope
|
|
219
|
+
// path). A scope always posts under the DERIVED marker `<rootTag>:<scope>` —
|
|
220
|
+
// from the ROOT config's tag exactly like `ecr ci` does (runRoutedCi prefers
|
|
221
|
+
// rootConfig.commentTag over manifest defaults when they diverge) — so a
|
|
222
|
+
// standalone scope post and CI's per-scope post/clear/reconcile paths always
|
|
223
|
+
// target the same marker, and the bare aggregate marker is never used here.
|
|
224
|
+
// (Per-scope commentTag overrides are rejected by the scope schema for exactly
|
|
225
|
+
// this reason.)
|
|
226
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [constrained-by] — must match ci.ts's derivation; the scope schema ban on commentTag is what keeps them aligned
|
|
227
|
+
const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
|
|
228
|
+
const reporter = postRepo != null && args.pr != null
|
|
229
|
+
? new GitHubReporter({
|
|
230
|
+
prNumber: args.pr,
|
|
231
|
+
repo: postRepo,
|
|
232
|
+
commentTag: scopedCommentTag(rootConfig.commentTag, args.scope),
|
|
233
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
234
|
+
cwd,
|
|
235
|
+
// Root-only feedback config (loadScopeConfig inherits it from the root).
|
|
236
|
+
feedback: config.feedback,
|
|
237
|
+
headSha: await reviewedHeadSha(source),
|
|
238
|
+
})
|
|
239
|
+
: null;
|
|
179
240
|
const review = await runReview(source, {
|
|
180
241
|
config,
|
|
181
242
|
mode: "local",
|
|
182
243
|
agents: args.agents,
|
|
183
244
|
route: args.route,
|
|
184
245
|
includePaths: files,
|
|
246
|
+
contextText,
|
|
247
|
+
// Explicit --stack-aware only: local config is not a trusted base, so the
|
|
248
|
+
// user typing the flag is the trust principal. Bounds come from the root
|
|
249
|
+
// config; validateArgs already rejected --stack-aware without --pr, and the
|
|
250
|
+
// pr guard here keeps that invariant local.
|
|
251
|
+
stack: args.stackAware && args.pr != null ? stackWalkFromConfig(rootConfig.stack) : undefined,
|
|
252
|
+
stackConfirm: args.stackAware && args.pr != null
|
|
253
|
+
? stackConfirmFromConfig(rootConfig.stack)
|
|
254
|
+
: undefined,
|
|
255
|
+
feedback: reporter && feedbackNeedsRunSeam(config.feedback)
|
|
256
|
+
? { config: config.feedback, match: (r) => reporter.matchAdjudicationItems(r) }
|
|
257
|
+
: undefined,
|
|
185
258
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
186
259
|
});
|
|
187
260
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
188
|
-
if (args.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Respect the author's break-glass opt-out, same as the non-scope path.
|
|
206
|
-
let breakGlass = false;
|
|
207
|
-
try {
|
|
208
|
-
breakGlass = await reporter.checkBreakGlass();
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
breakGlass = false;
|
|
212
|
-
}
|
|
213
|
-
if (breakGlass) {
|
|
214
|
-
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
|
|
261
|
+
if (args.pr != null && args.post) {
|
|
262
|
+
if (reporter) {
|
|
263
|
+
// Respect the author's break-glass opt-out, same as the non-scope path.
|
|
264
|
+
let breakGlass = false;
|
|
265
|
+
try {
|
|
266
|
+
breakGlass = await reporter.checkBreakGlass();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
breakGlass = false;
|
|
270
|
+
}
|
|
271
|
+
if (breakGlass) {
|
|
272
|
+
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${postRepo}#${args.pr} (break-glass).\n`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await reporter.report(review, review.feedback);
|
|
276
|
+
process.stderr.write(`\nPosted scope "${args.scope}" review to ${postRepo}#${args.pr}.\n`);
|
|
277
|
+
}
|
|
215
278
|
}
|
|
216
279
|
else {
|
|
217
|
-
|
|
218
|
-
|
|
280
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — a
|
|
281
|
+
// `gh` failure resolving the repo must not hide the review already
|
|
282
|
+
// printed above; only the post step fails here, with a clear message.
|
|
283
|
+
process.stderr.write(`\nNot posted: could not resolve the repo for --post (${errorMessage(postRepoError)}). Pass --repo owner/repo.\n`);
|
|
284
|
+
process.exitCode = 2;
|
|
219
285
|
}
|
|
220
286
|
}
|
|
221
287
|
}
|
|
@@ -226,39 +292,65 @@ export async function reviewCommand(argv) {
|
|
|
226
292
|
}
|
|
227
293
|
const config = await loadReviewConfig(cwd, { configDir: args.configDir });
|
|
228
294
|
const source = makeSource();
|
|
295
|
+
// Build the PR reporter up front when posting, so adjudicate mode can judge the
|
|
296
|
+
// PR's replies against the source before the result is rendered. Feedback only
|
|
297
|
+
// has replies to match when reviewing a PR and posting (the terminal preview never
|
|
298
|
+
// renders annotations), so it is wired only on the --post --pr path.
|
|
299
|
+
const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
|
|
300
|
+
const reporter = postRepo != null && args.pr != null
|
|
301
|
+
? new GitHubReporter({
|
|
302
|
+
prNumber: args.pr,
|
|
303
|
+
repo: postRepo,
|
|
304
|
+
commentTag: config.commentTag,
|
|
305
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
306
|
+
cwd,
|
|
307
|
+
feedback: config.feedback,
|
|
308
|
+
headSha: await reviewedHeadSha(source),
|
|
309
|
+
})
|
|
310
|
+
: null;
|
|
229
311
|
const review = await runReview(source, {
|
|
230
312
|
config,
|
|
231
313
|
mode: "local",
|
|
232
314
|
agents: args.agents,
|
|
233
315
|
route: args.route,
|
|
316
|
+
contextText,
|
|
317
|
+
// Explicit --stack-aware only (see the scope branch); validateArgs already
|
|
318
|
+
// rejected --stack-aware without --pr.
|
|
319
|
+
stack: args.stackAware && args.pr != null ? stackWalkFromConfig(config.stack) : undefined,
|
|
320
|
+
stackConfirm: args.stackAware && args.pr != null ? stackConfirmFromConfig(config.stack) : undefined,
|
|
321
|
+
feedback: reporter && feedbackNeedsRunSeam(config.feedback)
|
|
322
|
+
? { config: config.feedback, match: (r) => reporter.matchAdjudicationItems(r) }
|
|
323
|
+
: undefined,
|
|
234
324
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
235
325
|
});
|
|
236
326
|
// Always print the result here first.
|
|
237
327
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
238
328
|
// Then, only if asked, publish the same result to the PR.
|
|
239
|
-
if (args.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (breakGlass) {
|
|
257
|
-
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
|
|
329
|
+
if (args.pr != null && args.post) {
|
|
330
|
+
if (reporter) {
|
|
331
|
+
// Respect the author's break-glass opt-out, same as the CI path.
|
|
332
|
+
let breakGlass = false;
|
|
333
|
+
try {
|
|
334
|
+
breakGlass = await reporter.checkBreakGlass();
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
breakGlass = false;
|
|
338
|
+
}
|
|
339
|
+
if (breakGlass) {
|
|
340
|
+
process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${postRepo}#${args.pr} (break-glass).\n`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await reporter.report(review, review.feedback);
|
|
344
|
+
process.stderr.write(`\nPosted review to ${postRepo}#${args.pr}.\n`);
|
|
345
|
+
}
|
|
258
346
|
}
|
|
259
347
|
else {
|
|
260
|
-
|
|
261
|
-
|
|
348
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — a `gh`
|
|
349
|
+
// failure resolving the repo (no auth, no network, no GitHub remote, rate
|
|
350
|
+
// limit) must not hide the review already printed above; only the post
|
|
351
|
+
// step fails here, with a clear message.
|
|
352
|
+
process.stderr.write(`\nNot posted: could not resolve the repo for --post (${errorMessage(postRepoError)}). Pass --repo owner/repo.\n`);
|
|
353
|
+
process.exitCode = 2;
|
|
262
354
|
}
|
|
263
355
|
}
|
|
264
356
|
}
|
|
@@ -267,6 +359,49 @@ export async function reviewCommand(argv) {
|
|
|
267
359
|
process.exitCode = 2;
|
|
268
360
|
}
|
|
269
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* The head commit the review reads, when the source can pin one (a GitHub PR). The
|
|
364
|
+
* reporter binds every adjudication verdict to it, so a stored verdict carries to a
|
|
365
|
+
* later run only while the reviewed source is unchanged (see mergeFeedback). Never
|
|
366
|
+
* throws: an unresolvable head reads as unknown source, which re-judges the reply
|
|
367
|
+
* instead of trusting a verdict about code we cannot pin.
|
|
368
|
+
*/
|
|
369
|
+
async function reviewedHeadSha(source) {
|
|
370
|
+
try {
|
|
371
|
+
return (await source.getMetadata()).headOid;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — local runs
|
|
378
|
+
// still print the review even when --post's repo can't be resolved
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the repo needed for --post --pr, without ever throwing: a `gh` failure
|
|
381
|
+
* (no auth, no network, no GitHub remote, rate limit) must degrade to "skip the
|
|
382
|
+
* post step", not abort the review itself — the local run already trusts the
|
|
383
|
+
* caller and should still show them the review. Callers treat a returned `error`
|
|
384
|
+
* as "no repo, and here's why" once they reach the post step; the review and its
|
|
385
|
+
* optional feedback seam simply run without a reporter until then.
|
|
386
|
+
*
|
|
387
|
+
* `resolve` is injectable (defaults to the real `resolveRepo`) purely so tests can
|
|
388
|
+
* exercise the failure path deterministically, without a `gh` binary or network.
|
|
389
|
+
*/
|
|
390
|
+
export async function resolvePostRepo(args, cwd, resolve = resolveRepo) {
|
|
391
|
+
if (!(args.post && args.pr != null)) {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
if (args.repo) {
|
|
395
|
+
return { repo: args.repo };
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
return { repo: await resolve(cwd) };
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
return { error };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — mutually exclusive flags rejected outright, never silently ignored
|
|
270
405
|
/** Reject flag combinations that don't make sense together. */
|
|
271
406
|
function validateArgs(args) {
|
|
272
407
|
if (args.pr != null && (args.base || args.head || args.staged)) {
|
|
@@ -275,6 +410,11 @@ function validateArgs(args) {
|
|
|
275
410
|
if (args.pr == null && (args.repo || args.post)) {
|
|
276
411
|
throw new Error("--repo/--post only apply together with --pr.");
|
|
277
412
|
}
|
|
413
|
+
// Same rule as --repo/--post: the stack walk needs a PR to walk from, so a bare
|
|
414
|
+
// --stack-aware would be silently ignored — reject it instead.
|
|
415
|
+
if (args.pr == null && args.stackAware) {
|
|
416
|
+
throw new Error("--stack-aware only applies together with --pr.");
|
|
417
|
+
}
|
|
278
418
|
// --staged diffs the index against HEAD, so --base/--head have no effect. Reject
|
|
279
419
|
// the combination rather than silently ignoring the range the user asked for.
|
|
280
420
|
if (args.staged && (args.base || args.head)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#doctor-and-setup-auth — derives a plan from auth config, then guides local credential acquisition
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
@@ -65,6 +66,7 @@ export function opencodeAuthJsonPath(env = process.env) {
|
|
|
65
66
|
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
66
67
|
return path.join(dataHome, "opencode", "auth.json");
|
|
67
68
|
}
|
|
69
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — refresh tokens are single-use; they never leave OpenCode's store
|
|
68
70
|
/**
|
|
69
71
|
* The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
|
|
70
72
|
* refresh token deliberately never leaves OpenCode's store: refresh tokens are
|
|
@@ -102,6 +104,7 @@ async function confirm(question, skip) {
|
|
|
102
104
|
rl.close();
|
|
103
105
|
}
|
|
104
106
|
}
|
|
107
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — shell metacharacters in tokens never expand
|
|
105
108
|
/** The line to paste into a shell config. Single-quoted: tokens never contain '. */
|
|
106
109
|
export function exportLine(tokenEnv, value) {
|
|
107
110
|
return `export ${tokenEnv}='${value}'`;
|
|
@@ -1,3 +1,4 @@
|
|
|
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
|
|
1
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
@@ -28,6 +29,7 @@ Options:
|
|
|
28
29
|
--json Emit {ok, findings:[{file, problem}]} on stdout.
|
|
29
30
|
`;
|
|
30
31
|
const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
|
|
32
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — on-disk sweep, not git index, not the manifest; CONFIG_FILENAMES must mirror load.ts
|
|
31
33
|
/**
|
|
32
34
|
* Discover every config the CLI could ever read via a plain recursive walk (not
|
|
33
35
|
* `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
|
|
@@ -183,6 +185,7 @@ export async function verifyConfig(root, options = {}) {
|
|
|
183
185
|
}
|
|
184
186
|
seen.add(occurrence.value);
|
|
185
187
|
}
|
|
188
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — exact set equality; adding a credential is refused like repointing one
|
|
186
189
|
// With an expectation set, the declared names must equal the expected SET
|
|
187
190
|
// exactly (comma-separated; order-insensitive). A missing name is as much a
|
|
188
191
|
// finding as an extra one — a PR must not add, drop, or repoint credentials.
|
package/build/config/load.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — shared root/scope loader; ECR_CONFIG_DIR escape hatch
|
|
2
|
+
// @ref LLP 0006#model-resolution — REVIEWER_MODEL env override resolution
|
|
3
|
+
// @ref LLP 0006#auth-config-shapes — auth normalization (normalizeAuth, tokenEnvMismatch, loadAuthFromRoot)
|
|
4
|
+
// @ref LLP 0006#root-vs-scope-config — scope config loading, commentTag derivation, enforceAgents injection
|
|
1
5
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
6
|
import { existsSync } from "node:fs";
|
|
3
7
|
import path from "node:path";
|
|
4
8
|
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
|
|
5
9
|
import { toolMap } from "../core/tools.js";
|
|
6
10
|
export const CONFIG_DIRNAME = ".expo-code-review";
|
|
11
|
+
/** Stack config for a scope load (where `stack` is schema-rejected and absent). */
|
|
12
|
+
const STACK_CONFIG_DEFAULTS = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
maxDepth: 4,
|
|
15
|
+
maxPrs: 8,
|
|
16
|
+
maxFilesPerPr: 100,
|
|
17
|
+
requireSameAuthor: true,
|
|
18
|
+
confirmWithPatch: false,
|
|
19
|
+
maxConfirmations: 10,
|
|
20
|
+
};
|
|
21
|
+
/** Feedback config for a scope load (where `feedback` is schema-rejected and absent). */
|
|
22
|
+
const FEEDBACK_CONFIG_DEFAULTS = {
|
|
23
|
+
mode: "annotate",
|
|
24
|
+
match: "both",
|
|
25
|
+
dismiss: "never",
|
|
26
|
+
protectedCategories: ["secrets", "security"],
|
|
27
|
+
maxAdjudications: 10,
|
|
28
|
+
};
|
|
7
29
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
8
30
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
9
31
|
export function configDirFor(repoRoot) {
|
|
@@ -61,6 +83,7 @@ async function loadConfigDir(dir, schema) {
|
|
|
61
83
|
// agent and the coordinator then ran on whatever OpenCode picked by default, so a
|
|
62
84
|
// config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
|
|
63
85
|
// nothing anywhere said so. Trim too: a stray newline is the same class of accident.
|
|
86
|
+
// @ref LLP 0006#model-resolution [constrained-by] — never ??; GitHub Actions passes an unset var as empty string, not undefined
|
|
64
87
|
const override = process.env.REVIEWER_MODEL?.trim() || undefined;
|
|
65
88
|
const defaultModel = override ?? parsed.model;
|
|
66
89
|
const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
|
|
@@ -121,6 +144,14 @@ async function loadConfigDir(dir, schema) {
|
|
|
121
144
|
commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
|
|
122
145
|
auth: normalizeAuth(parsed.auth),
|
|
123
146
|
review: parsed.review,
|
|
147
|
+
// Root-only: the scope schema rejects `stack`, so parsed.stack is absent for a
|
|
148
|
+
// scope config and the defaults stand in (unused — the command layer reads the
|
|
149
|
+
// ROOT config's stack values to drive the walk).
|
|
150
|
+
stack: parsed.stack ?? STACK_CONFIG_DEFAULTS,
|
|
151
|
+
// Root-only: the scope schema rejects `feedback`, so parsed.feedback is absent
|
|
152
|
+
// for a scope config and the defaults stand in (unused — the command layer
|
|
153
|
+
// reads the ROOT config's feedback values; the comment lifecycle is global).
|
|
154
|
+
feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
|
|
124
155
|
};
|
|
125
156
|
return { config, raw: rawObject };
|
|
126
157
|
}
|
|
@@ -185,6 +216,7 @@ export function loadAuthFromRoot(rootConfig, manifest) {
|
|
|
185
216
|
* with the root config; the scope config activates after merge) instead of
|
|
186
217
|
* failing the run on exactly the PR that introduces the scope.
|
|
187
218
|
*/
|
|
219
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — deliberately bypasses ECR_CONFIG_DIR; scope subtrees stay repo-root-relative
|
|
188
220
|
export function hasScopeConfig(root, scope) {
|
|
189
221
|
if (scope.config === ".") {
|
|
190
222
|
return true;
|
|
@@ -224,6 +256,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
224
256
|
// loaded value here is only the manifest default, for display/doctor.
|
|
225
257
|
commentTag = manifest.defaults.commentTag;
|
|
226
258
|
}
|
|
259
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — ROOT enforced agent always wins a same-id scope agent (risk 11)
|
|
227
260
|
// Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
|
|
228
261
|
// same-id agent the scope defines (the enforced one wins — risk 11).
|
|
229
262
|
const agents = base.agents.map((agent) => ({ ...agent }));
|
|
@@ -247,6 +280,12 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
247
280
|
auth: loadAuthFromRoot(rootConfig, manifest),
|
|
248
281
|
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
249
282
|
commentTag,
|
|
283
|
+
// Root-only, like stack: a non-default scope's `base` carries only the
|
|
284
|
+
// hardcoded placeholder (the scope schema rejects `feedback`), so re-derive
|
|
285
|
+
// from the root here or consumers of a nested scope's config would silently
|
|
286
|
+
// run the default policy instead of the repo's real one.
|
|
287
|
+
stack: rootConfig.stack,
|
|
288
|
+
feedback: rootConfig.feedback,
|
|
250
289
|
scopeName: scope.name,
|
|
251
290
|
};
|
|
252
291
|
}
|
package/build/config/routing.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc parsing, scope resolution (last-match-wins), overlaps/unmatched
|
|
2
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — routing.jsonc travels with config.jsonc via resolveConfigDir
|
|
3
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — per-scope budget split (scopePassesBudgetMs)
|
|
1
4
|
import { readFile } from "node:fs/promises";
|
|
2
5
|
import { existsSync } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
@@ -15,6 +18,7 @@ export const ROUTING_FILENAME = "routing.jsonc";
|
|
|
15
18
|
* Scope `config` paths stay repo-root-relative (see `loadScopeConfig`): an
|
|
16
19
|
* override relocates only the ROOT artifacts, never the scopes' own subtrees.
|
|
17
20
|
*/
|
|
21
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — travels with config.jsonc; a real bug once let them split
|
|
18
22
|
export async function loadRoutingManifest(root, options = {}) {
|
|
19
23
|
const manifestPath = path.join(resolveConfigDir(root, options.configDir), ROUTING_FILENAME);
|
|
20
24
|
if (!existsSync(manifestPath)) {
|
|
@@ -30,6 +34,7 @@ export async function loadRoutingManifest(root, options = {}) {
|
|
|
30
34
|
// silently miss root-level files (README.md, package.json). To give the double-star +
|
|
31
35
|
// slash its conventional "zero or more directories" meaning, we also test the variant
|
|
32
36
|
// with each such prefix removed, so the catch-all matches both `a.ts` and `src/b.ts`.
|
|
37
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — workaround for matchesIgnore's **-needs-a-slash limitation (LLP 0004 dialect)
|
|
33
38
|
function patternVariants(pattern) {
|
|
34
39
|
const collapsed = pattern.replace(/\*\*\//g, "");
|
|
35
40
|
return collapsed !== pattern && collapsed.length > 0 ? [pattern, collapsed] : [pattern];
|
|
@@ -44,6 +49,7 @@ function scopeMatches(paths, file) {
|
|
|
44
49
|
* matchesIgnore (supports ** across / and * within a segment — the manifest
|
|
45
50
|
* documents this dialect). Deterministic, no filesystem access.
|
|
46
51
|
*/
|
|
52
|
+
// @ref LLP 0006#routing-manifest [implements] — last-match-wins (CODEOWNERS discipline); each file lands in exactly one scope
|
|
47
53
|
export function resolveScopes(manifest, changedFiles) {
|
|
48
54
|
const buckets = new Map();
|
|
49
55
|
const unmatched = [];
|
|
@@ -86,6 +92,7 @@ export function scopedCommentTag(rootTag, scopeName) {
|
|
|
86
92
|
* starting — and `overshoot` flags that the run will exceed the total budget so
|
|
87
93
|
* the caller can warn. Pure so the math is unit-testable.
|
|
88
94
|
*/
|
|
95
|
+
// @ref LLP 0006#budgets-and-chunking-defaults [implements] — floor(total/active) clamped to a 5-min floor; overshoot flags the clamp
|
|
89
96
|
export function scopePassesBudgetMs(totalMs, minMs, activeCount) {
|
|
90
97
|
const count = Math.max(1, activeCount);
|
|
91
98
|
const evenSplit = Math.floor(totalMs / count);
|
package/build/config/schema.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// @ref LLP 0006#root-vs-scope-config — schema for root vs. scope-overridable config keys
|
|
2
|
+
// @ref LLP 0006#auth-config-shapes — auth union schema (legacy single credential + per-provider map)
|
|
3
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc manifest schema (scopes, budgets, traversal guard)
|
|
4
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — chunk/budget default values and re-tuning heuristics
|
|
1
5
|
import path from "node:path";
|
|
2
6
|
import { z } from "zod";
|
|
7
|
+
import { CATEGORIES } from "../core/schema.js";
|
|
3
8
|
export const ReviewConfigSchema = z.object({
|
|
4
9
|
/** Default model for every agent + the coordinator. Override per-agent via
|
|
5
10
|
* frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
|
|
@@ -68,6 +73,7 @@ export const ReviewConfigSchema = z.object({
|
|
|
68
73
|
// Union order matters: the map form must be tried FIRST — the legacy object's keys
|
|
69
74
|
// all have defaults, so a non-strict legacy parse would accept (and gut) a
|
|
70
75
|
// { providers } object by stripping the unknown key.
|
|
76
|
+
// @ref LLP 0006#auth-config-shapes [constrained-by] — map-first order is load-bearing; reordering silently guts multi-provider auth
|
|
71
77
|
auth: z
|
|
72
78
|
.union([
|
|
73
79
|
z.object({
|
|
@@ -115,6 +121,77 @@ export const ReviewConfigSchema = z.object({
|
|
|
115
121
|
skipLabel: z.string().default("ai-review:skip"),
|
|
116
122
|
})
|
|
117
123
|
.default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
|
|
124
|
+
// Stack-aware requalification: walk the OPEN PRs stacked on top of this one and
|
|
125
|
+
// let the coordinator mark absence-style findings a later PR already addresses.
|
|
126
|
+
// ROOT-ONLY (one PR has one stack) and off by default — a suppression-adjacent
|
|
127
|
+
// feature earns trust with field data first. Under `ecr ci` it auto-enables from
|
|
128
|
+
// this trusted-base value; `ecr review --pr` needs an explicit --stack-aware.
|
|
129
|
+
// @ref LLP 0010#config-and-cli-surface [implements] — root-only + off-by-default; head config can never enable, widen, or disable it
|
|
130
|
+
stack: z
|
|
131
|
+
.object({
|
|
132
|
+
enabled: z.boolean().default(false),
|
|
133
|
+
maxDepth: z.number().int().positive().default(4),
|
|
134
|
+
// Children per level (per parent branch) the walk will follow.
|
|
135
|
+
maxPrs: z.number().int().positive().default(8),
|
|
136
|
+
maxFilesPerPr: z.number().int().positive().default(100),
|
|
137
|
+
// Only children whose author is the current PR's author enter the manifest —
|
|
138
|
+
// closes cross-author poisoning (a push-access colleague opening a child PR on
|
|
139
|
+
// the victim's branch). Set false from the trusted base for genuine team stacks.
|
|
140
|
+
requireSameAuthor: z.boolean().default(true),
|
|
141
|
+
// v2: confirm each requalification against the addressing PR's actual patch
|
|
142
|
+
// before believing it (a no-tools LLM reads the inlined patch). Default false so
|
|
143
|
+
// v2 ships dark until flipped; maxConfirmations bounds that cost.
|
|
144
|
+
confirmWithPatch: z.boolean().default(false),
|
|
145
|
+
maxConfirmations: z.number().int().positive().default(10),
|
|
146
|
+
})
|
|
147
|
+
.default({
|
|
148
|
+
enabled: false,
|
|
149
|
+
maxDepth: 4,
|
|
150
|
+
maxPrs: 8,
|
|
151
|
+
maxFilesPerPr: 100,
|
|
152
|
+
requireSameAuthor: true,
|
|
153
|
+
confirmWithPatch: false,
|
|
154
|
+
maxConfirmations: 10,
|
|
155
|
+
}),
|
|
156
|
+
// Author replies to findings: match them to the finding they answer, record
|
|
157
|
+
// them in the comment's embedded state, and (optionally) let a model judge the
|
|
158
|
+
// rebuttal against the source. ROOT-ONLY: the comment lifecycle is global.
|
|
159
|
+
// Defaults are deliberately ASYMMETRIC: `annotate` is on but `dismiss` is off.
|
|
160
|
+
// An adopting repo has its own config.jsonc and never re-copies this template,
|
|
161
|
+
// so a key it never set must still resolve to the safe, useful default via
|
|
162
|
+
// zod — annotating is safe and useful out of the box; suppressing a finding is
|
|
163
|
+
// not, so it stays opt-in.
|
|
164
|
+
// @ref LLP 0011#asymmetric-defaults [implements] — annotate on, dismiss off; adopting repos never re-copy the template
|
|
165
|
+
feedback: z
|
|
166
|
+
.object({
|
|
167
|
+
// "off" — ignore replies entirely.
|
|
168
|
+
// "annotate" — match + record + show "author replied" (no decision effect).
|
|
169
|
+
// "adjudicate" — also run a source-grounded judgment of the rebuttal and
|
|
170
|
+
// record its verdict. Dismissal still obeys `dismiss`.
|
|
171
|
+
mode: z.enum(["off", "annotate", "adjudicate"]).default("annotate"),
|
|
172
|
+
// How a reply is MATCHED to a finding. Clearing one additionally requires the
|
|
173
|
+
// reply to cite its `id:` token in the replier's own words, whatever this says.
|
|
174
|
+
match: z.enum(["quote", "id", "both"]).default("both"),
|
|
175
|
+
// Who/what may actually remove a finding from the blocking set (always on a
|
|
176
|
+
// reply citing the finding's `id:` token — a quote only annotates):
|
|
177
|
+
// "never" — nothing does (default: adjudication ships dark).
|
|
178
|
+
// "maintainers" — a maintainer reply dismisses, no model involved.
|
|
179
|
+
// "adjudicated" — a maintainer reply, or an author reply the adjudicator
|
|
180
|
+
// confirmed against the source.
|
|
181
|
+
dismiss: z.enum(["never", "maintainers", "adjudicated"]).default("never"),
|
|
182
|
+
// Categories a reply can NEVER clear, whatever the verdict. Also hard-coded
|
|
183
|
+
// as a floor in code — this only widens the set, never narrows it.
|
|
184
|
+
protectedCategories: z.array(z.enum(CATEGORIES)).default(["secrets", "security"]),
|
|
185
|
+
// Cap on adjudication model calls per run.
|
|
186
|
+
maxAdjudications: z.number().int().positive().default(10),
|
|
187
|
+
})
|
|
188
|
+
.default({
|
|
189
|
+
mode: "annotate",
|
|
190
|
+
match: "both",
|
|
191
|
+
dismiss: "never",
|
|
192
|
+
protectedCategories: ["secrets", "security"],
|
|
193
|
+
maxAdjudications: 10,
|
|
194
|
+
}),
|
|
118
195
|
});
|
|
119
196
|
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
120
197
|
export const RoutingScopeSchema = z.object({
|
|
@@ -126,6 +203,7 @@ export const RoutingScopeSchema = z.object({
|
|
|
126
203
|
* routing.jsonc is read from the PR-head checkout, so this field is
|
|
127
204
|
* PR-controllable input: absolute paths and `..` traversal are rejected so a
|
|
128
205
|
* scope config can never resolve outside the repo. */
|
|
206
|
+
// @ref LLP 0006#routing-manifest [implements] — traversal guard; load.ts re-checks at runtime (defense in depth)
|
|
129
207
|
config: z
|
|
130
208
|
.string()
|
|
131
209
|
.min(1)
|
|
@@ -159,6 +237,7 @@ export const RoutingManifestSchema = z
|
|
|
159
237
|
* chain still fires the default when the key is absent, which would make
|
|
160
238
|
* `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
|
|
161
239
|
* manifest that omits auth and silently override the root config's real auth. */
|
|
240
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — zod v4 default().optional() trap; unwrap avoids a phantom auth stub
|
|
162
241
|
auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
|
|
163
242
|
/** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
|
|
164
243
|
enforceAgents: z.array(z.string()).default([]),
|
|
@@ -206,10 +285,13 @@ export const RoutingManifestSchema = z
|
|
|
206
285
|
* standalone `ecr review --scope --post` always target the same marker — an
|
|
207
286
|
* honored per-scope tag would let the two halves strand each other's comments.
|
|
208
287
|
*/
|
|
288
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — one of three enforcement layers; z.never fails at parse, not runtime
|
|
209
289
|
export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
210
290
|
auth: true,
|
|
211
291
|
breakGlass: true,
|
|
212
292
|
commentTag: true,
|
|
293
|
+
stack: true,
|
|
294
|
+
feedback: true,
|
|
213
295
|
}).extend({
|
|
214
296
|
auth: z
|
|
215
297
|
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
@@ -220,4 +302,14 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
220
302
|
error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
|
|
221
303
|
})
|
|
222
304
|
.optional(),
|
|
305
|
+
stack: z
|
|
306
|
+
.never({
|
|
307
|
+
error: "stack is locked to the root config (one PR has one stack); remove it from this scope config",
|
|
308
|
+
})
|
|
309
|
+
.optional(),
|
|
310
|
+
feedback: z
|
|
311
|
+
.never({
|
|
312
|
+
error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
|
|
313
|
+
})
|
|
314
|
+
.optional(),
|
|
223
315
|
});
|