@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.
Files changed (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0005#finding-identity-fingerprints
1
2
  import { createHash } from "node:crypto";
2
3
  import { z } from "zod";
3
4
  import { normalizeCode } from "./util.js";
@@ -21,7 +22,45 @@ export const FindingSchema = z.object({
21
22
  * the finding is treated as hallucinated and dropped.
22
23
  */
23
24
  evidence: z.string().optional(),
25
+ /**
26
+ * Set by the coordinator (and then ground-checked, see groundStackRequalification)
27
+ * when a later, stacked-on-top PR already addresses this absence-style finding:
28
+ * `prNumber` + the EXACT upstack manifest `file` relied on + a one-line `reason`.
29
+ * A requalified finding is never dropped — it renders in its own section, is
30
+ * counted, and is only excluded from the blocking decision. Never part of the
31
+ * fingerprint, so dismissal identity is stable across re-reviews.
32
+ */
33
+ // @ref LLP 0010#requalification-schema-and-fingerprints [constrained-by] — annotate-only; excluded from fingerprintFinding so dismissal identity never lapses on requalification
34
+ requalifiedBy: z
35
+ .object({ prNumber: z.number().int(), file: z.string(), reason: z.string() })
36
+ .optional(),
37
+ /**
38
+ * Which reviewer agent produced this finding. Engine-populated, never the model:
39
+ * `ModelFindingSchema` omits it, so an `agent` in model JSON is dropped at the parse
40
+ * boundary and only the engine's fingerprint lookup can set it. Excluded from the
41
+ * fingerprint like `requalifiedBy`, so attribution appearing on a finding can never
42
+ * lapse an existing dismissal.
43
+ */
44
+ // @ref LLP 0011#attribution-and-identity [constrained-by] — attribution is annotation-only; never part of fingerprintFinding
45
+ agent: z.string().optional(),
24
46
  });
47
+ /**
48
+ * Title of the internal "overall PR risk" handoff finding. The cross-cutting
49
+ * reviewer (or the always-run security reviewer on a PR small enough to skip the
50
+ * cross-cutting pass) rides the ordinary finding channel to hand the coordinator
51
+ * a whole-PR risk assessment — see the "Overall PR risk handoff" section in
52
+ * `templates/shared.md`. It is prompt-level metadata, never a defect.
53
+ */
54
+ // @ref LLP 0009#prompt-rules-for-adopters [implements] — the deterministic strip that makes the handoff independent of policy.includeSuggestions
55
+ export const OVERALL_PR_RISK_TITLE = "__overall_pr_risk__";
56
+ /**
57
+ * Is this the internal risk handoff rather than a real finding? Matched on the
58
+ * exact title the prompt specifies. Reported findings must never include it: it
59
+ * would surface to PR authors as a nonsense bullet, and it is not a defect.
60
+ */
61
+ export function isOverallRiskHandoff(finding) {
62
+ return finding.title.trim() === OVERALL_PR_RISK_TITLE;
63
+ }
25
64
  /** A verifier's verdict on whether a finding is real (adversarial refute pass). */
26
65
  export const VerdictSchema = z.object({
27
66
  verified: z.boolean(),
@@ -30,14 +69,36 @@ export const VerdictSchema = z.object({
30
69
  export function parseVerdict(text) {
31
70
  return VerdictSchema.parse(extractJsonObject(text));
32
71
  }
72
+ /**
73
+ * A stack verifier's verdict on whether a later stacked PR's patch actually
74
+ * addresses an absence-style finding (v2 patch confirmation). Fails toward
75
+ * blocking: anything but a clear `addressed: true` strips the requalification.
76
+ */
77
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — addressed !== true keeps the finding blocking
78
+ export const StackVerdictSchema = z.object({
79
+ addressed: z.boolean(),
80
+ reason: z.string().default(""),
81
+ });
82
+ export function parseStackVerdict(text) {
83
+ return StackVerdictSchema.parse(extractJsonObject(text));
84
+ }
85
+ // @ref LLP 0011#attribution-and-identity [implements] — `agent` is engine-populated, so the model-facing schema drops it at the parse boundary instead of trusting call sites to strip it
86
+ /**
87
+ * The finding shape a MODEL may emit: `FindingSchema` minus the engine-only `agent`.
88
+ * Both model outputs parse through this, so an `agent` a reviewer pass or the
89
+ * coordinator invented is dropped where model JSON becomes typed data — the engine's own
90
+ * fingerprint lookup is then the only thing that can set it. The transform re-widens the
91
+ * result to `Finding` so downstream code (which does set `agent`) needs no change.
92
+ */
93
+ const ModelFindingSchema = FindingSchema.omit({ agent: true }).transform((finding) => finding);
33
94
  /** Shape each sub-reviewer must emit. */
34
95
  export const ReviewerOutputSchema = z.object({
35
- findings: z.array(FindingSchema).default([]),
96
+ findings: z.array(ModelFindingSchema).default([]),
36
97
  });
37
98
  /** Mode-agnostic coordinator result; each Reporter decides how to render it. */
38
99
  export const CoordinatorOutputSchema = z.object({
39
100
  decision: z.enum(DECISIONS),
40
- findings: z.array(FindingSchema).default([]),
101
+ findings: z.array(ModelFindingSchema).default([]),
41
102
  summary: z.string(),
42
103
  /**
43
104
  * Human-readable notes about reduced coverage (e.g. a review pass that hit its
@@ -55,9 +116,158 @@ export const CoordinatorOutputSchema = z.object({
55
116
  */
56
117
  couldNotComplete: z.boolean().optional(),
57
118
  });
119
+ /** How an author's reply to a finding held up against the source. */
120
+ export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
121
+ /** The kinds of pushback authors actually write, as a closed set. */
122
+ export const FEEDBACK_REASONS = [
123
+ "pre-existing", // the PR only continues a pattern already in the repo
124
+ "deliberate-scope", // a bounded, intentional limitation of new code
125
+ "fixed", // the author says they addressed it
126
+ "disagree", // the author disputes the analysis itself
127
+ "other",
128
+ ];
129
+ // @ref LLP 0011#never-echo-reply-text [constrained-by] — no free-text field: reply prose never reaches the comment body, so it can never carry a forged state marker
130
+ /**
131
+ * One author reply matched to one finding, keyed by fingerprint. Everything here
132
+ * is either engine-derived or enum-valued — deliberately NO free-text field. The
133
+ * reply's own prose is never stored and never rendered; adding a field for it
134
+ * would put attacker-written text back into the comment body.
135
+ */
136
+ export const FeedbackRecordSchema = z.object({
137
+ fp: z.string(),
138
+ by: z.string(),
139
+ commentId: z.number().int(),
140
+ url: z.string().optional(),
141
+ maintainer: z.boolean().default(false),
142
+ /**
143
+ * True when the replying login is the PR author. Re-derived from the live comment
144
+ * every run (never trusted from stored state), so it is unspoofable like `maintainer`.
145
+ * Only a maintainer OR the PR author may clear a finding via a reply — an untrusted
146
+ * third-party commenter is annotated but can never be counted as an adjudicatable
147
+ * rebuttal (feedbackApplied gates the adjudicated path on this).
148
+ */
149
+ // @ref LLP 0011#hard-floors-in-code [constrained-by] — the adjudicated clear path is for the PR author only; a third-party commenter's rebuttal never clears
150
+ author: z.boolean().optional(),
151
+ /**
152
+ * True when the reply cites this finding's `id:<fp>` token in the replier's OWN words
153
+ * (outside every blockquote). Re-derived from the live comment every run by
154
+ * `matchReplies`, never trusted from stored state, exactly like `maintainer`/`author`.
155
+ * A quote-only match still ANNOTATES the finding; only a cited id may CLEAR it, on
156
+ * both clear paths. Absent ⇒ false ⇒ clears nothing, so a record written before this
157
+ * field parses fine and fails closed until its reply is matched again.
158
+ */
159
+ // @ref LLP 0011#a-quote-annotates-an-id-clears [constrained-by] — a quoted line can be text the untrusted PR author planted for a maintainer to quote-reply, so a quote may annotate but never clear
160
+ citedId: z.boolean().optional(),
161
+ verdict: z.enum(FEEDBACK_VERDICTS).optional(),
162
+ reason: z.enum(FEEDBACK_REASONS).optional(),
163
+ /**
164
+ * The reviewed head commit the verdict above was judged against (the PR head OID of
165
+ * the run whose adjudicator answered). A verdict is a statement about SOURCE, and a
166
+ * fingerprint deliberately excludes the line number, so a finding keeps its identity
167
+ * while the code that justified the rebuttal is edited away. Binding the verdict to
168
+ * the revision it judged is what lets mergeFeedback drop it once the head moves.
169
+ * Absent ⇒ unknown source (a record from before this field, or a run with no
170
+ * resolvable head OID): the verdict never carries, so a missing SHA can never pin a
171
+ * decision forever.
172
+ */
173
+ // @ref LLP 0011#suppression-is-never-silent [constrained-by] — a carried verdict must be re-judged when the source it judged changes; unknown source fails safe to re-judging
174
+ sourceSha: z.string().optional(),
175
+ /** True when this reply actually removed the finding from the blocking set. */
176
+ applied: z.boolean().default(false),
177
+ /**
178
+ * Set when a human ran `/undismiss <id>` on a finding a reply had cleared: the
179
+ * finding returns to the active list and no live reply may re-apply it, so a later
180
+ * re-review recomputing `applied` from the still-present reply keeps it un-cleared.
181
+ *
182
+ * DERIVED, never the storage: the pin itself lives in the comment state's own `pins`
183
+ * set (see FeedbackPinSchema), which survives a run where no reply matches the
184
+ * finding at all. This flag is stamped back onto the record by `applyPins` on every
185
+ * render so `feedbackApplied` stays a pure record-local decision — and so a comment
186
+ * written by this version is still read correctly by an older one.
187
+ */
188
+ // @ref LLP 0011#suppression-is-never-silent [constrained-by] — /undismiss must actually restore a reply-cleared finding, and the untrusted PR author must not be able to lift that restore by removing or replacing their reply
189
+ unclearedByHuman: z.boolean().optional(),
190
+ });
191
+ // @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin is state about a FINDING, stored outside the reply record so a vanishing reply can never drop it
192
+ /**
193
+ * One maintainer `/undismiss` pin: the finding they restored, plus the reply comment
194
+ * the pin was applied against (absent when the finding had no reply record). The
195
+ * comment id is what makes "a maintainer's own NEWER reply lifts the pin" decidable
196
+ * without the pin having to live on a reply record: only a maintainer reply posted
197
+ * AFTER the pin (a strictly greater comment id — GitHub issue comment ids increase)
198
+ * releases it.
199
+ */
200
+ export const FeedbackPinSchema = z.object({
201
+ fp: z.string(),
202
+ commentId: z.number().int().optional(),
203
+ });
204
+ /**
205
+ * The pin set to work with: the state's own `pins` plus any record-level
206
+ * `unclearedByHuman` flag. The second half is the migration for a comment written
207
+ * before `pins` existed — its pins live only on the records, and dropping them would
208
+ * silently lift a maintainer's restore on the first render by this version. Idempotent.
209
+ */
210
+ export function collectPins(pins, records) {
211
+ const byFp = new Map();
212
+ for (const pin of pins ?? []) {
213
+ byFp.set(pin.fp, pin);
214
+ }
215
+ for (const record of records) {
216
+ if (record.unclearedByHuman === true && !byFp.has(record.fp)) {
217
+ byFp.set(record.fp, { fp: record.fp, commentId: record.commentId });
218
+ }
219
+ }
220
+ return [...byFp.values()];
221
+ }
222
+ // @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — the pin set is the single source of truth; the record flag is stamped from it, never the other way round
223
+ /**
224
+ * Carry the pin set across one render and stamp the records it covers. A pinned
225
+ * record can never be `applied` (the finding stays in the active list), and a record
226
+ * the set does NOT pin loses any stale flag — the set decides, so a flag left on a
227
+ * record could never resurrect a lifted pin.
228
+ *
229
+ * The only lift here is a maintainer's own newer reply: a reply record for the pinned
230
+ * finding, from a maintainer, posted after the pin. Everything else (a newer reply from
231
+ * the untrusted PR author, an edited or deleted reply, a re-review that matched no
232
+ * reply at all) leaves the pin exactly where it is. The other lift — a maintainer's
233
+ * `/dismiss` on that finding — happens in applyDismissalToState, the same trusted hand
234
+ * deciding the opposite way.
235
+ */
236
+ export function applyPins(records, pins) {
237
+ const kept = pins.filter((pin) =>
238
+ // A pin with no recorded commentId is never lifted by a reply: "unknown" must not
239
+ // read as "older than every comment", which would let any maintainer reply lift it.
240
+ pin.commentId === undefined ||
241
+ !records.some((record) => record.fp === pin.fp && record.maintainer === true && record.commentId > pin.commentId));
242
+ const pinnedFps = new Set(kept.map((pin) => pin.fp));
243
+ const stamped = records.map((record) => {
244
+ if (pinnedFps.has(record.fp)) {
245
+ return { ...record, unclearedByHuman: true, applied: false };
246
+ }
247
+ if (record.unclearedByHuman === undefined) {
248
+ return record;
249
+ }
250
+ const { unclearedByHuman: _lifted, ...rest } = record;
251
+ return rest;
252
+ });
253
+ return { records: stamped, pins: kept };
254
+ }
255
+ /**
256
+ * The adjudicator's verdict on one rebuttal, re-derived from the source. Both
257
+ * fields are enum-constrained: the judgment is a classification, never prose the
258
+ * model could smuggle instructions (or a state marker) through.
259
+ */
260
+ export const AdjudicationSchema = z.object({
261
+ verdict: z.enum(FEEDBACK_VERDICTS),
262
+ reason: z.enum(FEEDBACK_REASONS).default("other"),
263
+ });
264
+ export function parseAdjudication(text) {
265
+ return AdjudicationSchema.parse(extractJsonObject(text));
266
+ }
58
267
  /** Minimum normalized evidence length to key a fingerprint on the code (below
59
268
  * this we fall back to the title). */
60
269
  const MIN_FP_EVIDENCE_LEN = 12;
270
+ // @ref LLP 0005#finding-identity-fingerprints [implements] — keys on evidence (v2) not the LLM-written title; excludes line number
61
271
  /**
62
272
  * Stable identifier for a finding — dedupes across re-reviews and is the key for
63
273
  * dismissals. Excludes the line number (which shifts as a PR grows). Keys on the
@@ -73,6 +283,7 @@ export function fingerprintFinding(finding) {
73
283
  const normalized = ["v2", finding.file, finding.category, key].join("|");
74
284
  return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
75
285
  }
286
+ // @ref LLP 0005#finding-identity-fingerprints [implements] — default scope passes null so pre-routing dismissals still resolve (risk 9)
76
287
  /**
77
288
  * Namespace a finding's fingerprint by scope so cross-scope dismissals never
78
289
  * collide. The DEFAULT scope (config '.') passes `null` and keeps the plain
@@ -112,7 +323,12 @@ export function extractJsonObject(text) {
112
323
  lastError = error;
113
324
  }
114
325
  }
115
- throw new Error(`Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
326
+ throw new Error(lastError === undefined
327
+ ? // No candidate was even tried: the response held no {...} block at all —
328
+ // empty output, or prose/pseudo-tool-call text with no JSON in it.
329
+ `Could not extract JSON from model response: no JSON object found in ` +
330
+ `${text.trim() === "" ? "an EMPTY response" : `a ${text.length}-char response with no {...}`}`
331
+ : `Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
116
332
  }
117
333
  /** The router's choice of which agent ids to run. */
118
334
  export const RouteOutputSchema = z.object({
@@ -1,4 +1,5 @@
1
- import { readdir, rm } from "node:fs/promises";
1
+ // @ref LLP 0001#read-root-scrubbing [implements] strips ambient config + escaping symlinks before the model runtime roots here
2
+ import { readdir, realpath, rm } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  /**
4
5
  * Ambient runtime configuration the OpenCode server (and the Claude-compatible
@@ -29,6 +30,7 @@ export const AMBIENT_RUNTIME_CONFIG_NAMES = new Set([
29
30
  ".cursor",
30
31
  ".cursorrules",
31
32
  ]);
33
+ // @ref LLP 0001#read-root-scrubbing [constrained-by] — .git skipped here for a different reason than node_modules is skipped below
32
34
  /** Names never descended into (and never scrubbed as a unit — `.git` is the worktree link). */
33
35
  const SKIP_DIRS = new Set([".git", "node_modules"]);
34
36
  /** Whether a directory entry is ambient runtime config that must not reach the model runtime. */
@@ -41,6 +43,7 @@ export function isAmbientRuntimeConfig(name) {
41
43
  * extracted archive) — never on the user's checkout. Returns the repo-relative
42
44
  * paths removed so callers can log them.
43
45
  */
46
+ // @ref LLP 0001#read-root-scrubbing [implements] — closes the fork-reachable code-execution path (601b19a)
44
47
  export async function scrubAmbientRuntimeConfig(root) {
45
48
  const removed = [];
46
49
  const walk = async (dir) => {
@@ -60,3 +63,62 @@ export async function scrubAmbientRuntimeConfig(root) {
60
63
  await walk(root);
61
64
  return removed.sort();
62
65
  }
66
+ /**
67
+ * Remove symlinks whose fully resolved target lies outside the materialized tree.
68
+ *
69
+ * The model runtime's read tools are path-scoped by the LITERAL path argument
70
+ * (buildClaudeArgs' permission rules, and equally OpenCode's project-root
71
+ * containment), but the fs layer underneath follows symlinks — so a PR-committed
72
+ * link (`docs/notes.md -> ~/.claude/.credentials.json`) passes the in-tree check
73
+ * and reads the out-of-tree target. Git can only materialize regular files,
74
+ * directories, and symlinks, so stripping escaping symlinks here closes the whole
75
+ * class.
76
+ *
77
+ * Fail closed: a link whose target cannot be resolved (broken, or a chain that
78
+ * leaves the tree at any hop) is removed too — the target could come into
79
+ * existence later, and a broken link has no legitimate review value. In-tree
80
+ * links survive (realpath resolves chains, so an in-tree alias of an in-tree
81
+ * file is provably contained). Unlike the config scrub, this walk descends into
82
+ * node_modules (a committed one is attacker content); `.git` stays skipped — in
83
+ * a worktree it is an ECR-created gitdir link, not PR content.
84
+ *
85
+ * Must only ever run on a tree ECR created and will delete. Returns the
86
+ * repo-relative paths removed so callers can log them.
87
+ */
88
+ // @ref LLP 0001#read-root-scrubbing [implements] — out-of-tree/broken/cyclic symlinks removed fail-closed
89
+ export async function removeEscapingSymlinks(root) {
90
+ // realpath the boundary itself: tmpdir-based roots are often behind symlinks
91
+ // (macOS /var -> /private/var), and containment must compare resolved paths.
92
+ const boundary = await realpath(root);
93
+ const removed = [];
94
+ const walk = async (dir) => {
95
+ const entries = await readdir(dir, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ const full = path.join(dir, entry.name);
98
+ if (entry.isSymbolicLink()) {
99
+ let contained = false;
100
+ try {
101
+ const target = await realpath(full);
102
+ contained = target === boundary || target.startsWith(boundary + path.sep);
103
+ }
104
+ catch {
105
+ // Unresolvable link: leave `contained` false (fail closed).
106
+ }
107
+ if (!contained) {
108
+ await rm(full, { force: true });
109
+ // Relative to the RESOLVED boundary — the walk runs there, and the
110
+ // caller's `root` may itself sit behind a symlink (macOS /var).
111
+ removed.push(path.relative(boundary, full));
112
+ }
113
+ // In-tree directory links are kept but never descended: their contents
114
+ // are walked once via the real path, and descending would loop on cycles.
115
+ continue;
116
+ }
117
+ if (entry.isDirectory() && entry.name !== ".git") {
118
+ await walk(full);
119
+ }
120
+ }
121
+ };
122
+ await walk(boundary);
123
+ return removed.sort();
124
+ }
@@ -0,0 +1,137 @@
1
+ // @ref LLP 0010#patch-level-confirmation-v2 [implements] — path membership is not semantic proof; read the addressing patch, fail toward keeping the finding blocking
2
+ import { addTokenUsage, promptAndParse, STACK_VERIFIER_AGENT } from "./opencode.js";
3
+ import { buildStackVerifierSystem, buildStackVerifierTask } from "./prompts.js";
4
+ import { fingerprintFinding, parseStackVerdict } from "./schema.js";
5
+ import { manifestKey } from "./stack.js";
6
+ import { errorMessage } from "./util.js";
7
+ // Confirmation runs after coordination (a serial tail step), in parallel over unique
8
+ // candidates. A confirmation that runs long is stripped (fail toward blocking), so this
9
+ // timeout is a hard "give up and keep the finding" bound, not a retry trigger.
10
+ const STACK_CONFIRM_TIMEOUT_MS = 3 * 60 * 1000;
11
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — one verdict PER FINDING (only the patch fetch is shared); overflow past maxConfirmations is STRIPPED, not skipped; addressed!==true / error / timeout all STRIP
12
+ /**
13
+ * Confirm every surviving requalification against the addressing PR's real patch and
14
+ * strip any that is not clearly addressed. Pure orchestration over an injected
15
+ * `confirmOne` so the per-finding / overflow / fail-open logic is unit-testable
16
+ * without gh or a live model:
17
+ * - every requalified finding gets its OWN verdict — the verdict question is
18
+ * finding-specific ("does this patch supply what THIS finding says is missing"),
19
+ * so a shared verdict would let one confirmed finding clear an unrelated one
20
+ * citing the same `(prNumber, file)`. Only the patch FETCH is shared (memoized in
21
+ * `patchConfirmer`); identical findings (same fingerprint + citation) collapse;
22
+ * - only the first `maxConfirmations` candidates are confirmed — any beyond that
23
+ * cap have their requalification STRIPPED (fail toward blocking), never silently
24
+ * kept;
25
+ * - a candidate is kept ONLY on `addressed === true`; `false`, any thrown error, or a
26
+ * timeout strips it.
27
+ * A stripped requalification leaves the finding fully intact and blocking.
28
+ */
29
+ export async function confirmStackRequalifications(findings, maxConfirmations, confirmOne, debug = () => { }) {
30
+ // One candidate per requalified finding, in first-seen order. The key pairs the
31
+ // citation with the finding's fingerprint so only true duplicates share a verdict.
32
+ const candidateKey = (finding, prNumber, file) => `${manifestKey(prNumber, file)}|${fingerprintFinding(finding)}`;
33
+ const candidates = new Map();
34
+ for (const finding of findings) {
35
+ const requalified = finding.requalifiedBy;
36
+ if (!requalified) {
37
+ continue;
38
+ }
39
+ const key = candidateKey(finding, requalified.prNumber, requalified.file);
40
+ if (!candidates.has(key)) {
41
+ candidates.set(key, { prNumber: requalified.prNumber, file: requalified.file, finding });
42
+ }
43
+ }
44
+ const keys = [...candidates.keys()];
45
+ const withinCap = new Set(keys.slice(0, maxConfirmations));
46
+ const overflow = keys.slice(maxConfirmations);
47
+ // Per-candidate strip reason, for the run-log audit trail the caller persists.
48
+ const stripReasons = new Map();
49
+ for (const key of overflow) {
50
+ stripReasons.set(key, `over maxConfirmations=${maxConfirmations} — stripped unconfirmed`);
51
+ }
52
+ if (overflow.length > 0) {
53
+ debug(`Stack: ${overflow.length} requalification(s) over maxConfirmations=${maxConfirmations} — stripped unconfirmed.`);
54
+ }
55
+ // Confirm the within-cap candidates in parallel; each call is independently guarded.
56
+ const addressed = new Set();
57
+ let cost = 0;
58
+ let model;
59
+ const tokens = {};
60
+ await Promise.all([...withinCap].map(async (key) => {
61
+ const candidate = candidates.get(key);
62
+ try {
63
+ const result = await confirmOne(candidate);
64
+ cost += result.cost;
65
+ addTokenUsage(tokens, result.tokens);
66
+ model = result.model ?? model;
67
+ if (result.addressed) {
68
+ addressed.add(key);
69
+ }
70
+ else {
71
+ stripReasons.set(key, "patch does not address it");
72
+ debug(`Stack: stripped requalification on "${candidate.finding.file}" (patch does not address it).`);
73
+ }
74
+ }
75
+ catch (error) {
76
+ // Fail toward blocking: a fetch/verify error keeps the finding blocking.
77
+ stripReasons.set(key, `confirmation failed: ${errorMessage(error)}`);
78
+ debug(`Stack: stripped requalification on "${candidate.finding.file}" (confirmation failed: ${errorMessage(error)}).`);
79
+ }
80
+ }));
81
+ let stripped = 0;
82
+ const strippedFindings = [];
83
+ const out = findings.map((finding) => {
84
+ const requalified = finding.requalifiedBy;
85
+ if (!requalified) {
86
+ return finding;
87
+ }
88
+ const key = candidateKey(finding, requalified.prNumber, requalified.file);
89
+ if (addressed.has(key)) {
90
+ return finding;
91
+ }
92
+ stripped++;
93
+ const { requalifiedBy: _dropped, ...rest } = finding;
94
+ strippedFindings.push({ finding: rest, reason: stripReasons.get(key) ?? "not confirmed" });
95
+ return rest;
96
+ });
97
+ return { findings: out, stripped, strippedFindings, cost, tokens, model };
98
+ }
99
+ // @ref LLP 0010#patch-level-confirmation-v2 [implements] — real confirmer: fetch just the cited file's patch (fail-open null → strip), inline it into the no-tools stack verifier
100
+ /**
101
+ * Build the real per-candidate confirmer: fetch just the cited file's patch from the
102
+ * addressing PR (the source fails open to `null` → treated as "not addressed"), inline
103
+ * it into the no-tools stack verifier, and require `addressed: true`. The patch is
104
+ * inlined, never materialized — there is no disk read and no tool use at all.
105
+ */
106
+ export function patchConfirmer(handle, source) {
107
+ // Per-run patch memo: distinct findings citing the same (prNumber, file) each get
108
+ // their own verdict but share one gh fetch. The source fails open to null (never
109
+ // rejects), so memoizing the promise is safe.
110
+ const patches = new Map();
111
+ return async ({ prNumber, file, finding }) => {
112
+ const patchKey = manifestKey(prNumber, file);
113
+ let patchPromise = patches.get(patchKey);
114
+ if (!patchPromise) {
115
+ patchPromise = source.getStackFilePatchAsync
116
+ ? source.getStackFilePatchAsync(prNumber, file)
117
+ : Promise.resolve(null);
118
+ patches.set(patchKey, patchPromise);
119
+ }
120
+ const patch = await patchPromise;
121
+ if (patch == null) {
122
+ // No patch for the cited file (or the source can't fetch it): not confirmable.
123
+ return { addressed: false, cost: 0, tokens: {} };
124
+ }
125
+ const { value, cost, tokens, model } = await promptAndParse(handle, {
126
+ agent: STACK_VERIFIER_AGENT,
127
+ system: buildStackVerifierSystem(),
128
+ text: buildStackVerifierTask(finding, prNumber, patch),
129
+ title: `stack-verify-${prNumber}`,
130
+ maxWaitMs: STACK_CONFIRM_TIMEOUT_MS,
131
+ // A timeout here throws AgentTimeoutError, which the caller catches and STRIPS
132
+ // (fail toward blocking) — so no finalize salvage, unlike the main verifier.
133
+ finalizeOnTimeout: false,
134
+ }, parseStackVerdict);
135
+ return { addressed: value.addressed === true, cost, tokens, model };
136
+ };
137
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Repo-root-relative normalization for exact manifest membership: strip a leading
3
+ * `./` or `/`, trim. No substrings, no basenames — the unsound-matching critique
4
+ * (`a.ts` must not match `data.ts`). Grounding and confirmation both key on this.
5
+ */
6
+ export function normalizeManifestPath(file) {
7
+ return file
8
+ .trim()
9
+ .replace(/^\.\/+/, "")
10
+ .replace(/^\/+/, "");
11
+ }
12
+ /** Membership/dedupe key for a cited `(prNumber, file)`. */
13
+ export function manifestKey(prNumber, file) {
14
+ return `${prNumber} ${normalizeManifestPath(file)}`;
15
+ }
16
+ /** The set of every `(prNumber, file)` the manifest actually lists. */
17
+ export function buildManifestMembership(manifest) {
18
+ const members = new Set();
19
+ for (const pr of manifest.upstackPRs) {
20
+ for (const file of pr.files) {
21
+ members.add(manifestKey(pr.number, file));
22
+ }
23
+ }
24
+ return members;
25
+ }
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0002#run-log-and-observability-sinks [implements] — swallows append errors; observability must never break a review
1
2
  import { appendFile } from "node:fs/promises";
2
3
  /**
3
4
  * Append a markdown section to the GitHub Actions step summary, so a run's
@@ -1,6 +1,8 @@
1
+ // @ref LLP 0005#inline-suppression-backstop — deterministic backstop for expo-code-review-ignore; used to be prompt-only
1
2
  import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  const DIRECTIVE = "expo-code-review-ignore";
5
+ // @ref LLP 0005#inline-suppression-backstop [constrained-by] — critical/secrets findings are never suppressed this way
4
6
  /**
5
7
  * Deterministic backstop for the inline `expo-code-review-ignore` directive (which
6
8
  * was previously prompt-only, i.e. honored only if the model chose to). Drops a
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0003#retry-taxonomy [implements] — rate-limit evidence: explicit 429 log lines are hard signal; stall + recent evidence means wait, not retry
1
2
  import { open } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -46,6 +47,7 @@ const EVIDENCE_WINDOW_MS = 5 * 60 * 1000;
46
47
  * is the signal the stall path consults. Fails soft everywhere: a missing or
47
48
  * unreadable log yields "no evidence", never an error.
48
49
  */
50
+ // @ref LLP 0003#retry-taxonomy [implements] — fails soft (missing/unreadable log = no evidence, never an error); scans only ERROR lines so a chatty INFO "retry" mention doesn't count
49
51
  export class RateLimitWatch {
50
52
  file;
51
53
  /** Total rate-limit ERROR lines seen this run. */
@@ -56,6 +58,16 @@ export class RateLimitWatch {
56
58
  constructor(file = opencodeLogFile()) {
57
59
  this.file = file;
58
60
  }
61
+ /**
62
+ * Record rate-limit evidence directly, for an engine that has no log file to
63
+ * scan (the Claude Code CLI surfaces limits per-invocation, not in a log the
64
+ * way the OpenCode server does). Feeds the same `events`/`recentlyLimited`
65
+ * signals `check()` would.
66
+ */
67
+ note(count = 1) {
68
+ this.events += count;
69
+ this.lastSeenAt = Date.now();
70
+ }
59
71
  /** Scan newly-appended log lines for rate-limit evidence. */
60
72
  async check() {
61
73
  try {
@@ -5,6 +5,24 @@ export function sleep(ms) {
5
5
  export function errorMessage(error) {
6
6
  return error instanceof Error ? error.message : String(error);
7
7
  }
8
+ /**
9
+ * A failure reason safe to post into a PUBLIC PR comment (see `ecr ci`'s failure
10
+ * notices). `run` (core/exec.ts) throws "Command failed: <cmd> <argv>\n<stderr>" (and
11
+ * "Command output exceeded …"), which embeds a subprocess's full command line, its
12
+ * stderr, and absolute runner paths — CI-internal detail of no use to a PR author and
13
+ * exactly the kind of thing that must not leak into an attacker-visible artifact. Those
14
+ * shapes collapse to a generic pointer at the workflow log; our own (argv/path-free)
15
+ * error messages pass through, since they are the actionable ones the fail-fast design
16
+ * means to surface. Every call site still writes the FULL reason to the job's stderr.
17
+ */
18
+ // @ref LLP 0002#run-log-and-observability-sinks [implements] — subprocess argv/stderr must never leak into PR-facing comments
19
+ export function publicFailureReason(error) {
20
+ const message = errorMessage(error);
21
+ if (/^Command (failed|output exceeded)\b/.test(message)) {
22
+ return "a subprocess (gh, git, or the model CLI) failed — see the workflow logs for details";
23
+ }
24
+ return message;
25
+ }
8
26
  /** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
9
27
  export function normalizeCode(text) {
10
28
  return text.replace(/\s+/g, " ").trim().toLowerCase();
@@ -1,5 +1,8 @@
1
+ // @ref LLP 0005#evidence-grounding-escalate-never-hard-drop
2
+ // @ref LLP 0005#verifier-confinement-and-fail-open
1
3
  import { readFile } from "node:fs/promises";
2
4
  import path from "node:path";
5
+ import { pathInside } from "./exec.js";
3
6
  import { parseVerdict } from "./schema.js";
4
7
  import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
5
8
  import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
@@ -9,6 +12,7 @@ import { errorMessage, normalizeCode } from "./util.js";
9
12
  const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
10
13
  // Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
11
14
  const MIN_EVIDENCE_LEN = 12;
15
+ // @ref LLP 0005#evidence-grounding-escalate-never-hard-drop [implements] — exact-substring is a good positive but poor negative signal (33a970a revert)
12
16
  /**
13
17
  * Break `evidence` into normalized, substantive fragments for fuzzy matching:
14
18
  * split on newlines AND ellipses (the model often elides with `…`/`...`), strip
@@ -48,17 +52,30 @@ export function matchEvidence(evidence, content) {
48
52
  }
49
53
  return fragments.some((fragment) => normContent.includes(fragment)) ? "present" : "absent";
50
54
  }
55
+ // @ref LLP 0005#verifier-confinement-and-fail-open [implements] — pathInside gate: out-of-tree reads (and their present/absent verdict) refused
51
56
  /** Read the cited file and grade the evidence against it (see matchEvidence). */
52
57
  async function evidencePresence(finding, cwd) {
58
+ // finding.file is an unconstrained, LLM-authored string produced over untrusted PR
59
+ // content, so a prompt-injected finding could point it at a host secret. path.resolve
60
+ // IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
61
+ // and `..` segments escape upward — either would make this raw readFile reach outside
62
+ // the reviewed tree with the host user's privileges, and the present/absent grading
63
+ // would leak a content-oracle back into the review. Confine the read to cwd (the
64
+ // materialized PR-head tree); anything outside is uncheckable, never read.
65
+ const resolved = path.resolve(cwd, finding.file);
66
+ if (!pathInside(resolved, cwd)) {
67
+ return "unknown";
68
+ }
53
69
  let content;
54
70
  try {
55
- content = await readFile(path.resolve(cwd, finding.file), "utf8");
71
+ content = await readFile(resolved, "utf8");
56
72
  }
57
73
  catch {
58
74
  return "unknown";
59
75
  }
60
76
  return matchEvidence(finding.evidence ?? "", content);
61
77
  }
78
+ // @ref LLP 0005#verifier-confinement-and-fail-open [constrained-by] — fails open: a verify error/timeout keeps the finding, never drops it
62
79
  /**
63
80
  * Guard against hallucinated findings before they're surfaced, WITHOUT silently
64
81
  * dropping real ones on an imperfect quote: