@expo/code-review-cli 0.2.0 → 0.2.2
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 +20 -25
- package/build/core/prompts.js +21 -9
- package/build/core/render.js +60 -7
- package/build/core/verify.js +77 -41
- package/build/reporters/github.js +40 -6
- package/package.json +1 -1
- package/templates/shared.md +7 -5
package/README.md
CHANGED
|
@@ -68,6 +68,26 @@ ecr review --pr 4057
|
|
|
68
68
|
ecr review --pr 4057 --post
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
Options (most to least common):
|
|
72
|
+
|
|
73
|
+
| Flag | What it does |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
|
|
76
|
+
| `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
|
|
77
|
+
| `--staged` | Review only staged changes. |
|
|
78
|
+
| `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
|
|
79
|
+
| `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
|
|
80
|
+
| `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
|
|
81
|
+
| `--route` | Let an LLM router pick the relevant agents from the diff. |
|
|
82
|
+
| `--repo <owner/repo>` | Repo for `--pr` (default: inferred from the current checkout). |
|
|
83
|
+
| `--json` | Emit machine-readable JSON on stdout. |
|
|
84
|
+
| `--no-fail` | Always exit 0 (otherwise a `request_changes` decision exits non-zero). |
|
|
85
|
+
| `-h`, `--help` | Show help. |
|
|
86
|
+
|
|
87
|
+
`--pr` uses the PR's diff (authoritative) but reads your checked-out files for
|
|
88
|
+
surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
|
|
89
|
+
`ecr review`.
|
|
90
|
+
|
|
71
91
|
In CI it runs automatically from the scaffolded workflows — by label or a `/review`
|
|
72
92
|
comment (see **CI usage**). From Claude Code (or another agent), add a slash command
|
|
73
93
|
that runs it; eas-cli's
|
|
@@ -113,31 +133,6 @@ and applies Anthropic prompt caching automatically.
|
|
|
113
133
|
|
|
114
134
|
</details>
|
|
115
135
|
|
|
116
|
-
<details>
|
|
117
|
-
<summary><b><code>ecr review</code> options</b></summary>
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
--base <ref> Base ref to diff against (default: merge-base with default branch)
|
|
121
|
-
--head <ref> Head ref to diff (default: working tree, incl. uncommitted changes)
|
|
122
|
-
--staged Review only staged changes
|
|
123
|
-
--pr <n> Review GitHub PR #n by number (diff fetched via gh, no checkout);
|
|
124
|
-
not combinable with --base/--head/--staged
|
|
125
|
-
--repo <owner/repo> Repo for --pr (default: inferred from the current checkout)
|
|
126
|
-
--post With --pr: also post the result as the PR comment (needs gh auth).
|
|
127
|
-
Omit to preview only; re-run with --post to publish.
|
|
128
|
-
--agents <a,b> Run only these agents (comma-separated ids); default: all
|
|
129
|
-
--route Let an LLM router pick the relevant agents from the diff
|
|
130
|
-
--json Emit machine-readable JSON on stdout
|
|
131
|
-
--no-fail Always exit 0 (otherwise a request_changes decision exits non-zero)
|
|
132
|
-
-h, --help Show help
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
`--pr` uses the PR's diff (authoritative) but reads your checked-out files for
|
|
136
|
-
surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
|
|
137
|
-
`ecr review`.
|
|
138
|
-
|
|
139
|
-
</details>
|
|
140
|
-
|
|
141
136
|
<details>
|
|
142
137
|
<summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
|
|
143
138
|
|
package/build/core/prompts.js
CHANGED
|
@@ -182,25 +182,34 @@ export function buildCrossCuttingTask(allFiles, filtered = []) {
|
|
|
182
182
|
export function buildVerifierSystem() {
|
|
183
183
|
return [
|
|
184
184
|
'You are a skeptical verifier of a single code-review finding. Your default is',
|
|
185
|
-
'DISTRUST. Using your read/grep tools, open the cited file
|
|
186
|
-
'
|
|
185
|
+
'DISTRUST. Using your read/grep tools, open the cited file (search nearby files',
|
|
186
|
+
'if the code is not exactly there), locate the relevant code, and judge whether',
|
|
187
|
+
'the described PROBLEM is actually present in the source.',
|
|
188
|
+
'',
|
|
189
|
+
'Judge the SUBSTANCE, not the wording. The finding\'s quoted "evidence" may be',
|
|
190
|
+
'paraphrased, abbreviated, quoted across non-adjacent lines, or slightly',
|
|
191
|
+
'misquoted, and its file/line may be approximate. None of that alone makes the',
|
|
192
|
+
'finding false — verify against what the code actually does. Do NOT reject merely',
|
|
193
|
+
'because the quoted snippet is not a verbatim match; reject only if the',
|
|
194
|
+
'underlying problem is not real.',
|
|
187
195
|
'',
|
|
188
196
|
'Mark verified=false (reject) if any of these hold:',
|
|
189
|
-
'- the
|
|
190
|
-
'
|
|
191
|
-
'- the described failure/exploit cannot actually
|
|
197
|
+
'- the described problem does not actually occur in the code (it misread or',
|
|
198
|
+
' invented the behavior),',
|
|
199
|
+
'- the described failure/exploit cannot actually happen,',
|
|
192
200
|
"- the claim is internally contradictory (e.g. asserts a type error in code that",
|
|
193
201
|
' compiles), or',
|
|
194
|
-
'- you cannot substantiate
|
|
202
|
+
'- you cannot substantiate the underlying issue after reading the file.',
|
|
195
203
|
'',
|
|
196
|
-
'
|
|
197
|
-
'
|
|
204
|
+
'Mark verified=true when you have CONFIRMED, from the real source, that the',
|
|
205
|
+
'described problem genuinely exists. When genuinely unsure whether it is real,',
|
|
206
|
+
'reject.',
|
|
198
207
|
'',
|
|
199
208
|
'Return ONLY this JSON object and nothing else:',
|
|
200
209
|
'{"verified": true|false, "reason": "one concise sentence grounded in the file"}',
|
|
201
210
|
].join('\n');
|
|
202
211
|
}
|
|
203
|
-
export function buildVerifierTask(finding) {
|
|
212
|
+
export function buildVerifierTask(finding, opts = {}) {
|
|
204
213
|
const lines = [
|
|
205
214
|
'Verify this finding by reading the real source (do not trust its wording):',
|
|
206
215
|
'',
|
|
@@ -214,6 +223,9 @@ export function buildVerifierTask(finding) {
|
|
|
214
223
|
if (finding.evidence) {
|
|
215
224
|
lines.push('- code the finding claims is present (UNTRUSTED — verify it against the file):', '<<<EVIDENCE', finding.evidence, 'EVIDENCE');
|
|
216
225
|
}
|
|
226
|
+
if (opts.evidenceUngrounded) {
|
|
227
|
+
lines.push('', 'NOTE: the quoted evidence could NOT be located verbatim in the file. It may be', 'a paraphrase, an elision, or a slightly wrong location — do not reject on that', 'basis alone. Read the file (and nearby files) and judge whether the described', 'problem is genuinely present.');
|
|
228
|
+
}
|
|
217
229
|
lines.push('', 'Open the file, find the relevant code, and return the single verdict JSON object.');
|
|
218
230
|
return lines.join('\n');
|
|
219
231
|
}
|
package/build/core/render.js
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { fingerprintFinding, SEVERITIES, SEVERITY_RANK } from './schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build the file → right-side-line-numbers index from changed files' patch text,
|
|
5
|
+
* by walking each unified-diff hunk (`@@ -a,b +c,d @@`) and collecting the new-tree
|
|
6
|
+
* line number of every added (`+`) and context (` `) line. Deleted (`-`) lines have
|
|
7
|
+
* no right-side line and are skipped.
|
|
8
|
+
*/
|
|
9
|
+
export function buildDiffLineIndex(files) {
|
|
10
|
+
const index = new Map();
|
|
11
|
+
const hunkRe = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
const lines = new Set();
|
|
14
|
+
let right = 0;
|
|
15
|
+
let inHunk = false;
|
|
16
|
+
for (const raw of file.patch.split('\n')) {
|
|
17
|
+
const hunk = hunkRe.exec(raw);
|
|
18
|
+
if (hunk) {
|
|
19
|
+
right = parseInt(hunk[1], 10);
|
|
20
|
+
inHunk = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!inHunk || raw.startsWith('+++') || raw.startsWith('---') || raw.startsWith('\\')) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const marker = raw[0];
|
|
27
|
+
if (marker === '+' || marker === ' ') {
|
|
28
|
+
lines.add(right);
|
|
29
|
+
right++;
|
|
30
|
+
}
|
|
31
|
+
// '-' is left-side only (no new-tree line); anything else is ignored.
|
|
32
|
+
}
|
|
33
|
+
if (lines.size > 0) {
|
|
34
|
+
index.set(file.path, lines);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
3
39
|
const DECISION_LABEL = {
|
|
4
40
|
approve: 'Approve',
|
|
5
41
|
approve_with_comments: 'Approve with comments',
|
|
@@ -30,19 +66,36 @@ function locationText(finding) {
|
|
|
30
66
|
return finding.line != null ? `${finding.file}:${finding.line}` : finding.file;
|
|
31
67
|
}
|
|
32
68
|
/**
|
|
33
|
-
* Render a finding's location as inline code, linked to the
|
|
34
|
-
* PR's "Files changed" tab
|
|
35
|
-
* diff
|
|
69
|
+
* Render a finding's location as inline code, linked to the code it points at:
|
|
70
|
+
* - in the diff (file+line shown in a hunk) → the PR's "Files changed" tab at that
|
|
71
|
+
* line (`#diff-<sha256(path)>R<n>`), so the reader lands in the review diff;
|
|
72
|
+
* - not in the diff (unchanged code the PR references, e.g. a caller/helper) → the
|
|
73
|
+
* source blob on the PR base at that line (`/blob/<baseSha>/<path>#L<n>`);
|
|
74
|
+
* - if neither is possible (no link context / no base SHA) → plain inline code.
|
|
75
|
+
* Never emits a dead diff anchor for a line that isn't in the diff.
|
|
36
76
|
*/
|
|
37
77
|
function location(finding, link) {
|
|
38
78
|
const text = locationText(finding);
|
|
39
79
|
if (!link) {
|
|
40
80
|
return `\`${text}\``;
|
|
41
81
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
82
|
+
const fileLines = link.diffLines?.get(finding.file);
|
|
83
|
+
// In the diff when the file is present and (if the finding names a line) that line
|
|
84
|
+
// is one of the diff's right-side lines. A file-level finding (no line) counts as
|
|
85
|
+
// in-diff as long as the file appears in the diff.
|
|
86
|
+
const inDiff = fileLines != null && (finding.line == null || fileLines.has(finding.line));
|
|
87
|
+
if (inDiff) {
|
|
88
|
+
const fileHash = createHash('sha256').update(finding.file).digest('hex');
|
|
89
|
+
const anchor = finding.line != null ? `diff-${fileHash}R${finding.line}` : `diff-${fileHash}`;
|
|
90
|
+
const url = `https://github.com/${link.repo}/pull/${link.prNumber}/files#${anchor}`;
|
|
91
|
+
return `[\`${text}\`](${url})`;
|
|
92
|
+
}
|
|
93
|
+
if (link.baseSha) {
|
|
94
|
+
const lineAnchor = finding.line != null ? `#L${finding.line}` : '';
|
|
95
|
+
const url = `https://github.com/${link.repo}/blob/${link.baseSha}/${finding.file}${lineAnchor}`;
|
|
96
|
+
return `[\`${text}\`](${url})`;
|
|
97
|
+
}
|
|
98
|
+
return `\`${text}\``;
|
|
46
99
|
}
|
|
47
100
|
/**
|
|
48
101
|
* GitHub comment body. The marker + embedded state enable in-place updates and
|
package/build/core/verify.js
CHANGED
|
@@ -10,16 +10,46 @@ const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
|
10
10
|
// Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
|
|
11
11
|
const MIN_EVIDENCE_LEN = 12;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Break `evidence` into normalized, substantive fragments for fuzzy matching:
|
|
14
|
+
* split on newlines AND ellipses (the model often elides with `…`/`...`), strip
|
|
15
|
+
* leading diff markers (`+`/`-`) and comment markers (`//`, `#`, `*`) it may have
|
|
16
|
+
* copied along, normalize, and keep only fragments long enough to be meaningful.
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function evidenceFragments(evidence) {
|
|
19
|
+
return evidence
|
|
20
|
+
.split(/\r?\n|…|\.\.\./)
|
|
21
|
+
.map(line => line.replace(/^[+\-\s]*/, '').replace(/^(\/\/+|#+|\*+|\/\*)\s?/, ''))
|
|
22
|
+
.map(normalizeCode)
|
|
23
|
+
.filter(fragment => fragment.length >= MIN_EVIDENCE_LEN);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Does the finding's `evidence` correspond to code in the file?
|
|
27
|
+
* - exact (whitespace-normalized) substring → 'present'
|
|
28
|
+
* - else any substantive line/fragment present verbatim → 'present' (fuzzy: this
|
|
29
|
+
* rescues cross-line quotes, ellipsis elisions, and copied comment/diff markers)
|
|
30
|
+
* - a real quote that matches nothing → 'absent'
|
|
31
|
+
* Returns 'unknown' (don't judge) when evidence is too short to conclude anything
|
|
32
|
+
* or the file can't be read, so we never drop a finding we couldn't actually check.
|
|
33
|
+
* NOTE: 'absent' is NOT terminal — the caller escalates it to the LLM verifier
|
|
34
|
+
* rather than dropping, because an imperfect quote does not mean a false finding.
|
|
35
|
+
*/
|
|
36
|
+
export function matchEvidence(evidence, content) {
|
|
37
|
+
const normEvidence = normalizeCode(evidence);
|
|
38
|
+
if (normEvidence.length < MIN_EVIDENCE_LEN) {
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
const normContent = normalizeCode(content);
|
|
42
|
+
if (normContent.includes(normEvidence)) {
|
|
43
|
+
return 'present';
|
|
44
|
+
}
|
|
45
|
+
const fragments = evidenceFragments(evidence);
|
|
46
|
+
if (fragments.length === 0) {
|
|
21
47
|
return 'unknown';
|
|
22
48
|
}
|
|
49
|
+
return fragments.some(fragment => normContent.includes(fragment)) ? 'present' : 'absent';
|
|
50
|
+
}
|
|
51
|
+
/** Read the cited file and grade the evidence against it (see matchEvidence). */
|
|
52
|
+
async function evidencePresence(finding, cwd) {
|
|
23
53
|
let content;
|
|
24
54
|
try {
|
|
25
55
|
content = await readFile(path.resolve(cwd, finding.file), 'utf8');
|
|
@@ -27,67 +57,73 @@ async function evidencePresence(finding, cwd) {
|
|
|
27
57
|
catch {
|
|
28
58
|
return 'unknown';
|
|
29
59
|
}
|
|
30
|
-
return
|
|
60
|
+
return matchEvidence(finding.evidence ?? '', content);
|
|
31
61
|
}
|
|
32
62
|
/**
|
|
33
|
-
* Guard against hallucinated findings before they're surfaced
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
63
|
+
* Guard against hallucinated findings before they're surfaced, WITHOUT silently
|
|
64
|
+
* dropping real ones on an imperfect quote:
|
|
65
|
+
* 1. Quote-grounding (deterministic, all findings): grade each finding's `evidence`
|
|
66
|
+
* against the file (exact + fuzzy — see matchEvidence).
|
|
67
|
+
* 2. LLM verify (adversarial, in parallel) runs for a finding when EITHER:
|
|
68
|
+
* - its evidence is `absent` (any severity) — the quote isn't grounded, but
|
|
69
|
+
* that alone doesn't make the finding false, so the verifier re-reads the
|
|
70
|
+
* real file (and nearby files) and judges the underlying problem; or
|
|
71
|
+
* - it's a `critical` (even if grounded) — a skeptical double-check.
|
|
72
|
+
* A finding is dropped ONLY when the verifier refutes it. `present`/`unknown`
|
|
73
|
+
* non-criticals are kept without an LLM call (the fast, cheap path).
|
|
74
|
+
* Fails OPEN — if a verify call itself errors, the finding is kept (better a
|
|
75
|
+
* possible false positive than hiding a real finding on an infra hiccup).
|
|
76
|
+
*
|
|
77
|
+
* This replaces the old "absent evidence → hard drop" rule, which was suppressing
|
|
78
|
+
* real findings whose natural evidence (a structural/absence bug, a cross-line
|
|
79
|
+
* quote, a slightly-wrong location) wasn't a verbatim substring.
|
|
40
80
|
*/
|
|
41
81
|
export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
42
82
|
const dropped = [];
|
|
43
83
|
let cost = 0;
|
|
44
84
|
const tokens = {};
|
|
45
|
-
// Phase 1 — quote-grounding for every finding.
|
|
85
|
+
// Phase 1 — deterministic quote-grounding for every finding.
|
|
46
86
|
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
47
|
-
|
|
87
|
+
// Decide which findings need an LLM check vs. can be kept directly.
|
|
88
|
+
const verdicts = new Map();
|
|
89
|
+
const toVerify = [];
|
|
48
90
|
for (const { finding, presence } of checked) {
|
|
49
|
-
if (presence === 'absent') {
|
|
50
|
-
|
|
51
|
-
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — quoted code not in ${finding.file}`);
|
|
91
|
+
if (presence === 'absent' || finding.severity === 'critical') {
|
|
92
|
+
toVerify.push({ finding, presence });
|
|
52
93
|
}
|
|
53
94
|
else {
|
|
54
|
-
|
|
95
|
+
verdicts.set(finding, 'keep'); // grounded (or uncheckable) non-critical
|
|
55
96
|
}
|
|
56
97
|
}
|
|
57
|
-
// Phase 2 —
|
|
58
|
-
|
|
59
|
-
await Promise.all(survivors
|
|
60
|
-
.filter(finding => finding.severity === 'critical')
|
|
61
|
-
.map(async (finding, index) => {
|
|
98
|
+
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
|
|
99
|
+
await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
|
|
62
100
|
try {
|
|
63
101
|
const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
|
|
64
102
|
agent: VERIFIER_AGENT,
|
|
65
103
|
system: buildVerifierSystem(),
|
|
66
|
-
text: buildVerifierTask(finding),
|
|
104
|
+
text: buildVerifierTask(finding, { evidenceUngrounded: presence === 'absent' }),
|
|
67
105
|
title: `verify-${index}`,
|
|
68
106
|
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
69
107
|
finalizeOnTimeout: true,
|
|
70
108
|
}, parseVerdict);
|
|
71
109
|
cost += verifyCost;
|
|
72
110
|
addTokenUsage(tokens, verifyTokens);
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
if (value.verified) {
|
|
112
|
+
verdicts.set(finding, 'keep');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
verdicts.set(finding, 'drop');
|
|
116
|
+
dropped.push({ finding, reason: value.reason || 'refuted by verifier' });
|
|
117
|
+
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
|
|
76
118
|
}
|
|
77
119
|
}
|
|
78
120
|
catch (error) {
|
|
79
|
-
// Fail open: keep the
|
|
80
|
-
|
|
121
|
+
// Fail open: keep the finding if verification itself failed.
|
|
122
|
+
verdicts.set(finding, 'keep');
|
|
123
|
+
onProgress?.(` verify: could not verify "${finding.title}" (${errorMessage(error)}); keeping it`);
|
|
81
124
|
}
|
|
82
125
|
}));
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (refuted.has(finding)) {
|
|
86
|
-
dropped.push({ finding, reason: 'refuted by verifier' });
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
kept.push(finding);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
126
|
+
// Preserve original order.
|
|
127
|
+
const kept = findings.filter(finding => verdicts.get(finding) === 'keep');
|
|
92
128
|
return { kept, dropped, cost, tokens };
|
|
93
129
|
}
|
|
@@ -2,7 +2,8 @@ import { writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { run } from '../core/exec.js';
|
|
5
|
-
import {
|
|
5
|
+
import { parseUnifiedDiff } from '../core/diff.js';
|
|
6
|
+
import { buildDiffLineIndex, commentMarker, parseReviewState, renderMarkdown } from '../core/render.js';
|
|
6
7
|
import { fingerprintFinding } from '../core/schema.js';
|
|
7
8
|
const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
8
9
|
/**
|
|
@@ -33,11 +34,43 @@ export class GitHubReporter {
|
|
|
33
34
|
const dismissed = existing
|
|
34
35
|
? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
|
|
35
36
|
: [];
|
|
36
|
-
|
|
37
|
+
const link = await this.linkContextAsync();
|
|
38
|
+
await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
|
|
37
39
|
}
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* PR context for turning finding locations into links: the set of lines actually
|
|
42
|
+
* in the diff (for in-diff findings → diff-anchor links) and the base commit SHA
|
|
43
|
+
* (for out-of-diff findings → source-blob links on the base). Both fetches fail
|
|
44
|
+
* soft — a missing piece just degrades to a plain-text location, never a dead link.
|
|
45
|
+
*/
|
|
46
|
+
async linkContextAsync() {
|
|
47
|
+
const link = { repo: this.options.repo, prNumber: this.options.prNumber };
|
|
48
|
+
const prArgs = [String(this.options.prNumber), '--repo', this.options.repo];
|
|
49
|
+
const cwd = this.options.cwd;
|
|
50
|
+
await Promise.all([
|
|
51
|
+
(async () => {
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await run('gh', ['pr', 'diff', ...prArgs], { cwd });
|
|
54
|
+
link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// leave diffLines unset → in-diff findings degrade to plain text
|
|
58
|
+
}
|
|
59
|
+
})(),
|
|
60
|
+
(async () => {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await run('gh', ['pr', 'view', ...prArgs, '--json', 'baseRefOid'], { cwd });
|
|
63
|
+
const oid = JSON.parse(stdout).baseRefOid;
|
|
64
|
+
if (oid) {
|
|
65
|
+
link.baseSha = oid;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// leave baseSha unset → out-of-diff findings degrade to plain text
|
|
70
|
+
}
|
|
71
|
+
})(),
|
|
72
|
+
]);
|
|
73
|
+
return link;
|
|
41
74
|
}
|
|
42
75
|
/**
|
|
43
76
|
* Add or remove per-PR finding dismissals in the reviewer's comment and re-render
|
|
@@ -61,7 +94,8 @@ export class GitHubReporter {
|
|
|
61
94
|
dismissed.push({ fp, by, reason });
|
|
62
95
|
}
|
|
63
96
|
}
|
|
64
|
-
|
|
97
|
+
const link = await this.linkContextAsync();
|
|
98
|
+
await this.patchComment(existing.id, renderMarkdown(state.review, this.options.commentTag, dismissed, link));
|
|
65
99
|
return { dismissedCount: dismissed.length, matched, unmatched };
|
|
66
100
|
}
|
|
67
101
|
/** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
|
package/package.json
CHANGED
package/templates/shared.md
CHANGED
|
@@ -65,7 +65,7 @@ Return **only** a single fenced ```json code block, an object of this shape:
|
|
|
65
65
|
"line": 142,
|
|
66
66
|
"title": "short one-line summary",
|
|
67
67
|
"rationale": "why this is a problem, with the concrete failure/exploit path",
|
|
68
|
-
"evidence": "
|
|
68
|
+
"evidence": "one contiguous line of the flagged code, copied VERBATIM",
|
|
69
69
|
"suggestion": "optional concrete fix, or omit"
|
|
70
70
|
}
|
|
71
71
|
]
|
|
@@ -73,7 +73,9 @@ Return **only** a single fenced ```json code block, an object of this shape:
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
`line` is the start line in the new version of the file, or `null` if not
|
|
76
|
-
line-specific. `evidence`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
line-specific. `evidence` is used to help verify the finding, so make it easy to
|
|
77
|
+
locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning
|
|
78
|
+
multiple lines, no `…` elisions, no paraphrasing). For a structural/"missing" issue,
|
|
79
|
+
quote the single most relevant real line (e.g. the early `return` that skips the
|
|
80
|
+
handling). If you have nothing to report, return `{ "findings": [] }`. Emit no prose
|
|
81
|
+
outside the JSON block.
|