Files
cleveragents-core/tools/review_validate.py
T
drew f3b10a5e72 refactor(auto-agents): rename review clone/diff substrate for implementer parity
Phase 0 of the implementer-parity plan: rename the formerly review-only
pre-clone + diff-fetch helpers so the implementer dispatcher can share
them in upcoming phases without forking the substrate or hauling
review-specific naming into a different work-group registry.

Renames (git rename-detected):
- tools/_review_clone.py    -> tools/_pr_clone.py
- tools/_review_diff.py     -> tools/_pr_diff.py

Extractions (new shared modules):
- tools/_pr_prompt.py            UNTRUSTED-CONTENT marker helpers
                                 (fence_markers, redact_marker,
                                 wrap_untrusted_section) for Phase 2
                                 pre-fetch consumers.
- tools/_commit_lint.py          lint_commit_message + CONVENTIONAL_TYPES
                                 + _bot_committer_email so both
                                 self-validation CLIs reuse one lint.
- tools/_validate_cli_common.py  DiffResult / DiffErrorKind /
                                 diff_from_worktree /
                                 commit_from_worktree / _excerpt_*
                                 / resolve_base_ref / emit_error so
                                 implementer_validate (Phase 1) does
                                 not duplicate ~280 lines of CLI
                                 plumbing.

Slim:
- tools/_review_validate_helpers.py shrinks 499 -> 223 lines and now
  owns only review-specific validate_position_in_diff +
  draft_strict_checks. Re-exports the moved helpers so existing test
  monkeypatches (helpers.diff_from_worktree, helpers.subprocess) keep
  working without churn.

API surface change:
- prepare_pr_worktree(cfg, n, sha, *, kind="review") -- back-compat
  default; implementer dispatcher passes kind="implementer" in Phase 3.
- _worktree_base / _is_preclone_disabled now consult per-kind env
  vars (REVIEW_DISPATCHER_WORKTREE_BASE vs.
  IMPLEMENTER_DISPATCHER_WORKTREE_BASE; matching DISABLE_PRECLONE
  toggles). Mirror is shared per repo regardless of kind.
- WorktreeHandle gains a `kind` field (defaulted) so cleanup paths
  can discriminate.

Bug fix discovered along the way:
- _validate_cli_common.emit_error froze stream=sys.stdout at
  function-definition time, which made pytest's capsys invisible to
  the JSON error output. Now resolves sys.stdout at call time.

Tests:
- New tests/auto_agents/test_shared_substrate.py (14 tests) covers:
  every public symbol the legacy modules exposed, helper-re-export
  identity preservation, per-kind worktree-base + disable-toggle
  semantics, the kind back-compat default, and the new _pr_prompt
  fence/redact helpers. Also guards against the deleted
  _review_clone.py / _review_diff.py reappearing on disk.
- All 24 importing call-sites updated; full reviewer test suite
  remains green (637 passed, 3 skipped).
- `git grep '_review_clone\|_review_diff' tools/` returns zero
  matches, the plan's Phase 0 exit criterion.

Sets up Phase 1 (implementer-helpers skill), Phase 2 (pre-fetch
parity), and Phase 3 (pre-cloned implementer worktrees) with no
shared reviewer-only code paths.

ISSUES CLOSED: #N/A

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 13:25:35 -04:00

422 lines
17 KiB
Python

#!/usr/bin/env python3
"""Mid-session self-validation CLI for ``pr-review-worker``.
The reviewer worker is restricted to a single structured-JSON verdict in
its final reply, but several decisions before that emit are easy for an
LLM to get wrong on the first try:
- Conventional-commit subject format on every commit.
- ``ISSUES CLOSED: #N`` footer on the head commit.
- Inline-comment ``(path, new_position)`` placement against the actual
diff (a stale or hallucinated line number 422s the entire review).
- Final verdict shape (``event`` value, presence of required fields).
This module exposes those same checks as a host-side CLI the worker
invokes via ``bash`` while it is still drafting. The worker's prompt
already knows how to write a file with ``printf > /tmp/...`` and run
``python3 tools/<script>.py ...`` per :doc:`bash-commands`. Keeping the
implementation in Python (rather than reimplementing in TypeScript like
the existing ``.opencode/skills/auto-agents-system/scripts/`` set)
means the JSON-shape validator (:func:`cmd_validate_draft`) calls
``_review_parser.parse_worker_review_json`` directly — it is the exact
same code path the dispatcher runs post-session, so a draft that passes
that validator cannot be rejected by the dispatcher for shape reasons.
The other two validators (:func:`cmd_validate_inline_comment`,
:func:`cmd_lint_commit`) are *strictly stronger* than the dispatcher:
they catch issues Forgejo enforces post-submit (HTTP 422 for a
mis-anchored inline comment) and project-policy issues nothing
enforces today (conventional-commit subject prefix / ISSUES CLOSED
footer per CONTRIBUTING.md). They do not promise dispatcher parity —
the dispatcher is silent on these conditions — they promise that a
draft that passes them is at least as well-formed as a draft that
silently slips past the dispatcher.
Subcommands (backed by reviewer-specific helpers in
:mod:`_review_validate_helpers` plus shared substrate from
:mod:`_validate_cli_common` and :mod:`_commit_lint` so this entry
point stays under the project's 500-line per-file budget):
- :func:`cmd_validate_draft` — wrap
:func:`tools._review_parser.parse_worker_review_json` and surface
``{ok, errors[], normalised}`` so the worker can self-check before
emitting its final JSON. Adds a strict ``commit_id == head_sha``
check on top of the parser's lenient default (Forgejo enforces it
with HTTP 422 post-submit).
- :func:`cmd_validate_inline_comment` — confirm a planned inline
comment's ``(path, new_position)`` lands on an actual ``+``-line
of the PR's diff. Reads the diff from the dispatcher's pre-clone
worktree when ``--worktree`` is supplied; otherwise from a
``--diff-file`` the worker wrote.
- :func:`cmd_lint_commit` — conventional-commit subject prefix +
``ISSUES CLOSED: #N`` footer (head only) + bot-author exemption
against a single commit. Reads commit metadata from the
dispatcher's pre-clone worktree by SHA.
Each subcommand prints exactly one JSON object on stdout and exits 0
when the validator ran (regardless of whether validation passed —
structured rejections are themselves a successful CLI run), 2 when
the CLI itself could not run (unreadable draft file, git-internal
failure under ``--worktree``). Argparse-detected usage errors
(missing required flag, both ``--worktree`` and ``--diff-file``,
etc.) are caught by argparse and produce a usage error on stderr
with its own ``SystemExit(2)`` before any subcommand runs. The
worker reads stdout JSON and decides whether to revise its draft
or emit it as-is.
Why one CLI dispatcher rather than three separate scripts: a single
``bash`` allow-list entry (``python3 tools/review_validate.py *``)
covers all current and future subcommands and the script imports the
canonical Python validators from :mod:`_review_parser`,
:mod:`_review_validate_helpers`, :mod:`_validate_cli_common`, and
:mod:`_commit_lint` without duplicating them.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Make sibling tools/ modules importable regardless of how this CLI is
# invoked (``python3 tools/review_validate.py`` from repo root, or by
# absolute path from anywhere). Mirrors the prelude every other entry
# point in this directory uses.
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
_review_parser = _load_sibling("_review_parser", "_review_parser.py")
_helpers = _load_sibling(
"_review_validate_helpers", "_review_validate_helpers.py"
)
_validate_common = _load_sibling(
"_validate_cli_common", "_validate_cli_common.py"
)
_commit_lint = _load_sibling("_commit_lint", "_commit_lint.py")
# Default merge base for ``validate-inline-comment --worktree``.
# The actual fallback chain lives in
# :func:`_validate_cli_common.resolve_base_ref` — this constant is
# kept for back-compat with tests / log lines that quote it.
_DEFAULT_BASE_REF = "origin/master"
# Re-export of :func:`_validate_cli_common._resolve_base_ref` so
# tests that monkeypatch ``review_validate._resolve_base_ref``
# continue to work without needing a churn-only edit to switch them
# over to the shared module. New code should call
# :func:`_validate_common.resolve_base_ref` directly.
_resolve_base_ref = _validate_common._resolve_base_ref
# ─── Subcommand: validate-draft ────────────────────────────────────────────
def cmd_validate_draft(args: argparse.Namespace) -> int:
"""Validate a worker draft JSON against the dispatcher's strict parser.
Reads the candidate verdict from ``--draft-file`` (the worker writes
it via ``printf > /tmp/...`` per the bash-commands rules), runs it
through :func:`_review_parser.parse_worker_review_json` followed by
:func:`_helpers.draft_strict_checks`, and emits a machine-readable
result so the worker can self-correct without the dispatcher ever
seeing the malformed draft.
Stdout shape::
{"ok": true, "normalised": {<parsed dict>}}
{"ok": false, "errors": [{"message": "...", "excerpt": "..."}]}
Exit code: 0 on validation pass, 0 on validation FAIL (the
validator ran successfully and reported issues — that's a
successful run of the CLI), 2 on usage error (missing draft
file etc.).
"""
try:
raw = Path(args.draft_file).read_text(encoding="utf-8", errors="replace")
except OSError as exc:
_validate_common._emit_error(
f"could not read --draft-file {args.draft_file!r}: {exc}"
)
return 2
try:
normalised = _review_parser.parse_worker_review_json(
raw, review_type=args.review_type, head_sha=args.head_sha
)
except _review_parser.WorkerJSONError as exc:
print(
json.dumps(
{
"ok": False,
"errors": [
{"message": exc.message, "excerpt": exc.excerpt}
],
}
)
)
return 0
strict_errors = _helpers.draft_strict_checks(
normalised, head_sha=args.head_sha, raw_draft=raw
)
if strict_errors:
print(json.dumps({"ok": False, "errors": strict_errors}))
return 0
print(json.dumps({"ok": True, "normalised": normalised}))
return 0
# ─── Subcommand: validate-inline-comment ──────────────────────────────────
def cmd_validate_inline_comment(args: argparse.Namespace) -> int:
"""Validate ``(path, new_position)`` lands on an added line.
Diff source (in priority order):
1. ``--worktree``: the dispatcher's pre-clone path. Run
``git -C <worktree> diff <base-ref>...HEAD -- <path>``, where
``<base-ref>`` defaults to ``origin/master`` and is overridable
via ``--base-ref`` (or the ``REVIEW_VALIDATE_BASE_REF`` env var
the dispatcher exports for the worker session). Local, no API
quota cost. The worker is expected to pass this when the
prompt's ``## Pre-cloned working copy`` section gave it a
``repo_dir``.
2. ``--diff-file``: a file the worker wrote containing the unified
diff for ``path`` (or the whole PR). Used when the worker
already sliced the embedded prompt diff into ``/tmp``.
Stdout shape::
{"ok": true, "matched_line": "<the + line content>"}
{"ok": false, "reason": "path-not-in-diff" | "line-not-added" |
"line-out-of-range",
"details": "<why>"}
Exit code: 0 on valid CLI run (regardless of placement), 2 on
usage error or git-internal failure (timeout / unreadable repo /
bad base-ref). The error kind is surfaced in the JSON output so
the worker does not silently treat git timeouts as
``path-not-in-diff``.
"""
# argparse's mutually-exclusive group enforces "exactly one of
# --worktree / --diff-file" with required=True, so reaching this
# function means args has exactly one of the two set.
diff_text: str
if args.worktree:
base_ref = _resolve_base_ref(args.base_ref)
diff = _validate_common.diff_from_worktree(
args.worktree, args.path, base_ref=base_ref
)
if diff.error_kind in ("git-timeout", "git-not-found", "git-error"):
# Distinguish validator-internal failure from "path not in
# diff" so the worker does not silently drop comments due
# to a slow disk / busy mirror / typo'd base-ref.
_validate_common._emit_error(
f"git failed for path={args.path!r} ({diff.error_kind}): "
f"{diff.stderr.strip() or '(no stderr)'}",
error_kind=diff.error_kind,
)
return 2
if diff.error_kind == "empty-output":
print(
json.dumps(
{
"ok": False,
"reason": "path-not-in-diff",
"details": (
f"git diff produced no output for path={args.path!r}; "
"the file is not part of this PR's changeset"
),
}
)
)
return 0
# ``error_kind is None`` is the success case; ``DiffResult``'s
# invariant guarantees ``text`` is a non-empty string here.
# The runtime check is a belt-and-suspenders guard for a
# future ``DiffResult`` constructor regression; ``assert``
# would be stripped under ``python -O``.
if diff.text is None:
raise RuntimeError(
"DiffResult invariant violated: error_kind=None but "
"text=None; this indicates a bug in diff_from_worktree"
)
diff_text = diff.text
else:
try:
diff_text = Path(args.diff_file).read_text(
encoding="utf-8", errors="replace"
)
except OSError as exc:
_validate_common._emit_error(f"could not read --diff-file: {exc}")
return 2
result = _helpers.validate_position_in_diff(
diff_text, args.path, args.new_position
)
print(json.dumps(result))
return 0
# ─── Subcommand: lint-commit ──────────────────────────────────────────────
def cmd_lint_commit(args: argparse.Namespace) -> int:
"""Check one commit against the project's conventional-commit rules.
Reads ``(message, committer_email)`` from
``git -C <worktree> log -1 --pretty=...`` so a multi-line commit
body is handled losslessly. ``--is-head`` activates the
``ISSUES CLOSED: #N`` footer check (only the head commit is
required to carry it per CONTRIBUTING.md).
Stdout shape::
{"ok": true, "exempt_bot": <bool>, "violations": []}
{"ok": false, "exempt_bot": <bool>,
"violations": [{"rule": "subject-prefix" | "issues-closed-footer",
"details": "..."}]}
Exit code: 0 on valid CLI run, 2 on usage error.
``--worktree`` and ``--sha`` are declared ``required=True`` on
the subparser so missing-flag enforcement runs in argparse
before this function is reached; we never need a manual
re-check here.
"""
info = _commit_from_worktree(args.worktree, args.sha)
if info is None:
_validate_common._emit_error(
f"git could not read commit {args.sha!r} from worktree "
f"{args.worktree!r}"
)
return 2
message, committer_email = info
result = _commit_lint.lint_commit_message(
message, committer_email, is_head=args.is_head
)
print(json.dumps(result))
return 0
def _commit_from_worktree(worktree: str, sha: str) -> tuple[str, str] | None:
"""Module-local indirection to :func:`_validate_common.commit_from_worktree`.
Tests monkeypatch this name directly to feed canned ``(message,
committer_email)`` tuples without spinning up a real git repo.
Without the indirection, the test would have to monkeypatch the
shared module — workable but brittle (the cmd_* function captures
the shared module at import time via :func:`_load_sibling`).
Asymmetry note: ``diff_from_worktree`` deliberately has NO
analogous wrapper. The diff path's CI coverage is via the
``real_git_repo`` integration tests (a real git invocation),
not stubs — that's where the regression risk lives (subprocess
wiring, base-ref handling, etc.). The commit path's logic is in
:func:`lint_commit_message` which is pure-Python and unit-tested
directly; only the git-fetch boundary needs stubbing, hence the
one wrapper.
"""
return _validate_common.commit_from_worktree(worktree, sha)
# Re-export of :func:`_validate_cli_common._emit_error` so tests
# that monkeypatch ``review_validate._emit_error`` continue to work.
# New code should call :func:`_validate_common.emit_error` directly.
_emit_error = _validate_common._emit_error
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="review_validate",
description="Mid-session self-validation CLI for pr-review-worker.",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_draft = sub.add_parser(
"validate-draft",
help="Strict-parse a worker review draft JSON and report errors.",
)
p_draft.add_argument("--draft-file", required=True)
p_draft.add_argument("--review-type", required=True)
p_draft.add_argument("--head-sha", required=True)
p_draft.set_defaults(func=cmd_validate_draft)
p_inline = sub.add_parser(
"validate-inline-comment",
help="Confirm an inline comment's (path, new_position) is an added line.",
)
p_inline.add_argument("--path", required=True)
p_inline.add_argument("--new-position", type=int, required=True)
# --worktree and --diff-file are alternative diff sources; passing
# both is almost certainly a mistake, so make argparse reject it
# with a clear error rather than silently preferring one.
diff_source = p_inline.add_mutually_exclusive_group(required=True)
diff_source.add_argument(
"--worktree",
help=(
"Path to the dispatcher's pre-cloned PR worktree. The "
"validator runs 'git -C <worktree> diff <base-ref>...HEAD' "
"locally. Validator-internal failures (git timeout, missing "
"binary, bad base-ref) surface as exit-code-2 errors with "
"an 'error_kind' field, NOT as 'path-not-in-diff', so the "
"worker does not silently drop comments on a sick validator."
),
)
diff_source.add_argument(
"--diff-file",
help=(
"Path to a file containing the unified diff. Used as a "
"fallback when --worktree is not available (e.g. the "
"dispatcher's pre-clone failed and the worker sliced the "
"embedded prompt diff into /tmp)."
),
)
p_inline.add_argument(
"--base-ref",
default=None,
help=(
f"Merge base for the worktree-mode diff. When omitted, "
f"falls back to (in order) REVIEW_VALIDATE_BASE_REF env "
f"var, FORGEJO_DEFAULT_BRANCH env var (auto-prefixed "
f"with 'origin/'), then {_DEFAULT_BASE_REF!r}. Pass "
f"explicitly to override every fallback (e.g. "
f"'--base-ref origin/master' on a main-default "
f"deployment to compare against a master line). "
f"Ignored when --diff-file is supplied."
),
)
p_inline.set_defaults(func=cmd_validate_inline_comment)
p_lint = sub.add_parser(
"lint-commit",
help="Conventional-commit + ISSUES CLOSED footer + bot-author exemption.",
)
p_lint.add_argument("--worktree", required=True)
p_lint.add_argument("--sha", required=True)
p_lint.add_argument(
"--is-head",
action="store_true",
help=(
"Activate the 'ISSUES CLOSED: #N' footer check; required only "
"on the head commit per CONTRIBUTING.md."
),
)
p_lint.set_defaults(func=cmd_lint_commit)
return parser
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())