@expo/code-review-cli 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
@@ -1,11 +1,13 @@
1
- import { loadReviewConfig } from '../config/load.js';
2
- import { repoRoot, resolveRepo } from '../core/exec.js';
3
- import { errorMessage } from '../core/util.js';
4
- import { runReview } from '../core/review.js';
5
- import { LocalGitSource } from '../sources/local-git.js';
6
- import { GitHubPRSource } from '../sources/github-pr.js';
7
- import { TerminalReporter } from '../reporters/terminal.js';
8
- import { GitHubReporter } from '../reporters/github.js';
1
+ import { loadReviewConfig, loadScopeConfig } from "../config/load.js";
2
+ import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/routing.js";
3
+ import { repoRoot, resolveRepo } from "../core/exec.js";
4
+ import { errorMessage } from "../core/util.js";
5
+ import { runReview } from "../core/review.js";
6
+ import { LocalGitSource } from "../sources/local-git.js";
7
+ import { GitHubPRSource } from "../sources/github-pr.js";
8
+ import { memoizeSource } from "../sources/source.js";
9
+ import { TerminalReporter } from "../reporters/terminal.js";
10
+ import { GitHubReporter } from "../reporters/github.js";
9
11
  const USAGE = `ecr review — AI code review, printed to your terminal
10
12
 
11
13
  Usage:
@@ -28,6 +30,10 @@ Options:
28
30
  to publish.
29
31
  --agents <a,b> run only these agents (comma-separated ids); default: all
30
32
  --route let the router pick relevant agents from the diff
33
+ --scope <name> review only this routing scope (needs a routing.jsonc);
34
+ runs its config over just that scope's changed files
35
+ --config-dir <dir> load config from <dir> instead of .expo-code-review/
36
+ (also ECR_CONFIG_DIR); can't combine with --scope
31
37
  --json emit machine-readable JSON on stdout
32
38
  --no-fail always exit 0, even on request-changes
33
39
  -h, --help show this help
@@ -39,7 +45,7 @@ on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
39
45
  Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
40
46
  `;
41
47
  function requireValue(flag, value) {
42
- if (value === undefined || value.startsWith('--')) {
48
+ if (value === undefined || value.startsWith("--")) {
43
49
  throw new Error(`${flag} requires a value`);
44
50
  }
45
51
  return value;
@@ -56,16 +62,16 @@ function parseArgs(argv) {
56
62
  for (let i = 0; i < argv.length; i++) {
57
63
  const arg = argv[i];
58
64
  switch (arg) {
59
- case '--base':
65
+ case "--base":
60
66
  args.base = requireValue(arg, argv[++i]);
61
67
  break;
62
- case '--head':
68
+ case "--head":
63
69
  args.head = requireValue(arg, argv[++i]);
64
70
  break;
65
- case '--staged':
71
+ case "--staged":
66
72
  args.staged = true;
67
73
  break;
68
- case '--pr': {
74
+ case "--pr": {
69
75
  const value = requireValue(arg, argv[++i]);
70
76
  const number = Number(value);
71
77
  if (!Number.isInteger(number) || number <= 0) {
@@ -74,29 +80,35 @@ function parseArgs(argv) {
74
80
  args.pr = number;
75
81
  break;
76
82
  }
77
- case '--repo':
83
+ case "--repo":
78
84
  args.repo = requireValue(arg, argv[++i]);
79
85
  break;
80
- case '--post':
86
+ case "--post":
81
87
  args.post = true;
82
88
  break;
83
- case '--agents':
89
+ case "--agents":
84
90
  args.agents = requireValue(arg, argv[++i])
85
- .split(',')
86
- .map(id => id.trim())
91
+ .split(",")
92
+ .map((id) => id.trim())
87
93
  .filter(Boolean);
88
94
  break;
89
- case '--route':
95
+ case "--route":
90
96
  args.route = true;
91
97
  break;
92
- case '--json':
98
+ case "--scope":
99
+ args.scope = requireValue(arg, argv[++i]);
100
+ break;
101
+ case "--config-dir":
102
+ args.configDir = requireValue(arg, argv[++i]);
103
+ break;
104
+ case "--json":
93
105
  args.json = true;
94
106
  break;
95
- case '--no-fail':
107
+ case "--no-fail":
96
108
  args.noFail = true;
97
109
  break;
98
- case '-h':
99
- case '--help':
110
+ case "-h":
111
+ case "--help":
100
112
  args.help = true;
101
113
  break;
102
114
  default:
@@ -134,17 +146,90 @@ export async function reviewCommand(argv) {
134
146
  process.chdir(root);
135
147
  }
136
148
  try {
137
- const config = await loadReviewConfig(process.cwd());
138
149
  const cwd = process.cwd();
139
- const source = args.pr != null
150
+ const makeSource = () => args.pr != null
140
151
  ? new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd })
141
152
  : new LocalGitSource({ base: args.base, head: args.head, staged: args.staged, cwd });
153
+ // --scope: load the named scope's config and review only that scope's files.
154
+ if (args.scope) {
155
+ // --scope and --config-dir are mutually exclusive (validateArgs), so
156
+ // args.configDir is undefined here; pass it through for consistency and so
157
+ // the manifest always resolves from the same dir as the root config.
158
+ const manifest = await loadRoutingManifest(cwd, { configDir: args.configDir });
159
+ if (!manifest) {
160
+ throw new Error("no .expo-code-review/routing.jsonc — --scope requires a routing manifest");
161
+ }
162
+ const scopeDef = manifest.scopes.find((scope) => scope.name === args.scope);
163
+ if (!scopeDef) {
164
+ throw new Error(`unknown scope "${args.scope}". Known scopes: ${manifest.scopes.map((s) => s.name).join(", ")}`);
165
+ }
166
+ const rootConfig = await loadReviewConfig(cwd);
167
+ const config = await loadScopeConfig(cwd, scopeDef, manifest, rootConfig);
168
+ const source = memoizeSource(makeSource());
169
+ try {
170
+ const changed = await source.getChangedFiles();
171
+ const resolution = resolveScopes(manifest, changed.map((file) => file.path));
172
+ const files = resolution.active.find((scope) => scope.name === args.scope)?.files ?? [];
173
+ if (files.length === 0) {
174
+ process.stdout.write(`No changed files in scope ${args.scope}.\n`);
175
+ return;
176
+ }
177
+ const review = await runReview(source, {
178
+ config,
179
+ mode: "local",
180
+ agents: args.agents,
181
+ route: args.route,
182
+ includePaths: files,
183
+ onProgress: (message) => process.stderr.write(`${message}\n`),
184
+ });
185
+ await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
186
+ if (args.post && args.pr != null) {
187
+ const repo = args.repo ?? (await resolveRepo(cwd));
188
+ // A scope always posts under the DERIVED marker `<rootTag>:<scope>` —
189
+ // from the ROOT config's tag exactly like `ecr ci` does (runRoutedCi
190
+ // prefers rootConfig.commentTag over manifest defaults when they
191
+ // diverge) — so a standalone scope post and CI's per-scope post/clear/
192
+ // reconcile paths always target the same marker, and the bare aggregate
193
+ // marker is never used here. (Per-scope commentTag overrides are
194
+ // rejected by the scope schema for exactly this reason.)
195
+ const tag = scopedCommentTag(rootConfig.commentTag, args.scope);
196
+ const reporter = new GitHubReporter({
197
+ prNumber: args.pr,
198
+ repo,
199
+ commentTag: tag,
200
+ breakGlassMarker: config.breakGlassMarker,
201
+ cwd,
202
+ });
203
+ // Respect the author's break-glass opt-out, same as the non-scope path.
204
+ let breakGlass = false;
205
+ try {
206
+ breakGlass = await reporter.checkBreakGlass();
207
+ }
208
+ catch {
209
+ breakGlass = false;
210
+ }
211
+ if (breakGlass) {
212
+ process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
213
+ }
214
+ else {
215
+ await reporter.report(review);
216
+ process.stderr.write(`\nPosted scope "${args.scope}" review to ${repo}#${args.pr}.\n`);
217
+ }
218
+ }
219
+ }
220
+ finally {
221
+ await source.dispose();
222
+ }
223
+ return;
224
+ }
225
+ const config = await loadReviewConfig(cwd, { configDir: args.configDir });
226
+ const source = makeSource();
142
227
  const review = await runReview(source, {
143
228
  config,
144
- mode: 'local',
229
+ mode: "local",
145
230
  agents: args.agents,
146
231
  route: args.route,
147
- onProgress: message => process.stderr.write(`${message}\n`),
232
+ onProgress: (message) => process.stderr.write(`${message}\n`),
148
233
  });
149
234
  // Always print the result here first.
150
235
  await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
@@ -183,15 +268,18 @@ export async function reviewCommand(argv) {
183
268
  /** Reject flag combinations that don't make sense together. */
184
269
  function validateArgs(args) {
185
270
  if (args.pr != null && (args.base || args.head || args.staged)) {
186
- throw new Error('--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.');
271
+ throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
187
272
  }
188
273
  if (args.pr == null && (args.repo || args.post)) {
189
- throw new Error('--repo/--post only apply together with --pr.');
274
+ throw new Error("--repo/--post only apply together with --pr.");
190
275
  }
191
276
  // --staged diffs the index against HEAD, so --base/--head have no effect. Reject
192
277
  // the combination rather than silently ignoring the range the user asked for.
193
278
  if (args.staged && (args.base || args.head)) {
194
- throw new Error('--staged reviews the staged changes (index vs HEAD) and cannot be combined with --base/--head.');
279
+ throw new Error("--staged reviews the staged changes (index vs HEAD) and cannot be combined with --base/--head.");
280
+ }
281
+ if (args.scope && args.configDir) {
282
+ throw new Error("--scope and --config-dir are mutually exclusive.");
195
283
  }
196
284
  }
197
285
  /** Resolve owner/repo from the current checkout via gh (for --post). */
@@ -0,0 +1,214 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
4
+ import { ROUTING_FILENAME } from "../config/routing.js";
5
+ import { repoRoot } from "../core/exec.js";
6
+ import { errorMessage } from "../core/util.js";
7
+ const USAGE = `ecr verify-config — refuse to run when a checked-out config could redirect the model credential
8
+
9
+ Usage:
10
+ ecr verify-config [--expected <ENV_NAME>] [--json]
11
+
12
+ The canonical pre-review guard (ships with the CLI). It sweeps EVERY
13
+ .expo-code-review/config.jsonc|config.json and routing.jsonc in the repo via a
14
+ plain recursive walk (skipping node_modules/.git, so a staged-but-unreferenced
15
+ config can't hide from git's index), parses each with the real comment-aware JSONC
16
+ parser (never regex-scraping), and refuses to run (exit 1) when:
17
+ • auth.tokenEnv (config) / defaults.auth.tokenEnv (routing.jsonc) appears more
18
+ than once, or in a non-root file, or — with --expected / ECR_EXPECTED_TOKEN_ENV
19
+ set — differs from the expected name or is absent (count must be exactly one);
20
+ • a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
21
+ • any file fails to parse (fail-closed), reporting the parse error.
22
+ Exit 0 = safe to run the review.
23
+
24
+ Options:
25
+ --expected <ENV_NAME> Require tokenEnv to equal this (else ECR_EXPECTED_TOKEN_ENV).
26
+ --json Emit {ok, findings:[{file, problem}]} on stdout.
27
+ `;
28
+ const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
29
+ /**
30
+ * Discover every config the CLI could ever read via a plain recursive walk (not
31
+ * `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
32
+ * on-disk sweep the way it could from git's index. Skips node_modules and .git.
33
+ */
34
+ async function discoverConfigFiles(root) {
35
+ const found = [];
36
+ async function walk(dir) {
37
+ let entries;
38
+ try {
39
+ entries = await readdir(dir, { withFileTypes: true });
40
+ }
41
+ catch {
42
+ return; // unreadable dir — nothing to sweep here
43
+ }
44
+ for (const entry of entries) {
45
+ if (entry.isDirectory()) {
46
+ if (entry.name === "node_modules" || entry.name === ".git") {
47
+ continue;
48
+ }
49
+ await walk(path.join(dir, entry.name));
50
+ }
51
+ else if (entry.isFile() &&
52
+ path.basename(dir) === CONFIG_DIRNAME &&
53
+ CONFIG_FILENAMES.has(entry.name)) {
54
+ found.push(path.join(dir, entry.name));
55
+ }
56
+ }
57
+ }
58
+ await walk(root);
59
+ return found.sort();
60
+ }
61
+ function asObject(value) {
62
+ return value && typeof value === "object" && !Array.isArray(value)
63
+ ? value
64
+ : undefined;
65
+ }
66
+ /** Read the security-relevant declarations from a parsed config/routing object. */
67
+ function extractFacts(file, parsed) {
68
+ if (path.basename(file) === ROUTING_FILENAME) {
69
+ // routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
70
+ const defaults = asObject(parsed.defaults);
71
+ const auth = asObject(defaults?.auth);
72
+ return {
73
+ tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
74
+ declaresAuth: Boolean(defaults) && "auth" in defaults,
75
+ declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
76
+ declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
77
+ };
78
+ }
79
+ const auth = asObject(parsed.auth);
80
+ return {
81
+ tokenEnv: typeof auth?.tokenEnv === "string" ? auth.tokenEnv : undefined,
82
+ declaresAuth: "auth" in parsed,
83
+ declaresBreakGlass: "breakGlass" in parsed,
84
+ declaresCommentTag: "commentTag" in parsed,
85
+ };
86
+ }
87
+ /**
88
+ * Verify every discoverable config is safe to run a review against. Fail-closed:
89
+ * any parse error, any tokenEnv anomaly, or any root-locked key in a non-root
90
+ * config is a finding. Never trusts the routing manifest — an unreferenced staged
91
+ * config dir is swept the same as a referenced one.
92
+ */
93
+ export async function verifyConfig(root, options = {}) {
94
+ const findings = [];
95
+ const rootConfigDir = path.join(root, CONFIG_DIRNAME);
96
+ const rel = (file) => path.relative(root, file) || path.basename(file);
97
+ const files = await discoverConfigFiles(root);
98
+ const tokenEnvOccurrences = [];
99
+ for (const file of files) {
100
+ const isRoot = path.dirname(file) === rootConfigDir;
101
+ let parsed;
102
+ try {
103
+ const raw = await readFile(file, "utf8");
104
+ parsed = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
105
+ }
106
+ catch (error) {
107
+ findings.push({ file: rel(file), problem: `failed to parse: ${errorMessage(error)}` });
108
+ continue;
109
+ }
110
+ const object = asObject(parsed);
111
+ if (!object) {
112
+ findings.push({ file: rel(file), problem: "config is not a JSON object" });
113
+ continue;
114
+ }
115
+ const facts = extractFacts(file, object);
116
+ if (facts.tokenEnv !== undefined) {
117
+ tokenEnvOccurrences.push({ file: rel(file), value: facts.tokenEnv, isRoot });
118
+ }
119
+ if (!isRoot) {
120
+ const locked = [];
121
+ if (facts.declaresAuth) {
122
+ locked.push("auth");
123
+ }
124
+ if (facts.declaresBreakGlass) {
125
+ locked.push("breakGlass");
126
+ }
127
+ if (facts.declaresCommentTag) {
128
+ locked.push("commentTag");
129
+ }
130
+ if (locked.length > 0) {
131
+ findings.push({
132
+ file: rel(file),
133
+ problem: `non-root config declares ${locked.join(", ")} — root-locked; only the root .expo-code-review config may set ${locked.length > 1 ? "them" : "it"}`,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ // tokenEnv must appear at most once, only in a root-owned file.
139
+ for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
140
+ findings.push({
141
+ file: occurrence.file,
142
+ problem: `tokenEnv "${occurrence.value}" is declared outside the root config; only a root-owned config.jsonc/config.json or routing.jsonc may name the forwarded credential`,
143
+ });
144
+ }
145
+ if (tokenEnvOccurrences.length > 1) {
146
+ findings.push({
147
+ file: tokenEnvOccurrences.map((o) => o.file).join(", "),
148
+ problem: `tokenEnv is declared in ${tokenEnvOccurrences.length} files; it must appear exactly once, in a root-owned config`,
149
+ });
150
+ }
151
+ // With an expectation set, exactly one root occurrence equal to it is required.
152
+ const expected = options.expected;
153
+ if (expected) {
154
+ const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
155
+ if (rootOccurrences.length === 0) {
156
+ findings.push({
157
+ file: path.join(CONFIG_DIRNAME, "config.jsonc"),
158
+ problem: `no tokenEnv found, but an expected value "${expected}" is set — exactly one root-owned tokenEnv is required`,
159
+ });
160
+ }
161
+ else {
162
+ for (const occurrence of rootOccurrences) {
163
+ if (occurrence.value !== expected) {
164
+ findings.push({
165
+ file: occurrence.file,
166
+ problem: `tokenEnv "${occurrence.value}" != expected "${expected}" — a PR must not repoint which secret is forwarded to the model provider`,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+ return { ok: findings.length === 0, findings };
173
+ }
174
+ /** CLI wrapper: parse flags, run the sweep, print, and set the exit code. */
175
+ export async function verifyConfigCommand(argv = []) {
176
+ if (argv.includes("-h") || argv.includes("--help")) {
177
+ process.stdout.write(USAGE);
178
+ return;
179
+ }
180
+ const json = argv.includes("--json");
181
+ let expected = process.env.ECR_EXPECTED_TOKEN_ENV || undefined;
182
+ const expectedIdx = argv.indexOf("--expected");
183
+ if (expectedIdx >= 0) {
184
+ const value = argv[expectedIdx + 1];
185
+ if (!value || value.startsWith("-")) {
186
+ process.stderr.write("--expected requires a value (the env var name)\n");
187
+ process.exitCode = 2;
188
+ return;
189
+ }
190
+ expected = value;
191
+ }
192
+ const root = (await repoRoot()) ?? process.cwd();
193
+ const result = await verifyConfig(root, { expected });
194
+ // The sweep is deliberately repo-wide (a security check, not a config loader), so
195
+ // it is unaffected by ECR_CONFIG_DIR. But when the override is set, the root the
196
+ // loaders actually honor may not be ./.expo-code-review — note that so the two
197
+ // don't look inconsistent in a job log. JSON output stays machine-clean.
198
+ if (!json && process.env.ECR_CONFIG_DIR) {
199
+ process.stderr.write(` ℹ ECR_CONFIG_DIR is set (${process.env.ECR_CONFIG_DIR}); the honored root config may differ from ./${CONFIG_DIRNAME}. This sweep still scans the ENTIRE repo (unchanged).\n`);
200
+ }
201
+ if (json) {
202
+ process.stdout.write(`${JSON.stringify(result)}\n`);
203
+ }
204
+ else if (result.ok) {
205
+ process.stdout.write(`verify-config: OK — ${expected ? `tokenEnv locked to "${expected}"` : "no tokenEnv anomalies"}; no non-root config declares root-locked keys.\n`);
206
+ }
207
+ else {
208
+ for (const finding of result.findings) {
209
+ process.stderr.write(`::error::${finding.problem} (${finding.file})\n`);
210
+ }
211
+ process.stderr.write(`verify-config: refusing to run — ${result.findings.length} problem(s) above.\n`);
212
+ }
213
+ process.exitCode = result.ok ? 0 : 1;
214
+ }