@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.
Files changed (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -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.post && args.pr != null) {
189
- const repo = args.repo ?? (await resolveRepo(cwd));
190
- // A scope always posts under the DERIVED marker `<rootTag>:<scope>`
191
- // from the ROOT config's tag exactly like `ecr ci` does (runRoutedCi
192
- // prefers rootConfig.commentTag over manifest defaults when they
193
- // diverge) so a standalone scope post and CI's per-scope post/clear/
194
- // reconcile paths always target the same marker, and the bare aggregate
195
- // marker is never used here. (Per-scope commentTag overrides are
196
- // rejected by the scope schema for exactly this reason.)
197
- const tag = scopedCommentTag(rootConfig.commentTag, args.scope);
198
- const reporter = new GitHubReporter({
199
- prNumber: args.pr,
200
- repo,
201
- commentTag: tag,
202
- breakGlassMarker: config.breakGlassMarker,
203
- cwd,
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
- await reporter.report(review);
218
- process.stderr.write(`\nPosted scope "${args.scope}" review to ${repo}#${args.pr}.\n`);
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.post && args.pr != null) {
240
- const repo = args.repo ?? (await resolveRepo(cwd));
241
- const reporter = new GitHubReporter({
242
- prNumber: args.pr,
243
- repo,
244
- commentTag: config.commentTag,
245
- breakGlassMarker: config.breakGlassMarker,
246
- cwd,
247
- });
248
- // Respect the author's break-glass opt-out, same as the CI path.
249
- let breakGlass = false;
250
- try {
251
- breakGlass = await reporter.checkBreakGlass();
252
- }
253
- catch {
254
- breakGlass = false;
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
- await reporter.report(review);
261
- process.stderr.write(`\nPosted review to ${repo}#${args.pr}.\n`);
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.
@@ -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
  }
@@ -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);
@@ -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
  });