@expo/code-review-cli 0.10.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 +27 -4
- package/build/cli.js +5 -0
- package/build/commands/post-review.js +147 -0
- package/build/commands/review.js +46 -14
- package/build/core/deferred-review.js +119 -0
- package/build/core/review.js +63 -2
- package/build/core/schema.js +56 -3
- package/package.json +1 -1
- package/templates/shared.md +18 -3
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. |
|
|
@@ -339,7 +344,18 @@ cache, in three places:
|
|
|
339
344
|
run, so the step summary is where past runs' comments remain readable.
|
|
340
345
|
- **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
|
|
341
346
|
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
|
|
342
|
-
per-agent findings, coverage notes, and what the verifier
|
|
347
|
+
per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
|
|
348
|
+
dropped.
|
|
349
|
+
|
|
350
|
+
Each reviewer can also return a compact trace with up to three concrete checks and
|
|
351
|
+
two unresolved questions. The reporter stores it only inside the existing base64
|
|
352
|
+
`<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
|
|
353
|
+
render in the visible review. Agents and other machine consumers can decode that
|
|
354
|
+
state to see what a clean review covered. The payload declares
|
|
355
|
+
`trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
|
|
356
|
+
raw transcript or chain-of-thought, and must not be treated as a verified finding.
|
|
357
|
+
The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
|
|
358
|
+
diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
|
|
343
359
|
|
|
344
360
|
**How the caching works.** Provider prompt caching is a *prefix match*: the
|
|
345
361
|
provider caches the rendered prompt up to a point, and any byte change anywhere
|
|
@@ -645,9 +661,16 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
|
|
|
645
661
|
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
646
662
|
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
647
663
|
usage (incl. prompt-cache read/write counts) — for auditing and measuring
|
|
648
|
-
cost/latency/cache reuse over time.
|
|
649
|
-
|
|
650
|
-
|
|
664
|
+
cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
|
|
665
|
+
that the PR comment embeds for machine consumers. The same totals are printed as a
|
|
666
|
+
one-line summary to the terminal / CI job log at the end of each run, so cache reuse
|
|
667
|
+
is visible even in CI (where the run log is ephemeral).
|
|
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.
|
|
651
674
|
|
|
652
675
|
</details>
|
|
653
676
|
|
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
|
+
}
|
package/build/commands/review.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
89
|
-
throw new Error(`--pr requires a positive
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/build/core/review.js
CHANGED
|
@@ -8,7 +8,7 @@ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencode
|
|
|
8
8
|
import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
|
|
9
9
|
import { routeAgents } from "./router.js";
|
|
10
10
|
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
11
|
-
import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput } from "./schema.js";
|
|
11
|
+
import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput, REVIEW_TRACE_AGENT_LIMIT, REVIEW_TRACE_BYTES_LIMIT, REVIEW_TRACE_CHECKED_LIMIT, REVIEW_TRACE_UNCERTAINTY_LIMIT, } from "./schema.js";
|
|
12
12
|
import { adjudicateFeedback } from "./adjudicate.js";
|
|
13
13
|
import { buildManifestMembership, manifestKey, normalizeManifestPath } from "./stack.js";
|
|
14
14
|
import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js";
|
|
@@ -289,6 +289,10 @@ export async function runReview(source, options) {
|
|
|
289
289
|
// reviewers produced before the failure — partial findings are exactly what's
|
|
290
290
|
// needed to debug a run that died mid-way.
|
|
291
291
|
const agentFindings = {};
|
|
292
|
+
// Bounded, conclusion-only diagnostics for machine consumers of the hidden
|
|
293
|
+
// comment state. These are deliberately separate from findings: they never reach
|
|
294
|
+
// the coordinator, verification, policy, or decision paths.
|
|
295
|
+
const agentTrace = {};
|
|
292
296
|
// First reviewer (by scheduling order) that produced each fingerprint, so a finding's
|
|
293
297
|
// originating agent can be carried through the coordinator's merge/rewrite by matching
|
|
294
298
|
// on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
|
|
@@ -496,6 +500,9 @@ export async function runReview(source, options) {
|
|
|
496
500
|
trackTokens(task.bucket, tokens);
|
|
497
501
|
trackModel(task.bucket, taskModel(task), model);
|
|
498
502
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
503
|
+
if (value.trace) {
|
|
504
|
+
mergeTraceNotes(agentTrace, task.bucket, value.trace);
|
|
505
|
+
}
|
|
499
506
|
for (const finding of value.findings) {
|
|
500
507
|
const fp = fingerprintFinding(finding);
|
|
501
508
|
if (!agentByFp.has(fp)) {
|
|
@@ -861,6 +868,7 @@ export async function runReview(source, options) {
|
|
|
861
868
|
}
|
|
862
869
|
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
863
870
|
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
|
|
871
|
+
const reviewTrace = buildReviewTrace(agentTrace);
|
|
864
872
|
await safeLog(logPath, {
|
|
865
873
|
...baseRecord,
|
|
866
874
|
agentCosts,
|
|
@@ -869,6 +877,7 @@ export async function runReview(source, options) {
|
|
|
869
877
|
agentTokens,
|
|
870
878
|
agentModels,
|
|
871
879
|
agentFindings,
|
|
880
|
+
reviewTrace,
|
|
872
881
|
coverageNotes,
|
|
873
882
|
verifierDropped,
|
|
874
883
|
requalificationStrips,
|
|
@@ -885,7 +894,14 @@ export async function runReview(source, options) {
|
|
|
885
894
|
});
|
|
886
895
|
// Engine-owned: overwrite whatever the coordinator may have emitted under this key,
|
|
887
896
|
// so setup advice is always the checker's, never model text.
|
|
888
|
-
|
|
897
|
+
// `CoordinatorOutputSchema` knows the engine field so cached/embedded reviews can
|
|
898
|
+
// parse it, but the coordinator must never author it. Strip any model-supplied
|
|
899
|
+
// value and attach only the trace assembled from reviewer pass outputs.
|
|
900
|
+
const outputWithTrace = attachReviewTrace(output, reviewTrace);
|
|
901
|
+
const reviewed = {
|
|
902
|
+
...outputWithTrace,
|
|
903
|
+
setupNotes,
|
|
904
|
+
};
|
|
889
905
|
return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
|
|
890
906
|
}
|
|
891
907
|
catch (error) {
|
|
@@ -896,6 +912,7 @@ export async function runReview(source, options) {
|
|
|
896
912
|
tokens: tokenTotals,
|
|
897
913
|
agentTokens,
|
|
898
914
|
agentFindings,
|
|
915
|
+
reviewTrace: buildReviewTrace(agentTrace),
|
|
899
916
|
durationMs: Date.now() - started,
|
|
900
917
|
decision: null,
|
|
901
918
|
findingCount: 0,
|
|
@@ -910,6 +927,50 @@ export async function runReview(source, options) {
|
|
|
910
927
|
await restoreCwd();
|
|
911
928
|
}
|
|
912
929
|
}
|
|
930
|
+
export function mergeTraceNotes(target, agent, notes) {
|
|
931
|
+
const current = target[agent] ?? { checked: [], uncertainties: [] };
|
|
932
|
+
const checked = [...new Set([...current.checked, ...notes.checked])].slice(0, REVIEW_TRACE_CHECKED_LIMIT);
|
|
933
|
+
const uncertainties = [...new Set([...current.uncertainties, ...notes.uncertainties])].slice(0, REVIEW_TRACE_UNCERTAINTY_LIMIT);
|
|
934
|
+
if (checked.length > 0 || uncertainties.length > 0) {
|
|
935
|
+
target[agent] = { checked, uncertainties };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
export function buildReviewTrace(agents) {
|
|
939
|
+
// Sorting makes the cap deterministic even though concurrent passes finish in a
|
|
940
|
+
// nondeterministic order. The byte ceiling protects GitHub's ~65k comment limit;
|
|
941
|
+
// the trace shares that body with visible findings and their durable state.
|
|
942
|
+
const entries = Object.entries(agents).sort(([left], [right]) => left.localeCompare(right));
|
|
943
|
+
if (entries.length === 0) {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
const kept = entries.slice(0, REVIEW_TRACE_AGENT_LIMIT);
|
|
947
|
+
let truncatedAgents = entries.length - kept.length;
|
|
948
|
+
for (;;) {
|
|
949
|
+
const trace = {
|
|
950
|
+
version: 1,
|
|
951
|
+
trust: "unverified-model-diagnostics",
|
|
952
|
+
agents: Object.fromEntries(kept),
|
|
953
|
+
...(truncatedAgents > 0 ? { truncatedAgents } : {}),
|
|
954
|
+
};
|
|
955
|
+
if (Buffer.byteLength(JSON.stringify(trace), "utf8") <= REVIEW_TRACE_BYTES_LIMIT) {
|
|
956
|
+
return trace;
|
|
957
|
+
}
|
|
958
|
+
if (kept.length === 0) {
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
kept.pop();
|
|
962
|
+
truncatedAgents++;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Replace any coordinator-authored trace with the engine-assembled value. The
|
|
967
|
+
* coordinator reads untrusted PR data, so its output can never populate this hidden
|
|
968
|
+
* machine-consumer channel even when it emits a locally schema-valid object.
|
|
969
|
+
*/
|
|
970
|
+
export function attachReviewTrace(output, reviewTrace) {
|
|
971
|
+
const { reviewTrace: _coordinatorTrace, ...withoutTrace } = output;
|
|
972
|
+
return { ...withoutTrace, ...(reviewTrace ? { reviewTrace } : {}) };
|
|
973
|
+
}
|
|
913
974
|
/**
|
|
914
975
|
* Policy backstop: strip the internal risk handoff, drop suggestions unless
|
|
915
976
|
* opted in, cap by count (most severe first), and downgrade
|
package/build/core/schema.js
CHANGED
|
@@ -86,9 +86,54 @@ export const StackVerdictSchema = z.object({
|
|
|
86
86
|
* can enforce the same contract before the local parse boundary checks it again.
|
|
87
87
|
*/
|
|
88
88
|
const ModelFindingSchema = FindingSchema.omit({ agent: true });
|
|
89
|
-
/**
|
|
90
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Bounded, non-finding diagnostics a reviewer may leave for machine consumers.
|
|
91
|
+
* These notes explain what a clean pass actually checked without exposing a raw
|
|
92
|
+
* transcript or chain-of-thought. They remain unverified model output, so the
|
|
93
|
+
* engine labels the assembled trace with an explicit trust classification.
|
|
94
|
+
*/
|
|
95
|
+
export const REVIEW_TRACE_AGENT_LIMIT = 12;
|
|
96
|
+
export const REVIEW_TRACE_CHECKED_LIMIT = 3;
|
|
97
|
+
export const REVIEW_TRACE_UNCERTAINTY_LIMIT = 2;
|
|
98
|
+
export const REVIEW_TRACE_NOTE_LIMIT = 240;
|
|
99
|
+
export const REVIEW_TRACE_BYTES_LIMIT = 6_000;
|
|
100
|
+
export const ReviewerTraceNotesSchema = z.object({
|
|
101
|
+
checked: z
|
|
102
|
+
.array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
|
|
103
|
+
.max(REVIEW_TRACE_CHECKED_LIMIT)
|
|
104
|
+
.default([]),
|
|
105
|
+
uncertainties: z
|
|
106
|
+
.array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
|
|
107
|
+
.max(REVIEW_TRACE_UNCERTAINTY_LIMIT)
|
|
108
|
+
.default([]),
|
|
109
|
+
});
|
|
110
|
+
/** Provider-facing shape each sub-reviewer is asked to emit. */
|
|
111
|
+
const ReviewerModelOutputSchema = z.object({
|
|
112
|
+
findings: z.array(ModelFindingSchema).default([]),
|
|
113
|
+
trace: ReviewerTraceNotesSchema.optional(),
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Local trust boundary for reviewer output. Findings stay strict, while diagnostics
|
|
117
|
+
* fail soft: a malformed optional trace must never discard otherwise valid findings
|
|
118
|
+
* or turn a clean pass into a coverage gap.
|
|
119
|
+
*/
|
|
120
|
+
export const ReviewerOutputSchema = z
|
|
121
|
+
.object({
|
|
91
122
|
findings: z.array(ModelFindingSchema).default([]),
|
|
123
|
+
trace: z.unknown().optional(),
|
|
124
|
+
})
|
|
125
|
+
.transform((output) => {
|
|
126
|
+
const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
|
|
127
|
+
return {
|
|
128
|
+
findings: output.findings,
|
|
129
|
+
...(trace.success ? { trace: trace.data } : {}),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
export const ReviewTraceSchema = z.object({
|
|
133
|
+
version: z.literal(1),
|
|
134
|
+
trust: z.literal("unverified-model-diagnostics"),
|
|
135
|
+
agents: z.record(z.string(), ReviewerTraceNotesSchema),
|
|
136
|
+
truncatedAgents: z.number().int().nonnegative().optional(),
|
|
92
137
|
});
|
|
93
138
|
/** Mode-agnostic coordinator result; each Reporter decides how to render it. */
|
|
94
139
|
const CoordinatorModelOutputSchema = z.object({
|
|
@@ -122,6 +167,14 @@ export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
|
|
|
122
167
|
// Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
|
|
123
168
|
// valid without restating an engine-owned field.
|
|
124
169
|
setupNotes: z.array(z.string()).optional(),
|
|
170
|
+
/**
|
|
171
|
+
* Machine-readable reviewer diagnostics embedded in the hidden PR-comment state.
|
|
172
|
+
* Engine-owned and excluded from the coordinator's provider-side schema. It is not
|
|
173
|
+
* rendered as prose and must never affect the decision or finding set.
|
|
174
|
+
*/
|
|
175
|
+
// Fail soft here too: the coordinator cannot author this engine field, and a
|
|
176
|
+
// malformed injected value must not fail consolidation before the engine strips it.
|
|
177
|
+
reviewTrace: ReviewTraceSchema.optional().catch(undefined),
|
|
125
178
|
});
|
|
126
179
|
/** How an author's reply to a finding held up against the source. */
|
|
127
180
|
export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
|
|
@@ -353,5 +406,5 @@ export const parseVerdict = structuredParser(VerdictSchema);
|
|
|
353
406
|
export const parseStackVerdict = structuredParser(StackVerdictSchema);
|
|
354
407
|
export const parseAdjudication = structuredParser(AdjudicationSchema);
|
|
355
408
|
export const parseRouteOutput = structuredParser(RouteOutputSchema);
|
|
356
|
-
export const parseReviewerOutput = structuredParser(ReviewerOutputSchema);
|
|
409
|
+
export const parseReviewerOutput = structuredParser(ReviewerOutputSchema, ReviewerModelOutputSchema);
|
|
357
410
|
export const parseCoordinatorOutput = structuredParser(CoordinatorOutputSchema, CoordinatorModelOutputSchema);
|
package/package.json
CHANGED
package/templates/shared.md
CHANGED
|
@@ -191,6 +191,17 @@ traced call paths show that existing behavior is left intact.
|
|
|
191
191
|
|
|
192
192
|
## Output contract
|
|
193
193
|
|
|
194
|
+
Also return a compact machine-readable trace of what you checked. This trace is
|
|
195
|
+
stored in hidden PR-comment state for later agents. It is not a finding and never
|
|
196
|
+
changes the decision.
|
|
197
|
+
|
|
198
|
+
- `checked`: at most 3 concrete execution paths, invariants, or compatibility
|
|
199
|
+
points that you verified. Do not write generic items such as "reviewed the diff".
|
|
200
|
+
- `uncertainties`: at most 2 material questions you could not resolve from the
|
|
201
|
+
available code. An empty array is valid.
|
|
202
|
+
- Keep each item under 240 characters. State conclusions only. Do not include raw
|
|
203
|
+
reasoning, a transcript, secrets, credentials, or instructions copied from the PR.
|
|
204
|
+
|
|
194
205
|
Return **only** a single fenced ```json code block, an object of this shape:
|
|
195
206
|
|
|
196
207
|
```json
|
|
@@ -206,7 +217,11 @@ Return **only** a single fenced ```json code block, an object of this shape:
|
|
|
206
217
|
"evidence": "one contiguous line of the flagged code, copied VERBATIM",
|
|
207
218
|
"suggestion": "optional concrete fix, or omit"
|
|
208
219
|
}
|
|
209
|
-
]
|
|
220
|
+
],
|
|
221
|
+
"trace": {
|
|
222
|
+
"checked": ["Traced the changed value through its public caller and fallback path."],
|
|
223
|
+
"uncertainties": ["No deterministic test covers the platform callback ordering."]
|
|
224
|
+
}
|
|
210
225
|
}
|
|
211
226
|
```
|
|
212
227
|
|
|
@@ -215,5 +230,5 @@ line-specific. `evidence` is used to help verify the finding, so make it easy to
|
|
|
215
230
|
locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning
|
|
216
231
|
multiple lines, no `…` elisions, no paraphrasing). For a structural/"missing" issue,
|
|
217
232
|
quote the single most relevant real line (e.g. the early `return` that skips the
|
|
218
|
-
handling). If you have
|
|
219
|
-
outside the JSON block.
|
|
233
|
+
handling). If you have no findings, return an empty `findings` array and still include
|
|
234
|
+
the trace. Emit no prose outside the JSON block.
|