@expo/code-review-cli 0.3.0 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -1,5 +1,5 @@
1
- import { createHash } from 'node:crypto';
2
- import { fingerprintFinding, SEVERITIES, SEVERITY_RANK } from './schema.js';
1
+ import { createHash } from "node:crypto";
2
+ import { fingerprintFinding, scopedFingerprint, SEVERITIES, SEVERITY_RANK } from "./schema.js";
3
3
  /**
4
4
  * Build the file β†’ right-side-line-numbers index from changed files' patch text,
5
5
  * by walking each unified-diff hunk (`@@ -a,b +c,d @@`) and collecting the new-tree
@@ -13,18 +13,18 @@ export function buildDiffLineIndex(files) {
13
13
  const lines = new Set();
14
14
  let right = 0;
15
15
  let inHunk = false;
16
- for (const raw of file.patch.split('\n')) {
16
+ for (const raw of file.patch.split("\n")) {
17
17
  const hunk = hunkRe.exec(raw);
18
18
  if (hunk) {
19
19
  right = parseInt(hunk[1], 10);
20
20
  inHunk = true;
21
21
  continue;
22
22
  }
23
- if (!inHunk || raw.startsWith('+++') || raw.startsWith('---') || raw.startsWith('\\')) {
23
+ if (!inHunk || raw.startsWith("+++") || raw.startsWith("---") || raw.startsWith("\\")) {
24
24
  continue;
25
25
  }
26
26
  const marker = raw[0];
27
- if (marker === '+' || marker === ' ') {
27
+ if (marker === "+" || marker === " ") {
28
28
  lines.add(right);
29
29
  right++;
30
30
  }
@@ -37,16 +37,16 @@ export function buildDiffLineIndex(files) {
37
37
  return index;
38
38
  }
39
39
  const DECISION_LABEL = {
40
- approve: 'Approve',
41
- approve_with_comments: 'Approve with comments',
42
- request_changes: 'Request changes',
40
+ approve: "Approve",
41
+ approve_with_comments: "Approve with comments",
42
+ request_changes: "Request changes",
43
43
  };
44
44
  export function decisionLabel(decision) {
45
45
  return DECISION_LABEL[decision];
46
46
  }
47
47
  /** Rubric exit code: 0 for approve / approve-with-comments, 1 for request-changes. */
48
48
  export function decisionExitCode(decision) {
49
- return decision === 'request_changes' ? 1 : 0;
49
+ return decision === "request_changes" ? 1 : 0;
50
50
  }
51
51
  export function sortFindings(findings) {
52
52
  return [...findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
@@ -85,13 +85,13 @@ function location(finding, link) {
85
85
  // in-diff as long as the file appears in the diff.
86
86
  const inDiff = fileLines != null && (finding.line == null || fileLines.has(finding.line));
87
87
  if (inDiff) {
88
- const fileHash = createHash('sha256').update(finding.file).digest('hex');
88
+ const fileHash = createHash("sha256").update(finding.file).digest("hex");
89
89
  const anchor = finding.line != null ? `diff-${fileHash}R${finding.line}` : `diff-${fileHash}`;
90
90
  const url = `https://github.com/${link.repo}/pull/${link.prNumber}/files#${anchor}`;
91
91
  return `[\`${text}\`](${url})`;
92
92
  }
93
93
  if (link.baseSha) {
94
- const lineAnchor = finding.line != null ? `#L${finding.line}` : '';
94
+ const lineAnchor = finding.line != null ? `#L${finding.line}` : "";
95
95
  const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${finding.file}${lineAnchor}`;
96
96
  return `[\`${text}\`](${url})`;
97
97
  }
@@ -103,53 +103,63 @@ function location(finding, link) {
103
103
  * collapsed "Dismissed" section instead of the main list.
104
104
  */
105
105
  export function renderMarkdown(review, tag, dismissed = [], link) {
106
- const dismissedByFp = new Map(dismissed.map(record => [record.fp, record]));
107
- const withFp = review.findings.map(finding => ({ finding, fp: fingerprintFinding(finding) }));
106
+ const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
107
+ const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
108
108
  const kept = withFp.filter(({ fp }) => !dismissedByFp.has(fp));
109
109
  const dropped = withFp.filter(({ fp }) => dismissedByFp.has(fp));
110
- const lines = [commentMarker(tag), '## πŸ€– AI code review', ''];
111
- lines.push(`**Decision:** ${decisionLabel(review.decision)}`, '', review.summary, '');
110
+ const lines = [commentMarker(tag), "## πŸ€– AI code review", ""];
111
+ lines.push(`**Decision:** ${decisionLabel(review.decision)}`, "", review.summary, "");
112
112
  if (review.incomplete.length > 0) {
113
- lines.push('> ⏱️ **Coverage note:** coverage is partial β€” some review passes did not', '> finish (timed out or failed), so issues may exist in areas not fully reviewed:', ...review.incomplete.map(note => `> - ${note}`), '');
113
+ lines.push("> ⏱️ **Coverage note:** coverage is partial β€” some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${note}`), "");
114
114
  }
115
115
  if (kept.length === 0) {
116
- lines.push('No findings.', '');
116
+ lines.push("No findings.", "");
117
117
  }
118
118
  else {
119
- const groups = groupBySeverity(sortFindings(kept.map(entry => entry.finding)));
120
- for (const severity of SEVERITIES) {
121
- const group = groups[severity];
122
- if (group.length === 0) {
123
- continue;
124
- }
125
- lines.push(`### ${severityHeading(severity)} (${group.length})`, '');
126
- for (const finding of group) {
127
- lines.push(...renderFindingLines(finding, link));
128
- }
129
- lines.push('');
130
- }
119
+ lines.push(...renderSeveritySections(kept.map((entry) => entry.finding), link));
131
120
  }
132
121
  if (dropped.length > 0) {
133
- lines.push('<details>', `<summary>🚫 Dismissed on this PR (${dropped.length})</summary>`, '');
122
+ lines.push("<details>", `<summary>🚫 Dismissed on this PR (${dropped.length})</summary>`, "");
134
123
  for (const { finding, fp } of dropped) {
135
124
  const record = dismissedByFp.get(fp);
136
- const who = record.by ? ` by @${record.by}` : '';
137
- const why = record.reason ? ` β€” ${record.reason}` : '';
125
+ const who = record.by ? ` by @${record.by}` : "";
126
+ const why = record.reason ? ` β€” ${record.reason}` : "";
138
127
  lines.push(`- **${finding.title}** β€” ${location(finding, link)} \`id:${fp}\`${who}${why}`);
139
128
  }
140
- lines.push('', '_Re-add one with `/undismiss <id>`._', '</details>', '');
129
+ lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
141
130
  }
142
- lines.push('---', '_This review is advisory β€” it never blocks a merge and never auto-approves._');
131
+ lines.push("---", "_This review is advisory β€” it never blocks a merge and never auto-approves._");
143
132
  // Embedded, machine-readable state: fingerprints (back-compat) + the full review
144
133
  // and dismissals, so `/dismiss` can re-render this comment without re-running.
145
134
  const fingerprints = review.findings.map(fingerprintFinding);
146
- lines.push('', `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
135
+ lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
147
136
  lines.push(`<!-- ${tag}:state=${encodeState({ review, dismissed })} -->`);
148
- return lines.join('\n');
137
+ return lines.join("\n");
138
+ }
139
+ /**
140
+ * Render sorted, severity-grouped findings. Shared by the single-comment and
141
+ * aggregate renderers. `idFor` supplies each finding's id (default:
142
+ * fingerprintFinding); the aggregate renderer passes a scope-namespaced id.
143
+ */
144
+ function renderSeveritySections(findings, link, idFor = fingerprintFinding) {
145
+ const out = [];
146
+ const groups = groupBySeverity(sortFindings(findings));
147
+ for (const severity of SEVERITIES) {
148
+ const group = groups[severity];
149
+ if (group.length === 0) {
150
+ continue;
151
+ }
152
+ out.push(`### ${severityHeading(severity)} (${group.length})`, "");
153
+ for (const finding of group) {
154
+ out.push(...renderFindingLines(finding, link, idFor(finding)));
155
+ }
156
+ out.push("");
157
+ }
158
+ return out;
149
159
  }
150
- function renderFindingLines(finding, link) {
160
+ function renderFindingLines(finding, link, id = fingerprintFinding(finding)) {
151
161
  const out = [
152
- `- **${finding.title}** β€” ${location(finding, link)} _(${finding.category})_ Β· \`id:${fingerprintFinding(finding)}\``,
162
+ `- **${finding.title}** β€” ${location(finding, link)} _(${finding.category})_ Β· \`id:${id}\``,
153
163
  ` ${finding.rationale}`,
154
164
  ];
155
165
  if (finding.suggestion) {
@@ -160,7 +170,7 @@ function renderFindingLines(finding, link) {
160
170
  /** Parse the fingerprints embedded in a previously-posted comment body. */
161
171
  export function parseEmbeddedFingerprints(body, tag) {
162
172
  // Escape the (config-controlled) tag so regex metacharacters can't break the match.
163
- const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
164
174
  const match = body.match(new RegExp(`<!-- ${escapedTag}:fingerprints=(\\[.*?\\]) -->`));
165
175
  if (!match) {
166
176
  return [];
@@ -173,18 +183,162 @@ export function parseEmbeddedFingerprints(body, tag) {
173
183
  return [];
174
184
  }
175
185
  }
186
+ const DECISION_RANK = {
187
+ approve: 0,
188
+ approve_with_comments: 1,
189
+ request_changes: 2,
190
+ };
191
+ /** Worst (most severe) decision across scopes. */
192
+ export function worstDecision(decisions) {
193
+ return decisions.reduce((worst, decision) => (DECISION_RANK[decision] > DECISION_RANK[worst] ? decision : worst), "approve");
194
+ }
195
+ /** GitHub's comment body limit is ~65k chars; keep a margin. */
196
+ const MAX_COMMENT_CHARS = 60_000;
197
+ /**
198
+ * One aggregated comment under the single existing marker: a scope summary table,
199
+ * an optional coverage block, one <details> per scope (findings rendered with
200
+ * scope-namespaced ids), and the dismissed section. The embedded v1 `review` field
201
+ * is a synthesized merge so v1 state consumers still see a valid shape; `scopes`
202
+ * carries the real per-scope data. Oversized bodies trim each scope's findings to
203
+ * the most severe N (halving until it fits, floor 3) with a per-scope note.
204
+ */
205
+ export function renderAggregateMarkdown(results, tag, dismissed, link, opts) {
206
+ const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
207
+ const idOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
208
+ // Split each scope's findings into kept/dropped once (dismissal is limit-independent).
209
+ const perScope = results.map((result) => {
210
+ const withId = result.review.findings.map((finding) => ({
211
+ finding,
212
+ id: idOf(result, finding),
213
+ }));
214
+ return {
215
+ result,
216
+ kept: withId.filter((entry) => !dismissedByFp.has(entry.id)),
217
+ dropped: withId.filter((entry) => dismissedByFp.has(entry.id)),
218
+ };
219
+ });
220
+ const worst = worstDecision(results.map((result) => result.review.decision));
221
+ const unmatched = opts?.unmatchedFiles ?? [];
222
+ const buildBody = (limitPerScope) => {
223
+ const lines = [
224
+ commentMarker(tag),
225
+ "## πŸ€– AI code review",
226
+ "",
227
+ `**Decision:** ${decisionLabel(worst)}`,
228
+ "",
229
+ "| Scope | Decision | Findings |",
230
+ "| --- | --- | --- |",
231
+ ];
232
+ for (const { result, kept } of perScope) {
233
+ lines.push(`| ${result.scope} | ${decisionLabel(result.review.decision)} | ${kept.length} |`);
234
+ }
235
+ lines.push("");
236
+ const anyIncomplete = results.some((result) => result.review.incomplete.length > 0);
237
+ if (unmatched.length > 0 || anyIncomplete) {
238
+ lines.push("> ⏱️ **Coverage note:** parts of this PR may not be fully reviewed:");
239
+ if (unmatched.length > 0) {
240
+ const shown = unmatched
241
+ .slice(0, 10)
242
+ .map((file) => `\`${file}\``)
243
+ .join(", ");
244
+ const more = unmatched.length > 10 ? `, …(+${unmatched.length - 10} more)` : "";
245
+ lines.push(`> - ${unmatched.length} changed file(s) matched no scope: ${shown}${more}`);
246
+ }
247
+ for (const result of results) {
248
+ for (const note of result.review.incomplete) {
249
+ lines.push(`> - [${result.scope}] ${note}`);
250
+ }
251
+ }
252
+ lines.push("");
253
+ }
254
+ // Shown = the most-severe N kept findings per scope (N = limitPerScope). The
255
+ // embedded state trims KEPT findings to the same set so a truncated comment
256
+ // still fits GitHub's body limit (the hidden findings are noted, not silently
257
+ // carried) β€” but dismissed findings are always kept in state (see below).
258
+ const rendered = perScope.map(({ result, kept, dropped }) => ({
259
+ result,
260
+ shown: sortFindings(kept.map((entry) => entry.finding)).slice(0, limitPerScope),
261
+ hidden: Math.max(0, kept.length - limitPerScope),
262
+ dropped,
263
+ }));
264
+ for (const { result, shown, hidden } of rendered) {
265
+ const open = shown.length > 0 ? " open" : "";
266
+ const keptCount = shown.length + hidden;
267
+ lines.push(`<details${open}>`, `<summary>${result.scope} β€” ${decisionLabel(result.review.decision)} (${keptCount})</summary>`, "");
268
+ if (result.review.summary) {
269
+ lines.push(result.review.summary, "");
270
+ }
271
+ if (shown.length === 0) {
272
+ lines.push("No findings.", "");
273
+ }
274
+ else {
275
+ lines.push(...renderSeveritySections(shown, link, (finding) => idOf(result, finding)));
276
+ }
277
+ if (hidden > 0) {
278
+ lines.push(`_…and ${hidden} more finding(s) β€” see the workflow log._`, "");
279
+ }
280
+ lines.push("</details>", "");
281
+ }
282
+ const allDropped = perScope.flatMap(({ result, dropped }) => dropped.map((entry) => ({ ...entry, scope: result.scope })));
283
+ if (allDropped.length > 0) {
284
+ lines.push("<details>", `<summary>🚫 Dismissed on this PR (${allDropped.length})</summary>`, "");
285
+ for (const { finding, id, scope } of allDropped) {
286
+ const record = dismissedByFp.get(id);
287
+ const who = record.by ? ` by @${record.by}` : "";
288
+ const why = record.reason ? ` β€” ${record.reason}` : "";
289
+ lines.push(`- **${finding.title}** β€” ${location(finding, link)} \`id:${id}\` _(${scope})_${who}${why}`);
290
+ }
291
+ lines.push("", "_Re-add one with `/undismiss <id>`._", "</details>", "");
292
+ }
293
+ lines.push("---", "_This review is advisory β€” it never blocks a merge and never auto-approves._");
294
+ // Embedded state carries the shown (kept) findings PLUS every dismissed one β€”
295
+ // truncation may trim kept findings so the comment fits, but dismissed findings
296
+ // must survive in state (mirroring renderMarkdown, which embeds the full
297
+ // review) so /undismiss can restore them and the Dismissed section persists
298
+ // across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
299
+ // keep both v2 and v1 consumers working.
300
+ const stateScopes = rendered.map(({ result, shown, dropped }) => ({
301
+ scope: result.scope,
302
+ isDefault: result.isDefault,
303
+ review: { ...result.review, findings: [...shown, ...dropped.map((entry) => entry.finding)] },
304
+ }));
305
+ const merged = {
306
+ decision: worst,
307
+ findings: stateScopes.flatMap((scope) => scope.review.findings),
308
+ summary: results
309
+ .map((result) => `**${result.scope}:** ${result.review.summary}`)
310
+ .join("\n\n"),
311
+ incomplete: [...new Set(results.flatMap((result) => result.review.incomplete))],
312
+ };
313
+ const fingerprints = stateScopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding)));
314
+ lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
315
+ lines.push(`<!-- ${tag}:state=${encodeState({ review: merged, dismissed, scopes: stateScopes })} -->`);
316
+ return lines.join("\n");
317
+ };
318
+ let limit = Number.POSITIVE_INFINITY;
319
+ let body = buildBody(limit);
320
+ const largestScope = Math.max(0, ...perScope.map((entry) => entry.kept.length));
321
+ while (body.length > MAX_COMMENT_CHARS && limit > 3) {
322
+ limit =
323
+ limit === Number.POSITIVE_INFINITY
324
+ ? Math.max(3, Math.floor(largestScope / 2))
325
+ : Math.max(3, Math.floor(limit / 2));
326
+ body = buildBody(limit);
327
+ }
328
+ return body;
329
+ }
176
330
  function encodeState(state) {
177
- return Buffer.from(JSON.stringify(state), 'utf8').toString('base64');
331
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64");
178
332
  }
179
333
  /** Recover the embedded `{ review, dismissed }` state from a posted comment body. */
180
334
  export function parseReviewState(body, tag) {
181
- const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
335
+ const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
182
336
  const match = body.match(new RegExp(`<!-- ${escapedTag}:state=([A-Za-z0-9+/=]+) -->`));
183
337
  if (!match) {
184
338
  return null;
185
339
  }
186
340
  try {
187
- const parsed = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8'));
341
+ const parsed = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
188
342
  if (parsed && Array.isArray(parsed.review?.findings) && Array.isArray(parsed.dismissed)) {
189
343
  return parsed;
190
344
  }
@@ -196,11 +350,11 @@ export function parseReviewState(body, tag) {
196
350
  }
197
351
  function severityHeading(severity) {
198
352
  switch (severity) {
199
- case 'critical':
200
- return 'πŸ”΄ Critical';
201
- case 'warning':
202
- return '🟑 Warning';
203
- case 'suggestion':
204
- return 'πŸ”΅ Suggestion';
353
+ case "critical":
354
+ return "πŸ”΄ Critical";
355
+ case "warning":
356
+ return "🟑 Warning";
357
+ case "suggestion":
358
+ return "πŸ”΅ Suggestion";
205
359
  }
206
360
  }