@expo/code-review-cli 0.11.0 → 0.11.1

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.
package/README.md CHANGED
@@ -71,6 +71,9 @@ ecr review
71
71
  ecr review --pr 123
72
72
  # …and post it as the PR comment
73
73
  ecr review --pr 123 --post
74
+ # Preview once, save the exact result, and post it later without another model run
75
+ ecr review --repo owner/repo --pr 123 --save-review --json
76
+ ecr post-review --artifact .expo-code-review/.runs/deferred/<artifact>.json --repo owner/repo --pr 123
74
77
  ```
75
78
 
76
79
  Options (most to least common):
@@ -79,6 +82,7 @@ Options (most to least common):
79
82
  | --- | --- |
80
83
  | `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
81
84
  | `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
85
+ | `--save-review` | With explicit `--repo` + `--pr`, save the exact preview as a private postable artifact. Mutually exclusive with `--post`. |
82
86
  | `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
83
87
  | `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
84
88
  | `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
@@ -111,6 +115,7 @@ is a ready example to adapt.
111
115
  | `ecr setup-auth [--yes]` | Walk through getting model credentials for local runs (ChatGPT/Claude sign-in and/or API keys), printing the `export` lines for your shell config. |
112
116
  | `ecr review [options]` | Review local changes and print an advisory review (default command). |
113
117
  | `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
118
+ | `ecr post-review --artifact <path> --repo <owner/repo> --pr <n>` | Post an exact saved PR preview without re-running models; refuses target, head, config, or break-glass drift. |
114
119
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
115
120
  | `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
116
121
  | `ecr feedback [--repo <owner/repo>]` | Report which findings PR authors pushed back on, across history. See below. |
@@ -661,6 +666,12 @@ that the PR comment embeds for machine consumers. The same totals are printed as
661
666
  one-line summary to the terminal / CI job log at the end of each run, so cache reuse
662
667
  is visible even in CI (where the run log is ephemeral).
663
668
 
669
+ `ecr review --save-review` additionally writes a versioned artifact under
670
+ `.expo-code-review/.runs/deferred/` with owner-only permissions. It contains the
671
+ verified final review and bounded feedback metadata, but no credential. `ecr
672
+ post-review` schema-validates it and refuses to post if its explicit repo/PR, live
673
+ head commit, or local comment-policy fingerprint no longer matches.
674
+
664
675
  </details>
665
676
 
666
677
  <a id="other-providers"></a>
package/build/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dismissCommand } from "./commands/dismiss.js";
5
5
  import { doctorCommand } from "./commands/doctor.js";
6
6
  import { feedbackCommand } from "./commands/feedback.js";
7
7
  import { initCommand } from "./commands/init.js";
8
+ import { postReviewCommand } from "./commands/post-review.js";
8
9
  import { refCheckCommand } from "./commands/ref-check.js";
9
10
  import { reviewCommand } from "./commands/review.js";
10
11
  import { setupAuthCommand } from "./commands/setup-auth.js";
@@ -13,6 +14,7 @@ const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
13
14
 
14
15
  Usage:
15
16
  ecr review [options] Review local changes (default). See \`ecr review --help\`.
17
+ ecr post-review --artifact <path> --repo <owner/repo> --pr <n> Post an exact saved preview.
16
18
  ecr ci Review the current PR and post a comment (GitHub Actions).
17
19
  ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
18
20
  ecr undismiss --pr <n> <id...> Restore a dismissed finding.
@@ -43,6 +45,9 @@ async function main() {
43
45
  case "review":
44
46
  await reviewCommand(rest);
45
47
  break;
48
+ case "post-review":
49
+ await postReviewCommand(rest);
50
+ break;
46
51
  case "ci":
47
52
  await ciCommand(rest);
48
53
  break;
@@ -0,0 +1,147 @@
1
+ // @ref LLP 0007#deferred-review-posting [implements] — no-model post of an exact, target-bound preview artifact
2
+ import path from "node:path";
3
+ import { loadReviewConfig } from "../config/load.js";
4
+ import { assertDeferredReviewCurrent, readDeferredReviewArtifact, reviewPostingConfigFingerprint, } from "../core/deferred-review.js";
5
+ import { repoRoot } from "../core/exec.js";
6
+ import { errorMessage } from "../core/util.js";
7
+ import { GitHubReporter } from "../reporters/github.js";
8
+ import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
9
+ const USAGE = `ecr post-review — post an exact saved PR review without re-running models
10
+
11
+ Usage:
12
+ ecr post-review --artifact <path> --repo <owner/repo> --pr <n>
13
+
14
+ The artifact must come from \`ecr review --save-review --repo <owner/repo> --pr <n>\`.
15
+ Before writing to GitHub, this command verifies the explicit repo/PR, the live PR
16
+ head commit, the local posting policy, and the PR's break-glass marker.
17
+ `;
18
+ function requireValue(flag, value) {
19
+ if (value === undefined || value.startsWith("--")) {
20
+ throw new Error(`${flag} requires a value`);
21
+ }
22
+ return value;
23
+ }
24
+ export function parsePostReviewArgs(argv) {
25
+ const args = { help: false };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ switch (arg) {
29
+ case "--artifact":
30
+ args.artifact = requireValue(arg, argv[++i]);
31
+ break;
32
+ case "--repo":
33
+ args.repo = requireValue(arg, argv[++i]);
34
+ break;
35
+ case "--pr": {
36
+ const value = requireValue(arg, argv[++i]);
37
+ const number = Number(value);
38
+ if (!Number.isSafeInteger(number) || number <= 0) {
39
+ throw new Error(`--pr requires a positive safe integer (got "${value}")`);
40
+ }
41
+ args.pr = number;
42
+ break;
43
+ }
44
+ case "-h":
45
+ case "--help":
46
+ args.help = true;
47
+ break;
48
+ default:
49
+ throw new Error(`Unknown argument: ${arg}`);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+ function validatePostReviewArgs(args) {
55
+ if (!args.artifact || !args.repo || args.pr == null) {
56
+ throw new Error("--artifact, --repo, and --pr are all required");
57
+ }
58
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(args.repo)) {
59
+ throw new Error(`--repo requires owner/repo (got "${args.repo}")`);
60
+ }
61
+ }
62
+ /** Break-glass errors fail closed: no report call occurs unless the check says false. */
63
+ export async function publishDeferredReview(poster, artifact, assertReadyToPost = async () => { }) {
64
+ if (await poster.checkBreakGlass()) {
65
+ return "break-glass";
66
+ }
67
+ await assertReadyToPost();
68
+ await poster.report(artifact.review, artifact.feedback);
69
+ return "posted";
70
+ }
71
+ export async function postReviewCommand(argv) {
72
+ let args;
73
+ try {
74
+ args = parsePostReviewArgs(argv);
75
+ if (args.help) {
76
+ process.stdout.write(USAGE);
77
+ return;
78
+ }
79
+ validatePostReviewArgs(args);
80
+ }
81
+ catch (error) {
82
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
83
+ process.exitCode = 2;
84
+ return;
85
+ }
86
+ // Resolve before chdir so a relative artifact path keeps the caller's meaning.
87
+ const artifactPath = path.resolve(process.cwd(), args.artifact);
88
+ try {
89
+ const root = await repoRoot();
90
+ if (root && root !== process.cwd()) {
91
+ process.chdir(root);
92
+ }
93
+ const cwd = process.cwd();
94
+ const [artifact, config] = await Promise.all([
95
+ readDeferredReviewArtifact(artifactPath),
96
+ loadReviewConfig(cwd),
97
+ ]);
98
+ const source = new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd });
99
+ const headSha = (await source.getMetadata()).headOid;
100
+ if (!isCommitOid(headSha)) {
101
+ throw new Error(`could not resolve a full head commit for ${args.repo}#${args.pr}`);
102
+ }
103
+ assertDeferredReviewCurrent(artifact, {
104
+ repo: args.repo,
105
+ pr: args.pr,
106
+ headSha,
107
+ configFingerprint: reviewPostingConfigFingerprint(config),
108
+ });
109
+ const reporter = new GitHubReporter({
110
+ prNumber: args.pr,
111
+ repo: args.repo,
112
+ commentTag: config.commentTag,
113
+ breakGlassMarker: config.breakGlassMarker,
114
+ cwd,
115
+ feedback: config.feedback,
116
+ headSha,
117
+ });
118
+ const result = await publishDeferredReview(reporter, artifact, async () => {
119
+ // Re-fetch rather than reusing GitHubPRSource's memoized metadata, and reload
120
+ // config after the break-glass API call. This narrows the unavoidable remote
121
+ // TOCTOU window and prevents a head/policy change during setup from reaching
122
+ // the reporter.
123
+ const [latestMetadata, latestConfig] = await Promise.all([
124
+ new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd }).getMetadata(),
125
+ loadReviewConfig(cwd),
126
+ ]);
127
+ if (!isCommitOid(latestMetadata.headOid)) {
128
+ throw new Error(`could not re-resolve a full head commit for ${args.repo}#${args.pr}`);
129
+ }
130
+ assertDeferredReviewCurrent(artifact, {
131
+ repo: args.repo,
132
+ pr: args.pr,
133
+ headSha: latestMetadata.headOid,
134
+ configFingerprint: reviewPostingConfigFingerprint(latestConfig),
135
+ });
136
+ });
137
+ if (result === "break-glass") {
138
+ process.stderr.write(`Not posting: ${config.breakGlassMarker} is set on ${args.repo}#${args.pr}.\n`);
139
+ return;
140
+ }
141
+ process.stderr.write(`Posted saved review to ${args.repo}#${args.pr}.\n`);
142
+ }
143
+ catch (error) {
144
+ process.stderr.write(`Saved review was not posted: ${errorMessage(error)}\n`);
145
+ process.exitCode = 2;
146
+ }
147
+ }
@@ -4,10 +4,11 @@ import { loadRoutingManifest, resolveScopes, scopedCommentTag } from "../config/
4
4
  import { repoRoot, resolveRepo } from "../core/exec.js";
5
5
  import { errorMessage } from "../core/util.js";
6
6
  import { readContextFile } from "../core/context-file.js";
7
+ import { writeDeferredReviewArtifact } from "../core/deferred-review.js";
7
8
  import { feedbackNeedsRunSeam } from "../core/adjudicate.js";
8
9
  import { runReview } from "../core/review.js";
9
10
  import { LocalGitSource } from "../sources/local-git.js";
10
- import { GitHubPRSource } from "../sources/github-pr.js";
11
+ import { GitHubPRSource, isCommitOid } from "../sources/github-pr.js";
11
12
  import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
12
13
  import { TerminalReporter } from "../reporters/terminal.js";
13
14
  import { GitHubReporter } from "../reporters/github.js";
@@ -15,7 +16,8 @@ const USAGE = `ecr review — AI code review, printed to your terminal
15
16
 
16
17
  Usage:
17
18
  ecr review [options] review local changes
18
- ecr review --pr <n> [--post] review a GitHub PR by number
19
+ ecr review --pr <n> [--post | --save-review]
20
+ review a GitHub PR by number
19
21
 
20
22
  Source (pick one):
21
23
  (default) diff the working tree against the merge-base
@@ -31,6 +33,8 @@ Options:
31
33
  --post with --pr: also post the result as the PR comment (needs
32
34
  \`gh\` auth). Omit to only preview here; re-run with --post
33
35
  to publish.
36
+ --save-review with explicit --repo + --pr: save this exact preview as a
37
+ postable artifact for a later \`ecr post-review\` command.
34
38
  --agents <a,b> run only these agents (comma-separated ids); default: all
35
39
  --route let the router pick relevant agents from the diff
36
40
  --scope <name> review only this routing scope (needs a routing.jsonc);
@@ -60,10 +64,11 @@ function requireValue(flag, value) {
60
64
  }
61
65
  return value;
62
66
  }
63
- function parseArgs(argv) {
67
+ export function parseReviewArgs(argv) {
64
68
  const args = {
65
69
  staged: false,
66
70
  post: false,
71
+ saveReview: false,
67
72
  route: false,
68
73
  stackAware: false,
69
74
  json: false,
@@ -85,8 +90,8 @@ function parseArgs(argv) {
85
90
  case "--pr": {
86
91
  const value = requireValue(arg, argv[++i]);
87
92
  const number = Number(value);
88
- if (!Number.isInteger(number) || number <= 0) {
89
- throw new Error(`--pr requires a positive PR number (got "${value}")`);
93
+ if (!Number.isSafeInteger(number) || number <= 0) {
94
+ throw new Error(`--pr requires a positive safe integer (got "${value}")`);
90
95
  }
91
96
  args.pr = number;
92
97
  break;
@@ -97,6 +102,9 @@ function parseArgs(argv) {
97
102
  case "--post":
98
103
  args.post = true;
99
104
  break;
105
+ case "--save-review":
106
+ args.saveReview = true;
107
+ break;
100
108
  case "--agents":
101
109
  args.agents = requireValue(arg, argv[++i])
102
110
  .split(",")
@@ -137,7 +145,7 @@ function parseArgs(argv) {
137
145
  export async function reviewCommand(argv) {
138
146
  let args;
139
147
  try {
140
- args = parseArgs(argv);
148
+ args = parseReviewArgs(argv);
141
149
  }
142
150
  catch (error) {
143
151
  process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
@@ -149,7 +157,7 @@ export async function reviewCommand(argv) {
149
157
  return;
150
158
  }
151
159
  try {
152
- validateArgs(args);
160
+ validateReviewArgs(args);
153
161
  }
154
162
  catch (error) {
155
163
  process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
@@ -292,11 +300,13 @@ export async function reviewCommand(argv) {
292
300
  }
293
301
  const config = await loadReviewConfig(cwd, { configDir: args.configDir });
294
302
  const source = makeSource();
295
- // Build the PR reporter up front when posting, so adjudicate mode can judge the
303
+ // Build the PR reporter up front when posting OR saving a postable preview, so
304
+ // adjudicate mode can judge the
296
305
  // PR's replies against the source before the result is rendered. Feedback only
297
306
  // has replies to match when reviewing a PR and posting (the terminal preview never
298
- // renders annotations), so it is wired only on the --post --pr path.
299
- const { repo: postRepo, error: postRepoError } = await resolvePostRepo(args, cwd);
307
+ // renders annotations), so it is wired only when a later/current post is possible.
308
+ const { repo: postRepo, error: postRepoError } = await resolvePostRepo({ ...args, post: args.post || args.saveReview }, cwd);
309
+ const headSha = postRepo != null && args.pr != null ? await reviewedHeadSha(source) : undefined;
300
310
  const reporter = postRepo != null && args.pr != null
301
311
  ? new GitHubReporter({
302
312
  prNumber: args.pr,
@@ -305,7 +315,7 @@ export async function reviewCommand(argv) {
305
315
  breakGlassMarker: config.breakGlassMarker,
306
316
  cwd,
307
317
  feedback: config.feedback,
308
- headSha: await reviewedHeadSha(source),
318
+ headSha,
309
319
  })
310
320
  : null;
311
321
  const review = await runReview(source, {
@@ -325,6 +335,19 @@ export async function reviewCommand(argv) {
325
335
  });
326
336
  // Always print the result here first.
327
337
  await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
338
+ if (args.saveReview) {
339
+ if (postRepo == null || args.pr == null || !isCommitOid(headSha)) {
340
+ throw new Error(`could not save a postable review: ${postRepoError ? errorMessage(postRepoError) : "the PR head commit could not be resolved"}`);
341
+ }
342
+ const artifactPath = await writeDeferredReviewArtifact(config, {
343
+ repo: postRepo,
344
+ pr: args.pr,
345
+ headSha,
346
+ review,
347
+ feedback: review.feedback,
348
+ });
349
+ process.stderr.write(`\nSaved postable review artifact: ${artifactPath}\n`);
350
+ }
328
351
  // Then, only if asked, publish the same result to the PR.
329
352
  if (args.pr != null && args.post) {
330
353
  if (reporter) {
@@ -403,12 +426,21 @@ export async function resolvePostRepo(args, cwd, resolve = resolveRepo) {
403
426
  }
404
427
  // @ref LLP 0007#ecr-review-local-trust-and-flag-rules [implements] — mutually exclusive flags rejected outright, never silently ignored
405
428
  /** Reject flag combinations that don't make sense together. */
406
- function validateArgs(args) {
429
+ export function validateReviewArgs(args) {
407
430
  if (args.pr != null && (args.base || args.head || args.staged)) {
408
431
  throw new Error("--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.");
409
432
  }
410
- if (args.pr == null && (args.repo || args.post)) {
411
- throw new Error("--repo/--post only apply together with --pr.");
433
+ if (args.pr == null && (args.repo || args.post || args.saveReview)) {
434
+ throw new Error("--repo/--post/--save-review only apply together with --pr.");
435
+ }
436
+ if (args.saveReview && !args.repo) {
437
+ throw new Error("--save-review requires explicit --repo owner/repo.");
438
+ }
439
+ if (args.saveReview && args.post) {
440
+ throw new Error("--save-review and --post are mutually exclusive.");
441
+ }
442
+ if (args.saveReview && (args.scope || args.configDir)) {
443
+ throw new Error("--save-review does not support --scope or --config-dir.");
412
444
  }
413
445
  // Same rule as --repo/--post: the stack walk needs a PR to walk from, so a bare
414
446
  // --stack-aware would be silently ignored — reject it instead.
@@ -0,0 +1,119 @@
1
+ // @ref LLP 0007#deferred-review-posting [implements] — exact preview artifact, explicit target binding, and stale-head/config refusal
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, open, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { z } from "zod";
6
+ import { CoordinatorOutputSchema, FeedbackRecordSchema, } from "./schema.js";
7
+ export const DEFERRED_REVIEW_ARTIFACT_VERSION = 1;
8
+ export const DEFERRED_REVIEW_ARTIFACT_MAX_BYTES = 1_000_000;
9
+ const READ_CHUNK_BYTES = 65_536;
10
+ const RepoSchema = z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, "expected owner/repo");
11
+ const CommitOidSchema = z.string().regex(/^[0-9a-f]{40}$/i, "expected a full commit OID");
12
+ /**
13
+ * A postable review produced by one completed local PR review. The review and
14
+ * feedback are the exact verified values that terminal preview rendered; target,
15
+ * head and posting-policy bindings are checked again before any GitHub write.
16
+ */
17
+ export const DeferredReviewArtifactSchema = z
18
+ .object({
19
+ version: z.literal(DEFERRED_REVIEW_ARTIFACT_VERSION),
20
+ createdAt: z.string().datetime(),
21
+ repo: RepoSchema,
22
+ pr: z.number().int().positive().safe(),
23
+ headSha: CommitOidSchema,
24
+ configFingerprint: z.string().regex(/^[0-9a-f]{64}$/i),
25
+ review: CoordinatorOutputSchema,
26
+ feedback: z.array(FeedbackRecordSchema).optional(),
27
+ })
28
+ .strict();
29
+ /** Bind the artifact to the local config fields that affect the posted comment. */
30
+ export function reviewPostingConfigFingerprint(config) {
31
+ return createHash("sha256")
32
+ .update(JSON.stringify({
33
+ commentTag: config.commentTag,
34
+ breakGlassMarker: config.breakGlassMarker,
35
+ feedback: config.feedback,
36
+ }))
37
+ .digest("hex");
38
+ }
39
+ function artifactFilename(repo, pr) {
40
+ const safeRepo = repo.replace(/[^A-Za-z0-9_.-]+/g, "-");
41
+ return `${safeRepo}-pr-${pr}-${randomUUID()}.json`;
42
+ }
43
+ /**
44
+ * Persist with owner-only permissions and exclusive creation. The random filename
45
+ * avoids overwriting another session's pending review; no credential is stored.
46
+ */
47
+ export async function writeDeferredReviewArtifact(config, input) {
48
+ const artifact = DeferredReviewArtifactSchema.parse({
49
+ version: DEFERRED_REVIEW_ARTIFACT_VERSION,
50
+ createdAt: new Date().toISOString(),
51
+ repo: input.repo,
52
+ pr: input.pr,
53
+ headSha: input.headSha,
54
+ configFingerprint: reviewPostingConfigFingerprint(config),
55
+ review: input.review,
56
+ ...(input.feedback ? { feedback: input.feedback } : {}),
57
+ });
58
+ const serialized = `${JSON.stringify(artifact, null, 2)}\n`;
59
+ const serializedBytes = Buffer.byteLength(serialized);
60
+ if (serializedBytes > DEFERRED_REVIEW_ARTIFACT_MAX_BYTES) {
61
+ throw new Error(`deferred review artifact would be ${serializedBytes} bytes; maximum is ${DEFERRED_REVIEW_ARTIFACT_MAX_BYTES}`);
62
+ }
63
+ const dir = path.join(config.configDir, ".runs", "deferred");
64
+ await mkdir(dir, { recursive: true });
65
+ const artifactPath = path.join(dir, artifactFilename(input.repo, input.pr));
66
+ await writeFile(artifactPath, serialized, {
67
+ encoding: "utf8",
68
+ flag: "wx",
69
+ mode: 0o600,
70
+ });
71
+ return artifactPath;
72
+ }
73
+ /** Read once, byte-cap before parsing, then cross the strict schema boundary. */
74
+ export async function readDeferredReviewArtifact(artifactPath) {
75
+ const handle = await open(artifactPath, "r");
76
+ const chunks = [];
77
+ let total = 0;
78
+ try {
79
+ for (;;) {
80
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
81
+ const { bytesRead } = await handle.read(chunk, 0, READ_CHUNK_BYTES);
82
+ if (bytesRead === 0) {
83
+ break;
84
+ }
85
+ total += bytesRead;
86
+ if (total > DEFERRED_REVIEW_ARTIFACT_MAX_BYTES) {
87
+ throw new Error(`deferred review artifact is over ${DEFERRED_REVIEW_ARTIFACT_MAX_BYTES} bytes`);
88
+ }
89
+ chunks.push(chunk.subarray(0, bytesRead));
90
+ }
91
+ }
92
+ finally {
93
+ await handle.close();
94
+ }
95
+ const raw = Buffer.concat(chunks);
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(raw.toString("utf8"));
99
+ }
100
+ catch (error) {
101
+ throw new Error(`deferred review artifact is not valid JSON: ${String(error)}`);
102
+ }
103
+ return DeferredReviewArtifactSchema.parse(parsed);
104
+ }
105
+ /**
106
+ * Final no-write gate. Every value comes from a separate authority: repo/PR from
107
+ * explicit argv, head from live GitHub, config from the local trusted checkout.
108
+ */
109
+ export function assertDeferredReviewCurrent(artifact, expected) {
110
+ if (artifact.repo !== expected.repo || artifact.pr !== expected.pr) {
111
+ throw new Error(`artifact targets ${artifact.repo}#${artifact.pr}, not explicitly requested ${expected.repo}#${expected.pr}`);
112
+ }
113
+ if (artifact.headSha !== expected.headSha) {
114
+ throw new Error(`PR head changed after preview (${artifact.headSha} → ${expected.headSha}); run a fresh review instead of posting stale findings`);
115
+ }
116
+ if (artifact.configFingerprint !== expected.configFingerprint) {
117
+ throw new Error("local review posting policy changed after preview; run a fresh review before posting");
118
+ }
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
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": {