Files
cleveragents-core/tools/_validate_cli_common.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

343 lines
12 KiB
Python

"""Shared primitives for the auto-agents self-validation CLIs.
Phase 0 extraction from :mod:`_review_validate_helpers` and
:mod:`review_validate` so the implementer's forthcoming
:mod:`implementer_validate` CLI can reuse the same building blocks
without duplicating the dataclasses, the git-diff helper, or the
argparse-flag-resolution chain.
What's here:
- :class:`DiffResult` / :data:`DiffErrorKind` -- typed return
envelope for :func:`diff_from_worktree` discriminating
``empty-output`` (path not in diff) from the three
validator-internal failure kinds (``git-timeout`` /
``git-not-found`` / ``git-error``). The CLI maps the failure
kinds onto exit-code-2 errors so the worker does NOT silently
drop comments / file-budget violations on a sick validator.
- :func:`diff_from_worktree` -- ``git diff <base>...HEAD -- <path>``
with timeout, not-found, OSError, and non-zero-exit each mapped
onto a discriminated :class:`DiffResult`.
- :func:`commit_from_worktree` -- ``git log -1 --pretty=...``
pulling ``(message, committer_email)`` out of one commit by SHA.
- :func:`_excerpt_around` / :func:`_excerpt_for_field` --
text-window helpers used by both CLIs to ground error messages
in the actual draft / commit body.
- :func:`resolve_base_ref` -- the ``--base-ref`` → env-var fallback
chain (``REVIEW_VALIDATE_BASE_REF`` → ``FORGEJO_DEFAULT_BRANCH``
→ ``origin/master``). Reused verbatim by both CLIs.
- :func:`emit_error` -- single-line JSON error with optional
``error_kind`` discriminator. Both CLIs honour the same shape so
a worker reading the output JSON can pattern-match on it.
Why the underscore-prefixed names: callers historically reached
into ``review_validate.py``'s module-private ``_resolve_base_ref``
and ``_emit_error`` directly. To keep test patches working without
a renaming churn, the public names mirror the private ones with a
re-export layer in the consuming CLIs. New callers (e.g.
``implementer_validate``) should prefer the un-prefixed
:func:`resolve_base_ref` / :func:`emit_error` aliases.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from dataclasses import dataclass
from typing import IO, Any, Literal
_logger = logging.getLogger("validate_cli_common")
_DEFAULT_BASE_REF = "origin/master"
DiffErrorKind = Literal[
"git-timeout",
"git-not-found",
"git-error",
"empty-output",
]
@dataclass(frozen=True)
class DiffResult:
"""Outcome of :func:`diff_from_worktree`.
Exactly one of ``text`` / ``error_kind`` is set:
- On success: ``text`` is the unified diff (non-empty), ``error_kind``
is ``None``.
- On a git internal failure (timeout, missing binary, non-zero
exit, OSError): ``text`` is ``None`` and ``error_kind`` is one of
``git-timeout`` / ``git-not-found`` / ``git-error``. ``stderr``
carries up to 500 bytes of git's stderr for operator triage.
- On a clean run that produced empty output (the path is genuinely
not part of the diff, e.g. an unmodified file): ``text`` is
``None`` and ``error_kind`` is ``empty-output``.
The CLIs map the three internal failure kinds onto exit-code-2
errors so a sick validator does NOT silently get treated as a
structured ``path-not-in-diff`` rejection.
"""
text: str | None
error_kind: DiffErrorKind | None
stderr: str = ""
def diff_from_worktree(
worktree: str, path: str, *, base_ref: str = "origin/master"
) -> DiffResult:
"""Run ``git -C <worktree> diff <base_ref>...HEAD -- <path>``.
``base_ref`` is configurable so deployments whose default branch
is not ``master`` (Forgejo / Gitea projects commonly use ``main``,
some teams use ``develop``) get a correct merge-base diff. Both
CLIs resolve the base ref via :func:`resolve_base_ref` so the
``--base-ref`` / ``REVIEW_VALIDATE_BASE_REF`` /
``FORGEJO_DEFAULT_BRANCH`` precedence is identical between them.
Returns a :class:`DiffResult` discriminating success, "git ran
cleanly but produced no output" (empty-output → path is
genuinely not in this PR), and three classes of git-internal
failure (timeout / not-found / error). The CLIs surface the
three internal failures as exit-code-2 errors so the worker
distinguishes "drop this comment, the path is not in the PR"
from "the validator itself is unhealthy, do not silently drop."
``capture_output=True`` keeps stderr out of the CLI's own stdout
JSON line; the CLI's caller (the worker) parses stdout-as-JSON.
"""
try:
result = subprocess.run(
[
"git",
"-C",
worktree,
"diff",
f"{base_ref}...HEAD",
"--",
path,
],
check=False,
timeout=30,
capture_output=True,
text=True,
)
except subprocess.TimeoutExpired as exc:
return DiffResult(
text=None,
error_kind="git-timeout",
stderr=(exc.stderr or b"")[:500].decode("utf-8", errors="replace")
if isinstance(exc.stderr, (bytes, bytearray))
else (exc.stderr or "")[:500],
)
except FileNotFoundError as exc:
return DiffResult(text=None, error_kind="git-not-found", stderr=str(exc)[:500])
except OSError as exc:
return DiffResult(text=None, error_kind="git-error", stderr=str(exc)[:500])
if result.returncode != 0:
return DiffResult(
text=None,
error_kind="git-error",
stderr=(result.stderr or "")[:500],
)
text = result.stdout
if not text.strip():
return DiffResult(text=None, error_kind="empty-output")
return DiffResult(text=text, error_kind=None)
def commit_from_worktree(worktree: str, sha: str) -> tuple[str, str] | None:
"""Return ``(commit_message, committer_email)`` or ``None`` on git error.
Uses a sentinel separator (``---SEP---``) between the body and the
committer email so the parser does not confuse the two. ``%B``
emits the full message (subject + body + footer), ``%ce`` the
committer email.
Each failure mode logs at warning level so an operator tailing
the validator's stderr can distinguish "git refused" from "git
timed out" from "the parse failed because the commit message
contained the sentinel" without grepping git's stderr.
"""
try:
result = subprocess.run(
[
"git",
"-C",
worktree,
"log",
"-1",
"--pretty=format:%B%n---SEP---%n%ce",
sha,
],
check=False,
timeout=15,
capture_output=True,
text=True,
)
except subprocess.TimeoutExpired as exc:
_logger.warning("commit_from_worktree timed out for sha=%s: %s", sha[:12], exc)
return None
except OSError as exc:
_logger.warning(
"commit_from_worktree git invocation failed for sha=%s: %s",
sha[:12],
exc,
)
return None
if result.returncode != 0:
_logger.warning(
"commit_from_worktree git exited rc=%s for sha=%s; stderr=%s",
result.returncode,
sha[:12],
(result.stderr or "")[:200],
)
return None
if not result.stdout:
_logger.warning(
"commit_from_worktree produced empty stdout for sha=%s", sha[:12]
)
return None
parts = result.stdout.rsplit("\n---SEP---\n", 1)
if len(parts) != 2:
_logger.warning(
"commit_from_worktree could not parse sentinel for sha=%s", sha[:12]
)
return None
message = parts[0]
committer_email = parts[1].strip()
return message, committer_email
def _excerpt_around(text: str, needle: str, window: int = 80) -> str:
"""Return up to ``2 * window`` chars of ``text`` centred on the
first occurrence of ``needle``. Empty string when ``needle`` is
not in ``text`` so the caller can fall back to a synthesized
excerpt rather than the entire draft."""
if not text or not needle:
return ""
idx = text.find(needle)
if idx < 0:
return ""
start = max(0, idx - window)
end = min(len(text), idx + len(needle) + window)
return text[start:end]
def _excerpt_for_field(text: str, field: str, value: str, window: int = 80) -> str:
"""Locate a JSON ``"<field>":"<value>"`` (or unquoted-value)
occurrence in ``text`` and return a window around it.
Tries quoted form first (``"commit_id":"abc..."``), then
whitespace-tolerant variants (``"commit_id" : "abc..."``). Returns
empty string when no plausible match is found so the caller can
fall back to a less-specific locator. Anchoring on the field name
ensures the excerpt always points at the actual offending
assignment rather than coincidentally identical SHAs elsewhere
in the draft (e.g. quoted in ``review.body`` discussing prior
commits)."""
if not text or not field or not value:
return ""
candidates = (
f'"{field}":"{value}"',
f'"{field}": "{value}"',
f'"{field}" : "{value}"',
f'"{field}" :"{value}"',
)
for needle in candidates:
idx = text.find(needle)
if idx >= 0:
start = max(0, idx - window)
end = min(len(text), idx + len(needle) + window)
return text[start:end]
return ""
def resolve_base_ref(flag_value: str | None) -> str:
"""Resolve the base ref the diff is computed against.
Priority (most specific wins):
1. ``--base-ref`` when supplied (any non-``None`` value, even
the literal ``origin/master``). The flag's argparse default
is ``None``, so a user who explicitly types
``--base-ref origin/master`` on a ``main``-default
deployment gets ``origin/master`` — not silently overridden
by the env var fallbacks.
2. ``REVIEW_VALIDATE_BASE_REF`` env var — most specific, an
explicit override the dispatcher / operator can set per
worker session.
3. ``FORGEJO_DEFAULT_BRANCH`` env var — already exported by
``launch_fork.sh`` and read by every other tool in this
directory. Treated as a bare branch name and prefixed with
``origin/`` if not already namespaced (the diff is against
the *remote-tracking* ref, not the local branch).
4. ``origin/master``.
"""
if flag_value is not None:
return flag_value
explicit = os.environ.get("REVIEW_VALIDATE_BASE_REF")
if explicit:
return explicit
forgejo_default = os.environ.get("FORGEJO_DEFAULT_BRANCH")
if forgejo_default:
return (
forgejo_default if "/" in forgejo_default else f"origin/{forgejo_default}"
)
return _DEFAULT_BASE_REF
def emit_error(
message: str,
*,
error_kind: str | None = None,
stream: IO[str] | None = None,
) -> None:
"""Single-line JSON error parseable by the worker.
``error_kind`` is an optional discriminator the worker can pattern-
match on to decide whether the failure is recoverable (e.g. the
diff-walker uses ``git-timeout`` / ``git-not-found`` /
``git-error`` so the worker does not treat a sick validator as
a structured ``path-not-in-diff`` rejection).
``stream`` defaults to ``sys.stdout`` resolved at call time
(NOT at function-definition time) so a pytest ``capsys`` /
``capfd`` redirect that swaps ``sys.stdout`` after this module
was imported sees the printed JSON.
"""
payload: dict[str, Any] = {"ok": False, "error": message}
if error_kind is not None:
payload["error_kind"] = error_kind
target: IO[str] = sys.stdout if stream is None else stream
print(json.dumps(payload), file=target)
# Underscore-prefixed aliases for back-compat with the original
# ``review_validate._resolve_base_ref`` / ``review_validate._emit_error``
# names that tests monkeypatch directly. New code should call the
# public spellings above; the aliases let us move the implementations
# without touching every test in the same Phase 0 commit.
_resolve_base_ref = resolve_base_ref
_emit_error = emit_error
__all__ = (
"DiffErrorKind",
"DiffResult",
"_DEFAULT_BASE_REF",
"_emit_error",
"_excerpt_around",
"_excerpt_for_field",
"_resolve_base_ref",
"commit_from_worktree",
"diff_from_worktree",
"emit_error",
"resolve_base_ref",
)