@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,34 +1,67 @@
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))));
31
- const override = process.env.REVIEWER_MODEL;
54
+ const raw = await readFile(configPath, "utf8");
55
+ const rawObject = JSON.parse(stripTrailingCommas(stripJsonComments(raw)));
56
+ const parsed = schema.parse(rawObject);
57
+ // An EMPTY REVIEWER_MODEL means "not set", not "use the empty model". GitHub Actions
58
+ // passes `${{ vars.REVIEWER_MODEL }}` as an empty string whenever that repo variable
59
+ // doesn't exist — which both scaffolded workflows do — so `??` (which only falls
60
+ // through on null/undefined) silently replaced every configured model with "". Every
61
+ // agent and the coordinator then ran on whatever OpenCode picked by default, so a
62
+ // config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
63
+ // nothing anywhere said so. Trim too: a stray newline is the same class of accident.
64
+ const override = process.env.REVIEWER_MODEL?.trim() || undefined;
32
65
  const defaultModel = override ?? parsed.model;
33
66
  const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
34
67
  const resolveTemp = (value, fallback) => {
@@ -36,39 +69,39 @@ export async function loadReviewConfig(repoRoot) {
36
69
  return Number.isFinite(n) ? n : fallback;
37
70
  };
38
71
  // shared.md is optional; the coordinator is required.
39
- const sharedPath = path.join(dir, 'shared.md');
72
+ const sharedPath = path.join(dir, "shared.md");
40
73
  const sharedPromptText = existsSync(sharedPath)
41
- ? parseFrontmatter(await readFile(sharedPath, 'utf8')).body
42
- : '';
43
- const coordinatorPath = path.join(dir, 'coordinator.md');
74
+ ? parseFrontmatter(await readFile(sharedPath, "utf8")).body
75
+ : "";
76
+ const coordinatorPath = path.join(dir, "coordinator.md");
44
77
  if (!existsSync(coordinatorPath)) {
45
78
  throw new Error(`Missing ${CONFIG_DIRNAME}/coordinator.md`);
46
79
  }
47
- const coordinatorMd = parseFrontmatter(await readFile(coordinatorPath, 'utf8'));
80
+ const coordinatorMd = parseFrontmatter(await readFile(coordinatorPath, "utf8"));
48
81
  // Every markdown file in agents/ is a reviewer agent (id = filename).
49
- const agentsDir = path.join(dir, 'agents');
82
+ const agentsDir = path.join(dir, "agents");
50
83
  if (!existsSync(agentsDir)) {
51
84
  throw new Error(`Missing ${CONFIG_DIRNAME}/agents/ directory. Run \`ecr init\`.`);
52
85
  }
53
- const agentFiles = (await readdir(agentsDir)).filter(name => name.endsWith('.md')).sort();
86
+ const agentFiles = (await readdir(agentsDir)).filter((name) => name.endsWith(".md")).sort();
54
87
  if (agentFiles.length === 0) {
55
88
  throw new Error(`No agent markdown files in ${CONFIG_DIRNAME}/agents/.`);
56
89
  }
57
90
  const agents = [];
58
91
  for (const file of agentFiles) {
59
- const md = parseFrontmatter(await readFile(path.join(agentsDir, file), 'utf8'));
60
- const id = file.replace(/\.md$/, '');
92
+ const md = parseFrontmatter(await readFile(path.join(agentsDir, file), "utf8"));
93
+ const id = file.replace(/\.md$/, "");
61
94
  agents.push({
62
95
  id,
63
- description: md.data.description ?? '',
64
- alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ''),
96
+ description: md.data.description ?? "",
97
+ alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ""),
65
98
  model: resolveModel(md.data.model),
66
99
  temperature: resolveTemp(md.data.temperature, 0.1),
67
100
  tools: DEFAULT_AGENT_TOOLS,
68
101
  promptText: md.body,
69
102
  });
70
103
  }
71
- return {
104
+ const config = {
72
105
  configDir: dir,
73
106
  sharedPromptText,
74
107
  agents,
@@ -80,15 +113,127 @@ export async function loadReviewConfig(repoRoot) {
80
113
  policy: parsed.policy,
81
114
  chunk: parsed.chunk,
82
115
  noise: parsed.noise,
83
- breakGlassMarker: parsed.breakGlass.marker,
84
- commentTag: parsed.commentTag,
85
- auth: {
86
- mode: parsed.auth.mode,
87
- provider: parsed.auth.provider,
88
- tokenEnv: parsed.auth.tokenEnv,
89
- },
116
+ // parsed.breakGlass/auth are always present for the root schema (defaults) and
117
+ // absent for the scope schema; loadScopeConfig overrides both afterwards.
118
+ breakGlassMarker: parsed.breakGlass?.marker ?? "/skip-review",
119
+ // Scope configs can't declare commentTag (scope schema rejects it);
120
+ // loadScopeConfig overwrites this placeholder with the manifest default.
121
+ commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
122
+ auth: normalizeAuth(parsed.auth),
90
123
  review: parsed.review,
91
124
  };
125
+ return { config, raw: rawObject };
126
+ }
127
+ /**
128
+ * Normalize either accepted `auth` shape (legacy single object, or the
129
+ * per-provider `{ providers }` map) into the canonical entry list. Absent auth
130
+ * means the schema default (api-key/openai, no tokenEnv).
131
+ */
132
+ export function normalizeAuth(auth) {
133
+ if (!auth) {
134
+ return [{ provider: "openai", mode: "api-key" }];
135
+ }
136
+ if ("providers" in auth) {
137
+ return Object.entries(auth.providers).map(([provider, entry]) => ({
138
+ provider,
139
+ mode: entry.mode,
140
+ tokenEnv: entry.tokenEnv,
141
+ upstream: entry.upstream,
142
+ }));
143
+ }
144
+ return [{ provider: auth.provider, mode: auth.mode, tokenEnv: auth.tokenEnv }];
145
+ }
146
+ /**
147
+ * Runtime auth lock: null when the entries' tokenEnv names equal the expected
148
+ * comma-separated set exactly (order-insensitive), else a human-readable
149
+ * mismatch. Set semantics because a multi-provider auth block names several
150
+ * credential envs — a PR must not be able to add, drop, or repoint any of them.
151
+ */
152
+ export function tokenEnvMismatch(auth, expected) {
153
+ const declared = [
154
+ ...new Set(auth.map((entry) => entry.tokenEnv).filter((v) => Boolean(v))),
155
+ ].sort();
156
+ const expectedSet = [
157
+ ...new Set(expected
158
+ .split(",")
159
+ .map((name) => name.trim())
160
+ .filter(Boolean)),
161
+ ].sort();
162
+ if (JSON.stringify(declared) === JSON.stringify(expectedSet)) {
163
+ return null;
164
+ }
165
+ return `configured tokenEnv set [${declared.join(", ") || "(none)"}] != ECR_EXPECTED_TOKEN_ENV [${expectedSet.join(", ")}]`;
166
+ }
167
+ /**
168
+ * auth is honored ONLY here: the manifest's `defaults.auth` wins when present,
169
+ * otherwise the root config.jsonc auth. A scope config can never contribute auth
170
+ * (the scope schema rejects it), so the secret-forwarding surface stays a single,
171
+ * root-owned value no matter how many scopes exist.
172
+ */
173
+ export function loadAuthFromRoot(rootConfig, manifest) {
174
+ const override = manifest?.defaults.auth;
175
+ if (override) {
176
+ return normalizeAuth(override);
177
+ }
178
+ return rootConfig.auth;
179
+ }
180
+ /**
181
+ * Load one scope's fully-resolved config. The default scope (config '.') reuses
182
+ * the root config unchanged except auth; a nested scope reads its own
183
+ * `.expo-code-review/` via the scope schema (auth/breakGlass rejected by Zod).
184
+ * auth and breakGlass always come from the root; `defaults.enforceAgents` are
185
+ * injected with alwaysRun and win any same-id agent in the scope roster (risk 11).
186
+ */
187
+ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
188
+ let base;
189
+ let commentTag;
190
+ if (scope.config === ".") {
191
+ base = rootConfig;
192
+ // The default scope keeps the ROOT comment marker so the existing single
193
+ // comment (and its dismissal state) upserts in place, not duplicated (risk 8).
194
+ commentTag = rootConfig.commentTag;
195
+ }
196
+ else {
197
+ // Defense in depth behind the schema's traversal refinement: never read a
198
+ // scope config from outside the repo checkout (scope.config is PR-controllable).
199
+ const resolvedRoot = path.resolve(root);
200
+ const resolvedScope = path.resolve(root, scope.config);
201
+ if (resolvedScope !== resolvedRoot && !resolvedScope.startsWith(resolvedRoot + path.sep)) {
202
+ throw new Error(`scope "${scope.name}": config "${scope.config}" resolves outside the repo checkout`);
203
+ }
204
+ const dir = path.join(root, scope.config, CONFIG_DIRNAME);
205
+ const { config } = await loadConfigDir(dir, ScopeReviewConfigSchema);
206
+ base = config;
207
+ // Non-default scopes never carry their own marker (the scope schema rejects
208
+ // commentTag): ci derives `<rootTag>:<scope>` for per-scope comments, so the
209
+ // loaded value here is only the manifest default, for display/doctor.
210
+ commentTag = manifest.defaults.commentTag;
211
+ }
212
+ // Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
213
+ // same-id agent the scope defines (the enforced one wins — risk 11).
214
+ const agents = base.agents.map((agent) => ({ ...agent }));
215
+ for (const id of manifest.defaults.enforceAgents) {
216
+ const rootAgent = rootConfig.agents.find((agent) => agent.id === id);
217
+ if (!rootAgent) {
218
+ throw new Error(`defaults.enforceAgents lists "${id}", but the root roster has no agent with that id.`);
219
+ }
220
+ const enforced = { ...rootAgent, alwaysRun: true };
221
+ const index = agents.findIndex((agent) => agent.id === id);
222
+ if (index >= 0) {
223
+ agents[index] = enforced;
224
+ }
225
+ else {
226
+ agents.push(enforced);
227
+ }
228
+ }
229
+ return {
230
+ ...base,
231
+ agents,
232
+ auth: loadAuthFromRoot(rootConfig, manifest),
233
+ breakGlassMarker: rootConfig.breakGlassMarker,
234
+ commentTag,
235
+ scopeName: scope.name,
236
+ };
92
237
  }
93
238
  /**
94
239
  * Parse optional YAML-ish frontmatter (simple `key: value` scalars) from the top
@@ -96,20 +241,20 @@ export async function loadReviewConfig(repoRoot) {
96
241
  * stripped. Supports per-agent overrides like `model:` and `temperature:`.
97
242
  */
98
243
  export function parseFrontmatter(md) {
99
- if (!md.startsWith('---')) {
244
+ if (!md.startsWith("---")) {
100
245
  return { data: {}, body: md };
101
246
  }
102
- const end = md.indexOf('\n---', 3);
247
+ const end = md.indexOf("\n---", 3);
103
248
  if (end === -1) {
104
249
  return { data: {}, body: md };
105
250
  }
106
251
  const header = md.slice(3, end).trim();
107
- const body = md.slice(end + 4).replace(/^\r?\n/, '');
252
+ const body = md.slice(end + 4).replace(/^\r?\n/, "");
108
253
  const data = {};
109
- for (const line of header.split('\n')) {
254
+ for (const line of header.split("\n")) {
110
255
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
111
256
  if (match) {
112
- data[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
257
+ data[match[1]] = match[2].trim().replace(/^["']|["']$/g, "");
113
258
  }
114
259
  }
115
260
  return { data, body };
@@ -119,7 +264,7 @@ export function parseFrontmatter(md) {
119
264
  * string literals. The config is trusted (in-repo), so a light scanner suffices.
120
265
  */
121
266
  export function stripJsonComments(input) {
122
- let out = '';
267
+ let out = "";
123
268
  let inString = false;
124
269
  let inLine = false;
125
270
  let inBlock = false;
@@ -127,14 +272,14 @@ export function stripJsonComments(input) {
127
272
  const char = input[i];
128
273
  const next = input[i + 1];
129
274
  if (inLine) {
130
- if (char === '\n') {
275
+ if (char === "\n") {
131
276
  inLine = false;
132
277
  out += char;
133
278
  }
134
279
  continue;
135
280
  }
136
281
  if (inBlock) {
137
- if (char === '*' && next === '/') {
282
+ if (char === "*" && next === "/") {
138
283
  inBlock = false;
139
284
  i++;
140
285
  }
@@ -142,8 +287,8 @@ export function stripJsonComments(input) {
142
287
  }
143
288
  if (inString) {
144
289
  out += char;
145
- if (char === '\\') {
146
- out += input[i + 1] ?? '';
290
+ if (char === "\\") {
291
+ out += input[i + 1] ?? "";
147
292
  i++;
148
293
  }
149
294
  else if (char === '"') {
@@ -155,11 +300,11 @@ export function stripJsonComments(input) {
155
300
  inString = true;
156
301
  out += char;
157
302
  }
158
- else if (char === '/' && next === '/') {
303
+ else if (char === "/" && next === "/") {
159
304
  inLine = true;
160
305
  i++;
161
306
  }
162
- else if (char === '/' && next === '*') {
307
+ else if (char === "/" && next === "*") {
163
308
  inBlock = true;
164
309
  i++;
165
310
  }
@@ -171,14 +316,14 @@ export function stripJsonComments(input) {
171
316
  }
172
317
  /** Remove trailing commas before `}`/`]` (JSONC), ignoring string contents. */
173
318
  export function stripTrailingCommas(input) {
174
- let out = '';
319
+ let out = "";
175
320
  let inString = false;
176
321
  for (let i = 0; i < input.length; i++) {
177
322
  const char = input[i];
178
323
  if (inString) {
179
324
  out += char;
180
- if (char === '\\') {
181
- out += input[i + 1] ?? '';
325
+ if (char === "\\") {
326
+ out += input[i + 1] ?? "";
182
327
  i++;
183
328
  }
184
329
  else if (char === '"') {
@@ -191,12 +336,12 @@ export function stripTrailingCommas(input) {
191
336
  out += char;
192
337
  continue;
193
338
  }
194
- if (char === ',') {
339
+ if (char === ",") {
195
340
  let j = i + 1;
196
341
  while (j < input.length && /\s/.test(input[j])) {
197
342
  j++;
198
343
  }
199
- if (input[j] === '}' || input[j] === ']') {
344
+ if (input[j] === "}" || input[j] === "]") {
200
345
  continue; // drop the trailing comma
201
346
  }
202
347
  }
@@ -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
+ }