@expo/code-review-cli 0.3.0 → 0.5.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 (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  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 +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  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 +28 -26
  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 +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -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,252 @@
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
+ • a tokenEnv (auth.tokenEnv, or any auth.providers.<id>.tokenEnv; routing.jsonc:
18
+ same under defaults.auth) appears in a non-root file, in more than one root
19
+ file, twice under the same name, or — with --expected / ECR_EXPECTED_TOKEN_ENV
20
+ set (comma-separated) — the declared set differs from the expected set;
21
+ • a non-root config declares auth, breakGlass, or commentTag (root-locked keys);
22
+ • any file fails to parse (fail-closed), reporting the parse error.
23
+ Exit 0 = safe to run the review.
24
+
25
+ Options:
26
+ --expected <ENVS> Require the declared tokenEnv set to equal this
27
+ comma-separated set (else ECR_EXPECTED_TOKEN_ENV).
28
+ --json Emit {ok, findings:[{file, problem}]} on stdout.
29
+ `;
30
+ const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
31
+ /**
32
+ * Discover every config the CLI could ever read via a plain recursive walk (not
33
+ * `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
34
+ * on-disk sweep the way it could from git's index. Skips node_modules and .git.
35
+ */
36
+ async function discoverConfigFiles(root) {
37
+ const found = [];
38
+ async function walk(dir) {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return; // unreadable dir — nothing to sweep here
45
+ }
46
+ for (const entry of entries) {
47
+ if (entry.isDirectory()) {
48
+ if (entry.name === "node_modules" || entry.name === ".git") {
49
+ continue;
50
+ }
51
+ await walk(path.join(dir, entry.name));
52
+ }
53
+ else if (entry.isFile() &&
54
+ path.basename(dir) === CONFIG_DIRNAME &&
55
+ CONFIG_FILENAMES.has(entry.name)) {
56
+ found.push(path.join(dir, entry.name));
57
+ }
58
+ }
59
+ }
60
+ await walk(root);
61
+ return found.sort();
62
+ }
63
+ function asObject(value) {
64
+ return value && typeof value === "object" && !Array.isArray(value)
65
+ ? value
66
+ : undefined;
67
+ }
68
+ /** Every tokenEnv an auth block names — legacy single, or one per providers entry. */
69
+ function collectTokenEnvs(auth) {
70
+ if (!auth) {
71
+ return [];
72
+ }
73
+ const found = [];
74
+ if (typeof auth.tokenEnv === "string") {
75
+ found.push(auth.tokenEnv);
76
+ }
77
+ const providers = asObject(auth.providers);
78
+ for (const entry of Object.values(providers ?? {})) {
79
+ const tokenEnv = asObject(entry)?.tokenEnv;
80
+ if (typeof tokenEnv === "string") {
81
+ found.push(tokenEnv);
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+ /** Read the security-relevant declarations from a parsed config/routing object. */
87
+ function extractFacts(file, parsed) {
88
+ if (path.basename(file) === ROUTING_FILENAME) {
89
+ // routing.jsonc locks auth under defaults.auth (defaults.auth.tokenEnv).
90
+ const defaults = asObject(parsed.defaults);
91
+ return {
92
+ tokenEnvs: collectTokenEnvs(asObject(defaults?.auth)),
93
+ declaresAuth: Boolean(defaults) && "auth" in defaults,
94
+ declaresBreakGlass: false, // routing.jsonc has no breakGlass concept
95
+ declaresCommentTag: Boolean(defaults) && "commentTag" in defaults,
96
+ };
97
+ }
98
+ return {
99
+ tokenEnvs: collectTokenEnvs(asObject(parsed.auth)),
100
+ declaresAuth: "auth" in parsed,
101
+ declaresBreakGlass: "breakGlass" in parsed,
102
+ declaresCommentTag: "commentTag" in parsed,
103
+ };
104
+ }
105
+ /**
106
+ * Verify every discoverable config is safe to run a review against. Fail-closed:
107
+ * any parse error, any tokenEnv anomaly, or any root-locked key in a non-root
108
+ * config is a finding. Never trusts the routing manifest — an unreferenced staged
109
+ * config dir is swept the same as a referenced one.
110
+ */
111
+ export async function verifyConfig(root, options = {}) {
112
+ const findings = [];
113
+ const rootConfigDir = path.join(root, CONFIG_DIRNAME);
114
+ const rel = (file) => path.relative(root, file) || path.basename(file);
115
+ const files = await discoverConfigFiles(root);
116
+ const tokenEnvOccurrences = [];
117
+ for (const file of files) {
118
+ const isRoot = path.dirname(file) === rootConfigDir;
119
+ let parsed;
120
+ try {
121
+ const raw = await readFile(file, "utf8");
122
+ parsed = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
123
+ }
124
+ catch (error) {
125
+ findings.push({ file: rel(file), problem: `failed to parse: ${errorMessage(error)}` });
126
+ continue;
127
+ }
128
+ const object = asObject(parsed);
129
+ if (!object) {
130
+ findings.push({ file: rel(file), problem: "config is not a JSON object" });
131
+ continue;
132
+ }
133
+ const facts = extractFacts(file, object);
134
+ for (const value of facts.tokenEnvs) {
135
+ tokenEnvOccurrences.push({ file: rel(file), value, isRoot });
136
+ }
137
+ if (!isRoot) {
138
+ const locked = [];
139
+ if (facts.declaresAuth) {
140
+ locked.push("auth");
141
+ }
142
+ if (facts.declaresBreakGlass) {
143
+ locked.push("breakGlass");
144
+ }
145
+ if (facts.declaresCommentTag) {
146
+ locked.push("commentTag");
147
+ }
148
+ if (locked.length > 0) {
149
+ findings.push({
150
+ file: rel(file),
151
+ problem: `non-root config declares ${locked.join(", ")} — root-locked; only the root .expo-code-review config may set ${locked.length > 1 ? "them" : "it"}`,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ // tokenEnvs may only be declared in root-owned files…
157
+ for (const occurrence of tokenEnvOccurrences.filter((o) => !o.isRoot)) {
158
+ findings.push({
159
+ file: occurrence.file,
160
+ 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`,
161
+ });
162
+ }
163
+ // …and all in ONE root file (multiple entries in one auth block are fine —
164
+ // that's the multi-provider map — but split across files there is no single
165
+ // honored source and a stale/staged second file could smuggle a credential).
166
+ const rootOccurrences = tokenEnvOccurrences.filter((o) => o.isRoot);
167
+ const rootFiles = [...new Set(rootOccurrences.map((o) => o.file))];
168
+ if (rootFiles.length > 1) {
169
+ findings.push({
170
+ file: rootFiles.join(", "),
171
+ problem: `tokenEnv is declared in ${rootFiles.length} root files; all credential env names must live in ONE root-owned config`,
172
+ });
173
+ }
174
+ // Duplicate names within a file are a config bug worth failing on too: two auth
175
+ // entries forwarding the same env var means one of them is misconfigured.
176
+ const seen = new Set();
177
+ for (const occurrence of rootOccurrences) {
178
+ if (seen.has(occurrence.value)) {
179
+ findings.push({
180
+ file: occurrence.file,
181
+ problem: `tokenEnv "${occurrence.value}" is declared more than once; each credential env name must appear exactly once`,
182
+ });
183
+ }
184
+ seen.add(occurrence.value);
185
+ }
186
+ // With an expectation set, the declared names must equal the expected SET
187
+ // exactly (comma-separated; order-insensitive). A missing name is as much a
188
+ // finding as an extra one — a PR must not add, drop, or repoint credentials.
189
+ const expected = options.expected;
190
+ if (expected) {
191
+ const expectedSet = expected
192
+ .split(",")
193
+ .map((name) => name.trim())
194
+ .filter(Boolean)
195
+ .sort();
196
+ const declared = [...seen].sort();
197
+ if (declared.length === 0) {
198
+ findings.push({
199
+ file: path.join(CONFIG_DIRNAME, "config.jsonc"),
200
+ problem: `no tokenEnv found, but expected "${expectedSet.join(", ")}" — the root-owned config must name exactly those credential env(s)`,
201
+ });
202
+ }
203
+ else if (JSON.stringify(declared) !== JSON.stringify(expectedSet)) {
204
+ findings.push({
205
+ file: rootFiles.join(", ") || path.join(CONFIG_DIRNAME, "config.jsonc"),
206
+ problem: `declared tokenEnv set [${declared.join(", ")}] != expected [${expectedSet.join(", ")}] — a PR must not add, drop, or repoint which secrets are forwarded to model providers`,
207
+ });
208
+ }
209
+ }
210
+ return { ok: findings.length === 0, findings };
211
+ }
212
+ /** CLI wrapper: parse flags, run the sweep, print, and set the exit code. */
213
+ export async function verifyConfigCommand(argv = []) {
214
+ if (argv.includes("-h") || argv.includes("--help")) {
215
+ process.stdout.write(USAGE);
216
+ return;
217
+ }
218
+ const json = argv.includes("--json");
219
+ let expected = process.env.ECR_EXPECTED_TOKEN_ENV || undefined;
220
+ const expectedIdx = argv.indexOf("--expected");
221
+ if (expectedIdx >= 0) {
222
+ const value = argv[expectedIdx + 1];
223
+ if (!value || value.startsWith("-")) {
224
+ process.stderr.write("--expected requires a value (the env var name)\n");
225
+ process.exitCode = 2;
226
+ return;
227
+ }
228
+ expected = value;
229
+ }
230
+ const root = (await repoRoot()) ?? process.cwd();
231
+ const result = await verifyConfig(root, { expected });
232
+ // The sweep is deliberately repo-wide (a security check, not a config loader), so
233
+ // it is unaffected by ECR_CONFIG_DIR. But when the override is set, the root the
234
+ // loaders actually honor may not be ./.expo-code-review — note that so the two
235
+ // don't look inconsistent in a job log. JSON output stays machine-clean.
236
+ if (!json && process.env.ECR_CONFIG_DIR) {
237
+ 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`);
238
+ }
239
+ if (json) {
240
+ process.stdout.write(`${JSON.stringify(result)}\n`);
241
+ }
242
+ else if (result.ok) {
243
+ process.stdout.write(`verify-config: OK — ${expected ? `tokenEnv locked to "${expected}"` : "no tokenEnv anomalies"}; no non-root config declares root-locked keys.\n`);
244
+ }
245
+ else {
246
+ for (const finding of result.findings) {
247
+ process.stderr.write(`::error::${finding.problem} (${finding.file})\n`);
248
+ }
249
+ process.stderr.write(`verify-config: refusing to run — ${result.findings.length} problem(s) above.\n`);
250
+ }
251
+ process.exitCode = result.ok ? 0 : 1;
252
+ }