@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.
- package/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- 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 +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- 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 +28 -26
- 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 +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- 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 +61 -26
package/build/config/load.js
CHANGED
|
@@ -1,34 +1,67 @@
|
|
|
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
|
|
31
|
-
const
|
|
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,
|
|
72
|
+
const sharedPath = path.join(dir, "shared.md");
|
|
40
73
|
const sharedPromptText = existsSync(sharedPath)
|
|
41
|
-
? parseFrontmatter(await readFile(sharedPath,
|
|
42
|
-
:
|
|
43
|
-
const coordinatorPath = path.join(dir,
|
|
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,
|
|
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,
|
|
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(
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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(
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
303
|
+
else if (char === "/" && next === "/") {
|
|
159
304
|
inLine = true;
|
|
160
305
|
i++;
|
|
161
306
|
}
|
|
162
|
-
else if (char ===
|
|
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] ===
|
|
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
|
+
}
|