@expo/code-review-cli 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- package/build/core/coordinator.js +5 -5
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +25 -25
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/config.jsonc +10 -0
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +58 -23
package/build/core/review.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import { prepareAuth } from
|
|
3
|
-
import { coordinate } from
|
|
4
|
-
import { writeRunLog } from
|
|
5
|
-
import { filterNoise, writePatchWorkspace } from
|
|
6
|
-
import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from
|
|
7
|
-
import { routeAgents } from
|
|
8
|
-
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from
|
|
9
|
-
import { fingerprintFinding, parseReviewerOutput } from
|
|
10
|
-
import { sortFindings } from
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { prepareAuth } from "./auth.js";
|
|
3
|
+
import { coordinate } from "./coordinator.js";
|
|
4
|
+
import { writeRunLog } from "./log.js";
|
|
5
|
+
import { filterNoise, writePatchWorkspace } from "./noise.js";
|
|
6
|
+
import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
7
|
+
import { routeAgents } from "./router.js";
|
|
8
|
+
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
9
|
+
import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
|
|
10
|
+
import { sortFindings } from "./render.js";
|
|
11
|
+
import { appendStepSummary } from "./step-summary.js";
|
|
12
|
+
import { errorMessage, sleep } from "./util.js";
|
|
13
|
+
import { verifyFindings } from "./verify.js";
|
|
14
|
+
import { applyInlineIgnores } from "./suppress.js";
|
|
15
|
+
/**
|
|
16
|
+
* Filter changed files down to an explicit include set (exact-path membership, not
|
|
17
|
+
* globs — scope assignment already happened in resolveScopes). With no include set,
|
|
18
|
+
* returns the input unchanged so the non-routed path is byte-identical.
|
|
19
|
+
*/
|
|
20
|
+
export function filterByIncludePaths(files, includePaths) {
|
|
21
|
+
if (!includePaths) {
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
const included = new Set(includePaths);
|
|
25
|
+
return files.filter((file) => included.has(file.path));
|
|
26
|
+
}
|
|
14
27
|
function makeRunId() {
|
|
15
|
-
return new Date().toISOString().replace(/[:.]/g,
|
|
28
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
16
29
|
}
|
|
17
30
|
/**
|
|
18
31
|
* The invariant, mode-agnostic review core: filter → spawn each configured agent
|
|
@@ -24,9 +37,9 @@ export async function runReview(source, options) {
|
|
|
24
37
|
const started = Date.now();
|
|
25
38
|
const runId = makeRunId();
|
|
26
39
|
const progress = options.onProgress ?? (() => { });
|
|
27
|
-
const runsRoot = path.join(config.configDir,
|
|
40
|
+
const runsRoot = path.join(config.configDir, ".runs");
|
|
28
41
|
const runDir = path.join(runsRoot, runId);
|
|
29
|
-
const logPath = path.join(runsRoot,
|
|
42
|
+
const logPath = path.join(runsRoot, "reviews.jsonl");
|
|
30
43
|
// Fail fast on an invalid explicit selection before doing any work. Routing
|
|
31
44
|
// (if requested) is resolved later, once the server is up.
|
|
32
45
|
const explicitAgents = options.agents?.length
|
|
@@ -36,24 +49,27 @@ export async function runReview(source, options) {
|
|
|
36
49
|
source.getMetadata(),
|
|
37
50
|
source.getChangedFiles(),
|
|
38
51
|
]);
|
|
39
|
-
|
|
52
|
+
// Scope isolation: when includePaths is set, this run only ever sees its own
|
|
53
|
+
// scope's files — no scope reviews another team's diff.
|
|
54
|
+
const scopedFiles = filterByIncludePaths(changedFiles, options.includePaths);
|
|
55
|
+
const { kept, filtered } = await filterNoise(scopedFiles, {
|
|
40
56
|
additionalIgnores: config.noise.additionalIgnores,
|
|
41
57
|
additionalMarkers: config.noise.additionalMarkers,
|
|
42
58
|
});
|
|
43
|
-
progress(`${
|
|
59
|
+
progress(`${scopedFiles.length} changed file(s); ${kept.length} to review, ${filtered.length} filtered.`);
|
|
44
60
|
const baseRecord = {
|
|
45
61
|
timestamp: new Date().toISOString(),
|
|
46
62
|
mode: options.mode,
|
|
47
63
|
runId,
|
|
48
64
|
metadata: { baseRef: metadata.baseRef, headRef: metadata.headRef },
|
|
49
|
-
reviewedFiles: kept.map(entry => entry.path),
|
|
65
|
+
reviewedFiles: kept.map((entry) => entry.path),
|
|
50
66
|
filteredFiles: filtered,
|
|
51
67
|
};
|
|
52
68
|
if (kept.length === 0) {
|
|
53
69
|
const output = {
|
|
54
|
-
decision:
|
|
70
|
+
decision: "approve",
|
|
55
71
|
findings: [],
|
|
56
|
-
summary:
|
|
72
|
+
summary: "No reviewable changes after noise filtering.",
|
|
57
73
|
incomplete: [],
|
|
58
74
|
};
|
|
59
75
|
await safeLog(logPath, {
|
|
@@ -86,10 +102,10 @@ export async function runReview(source, options) {
|
|
|
86
102
|
}
|
|
87
103
|
};
|
|
88
104
|
if (readRoot) {
|
|
89
|
-
progress(
|
|
105
|
+
progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
|
|
90
106
|
process.chdir(readRoot.dir);
|
|
91
107
|
}
|
|
92
|
-
progress(
|
|
108
|
+
progress("Starting OpenCode server…");
|
|
93
109
|
let handle = null;
|
|
94
110
|
try {
|
|
95
111
|
handle = await startOpencode(buildOpencodeConfig(config));
|
|
@@ -102,18 +118,29 @@ export async function runReview(source, options) {
|
|
|
102
118
|
}
|
|
103
119
|
const agentCosts = {};
|
|
104
120
|
const tokenTotals = {};
|
|
121
|
+
const agentTokens = {};
|
|
122
|
+
// Declared outside the try so the error-path log still carries whatever the
|
|
123
|
+
// reviewers produced before the failure — partial findings are exactly what's
|
|
124
|
+
// needed to debug a run that died mid-way.
|
|
125
|
+
const agentFindings = {};
|
|
126
|
+
// Every model request's usage lands in the run total AND its bucket, so the run
|
|
127
|
+
// log can show cache effectiveness per pass and not just run-wide.
|
|
128
|
+
const trackTokens = (bucket, tokens) => {
|
|
129
|
+
addTokenUsage(tokenTotals, tokens);
|
|
130
|
+
addTokenUsage((agentTokens[bucket] ??= {}), tokens);
|
|
131
|
+
};
|
|
105
132
|
try {
|
|
106
133
|
const workspace = await writePatchWorkspace(kept, metadata, runDir);
|
|
107
134
|
// Resolve which agents run: an explicit list wins; otherwise route (LLM picks
|
|
108
135
|
// relevant agents + always-run) when asked, else all.
|
|
109
136
|
let selectedAgents = explicitAgents ?? config.agents;
|
|
110
137
|
if (!explicitAgents && options.route) {
|
|
111
|
-
progress(
|
|
138
|
+
progress("Routing: selecting relevant agents…");
|
|
112
139
|
const routed = await routeAgents(handle, config, workspace.files);
|
|
113
140
|
selectedAgents = routed.agents;
|
|
114
141
|
progress(routed.routed
|
|
115
|
-
? `Router selected: ${selectedAgents.map(a => a.id).join(
|
|
116
|
-
:
|
|
142
|
+
? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
|
|
143
|
+
: "Router unavailable; running all agents.");
|
|
117
144
|
}
|
|
118
145
|
// Split the diff into focused chunks so each reviewer call sees a small file
|
|
119
146
|
// set (better recall than one giant blob), and run all agent×chunk calls
|
|
@@ -121,10 +148,9 @@ export async function runReview(source, options) {
|
|
|
121
148
|
const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
|
|
122
149
|
// Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
|
|
123
150
|
const chunked = chunks.length > 1;
|
|
124
|
-
progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map(a => a.id).join(
|
|
125
|
-
`${chunked ?
|
|
151
|
+
progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
|
|
152
|
+
`${chunked ? " + cross-cutting pass" : ""} ` +
|
|
126
153
|
`(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
|
|
127
|
-
const agentFindings = {};
|
|
128
154
|
for (const agent of selectedAgents) {
|
|
129
155
|
agentFindings[agent.id] = [];
|
|
130
156
|
agentCosts[agent.id] = 0;
|
|
@@ -148,19 +174,20 @@ export async function runReview(source, options) {
|
|
|
148
174
|
// job timeout. Past this, a timed-out pass is reported as a gap rather than
|
|
149
175
|
// broken down further, so total wall-clock stays bounded.
|
|
150
176
|
const PASSES_BUDGET_MS = 32 * 60 * 1000;
|
|
151
|
-
const
|
|
177
|
+
const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
|
|
178
|
+
const passesDeadline = started + passesBudgetMs;
|
|
152
179
|
const tasks = [];
|
|
153
180
|
for (const agent of selectedAgents) {
|
|
154
181
|
const system = buildReviewerSystem(config, agent);
|
|
155
182
|
chunks.forEach((chunk, index) => {
|
|
156
183
|
tasks.push({
|
|
157
184
|
bucket: agent.id,
|
|
158
|
-
kind:
|
|
185
|
+
kind: "reviewer",
|
|
159
186
|
system,
|
|
160
187
|
label: chunked ? `${agent.id} [${index + 1}/${chunks.length}]` : agent.id,
|
|
161
188
|
title: `review-${agent.id}-c${index}`,
|
|
162
189
|
files: chunk,
|
|
163
|
-
coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` :
|
|
190
|
+
coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ""}`,
|
|
164
191
|
maxWaitMs: CHUNK_TIMEOUT_MS,
|
|
165
192
|
maxToolCalls: CHUNK_MAX_TOOL_CALLS,
|
|
166
193
|
depth: 0,
|
|
@@ -173,12 +200,12 @@ export async function runReview(source, options) {
|
|
|
173
200
|
if (chunked) {
|
|
174
201
|
tasks.push({
|
|
175
202
|
bucket: CROSS_CUTTING_AGENT,
|
|
176
|
-
kind:
|
|
177
|
-
system: buildCrossCuttingSystem(config
|
|
178
|
-
label:
|
|
179
|
-
title:
|
|
203
|
+
kind: "cross-cutting",
|
|
204
|
+
system: buildCrossCuttingSystem(config),
|
|
205
|
+
label: "cross-file",
|
|
206
|
+
title: "review-xcut",
|
|
180
207
|
files: workspace.files,
|
|
181
|
-
coverageLabel:
|
|
208
|
+
coverageLabel: "the cross-file review (issues spanning multiple changed files)",
|
|
182
209
|
maxWaitMs: CROSS_CUTTING_TIMEOUT_MS,
|
|
183
210
|
maxToolCalls: CROSS_CUTTING_MAX_TOOL_CALLS,
|
|
184
211
|
depth: 0,
|
|
@@ -191,15 +218,15 @@ export async function runReview(source, options) {
|
|
|
191
218
|
// Build the task prompt on demand (so a subdivided task rebuilds over its
|
|
192
219
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
193
220
|
const buildTaskText = (task) => {
|
|
194
|
-
const base = task.kind ===
|
|
195
|
-
? buildCrossCuttingTask(task.files, filtered)
|
|
221
|
+
const base = task.kind === "cross-cutting"
|
|
222
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered)
|
|
196
223
|
: buildReviewerTask(task.files, workspace.files, filtered);
|
|
197
224
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
198
225
|
};
|
|
199
226
|
const filesLabel = (files) => files.length === 1
|
|
200
227
|
? `\`${files[0].path}\``
|
|
201
228
|
: `${files.length} files (e.g. \`${files[0].path}\`)`;
|
|
202
|
-
const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ?
|
|
229
|
+
const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? "cross-file" : bucket;
|
|
203
230
|
const childTask = (parent, files, labelSuffix, overrides) => ({
|
|
204
231
|
...parent,
|
|
205
232
|
files,
|
|
@@ -225,13 +252,13 @@ export async function runReview(source, options) {
|
|
|
225
252
|
system: task.system,
|
|
226
253
|
text: buildTaskText(task),
|
|
227
254
|
title: task.title,
|
|
228
|
-
onActivity: line => progress(` ${task.label}: ${line}`),
|
|
255
|
+
onActivity: (line) => progress(` ${task.label}: ${line}`),
|
|
229
256
|
maxWaitMs: task.maxWaitMs,
|
|
230
257
|
maxToolCalls: task.maxToolCalls,
|
|
231
258
|
finalizeOnTimeout: true,
|
|
232
259
|
}, parseReviewerOutput);
|
|
233
260
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
|
|
234
|
-
|
|
261
|
+
trackTokens(task.bucket, tokens);
|
|
235
262
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
236
263
|
completedPasses++;
|
|
237
264
|
if (truncated) {
|
|
@@ -245,18 +272,25 @@ export async function runReview(source, options) {
|
|
|
245
272
|
if (!(error instanceof AgentTimeoutError)) {
|
|
246
273
|
failedPasses++;
|
|
247
274
|
progress(` ${task.label}: FAILED (${errorMessage(error)})`);
|
|
248
|
-
|
|
275
|
+
// An auth/permission failure hits every pass identically; push one shared,
|
|
276
|
+
// actionable note (deduped into a single coverage line) instead of N generic
|
|
277
|
+
// per-pass failures that bury the real, fixable cause.
|
|
278
|
+
incomplete.push(isAuthError(error)
|
|
279
|
+
? AUTH_FAILURE_NOTE
|
|
280
|
+
: `${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
|
|
249
281
|
return;
|
|
250
282
|
}
|
|
251
283
|
// Account for the abandoned investigation's spend regardless of what's next.
|
|
252
284
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
|
|
253
|
-
|
|
285
|
+
trackTokens(task.bucket, error.tokens);
|
|
254
286
|
const remaining = passesDeadline - Date.now();
|
|
255
287
|
// Cross-file analysis needs ≥2 files to be meaningful; a single-file
|
|
256
288
|
// reviewer chunk can't be split further.
|
|
257
|
-
const minFiles = task.kind ===
|
|
289
|
+
const minFiles = task.kind === "cross-cutting" ? 2 : 1;
|
|
258
290
|
const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
|
|
259
|
-
if (task.files.length > minFiles &&
|
|
291
|
+
if (task.files.length > minFiles &&
|
|
292
|
+
task.depth < MAX_SUBDIVIDE_DEPTH &&
|
|
293
|
+
remaining > childCap) {
|
|
260
294
|
const mid = Math.ceil(task.files.length / 2);
|
|
261
295
|
const left = task.files.slice(0, mid);
|
|
262
296
|
const right = task.files.slice(mid);
|
|
@@ -268,9 +302,9 @@ export async function runReview(source, options) {
|
|
|
268
302
|
}
|
|
269
303
|
// Can't subdivide further: try a fast no-tools pass over the inlined diff
|
|
270
304
|
// (reviewer only — cross-file analysis fundamentally needs to read files).
|
|
271
|
-
if (task.kind ===
|
|
305
|
+
if (task.kind === "reviewer" && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
|
|
272
306
|
progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
|
|
273
|
-
enqueue(childTask(task, task.files,
|
|
307
|
+
enqueue(childTask(task, task.files, "(no-tools fallback)", {
|
|
274
308
|
fallback: true,
|
|
275
309
|
maxWaitMs: FALLBACK_TIMEOUT_MS,
|
|
276
310
|
maxToolCalls: 0,
|
|
@@ -283,7 +317,7 @@ export async function runReview(source, options) {
|
|
|
283
317
|
// vs. the task was already at its smallest reviewable unit and still failed.
|
|
284
318
|
failedPasses++;
|
|
285
319
|
const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
|
|
286
|
-
(task.kind ===
|
|
320
|
+
(task.kind === "reviewer" && !task.fallback);
|
|
287
321
|
if (couldStillReduce) {
|
|
288
322
|
progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
|
|
289
323
|
incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
|
|
@@ -301,27 +335,27 @@ export async function runReview(source, options) {
|
|
|
301
335
|
let output;
|
|
302
336
|
if (completedPasses === 0) {
|
|
303
337
|
// Nothing succeeded — do NOT let this render as a clean "approve".
|
|
304
|
-
progress(
|
|
338
|
+
progress("All review passes failed — reporting an incomplete review.");
|
|
305
339
|
output = {
|
|
306
|
-
decision:
|
|
340
|
+
decision: "approve_with_comments",
|
|
307
341
|
findings: [],
|
|
308
|
-
summary:
|
|
342
|
+
summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
|
|
309
343
|
'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
|
|
310
344
|
incomplete: coverageNotes,
|
|
311
345
|
};
|
|
312
346
|
}
|
|
313
347
|
else {
|
|
314
|
-
progress(
|
|
348
|
+
progress("Coordinating findings…");
|
|
315
349
|
let consolidated;
|
|
316
350
|
try {
|
|
317
351
|
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
|
|
318
|
-
agentCosts[
|
|
319
|
-
|
|
352
|
+
agentCosts["coordinator"] = cost;
|
|
353
|
+
trackTokens("coordinator", coordinatorTokens);
|
|
320
354
|
consolidated = applyReviewPolicy(rawOutput, config.policy);
|
|
321
355
|
if (coordinatorTruncated) {
|
|
322
356
|
// The coordinator ran out of time and returned partial findings — flag it
|
|
323
357
|
// like any other truncated pass so reduced coverage is never silent.
|
|
324
|
-
coverageNotes.push(
|
|
358
|
+
coverageNotes.push("The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.");
|
|
325
359
|
}
|
|
326
360
|
}
|
|
327
361
|
catch (error) {
|
|
@@ -330,11 +364,11 @@ export async function runReview(source, options) {
|
|
|
330
364
|
// merge so a comment is still posted.
|
|
331
365
|
progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
|
|
332
366
|
consolidated = fallbackConsolidation(agentFindings, config.policy);
|
|
333
|
-
coverageNotes.push(
|
|
367
|
+
coverageNotes.push("The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.");
|
|
334
368
|
}
|
|
335
369
|
// A run with any failed/timed-out pass must never present as a clean approve.
|
|
336
|
-
const decision = failedPasses > 0 && consolidated.decision ===
|
|
337
|
-
?
|
|
370
|
+
const decision = failedPasses > 0 && consolidated.decision === "approve"
|
|
371
|
+
? "approve_with_comments"
|
|
338
372
|
: consolidated.decision;
|
|
339
373
|
output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
|
|
340
374
|
}
|
|
@@ -342,11 +376,13 @@ export async function runReview(source, options) {
|
|
|
342
376
|
// finding against the real file, and adversarially verify criticals. This is
|
|
343
377
|
// what stops a confident but wrong critical from shipping.
|
|
344
378
|
const findingCountBeforeChecks = output.findings.length;
|
|
379
|
+
let verifierDropped = [];
|
|
345
380
|
if (output.findings.length > 0) {
|
|
346
|
-
progress(
|
|
381
|
+
progress("Verifying findings…");
|
|
347
382
|
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
348
|
-
agentCosts[
|
|
349
|
-
|
|
383
|
+
agentCosts["verifier"] = verification.cost;
|
|
384
|
+
trackTokens("verifier", verification.tokens);
|
|
385
|
+
verifierDropped = verification.dropped;
|
|
350
386
|
if (verification.dropped.length > 0) {
|
|
351
387
|
progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
|
|
352
388
|
output = {
|
|
@@ -375,11 +411,17 @@ export async function runReview(source, options) {
|
|
|
375
411
|
if (removedAfterChecks > 0) {
|
|
376
412
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
377
413
|
}
|
|
414
|
+
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
415
|
+
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts)));
|
|
378
416
|
await safeLog(logPath, {
|
|
379
417
|
...baseRecord,
|
|
380
418
|
agentCosts,
|
|
381
419
|
totalCost: sum(agentCosts),
|
|
382
420
|
tokens: tokenTotals,
|
|
421
|
+
agentTokens,
|
|
422
|
+
agentFindings,
|
|
423
|
+
coverageNotes,
|
|
424
|
+
verifierDropped,
|
|
383
425
|
durationMs: Date.now() - started,
|
|
384
426
|
decision: output.decision,
|
|
385
427
|
findingCount: output.findings.length,
|
|
@@ -393,6 +435,8 @@ export async function runReview(source, options) {
|
|
|
393
435
|
agentCosts,
|
|
394
436
|
totalCost: sum(agentCosts),
|
|
395
437
|
tokens: tokenTotals,
|
|
438
|
+
agentTokens,
|
|
439
|
+
agentFindings,
|
|
396
440
|
durationMs: Date.now() - started,
|
|
397
441
|
decision: null,
|
|
398
442
|
findingCount: 0,
|
|
@@ -414,13 +458,13 @@ export async function runReview(source, options) {
|
|
|
414
458
|
export function applyReviewPolicy(output, policy) {
|
|
415
459
|
let findings = policy.includeSuggestions
|
|
416
460
|
? output.findings
|
|
417
|
-
: output.findings.filter(finding => finding.severity !==
|
|
461
|
+
: output.findings.filter((finding) => finding.severity !== "suggestion");
|
|
418
462
|
findings = sortFindings(findings);
|
|
419
463
|
if (policy.maxFindings != null) {
|
|
420
464
|
findings = findings.slice(0, policy.maxFindings);
|
|
421
465
|
}
|
|
422
|
-
const decision = output.decision ===
|
|
423
|
-
?
|
|
466
|
+
const decision = output.decision === "approve_with_comments" && findings.length === 0
|
|
467
|
+
? "approve"
|
|
424
468
|
: output.decision;
|
|
425
469
|
return { ...output, findings, decision };
|
|
426
470
|
}
|
|
@@ -442,16 +486,16 @@ function fallbackConsolidation(agentFindings, policy) {
|
|
|
442
486
|
}
|
|
443
487
|
}
|
|
444
488
|
}
|
|
445
|
-
const decision = merged.some(finding => finding.severity ===
|
|
446
|
-
?
|
|
489
|
+
const decision = merged.some((finding) => finding.severity === "critical")
|
|
490
|
+
? "request_changes"
|
|
447
491
|
: merged.length > 0
|
|
448
|
-
?
|
|
449
|
-
:
|
|
492
|
+
? "approve_with_comments"
|
|
493
|
+
: "approve";
|
|
450
494
|
return applyReviewPolicy({
|
|
451
495
|
decision,
|
|
452
496
|
findings: merged,
|
|
453
|
-
summary:
|
|
454
|
-
|
|
497
|
+
summary: "Consolidation step failed; showing the specialist reviewers’ findings " +
|
|
498
|
+
"merged and de-duplicated, but not re-judged.",
|
|
455
499
|
incomplete: [],
|
|
456
500
|
}, policy);
|
|
457
501
|
}
|
|
@@ -462,10 +506,10 @@ function fallbackConsolidation(agentFindings, policy) {
|
|
|
462
506
|
*/
|
|
463
507
|
export function decisionAfterVerification(previous, kept) {
|
|
464
508
|
if (kept.length === 0) {
|
|
465
|
-
return
|
|
509
|
+
return "approve";
|
|
466
510
|
}
|
|
467
|
-
if (previous ===
|
|
468
|
-
return
|
|
511
|
+
if (previous === "request_changes" && !kept.some((finding) => finding.severity === "critical")) {
|
|
512
|
+
return "approve_with_comments";
|
|
469
513
|
}
|
|
470
514
|
return previous;
|
|
471
515
|
}
|
|
@@ -478,26 +522,49 @@ export function decisionAfterVerification(previous, kept) {
|
|
|
478
522
|
*/
|
|
479
523
|
export function reconcileSummary(summary, remaining) {
|
|
480
524
|
if (remaining === 0) {
|
|
481
|
-
return
|
|
525
|
+
return "All candidate findings were removed by automated verification and suppression, so no issues remain to report.";
|
|
482
526
|
}
|
|
483
|
-
return (
|
|
484
|
-
|
|
527
|
+
return ("_Note: some findings were removed by automated verification/suppression after " +
|
|
528
|
+
"this summary was written, so it may mention issues no longer listed below._\n\n" +
|
|
485
529
|
summary);
|
|
486
530
|
}
|
|
487
531
|
/** Capitalize the first letter (coverage notes read as sentences). */
|
|
488
532
|
function capitalize(text) {
|
|
489
533
|
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
|
|
490
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* An authentication/authorization failure from the model provider (401/403, a
|
|
537
|
+
* rejected/expired/missing credential) — distinct from a transient blip or a real
|
|
538
|
+
* code finding. Every pass hits the same wall, so the caller collapses it into one
|
|
539
|
+
* actionable coverage note instead of N generic "failed to run" lines.
|
|
540
|
+
*/
|
|
541
|
+
export function isAuthError(error) {
|
|
542
|
+
const message = errorMessage(error).toLowerCase();
|
|
543
|
+
const cred = /(api.?key|token|credential)/;
|
|
544
|
+
const problem = /(invalid|expired|revoked|missing|rejected|no)/;
|
|
545
|
+
return (/\b401\b|\b403\b/.test(message) ||
|
|
546
|
+
/unauthor/.test(message) ||
|
|
547
|
+
/\bforbidden\b/.test(message) ||
|
|
548
|
+
/authentication/.test(message) ||
|
|
549
|
+
/permission denied/.test(message) ||
|
|
550
|
+
/invalid x-api-key/.test(message) ||
|
|
551
|
+
// a credential noun and a problem word near each other, in either order
|
|
552
|
+
new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
|
|
553
|
+
new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
|
|
554
|
+
}
|
|
555
|
+
const AUTH_FAILURE_NOTE = "The model provider rejected the request (authentication or permission). Check the " +
|
|
556
|
+
"configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — " +
|
|
557
|
+
"those changes were not reviewed.";
|
|
491
558
|
function selectAgents(all, filter) {
|
|
492
559
|
if (!filter?.length) {
|
|
493
560
|
return all;
|
|
494
561
|
}
|
|
495
|
-
const known = new Set(all.map(agent => agent.id));
|
|
496
|
-
const unknown = filter.filter(id => !known.has(id));
|
|
562
|
+
const known = new Set(all.map((agent) => agent.id));
|
|
563
|
+
const unknown = filter.filter((id) => !known.has(id));
|
|
497
564
|
if (unknown.length > 0) {
|
|
498
|
-
throw new Error(`Unknown agent(s): ${unknown.join(
|
|
565
|
+
throw new Error(`Unknown agent(s): ${unknown.join(", ")}. Available: ${all.map((a) => a.id).join(", ")}`);
|
|
499
566
|
}
|
|
500
|
-
return all.filter(agent => filter.includes(agent.id));
|
|
567
|
+
return all.filter((agent) => filter.includes(agent.id));
|
|
501
568
|
}
|
|
502
569
|
/**
|
|
503
570
|
* Greedily pack files into chunks bounded by total changed lines (primary) and
|
|
@@ -563,6 +630,45 @@ export async function runGrowableQueue(initial, limit, fn) {
|
|
|
563
630
|
function sum(costs) {
|
|
564
631
|
return Object.values(costs).reduce((total, value) => total + value, 0);
|
|
565
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* One-line usage summary for the run. Emitted via progress so it lands in the CI
|
|
635
|
+
* job log (and the local terminal) — `.runs/reviews.jsonl` is ephemeral in CI, so
|
|
636
|
+
* this is the only place the token/cache totals are visible after a CI run, which
|
|
637
|
+
* is how prompt-cache effectiveness gets confirmed there.
|
|
638
|
+
*/
|
|
639
|
+
export function formatUsageSummary(tokens, totalCost) {
|
|
640
|
+
const parts = [`input ${tokens.input ?? 0}`, `output ${tokens.output ?? 0}`];
|
|
641
|
+
if (tokens.reasoning) {
|
|
642
|
+
parts.push(`reasoning ${tokens.reasoning}`);
|
|
643
|
+
}
|
|
644
|
+
parts.push(`cache read ${tokens.cache?.read ?? 0}`, `cache write ${tokens.cache?.write ?? 0}`);
|
|
645
|
+
const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : "";
|
|
646
|
+
return `Token usage — ${parts.join(", ")}${cost}`;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Markdown-table version of the usage summary for the Actions step summary: one
|
|
650
|
+
* row per pass plus a total, and the prompt-cache hit rate (the share of prompt
|
|
651
|
+
* tokens served from cache instead of being reprocessed at full price).
|
|
652
|
+
*/
|
|
653
|
+
export function renderUsageMarkdown(agentTokens, agentCosts, totals, totalCost) {
|
|
654
|
+
const row = (label, tokens, cost) => `| ${label} | ${tokens.input ?? 0} | ${tokens.output ?? 0} | ${tokens.cache?.read ?? 0} | ${tokens.cache?.write ?? 0} | $${cost.toFixed(4)} |`;
|
|
655
|
+
const lines = [
|
|
656
|
+
"### 🤖 AI review — token usage",
|
|
657
|
+
"",
|
|
658
|
+
"| pass | input | output | cache read | cache write | cost |",
|
|
659
|
+
"| --- | ---: | ---: | ---: | ---: | ---: |",
|
|
660
|
+
...Object.keys(agentCosts).map((bucket) => row(bucket, agentTokens[bucket] ?? {}, agentCosts[bucket] ?? 0)),
|
|
661
|
+
row("**total**", totals, totalCost),
|
|
662
|
+
];
|
|
663
|
+
const read = totals.cache?.read ?? 0;
|
|
664
|
+
const uncached = totals.input ?? 0;
|
|
665
|
+
if (read + uncached > 0) {
|
|
666
|
+
const rate = Math.round((read / (read + uncached)) * 100);
|
|
667
|
+
lines.push("", `Prompt cache hit rate: **${rate}%** (cache read / (cache read + input)). ` +
|
|
668
|
+
'See "Tokens, cost & prompt caching" in the README for how to read these numbers.');
|
|
669
|
+
}
|
|
670
|
+
return lines.join("\n");
|
|
671
|
+
}
|
|
566
672
|
async function safeLog(logPath, record) {
|
|
567
673
|
try {
|
|
568
674
|
await writeRunLog(logPath, record);
|
package/build/core/router.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { promptAndParse } from
|
|
2
|
-
import { buildRouterSystem, buildRouterTask } from
|
|
3
|
-
import { parseRouteOutput } from
|
|
1
|
+
import { promptAndParse } from "./opencode.js";
|
|
2
|
+
import { buildRouterSystem, buildRouterTask } from "./prompts.js";
|
|
3
|
+
import { parseRouteOutput } from "./schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* Ask the model which agents are relevant to the changed files. Agents marked
|
|
6
6
|
* `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
|
|
7
7
|
* returns nothing usable or errors — a review must never run with zero agents.
|
|
8
8
|
*/
|
|
9
9
|
export async function routeAgents(handle, config, files) {
|
|
10
|
-
const always = config.agents.filter(agent => agent.alwaysRun);
|
|
10
|
+
const always = config.agents.filter((agent) => agent.alwaysRun);
|
|
11
11
|
try {
|
|
12
12
|
const { value } = await promptAndParse(handle, {
|
|
13
|
-
agent:
|
|
13
|
+
agent: "coordinator",
|
|
14
14
|
system: buildRouterSystem(),
|
|
15
15
|
text: buildRouterTask(config.agents, files),
|
|
16
|
-
title:
|
|
16
|
+
title: "route",
|
|
17
17
|
}, parseRouteOutput);
|
|
18
|
-
const byId = new Map(config.agents.map(agent => [agent.id, agent]));
|
|
18
|
+
const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
|
|
19
19
|
const picked = value.agents
|
|
20
|
-
.map(id => byId.get(id))
|
|
20
|
+
.map((id) => byId.get(id))
|
|
21
21
|
.filter((agent) => Boolean(agent));
|
|
22
|
-
const chosenIds = new Set([...picked, ...always].map(agent => agent.id));
|
|
22
|
+
const chosenIds = new Set([...picked, ...always].map((agent) => agent.id));
|
|
23
23
|
// Preserve config order and dedupe.
|
|
24
|
-
const chosen = config.agents.filter(agent => chosenIds.has(agent.id));
|
|
24
|
+
const chosen = config.agents.filter((agent) => chosenIds.has(agent.id));
|
|
25
25
|
if (chosen.length === 0) {
|
|
26
26
|
return { agents: config.agents, routed: false };
|
|
27
27
|
}
|
package/build/core/schema.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { z } from
|
|
3
|
-
import { normalizeCode } from
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { normalizeCode } from "./util.js";
|
|
4
4
|
/** Severity levels, ordered most→least severe for sorting/rendering. */
|
|
5
|
-
export const SEVERITIES = [
|
|
5
|
+
export const SEVERITIES = ["critical", "warning", "suggestion"];
|
|
6
6
|
/** Sort rank for severities (0 = most severe). Single source of truth. */
|
|
7
7
|
export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
|
|
8
|
-
export const CATEGORIES = [
|
|
9
|
-
export const DECISIONS = [
|
|
8
|
+
export const CATEGORIES = ["correctness", "quality", "security", "secrets"];
|
|
9
|
+
export const DECISIONS = ["approve", "approve_with_comments", "request_changes"];
|
|
10
10
|
export const FindingSchema = z.object({
|
|
11
11
|
severity: z.enum(SEVERITIES),
|
|
12
12
|
category: z.enum(CATEGORIES),
|
|
@@ -25,7 +25,7 @@ export const FindingSchema = z.object({
|
|
|
25
25
|
/** A verifier's verdict on whether a finding is real (adversarial refute pass). */
|
|
26
26
|
export const VerdictSchema = z.object({
|
|
27
27
|
verified: z.boolean(),
|
|
28
|
-
reason: z.string().default(
|
|
28
|
+
reason: z.string().default(""),
|
|
29
29
|
});
|
|
30
30
|
export function parseVerdict(text) {
|
|
31
31
|
return VerdictSchema.parse(extractJsonObject(text));
|
|
@@ -60,10 +60,24 @@ const MIN_FP_EVIDENCE_LEN = 12;
|
|
|
60
60
|
* there's too little evidence to key on.
|
|
61
61
|
*/
|
|
62
62
|
export function fingerprintFinding(finding) {
|
|
63
|
-
const evidence = normalizeCode(finding.evidence ??
|
|
63
|
+
const evidence = normalizeCode(finding.evidence ?? "");
|
|
64
64
|
const key = evidence.length >= MIN_FP_EVIDENCE_LEN ? evidence : normalizeCode(finding.title);
|
|
65
|
-
const normalized = [
|
|
66
|
-
return createHash(
|
|
65
|
+
const normalized = ["v2", finding.file, finding.category, key].join("|");
|
|
66
|
+
return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Namespace a finding's fingerprint by scope so cross-scope dismissals never
|
|
70
|
+
* collide. The DEFAULT scope (config '.') passes `null` and keeps the plain
|
|
71
|
+
* fingerprintFinding value, so pre-routing dismissal state carries over unchanged
|
|
72
|
+
* (risk 9). Non-default scopes hash into the same hex alphabet the dismiss command
|
|
73
|
+
* sanitizes to (dismiss.ts strips /[^a-f0-9]/), at the same length.
|
|
74
|
+
*/
|
|
75
|
+
export function scopedFingerprint(scopeName, finding) {
|
|
76
|
+
const fp = fingerprintFinding(finding);
|
|
77
|
+
if (!scopeName) {
|
|
78
|
+
return fp;
|
|
79
|
+
}
|
|
80
|
+
return createHash("sha1").update(`scope|${scopeName}|${fp}`).digest("hex").slice(0, fp.length);
|
|
67
81
|
}
|
|
68
82
|
/**
|
|
69
83
|
* Extract the JSON payload from an LLM response. Prefers the last fenced
|
|
@@ -76,8 +90,8 @@ export function extractJsonObject(text) {
|
|
|
76
90
|
if (fenceMatches.length > 0) {
|
|
77
91
|
candidates.push(fenceMatches[fenceMatches.length - 1][1].trim());
|
|
78
92
|
}
|
|
79
|
-
const firstBrace = text.indexOf(
|
|
80
|
-
const lastBrace = text.lastIndexOf(
|
|
93
|
+
const firstBrace = text.indexOf("{");
|
|
94
|
+
const lastBrace = text.lastIndexOf("}");
|
|
81
95
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
82
96
|
candidates.push(text.slice(firstBrace, lastBrace + 1));
|
|
83
97
|
}
|