@expo/code-review-cli 0.7.0 → 0.9.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 (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -0,0 +1,194 @@
1
+ import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
2
+ import { buildAdjudicatorSystem, buildAdjudicatorTask } from "./prompts.js";
3
+ import { parseAdjudication } from "./schema.js";
4
+ import { errorMessage } from "./util.js";
5
+ // Adjudication runs after coordination/verification (a serial tail step) and in
6
+ // parallel over records; keep the per-call bound in the shape of the main verifier's
7
+ // VERIFY_TIMEOUT_MS. A call that runs long finalizes on whatever it has.
8
+ const ADJUDICATE_TIMEOUT_MS = 3 * 60 * 1000;
9
+ // @ref LLP 0011#hard-floors-in-code [implements] — secrets/security can NEVER be cleared by a reply, whatever the config; a code floor `protectedCategories` can only widen, never narrow
10
+ /** Categories no reply can ever clear, independent of the configured protected set. */
11
+ export const HARD_FLOOR_CATEGORIES = ["secrets", "security"];
12
+ /**
13
+ * Whether no config — current or future — can ever let a reply clear this finding.
14
+ * `feedbackApplied`'s floor and `adjudicateFeedback`'s "don't spend a model call on it"
15
+ * skip are the same question, so they read it from one place and cannot drift.
16
+ * Deliberately NOT `config.protectedCategories`/`dismiss`: those are user-tunable, so a
17
+ * stored verdict on such a finding becomes useful the moment the config changes.
18
+ */
19
+ function hardFloored(finding) {
20
+ return finding.severity === "critical" || HARD_FLOOR_CATEGORIES.includes(finding.category);
21
+ }
22
+ // @ref LLP 0011#hard-floors-in-code [constrained-by] — dismissal is gated by `dismiss` alone (default "never"); critical + secrets/security are floored in code, and a prompt-injected verdict can't clear them
23
+ /**
24
+ * Whether a reply actually removes this finding from the blocking set. `dismiss` is the
25
+ * one knob that gates clearing — it defaults to "never", so suppression is always an
26
+ * explicit opt-in. `mode` is a separate axis (how much machinery runs), so
27
+ * `dismiss: "maintainers"` works under `mode: "annotate"` with no model involved, the
28
+ * same trust gate as `/dismiss`. NEVER for a critical / secrets / security / protected
29
+ * finding — those floors live here in code, not in any prompt. A maintainer reply that
30
+ * CITES the finding's id clears; the PR author's cited reply clears only under
31
+ * `dismiss: "adjudicated"` with an "accepted" verdict. An untrusted third-party
32
+ * commenter (neither maintainer nor PR author) never clears — its reply is annotated
33
+ * only, as is any reply that merely quotes the finding.
34
+ */
35
+ export function feedbackApplied(finding, record, config) {
36
+ if (config.mode === "off" || config.dismiss === "never") {
37
+ return false;
38
+ }
39
+ // @ref LLP 0011#a-quote-annotates-an-id-clears [implements] — a quote may annotate, only a cited id may clear, on BOTH clear paths below
40
+ // A quoted line is not consent: GitHub's "Quote reply" copies a comment the untrusted
41
+ // PR author wrote, so a maintainer can clear-by-accident (or be led to) on text they
42
+ // never authored. The `id:<fp>` token is printed only by our own comment and counts
43
+ // only outside a blockquote, so citing it is an act of the replier.
44
+ if (record.citedId !== true) {
45
+ return false;
46
+ }
47
+ // A human ran `/undismiss` on this reply-cleared finding: it is pinned back to the
48
+ // active list, so the still-present reply must not silently re-clear it.
49
+ if (record.unclearedByHuman) {
50
+ return false;
51
+ }
52
+ if (hardFloored(finding)) {
53
+ return false;
54
+ }
55
+ if (config.protectedCategories.includes(finding.category)) {
56
+ return false;
57
+ }
58
+ if (record.maintainer) {
59
+ return true;
60
+ }
61
+ // The adjudicated path is the PR author's alone. `record.author` is re-derived from
62
+ // the live comment's unspoofable login every run, so a random PR commenter — even
63
+ // with a model-accepted rebuttal — can never clear a finding.
64
+ return (config.dismiss === "adjudicated" && record.verdict === "accepted" && record.author === true);
65
+ }
66
+ // @ref LLP 0011#suppression-is-never-silent [implements] — a verdict binds to the source it judged; both merge paths (reporter + aggregate) share THIS predicate so they can never drift apart
67
+ /**
68
+ * Strip a stored decision that no longer judges the source under review. A verdict is a
69
+ * claim about CODE, not only about words, and `fingerprintFinding` deliberately excludes
70
+ * the line number, so the author can edit away the code a rebuttal relied on while the
71
+ * finding keeps its identity. Unknown head — no `headSha` for this run, or a record
72
+ * written before the field existed — counts as different, never as trusted. A record
73
+ * with no verdict (a maintainer reply, an unjudged annotation) has no source-dependent
74
+ * decision and passes through untouched. The stale `applied` drops with the verdict:
75
+ * carrying `true` would hide the finding for one render before the recompute.
76
+ */
77
+ export function dropStaleVerdict(record, headSha) {
78
+ if (record.verdict === undefined || (headSha !== undefined && record.sourceSha === headSha)) {
79
+ return record;
80
+ }
81
+ const { verdict: _verdict, reason: _reason, sourceSha: _sourceSha, ...rest } = record;
82
+ return { ...rest, applied: false };
83
+ }
84
+ // @ref LLP 0011#hard-floors-in-code [constrained-by] — the extra `gh pr view` runs only where the author flag can move an outcome; a skipped lookup reads exactly like a failed one (author:false, clears nothing)
85
+ /**
86
+ * Whether the PR author's login has to be resolved at all for this config. The `author`
87
+ * flag gates exactly one thing — the adjudicated clear path in `feedbackApplied` (and,
88
+ * through it, which replies are worth a model call) — so under any other `dismiss`
89
+ * value resolving it is a `gh` call that can never move an outcome. Absent config (the
90
+ * `ecr feedback` crawl) resolves nothing, which is also the fail-closed answer: an
91
+ * unresolved author marks no reply as the author's and clears nothing.
92
+ */
93
+ export function feedbackNeedsPrAuthor(config) {
94
+ return config?.dismiss === "adjudicated";
95
+ }
96
+ /**
97
+ * Whether the command layer must wire the runReview feedback seam at all: either a
98
+ * model judges replies ("adjudicate"), or replies may clear findings (`dismiss` opted
99
+ * in) and `applied` must be computed here even though no model runs. Everything else
100
+ * (annotate + never) is handled by the reporter at report time, with no seam.
101
+ */
102
+ export function feedbackNeedsRunSeam(config) {
103
+ return config.mode === "adjudicate" || (config.mode !== "off" && config.dismiss !== "never");
104
+ }
105
+ /**
106
+ * Judge each author reply against the source and record its verdict, then recompute
107
+ * every record's `applied` flag under the hard floors. Bounded: at most
108
+ * `config.maxAdjudications` model calls per run, run in parallel; records past the cap
109
+ * are left unjudged and counted in `skipped` (never silently dropped). Fails open — a
110
+ * model/parse/timeout error on one record keeps it (verdict unset) and never throws.
111
+ * When mode !== "adjudicate" it makes no model calls at all and only recomputes
112
+ * `applied` — under `dismiss: "maintainers"` a maintainer reply still clears without
113
+ * any model; under `dismiss: "never"` nothing does.
114
+ *
115
+ * `sourceSha` is the head commit this run reviewed: every verdict decided here is
116
+ * stamped with it, so a later run can tell whether the verdict still judges the same
117
+ * source (see mergeFeedback). A source with no resolvable head OID (a local run) stamps
118
+ * nothing, which makes the verdict non-carrying rather than permanent.
119
+ */
120
+ export async function adjudicateFeedback(handle, items, config, debug = () => { }, sourceSha) {
121
+ let cost = 0;
122
+ let model;
123
+ const tokens = {};
124
+ let failed = 0;
125
+ // Only judge in "adjudicate" mode, and only records without a verdict already decided
126
+ // on a prior run (the reporter carries those forward — re-judging would re-spend the
127
+ // budget on the same words). A reply with no text can't be judged. Only the PR
128
+ // author's replies are worth judging: a maintainer clears without any verdict, and a
129
+ // third-party commenter can never clear (feedbackApplied), so judging either would
130
+ // spend the budget on a rebuttal that changes no outcome. A reply that only quotes the
131
+ // finding can never clear it either, and would otherwise starve a cited reply of the
132
+ // cap. A finding a human already restored via `/undismiss` is likewise left unjudged.
133
+ // A hard-floored finding is skipped for the same reason and is deliberately NOT counted
134
+ // in `skipped` below: that figure means "reduced coverage, raise the cap", and a verdict
135
+ // no config could ever act on is not coverage a higher cap would buy back.
136
+ // @ref LLP 0011#hard-floors-in-code [constrained-by] — the code floor also means the model never judges a critical/secrets/security rebuttal: no verdict could move that outcome
137
+ const toJudge = config.mode === "adjudicate"
138
+ ? items.filter((item) => item.record.verdict === undefined &&
139
+ item.record.author === true &&
140
+ item.record.citedId === true &&
141
+ !item.record.unclearedByHuman &&
142
+ !hardFloored(item.finding) &&
143
+ item.replyText.trim() !== "")
144
+ : [];
145
+ // Never truncate silently: the first `maxAdjudications` are judged, the rest are
146
+ // reported as skipped so the caller can surface reduced coverage.
147
+ const within = toJudge.slice(0, config.maxAdjudications);
148
+ const skipped = toJudge.length - within.length;
149
+ if (skipped > 0) {
150
+ debug(`Feedback: ${skipped} repl${skipped === 1 ? "y" : "ies"} over ` +
151
+ `maxAdjudications=${config.maxAdjudications} — left unjudged this run.`);
152
+ }
153
+ // A successful call records the verdict against the record it judged; an error leaves
154
+ // it out of the map, so the record below stays untouched (fail open).
155
+ const judged = new Map();
156
+ await Promise.all(within.map(async (item, index) => {
157
+ try {
158
+ const { value, cost: callCost, tokens: callTokens, model: callModel, } = await promptAndParse(handle, {
159
+ // Reuse the verifier's OpenCode agent: it carries exactly the read+grep tool
160
+ // set the adjudicator needs (open the cited file, trace the path) and the
161
+ // reviewing model, and its distrust posture matches. The system prompt below
162
+ // is what makes this an adjudication rather than a verification.
163
+ agent: VERIFIER_AGENT,
164
+ system: buildAdjudicatorSystem(),
165
+ text: buildAdjudicatorTask(item.finding, item.replyText),
166
+ title: `adjudicate-${index}`,
167
+ maxWaitMs: ADJUDICATE_TIMEOUT_MS,
168
+ finalizeOnTimeout: true,
169
+ }, parseAdjudication);
170
+ cost += callCost;
171
+ addTokenUsage(tokens, callTokens);
172
+ model = callModel ?? model;
173
+ judged.set(item.record, { verdict: value.verdict, reason: value.reason });
174
+ }
175
+ catch (error) {
176
+ // Fail open: an error leaves the record unjudged and the finding intact.
177
+ failed++;
178
+ debug(`Feedback: could not adjudicate a reply (${errorMessage(error)}); leaving it unjudged.`);
179
+ }
180
+ }));
181
+ // Re-emit every record: set the verdict/reason where judged, then recompute `applied`
182
+ // under the hard floors (this also clears a maintainer reply, which needs no verdict).
183
+ const records = items.map((item) => {
184
+ const decision = judged.get(item.record);
185
+ // A fresh verdict is stamped with the source it judged (when the run knows it), so
186
+ // the next run re-judges the same reply once that source moves on.
187
+ // @ref LLP 0011#suppression-is-never-silent [implements] — the verdict is bound to the revision it was decided against
188
+ const withVerdict = decision
189
+ ? { ...item.record, verdict: decision.verdict, reason: decision.reason, sourceSha }
190
+ : item.record;
191
+ return { ...withVerdict, applied: feedbackApplied(item.finding, withVerdict, config) };
192
+ });
193
+ return { records, cost, tokens, model, adjudicated: judged.size, skipped, failed };
194
+ }
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — deny-list, cross-provider guard, isolated OAuth staging, and the forwarding-site recheck for Claude Code
1
2
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import path from "node:path";
@@ -32,7 +33,7 @@ const ANTHROPIC_TOKEN_ENVS = {
32
33
  * minted for that provider, so we hard-refuse these well-known unrelated secrets.
33
34
  * Defense-in-depth alongside loading config only from the trusted base ref.
34
35
  */
35
- const FORBIDDEN_TOKEN_ENVS = new Set([
36
+ export const FORBIDDEN_TOKEN_ENVS = new Set([
36
37
  "GITHUB_TOKEN",
37
38
  "GH_TOKEN",
38
39
  "ACTIONS_RUNTIME_TOKEN",
@@ -154,6 +155,7 @@ export function checkOauthTokenShape(provider, token, tokenEnv) {
154
155
  }
155
156
  return ok;
156
157
  }
158
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — two deny checks: FORBIDDEN_TOKEN_ENVS refuses well-known unrelated secrets; the cross-provider ownership guard refuses a non-anthropic entry naming an ANTHROPIC_TOKEN_ENVS var
157
159
  /**
158
160
  * Decide whether ONE configured credential is usable, WITHOUT mutating the
159
161
  * environment. See checkProviderAuth for the all-entries wrapper.
@@ -398,6 +400,7 @@ export function jwtExpiryMs(token) {
398
400
  * setup-token style bearers), far-future expiry so OpenCode never tries to
399
401
  * refresh a credential that has no refresh half.
400
402
  */
403
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — JWT access tokens are used as-is and never refreshed; opaque tokens are stored with expires:0 for the codex refresh flow, only safe when this run is the token's sole consumer (refresh tokens are single-use)
401
404
  export function oauthAuthJsonEntry(provider, token) {
402
405
  if (provider === "openai" && !isJwtAccessToken(token)) {
403
406
  return { type: "oauth", access: "", refresh: token, expires: 0 };
@@ -420,6 +423,7 @@ export function oauthAuthJsonEntry(provider, token) {
420
423
  * per-provider shapes). Isolated so it never touches the developer's real
421
424
  * auth.json.
422
425
  */
426
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — stages all OAuth credentials into one isolated auth.json under a temp XDG_DATA_HOME; anthropic never passes through this path (its credential goes straight into startClaudeCode's child env)
423
427
  export async function prepareAuth(config) {
424
428
  const noop = { cleanup: async () => { } };
425
429
  // REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
@@ -1,8 +1,10 @@
1
+ // @ref LLP 0003#claude-code-cli-containment [implements] — argv/env hardening for the claude -p subprocess
2
+ // @ref LLP 0003#two-engines-per-agent-dispatch [implements] — anthropic/* routing and the per-agent engine map
1
3
  import { tmpdir } from "node:os";
2
4
  import path from "node:path";
3
5
  import { checkAuthEntry } from "./auth.js";
4
6
  import { pathInside, resolveOnPath, run } from "./exec.js";
5
- import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
7
+ import { addTokenUsage, AgentTimeoutError, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, STACK_VERIFIER_AGENT, VERIFIER_AGENT, withTransientRetry, } from "./opencode.js";
6
8
  import { RateLimitWatch } from "./throttle.js";
7
9
  /** Coarse per-pass wander bound; the review's own maxWaitMs is the real ceiling. */
8
10
  const CLAUDE_MAX_TURNS = 60;
@@ -26,6 +28,7 @@ const READ_TOOL_MAP = {
26
28
  glob: "Glob",
27
29
  };
28
30
  const ALL_READ_TOOLS = ["Read", "Grep", "Glob"];
31
+ // @ref LLP 0003#claude-code-cli-containment [implements] — deny enumeration (not allow-only) because an empty/absent --allowedTools list default-ALLOWS reads; verified against claude 2.1.212, revisit on every CLI version bump
29
32
  /**
30
33
  * Tools never available to a review pass, whatever the role. A DENY enumeration is
31
34
  * the only workable containment: permission rules cannot fail closed here — reads
@@ -102,6 +105,7 @@ const MISSING_CLI_MESSAGE = "The `claude` CLI is not installed. Install Claude C
102
105
  * served by the CLI; the retired anthropic-via-OpenCode x-api-key path no longer
103
106
  * exists (the CLI accepts an API key too).
104
107
  */
108
+ // @ref LLP 0003#two-engines-per-agent-dispatch [implements] — engine choice is a pure function of the model id's provider prefix, no run-level auth-mode switch
105
109
  export function engineForModel(model) {
106
110
  const slash = model.indexOf("/");
107
111
  const provider = slash > 0 ? model.slice(0, slash) : model;
@@ -132,6 +136,8 @@ export function buildEngineMap(config, agents = config.agents) {
132
136
  const shared = config.agents[0]?.model ?? config.coordinator.model;
133
137
  modelOf[CROSS_CUTTING_AGENT] = shared;
134
138
  modelOf[VERIFIER_AGENT] = shared;
139
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — the id MUST live in modelOf/engineOf or a claude-routed run dispatches against an undefined handle and crashes
140
+ modelOf[STACK_VERIFIER_AGENT] = shared;
135
141
  modelOf["coordinator"] = config.coordinator.model;
136
142
  const engineOf = {};
137
143
  for (const [id, model] of Object.entries(modelOf)) {
@@ -330,6 +336,7 @@ export function usageLimitResetMs(errorText) {
330
336
  * reset time instead. The interpolated reset epoch is a long digit run with no
331
337
  * internal word boundary, so it can't spuriously match `\b429\b`/`\b50x\b`.
332
338
  */
339
+ // @ref LLP 0003#retry-taxonomy [constrained-by] — message text deliberately avoids matching isTransientApiError's regex so a subscription cap fails fast rather than retrying
333
340
  export function usageLimitMessage(errorText) {
334
341
  const resetMs = usageLimitResetMs(errorText);
335
342
  const when = resetMs ? new Date(resetMs).toISOString() : "later";
@@ -577,6 +584,7 @@ export function claudeTokenCredential(entry, env = process.env) {
577
584
  }
578
585
  return { value, kind: value.startsWith("sk-ant-oat") ? "oauth" : "api-key" };
579
586
  }
587
+ // @ref LLP 0003#credential-resolution-and-forwarding [implements] — re-runs checkAuthEntry at the forwarding site because REVIEWER_MODEL bypasses prepareAuth/checkProviderAuth entirely
580
588
  /** Start the Claude Code engine: resolve the CLI and build the subscription env. */
581
589
  export async function startClaudeCode(config) {
582
590
  const cliPath = await resolveOnPath("claude");
@@ -659,6 +667,9 @@ export async function startClaudeCode(config) {
659
667
  // consolidates findings and needs no repo tools.
660
668
  tools[CROSS_CUTTING_AGENT] = ["read", "grep"];
661
669
  tools[VERIFIER_AGENT] = ["read", "grep"];
670
+ // No tools: the addressing PR's patch is inlined into the task, so the stack
671
+ // verifier never reads the disk (mirrors the coordinator's empty list).
672
+ tools[STACK_VERIFIER_AGENT] = [];
662
673
  tools["coordinator"] = [];
663
674
  const defaultModel = config.agents[0]?.model ?? config.coordinator.model;
664
675
  return {