#!/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\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": } {"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: #' " "or 'Parent: #' 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 ...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 ``/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": } {"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 ...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//pr-body.md). The validator reads " "this file and looks for an 'Epic: #' or 'Parent: #' " "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 ...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 ...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())