@expo/code-review-cli 0.2.3 → 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.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- package/build/core/coordinator.js +5 -5
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +25 -25
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/config.jsonc +10 -0
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +58 -23
package/build/commands/review.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { loadReviewConfig } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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:
|
|
@@ -16,7 +18,8 @@ Source (pick one):
|
|
|
16
18
|
(default) diff the working tree against the merge-base
|
|
17
19
|
--base <ref> base ref to diff against
|
|
18
20
|
--head <ref> head ref to diff
|
|
19
|
-
--staged review only staged changes
|
|
21
|
+
--staged review only staged changes (index vs HEAD; not combinable
|
|
22
|
+
with --base/--head)
|
|
20
23
|
--pr <n> review GitHub PR #n by number (diff fetched via \`gh\`, no
|
|
21
24
|
checkout needed); can't be combined with --base/--head/--staged
|
|
22
25
|
|
|
@@ -27,6 +30,10 @@ Options:
|
|
|
27
30
|
to publish.
|
|
28
31
|
--agents <a,b> run only these agents (comma-separated ids); default: all
|
|
29
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
|
|
30
37
|
--json emit machine-readable JSON on stdout
|
|
31
38
|
--no-fail always exit 0, even on request-changes
|
|
32
39
|
-h, --help show this help
|
|
@@ -38,7 +45,7 @@ on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
|
|
|
38
45
|
Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
|
|
39
46
|
`;
|
|
40
47
|
function requireValue(flag, value) {
|
|
41
|
-
if (value === undefined || value.startsWith(
|
|
48
|
+
if (value === undefined || value.startsWith("--")) {
|
|
42
49
|
throw new Error(`${flag} requires a value`);
|
|
43
50
|
}
|
|
44
51
|
return value;
|
|
@@ -55,16 +62,16 @@ function parseArgs(argv) {
|
|
|
55
62
|
for (let i = 0; i < argv.length; i++) {
|
|
56
63
|
const arg = argv[i];
|
|
57
64
|
switch (arg) {
|
|
58
|
-
case
|
|
65
|
+
case "--base":
|
|
59
66
|
args.base = requireValue(arg, argv[++i]);
|
|
60
67
|
break;
|
|
61
|
-
case
|
|
68
|
+
case "--head":
|
|
62
69
|
args.head = requireValue(arg, argv[++i]);
|
|
63
70
|
break;
|
|
64
|
-
case
|
|
71
|
+
case "--staged":
|
|
65
72
|
args.staged = true;
|
|
66
73
|
break;
|
|
67
|
-
case
|
|
74
|
+
case "--pr": {
|
|
68
75
|
const value = requireValue(arg, argv[++i]);
|
|
69
76
|
const number = Number(value);
|
|
70
77
|
if (!Number.isInteger(number) || number <= 0) {
|
|
@@ -73,29 +80,35 @@ function parseArgs(argv) {
|
|
|
73
80
|
args.pr = number;
|
|
74
81
|
break;
|
|
75
82
|
}
|
|
76
|
-
case
|
|
83
|
+
case "--repo":
|
|
77
84
|
args.repo = requireValue(arg, argv[++i]);
|
|
78
85
|
break;
|
|
79
|
-
case
|
|
86
|
+
case "--post":
|
|
80
87
|
args.post = true;
|
|
81
88
|
break;
|
|
82
|
-
case
|
|
89
|
+
case "--agents":
|
|
83
90
|
args.agents = requireValue(arg, argv[++i])
|
|
84
|
-
.split(
|
|
85
|
-
.map(id => id.trim())
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((id) => id.trim())
|
|
86
93
|
.filter(Boolean);
|
|
87
94
|
break;
|
|
88
|
-
case
|
|
95
|
+
case "--route":
|
|
89
96
|
args.route = true;
|
|
90
97
|
break;
|
|
91
|
-
case
|
|
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":
|
|
92
105
|
args.json = true;
|
|
93
106
|
break;
|
|
94
|
-
case
|
|
107
|
+
case "--no-fail":
|
|
95
108
|
args.noFail = true;
|
|
96
109
|
break;
|
|
97
|
-
case
|
|
98
|
-
case
|
|
110
|
+
case "-h":
|
|
111
|
+
case "--help":
|
|
99
112
|
args.help = true;
|
|
100
113
|
break;
|
|
101
114
|
default:
|
|
@@ -133,17 +146,90 @@ export async function reviewCommand(argv) {
|
|
|
133
146
|
process.chdir(root);
|
|
134
147
|
}
|
|
135
148
|
try {
|
|
136
|
-
const config = await loadReviewConfig(process.cwd());
|
|
137
149
|
const cwd = process.cwd();
|
|
138
|
-
const
|
|
150
|
+
const makeSource = () => args.pr != null
|
|
139
151
|
? new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd })
|
|
140
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();
|
|
141
227
|
const review = await runReview(source, {
|
|
142
228
|
config,
|
|
143
|
-
mode:
|
|
229
|
+
mode: "local",
|
|
144
230
|
agents: args.agents,
|
|
145
231
|
route: args.route,
|
|
146
|
-
onProgress: message => process.stderr.write(`${message}\n`),
|
|
232
|
+
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
147
233
|
});
|
|
148
234
|
// Always print the result here first.
|
|
149
235
|
await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
|
|
@@ -182,10 +268,18 @@ export async function reviewCommand(argv) {
|
|
|
182
268
|
/** Reject flag combinations that don't make sense together. */
|
|
183
269
|
function validateArgs(args) {
|
|
184
270
|
if (args.pr != null && (args.base || args.head || args.staged)) {
|
|
185
|
-
throw new Error(
|
|
271
|
+
throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
|
|
186
272
|
}
|
|
187
273
|
if (args.pr == null && (args.repo || args.post)) {
|
|
188
|
-
throw new Error(
|
|
274
|
+
throw new Error("--repo/--post only apply together with --pr.");
|
|
275
|
+
}
|
|
276
|
+
// --staged diffs the index against HEAD, so --base/--head have no effect. Reject
|
|
277
|
+
// the combination rather than silently ignoring the range the user asked for.
|
|
278
|
+
if (args.staged && (args.base || args.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.");
|
|
189
283
|
}
|
|
190
284
|
}
|
|
191
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
|
+
}
|