@expo/code-review-cli 0.7.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 +118 -13
  2. package/build/cli.js +7 -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/review.js +191 -51
  9. package/build/commands/setup-auth.js +3 -0
  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 +92 -0
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +5 -1
  16. package/build/core/claude-code.js +12 -1
  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 +4 -0
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +22 -0
  24. package/build/core/prompts.js +311 -3
  25. package/build/core/render.js +255 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +290 -15
  28. package/build/core/schema.js +213 -2
  29. package/build/core/scrub.js +4 -0
  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 +2 -0
  35. package/build/core/util.js +1 -0
  36. package/build/core/verify.js +5 -0
  37. package/build/reporters/github.js +465 -31
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +272 -0
  40. package/build/sources/local-git.js +3 -0
  41. package/build/sources/source.js +35 -0
  42. package/package.json +2 -1
  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 +50 -1
  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 +96 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,13 +1,120 @@
1
+ // @ref LLP 0008#pr-head-materialization — PR HEAD is materialized as a detached worktree pinned to the immutable head OID, never a branch/ref name
1
2
  import { mkdtemp, rm } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { resolveTrustedTool, run } from "../core/exec.js";
5
6
  import { parseUnifiedDiff } from "../core/diff.js";
7
+ import { normalizeManifestPath } from "../core/stack.js";
6
8
  import { removeEscapingSymlinks, scrubAmbientRuntimeConfig } from "../core/scrub.js";
7
9
  /** A full 40-hex-char commit OID — the only ref form passed to security-sensitive git calls. */
10
+ // @ref LLP 0008#pr-head-materialization [constrained-by] — the single gate: only a full 40-hex OID reaches git worktree add/fetch, closing the TOCTOU race and blocking ref/argument injection at once
8
11
  export function isCommitOid(value) {
9
12
  return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
10
13
  }
14
+ // @ref LLP 0010#bounded-guarded-upward-walk [implements] — every guard (same-repo, same-author, depth/width caps, cycle guard, fail-open) lives here as pure logic; the IO is injected so it is unit-testable without gh
15
+ /**
16
+ * The bounded, guarded upward walk, factored pure over injected fetchers so it can be
17
+ * tested without gh. Level-by-level (BFS over head branches), it keeps only same-repo
18
+ * (and, when required, same-author) children, caps children per level at `maxPrs`,
19
+ * stops at `maxDepth`, and guards against branch cycles. ANY fetch error → `null`
20
+ * (fail-open); an empty result → `null` (nothing to inject).
21
+ */
22
+ export async function walkUpstack(rootHeadRef, rootAuthor, options, fetchChildren, fetchFiles) {
23
+ const upstackPRs = [];
24
+ let truncated = false;
25
+ const visited = new Set([rootHeadRef]);
26
+ let frontier = [rootHeadRef];
27
+ try {
28
+ for (let depth = 0; depth < options.maxDepth && frontier.length > 0; depth++) {
29
+ const next = [];
30
+ // maxPrs is a PER-LEVEL budget shared by every parent in the frontier, not a
31
+ // per-parent one — otherwise a branching (diamond) stack would widen each level
32
+ // to frontier.length × maxPrs and compound across depths, blowing the documented
33
+ // hard bound on walked PRs and gh calls.
34
+ let levelBudget = options.maxPrs;
35
+ for (const baseBranch of frontier) {
36
+ if (levelBudget <= 0) {
37
+ break;
38
+ }
39
+ const children = await fetchChildren(baseBranch);
40
+ const eligible = children
41
+ .filter((child) => child.sameRepo)
42
+ .filter((child) => !options.requireSameAuthor || child.authorLogin === rootAuthor)
43
+ .filter((child) => !visited.has(child.headRef))
44
+ .slice(0, levelBudget);
45
+ levelBudget -= eligible.length;
46
+ for (const child of eligible) {
47
+ visited.add(child.headRef);
48
+ const { files, truncated: capped } = await fetchFiles(child.number);
49
+ truncated = truncated || capped;
50
+ upstackPRs.push({
51
+ number: child.number,
52
+ title: child.title,
53
+ authorLogin: child.authorLogin,
54
+ files,
55
+ });
56
+ next.push(child.headRef);
57
+ }
58
+ }
59
+ frontier = next;
60
+ }
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ return upstackPRs.length > 0 ? { upstackPRs, truncated } : null;
66
+ }
67
+ /**
68
+ * Parse the NDJSON `{filename}` lines from the child-PR files endpoint into a clean
69
+ * path list. Any name carrying a control character (a git path may legally contain
70
+ * a newline) is dropped outright: split on raw lines it would have forged an extra
71
+ * manifest entry, and no legitimate reviewable path needs control characters. A
72
+ * malformed line throws — the walk's fail-open catch turns that into "no manifest".
73
+ * Exported for tests.
74
+ */
75
+ export function parseChildFileNdjson(stdout) {
76
+ return stdout
77
+ .split("\n")
78
+ .map((line) => line.trim())
79
+ .filter(Boolean)
80
+ .map((line) => JSON.parse(line).filename ?? "")
81
+ .filter((name) => name.length > 0 && ![...name].some((char) => char.charCodeAt(0) < 0x20));
82
+ }
83
+ /** Page size for the child-PR files endpoint (fetchAllComments convention). */
84
+ export const CHILD_FILES_PER_PAGE = 100;
85
+ /**
86
+ * Safety cap on pagination, same convention as the reporter's MAX_COMMENT_PAGES:
87
+ * 30 pages = 3000 files bounds a pathological child PR; virtually every real
88
+ * child PR exits far earlier via the maxFiles early stop below.
89
+ */
90
+ export const MAX_CHILD_FILE_PAGES = 30;
91
+ /**
92
+ * Collect a child PR's file pages, factored pure over an injected per-page fetcher
93
+ * (same pattern as walkUpstack) so the stop conditions are testable without gh.
94
+ * Stops at end-of-list, one entry past `maxFiles` (enough to know the list is
95
+ * truncated — never paginates a huge child PR to the end just to throw the tail
96
+ * away), or the hard page ceiling. Exported for tests.
97
+ */
98
+ export async function collectChildFiles(fetchPage, maxFiles) {
99
+ const files = [];
100
+ let sawEnd = false;
101
+ for (let page = 1; page <= MAX_CHILD_FILE_PAGES && !sawEnd; page++) {
102
+ const stdout = await fetchPage(page);
103
+ // "Last page?" is decided on the RAW line count (jq emits exactly one NDJSON
104
+ // line per array element): parseChildFileNdjson drops control-char names, so
105
+ // deciding on its output would mistake a page with a dropped entry for the
106
+ // final page and silently skip the rest of the list.
107
+ const rawCount = stdout.split("\n").filter((line) => line.trim()).length;
108
+ files.push(...parseChildFileNdjson(stdout));
109
+ sawEnd = rawCount < CHILD_FILES_PER_PAGE;
110
+ if (files.length > maxFiles) {
111
+ break;
112
+ }
113
+ }
114
+ // Hitting the page ceiling without seeing the end of the list still marks the
115
+ // manifest as a subset — never claim completeness that wasn't observed.
116
+ return { files: files.slice(0, maxFiles), truncated: files.length > maxFiles || !sawEnd };
117
+ }
11
118
  /**
12
119
  * Append gh as a git credential helper for a single command. The token comes from
13
120
  * GH_TOKEN via the credential-helper protocol — never argv, never `.git/config` —
@@ -130,6 +237,7 @@ export class GitHubPRSource {
130
237
  * Materialization FAILURES throw — the caller decides per mode whether that is
131
238
  * fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
132
239
  */
240
+ // @ref LLP 0008#pr-head-materialization [implements] — a half-scrubbed tree must never be returned; a scrub failure tears down the worktree and rethrows instead of handing back a partial scrub
133
241
  async prepareReadRootAsync() {
134
242
  if (!this.options.repo) {
135
243
  // Without an explicit owner/repo we can't build the fetch URL safely.
@@ -151,12 +259,176 @@ export class GitHubPRSource {
151
259
  }
152
260
  return root;
153
261
  }
262
+ // @ref LLP 0010#bounded-guarded-upward-walk [constrained-by] — the whole method is wrapped fail-open: no gh/parse error ever escapes as a throw, so a broken walk can never fail a check or block a finding
263
+ /**
264
+ * Walk the OPEN PRs stacked on top of this one and return a paths-only manifest.
265
+ * Fails open to `null` on ANY error (no repo, gh failure, rate limit, parse error,
266
+ * empty stack), so the review is exactly as if the feature were off.
267
+ */
268
+ async getStackContextAsync(options) {
269
+ const repo = this.options.repo;
270
+ if (!repo) {
271
+ // Without an explicit owner/repo we can't query the pulls list safely.
272
+ return null;
273
+ }
274
+ try {
275
+ const gh = await resolveTrustedTool("gh");
276
+ const [metadata, anchors] = await Promise.all([
277
+ this.getMetadata(),
278
+ this.fetchPrTrustAnchors(gh, repo),
279
+ ]);
280
+ if (!metadata.headRef || !anchors.author) {
281
+ return null;
282
+ }
283
+ // A fork PR's headRefName is a branch of the FORK, not of the base repo. Using
284
+ // it as the pulls-list `base=` filter would match a same-named BASE-repo branch
285
+ // (a fork head called "main" would pull in every open PR targeting main), so
286
+ // unrelated PRs would enter the manifest. A cross-repo head has no base-repo
287
+ // branch to walk — there is no stack.
288
+ if (anchors.crossRepo) {
289
+ return null;
290
+ }
291
+ return await walkUpstack(metadata.headRef, anchors.author, options, (baseBranch) => this.fetchOpenChildren(gh, repo, baseBranch), (prNumber) => this.fetchChildFiles(gh, repo, prNumber, options.maxFilesPerPr));
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ // @ref LLP 0010#patch-level-confirmation-v2 [implements] — fetch ONLY the cited file's patch, match by the same normalization grounding uses; the untrusted filename is filtered in JS, never spliced into a jq program
298
+ /**
299
+ * The unified-diff patch a stacked child PR (`prNumber`) applied to `file`, or `null`
300
+ * when the file isn't in that PR (or on any error — fail-open toward blocking). The
301
+ * files endpoint's `.patch` fragment is inlined by the caller and never written to
302
+ * disk. The untrusted `file` is matched against each entry's filename in JS (same
303
+ * normalization as grounding), NOT passed into the jq program, so it can't inject.
304
+ */
305
+ async getStackFilePatchAsync(prNumber, file) {
306
+ const repo = this.options.repo;
307
+ if (!repo) {
308
+ return null;
309
+ }
310
+ try {
311
+ const gh = await resolveTrustedTool("gh");
312
+ const { stdout } = await run(gh, [
313
+ "api",
314
+ // --method GET is mandatory once a -f field is present (else gh POSTs);
315
+ // 100/page is the fetchAllComments pagination convention.
316
+ "--method",
317
+ "GET",
318
+ `repos/${repo}/pulls/${prNumber}/files`,
319
+ "-f",
320
+ "per_page=100",
321
+ "--paginate",
322
+ "--jq",
323
+ ".[] | {filename, patch}",
324
+ ], { cwd: this.options.cwd });
325
+ const want = normalizeManifestPath(file);
326
+ for (const line of stdout.split("\n")) {
327
+ const trimmed = line.trim();
328
+ if (!trimmed) {
329
+ continue;
330
+ }
331
+ const raw = JSON.parse(trimmed);
332
+ if (raw.filename && normalizeManifestPath(raw.filename) === want) {
333
+ // A file with no textual patch (binary/rename-only) can't confirm a fix.
334
+ return typeof raw.patch === "string" ? raw.patch : null;
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ }
343
+ /**
344
+ * The current PR's author login (the same-author gate's trust anchor) and whether
345
+ * its head lives in another repository (a fork — see the cross-repo guard above).
346
+ */
347
+ async fetchPrTrustAnchors(gh, repo) {
348
+ const { stdout } = await run(gh, [
349
+ "pr",
350
+ "view",
351
+ String(this.options.prNumber),
352
+ "--repo",
353
+ repo,
354
+ "--json",
355
+ "author,isCrossRepository",
356
+ ], { cwd: this.options.cwd });
357
+ const raw = JSON.parse(stdout);
358
+ const author = raw.author?.login?.trim();
359
+ return { author: author || null, crossRepo: raw.isCrossRepository === true };
360
+ }
361
+ /** Open PRs whose base branch is `baseBranch` (this PR's head, or a child's head). */
362
+ async fetchOpenChildren(gh, repo, baseBranch) {
363
+ // --method GET is mandatory (else gh POSTs); --paginate + a per-element --jq
364
+ // yields NDJSON that stays valid when gh concatenates pages.
365
+ const { stdout } = await run(gh, [
366
+ "api",
367
+ "--method",
368
+ "GET",
369
+ `repos/${repo}/pulls`,
370
+ "-f",
371
+ "state=open",
372
+ "-f",
373
+ `base=${baseBranch}`,
374
+ // Safety cap on pagination (100/page), same convention as fetchAllComments:
375
+ // fewer round-trips per level of the walk.
376
+ "-f",
377
+ "per_page=100",
378
+ "--paginate",
379
+ "--jq",
380
+ ".[] | {number, title, authorLogin: .user.login, headRef: .head.ref, headRepoFullName: .head.repo.full_name}",
381
+ ], { cwd: this.options.cwd });
382
+ return stdout
383
+ .split("\n")
384
+ .map((line) => line.trim())
385
+ .filter(Boolean)
386
+ .map((line) => {
387
+ const raw = JSON.parse(line);
388
+ return {
389
+ number: raw.number,
390
+ title: raw.title ?? "",
391
+ authorLogin: raw.authorLogin ?? "",
392
+ headRef: raw.headRef ?? "",
393
+ sameRepo: raw.headRepoFullName === repo,
394
+ };
395
+ });
396
+ }
397
+ /**
398
+ * A child PR's changed paths, capped at `maxFiles` with a truncated marker.
399
+ * Pages are fetched one by one (not `--paginate`, which would walk a large
400
+ * child PR's whole file list before the client-side cap applies) so pagination
401
+ * stops as soon as the cap is exceeded, and never past MAX_CHILD_FILE_PAGES.
402
+ */
403
+ async fetchChildFiles(gh, repo, prNumber, maxFiles) {
404
+ return collectChildFiles(async (page) => {
405
+ const { stdout } = await run(gh, [
406
+ "api",
407
+ // --method GET is mandatory once a -f field is present (else gh POSTs).
408
+ "--method",
409
+ "GET",
410
+ `repos/${repo}/pulls/${prNumber}/files`,
411
+ "-f",
412
+ `per_page=${CHILD_FILES_PER_PAGE}`,
413
+ "-f",
414
+ `page=${page}`,
415
+ "--jq",
416
+ // Objects, NOT raw strings (.[].filename): gh prints a raw string result
417
+ // one value per line, so a git path containing a newline would split into
418
+ // TWO manifest entries — one of them a forged membership the grounding
419
+ // check would then accept. NDJSON keeps the newline escaped.
420
+ ".[] | {filename}",
421
+ ], { cwd: this.options.cwd });
422
+ return stdout;
423
+ }, maxFiles);
424
+ }
154
425
  /**
155
426
  * Materialize the PR's BASE commit as the trusted configuration root: review
156
427
  * policy, prompts, routing, and auth mapping load from here, so a PR cannot
157
428
  * change the reviewer that evaluates it (config changes activate on merge).
158
429
  * Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
159
430
  */
431
+ // @ref LLP 0008#the-trusted-base-root [implements] — materializes the PR BASE (not HEAD) as a separate, unscrubbed worktree; a PR cannot change the reviewer that evaluates it
160
432
  async prepareTrustedConfigRootAsync() {
161
433
  const metadata = await this.getMetadata();
162
434
  if (!isCommitOid(metadata.baseOid)) {
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0008#local-git-source — no network; diff base is merge-base(defaultBranch, HEAD), not the branch tip
1
2
  import { git, resolveTrustedTool, run } from "../core/exec.js";
2
3
  import { parseUnifiedDiff } from "../core/diff.js";
3
4
  /**
@@ -36,6 +37,7 @@ export class LocalGitSource {
36
37
  }
37
38
  return "main";
38
39
  }
40
+ // @ref LLP 0008#local-git-source [implements] — merge-base(default, HEAD), not the branch tip: reviews only the current branch's own changes, excluding commits merged into default after divergence
39
41
  async resolveBase() {
40
42
  if (this.resolvedBase) {
41
43
  return this.resolvedBase;
@@ -87,6 +89,7 @@ export class LocalGitSource {
87
89
  * Synthesize add-diffs for untracked files without mutating the index. Uses
88
90
  * `git diff --no-index` (which exits 1 when files differ, hence check: false).
89
91
  */
92
+ // @ref LLP 0008#local-git-source [implements] — no-index diff against /dev/null avoids mutating the index; -z listing and the -- separator guard against newline- and dash-prefixed filenames
90
93
  async untrackedDiffs() {
91
94
  // -z: null-terminated output so filenames containing newlines parse correctly.
92
95
  const listing = await git(["ls-files", "-z", "--others", "--exclude-standard"], this.cwd);
@@ -1,3 +1,18 @@
1
+ /** Pick the walk bounds out of a resolved stack config (drops enable/v2 fields). */
2
+ export function stackWalkFromConfig(stack) {
3
+ return {
4
+ maxDepth: stack.maxDepth,
5
+ maxPrs: stack.maxPrs,
6
+ maxFilesPerPr: stack.maxFilesPerPr,
7
+ requireSameAuthor: stack.requireSameAuthor,
8
+ };
9
+ }
10
+ /** The v2 confirmation cap, or undefined when confirmWithPatch is off. Callers apply
11
+ * the on/off gate (ci: trusted-base `enabled`; review: `--stack-aware`) around this. */
12
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — confirmWithPatch is the v2 gate; off (default) → grounding is the floor and no patch is ever fetched
13
+ export function stackConfirmFromConfig(stack) {
14
+ return stack.confirmWithPatch ? { maxConfirmations: stack.maxConfirmations } : undefined;
15
+ }
1
16
  /**
2
17
  * Wrap a source so getMetadata/getChangedFiles/prepareReadRootAsync each run once
3
18
  * and are shared across N sequential runReview calls (one `gh pr diff`, one
@@ -5,14 +20,34 @@
5
20
  * prepareReadRootAsync hands each run a handle whose cleanup() is a no-op; the real
6
21
  * cleanup is deferred to dispose(), which must be called once after the last scope.
7
22
  */
23
+ // @ref LLP 0008#the-reviewsource-contract [implements] — one fetch/worktree shared across N scope runs; dispose() must run once, after the last scope, not per call
8
24
  export function memoizeSource(source) {
9
25
  let metadataPromise;
10
26
  let changedPromise;
11
27
  let readRootPromise;
28
+ let stackPromise;
12
29
  let realHandle = null;
30
+ const stackFn = source.getStackContextAsync;
31
+ const patchFn = source.getStackFilePatchAsync;
13
32
  return {
14
33
  getMetadata: () => (metadataPromise ??= source.getMetadata()),
15
34
  getChangedFiles: () => (changedPromise ??= source.getChangedFiles()),
35
+ // One PR has one stack: fetch it once and share it across every scope's run
36
+ // (like getMetadata). Only exposed when the wrapped source can walk a stack, so
37
+ // an optional-chained call on a stack-less source stays a structural no-op.
38
+ ...(stackFn
39
+ ? {
40
+ getStackContextAsync: (options) => (stackPromise ??= stackFn.call(source, options)),
41
+ }
42
+ : {}),
43
+ // Passed straight through: v2 confirmation dedupes by (prNumber, file) within a run
44
+ // and the fetch is cheap, so it needs no cross-scope memo — only the conditional
45
+ // exposure that keeps a stack-less source a structural no-op.
46
+ ...(patchFn
47
+ ? {
48
+ getStackFilePatchAsync: (prNumber, file) => patchFn.call(source, prNumber, file),
49
+ }
50
+ : {}),
16
51
  prepareReadRootAsync: async () => {
17
52
  readRootPromise ??= source.prepareReadRootAsync
18
53
  ? source.prepareReadRootAsync()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "lint": "oxlint src",
31
31
  "fmt": "oxfmt --threads=1 src",
32
32
  "fmt:check": "oxfmt --threads=1 --check src",
33
+ "llp:check": "./ref-check",
33
34
  "dev": "bun run src/cli.ts",
34
35
  "test:unit": "bun test",
35
36
  "release": "bash scripts/release.sh",
@@ -1,3 +1,4 @@
1
+ <!-- @ref LLP 0009#prompt-rules-for-adopters — restraint is a cross-cutting constraint, echoed from shared.md -->
1
2
  ---
2
3
  description: Consistency with the repo's existing patterns and conventions for the same kind of change (flags, error messages and types, structure).
3
4
  ---
@@ -49,5 +50,6 @@ must expose flags to supply every prompted value so the command stays scriptable
49
50
  - A "pattern" you saw only once — you need multiple existing examples to call
50
51
  something an established convention.
51
52
 
53
+ <!-- @ref LLP 0009#prompt-rules-for-adopters [implements] — precedent requirement is this agent's form of restraint -->
52
54
  Only flag when you can name the existing sibling(s) that establish the pattern and
53
55
  say why matching it matters. If you can't point to the precedent, don't report it.
@@ -1,3 +1,4 @@
1
+ <!-- @ref LLP 0009#prompt-rules-for-adopters — restraint is a cross-cutting constraint, echoed from shared.md -->
1
2
  ---
2
3
  description: Logic, correctness, and code-quality bugs in the changed code (off-by-one, bad error handling, type-safety gaps, unsafe assumptions).
3
4
  ---
@@ -29,4 +30,5 @@ issues in the changed code.
29
30
  - Nitpicks about naming or idiom when the existing convention is being followed.
30
31
  - Anything a type-checker or linter would already catch.
31
32
 
33
+ <!-- @ref LLP 0009#prompt-rules-for-adopters [implements] -->
32
34
  Prefer zero findings over a low-value one.
@@ -1,3 +1,4 @@
1
+ <!-- @ref LLP 0009#workflow-security-posture — highest-stakes agent; author-association gates control who, not what -->
1
2
  ---
2
3
  description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
3
4
  alwaysRun: true
@@ -5,6 +6,7 @@ alwaysRun: true
5
6
  # reasoning, so it runs on the pro tier even though the other specialists use the
6
7
  # default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
7
8
  # subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
9
+ # @ref LLP 0009#config-and-prompt-templates [implements]
8
10
  model: openai/gpt-5.5-pro
9
11
  ---
10
12
 
@@ -36,6 +38,7 @@ the code. Flag:
36
38
  ref) and also exposes secrets or a write-scoped `GITHUB_TOKEN` in that job's
37
39
  environment is a secret-exfiltration RCE — the attacker controls build scripts,
38
40
  source, and install-time lifecycle hooks.
41
+ <!-- @ref LLP 0009#workflow-security-posture [explains] — quoted verbatim in the guide's workflow security posture -->
39
42
  - **Trigger fork semantics.** `pull_request` from a fork runs with secrets
40
43
  withheld and a read-only token; `issue_comment`, `workflow_run`, and
41
44
  `pull_request_target` are **NOT** fork-restricted. An `author_association` /
@@ -0,0 +1,123 @@
1
+ # @ref LLP 0009#atlantis-comment-triggered-template — issue_comment plan trigger; base-only checkout, comment body via env → --context-file
2
+ name: AI code review (atlantis plan)
3
+
4
+ # Runs the reviewer when Atlantis posts a `terraform plan` result on a PR, feeding
5
+ # the plan output into the review as UNTRUSTED external context (`--context-file`).
6
+ # This is an OPT-IN extra template — `ecr init` does NOT scaffold it. Copy it into
7
+ # .github/workflows/ and set two repo variables:
8
+ # ATLANTIS_BOT_LOGIN the Atlantis bot's comment author login (e.g. atlantis-app[bot])
9
+ # ATLANTIS_PLAN_MARKER optional; the plan-comment marker text (default 'Ran Plan for')
10
+
11
+ on:
12
+ issue_comment:
13
+ types: [created]
14
+
15
+ # Comment-only: read the repo, write PR comments (issue comments API).
16
+ permissions:
17
+ contents: read
18
+ pull-requests: write
19
+ issues: write
20
+
21
+ env:
22
+ # Published reviewer run via npx (override with repo variable ECR_VERSION; pin to a
23
+ # specific version to freeze it). Used for the guard AND the review so the engine
24
+ # that clears a config is the same engine that then reads it.
25
+ ECR_VERSION: ${{ vars.ECR_VERSION || 'latest' }}
26
+
27
+ concurrency:
28
+ group: ai-code-review-atlantis-${{ github.event.issue.number }}
29
+ cancel-in-progress: true
30
+
31
+ jobs:
32
+ atlantis-plan:
33
+ # Gate on WHO commented, not on what the run does: only a comment from the
34
+ # Atlantis bot login, on a PR, whose body carries the plan marker. The login
35
+ # gate is the security boundary (bot comments are write-gated); the marker is a
36
+ # cheap filter and is operator-overridable via ATLANTIS_PLAN_MARKER, so it must
37
+ # not be the only gate. Templates are also overridable via
38
+ # --markdown-template-overrides-dir, which is exactly why the identity gate is on
39
+ # the bot login and not on the marker text.
40
+ # @ref LLP 0009#atlantis-comment-triggered-template [implements] — identity gate on the bot login; marker filter is operator-overridable
41
+ if: >-
42
+ github.event.issue.pull_request != null &&
43
+ github.event.comment.user.login == vars.ATLANTIS_BOT_LOGIN &&
44
+ contains(github.event.comment.body, vars.ATLANTIS_PLAN_MARKER || 'Ran Plan for')
45
+ runs-on: ubuntu-latest
46
+ # Keep margin over the worst-case internal chain (passes budget 55m + coordinator
47
+ # 10m + verification + setup), like the auto-review workflow's cap.
48
+ timeout-minutes: 90
49
+ # A reviewer failure must never fail the PR's checks.
50
+ continue-on-error: true
51
+ steps:
52
+ # The plan body is UNTRUSTED. Pass it in via env: (never inline ${{ }}) and
53
+ # write it to a file with printf so it can never inject shell; export the path
54
+ # for the review step. The reviewer reads it as --context-file (untrusted data).
55
+ # @ref LLP 0009#workflow-security-posture [implements] — comment body only via env:, never inline ${{ }}
56
+ - name: Write plan to a file
57
+ env:
58
+ COMMENT: ${{ github.event.comment.body }}
59
+ run: |
60
+ printf '%s' "$COMMENT" > "$RUNNER_TEMP/atlantis-plan.txt"
61
+ echo "PLAN_FILE=$RUNNER_TEMP/atlantis-plan.txt" >> "$GITHUB_ENV"
62
+
63
+ # SECURITY: `issue_comment` is NOT fork-restricted — it runs in the base-repo
64
+ # context with full secrets regardless of whether the PR is a fork. Check out
65
+ # ONLY the trusted base ref (the default branch) for the `.expo-code-review/`
66
+ # config, and never `gh pr checkout` the PR head. The engine is the PUBLISHED
67
+ # package via npx, not built from any checkout, so PR code never runs here.
68
+ - name: Checkout (base ref only — never the PR head)
69
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
70
+ with:
71
+ fetch-depth: 1
72
+ # The CLI's own git fetches authenticate through `gh` from GH_TOKEN, so the
73
+ # token never lands in .git/config.
74
+ persist-credentials: false
75
+
76
+ - name: Set up Node
77
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
78
+ with:
79
+ node-version: 24
80
+ # The reviewer runs via npx and never installs with a package manager, so
81
+ # disable setup-node's auto package-manager cache.
82
+ package-manager-cache: false
83
+
84
+ # SECURITY: the base-ref checkout includes every .expo-code-review/ config,
85
+ # whose auth.tokenEnv names the forwarded model credential. `ecr verify-config`
86
+ # sweeps every config and refuses unless tokenEnv appears exactly once, in a
87
+ # ROOT-owned file, equal to ECR_EXPECTED_TOKEN_ENV. This is layer 2; layer 1 is
88
+ # the runtime lock in `ecr ci`. Runs the SAME $ECR_VERSION `ecr ci` will.
89
+ # @ref LLP 0009#guard-step-ordering-and-job-budgets [implements] — same $ECR_VERSION feeds guard and review
90
+ - name: Guard config tokenEnv (root + routing + all scopes)
91
+ env:
92
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
93
+ run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
94
+
95
+ # Running via `issue_comment` makes this a manual /review, which the CLI detects
96
+ # (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass, and it
97
+ # resolves the PR from issue.number. The bypass affects ONLY the trigger gate;
98
+ # the config guard above, break-glass, and the auth lock still apply.
99
+ - name: Run AI review
100
+ continue-on-error: true
101
+ env:
102
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103
+ # Layer-1 auth lock: keep in sync with the guard's EXPECTED.
104
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
105
+ # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
106
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
107
+ # Optional: override the model for every agent (uses your OpenCode login).
108
+ REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
109
+ run: |
110
+ # Build the flags as a bash array so the path never word-splits.
111
+ ARGS=(--context-file "$PLAN_FILE")
112
+ npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr ci "${ARGS[@]}"
113
+
114
+ # Same ephemeral per-run log as the other workflows; issue.number IS the PR
115
+ # number in issue_comment context.
116
+ - name: Upload review run log
117
+ if: always()
118
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
119
+ with:
120
+ name: review-run-log-pr${{ github.event.issue.number }}
121
+ path: .expo-code-review/.runs/reviews.jsonl
122
+ if-no-files-found: ignore
123
+ retention-days: 14
@@ -1,3 +1,4 @@
1
+ # @ref LLP 0009#workflow-security-posture — issue_comment is not fork-restricted; runs with full secrets regardless of PR origin
1
2
  name: AI code review (command)
2
3
 
3
4
  # On-demand, ONE-SHOT reviewer triggered by a PR comment (maintainers only):
@@ -31,6 +32,7 @@ concurrency:
31
32
  jobs:
32
33
  command:
33
34
  # Only PR comments starting with /review, from a maintainer.
35
+ # @ref LLP 0009#workflow-security-posture [implements] — gate controls who triggers, not what code runs
34
36
  if: >-
35
37
  github.event.issue.pull_request != null &&
36
38
  startsWith(github.event.comment.body, '/review') &&
@@ -43,6 +45,7 @@ jobs:
43
45
  # A reviewer failure must never fail the PR's checks.
44
46
  continue-on-error: true
45
47
  steps:
48
+ # @ref LLP 0009#workflow-security-posture [implements] — comment body only via env:; agent ids sanitized before reaching argv
46
49
  - name: Parse command
47
50
  id: cmd
48
51
  env:
@@ -118,6 +121,7 @@ jobs:
118
121
  # JSON-escaped key, or stage an unreferenced scope config with its own auth.
119
122
  # This is layer 2; layer 1 is the runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci`.
120
123
  # Runs after Set up Node so the guard runs the SAME $ECR_VERSION `ecr ci` will.
124
+ # @ref LLP 0009#guard-step-ordering-and-job-budgets [implements] — same $ECR_VERSION feeds guard and review
121
125
  - name: Guard config tokenEnv (root + routing + all scopes)
122
126
  if: steps.cmd.outputs.run == 'true'
123
127
  env:
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0009#config-and-prompt-templates — root config: agent roster by filename, phase-1 defaults, auth
1
2
  {
2
3
  // Default model for every agent. Override per-agent via frontmatter in the
3
4
  // agent's markdown, or at runtime with the REVIEWER_MODEL env var
@@ -10,6 +11,7 @@
10
11
  // reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
11
12
  // e.g. `---\nmodel: openai/gpt-5.5-pro\n---`.
12
13
 
14
+ // @ref LLP 0009#config-and-prompt-templates [implements] — suggestions off by default, not a schema limit
13
15
  "policy": {
14
16
  // Phase 1: keep signal high by surfacing only critical/warning.
15
17
  "includeSuggestions": false
@@ -87,5 +89,52 @@
87
89
  "mode": "api-key",
88
90
  "provider": "openai",
89
91
  "tokenEnv": "OPENAI_API_KEY"
90
- }
92
+ },
93
+
94
+ // Stack-aware requalification (ROOT-ONLY; off by default). When on, `ecr ci` walks
95
+ // the OPEN PRs stacked on top of this one and lets the coordinator mark an
96
+ // absence-style finding (a missing test/migration/doc) as addressed when a later
97
+ // stacked PR already adds it. Such findings are never dropped — they render in a
98
+ // collapsed "Addressed in stacked PRs" section, are counted in a visible audit line,
99
+ // and are only excluded from the blocking decision. Critical/secrets/security
100
+ // findings are never requalifiable. Loaded only from the trusted base commit, so a
101
+ // PR cannot enable, widen, or disable its own requalification.
102
+ // "stack": {
103
+ // "enabled": false, // turn the feature on
104
+ // "maxDepth": 4, // how many levels up the stack to walk
105
+ // "maxPrs": 8, // children per level to follow
106
+ // "maxFilesPerPr": 100, // per-child file-list cap
107
+ // "requireSameAuthor": true, // only children by this PR's author (anti-poisoning)
108
+ // "confirmWithPatch": false, // v2: confirm each requalification against the addressing PR's patch
109
+ // "maxConfirmations": 10 // v2: max patch confirmations per run (overflow is stripped)
110
+ // }
111
+
112
+ // Author feedback (ROOT-ONLY: the comment lifecycle is global). A PR author's
113
+ // reply is matched to the finding it answers (a quoted title and/or an
114
+ // `id:<fp>` token) and recorded in the comment's embedded state. This is ON
115
+ // by default even if you never touch this block, and deliberately ASYMMETRIC:
116
+ // `mode: "annotate"` marks a matched finding "author replied" with a link back
117
+ // to the comment — purely informational, no effect on the pass/fail decision.
118
+ // `dismiss: "never"` keeps it that way: no reply, and no model judgment, can
119
+ // remove a finding from the blocking set until you opt in below. This is
120
+ // deliberate — a repo that never edits this file still gets the useful,
121
+ // read-only behavior, never a surprise auto-dismissal.
122
+ // "mode": "off" | "annotate" | "adjudicate" — "adjudicate" additionally runs
123
+ // a model that re-checks the reply against the SOURCE (distrust by
124
+ // default, like the verifier) and records a verdict.
125
+ // "dismiss": "never" | "maintainers" | "adjudicated" — who/what may actually
126
+ // clear a finding: nothing, a maintainer's reply, or (with `adjudicate`) a
127
+ // maintainer reply OR an author reply the adjudicator confirmed.
128
+ // Clearing always needs the reply to cite the finding's `id:<fp>` token in the
129
+ // replier's OWN words: an id (or a title) inside a `>` quote only annotates,
130
+ // because "Quote reply" copies text the PR author wrote.
131
+ // Critical findings and `protectedCategories` can never be dismissed by a
132
+ // reply, whatever you set here — that floor is enforced in code, not here.
133
+ // "feedback": {
134
+ // "mode": "annotate",
135
+ // "match": "both", // "quote" | "id" | "both"
136
+ // "dismiss": "never",
137
+ // "protectedCategories": ["secrets", "security"],
138
+ // "maxAdjudications": 10 // cap on model calls per run
139
+ // }
91
140
  }