0bc734c020
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>
535 lines
20 KiB
Python
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())
|