@expo/code-review-cli 0.7.0 → 0.8.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 (54) hide show
  1. package/README.md +118 -13
  2. package/build/cli.js +7 -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/review.js +191 -51
  9. package/build/commands/setup-auth.js +3 -0
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +92 -0
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +5 -1
  16. package/build/core/claude-code.js +12 -1
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +4 -0
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +22 -0
  24. package/build/core/prompts.js +311 -3
  25. package/build/core/render.js +255 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +290 -15
  28. package/build/core/schema.js +213 -2
  29. package/build/core/scrub.js +4 -0
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +2 -0
  35. package/build/core/util.js +1 -0
  36. package/build/core/verify.js +5 -0
  37. package/build/reporters/github.js +465 -31
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +272 -0
  40. package/build/sources/local-git.js +3 -0
  41. package/build/sources/source.js +35 -0
  42. package/package.json +2 -1
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +50 -1
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +96 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#init-and-dismiss — scaffolds .expo-code-review/; --scope validates before any file lands on disk
1
2
  import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { existsSync } from "node:fs";
3
4
  import path from "node:path";
@@ -5,6 +6,7 @@ import { fileURLToPath } from "node:url";
5
6
  import { CONFIG_DIRNAME } from "../config/load.js";
6
7
  import { ROUTING_FILENAME } from "../config/routing.js";
7
8
  import { RoutingScopeSchema } from "../config/schema.js";
9
+ import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
8
10
  import { repoRoot } from "../core/exec.js";
9
11
  import { errorMessage } from "../core/util.js";
10
12
  const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
@@ -21,9 +23,18 @@ Options:
21
23
  --scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
22
24
  --no-workflow Skip writing the CI workflows (review, command, and dismiss
23
25
  under .github/workflows/)
26
+ --token-env <name[,name…]>
27
+ Env var(s) holding the model credential (default OPENAI_API_KEY,
28
+ e.g. CLAUDE_CODE_OAUTH_TOKEN). The scaffolded workflows forward
29
+ the matching repo secret(s) and expect this tokenEnv
24
30
  --force Overwrite existing files
31
+ --force-workflows
32
+ Overwrite only the CI workflow files, keeping your customized
33
+ config.jsonc, prompts, and agents/. Use this to re-run
34
+ --token-env on an already-scaffolded repo.
25
35
  -h, --help Show this help
26
36
  `;
37
+ const DEFAULT_TOKEN_ENV = "OPENAI_API_KEY";
27
38
  export async function initCommand(argv) {
28
39
  if (argv.includes("-h") || argv.includes("--help")) {
29
40
  process.stdout.write(USAGE);
@@ -32,6 +43,9 @@ export async function initCommand(argv) {
32
43
  try {
33
44
  const scopeDir = parseValue(argv, "--scope");
34
45
  if (scopeDir != null) {
46
+ if (parseValue(argv, "--token-env") != null) {
47
+ throw new Error("--token-env applies to the root scaffold's workflows; drop it from `--scope`");
48
+ }
35
49
  await scaffoldScope(argv, scopeDir);
36
50
  }
37
51
  else {
@@ -46,13 +60,58 @@ export async function initCommand(argv) {
46
60
  /** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
47
61
  async function scaffold(argv) {
48
62
  const force = argv.includes("--force");
63
+ // Re-running --token-env on an adopted repo needs to rewrite the workflow YAML
64
+ // without clobbering the adopter's tuned prompts and auth config, so the
65
+ // workflow writes honor a narrower force that leaves everything else skipped.
66
+ const forceWorkflows = force || argv.includes("--force-workflows");
49
67
  // The CI workflow is scaffolded by default (most repos adopting this want it);
50
68
  // `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
51
69
  // back-compat.
52
70
  const withWorkflow = !argv.includes("--no-workflow");
53
71
  const monorepo = argv.includes("--monorepo");
72
+ // Validate before any file is written so a bad flag can't leave a half scaffold.
73
+ const tokenEnvs = parseTokenEnvs(parseValue(argv, "--token-env"));
74
+ if (!withWorkflow && parseValue(argv, "--token-env") != null) {
75
+ throw new Error("--token-env customizes the CI workflows; drop it or remove --no-workflow");
76
+ }
54
77
  const root = (await repoRoot()) ?? process.cwd();
55
78
  const configDir = path.join(root, CONFIG_DIRNAME);
79
+ // A non-default --token-env only takes effect through the review workflows,
80
+ // but existing workflow files are skipped (not rewritten) without --force —
81
+ // the flag would silently never reach CI while the next steps claim the
82
+ // workflow references the new secret. Refuse before any file is written.
83
+ // @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
84
+ if (withWorkflow && tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV && !forceWorkflows) {
85
+ const existing = ["expo-code-review.yml", "expo-code-review-command.yml"]
86
+ .map((name) => path.join(".github", "workflows", name))
87
+ .filter((rel) => existsSync(path.join(root, rel)));
88
+ if (existing.length > 0) {
89
+ throw new Error(`--token-env cannot take effect: ${existing.join(" and ")} already ` +
90
+ `exist${existing.length > 1 ? "" : "s"} and would be skipped, so CI would keep ` +
91
+ `forwarding the default secret. Re-run with --force-workflows to rewrite just the ` +
92
+ `workflows (keeps your config.jsonc, prompts, and agents/), with --force to rewrite ` +
93
+ `everything, or edit their ECR_EXPECTED_TOKEN_ENV fallback and forwarded secret ` +
94
+ `lines by hand.`);
95
+ }
96
+ }
97
+ // The reverse of the guard above: --force-workflows (or --force) always rewrites
98
+ // the review workflows from the pristine template, which forwards the default
99
+ // OPENAI_API_KEY unless --token-env names the credential again. An adopter who
100
+ // scaffolded with a non-default tokenEnv and later refreshes the workflow YAML
101
+ // without re-passing --token-env would silently lose the forwarded secret, and
102
+ // CI would keep passing the auth lock but run with an empty credential. Refuse
103
+ // before any file is written; naming --token-env again (or the default, to reset
104
+ // on purpose) is the explicit opt-in.
105
+ if (withWorkflow && forceWorkflows && parseValue(argv, "--token-env") == null) {
106
+ const baked = await detectWorkflowTokenEnv(root);
107
+ if (baked != null && baked !== DEFAULT_TOKEN_ENV) {
108
+ throw new Error(`refusing to revert the CI credential wiring: the existing review workflows forward ` +
109
+ `${baked}, but this run has no --token-env, so rewriting them would restore the ` +
110
+ `default ${DEFAULT_TOKEN_ENV} and CI would run with an empty credential. Re-run with ` +
111
+ `--token-env ${baked} to keep the current credential, or --token-env ${DEFAULT_TOKEN_ENV} ` +
112
+ `to reset to OpenAI on purpose.`);
113
+ }
114
+ }
56
115
  // Create only the config dir; let copyInto create prompts/ so it reports
57
116
  // accurately as created vs skipped.
58
117
  await mkdir(configDir, { recursive: true });
@@ -78,25 +137,35 @@ async function scaffold(argv) {
78
137
  await mkdir(workflowDir, { recursive: true });
79
138
  // The auto (pull_request) workflow, plus the two issue_comment command
80
139
  // workflows: `/review` (on-demand one-shot) and `/dismiss` (hide a finding).
81
- await copyInto(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), force, created, skipped, root);
82
- await copyInto(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), force, created, skipped, root);
83
- await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), force, created, skipped, root);
140
+ // The two review-running workflows get the tokenEnv substituted so the
141
+ // credential mapping stays STATIC in the committed YAML (the auth lock relies
142
+ // on that); dismiss.yml runs no model and needs no credential.
143
+ await copyTemplate(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
144
+ await copyTemplate(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
145
+ await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), forceWorkflows, created, skipped, root);
84
146
  }
85
147
  reportFiles(created, skipped);
86
- process.stdout.write([
87
- "",
88
- "Next steps:",
89
- ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
90
- " 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
91
- " 3. Run `ecr doctor`, then `ecr review`.",
148
+ const names = tokenEnvs.map((name) => `\`${name}\``).join(" + ");
149
+ const steps = [
150
+ `Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
151
+ // --token-env only rewires the workflows; the scaffolded config.jsonc still
152
+ // declares OPENAI_API_KEY, and CI's `ecr verify-config` refuses to review
153
+ // until the config's tokenEnv set matches the workflow's expected set.
154
+ ...(tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV
155
+ ? [
156
+ `Point ${CONFIG_DIRNAME}/config.jsonc at ${tokenEnvs.length > 1 ? "these credentials" : "this credential"}: set \`auth\` (and \`model\`) per the file's comments — CI's \`ecr verify-config\` refuses to review until the config names ${names}.`,
157
+ ]
158
+ : []),
159
+ "Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
160
+ "Run `ecr doctor`, then `ecr review`.",
92
161
  withWorkflow
93
- ? " 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR."
94
- : " 4. (No CI workflow written — re-run without `--no-workflow` to add it.)",
162
+ ? `Add the ${names} repo secret${tokenEnvs.length > 1 ? "s" : ""} referenced by the workflow, then add an \`ai-review\` label to a PR.`
163
+ : "(No CI workflow written — re-run without `--no-workflow` to add it.)",
95
164
  monorepo
96
- ? ` 5. Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
97
- : ` 5. Monorepo? Run \`ecr init --monorepo\` to add a routing manifest.`,
98
- "",
99
- ].join("\n"));
165
+ ? `Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
166
+ : "Monorepo? Run `ecr init --monorepo` to add a routing manifest.",
167
+ ];
168
+ process.stdout.write(["", "Next steps:", ...steps.map((step, index) => ` ${index + 1}. ${step}`), ""].join("\n"));
100
169
  }
101
170
  /**
102
171
  * Scaffold a per-team scope under <dir>: <dir>/.expo-code-review/ with a no-auth
@@ -110,6 +179,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
110
179
  if (!existsSync(routingPath)) {
111
180
  throw new Error(`no ${CONFIG_DIRNAME}/${ROUTING_FILENAME} — run \`ecr init --monorepo\` first`);
112
181
  }
182
+ // @ref LLP 0007#init-and-dismiss [implements] — traversal and unparsable-routing.jsonc prevented before any file lands
113
183
  // Derive the scope entry and validate it BEFORE creating any files: the name must
114
184
  // satisfy RoutingScopeSchema's kebab-case rule (derived by sanitizing the dir,
115
185
  // apps/Foo_Bar -> apps-foo-bar), and the config path is rejected when absolute or
@@ -172,6 +242,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
172
242
  "",
173
243
  ].join("\n"));
174
244
  }
245
+ // @ref LLP 0007#init-and-dismiss [implements] — comment-preserving text surgery; idempotent; comma placement rules
175
246
  /**
176
247
  * Insert a scope entry before the closing ] of the "scopes" array in raw JSONC,
177
248
  * preserving comments/formatting. Returns the new text, the original text unchanged
@@ -293,3 +364,148 @@ async function copyInto(src, dest, force, created, skipped, root) {
293
364
  await cp(src, dest, { recursive: true, force, errorOnExist: false });
294
365
  (existed && !force ? skipped : created).push(path.relative(root, dest));
295
366
  }
367
+ /** Like copyInto for a single file, but pipes the content through `transform`. */
368
+ async function copyTemplate(src, dest, force, created, skipped, root, transform) {
369
+ if (existsSync(dest) && !force) {
370
+ skipped.push(path.relative(root, dest));
371
+ return;
372
+ }
373
+ await writeFile(dest, transform(await readFile(src, "utf8")), "utf8");
374
+ created.push(path.relative(root, dest));
375
+ }
376
+ /**
377
+ * Parse + validate `--token-env`: a comma-separated list of env var names holding
378
+ * the model credential(s). Refuses names the runtime would refuse anyway
379
+ * (FORBIDDEN_TOKEN_ENVS) so a bad choice fails here, not at review time.
380
+ */
381
+ // @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
382
+ export function parseTokenEnvs(value) {
383
+ if (value == null) {
384
+ return [DEFAULT_TOKEN_ENV];
385
+ }
386
+ const names = value.split(",").map((name) => name.trim());
387
+ if (names.some((name) => !/^[A-Z][A-Z0-9_]*$/.test(name))) {
388
+ throw new Error(`--token-env must be UPPER_SNAKE_CASE env var name(s), got "${value}"`);
389
+ }
390
+ for (const name of names) {
391
+ if (FORBIDDEN_TOKEN_ENVS.has(name)) {
392
+ throw new Error(`--token-env ${name} is a well-known unrelated secret; the reviewer refuses it`);
393
+ }
394
+ }
395
+ if (new Set(names).size !== names.length) {
396
+ throw new Error(`--token-env has duplicate names: "${value}"`);
397
+ }
398
+ return names;
399
+ }
400
+ // Secrets that a review workflow forwards for reasons other than the model
401
+ // credential, so a non-default name here is not a baked credential to preserve.
402
+ const NON_MODEL_FORWARDED_SECRETS = new Set(["GH_TOKEN", "GITHUB_TOKEN"]);
403
+ /**
404
+ * Detect the non-default model credential an existing review workflow bakes in,
405
+ * so a --force-workflows run can refuse to silently revert it to the default
406
+ * instead of rewriting from the pristine (OpenAI) template. Reads two
407
+ * independent signals and returns whichever is non-default:
408
+ * - the `ECR_EXPECTED_TOKEN_ENV` fallback (`vars.ECR_EXPECTED_TOKEN_ENV || '<name>'`),
409
+ * the joined list init bakes for the auth lock; and
410
+ * - the forwarded credential lines (`<NAME>: ${{ secrets.<NAME> }}`), which are
411
+ * what actually exposes the secret to the job. A hand edit — or a repo-variable
412
+ * lock (`vars.ECR_EXPECTED_TOKEN_ENV`) that leaves the YAML fallback at the
413
+ * default — can change the forwarded line alone, so reading only the fallback
414
+ * would miss the baked credential and revert it.
415
+ * Returns the credential value to re-pass via --token-env, or null when the
416
+ * workflows forward only the default (or none exist).
417
+ */
418
+ async function detectWorkflowTokenEnv(root) {
419
+ for (const name of ["expo-code-review.yml", "expo-code-review-command.yml"]) {
420
+ const file = path.join(root, ".github", "workflows", name);
421
+ if (!existsSync(file)) {
422
+ continue;
423
+ }
424
+ const raw = await readFile(file, "utf8");
425
+ const fallback = raw.match(/vars\.ECR_EXPECTED_TOKEN_ENV \|\| '([^']*)'/);
426
+ if (fallback && fallback[1] !== DEFAULT_TOKEN_ENV) {
427
+ return fallback[1];
428
+ }
429
+ const forwarded = forwardedModelCredentials(raw);
430
+ if (forwarded.length > 0) {
431
+ return forwarded.join(",");
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ /**
437
+ * Slice the `Run AI review` step (its `env:` maps the model credential) out of a
438
+ * workflow file, so credential detection reads only the block the scaffold owns
439
+ * and not secrets a hand edit forwards to unrelated steps (a deploy step's AWS
440
+ * keys, an acknowledge step's GH_TOKEN, …). Steps sit at 6-space `- ` indent, so
441
+ * the step runs until the next such line or EOF. Returns "" when the step is
442
+ * absent (drifted or removed template).
443
+ */
444
+ function runAiReviewStep(raw) {
445
+ const start = raw.indexOf("- name: Run AI review");
446
+ if (start === -1) {
447
+ return "";
448
+ }
449
+ const rest = raw.slice(start);
450
+ const nextStep = rest.search(/\n {6}- /);
451
+ return nextStep === -1 ? rest : rest.slice(0, nextStep);
452
+ }
453
+ /**
454
+ * Names of the non-default model credential a workflow's `Run AI review` step
455
+ * forwards, read from its `<NAME>: ${{ secrets.<...> }}` env lines. Skips the
456
+ * default, the known non-model secrets (GH_TOKEN), and any FORBIDDEN_TOKEN_ENVS
457
+ * name — the runtime refuses those as a model credential, so surfacing one as a
458
+ * baked credential would produce a remediation (`--token-env <name>`) that either
459
+ * cannot pass parseTokenEnvs or would wire an unrelated secret to the provider.
460
+ * Scanning is scoped to the credential block the scaffold owns so a secret a hand
461
+ * edit forwards to another step is never mistaken for the model credential.
462
+ */
463
+ function forwardedModelCredentials(raw) {
464
+ const names = [];
465
+ const re = /^\s*([A-Z][A-Z0-9_]*):\s*\$\{\{\s*secrets\.[A-Z][A-Z0-9_]*\s*\}\}/gm;
466
+ for (const match of runAiReviewStep(raw).matchAll(re)) {
467
+ const name = match[1];
468
+ if (name === DEFAULT_TOKEN_ENV ||
469
+ NON_MODEL_FORWARDED_SECRETS.has(name) ||
470
+ FORBIDDEN_TOKEN_ENVS.has(name)) {
471
+ continue;
472
+ }
473
+ if (!names.includes(name)) {
474
+ names.push(name);
475
+ }
476
+ }
477
+ return names;
478
+ }
479
+ /**
480
+ * Rewrite a scaffolded review workflow for a non-default tokenEnv: the
481
+ * ECR_EXPECTED_TOKEN_ENV fallback and the forwarded credential secret(s). GitHub
482
+ * Actions never exposes a secret the YAML doesn't map explicitly, so without this
483
+ * a Claude/Codex setup would pass the auth lock but run with an empty credential.
484
+ * Throws when a template marker is missing (template drift must fail loudly, not
485
+ * scaffold a workflow that silently keeps the OpenAI-only wiring).
486
+ */
487
+ // @ref LLP 0009#what-ecr-init-scaffolds [implements] — static credential mapping baked at init from a trusted flag, never resolved at run time
488
+ export function substituteTokenEnv(raw, tokenEnvs) {
489
+ const joined = tokenEnvs.join(",");
490
+ if (joined === DEFAULT_TOKEN_ENV) {
491
+ return raw;
492
+ }
493
+ const expectedFallback = `vars.ECR_EXPECTED_TOKEN_ENV || '${DEFAULT_TOKEN_ENV}'`;
494
+ const credentialBlock = [
495
+ " # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.",
496
+ " # Store it as a repo secret; a project-scoped key restricted to model",
497
+ " # inference (with a spend limit) is all the reviewer needs.",
498
+ " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
499
+ ].join("\n");
500
+ if (!raw.includes(expectedFallback) || !raw.includes(credentialBlock)) {
501
+ throw new Error("workflow template drifted: tokenEnv markers not found (report this bug)");
502
+ }
503
+ const replacement = [
504
+ ` # Model credential${tokenEnvs.length > 1 ? "s" : ""} — the env var${tokenEnvs.length > 1 ? "s" : ""} named by auth.tokenEnv in config.jsonc.`,
505
+ " # Store each as a repo secret under the same name.",
506
+ ...tokenEnvs.map((name) => ` ${name}: \${{ secrets.${name} }}`),
507
+ ].join("\n");
508
+ return raw
509
+ .replaceAll(expectedFallback, `vars.ECR_EXPECTED_TOKEN_ENV || '${joined}'`)
510
+ .replace(credentialBlock, replacement);
511
+ }
@@ -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)) {