fix(review): correct diff-compression line numbers, prior-review dedupe, triage skip

Four defects, all found reviewing PR #9 (two of them by pragent-bot's own
review of that PR, which the anchoring bug then misplaced):

* compress_diff dropped context lines but copied the original `@@` hunk
  header verbatim, so the header no longer described the lines beneath it.
  parse_diff_anchors then walked stale headers and produced anchor sets
  shifted by the number of elided lines, misplacing inline comments or
  demoting them to bullets. Each surviving run of lines is now re-emitted as
  its own hunk with a recomputed `@@ -a,b +c,d @@`, so the output stays a
  valid unified diff whose numbers describe the real post-change file. The
  pseudo-marker `@@ … N context line(s) omitted … @@` is gone; it parsed as
  a hunk header and reset the anchor counter to 0. Anchoring additionally
  runs on the raw diff now, so the prompt window can never shrink the
  anchorable set.

* compress_diff's `_FILE_HEADER` regex matched diff *body* lines: a removed
  YAML `---` separator or an added `++` line was read as a file header,
  truncating the hunk and dropping its `@@` header with it. Body detection
  is now prefix-based, with a full-shape hunk-header regex.

* extract_finding_bullets could not match the bullets pragent itself posts:
  summary_bullets renders an emoji severity badge between the `-` and the
  `[SEV]` tag, which the regex rejected, so compact_prior_reviews always
  returned [] and every re-review repeated its previous findings.

* triage returning `{"lenses":[]}` — documented in .opencode/agents/triage.md
  as "no lens has surface, skip the fan-out" — ran every lens instead, since
  _intersect_with_triage mapped an empty selection to "all" and the call site
  had a second `or reviewers` fallback. `[]` and None are now distinct
  outcomes: `[]` skips, None fails open. A roster naming only unknown lens
  ids now fails open rather than silencing the review. The skip path returns
  a well-formed empty-findings response instead of "", which had landed in
  ai_review's unparseable-output branch and posted "AI review produced no
  parseable output" — a malfunction message for a normal verdict.

Also: non-URL references (a CVE id, a doc title) rendered as
`[CVE-2024-1234](CVE-2024-1234)`, a broken relative link in Gitea — now
plain text. PRAGENT_DIFF_CONTEXT and friends parse through _int_env, so a
typo logs and falls back instead of killing a review mid-flight. Removed
format_usage_section, dead since the collapsible usage block replaced it and
carrying a duplicate copy of the price-target logic.

Tests: 290 -> 301. New coverage for hunk-header fidelity before/after
compression, header-shaped content lines, the bullet round-trip against the
real renderer, and triage's three outcomes (previously untested).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
This commit is contained in:
Marcos
2026-08-20 23:05:03 +00:00
parent 78bcf6a9a0
commit 2b1cf750b7
7 changed files with 526 additions and 235 deletions
+176 -91
View File
@@ -11,11 +11,18 @@ signal:
statement. Wider context = more reading; narrower = less. Set
``context=0`` for +/- only, ``context=-1`` to disable entirely.
Elided context is not merely deleted: each surviving run of lines is
re-emitted as its *own* ``@@ -a,b +c,d @@`` hunk with recomputed line
numbers, so the output stays a valid unified diff whose line numbers
still describe the post-change file. ``parse_diff_anchors`` (and the
model) therefore read the same line numbers before and after compression.
* ``extract_finding_bullets(review_body)`` — pulls the lines of a prior
review that look like a pragent finding (``- **[SEVERITY]** `path:line` — …``)
and drops everything else. The model already has the diff — repeating the
prose ("this PR adds eval() — risky") is just token burn. Bullet-only
priors cut ~75% off prior-review bytes on a typical 4-finding review.
review that look like a pragent finding (``- 🔴 [HIGH] `path:line` — …``,
or the older ``- **[HIGH]** …`` form) and drops everything else. The model
already has the diff — repeating the prose ("this PR adds eval() — risky")
is just token burn. Bullet-only priors cut ~75% off prior-review bytes on
a typical 4-finding review.
Stdlib only. No I/O. Tolerant of malformed input — never raises.
"""
@@ -24,20 +31,22 @@ from __future__ import annotations
import re
# Diff line types. Order matters: `+++`/ `---` headers and `@@` hunk headers
# are caught before the per-line prefix check.
_FILE_HEADER = re.compile(r"^(diff --git|Index:|---|\+\+\+|@@)")
# A real hunk header: `@@ -old[,count] +new[,count] @@[ trailing section]`.
# Captures both starts, both counts, and the trailing function-context text.
# Matching the full shape (not just a `@@` prefix) matters: a *removed* line
# whose content begins with `@@` is body, not a header.
_HUNK_RE = re.compile(
r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$"
)
# Captures `- <n>[,<m>]` AND `+ <n>[,<m>]` from `@@ -a,b +c,d @@`. We use the
# `+` side to reset the new-line counter; old-side is ignored.
_HUNK_RE = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@")
# Match a pragent summary-bullet line: `- **[SEVERITY]** \`path:line\` — …`.
# Severity is uppercased critical|high|medium|low per the findings schema.
# We also accept the lower-case form (`- [high]`) used by summary_bullets.
# Match a pragent summary-bullet line, in any of the shapes the renderer has
# emitted: `- 🔴 [HIGH] \`path:line\` — …` (current, `_severity_badge`),
# `- **[HIGH]** …` (bold, pre-badge), `- [high] …` (plain, oldest).
# Anything between the bullet marker and `[SEV]` (emoji, bold markers,
# whitespace) is tolerated — it is decoration, not signal.
_FINDING_BULLET_RE = re.compile(
r"^\s*-\s*\*?\*?\[(?P<sev>critical|high|medium|low|CRITICAL|HIGH|MEDIUM|LOW)\]"
r"\*?\*?\s+(?P<rest>.+)$"
r"^\s*[-*]\s*[^\w\[]*\[(?P<sev>critical|high|medium|low)\]",
re.IGNORECASE,
)
@@ -50,10 +59,12 @@ def compress_diff(diff: str, *, context: int = 2) -> tuple[str, int, int]:
for +/- only, -1 to disable compression (raw passthrough).
Returns:
`(text, original_chars, kept_chars)`. `original_chars` is the byte
length of `diff` as given; `kept_chars` is the byte length of `text`.
On parse failure the original is returned unchanged so the worst case
is no improvement, never corruption.
`(text, original_chars, kept_chars)`. `original_chars` is the character
length of `diff` as given; `kept_chars` is the character length of
`text`. Every emitted hunk header is recomputed to match the lines
under it, so the result is a valid unified diff. Lines that are not
part of a hunk (`diff --git`, `index …`, `Binary files differ`, mode
changes) pass through verbatim.
"""
if not diff:
return diff or "", len(diff or ""), len(diff or "")
@@ -64,106 +75,180 @@ def compress_diff(diff: str, *, context: int = 2) -> tuple[str, int, int]:
lines = diff.splitlines()
out: list[str] = []
# State for the per-file walk.
i = 0
n = len(lines)
while i < n:
# Copy file headers verbatim until we hit the first `@@` hunk header.
hunk_start = i
while hunk_start < n and not lines[hunk_start].startswith("@@"):
out.append(lines[hunk_start])
hunk_start += 1
i = hunk_start
# Walk hunks, copying headers verbatim and trimming the inside.
while i < n and lines[i].startswith("@@"):
hunk_header = lines[i]
m = _HUNK_RE.match(lines[i])
if m is None:
# File header, index line, binary marker, mode change, prose —
# anything outside a hunk body. Copy verbatim.
out.append(lines[i])
i += 1
continue
# Collect the hunk body: every line until the next `@@` / file
# header / EOF. Within the body, classify each line.
body_start = i
while i < n and not _FILE_HEADER.match(lines[i]):
i += 1
body = lines[body_start:i]
i += 1
body_start = i
while i < n and _is_body_line(lines[i]):
i += 1
body = lines[body_start:i]
# Render the body, collapsing long runs of context lines to a
# `@@ … @@` marker so the reviewer still sees that there IS more
# code there, just not in this window.
rendered, _ = _render_hunk_body(body, context=context)
if rendered:
out.append(hunk_header)
out.extend(rendered)
out.extend(
_render_hunk(
body,
old_start=int(m.group(1)),
new_start=int(m.group(3)),
section=m.group(5) or "",
context=context,
)
)
text = "\n".join(out) + ("\n" if diff.endswith("\n") else "")
if not text:
# splitlines() dropped nothing-but-newlines; fall back to original.
if not text.strip():
# Nothing survived (or the input was nothing but newlines); fall back
# to the original so the worst case is no improvement, not data loss.
return diff, orig, orig
if len(text) >= orig:
# Re-emitted hunk headers can outweigh the context they replace on a
# small, densely-changed diff. Never hand back something longer than
# what we were given.
return diff, orig, orig
return text, orig, len(text)
def _render_hunk_body(body: list[str], *, context: int) -> tuple[list[str], int]:
r"""Trim `body` to `context` unchanged lines around the +/- lines.
def _is_body_line(line: str) -> bool:
r"""True if `line` belongs to the current hunk body.
Body lines are classified:
- `+` line → keep
- `-` line → keep (paired with a `+` on the new side when both exist)
- `` `` line (or empty context) → keep only within ``context`` of a +/- line
- ``\ No newline at end of file`` → drop (no signal for the reviewer)
Hunk bodies contain only ` `/`+`/`-` prefixed lines and `\ No newline at
end of file`. An empty line is a context line whose trailing space was
stripped (common in mail-formatted diffs), so it counts as body too.
Collapsed gaps of ≥ 5 lines get a single ``@@ … N context line(s) omitted … @@``
marker so the reviewer knows code was elided. Smaller gaps (14 lines)
stay silent — the marker would be longer than the elision.
The check is prefix-based *and* header-aware: a removed line reading
`---` or an added line reading `+++` (YAML document separators, setext
underlines, `--` SQL comments) is body, not a file header — the previous
implementation misread those and silently dropped the rest of the hunk.
A new file section always opens with `diff --git`, which ends the body.
"""
if context == 0:
# Keep only +/- lines; drop all context.
out = [ln for ln in body if ln.startswith("+") or ln.startswith("-")]
return out, 0
if line == "":
return True
if line.startswith("diff --git ") or line.startswith("Index: "):
return False
if _HUNK_RE.match(line):
return False
return line[0] in " +-\\"
# Find the index of every +/- line; a context line is kept if its
# distance to the nearest +/- line is ≤ context.
plus_minus_idx = [
j for j, ln in enumerate(body)
if ln.startswith("+") or ln.startswith("-")
]
if not plus_minus_idx:
# No +/- at all (rare — pure-context hunk): drop entirely.
return [], 0
keep = set()
for k in plus_minus_idx:
lo = max(0, k - context)
hi = min(len(body) - 1, k + context)
for j in range(lo, hi + 1):
def _render_hunk(
body: list[str],
*,
old_start: int,
new_start: int,
section: str,
context: int,
) -> list[str]:
r"""Trim `body` to `context` unchanged lines around its +/- lines.
Each surviving run of consecutive lines is emitted as a standalone hunk
with a recomputed ``@@ -a,b +c,d @@`` header, so post-change line numbers
stay truthful. A hunk with no +/- lines at all (pure context) is dropped
entirely; ``\ No newline at end of file`` markers are dropped as noise.
Returns the rendered lines (headers included), or [] if nothing survived.
"""
# Number every body line on both sides before anything is dropped.
numbered: list[tuple[str, int, int]] = [] # (line, old_no, new_no)
old_no, new_no = old_start, new_start
for ln in body:
if ln.startswith("\\"):
continue # `\ No newline at end of file` — no signal, no numbering
kind = ln[0] if ln else " "
if kind == "+":
numbered.append((ln, -1, new_no))
new_no += 1
elif kind == "-":
numbered.append((ln, old_no, -1))
old_no += 1
else:
numbered.append((ln, old_no, new_no))
old_no += 1
new_no += 1
changed = [j for j, (ln, _, _) in enumerate(numbered) if ln[:1] in ("+", "-")]
if not changed:
return []
keep: set[int] = set()
for k in changed:
for j in range(max(0, k - context), min(len(numbered) - 1, k + context) + 1):
keep.add(j)
out: list[str] = []
last_kept = -2 # sentinel: a gap of ≥ 5 between consecutive kept lines triggers a marker
for j, ln in enumerate(body):
if ln.startswith("\\ No newline"):
continue
if j in keep:
if j - last_kept > 5 and last_kept >= 0:
out.append(f"@@ … {j - last_kept - 1} context line(s) omitted … @@")
out.append(ln)
last_kept = j
for run in _consecutive_runs(sorted(keep)):
chunk = [numbered[j] for j in run]
old_count = sum(1 for ln, _, _ in chunk if ln[:1] != "+")
new_count = sum(1 for ln, _, _ in chunk if ln[:1] != "-")
# A run's start is the first line that exists on that side. When a
# side has no lines at all (pure addition / pure deletion), unified
# diff convention is `start = line before, count = 0`.
old_first = next((o for ln, o, _ in chunk if o >= 0), None)
new_first = next((nw for ln, _, nw in chunk if nw >= 0), None)
old_hdr = old_first if old_first is not None else max(chunk[0][1], 0)
new_hdr = new_first if new_first is not None else max(chunk[0][2], 0)
if old_count == 0:
old_hdr = _side_start_before(numbered, run[0], side=1)
if new_count == 0:
new_hdr = _side_start_before(numbered, run[0], side=2)
out.append(
f"@@ -{old_hdr},{old_count} +{new_hdr},{new_count} @@{section}"
)
out.extend(ln for ln, _, _ in chunk)
return out
return out, len(out)
def _side_start_before(
numbered: list[tuple[str, int, int]], idx: int, *, side: int
) -> int:
"""Line number on `side` (1=old, 2=new) just before body index `idx`.
Used for the zero-count header form (`@@ -7,0 +8,3 @@`), where unified
diff names the line the change is inserted *after*.
"""
for j in range(idx - 1, -1, -1):
no = numbered[j][side]
if no >= 0:
return no
# Nothing before it: derive from the first numbered line on that side.
for _, old_no, new_no in numbered:
no = old_no if side == 1 else new_no
if no >= 0:
return max(no - 1, 0)
return 0
def _consecutive_runs(indices: list[int]) -> list[list[int]]:
"""Group a sorted index list into runs of consecutive integers."""
runs: list[list[int]] = []
for j in indices:
if runs and j == runs[-1][-1] + 1:
runs[-1].append(j)
else:
runs.append([j])
return runs
def extract_finding_bullets(review_body: str) -> list[str]:
"""Pull the finding-bullet lines out of a prior review body.
Returns the matching lines verbatim (with their original indentation +
any continuation text), preserving the ``**[SEV]** `path:line` — problem
…`` shape the model emitted. Lines that look like bullets but lack the
severity tag are dropped — the reviewer synthesizes from the matched ones.
Returns the matching lines stripped of surrounding whitespace, preserving
the rendered ``[SEV] `path:line` — problem`` shape (badge emoji and bold
markers included, whichever the renderer used). Lines that look like
bullets but carry no severity tag are dropped — the reviewer synthesizes
from the matched ones. Continuation lines (` - **Fix:** …`) are not
finding lines and are dropped with the rest of the prose.
"""
if not review_body:
return []
out = []
for line in review_body.splitlines():
m = _FINDING_BULLET_RE.match(line)
if m:
if _FINDING_BULLET_RE.match(line):
out.append(line.strip())
return out
return out