@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.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +406 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +173 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +154 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +116 -12
- package/build/core/auth.js +32 -29
- 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 +44 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +147 -85
- 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/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 +50 -20
package/build/config/load.js
CHANGED
|
@@ -1,33 +1,59 @@
|
|
|
1
|
-
import { readdir, readFile } from
|
|
2
|
-
import { existsSync } from
|
|
3
|
-
import path from
|
|
4
|
-
import { ReviewConfigSchema } from
|
|
5
|
-
import { toolMap } from
|
|
6
|
-
export const CONFIG_DIRNAME =
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
|
|
5
|
+
import { toolMap } from "../core/tools.js";
|
|
6
|
+
export const CONFIG_DIRNAME = ".expo-code-review";
|
|
7
7
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
8
|
-
const DEFAULT_AGENT_TOOLS = toolMap([
|
|
8
|
+
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
9
9
|
export function configDirFor(repoRoot) {
|
|
10
10
|
return path.join(repoRoot, CONFIG_DIRNAME);
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the config directory: an explicit override (absolute or repo-relative)
|
|
14
|
+
* → the ECR_CONFIG_DIR env var → the repo's default `.expo-code-review/`. This is
|
|
15
|
+
* the escape hatch (graft 1); with neither override present the result is exactly
|
|
16
|
+
* `configDirFor(repoRoot)`, so the default behavior is byte-identical.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveConfigDir(repoRoot, override) {
|
|
19
|
+
const chosen = override ?? process.env.ECR_CONFIG_DIR;
|
|
20
|
+
if (chosen) {
|
|
21
|
+
return path.isAbsolute(chosen) ? chosen : path.join(repoRoot, chosen);
|
|
22
|
+
}
|
|
23
|
+
return path.join(repoRoot, CONFIG_DIRNAME);
|
|
24
|
+
}
|
|
25
|
+
export function hasConfig(repoRoot, options = {}) {
|
|
26
|
+
// Resolve the same way loadReviewConfig does (incl. the ECR_CONFIG_DIR escape
|
|
27
|
+
// hatch) so doctor's "no config" diagnostic never disagrees with the loader.
|
|
28
|
+
const dir = resolveConfigDir(repoRoot, options.configDir);
|
|
29
|
+
return existsSync(path.join(dir, "config.jsonc")) || existsSync(path.join(dir, "config.json"));
|
|
15
30
|
}
|
|
16
31
|
/**
|
|
17
32
|
* Discover and fully resolve a repo's review config from `.expo-code-review/`:
|
|
18
33
|
* parse config.jsonc, read every prompt file, and resolve models (with an
|
|
19
34
|
* optional REVIEWER_MODEL env override applied to all agents + the coordinator).
|
|
20
35
|
*/
|
|
21
|
-
export async function loadReviewConfig(repoRoot) {
|
|
22
|
-
const dir =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
export async function loadReviewConfig(repoRoot, options = {}) {
|
|
37
|
+
const dir = resolveConfigDir(repoRoot, options.configDir);
|
|
38
|
+
return (await loadConfigDir(dir, ReviewConfigSchema)).config;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The shared per-directory loader: parse config.jsonc, read every prompt file,
|
|
42
|
+
* resolve models. Root and scope loading share this one code path — the schema
|
|
43
|
+
* argument controls whether the centrally-locked keys (auth/breakGlass) are
|
|
44
|
+
* accepted (root) or rejected at the Zod level (scope). `loadReviewConfig` with
|
|
45
|
+
* `ReviewConfigSchema` produces identical output for identical input.
|
|
46
|
+
*/
|
|
47
|
+
async function loadConfigDir(dir, schema) {
|
|
48
|
+
const configPath = ["config.jsonc", "config.json"]
|
|
49
|
+
.map((name) => path.join(dir, name))
|
|
50
|
+
.find((candidate) => existsSync(candidate));
|
|
26
51
|
if (!configPath) {
|
|
27
|
-
throw new Error(`No ${CONFIG_DIRNAME}/config.jsonc found in ${
|
|
52
|
+
throw new Error(`No ${CONFIG_DIRNAME}/config.jsonc found in ${path.dirname(dir)}. Run \`ecr init\` to scaffold one.`);
|
|
28
53
|
}
|
|
29
|
-
const raw = await readFile(configPath,
|
|
30
|
-
const
|
|
54
|
+
const raw = await readFile(configPath, "utf8");
|
|
55
|
+
const rawObject = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
|
|
56
|
+
const parsed = schema.parse(rawObject);
|
|
31
57
|
const override = process.env.REVIEWER_MODEL;
|
|
32
58
|
const defaultModel = override ?? parsed.model;
|
|
33
59
|
const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
|
|
@@ -36,39 +62,39 @@ export async function loadReviewConfig(repoRoot) {
|
|
|
36
62
|
return Number.isFinite(n) ? n : fallback;
|
|
37
63
|
};
|
|
38
64
|
// shared.md is optional; the coordinator is required.
|
|
39
|
-
const sharedPath = path.join(dir,
|
|
65
|
+
const sharedPath = path.join(dir, "shared.md");
|
|
40
66
|
const sharedPromptText = existsSync(sharedPath)
|
|
41
|
-
? parseFrontmatter(await readFile(sharedPath,
|
|
42
|
-
:
|
|
43
|
-
const coordinatorPath = path.join(dir,
|
|
67
|
+
? parseFrontmatter(await readFile(sharedPath, "utf8")).body
|
|
68
|
+
: "";
|
|
69
|
+
const coordinatorPath = path.join(dir, "coordinator.md");
|
|
44
70
|
if (!existsSync(coordinatorPath)) {
|
|
45
71
|
throw new Error(`Missing ${CONFIG_DIRNAME}/coordinator.md`);
|
|
46
72
|
}
|
|
47
|
-
const coordinatorMd = parseFrontmatter(await readFile(coordinatorPath,
|
|
73
|
+
const coordinatorMd = parseFrontmatter(await readFile(coordinatorPath, "utf8"));
|
|
48
74
|
// Every markdown file in agents/ is a reviewer agent (id = filename).
|
|
49
|
-
const agentsDir = path.join(dir,
|
|
75
|
+
const agentsDir = path.join(dir, "agents");
|
|
50
76
|
if (!existsSync(agentsDir)) {
|
|
51
77
|
throw new Error(`Missing ${CONFIG_DIRNAME}/agents/ directory. Run \`ecr init\`.`);
|
|
52
78
|
}
|
|
53
|
-
const agentFiles = (await readdir(agentsDir)).filter(name => name.endsWith(
|
|
79
|
+
const agentFiles = (await readdir(agentsDir)).filter((name) => name.endsWith(".md")).sort();
|
|
54
80
|
if (agentFiles.length === 0) {
|
|
55
81
|
throw new Error(`No agent markdown files in ${CONFIG_DIRNAME}/agents/.`);
|
|
56
82
|
}
|
|
57
83
|
const agents = [];
|
|
58
84
|
for (const file of agentFiles) {
|
|
59
|
-
const md = parseFrontmatter(await readFile(path.join(agentsDir, file),
|
|
60
|
-
const id = file.replace(/\.md$/,
|
|
85
|
+
const md = parseFrontmatter(await readFile(path.join(agentsDir, file), "utf8"));
|
|
86
|
+
const id = file.replace(/\.md$/, "");
|
|
61
87
|
agents.push({
|
|
62
88
|
id,
|
|
63
|
-
description: md.data.description ??
|
|
64
|
-
alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ??
|
|
89
|
+
description: md.data.description ?? "",
|
|
90
|
+
alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ""),
|
|
65
91
|
model: resolveModel(md.data.model),
|
|
66
92
|
temperature: resolveTemp(md.data.temperature, 0.1),
|
|
67
93
|
tools: DEFAULT_AGENT_TOOLS,
|
|
68
94
|
promptText: md.body,
|
|
69
95
|
});
|
|
70
96
|
}
|
|
71
|
-
|
|
97
|
+
const config = {
|
|
72
98
|
configDir: dir,
|
|
73
99
|
sharedPromptText,
|
|
74
100
|
agents,
|
|
@@ -80,15 +106,91 @@ export async function loadReviewConfig(repoRoot) {
|
|
|
80
106
|
policy: parsed.policy,
|
|
81
107
|
chunk: parsed.chunk,
|
|
82
108
|
noise: parsed.noise,
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
// parsed.breakGlass/auth are always present for the root schema (defaults) and
|
|
110
|
+
// absent for the scope schema; loadScopeConfig overrides both afterwards.
|
|
111
|
+
breakGlassMarker: parsed.breakGlass?.marker ?? "/skip-review",
|
|
112
|
+
// Scope configs can't declare commentTag (scope schema rejects it);
|
|
113
|
+
// loadScopeConfig overwrites this placeholder with the manifest default.
|
|
114
|
+
commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
|
|
85
115
|
auth: {
|
|
86
|
-
mode: parsed.auth
|
|
87
|
-
provider: parsed.auth
|
|
88
|
-
tokenEnv: parsed.auth
|
|
116
|
+
mode: parsed.auth?.mode ?? "api-key",
|
|
117
|
+
provider: parsed.auth?.provider ?? "anthropic",
|
|
118
|
+
tokenEnv: parsed.auth?.tokenEnv,
|
|
89
119
|
},
|
|
90
120
|
review: parsed.review,
|
|
91
121
|
};
|
|
122
|
+
return { config, raw: rawObject };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* auth is honored ONLY here: the manifest's `defaults.auth` wins when present,
|
|
126
|
+
* otherwise the root config.jsonc auth. A scope config can never contribute auth
|
|
127
|
+
* (the scope schema rejects it), so the secret-forwarding surface stays a single,
|
|
128
|
+
* root-owned value no matter how many scopes exist.
|
|
129
|
+
*/
|
|
130
|
+
export function loadAuthFromRoot(rootConfig, manifest) {
|
|
131
|
+
const override = manifest?.defaults.auth;
|
|
132
|
+
if (override) {
|
|
133
|
+
return { mode: override.mode, provider: override.provider, tokenEnv: override.tokenEnv };
|
|
134
|
+
}
|
|
135
|
+
return rootConfig.auth;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Load one scope's fully-resolved config. The default scope (config '.') reuses
|
|
139
|
+
* the root config unchanged except auth; a nested scope reads its own
|
|
140
|
+
* `.expo-code-review/` via the scope schema (auth/breakGlass rejected by Zod).
|
|
141
|
+
* auth and breakGlass always come from the root; `defaults.enforceAgents` are
|
|
142
|
+
* injected with alwaysRun and win any same-id agent in the scope roster (risk 11).
|
|
143
|
+
*/
|
|
144
|
+
export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
145
|
+
let base;
|
|
146
|
+
let commentTag;
|
|
147
|
+
if (scope.config === ".") {
|
|
148
|
+
base = rootConfig;
|
|
149
|
+
// The default scope keeps the ROOT comment marker so the existing single
|
|
150
|
+
// comment (and its dismissal state) upserts in place, not duplicated (risk 8).
|
|
151
|
+
commentTag = rootConfig.commentTag;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Defense in depth behind the schema's traversal refinement: never read a
|
|
155
|
+
// scope config from outside the repo checkout (scope.config is PR-controllable).
|
|
156
|
+
const resolvedRoot = path.resolve(root);
|
|
157
|
+
const resolvedScope = path.resolve(root, scope.config);
|
|
158
|
+
if (resolvedScope !== resolvedRoot && !resolvedScope.startsWith(resolvedRoot + path.sep)) {
|
|
159
|
+
throw new Error(`scope "${scope.name}": config "${scope.config}" resolves outside the repo checkout`);
|
|
160
|
+
}
|
|
161
|
+
const dir = path.join(root, scope.config, CONFIG_DIRNAME);
|
|
162
|
+
const { config } = await loadConfigDir(dir, ScopeReviewConfigSchema);
|
|
163
|
+
base = config;
|
|
164
|
+
// Non-default scopes never carry their own marker (the scope schema rejects
|
|
165
|
+
// commentTag): ci derives `<rootTag>:<scope>` for per-scope comments, so the
|
|
166
|
+
// loaded value here is only the manifest default, for display/doctor.
|
|
167
|
+
commentTag = manifest.defaults.commentTag;
|
|
168
|
+
}
|
|
169
|
+
// Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
|
|
170
|
+
// same-id agent the scope defines (the enforced one wins — risk 11).
|
|
171
|
+
const agents = base.agents.map((agent) => ({ ...agent }));
|
|
172
|
+
for (const id of manifest.defaults.enforceAgents) {
|
|
173
|
+
const rootAgent = rootConfig.agents.find((agent) => agent.id === id);
|
|
174
|
+
if (!rootAgent) {
|
|
175
|
+
throw new Error(`defaults.enforceAgents lists "${id}", but the root roster has no agent with that id.`);
|
|
176
|
+
}
|
|
177
|
+
const enforced = { ...rootAgent, alwaysRun: true };
|
|
178
|
+
const index = agents.findIndex((agent) => agent.id === id);
|
|
179
|
+
if (index >= 0) {
|
|
180
|
+
agents[index] = enforced;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
agents.push(enforced);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
agents,
|
|
189
|
+
auth: loadAuthFromRoot(rootConfig, manifest),
|
|
190
|
+
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
191
|
+
commentTag,
|
|
192
|
+
scopeName: scope.name,
|
|
193
|
+
};
|
|
92
194
|
}
|
|
93
195
|
/**
|
|
94
196
|
* Parse optional YAML-ish frontmatter (simple `key: value` scalars) from the top
|
|
@@ -96,20 +198,20 @@ export async function loadReviewConfig(repoRoot) {
|
|
|
96
198
|
* stripped. Supports per-agent overrides like `model:` and `temperature:`.
|
|
97
199
|
*/
|
|
98
200
|
export function parseFrontmatter(md) {
|
|
99
|
-
if (!md.startsWith(
|
|
201
|
+
if (!md.startsWith("---")) {
|
|
100
202
|
return { data: {}, body: md };
|
|
101
203
|
}
|
|
102
|
-
const end = md.indexOf(
|
|
204
|
+
const end = md.indexOf("\n---", 3);
|
|
103
205
|
if (end === -1) {
|
|
104
206
|
return { data: {}, body: md };
|
|
105
207
|
}
|
|
106
208
|
const header = md.slice(3, end).trim();
|
|
107
|
-
const body = md.slice(end + 4).replace(/^\r?\n/,
|
|
209
|
+
const body = md.slice(end + 4).replace(/^\r?\n/, "");
|
|
108
210
|
const data = {};
|
|
109
|
-
for (const line of header.split(
|
|
211
|
+
for (const line of header.split("\n")) {
|
|
110
212
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
111
213
|
if (match) {
|
|
112
|
-
data[match[1]] = match[2].trim().replace(/^["']|["']$/g,
|
|
214
|
+
data[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
|
|
113
215
|
}
|
|
114
216
|
}
|
|
115
217
|
return { data, body };
|
|
@@ -119,7 +221,7 @@ export function parseFrontmatter(md) {
|
|
|
119
221
|
* string literals. The config is trusted (in-repo), so a light scanner suffices.
|
|
120
222
|
*/
|
|
121
223
|
export function stripJsonComments(input) {
|
|
122
|
-
let out =
|
|
224
|
+
let out = "";
|
|
123
225
|
let inString = false;
|
|
124
226
|
let inLine = false;
|
|
125
227
|
let inBlock = false;
|
|
@@ -127,14 +229,14 @@ export function stripJsonComments(input) {
|
|
|
127
229
|
const char = input[i];
|
|
128
230
|
const next = input[i + 1];
|
|
129
231
|
if (inLine) {
|
|
130
|
-
if (char ===
|
|
232
|
+
if (char === "\n") {
|
|
131
233
|
inLine = false;
|
|
132
234
|
out += char;
|
|
133
235
|
}
|
|
134
236
|
continue;
|
|
135
237
|
}
|
|
136
238
|
if (inBlock) {
|
|
137
|
-
if (char ===
|
|
239
|
+
if (char === "*" && next === "/") {
|
|
138
240
|
inBlock = false;
|
|
139
241
|
i++;
|
|
140
242
|
}
|
|
@@ -142,8 +244,8 @@ export function stripJsonComments(input) {
|
|
|
142
244
|
}
|
|
143
245
|
if (inString) {
|
|
144
246
|
out += char;
|
|
145
|
-
if (char ===
|
|
146
|
-
out += input[i + 1] ??
|
|
247
|
+
if (char === "\\") {
|
|
248
|
+
out += input[i + 1] ?? "";
|
|
147
249
|
i++;
|
|
148
250
|
}
|
|
149
251
|
else if (char === '"') {
|
|
@@ -155,11 +257,11 @@ export function stripJsonComments(input) {
|
|
|
155
257
|
inString = true;
|
|
156
258
|
out += char;
|
|
157
259
|
}
|
|
158
|
-
else if (char ===
|
|
260
|
+
else if (char === "/" && next === "/") {
|
|
159
261
|
inLine = true;
|
|
160
262
|
i++;
|
|
161
263
|
}
|
|
162
|
-
else if (char ===
|
|
264
|
+
else if (char === "/" && next === "*") {
|
|
163
265
|
inBlock = true;
|
|
164
266
|
i++;
|
|
165
267
|
}
|
|
@@ -171,14 +273,14 @@ export function stripJsonComments(input) {
|
|
|
171
273
|
}
|
|
172
274
|
/** Remove trailing commas before `}`/`]` (JSONC), ignoring string contents. */
|
|
173
275
|
export function stripTrailingCommas(input) {
|
|
174
|
-
let out =
|
|
276
|
+
let out = "";
|
|
175
277
|
let inString = false;
|
|
176
278
|
for (let i = 0; i < input.length; i++) {
|
|
177
279
|
const char = input[i];
|
|
178
280
|
if (inString) {
|
|
179
281
|
out += char;
|
|
180
|
-
if (char ===
|
|
181
|
-
out += input[i + 1] ??
|
|
282
|
+
if (char === "\\") {
|
|
283
|
+
out += input[i + 1] ?? "";
|
|
182
284
|
i++;
|
|
183
285
|
}
|
|
184
286
|
else if (char === '"') {
|
|
@@ -191,12 +293,12 @@ export function stripTrailingCommas(input) {
|
|
|
191
293
|
out += char;
|
|
192
294
|
continue;
|
|
193
295
|
}
|
|
194
|
-
if (char ===
|
|
296
|
+
if (char === ",") {
|
|
195
297
|
let j = i + 1;
|
|
196
298
|
while (j < input.length && /\s/.test(input[j])) {
|
|
197
299
|
j++;
|
|
198
300
|
}
|
|
199
|
-
if (input[j] ===
|
|
301
|
+
if (input[j] === "}" || input[j] === "]") {
|
|
200
302
|
continue; // drop the trailing comma
|
|
201
303
|
}
|
|
202
304
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { RoutingManifestSchema } from "./schema.js";
|
|
5
|
+
import { resolveConfigDir, stripJsonComments, stripTrailingCommas } from "./load.js";
|
|
6
|
+
import { matchesIgnore } from "../core/noise.js";
|
|
7
|
+
export const ROUTING_FILENAME = "routing.jsonc";
|
|
8
|
+
/**
|
|
9
|
+
* Parse the routing manifest. It lives in the SAME resolved root config dir as
|
|
10
|
+
* config.jsonc (`resolveConfigDir` — the `--config-dir`/`ECR_CONFIG_DIR` escape
|
|
11
|
+
* hatch): the override designates an alternate ROOT config dir, so config.jsonc
|
|
12
|
+
* and routing.jsonc always travel together and never split across the override
|
|
13
|
+
* and the default tree. With no override the dir is `<root>/.expo-code-review`,
|
|
14
|
+
* byte-identical to the pre-escape-hatch path. Absent file => null (backcompat).
|
|
15
|
+
* Scope `config` paths stay repo-root-relative (see `loadScopeConfig`): an
|
|
16
|
+
* override relocates only the ROOT artifacts, never the scopes' own subtrees.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadRoutingManifest(root, options = {}) {
|
|
19
|
+
const manifestPath = path.join(resolveConfigDir(root, options.configDir), ROUTING_FILENAME);
|
|
20
|
+
if (!existsSync(manifestPath)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const raw = await readFile(manifestPath, "utf8");
|
|
24
|
+
// Let schema/JSON errors throw — a malformed manifest must be a loud error,
|
|
25
|
+
// never a silent fallback to single-scope behavior.
|
|
26
|
+
return RoutingManifestSchema.parse(JSON.parse(stripTrailingCommas(stripJsonComments(raw))));
|
|
27
|
+
}
|
|
28
|
+
// The repo's minimal glob translates `**` to `.*`, so a leading double-star + slash
|
|
29
|
+
// requires at least one slash in the path — which would make the documented catch-all
|
|
30
|
+
// silently miss root-level files (README.md, package.json). To give the double-star +
|
|
31
|
+
// slash its conventional "zero or more directories" meaning, we also test the variant
|
|
32
|
+
// with each such prefix removed, so the catch-all matches both `a.ts` and `src/b.ts`.
|
|
33
|
+
function patternVariants(pattern) {
|
|
34
|
+
const collapsed = pattern.replace(/\*\*\//g, "");
|
|
35
|
+
return collapsed !== pattern && collapsed.length > 0 ? [pattern, collapsed] : [pattern];
|
|
36
|
+
}
|
|
37
|
+
/** Does any of the scope's globs match this file? */
|
|
38
|
+
function scopeMatches(paths, file) {
|
|
39
|
+
return paths.some((pattern) => patternVariants(pattern).some((variant) => matchesIgnore(file, variant)));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Assign each changed file to exactly one scope: test the file against every
|
|
43
|
+
* scope's paths in ARRAY ORDER; the LAST matching scope wins. Glob matching via
|
|
44
|
+
* matchesIgnore (supports ** across / and * within a segment — the manifest
|
|
45
|
+
* documents this dialect). Deterministic, no filesystem access.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveScopes(manifest, changedFiles) {
|
|
48
|
+
const buckets = new Map();
|
|
49
|
+
const unmatched = [];
|
|
50
|
+
const overlaps = [];
|
|
51
|
+
for (const file of changedFiles) {
|
|
52
|
+
const matched = [];
|
|
53
|
+
for (const scope of manifest.scopes) {
|
|
54
|
+
if (scopeMatches(scope.paths, file)) {
|
|
55
|
+
matched.push(scope.name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (matched.length === 0) {
|
|
59
|
+
unmatched.push(file);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const winner = matched[matched.length - 1];
|
|
63
|
+
(buckets.get(winner) ?? buckets.set(winner, []).get(winner)).push(file);
|
|
64
|
+
if (matched.length > 1) {
|
|
65
|
+
overlaps.push({ file, matched, winner });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const active = [];
|
|
69
|
+
for (const scope of manifest.scopes) {
|
|
70
|
+
const files = buckets.get(scope.name);
|
|
71
|
+
if (files && files.length > 0) {
|
|
72
|
+
active.push({ name: scope.name, configDir: scope.config, files });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { active, unmatched, overlaps };
|
|
76
|
+
}
|
|
77
|
+
/** Scoped comment tag: `${defaults.commentTag}:${scope.name}` (distinct full marker). */
|
|
78
|
+
export function scopedCommentTag(rootTag, scopeName) {
|
|
79
|
+
return `${rootTag}:${scopeName}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Divide the total passes budget across N active scopes, which run SEQUENTIALLY
|
|
83
|
+
* in one `ecr ci` process. Even split = `floor(total / active)`, clamped up to
|
|
84
|
+
* `min` so a scope always gets a workable window. When that clamp wins (the even
|
|
85
|
+
* split fell below `min`), the floor is kept — a scope below `min` isn't worth
|
|
86
|
+
* starting — and `overshoot` flags that the run will exceed the total budget so
|
|
87
|
+
* the caller can warn. Pure so the math is unit-testable.
|
|
88
|
+
*/
|
|
89
|
+
export function scopePassesBudgetMs(totalMs, minMs, activeCount) {
|
|
90
|
+
const count = Math.max(1, activeCount);
|
|
91
|
+
const evenSplit = Math.floor(totalMs / count);
|
|
92
|
+
const perScopeMs = Math.max(minMs, evenSplit);
|
|
93
|
+
return { perScopeMs, overshoot: count * perScopeMs > totalMs };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Owner table for doctor/CI logs (graft 4): one row per file —
|
|
97
|
+
* `file → winning scope (also matched: a, b)`. Returns printable lines,
|
|
98
|
+
* capped at `limit` rows with a "+N more" tail.
|
|
99
|
+
*/
|
|
100
|
+
export function formatOwnerTable(resolution, limit = 40) {
|
|
101
|
+
const rows = [];
|
|
102
|
+
const overlapByFile = new Map(resolution.overlaps.map((o) => [o.file, o]));
|
|
103
|
+
for (const scope of resolution.active) {
|
|
104
|
+
for (const file of scope.files) {
|
|
105
|
+
const overlap = overlapByFile.get(file);
|
|
106
|
+
const also = overlap ? overlap.matched.filter((name) => name !== scope.name) : [];
|
|
107
|
+
rows.push({ file, scope: scope.name, also });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const file of resolution.unmatched) {
|
|
111
|
+
rows.push({ file, scope: "(none)", also: [] });
|
|
112
|
+
}
|
|
113
|
+
const lines = [];
|
|
114
|
+
for (const row of rows.slice(0, limit)) {
|
|
115
|
+
const suffix = row.also.length > 0 ? ` (also matched: ${row.also.join(", ")})` : "";
|
|
116
|
+
lines.push(` ${row.file} → ${row.scope}${suffix}`);
|
|
117
|
+
}
|
|
118
|
+
if (rows.length > limit) {
|
|
119
|
+
lines.push(` …and ${rows.length - limit} more`);
|
|
120
|
+
}
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
package/build/config/schema.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
export const ReviewConfigSchema = z.object({
|
|
3
4
|
/** Default model for every agent + the coordinator. Override per-agent via
|
|
4
5
|
* frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
|
|
5
|
-
model: z.string().default(
|
|
6
|
+
model: z.string().default("anthropic/claude-sonnet-5"),
|
|
6
7
|
policy: z
|
|
7
8
|
.object({
|
|
8
9
|
includeSuggestions: z.boolean().default(false),
|
|
@@ -48,20 +49,20 @@ export const ReviewConfigSchema = z.object({
|
|
|
48
49
|
})
|
|
49
50
|
.default({ additionalIgnores: [], additionalMarkers: [] }),
|
|
50
51
|
breakGlass: z
|
|
51
|
-
.object({ marker: z.string().default(
|
|
52
|
-
.default({ marker:
|
|
53
|
-
commentTag: z.string().default(
|
|
52
|
+
.object({ marker: z.string().default("/skip-review") })
|
|
53
|
+
.default({ marker: "/skip-review" }),
|
|
54
|
+
commentTag: z.string().default("expo-ai-code-reviewer"),
|
|
54
55
|
auth: z
|
|
55
56
|
.object({
|
|
56
57
|
// "api-key": the token env is sent as the provider's API key (x-api-key).
|
|
57
58
|
// "oauth": the token env is a Claude Pro/Max style OAuth token, injected
|
|
58
59
|
// into an isolated OpenCode auth.json so it's sent as a Bearer token.
|
|
59
|
-
mode: z.enum([
|
|
60
|
-
provider: z.string().default(
|
|
60
|
+
mode: z.enum(["api-key", "oauth"]).default("api-key"),
|
|
61
|
+
provider: z.string().default("anthropic"),
|
|
61
62
|
/** Env var holding the key/token. */
|
|
62
63
|
tokenEnv: z.string().optional(),
|
|
63
64
|
})
|
|
64
|
-
.default({ mode:
|
|
65
|
+
.default({ mode: "api-key", provider: "anthropic" }),
|
|
65
66
|
review: z
|
|
66
67
|
.object({
|
|
67
68
|
// Which PRs `ecr ci` acts on — the source of truth for trigger policy (a
|
|
@@ -69,12 +70,115 @@ export const ReviewConfigSchema = z.object({
|
|
|
69
70
|
// "all" — review every PR, unless it carries the `skipLabel`.
|
|
70
71
|
// "label" — review only PRs carrying `label` (e.g. `ai-review`) or a
|
|
71
72
|
// `label:<agent>` variant. `skipLabel` still wins.
|
|
72
|
-
trigger: z.enum([
|
|
73
|
+
trigger: z.enum(["all", "label"]).default("all"),
|
|
73
74
|
// Opt-in label (and prefix for `label:<agent>`) used when trigger is "label".
|
|
74
|
-
label: z.string().default(
|
|
75
|
+
label: z.string().default("ai-review"),
|
|
75
76
|
// Opt a single PR out of review. A label (not a config flag) because labels
|
|
76
77
|
// are write-gated to maintainers — a PR author can't add one to dodge review.
|
|
77
|
-
skipLabel: z.string().default(
|
|
78
|
+
skipLabel: z.string().default("ai-review:skip"),
|
|
78
79
|
})
|
|
79
|
-
.default({ trigger:
|
|
80
|
+
.default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
|
|
81
|
+
});
|
|
82
|
+
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
83
|
+
export const RoutingScopeSchema = z.object({
|
|
84
|
+
/** Kebab-case id; used in comments, fingerprint namespacing, --scopes. */
|
|
85
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scope name must be kebab-case"),
|
|
86
|
+
/** Ordered globs (same dialect as noise.additionalIgnores: ** and *). */
|
|
87
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
88
|
+
/** Repo-relative dir whose .expo-code-review/ holds the scope's config ('.' = root).
|
|
89
|
+
* routing.jsonc is read from the PR-head checkout, so this field is
|
|
90
|
+
* PR-controllable input: absolute paths and `..` traversal are rejected so a
|
|
91
|
+
* scope config can never resolve outside the repo. */
|
|
92
|
+
config: z
|
|
93
|
+
.string()
|
|
94
|
+
.min(1)
|
|
95
|
+
.refine((value) => !path.isAbsolute(value) && !path.normalize(value).split(/[/\\]/).includes(".."), { message: 'scope config must be a repo-relative path without ".." segments' }),
|
|
96
|
+
});
|
|
97
|
+
export const RoutingManifestSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
/** How N scopes render on one PR. */
|
|
100
|
+
comment: z.enum(["single", "per-scope"]).default("single"),
|
|
101
|
+
/** Wall-clock budget for the per-scope review passes. Active scopes run
|
|
102
|
+
* SEQUENTIALLY in one `ecr ci` process, so the total is divided across them
|
|
103
|
+
* (not spent per scope). Absent = today's totals (zod defaults). */
|
|
104
|
+
budget: z
|
|
105
|
+
.object({
|
|
106
|
+
/** Total passes budget (minutes) split across active scopes. Sized to fit
|
|
107
|
+
* the scaffolded workflow's `timeout-minutes` (60) with margin for the
|
|
108
|
+
* coordinator, verification, and git/gh overhead. */
|
|
109
|
+
totalPassesMinutes: z.number().int().positive().default(32),
|
|
110
|
+
/** Per-scope floor (minutes): below this a scope review isn't worth
|
|
111
|
+
* starting, so the even split clamps up to it — even when that makes the
|
|
112
|
+
* scopes overshoot the total (ecr ci warns; doctor flags the worst case). */
|
|
113
|
+
minScopeMinutes: z.number().int().positive().default(5),
|
|
114
|
+
})
|
|
115
|
+
.default({ totalPassesMinutes: 32, minScopeMinutes: 5 }),
|
|
116
|
+
defaults: z
|
|
117
|
+
.object({
|
|
118
|
+
/** The ONLY manifest-level place auth is honored (locks the root value).
|
|
119
|
+
* Unwrap the inner `.default()` first: in zod v4 a `.default().optional()`
|
|
120
|
+
* chain still fires the default when the key is absent, which would make
|
|
121
|
+
* `defaults.auth` a phantom `{mode:'api-key',provider:'anthropic'}` for every
|
|
122
|
+
* manifest that omits auth and silently override the root config's real auth. */
|
|
123
|
+
auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
|
|
124
|
+
/** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
|
|
125
|
+
enforceAgents: z.array(z.string()).default([]),
|
|
126
|
+
/** Root comment marker; per-scope tags derive from it. */
|
|
127
|
+
commentTag: z.string().default("expo-ai-code-reviewer"),
|
|
128
|
+
})
|
|
129
|
+
.default({ enforceAgents: [], commentTag: "expo-ai-code-reviewer" }),
|
|
130
|
+
/** Ordered; LAST matching scope wins per changed file (CODEOWNERS discipline). */
|
|
131
|
+
scopes: z.array(RoutingScopeSchema).min(1),
|
|
132
|
+
})
|
|
133
|
+
.superRefine((manifest, ctx) => {
|
|
134
|
+
// unique scope names; unique config dirs (after path.normalize).
|
|
135
|
+
const seenNames = new Set();
|
|
136
|
+
for (const scope of manifest.scopes) {
|
|
137
|
+
if (seenNames.has(scope.name)) {
|
|
138
|
+
ctx.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
message: `duplicate scope name: ${scope.name}`,
|
|
141
|
+
path: ["scopes"],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
seenNames.add(scope.name);
|
|
145
|
+
}
|
|
146
|
+
const seenDirs = new Map();
|
|
147
|
+
for (const scope of manifest.scopes) {
|
|
148
|
+
const norm = path.normalize(scope.config).replace(/[/\\]+$/, "");
|
|
149
|
+
if (seenDirs.has(norm)) {
|
|
150
|
+
ctx.addIssue({
|
|
151
|
+
code: "custom",
|
|
152
|
+
message: `duplicate scope config dir: ${scope.config} (already used by scope "${seenDirs.get(norm)}")`,
|
|
153
|
+
path: ["scopes"],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
seenDirs.set(norm, scope.name);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* Scope config = root config MINUS the centrally locked keys. Allowlist of
|
|
161
|
+
* scope-overridable keys (Turborepo-style, graft 6): model, policy, chunk,
|
|
162
|
+
* noise (+ the prompt files living beside it: shared.md, coordinator.md,
|
|
163
|
+
* agents/). NEVER auth or breakGlass — declaring either fails parsing at the
|
|
164
|
+
* Zod level so IDE/doctor catch it before CI. commentTag is also locked: a
|
|
165
|
+
* scope's comment marker is always DERIVED (`<rootTag>:<scope>`; the default
|
|
166
|
+
* scope keeps the root tag) so `ecr ci`'s post/clear/reconcile paths and a
|
|
167
|
+
* standalone `ecr review --scope --post` always target the same marker — an
|
|
168
|
+
* honored per-scope tag would let the two halves strand each other's comments.
|
|
169
|
+
*/
|
|
170
|
+
export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
171
|
+
auth: true,
|
|
172
|
+
breakGlass: true,
|
|
173
|
+
commentTag: true,
|
|
174
|
+
}).extend({
|
|
175
|
+
auth: z
|
|
176
|
+
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
177
|
+
.optional(),
|
|
178
|
+
breakGlass: z.never({ error: "breakGlass is locked to the root config" }).optional(),
|
|
179
|
+
commentTag: z
|
|
180
|
+
.never({
|
|
181
|
+
error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
|
|
182
|
+
})
|
|
183
|
+
.optional(),
|
|
80
184
|
});
|