@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
@@ -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.9.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
  ---
@@ -38,7 +39,10 @@ must support `--non-interactive` the way sibling commands do (a non-interactive
38
39
  path with no prompts, erroring clearly when a required value is missing), and it
39
40
  must expose flags to supply every prompted value so the command stays scriptable.
40
41
 
41
- <!-- TODO: replace the example above with this repo's most important conventions. -->
42
+ <!-- TODO: replace the example above with this repo's most important conventions.
43
+ Pin each file, directory, or symbol you cite with a ref on its own line —
44
+ @ref <path/to/file.ts>#<symbol> — why this matters
45
+ so `ecr ref-check` fails when the cited code moves. Never a line number. -->
42
46
 
43
47
  ## What NOT to flag
44
48
 
@@ -49,5 +53,6 @@ must expose flags to supply every prompted value so the command stays scriptable
49
53
  - A "pattern" you saw only once — you need multiple existing examples to call
50
54
  something an established convention.
51
55
 
56
+ <!-- @ref LLP 0009#prompt-rules-for-adopters [implements] — precedent requirement is this agent's form of restraint -->
52
57
  Only flag when you can name the existing sibling(s) that establish the pattern and
53
58
  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
  ---
@@ -18,7 +19,13 @@ issues in the changed code.
18
19
  concrete trigger.
19
20
 
20
21
  <!-- TODO: customize for this repo — add project-specific correctness rules,
21
- e.g. framework conventions, required flag handling, API compatibility. -->
22
+ e.g. framework conventions, required flag handling, API compatibility.
23
+
24
+ Cite real code, and pin every citation with a ref so `ecr ref-check` fails when
25
+ it moves. In a comment of its own, on one line:
26
+ @ref <path/to/file.ts>#<symbol> — why this matters
27
+ Targets: a file, a `<dir>/`, `glob:<pattern>`, `<file>#<symbol>`, `<doc>.md#<heading>`.
28
+ Never a line number. Not a path? `@ref-ignore <token>`. -->
22
29
 
23
30
  ## What NOT to flag
24
31
 
@@ -29,4 +36,5 @@ issues in the changed code.
29
36
  - Nitpicks about naming or idiom when the existing convention is being followed.
30
37
  - Anything a type-checker or linter would already catch.
31
38
 
39
+ <!-- @ref LLP 0009#prompt-rules-for-adopters [implements] -->
32
40
  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
 
@@ -24,7 +26,14 @@ average severity.
24
26
  - Insecure file permissions, or writing secrets to world-readable paths.
25
27
 
26
28
  <!-- TODO: customize for this repo — name the sensitive surfaces specific to this
27
- codebase (credential stores, tokens, arbitrary-command features, etc.). -->
29
+ codebase (credential stores, tokens, arbitrary-command features, etc.).
30
+
31
+ Pin every path or symbol you cite with a ref, on a line of its own:
32
+ @ref <path/to/file.ts>#<symbol> — why this matters
33
+ `ecr ref-check` then fails when that code moves, so this prompt never sends a
34
+ reviewer after a file that no longer exists. Never cite a line number. -->
35
+
36
+ <!-- @ref glob:.github/workflows/** — the workflows this section judges -->
28
37
 
29
38
  ## CI / workflow supply-chain (changes under `.github/workflows/**`)
30
39
 
@@ -36,6 +45,7 @@ the code. Flag:
36
45
  ref) and also exposes secrets or a write-scoped `GITHUB_TOKEN` in that job's
37
46
  environment is a secret-exfiltration RCE — the attacker controls build scripts,
38
47
  source, and install-time lifecycle hooks.
48
+ <!-- @ref LLP 0009#workflow-security-posture [explains] — quoted verbatim in the guide's workflow security posture -->
39
49
  - **Trigger fork semantics.** `pull_request` from a fork runs with secrets
40
50
  withheld and a read-only token; `issue_comment`, `workflow_run`, and
41
51
  `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: