@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,33 +1,59 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { ReviewConfigSchema } from './schema.js';
5
- import { toolMap } from '../core/tools.js';
6
- export const CONFIG_DIRNAME = '.expo-code-review';
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(['read', 'grep', 'glob', 'list']);
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
- export function hasConfig(repoRoot) {
13
- const dir = configDirFor(repoRoot);
14
- return existsSync(path.join(dir, 'config.jsonc')) || existsSync(path.join(dir, 'config.json'));
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 = configDirFor(repoRoot);
23
- const configPath = ['config.jsonc', 'config.json']
24
- .map(name => path.join(dir, name))
25
- .find(candidate => existsSync(candidate));
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 ${repoRoot}. Run \`ecr init\` to scaffold one.`);
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, 'utf8');
30
- const parsed = ReviewConfigSchema.parse(JSON.parse(stripTrailingCommas(stripJsonComments(raw))));
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, 'shared.md');
65
+ const sharedPath = path.join(dir, "shared.md");
40
66
  const sharedPromptText = existsSync(sharedPath)
41
- ? parseFrontmatter(await readFile(sharedPath, 'utf8')).body
42
- : '';
43
- const coordinatorPath = path.join(dir, 'coordinator.md');
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, 'utf8'));
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, 'agents');
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('.md')).sort();
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), 'utf8'));
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
- return {
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
- breakGlassMarker: parsed.breakGlass.marker,
84
- commentTag: parsed.commentTag,
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.mode,
87
- provider: parsed.auth.provider,
88
- tokenEnv: parsed.auth.tokenEnv,
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('\n---', 3);
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('\n')) {
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 === '\n') {
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 === '*' && next === '/') {
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 === '/' && next === '/') {
260
+ else if (char === "/" && next === "/") {
159
261
  inLine = true;
160
262
  i++;
161
263
  }
162
- else if (char === '/' && next === '*') {
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] === '}' || 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
+ }
@@ -1,8 +1,9 @@
1
- import { z } from 'zod';
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('anthropic/claude-sonnet-5'),
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('/skip-review') })
52
- .default({ marker: '/skip-review' }),
53
- commentTag: z.string().default('expo-ai-code-reviewer'),
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(['api-key', 'oauth']).default('api-key'),
60
- provider: z.string().default('anthropic'),
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: 'api-key', provider: 'anthropic' }),
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(['all', 'label']).default('all'),
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('ai-review'),
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('ai-review:skip'),
78
+ skipLabel: z.string().default("ai-review:skip"),
78
79
  })
79
- .default({ trigger: 'all', label: 'ai-review', skipLabel: 'ai-review:skip' }),
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
  });