Files
cleveragents-core/tools/implementer_validate.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

535 lines
20 KiB
Python

#!/usr/bin/env python3
"""Mid-session self-validation CLI for the implementer worker stack.
Mirrors :mod:`review_validate` on the implementer side. The implementer
worker drafts a commit, a PR description, a CHANGELOG entry, and a
CONTRIBUTORS entry over one session — and gets each wrong on the first
try with predictable regularity:
- Commit subject failing conventional-commit lint.
- Head commit missing the ``ISSUES CLOSED: #N`` footer.
- Diff exceeding the project's 500-line per-file budget.
- PR description without an Epic reference.
- CHANGELOG with no entry under ``[Unreleased]``.
The dispatcher (``tools/dispatch_implementer.py``) embeds the same
PR-Compliance Checklist in the worker prompt but the worker has no way
to self-validate before exiting; the next CI cycle catches the mistake
and the worker is rebaked. This CLI is what the worker invokes via
``bash`` mid-session to skip the rebake.
Contract mirrors :mod:`review_validate`:
- Each subcommand prints exactly one JSON object on stdout.
- Exit 0 = validator ran (pass OR structured fail).
- Exit 2 = validator could not run (unreadable file, git-internal
failure under ``--worktree``).
Subcommands (backed by :mod:`_validate_cli_common` + :mod:`_commit_lint`):
- :func:`cmd_validate_commit_message` — same as
``review_validate lint-commit`` (both call
:func:`_commit_lint.lint_commit_message`).
- :func:`cmd_validate_pr_compliance` — Epic reference in the drafted
PR body (Checklist item 6).
- :func:`cmd_validate_file_budget` — 500-line per-file budget over
``--file`` paths.
- :func:`cmd_validate_changelog` — at least one ``+``-line under
``## [Unreleased]`` in CHANGELOG.md vs ``--base-ref``.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
_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: I001, E402 type: ignore[import-not-found]
_validate_common = _load_sibling("_validate_cli_common", "_validate_cli_common.py")
_commit_lint = _load_sibling("_commit_lint", "_commit_lint.py")
# Per CONTRIBUTING.md / AGENTS.md: each Python source file under
# ``tools/`` must stay under this many lines. Surfaced here as a CLI
# default so a future policy change touches one place.
DEFAULT_FILE_BUDGET_LINES = 500
# Heuristic detection of an Epic reference in PR body text.
# Accepts ``Epic: #123``, ``Epic #123``, ``Parent: #123``, or
# ``Parent #123`` on its own line (case-insensitive). The ``#``-prefix
# avoids false positives where the prose mentions "the parent class"
# or "an epic refactor" — a reference must point at a concrete issue
# number to count. Public so :mod:`verify_implementer_invariant` can
# reuse the same compiled regex without coupling to a private name.
EPIC_REFERENCE_RE = re.compile(r"(?im)^\s*(?:epic|parent)\s*[:\-]?\s*#(?P<n>\d+)\b")
# Backwards-compat alias kept temporarily so tests written against
# the old private name keep working; new code MUST use
# :data:`EPIC_REFERENCE_RE`.
_EPIC_REFERENCE_RE = EPIC_REFERENCE_RE
# CHANGELOG section heading regex. The Keep-a-Changelog format the
# project uses puts the WIP section under ``## [Unreleased]``; a new
# entry is any added bullet under that heading and before the next
# ``## [`` heading.
_UNRELEASED_HEADING_RE = re.compile(r"(?m)^##\s*\[Unreleased\]\s*$")
_NEXT_VERSION_HEADING_RE = re.compile(r"(?m)^##\s*\[(?!Unreleased\])")
# ─── Subcommand: validate-commit-message ──────────────────────────────────
def cmd_validate_commit_message(args: argparse.Namespace) -> int:
"""Lint one commit's message + committer email.
Same implementation as :func:`review_validate.cmd_lint_commit` —
both call :func:`_commit_lint.lint_commit_message`. Renamed so the
implementer-side skill reads naturally.
Stdout::
{"ok": true, "exempt_bot": false, "violations": []}
{"ok": false, "exempt_bot": false,
"violations": [{"rule": ..., "details": ...}]}
Exit 0 on valid CLI run, 2 on git-internal failure.
"""
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 {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: tests monkeypatch this directly to
avoid a real git repo. Mirrors :mod:`review_validate`."""
return _validate_common.commit_from_worktree(worktree, sha)
# ─── Subcommand: validate-pr-compliance ───────────────────────────────────
def cmd_validate_pr_compliance(args: argparse.Namespace) -> int:
"""Check the drafted PR body for an Epic reference (Checklist item 6).
Other checklist items are enforced elsewhere: 1 by
:func:`cmd_validate_changelog`, 3 by
:func:`cmd_validate_commit_message`, 4 by next CI run, 5 by
test discovery, 7/8 by Forgejo API. Item 2 (CONTRIBUTORS) is a
diff-vs-base check intentionally out of scope here.
Stdout::
{"ok": true, "violations": [], "epic_number": <int>}
{"ok": false, "violations": [{"rule": "epic-reference",
"details": "..."}],
"epic_number": null}
Exit 0 on valid CLI run, 2 on usage error.
"""
try:
body = Path(args.pr_body_file).read_text(encoding="utf-8", errors="replace")
except OSError as exc:
_validate_common.emit_error(
f"could not read --pr-body-file {args.pr_body_file!r}: {exc}"
)
return 2
match = EPIC_REFERENCE_RE.search(body)
if match is None:
result = {
"ok": False,
"violations": [
{
"rule": "epic-reference",
"details": (
"PR description is missing an Epic reference. "
"Add a line of the form 'Epic: #<issue-number>' "
"or 'Parent: #<issue-number>' so the dispatcher's "
"PR Compliance Checklist item 6 is satisfied."
),
}
],
"epic_number": None,
}
else:
result = {
"ok": True,
"violations": [],
"epic_number": int(match.group("n")),
}
print(json.dumps(result))
return 0
# ─── Subcommand: validate-file-budget ─────────────────────────────────────
def cmd_validate_file_budget(args: argparse.Namespace) -> int:
"""Enforce the project's per-file line budget (default 500).
Counts lines in every ``--file`` and reports any over the limit.
Worker is expected to pass paths from
``git diff --name-only <base>...HEAD`` so we only check files in
the PR's blast radius.
Stdout::
{"ok": true, "violations": [], "limit": 500}
{"ok": false, "violations": [{"path": ..., "lines": 612,
"limit": 500}], "limit": 500}
Exit 0 on valid run (budget violations are ``ok=false`` exit-0),
2 on usage error or unreadable file.
"""
limit: int = args.max_lines
violations: list[dict[str, int | str]] = []
for raw_path in args.files:
path = Path(raw_path)
try:
with path.open("r", encoding="utf-8", errors="replace") as fh:
# ``sum(1 for _ in fh)`` counts lines as Python's text
# I/O does — universal-newline aware, treats a final
# line without a trailing ``\n`` as one line. Matches
# ``wc -l`` only when files end in ``\n`` but matches
# the project's "lines of code" intuition either way.
line_count = sum(1 for _ in fh)
except OSError as exc:
_validate_common.emit_error(f"could not read --file {raw_path!r}: {exc}")
return 2
if line_count > limit:
violations.append(
{
"path": str(path),
"lines": line_count,
"limit": limit,
}
)
print(json.dumps({"ok": not violations, "violations": violations, "limit": limit}))
return 0
# ─── Subcommand: validate-changelog ───────────────────────────────────────
def cmd_validate_changelog(args: argparse.Namespace) -> int:
"""Confirm CHANGELOG.md gained ≥1 entry under ``## [Unreleased]``.
Diffs ``<worktree>/CHANGELOG.md`` against ``--base-ref`` (same
fallback chain as :mod:`review_validate`). Counts ``+``-lines
between the ``## [Unreleased]`` heading and the next ``## [``
heading. Header-only changes don't satisfy the check.
Stdout::
{"ok": true, "added_lines": <int>}
{"ok": false, "violations": [{"rule":
"changelog-unreleased-entry", "details": ...}]}
Exit 0 on valid CLI run, 2 on git-internal failure.
"""
base_ref = _validate_common.resolve_base_ref(args.base_ref)
diff = _validate_common.diff_from_worktree(
args.worktree, "CHANGELOG.md", base_ref=base_ref
)
if diff.error_kind in ("git-timeout", "git-not-found", "git-error"):
_validate_common.emit_error(
f"git failed for CHANGELOG.md ({diff.error_kind}): "
f"{diff.stderr.strip() or '(no stderr)'}",
error_kind=diff.error_kind,
)
return 2
if diff.error_kind == "empty-output" or diff.text is None:
print(
json.dumps(
{
"ok": False,
"violations": [
{
"rule": "changelog-unreleased-entry",
"details": (
"CHANGELOG.md was not modified in this PR. "
"Add an entry under '## [Unreleased]' so "
"the dispatcher's PR Compliance Checklist "
"item 1 is satisfied."
),
}
],
}
)
)
return 0
added = _added_lines_under_unreleased(diff.text)
if not added:
print(
json.dumps(
{
"ok": False,
"violations": [
{
"rule": "changelog-unreleased-entry",
"details": (
"CHANGELOG.md was modified, but no '+'-lines "
"appear under the '## [Unreleased]' section "
"(checked between the [Unreleased] heading "
"and the next '## [' heading)."
),
}
],
}
)
)
return 0
print(json.dumps({"ok": True, "added_lines": added}))
return 0
def _added_lines_under_unreleased(diff_text: str) -> int:
"""Count ``+``-lines in ``diff_text`` inside the ``[Unreleased]``
section of the post-image CHANGELOG.md.
State-walk: ``-`` lines are skipped (pre-image only); ``+`` and
context lines update an "in_unreleased" flag and contribute to
the count if they are non-blank, non-heading ``+``-lines under
the Unreleased heading. A heading rename alone (no body change)
does NOT count as a real entry.
"""
in_unreleased = False
added = 0
for line in diff_text.splitlines():
if line.startswith("@@") or line.startswith("+++") or line.startswith("---"):
continue
if not line:
continue
# Hunk lines: '+', '-', or ' ' (or '\') for the no-newline-at-eof
# marker. We only care about the '+' and ' ' tracks for
# state-tracking; '-' lines are pre-image only.
if line.startswith("-"):
continue
body = line[1:] if line.startswith(("+", " ")) else line
if _UNRELEASED_HEADING_RE.match(body):
in_unreleased = True
continue
if _NEXT_VERSION_HEADING_RE.match(body):
in_unreleased = False
continue
if in_unreleased and line.startswith("+"):
stripped = body.strip()
# Skip blank-line-only additions and section-divider
# headings; only real entry text counts.
if stripped and not stripped.startswith("##"):
added += 1
return added
# ─── Argparse wiring ──────────────────────────────────────────────────────
def cmd_validate_bdd_touched(args: argparse.Namespace) -> int:
"""G6 harvest (2026-05-15) — flag PRs that change ``src/`` files
without correspondingly touching ``features/`` (BDD / Behave).
Ported from ``agents/final-working``'s 8-item PR Compliance
Checklist (item 5). The check is intentionally coarse — a flag,
not a hard gate — because behaviour-vs-test-touched is a
judgement call. The aim is to surface the case where a worker
finishes a PR that adds or changes source behaviour but never
opened a feature file, which CONTRIBUTING.md's BDD-first
convention requires.
Stdout::
{"ok": true, "violations": []}
{"ok": false, "violations": [{"rule": "bdd-touched", "details": "..."}]}
Decision: ``ok=false`` iff ``--src-changed`` is non-empty AND
``--features-changed`` is empty. Both inputs are filename lists
from ``git diff --name-only <base>...HEAD``, filtered to the
respective tree by the caller (worker prompt does this trivially
with a substring filter).
Exit 0 on a valid CLI run regardless of violation, 2 on usage
error. Matches the existing validator convention.
"""
src_changed: list[str] = list(args.src_changed or [])
features_changed: list[str] = list(args.features_changed or [])
if src_changed and not features_changed:
sample = ", ".join(src_changed[:3])
if len(src_changed) > 3:
sample += f", … (+{len(src_changed) - 3} more)"
result = {
"ok": False,
"violations": [
{
"rule": "bdd-touched",
"details": (
f"This PR changes source under src/ ({sample}) "
f"but touches no files under features/. "
f"CONTRIBUTING.md requires BDD coverage when "
f"behaviour changes — add or update a feature "
f"file that exercises the new behaviour, or "
f"document in the PR description why a "
f"feature-file update is not applicable."
),
}
],
}
else:
result = {"ok": True, "violations": []}
print(json.dumps(result))
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="implementer_validate",
description=("Mid-session self-validation CLI for the implementer worker."),
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_msg = sub.add_parser(
"validate-commit-message",
help=(
"Conventional-commit subject + ISSUES CLOSED footer + bot-author exemption."
),
)
p_msg.add_argument("--worktree", required=True)
p_msg.add_argument("--sha", required=True)
p_msg.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_msg.set_defaults(func=cmd_validate_commit_message)
p_pr = sub.add_parser(
"validate-pr-compliance",
help=(
"Check the drafted PR body for an Epic reference (PR "
"Compliance Checklist item 6)."
),
)
p_pr.add_argument(
"--pr-body-file",
required=True,
help=(
"Path to a file containing the worker's drafted PR description "
"(typically /tmp/<work-tag>/pr-body.md). The validator reads "
"this file and looks for an 'Epic: #<n>' or 'Parent: #<n>' "
"line."
),
)
p_pr.set_defaults(func=cmd_validate_pr_compliance)
p_budget = sub.add_parser(
"validate-file-budget",
help="Enforce the per-file line budget (default 500).",
)
p_budget.add_argument(
"--file",
dest="files",
action="append",
required=True,
help=(
"Path to a changed file (repeat once per changed file). The "
"worker typically passes paths from "
"'git diff --name-only <base>...HEAD'."
),
)
p_budget.add_argument(
"--max-lines",
type=int,
default=DEFAULT_FILE_BUDGET_LINES,
help=(
"Per-file line budget; the validator reports a violation for "
"any --file whose line count exceeds this. Default: "
f"{DEFAULT_FILE_BUDGET_LINES}."
),
)
p_budget.set_defaults(func=cmd_validate_file_budget)
p_ch = sub.add_parser(
"validate-changelog",
help=(
"Confirm at least one '+' entry under '## [Unreleased]' in CHANGELOG.md."
),
)
p_ch.add_argument(
"--worktree",
required=True,
help=(
"Path to the worker's local git working tree (the same "
"directory the worker is committing into)."
),
)
p_ch.add_argument(
"--base-ref",
default=None,
help=(
"Merge base for the diff. Falls back to (in order) "
"REVIEW_VALIDATE_BASE_REF env var, FORGEJO_DEFAULT_BRANCH "
"env var (auto-prefixed with 'origin/'), then "
"'origin/master'."
),
)
p_ch.set_defaults(func=cmd_validate_changelog)
p_bdd = sub.add_parser(
"validate-bdd-touched",
help=(
"Flag PRs that change src/ without touching features/ (BDD "
"coverage hint — G6 harvest 2026-05-15)."
),
)
p_bdd.add_argument(
"--src-changed",
action="append",
dest="src_changed",
default=[],
help=(
"A path under src/ that this PR changed (repeat per file). "
"Typically built by the worker from "
"'git diff --name-only <base>...HEAD' filtered to 'src/'."
),
)
p_bdd.add_argument(
"--features-changed",
action="append",
dest="features_changed",
default=[],
help=(
"A path under features/ that this PR changed (repeat per "
"file). The check passes when this list is non-empty OR "
"the --src-changed list is empty."
),
)
p_bdd.set_defaults(func=cmd_validate_bdd_touched)
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())