Skip to content

fix(code-review): surface discarded findings; stop treating absence as disproof (ISS-10711) - #203

Open
cchenault4 wants to merge 3 commits into
mainfrom
fix/iss-10711-review-root-scope
Open

cchenault4 wants to merge 3 commits into
mainfrom
fix/iss-10711-review-root-scope

Conversation

@cchenault4

Copy link
Copy Markdown

Closes the two ISS-10711 remedies that the cause fix did not cover.

Context: the cause is already fixed on main

ISS-10711 was filed against a machine pinned to plugin 3.1.0, where review_root was populated only for scope_kind == "pr" and both agent prompts told the fleet to fall back to the current working directory when it was empty. A pre-PR branch-scope review therefore ran its whole verifier fleet against the invoking checkout: eight verifiers, seven rejecting real findings at 0.95 confidence, and an APPROVED on a change carrying two P1 defects.

None of that is true of main. review_root is now always populated (worktree_path or _git_toplevel()), _require_review_root refuses a root it cannot prove holds the diff, and the cwd fallback is gone from both prompts.

This PR is only the part that survived that fix. Two remedies from the ticket were verifier-stage and rollup-stage, independent of where source is read, and both were still reachable on main.

1. An APPROVED must name what verification discarded

_compute_canonical_verdict ended in return "APPROVED", "". An APPROVED carrying an empty reason is indistinguishable from a review that found nothing, so a run whose every finding was discarded reads as clean — which is precisely how ISS-10711 went unnoticed until a lane distrusted it and re-read the findings by hand.

The verdict now carries e.g. 7 finding(s) rejected by verification (evidence_not_found x7).

Gating is deliberately unchanged. A rejected finding is by definition not gating, and every input returns the verdict it returned before; only the reason string differs. The reason is composed to fit _VERDICT_REASON_MAX rather than truncated to it, because a reason chopped mid-class reads as a different class — the count is the load-bearing half and survives when classes have to be dropped.

2. "Could not locate it" and "is not there" are different claims

The EXISTENCE CHECK collapsed every form of absence into REJECTED / evidence_not_found. It now splits three ways:

  • cited file missing under review_rootTENTATIVE. Equally well explained by a rename, a bad path, or a tree that is not the one this diff describes.
  • snippet present in the patch hunks as an ADDED line but absent from review_rootTENTATIVE, reported as a scope or checkout fault in the run rather than evidence against the finding. This is ISS-10711's fingerprint, and it would have caught that run even with the old review_root bug fully intact.
  • file exists, was read, and neither the snippet nor any snippet_hash matches, and it is not an added line in the patch → REJECTED. This is now the only branch carrying positive disconfirmation.

Narrow on purpose. A blanket downgrade would let every hallucinated finding survive as TENTATIVE, and TENTATIVE at MEDIUM promotes to NEEDS_ATTENTION — trading a false green for a false red across every review.

No code-side detector was added for the same condition: _require_review_root already refuses a root missing or drifted on any file the diff changes, so a mechanical check would be redundant.

Test plan

  • pytest plugins/code-review/1405 passed, 3 skipped, 0 failed.
  • ruff check on both touched Python files → 210 findings before, 210 after; none introduced.
  • 8 tests added, including an end-to-end assertion through cmd_finalize_result. The unit tests alone cannot catch a call site that forgets to pass rejected, and that wiring is the fix.
  • Counterfactuals run, both directions: reverting the reason to "" kills 4 unit tests; dropping the rejected=rejected argument at the call site kills the end-to-end test. The 3 unit tests that survive the first mutation are the ones asserting unchanged behaviour, which is correct.
  • The end-to-end fixture asserts validation_errors == [], so it cannot hide a real envelope regression in its own schema noise.

Editing verifier_prompt.txt changes verifier_prompt_hash, which correctly invalidates verdicts cached under the old ladder. test_verifier_prompt_changes_hash already pins that property.

Not covered

  • Remedy 4 from the ticketresolve-scope comparing against a local ref rather than origin/<base>, which silently widened one run's scope by 29 files of other people's work. Distinct trigger from the review_root cause; not verified against 3.7.0 and should not be assumed fixed alongside it.
  • ISS-10638 (resolve-scope prepending origin/ to a SHA base) remains its own ticket.

🤖 Generated with Claude Code

https://claude.ai/code/session_017hHB3pezXXMQdJZUdTjJ95

cchenault4 and others added 3 commits September 21, 2026 14:34
…S-10711)

An APPROVED carrying `reason: ""` is indistinguishable from a review that
found nothing. In ISS-10711 seven of eight verifiers rejected real findings
-- two P1s, one writing permanently-wrong rows into an append-only audit
ledger -- and the rollup emitted APPROVED with an empty reason. Nothing in
the output said anything had been discarded, so the run looked spotless.

`_compute_canonical_verdict` now takes the rejected bucket and composes a
reason naming the count and the dominant rejection classes. Gating is
deliberately unchanged: a rejected finding is by definition not gating, and
the verdict returned for every input is what it was before.

The reason is composed to fit `_VERDICT_REASON_MAX` rather than truncated to
it -- a reason chopped mid-class reads as a different class -- and the count
survives when classes have to be dropped.

Scope note: the underlying wrong-checkout cause ISS-10711 was filed for is
already fixed on main. `review_root` is now always populated (worktree_path
or `_git_toplevel()`), `_require_review_root` refuses a run it cannot prove,
and both agent prompts tell the fleet to STOP rather than fall back to the
working directory. This commit closes the remaining auditability half.

Tests: 8 added, including an end-to-end assertion through
`cmd_finalize_result` -- the unit tests alone cannot catch a call site that
forgets to pass `rejected`, and that wiring is the fix. Counterfactuals run:
reverting the reason kills 4 unit tests; dropping the call-site argument
kills the end-to-end test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hHB3pezXXMQdJZUdTjJ95
… existence check (ISS-10711)

The EXISTENCE CHECK collapsed every form of absence into REJECTED /
evidence_not_found. Absence splits three ways and only one of them
disproves a finding.

- cited file missing under review_root -> TENTATIVE. A missing file is
  equally well explained by a rename, a bad path, or a tree that is not the
  one this diff describes.
- snippet present in the patch hunks as an ADDED line but absent from
  review_root -> TENTATIVE, reported as a scope or checkout fault in the
  run. This is the ISS-10711 fingerprint: a tree holding none of the
  branch's new code makes every finding about it look fabricated, which is
  how eight verifiers rejected seven real findings at 0.95 confidence.
- file exists, read, and neither the snippet nor any snippet_hash matches,
  and it is not an added line in the patch -> REJECTED. This is now the
  only branch carrying positive disconfirmation.

Narrow on purpose. A blanket downgrade would let every hallucinated finding
survive as TENTATIVE, and TENTATIVE at MEDIUM promotes to NEEDS_ATTENTION --
that would trade a false green for a false red across every review.

No code-side detector added: `_require_review_root` already refuses a root
that is missing, or has drifted from, any file the diff changes, so a
mechanical check of the same condition would be redundant.

Editing this prompt changes verifier_prompt_hash, which correctly
invalidates cached verdicts produced under the old ladder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hHB3pezXXMQdJZUdTjJ95
Required by the Plugin Version Bump CI job: any change under plugins/<name>/
must bump that plugin's manifest.

Minor rather than patch, following the repo's own convention -- a fix that
changes observable behaviour takes a minor (3.9.0 "resolve review paths
against the reviewed worktree", 3.6.0), while a test-only or narrow fix
takes a patch (3.8.1, 3.7.1). This PR changes verifier verdict semantics
and the content of the verdict reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hHB3pezXXMQdJZUdTjJ95

@mikeangstadt mikeangstadt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix, and the reason-composition-not-truncation detail is the right call. Ran the 8 new tests, they pass. One real question on the first prompt bullet and two contract-hygiene things inline, none blocking.

"I could not locate it" and "it is not there" are different claims.

- **The cited file does not exist under `review_root`** → TENTATIVE.
A missing file is equally well explained by a rename, a bad path, or a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the bullet I'd push on. Bullets 2 and 3 are tightly scoped, but "cited file does not exist under review_root" is also the exact signature of the most common reviewer hallucination: an agent inventing a plausible path it never read. TENTATIVE lands the finding in verified[] via the bucketing at code_review_helpers.py:3850, and Rule 3.5 turns any TENTATIVE into NEEDS_ATTENTION for the whole run. So a single fabricated path now reds the review instead of being dismissed. The PR body says a blanket downgrade would let hallucinated findings survive as TENTATIVE, but that is what this bullet does for the hallucinated-path class specifically. _require_review_root does not help here either, since it only proves the root holds the files the diff changed, and a hallucinated path is by definition not one of those. False-red is the safer direction so I'm not holding the PR, but if you see NEEDS_ATTENTION noise from this, the split you want is probably "missing path is also not in the patch at all" to REJECTED, keeping TENTATIVE for the rename/drift case.

)

return "APPROVED", ""
return "APPROVED", _approved_verdict_reason(rejected)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCHEMA.md:278 still says "verdict_reason cites the specific finding(s) that produced the verdict." After this, on APPROVED it cites the findings that specifically did not. That doc line is the contract a downstream reader parses against, so it needs a sentence for the APPROVED case in this PR.

"name": "code-review",
"description": "Code review plugin",
"version": "3.10.0",
"version": "3.11.0",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No CHANGELOG.md in the branch. CI only enforces the version bump, so this passes green, but .githooks/pre-push blocks exactly this and CLAUDE.md says run /update-documentation before pushing. Looks like core.hooksPath isn't set locally. Worth a run before merge so the release notes aren't reconstructed later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants