@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 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
 
@@ -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, locate the code, and',
186
- 'confirm the finding against what the source ACTUALLY says.',
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 code the finding describes or quotes is not actually present as claimed',
190
- ' (it misread or invented the code),',
191
- '- the described failure/exploit cannot actually occur,',
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 it after reading the file.',
202
+ '- you cannot substantiate the underlying issue after reading the file.',
195
203
  '',
196
- 'Only mark verified=true when you have CONFIRMED, from the real source, that the',
197
- 'flagged code exists as described and the problem is genuine. When unsure, reject.',
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
  }
@@ -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 exact diff line in the
34
- * PR's "Files changed" tab when PR context is available. GitHub anchors each file's
35
- * diff as `diff-<sha256(path)>` and each right-hand (added/context) line as `…R<n>`.
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 fileHash = createHash('sha256').update(finding.file).digest('hex');
43
- const anchor = finding.line != null ? `diff-${fileHash}R${finding.line}` : `diff-${fileHash}`;
44
- const url = `https://github.com/${link.repo}/pull/${link.prNumber}/files#${anchor}`;
45
- return `[\`${text}\`](${url})`;
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
@@ -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
- * Deterministic quote-grounding: does the finding's `evidence` snippet actually
14
- * appear in the file? Returns `unknown` (don't judge) when there's too little
15
- * evidence or the file can't be read (e.g. a base-ref checkout that lacks a
16
- * PR-added file), so we never drop a finding we couldn't actually check.
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
- async function evidencePresence(finding, cwd) {
19
- const evidence = normalizeCode(finding.evidence ?? '');
20
- if (evidence.length < MIN_EVIDENCE_LEN) {
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 normalizeCode(content).includes(evidence) ? 'present' : 'absent';
60
+ return matchEvidence(finding.evidence ?? '', content);
31
61
  }
32
62
  /**
33
- * Guard against hallucinated findings before they're surfaced:
34
- * 1. Quote-grounding (deterministic, all findings): drop any whose quoted
35
- * `evidence` is definitively not in the file.
36
- * 2. Adversarial verify (LLM, criticals only): a skeptical pass re-reads the real
37
- * file and must confirm the critical is genuine; refuted criticals are dropped.
38
- * Fails OPEN if a verify call itself errors, the critical is kept (better a
39
- * possible false positive than hiding a real critical on an infra hiccup).
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
- const survivors = [];
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
- dropped.push({ finding, reason: 'quoted code not found in file (likely hallucinated)' });
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
- survivors.push(finding);
95
+ verdicts.set(finding, 'keep'); // grounded (or uncheckable) non-critical
55
96
  }
56
97
  }
57
- // Phase 2 — adversarial verify for surviving criticals, in parallel.
58
- const refuted = new Set();
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 (!value.verified) {
74
- refuted.add(finding);
75
- onProgress?.(` verify: dropped critical "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
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 critical if verification itself failed.
80
- onProgress?.(` verify: could not verify critical "${finding.title}" (${errorMessage(error)}); keeping it`);
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
- const kept = [];
84
- for (const finding of survivors) {
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 { commentMarker, parseReviewState, renderMarkdown } from '../core/render.js';
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
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, this.linkContext()));
37
+ const link = await this.linkContextAsync();
38
+ await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
37
39
  }
38
- /** PR context for turning finding locations into diff-line links. */
39
- linkContext() {
40
- return { repo: this.options.repo, prNumber: this.options.prNumber };
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
- await this.patchComment(existing.id, renderMarkdown(state.review, this.options.commentTag, dismissed, this.linkContext()));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {
@@ -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": "the exact line(s) of code you are flagging, copied VERBATIM",
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` MUST be the flagged code copied **verbatim** from the
77
- file it is used to verify the finding, and a finding whose evidence isn't found
78
- in the file is discarded (don't paraphrase or invent it). If you have nothing to
79
- report, return `{ "findings": [] }`. Emit no prose outside the JSON block.
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.