@expo/code-review-cli 0.6.0 → 0.8.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 +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#doctor-and-setup-auth — derives a plan from auth config, then guides local credential acquisition
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
@@ -5,7 +6,8 @@ import path from "node:path";
|
|
|
5
6
|
import readline from "node:readline/promises";
|
|
6
7
|
import { hasConfig, loadReviewConfig } from "../config/load.js";
|
|
7
8
|
import { jwtExpiryMs } from "../core/auth.js";
|
|
8
|
-
import {
|
|
9
|
+
import { claudeSubscriptionActive, resolveClaudeCli } from "../core/claude-code.js";
|
|
10
|
+
import { resolveOpencodeCli } from "../core/opencode.js";
|
|
9
11
|
import { errorMessage } from "../core/util.js";
|
|
10
12
|
const USAGE = `ecr setup-auth — set up model credentials for local runs
|
|
11
13
|
|
|
@@ -15,6 +17,10 @@ getting each credential:
|
|
|
15
17
|
\`opencode auth login\` (interactive; opens your browser), then prints the
|
|
16
18
|
\`export <tokenEnv>=…\` line to add to your shell config. An existing
|
|
17
19
|
OpenCode ChatGPT sign-in is reused instead of re-authenticating.
|
|
20
|
+
• a Claude Max/Team subscription (any anthropic/… model): reuses an active
|
|
21
|
+
\`claude\` login when present, or runs \`claude setup-token\` (interactive;
|
|
22
|
+
opens your browser) and prints the \`export <tokenEnv>=…\` line for
|
|
23
|
+
CI/headless runs.
|
|
18
24
|
• an API key (api-key entries): prints where to create the key, the exact
|
|
19
25
|
permissions it needs, and the export line to fill in.
|
|
20
26
|
|
|
@@ -24,10 +30,21 @@ with the default env name.
|
|
|
24
30
|
Options:
|
|
25
31
|
--yes Skip confirmation prompts (still interactive during the login itself).
|
|
26
32
|
`;
|
|
27
|
-
export function planFromAuth(auth) {
|
|
33
|
+
export function planFromAuth(auth, models = []) {
|
|
28
34
|
const plan = { manualKeys: [], unsupported: [] };
|
|
35
|
+
// anthropic is always served by the Claude Code CLI — a `claude setup-token`
|
|
36
|
+
// subscription login covers it. Trigger on either an explicit anthropic auth
|
|
37
|
+
// entry OR any anthropic/… model in the roster (an entry is entirely optional).
|
|
38
|
+
const anthropicEntry = auth.find((entry) => entry.provider === "anthropic");
|
|
39
|
+
const usesAnthropicModel = models.some((model) => model === "anthropic" || model.startsWith("anthropic/"));
|
|
40
|
+
if (anthropicEntry || usesAnthropicModel) {
|
|
41
|
+
plan.claudeLogin = { tokenEnv: anthropicEntry?.tokenEnv ?? "CLAUDE_CODE_OAUTH_TOKEN" };
|
|
42
|
+
}
|
|
29
43
|
for (const entry of auth) {
|
|
30
|
-
if (entry.
|
|
44
|
+
if (entry.provider === "anthropic") {
|
|
45
|
+
continue; // handled above (claude engine); mode is irrelevant here.
|
|
46
|
+
}
|
|
47
|
+
else if (entry.mode === "oauth" && entry.provider === "openai" && entry.tokenEnv) {
|
|
31
48
|
plan.chatgptLogin = { tokenEnv: entry.tokenEnv };
|
|
32
49
|
}
|
|
33
50
|
else if (entry.mode === "api-key" && entry.tokenEnv) {
|
|
@@ -49,6 +66,7 @@ export function opencodeAuthJsonPath(env = process.env) {
|
|
|
49
66
|
const dataHome = env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
50
67
|
return path.join(dataHome, "opencode", "auth.json");
|
|
51
68
|
}
|
|
69
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — refresh tokens are single-use; they never leave OpenCode's store
|
|
52
70
|
/**
|
|
53
71
|
* The stored ChatGPT sign-in's ACCESS token, if OpenCode has a live one. The
|
|
54
72
|
* refresh token deliberately never leaves OpenCode's store: refresh tokens are
|
|
@@ -86,6 +104,7 @@ async function confirm(question, skip) {
|
|
|
86
104
|
rl.close();
|
|
87
105
|
}
|
|
88
106
|
}
|
|
107
|
+
// @ref LLP 0007#doctor-and-setup-auth [constrained-by] — shell metacharacters in tokens never expand
|
|
89
108
|
/** The line to paste into a shell config. Single-quoted: tokens never contain '. */
|
|
90
109
|
export function exportLine(tokenEnv, value) {
|
|
91
110
|
return `export ${tokenEnv}='${value}'`;
|
|
@@ -104,7 +123,10 @@ export async function setupAuthCommand(argv = []) {
|
|
|
104
123
|
let plan;
|
|
105
124
|
if (hasConfig(process.cwd())) {
|
|
106
125
|
const config = await loadReviewConfig(process.cwd());
|
|
107
|
-
plan = planFromAuth(config.auth
|
|
126
|
+
plan = planFromAuth(config.auth, [
|
|
127
|
+
...config.agents.map((agent) => agent.model),
|
|
128
|
+
config.coordinator.model,
|
|
129
|
+
]);
|
|
108
130
|
}
|
|
109
131
|
else {
|
|
110
132
|
err("No .expo-code-review config here — setting up the default ChatGPT/Codex flow.");
|
|
@@ -112,11 +134,55 @@ export async function setupAuthCommand(argv = []) {
|
|
|
112
134
|
{ provider: "openai", mode: "oauth", tokenEnv: "CODEX_OAUTH_ACCESS_TOKEN" },
|
|
113
135
|
]);
|
|
114
136
|
}
|
|
115
|
-
if (!plan.chatgptLogin &&
|
|
137
|
+
if (!plan.chatgptLogin &&
|
|
138
|
+
!plan.claudeLogin &&
|
|
139
|
+
plan.manualKeys.length === 0 &&
|
|
140
|
+
plan.unsupported.length === 0) {
|
|
116
141
|
out("This repo's auth config needs no local credential setup (OpenCode's own login covers it).");
|
|
117
142
|
return;
|
|
118
143
|
}
|
|
119
144
|
const exports = [];
|
|
145
|
+
if (plan.claudeLogin) {
|
|
146
|
+
const { tokenEnv } = plan.claudeLogin;
|
|
147
|
+
if (process.env[tokenEnv]) {
|
|
148
|
+
err(`✓ ${tokenEnv} is already set in this shell — skipping the Claude subscription login.`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Resolve to a trusted absolute path (refusing an in-tree binary), never a
|
|
152
|
+
// bare `claude`: setup-auth may run inside a cloned untrusted repo, so a
|
|
153
|
+
// PR-committed shim must not be the `claude` we probe or hand the terminal to.
|
|
154
|
+
const claudeCliPath = await resolveClaudeCli();
|
|
155
|
+
// A live local `claude` login already covers interactive runs — only CI or
|
|
156
|
+
// a headless box needs the token in an env var.
|
|
157
|
+
const loggedIn = await claudeSubscriptionActive({ cliPath: claudeCliPath ?? undefined });
|
|
158
|
+
if (loggedIn) {
|
|
159
|
+
err("✓ A Claude Max/Team subscription login is active locally — `ecr review` works now. " +
|
|
160
|
+
`You only need ${tokenEnv} for CI/headless runs.`);
|
|
161
|
+
}
|
|
162
|
+
err("`claude setup-token` mints a 1-year subscription token (opens your browser).");
|
|
163
|
+
if (!(await confirm("Run it now?", yes))) {
|
|
164
|
+
err("Skipped `claude setup-token`.");
|
|
165
|
+
}
|
|
166
|
+
else if (!claudeCliPath) {
|
|
167
|
+
throw new Error("The `claude` CLI is not installed on this host (npm i -g " +
|
|
168
|
+
"@anthropic-ai/claude-code); nothing was changed.");
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const result = spawnSync(claudeCliPath, ["setup-token"], {
|
|
172
|
+
stdio: "inherit",
|
|
173
|
+
cwd: os.tmpdir(),
|
|
174
|
+
});
|
|
175
|
+
if (result.status !== 0) {
|
|
176
|
+
throw new Error(`\`claude setup-token\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
|
|
177
|
+
}
|
|
178
|
+
// setup-token prints the token to the terminal and persists it nowhere we
|
|
179
|
+
// can read back, so the user pastes it into the export line themselves.
|
|
180
|
+
err(`Copy the token \`claude setup-token\` just printed and paste it in place of the ` +
|
|
181
|
+
`placeholder below.`);
|
|
182
|
+
exports.push(exportLine(tokenEnv, "<paste the token setup-token printed>"));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
120
186
|
if (plan.chatgptLogin) {
|
|
121
187
|
const { tokenEnv } = plan.chatgptLogin;
|
|
122
188
|
if (process.env[tokenEnv]) {
|
|
@@ -141,9 +207,20 @@ export async function setupAuthCommand(argv = []) {
|
|
|
141
207
|
err("Skipped the ChatGPT sign-in.");
|
|
142
208
|
}
|
|
143
209
|
else {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
210
|
+
// Resolve to a trusted absolute path (our bundled shim, else PATH with an
|
|
211
|
+
// in-tree refusal), never a bare `opencode`: setup-auth may run inside a
|
|
212
|
+
// cloned untrusted repo, so a PR-committed shim must not be the CLI we hand
|
|
213
|
+
// the terminal to. Run from tmpdir(), never the (possibly untrusted) cwd —
|
|
214
|
+
// the login writes to OpenCode's global auth store, not the working dir.
|
|
215
|
+
const opencodeCli = await resolveOpencodeCli();
|
|
216
|
+
if (!opencodeCli) {
|
|
217
|
+
throw new Error("The `opencode` CLI is not available (install `opencode-ai`, or add " +
|
|
218
|
+
"node_modules/.bin to PATH); nothing was changed.");
|
|
219
|
+
}
|
|
220
|
+
const result = spawnSync(opencodeCli, ["auth", "login"], {
|
|
221
|
+
stdio: "inherit",
|
|
222
|
+
cwd: os.tmpdir(),
|
|
223
|
+
});
|
|
147
224
|
if (result.status !== 0) {
|
|
148
225
|
throw new Error(`\`opencode auth login\` exited with ${result.status ?? "a signal"}; nothing was changed.`);
|
|
149
226
|
}
|
|
@@ -190,9 +267,7 @@ export async function setupAuthCommand(argv = []) {
|
|
|
190
267
|
for (const entry of plan.unsupported) {
|
|
191
268
|
err("");
|
|
192
269
|
err(`auth for "${entry.provider}" is mode "oauth", which has no automated setup flow here` +
|
|
193
|
-
|
|
194
|
-
? " — and cannot work: Anthropic prohibits subscription tokens in third-party tools. Use an API key instead."
|
|
195
|
-
: `. Set ${entry.tokenEnv ?? "its token env"} manually.`));
|
|
270
|
+
`. Set ${entry.tokenEnv ?? "its token env"} manually.`);
|
|
196
271
|
}
|
|
197
272
|
if (exports.length > 0) {
|
|
198
273
|
const rc = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "your shell config";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0007#verify-config-the-config-guard — the CI trust guard that runs before the loaders are trusted, so it deliberately does not use them
|
|
1
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
@@ -28,6 +29,7 @@ Options:
|
|
|
28
29
|
--json Emit {ok, findings:[{file, problem}]} on stdout.
|
|
29
30
|
`;
|
|
30
31
|
const CONFIG_FILENAMES = new Set(["config.jsonc", "config.json", ROUTING_FILENAME]);
|
|
32
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — on-disk sweep, not git index, not the manifest; CONFIG_FILENAMES must mirror load.ts
|
|
31
33
|
/**
|
|
32
34
|
* Discover every config the CLI could ever read via a plain recursive walk (not
|
|
33
35
|
* `git ls-files`): a PR can't hide an unreferenced/untracked config dir from an
|
|
@@ -183,6 +185,7 @@ export async function verifyConfig(root, options = {}) {
|
|
|
183
185
|
}
|
|
184
186
|
seen.add(occurrence.value);
|
|
185
187
|
}
|
|
188
|
+
// @ref LLP 0007#verify-config-the-config-guard [implements] — exact set equality; adding a credential is refused like repointing one
|
|
186
189
|
// With an expectation set, the declared names must equal the expected SET
|
|
187
190
|
// exactly (comma-separated; order-insensitive). A missing name is as much a
|
|
188
191
|
// finding as an extra one — a PR must not add, drop, or repoint credentials.
|
package/build/config/load.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — shared root/scope loader; ECR_CONFIG_DIR escape hatch
|
|
2
|
+
// @ref LLP 0006#model-resolution — REVIEWER_MODEL env override resolution
|
|
3
|
+
// @ref LLP 0006#auth-config-shapes — auth normalization (normalizeAuth, tokenEnvMismatch, loadAuthFromRoot)
|
|
4
|
+
// @ref LLP 0006#root-vs-scope-config — scope config loading, commentTag derivation, enforceAgents injection
|
|
1
5
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
6
|
import { existsSync } from "node:fs";
|
|
3
7
|
import path from "node:path";
|
|
4
8
|
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "./schema.js";
|
|
5
9
|
import { toolMap } from "../core/tools.js";
|
|
6
10
|
export const CONFIG_DIRNAME = ".expo-code-review";
|
|
11
|
+
/** Stack config for a scope load (where `stack` is schema-rejected and absent). */
|
|
12
|
+
const STACK_CONFIG_DEFAULTS = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
maxDepth: 4,
|
|
15
|
+
maxPrs: 8,
|
|
16
|
+
maxFilesPerPr: 100,
|
|
17
|
+
requireSameAuthor: true,
|
|
18
|
+
confirmWithPatch: false,
|
|
19
|
+
maxConfirmations: 10,
|
|
20
|
+
};
|
|
21
|
+
/** Feedback config for a scope load (where `feedback` is schema-rejected and absent). */
|
|
22
|
+
const FEEDBACK_CONFIG_DEFAULTS = {
|
|
23
|
+
mode: "annotate",
|
|
24
|
+
match: "both",
|
|
25
|
+
dismiss: "never",
|
|
26
|
+
protectedCategories: ["secrets", "security"],
|
|
27
|
+
maxAdjudications: 10,
|
|
28
|
+
};
|
|
7
29
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
8
30
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
|
9
31
|
export function configDirFor(repoRoot) {
|
|
@@ -61,6 +83,7 @@ async function loadConfigDir(dir, schema) {
|
|
|
61
83
|
// agent and the coordinator then ran on whatever OpenCode picked by default, so a
|
|
62
84
|
// config saying `anthropic/claude-sonnet-5` reviewed with something else entirely and
|
|
63
85
|
// nothing anywhere said so. Trim too: a stray newline is the same class of accident.
|
|
86
|
+
// @ref LLP 0006#model-resolution [constrained-by] — never ??; GitHub Actions passes an unset var as empty string, not undefined
|
|
64
87
|
const override = process.env.REVIEWER_MODEL?.trim() || undefined;
|
|
65
88
|
const defaultModel = override ?? parsed.model;
|
|
66
89
|
const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
|
|
@@ -121,6 +144,14 @@ async function loadConfigDir(dir, schema) {
|
|
|
121
144
|
commentTag: parsed.commentTag ?? "expo-ai-code-reviewer",
|
|
122
145
|
auth: normalizeAuth(parsed.auth),
|
|
123
146
|
review: parsed.review,
|
|
147
|
+
// Root-only: the scope schema rejects `stack`, so parsed.stack is absent for a
|
|
148
|
+
// scope config and the defaults stand in (unused — the command layer reads the
|
|
149
|
+
// ROOT config's stack values to drive the walk).
|
|
150
|
+
stack: parsed.stack ?? STACK_CONFIG_DEFAULTS,
|
|
151
|
+
// Root-only: the scope schema rejects `feedback`, so parsed.feedback is absent
|
|
152
|
+
// for a scope config and the defaults stand in (unused — the command layer
|
|
153
|
+
// reads the ROOT config's feedback values; the comment lifecycle is global).
|
|
154
|
+
feedback: parsed.feedback ?? FEEDBACK_CONFIG_DEFAULTS,
|
|
124
155
|
};
|
|
125
156
|
return { config, raw: rawObject };
|
|
126
157
|
}
|
|
@@ -185,6 +216,7 @@ export function loadAuthFromRoot(rootConfig, manifest) {
|
|
|
185
216
|
* with the root config; the scope config activates after merge) instead of
|
|
186
217
|
* failing the run on exactly the PR that introduces the scope.
|
|
187
218
|
*/
|
|
219
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — deliberately bypasses ECR_CONFIG_DIR; scope subtrees stay repo-root-relative
|
|
188
220
|
export function hasScopeConfig(root, scope) {
|
|
189
221
|
if (scope.config === ".") {
|
|
190
222
|
return true;
|
|
@@ -224,6 +256,7 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
224
256
|
// loaded value here is only the manifest default, for display/doctor.
|
|
225
257
|
commentTag = manifest.defaults.commentTag;
|
|
226
258
|
}
|
|
259
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — ROOT enforced agent always wins a same-id scope agent (risk 11)
|
|
227
260
|
// Inject the enforced agents from the ROOT roster with alwaysRun, replacing any
|
|
228
261
|
// same-id agent the scope defines (the enforced one wins — risk 11).
|
|
229
262
|
const agents = base.agents.map((agent) => ({ ...agent }));
|
|
@@ -247,6 +280,12 @@ export async function loadScopeConfig(root, scope, manifest, rootConfig) {
|
|
|
247
280
|
auth: loadAuthFromRoot(rootConfig, manifest),
|
|
248
281
|
breakGlassMarker: rootConfig.breakGlassMarker,
|
|
249
282
|
commentTag,
|
|
283
|
+
// Root-only, like stack: a non-default scope's `base` carries only the
|
|
284
|
+
// hardcoded placeholder (the scope schema rejects `feedback`), so re-derive
|
|
285
|
+
// from the root here or consumers of a nested scope's config would silently
|
|
286
|
+
// run the default policy instead of the repo's real one.
|
|
287
|
+
stack: rootConfig.stack,
|
|
288
|
+
feedback: rootConfig.feedback,
|
|
250
289
|
scopeName: scope.name,
|
|
251
290
|
};
|
|
252
291
|
}
|
package/build/config/routing.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc parsing, scope resolution (last-match-wins), overlaps/unmatched
|
|
2
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch — routing.jsonc travels with config.jsonc via resolveConfigDir
|
|
3
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — per-scope budget split (scopePassesBudgetMs)
|
|
1
4
|
import { readFile } from "node:fs/promises";
|
|
2
5
|
import { existsSync } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
@@ -15,6 +18,7 @@ export const ROUTING_FILENAME = "routing.jsonc";
|
|
|
15
18
|
* Scope `config` paths stay repo-root-relative (see `loadScopeConfig`): an
|
|
16
19
|
* override relocates only the ROOT artifacts, never the scopes' own subtrees.
|
|
17
20
|
*/
|
|
21
|
+
// @ref LLP 0006#loading-and-the-config-dir-escape-hatch [implements] — travels with config.jsonc; a real bug once let them split
|
|
18
22
|
export async function loadRoutingManifest(root, options = {}) {
|
|
19
23
|
const manifestPath = path.join(resolveConfigDir(root, options.configDir), ROUTING_FILENAME);
|
|
20
24
|
if (!existsSync(manifestPath)) {
|
|
@@ -30,6 +34,7 @@ export async function loadRoutingManifest(root, options = {}) {
|
|
|
30
34
|
// silently miss root-level files (README.md, package.json). To give the double-star +
|
|
31
35
|
// slash its conventional "zero or more directories" meaning, we also test the variant
|
|
32
36
|
// with each such prefix removed, so the catch-all matches both `a.ts` and `src/b.ts`.
|
|
37
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — workaround for matchesIgnore's **-needs-a-slash limitation (LLP 0004 dialect)
|
|
33
38
|
function patternVariants(pattern) {
|
|
34
39
|
const collapsed = pattern.replace(/\*\*\//g, "");
|
|
35
40
|
return collapsed !== pattern && collapsed.length > 0 ? [pattern, collapsed] : [pattern];
|
|
@@ -44,6 +49,7 @@ function scopeMatches(paths, file) {
|
|
|
44
49
|
* matchesIgnore (supports ** across / and * within a segment — the manifest
|
|
45
50
|
* documents this dialect). Deterministic, no filesystem access.
|
|
46
51
|
*/
|
|
52
|
+
// @ref LLP 0006#routing-manifest [implements] — last-match-wins (CODEOWNERS discipline); each file lands in exactly one scope
|
|
47
53
|
export function resolveScopes(manifest, changedFiles) {
|
|
48
54
|
const buckets = new Map();
|
|
49
55
|
const unmatched = [];
|
|
@@ -86,6 +92,7 @@ export function scopedCommentTag(rootTag, scopeName) {
|
|
|
86
92
|
* starting — and `overshoot` flags that the run will exceed the total budget so
|
|
87
93
|
* the caller can warn. Pure so the math is unit-testable.
|
|
88
94
|
*/
|
|
95
|
+
// @ref LLP 0006#budgets-and-chunking-defaults [implements] — floor(total/active) clamped to a 5-min floor; overshoot flags the clamp
|
|
89
96
|
export function scopePassesBudgetMs(totalMs, minMs, activeCount) {
|
|
90
97
|
const count = Math.max(1, activeCount);
|
|
91
98
|
const evenSplit = Math.floor(totalMs / count);
|
package/build/config/schema.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// @ref LLP 0006#root-vs-scope-config — schema for root vs. scope-overridable config keys
|
|
2
|
+
// @ref LLP 0006#auth-config-shapes — auth union schema (legacy single credential + per-provider map)
|
|
3
|
+
// @ref LLP 0006#routing-manifest — routing.jsonc manifest schema (scopes, budgets, traversal guard)
|
|
4
|
+
// @ref LLP 0006#budgets-and-chunking-defaults — chunk/budget default values and re-tuning heuristics
|
|
1
5
|
import path from "node:path";
|
|
2
6
|
import { z } from "zod";
|
|
7
|
+
import { CATEGORIES } from "../core/schema.js";
|
|
3
8
|
export const ReviewConfigSchema = z.object({
|
|
4
9
|
/** Default model for every agent + the coordinator. Override per-agent via
|
|
5
10
|
* frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
|
|
@@ -68,6 +73,7 @@ export const ReviewConfigSchema = z.object({
|
|
|
68
73
|
// Union order matters: the map form must be tried FIRST — the legacy object's keys
|
|
69
74
|
// all have defaults, so a non-strict legacy parse would accept (and gut) a
|
|
70
75
|
// { providers } object by stripping the unknown key.
|
|
76
|
+
// @ref LLP 0006#auth-config-shapes [constrained-by] — map-first order is load-bearing; reordering silently guts multi-provider auth
|
|
71
77
|
auth: z
|
|
72
78
|
.union([
|
|
73
79
|
z.object({
|
|
@@ -76,9 +82,13 @@ export const ReviewConfigSchema = z.object({
|
|
|
76
82
|
// "oauth": tokenEnv holds an OAuth token, injected into an isolated
|
|
77
83
|
// OpenCode auth.json. For "openai" this is the REFRESH token from a
|
|
78
84
|
// ChatGPT/Codex sign-in (OpenCode's codex plugin mints access tokens
|
|
79
|
-
// from it).
|
|
80
|
-
// anthropic
|
|
81
|
-
//
|
|
85
|
+
// from it).
|
|
86
|
+
// NOTE: provider "anthropic" is ALWAYS served by the Claude Code CLI
|
|
87
|
+
// (the engine is inferred from the `anthropic/…` model, not this mode) —
|
|
88
|
+
// for anthropic, mode is irrelevant; tokenEnv optionally names the
|
|
89
|
+
// credential env (an "sk-ant-oat…" subscription token or an Anthropic
|
|
90
|
+
// API key), and no entry at all falls back to the machine's `claude`
|
|
91
|
+
// login. See core/claude-code.ts.
|
|
82
92
|
mode: z.enum(["api-key", "oauth"]).default("api-key"),
|
|
83
93
|
tokenEnv: z.string().optional(),
|
|
84
94
|
// Set ⇒ this provider id is an ALIAS synthesized into the OpenCode
|
|
@@ -111,6 +121,77 @@ export const ReviewConfigSchema = z.object({
|
|
|
111
121
|
skipLabel: z.string().default("ai-review:skip"),
|
|
112
122
|
})
|
|
113
123
|
.default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
|
|
124
|
+
// Stack-aware requalification: walk the OPEN PRs stacked on top of this one and
|
|
125
|
+
// let the coordinator mark absence-style findings a later PR already addresses.
|
|
126
|
+
// ROOT-ONLY (one PR has one stack) and off by default — a suppression-adjacent
|
|
127
|
+
// feature earns trust with field data first. Under `ecr ci` it auto-enables from
|
|
128
|
+
// this trusted-base value; `ecr review --pr` needs an explicit --stack-aware.
|
|
129
|
+
// @ref LLP 0010#config-and-cli-surface [implements] — root-only + off-by-default; head config can never enable, widen, or disable it
|
|
130
|
+
stack: z
|
|
131
|
+
.object({
|
|
132
|
+
enabled: z.boolean().default(false),
|
|
133
|
+
maxDepth: z.number().int().positive().default(4),
|
|
134
|
+
// Children per level (per parent branch) the walk will follow.
|
|
135
|
+
maxPrs: z.number().int().positive().default(8),
|
|
136
|
+
maxFilesPerPr: z.number().int().positive().default(100),
|
|
137
|
+
// Only children whose author is the current PR's author enter the manifest —
|
|
138
|
+
// closes cross-author poisoning (a push-access colleague opening a child PR on
|
|
139
|
+
// the victim's branch). Set false from the trusted base for genuine team stacks.
|
|
140
|
+
requireSameAuthor: z.boolean().default(true),
|
|
141
|
+
// v2: confirm each requalification against the addressing PR's actual patch
|
|
142
|
+
// before believing it (a no-tools LLM reads the inlined patch). Default false so
|
|
143
|
+
// v2 ships dark until flipped; maxConfirmations bounds that cost.
|
|
144
|
+
confirmWithPatch: z.boolean().default(false),
|
|
145
|
+
maxConfirmations: z.number().int().positive().default(10),
|
|
146
|
+
})
|
|
147
|
+
.default({
|
|
148
|
+
enabled: false,
|
|
149
|
+
maxDepth: 4,
|
|
150
|
+
maxPrs: 8,
|
|
151
|
+
maxFilesPerPr: 100,
|
|
152
|
+
requireSameAuthor: true,
|
|
153
|
+
confirmWithPatch: false,
|
|
154
|
+
maxConfirmations: 10,
|
|
155
|
+
}),
|
|
156
|
+
// Author replies to findings: match them to the finding they answer, record
|
|
157
|
+
// them in the comment's embedded state, and (optionally) let a model judge the
|
|
158
|
+
// rebuttal against the source. ROOT-ONLY: the comment lifecycle is global.
|
|
159
|
+
// Defaults are deliberately ASYMMETRIC: `annotate` is on but `dismiss` is off.
|
|
160
|
+
// An adopting repo has its own config.jsonc and never re-copies this template,
|
|
161
|
+
// so a key it never set must still resolve to the safe, useful default via
|
|
162
|
+
// zod — annotating is safe and useful out of the box; suppressing a finding is
|
|
163
|
+
// not, so it stays opt-in.
|
|
164
|
+
// @ref LLP 0011#asymmetric-defaults [implements] — annotate on, dismiss off; adopting repos never re-copy the template
|
|
165
|
+
feedback: z
|
|
166
|
+
.object({
|
|
167
|
+
// "off" — ignore replies entirely.
|
|
168
|
+
// "annotate" — match + record + show "author replied" (no decision effect).
|
|
169
|
+
// "adjudicate" — also run a source-grounded judgment of the rebuttal and
|
|
170
|
+
// record its verdict. Dismissal still obeys `dismiss`.
|
|
171
|
+
mode: z.enum(["off", "annotate", "adjudicate"]).default("annotate"),
|
|
172
|
+
// How a reply is MATCHED to a finding. Clearing one additionally requires the
|
|
173
|
+
// reply to cite its `id:` token in the replier's own words, whatever this says.
|
|
174
|
+
match: z.enum(["quote", "id", "both"]).default("both"),
|
|
175
|
+
// Who/what may actually remove a finding from the blocking set (always on a
|
|
176
|
+
// reply citing the finding's `id:` token — a quote only annotates):
|
|
177
|
+
// "never" — nothing does (default: adjudication ships dark).
|
|
178
|
+
// "maintainers" — a maintainer reply dismisses, no model involved.
|
|
179
|
+
// "adjudicated" — a maintainer reply, or an author reply the adjudicator
|
|
180
|
+
// confirmed against the source.
|
|
181
|
+
dismiss: z.enum(["never", "maintainers", "adjudicated"]).default("never"),
|
|
182
|
+
// Categories a reply can NEVER clear, whatever the verdict. Also hard-coded
|
|
183
|
+
// as a floor in code — this only widens the set, never narrows it.
|
|
184
|
+
protectedCategories: z.array(z.enum(CATEGORIES)).default(["secrets", "security"]),
|
|
185
|
+
// Cap on adjudication model calls per run.
|
|
186
|
+
maxAdjudications: z.number().int().positive().default(10),
|
|
187
|
+
})
|
|
188
|
+
.default({
|
|
189
|
+
mode: "annotate",
|
|
190
|
+
match: "both",
|
|
191
|
+
dismiss: "never",
|
|
192
|
+
protectedCategories: ["secrets", "security"],
|
|
193
|
+
maxAdjudications: 10,
|
|
194
|
+
}),
|
|
114
195
|
});
|
|
115
196
|
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
116
197
|
export const RoutingScopeSchema = z.object({
|
|
@@ -122,6 +203,7 @@ export const RoutingScopeSchema = z.object({
|
|
|
122
203
|
* routing.jsonc is read from the PR-head checkout, so this field is
|
|
123
204
|
* PR-controllable input: absolute paths and `..` traversal are rejected so a
|
|
124
205
|
* scope config can never resolve outside the repo. */
|
|
206
|
+
// @ref LLP 0006#routing-manifest [implements] — traversal guard; load.ts re-checks at runtime (defense in depth)
|
|
125
207
|
config: z
|
|
126
208
|
.string()
|
|
127
209
|
.min(1)
|
|
@@ -155,6 +237,7 @@ export const RoutingManifestSchema = z
|
|
|
155
237
|
* chain still fires the default when the key is absent, which would make
|
|
156
238
|
* `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
|
|
157
239
|
* manifest that omits auth and silently override the root config's real auth. */
|
|
240
|
+
// @ref LLP 0006#routing-manifest [constrained-by] — zod v4 default().optional() trap; unwrap avoids a phantom auth stub
|
|
158
241
|
auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
|
|
159
242
|
/** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
|
|
160
243
|
enforceAgents: z.array(z.string()).default([]),
|
|
@@ -202,10 +285,13 @@ export const RoutingManifestSchema = z
|
|
|
202
285
|
* standalone `ecr review --scope --post` always target the same marker — an
|
|
203
286
|
* honored per-scope tag would let the two halves strand each other's comments.
|
|
204
287
|
*/
|
|
288
|
+
// @ref LLP 0006#root-vs-scope-config [implements] — one of three enforcement layers; z.never fails at parse, not runtime
|
|
205
289
|
export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
206
290
|
auth: true,
|
|
207
291
|
breakGlass: true,
|
|
208
292
|
commentTag: true,
|
|
293
|
+
stack: true,
|
|
294
|
+
feedback: true,
|
|
209
295
|
}).extend({
|
|
210
296
|
auth: z
|
|
211
297
|
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
@@ -216,4 +302,14 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
|
216
302
|
error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
|
|
217
303
|
})
|
|
218
304
|
.optional(),
|
|
305
|
+
stack: z
|
|
306
|
+
.never({
|
|
307
|
+
error: "stack is locked to the root config (one PR has one stack); remove it from this scope config",
|
|
308
|
+
})
|
|
309
|
+
.optional(),
|
|
310
|
+
feedback: z
|
|
311
|
+
.never({
|
|
312
|
+
error: "feedback is locked to the root config (the comment lifecycle is global); remove it from this scope config",
|
|
313
|
+
})
|
|
314
|
+
.optional(),
|
|
219
315
|
});
|