Files
cleveragents-core/tests/auto_agents/test_implementer_validate.py
T
drew 54a701fec2 feat(auto-agents): BDD-touched / PR label-set / milestone compliance (G6)
Ports the three missing items from agents/final-working's 8-item
PR Compliance Checklist that dmpipeline did not previously enforce
deterministically.

implementer_validate.py: new validate-bdd-touched subcommand. Flags
PRs that touched src/ files without touching features/. Coarse — a
hint, not a hard gate — because behaviour-vs-test-touched is a
judgement call. Inputs are filename lists the worker derives from
git diff --name-only.

_implementer_compliance.py: new check_pr_metadata_gaps(pr_details)
pure function. Returns {pr_label_set_complete, pr_milestone_assigned}
strict-bool dict. pr_label_set_complete requires at least one label
in each of State/, Priority/, Type/, MoSCoW/. Composes with the
existing worktree-side gaps dict via {**worktree_gaps, **metadata_gaps}.
Conservative on None / malformed inputs (False/False — the "treat as
gap" position, same convention as the worktree-clean masking).

8 unit tests covering happy / failure / empty-input / defensive
shape-tolerance per the dmpipeline test convention.

Refs: docs/development/final-working-harvest-plan.md (G6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:06:57 -04:00

667 lines
21 KiB
Python

"""Unit tests for ``tools/implementer_validate.py``.
The CLI dispatcher exposes four subcommands the inner
``task-implementor`` agent calls before committing or opening a PR:
- ``validate-commit-message`` — conventional-commit + ISSUES CLOSED
footer + bot-author exemption (mirrors ``review_validate
lint-commit``; both call ``_commit_lint.lint_commit_message``).
- ``validate-pr-compliance`` — Epic reference in the drafted PR
body (Checklist item 6).
- ``validate-file-budget`` — 500-line per-file budget enforcement.
- ``validate-changelog`` — at least one ``+``-line under
``## [Unreleased]`` in CHANGELOG.md vs ``--base-ref``.
Tests cover argparse plumbing, accept/reject branches per subcommand,
and the same exit-code contract as ``review_validate`` (0 on valid
CLI run, 2 on usage error / git-internal failure). Integration with
a real git repo is exercised in
``test_implementer_validate_integration.py`` — this file uses
monkeypatched stubs so the unit tests run in milliseconds.
"""
from __future__ import annotations
import json
import pytest
@pytest.fixture
def cli(cli_implementer_validate):
"""Alias the conftest's ``cli_implementer_validate`` fixture under
the short name every test in this file uses."""
return cli_implementer_validate
# ─── validate-commit-message ──────────────────────────────────────────────
def test_validate_commit_message_passes_clean_commit(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli,
"_commit_from_worktree",
lambda wt, sha: ("feat: ship X\n\nBody.\n\nISSUES CLOSED: #42", "drew@x"),
)
rc = cli.main(
[
"validate-commit-message",
"--worktree",
"/tmp/work",
"--sha",
"abc1234",
"--is-head",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out == {"ok": True, "exempt_bot": False, "violations": []}
def test_validate_commit_message_rejects_bad_subject(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli,
"_commit_from_worktree",
lambda wt, sha: ("fix typo", "drew@x"),
)
rc = cli.main(
["validate-commit-message", "--worktree", "/tmp/w", "--sha", "abc"]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert any(v["rule"] == "subject-prefix" for v in out["violations"])
def test_validate_commit_message_requires_footer_on_head(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli,
"_commit_from_worktree",
lambda wt, sha: ("feat: ship X\n\nNo footer here.", "drew@x"),
)
rc = cli.main(
[
"validate-commit-message",
"--worktree",
"/tmp/w",
"--sha",
"abc",
"--is-head",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert any(
v["rule"] == "issues-closed-footer" for v in out["violations"]
)
def test_validate_commit_message_skips_footer_when_not_head(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli,
"_commit_from_worktree",
lambda wt, sha: ("feat: ship X\n\nNo footer here.", "drew@x"),
)
rc = cli.main(
["validate-commit-message", "--worktree", "/tmp/w", "--sha", "abc"]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
def test_validate_commit_message_bot_exempt(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli,
"_commit_from_worktree",
lambda wt, sha: ("merge artifact", "forgejo@cleverthis.com"),
)
rc = cli.main(
[
"validate-commit-message",
"--worktree",
"/tmp/w",
"--sha",
"abc",
"--is-head",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["exempt_bot"] is True
def test_validate_commit_message_returns_2_on_unreadable_commit(
cli, monkeypatch, capsys
):
monkeypatch.setattr(cli, "_commit_from_worktree", lambda wt, sha: None)
rc = cli.main(
["validate-commit-message", "--worktree", "/tmp/w", "--sha", "abc"]
)
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert "could not read" in out["error"]
def test_validate_commit_message_requires_worktree_and_sha(cli):
with pytest.raises(SystemExit) as exc_info:
cli.main(["validate-commit-message"])
assert exc_info.value.code == 2
# ─── validate-pr-compliance ──────────────────────────────────────────────
def test_validate_pr_compliance_passes_with_epic_colon(cli, tmp_path, capsys):
body = tmp_path / "pr-body.md"
body.write_text("# Title\n\nSummary.\n\nEpic: #42\n")
rc = cli.main(["validate-pr-compliance", "--pr-body-file", str(body)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out == {"ok": True, "violations": [], "epic_number": 42}
def test_validate_pr_compliance_passes_with_parent_no_colon(cli, tmp_path, capsys):
body = tmp_path / "pr-body.md"
body.write_text("Summary\n\nParent #99\n")
rc = cli.main(["validate-pr-compliance", "--pr-body-file", str(body)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["epic_number"] == 99
def test_validate_pr_compliance_rejects_prose_only(cli, tmp_path, capsys):
body = tmp_path / "pr-body.md"
body.write_text(
"This addresses an epic refactor of the parent class.\n"
"Closes #42\n"
)
rc = cli.main(["validate-pr-compliance", "--pr-body-file", str(body)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["epic_number"] is None
assert any(v["rule"] == "epic-reference" for v in out["violations"])
def test_validate_pr_compliance_case_insensitive(cli, tmp_path, capsys):
body = tmp_path / "pr-body.md"
body.write_text("epic: #7\n")
rc = cli.main(["validate-pr-compliance", "--pr-body-file", str(body)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["epic_number"] == 7
def test_validate_pr_compliance_returns_2_on_missing_file(cli, tmp_path, capsys):
rc = cli.main(
["validate-pr-compliance", "--pr-body-file", str(tmp_path / "nope.md")]
)
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
def test_validate_pr_compliance_requires_pr_body_file(cli):
with pytest.raises(SystemExit) as exc_info:
cli.main(["validate-pr-compliance"])
assert exc_info.value.code == 2
# ─── validate-file-budget ────────────────────────────────────────────────
def test_validate_file_budget_passes_for_small_file(cli, tmp_path, capsys):
f = tmp_path / "small.py"
f.write_text("a\n" * 10)
rc = cli.main(["validate-file-budget", "--file", str(f)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["violations"] == []
assert out["limit"] == 500
def test_validate_file_budget_reports_oversize_file(cli, tmp_path, capsys):
f = tmp_path / "big.py"
f.write_text("a\n" * 600)
rc = cli.main(["validate-file-budget", "--file", str(f)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert len(out["violations"]) == 1
v = out["violations"][0]
assert v["lines"] == 600
assert v["limit"] == 500
assert v["path"].endswith("big.py")
def test_validate_file_budget_at_exact_limit_passes(cli, tmp_path, capsys):
f = tmp_path / "edge.py"
f.write_text("a\n" * 500)
rc = cli.main(["validate-file-budget", "--file", str(f)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
def test_validate_file_budget_one_over_fails(cli, tmp_path, capsys):
f = tmp_path / "edge.py"
f.write_text("a\n" * 501)
rc = cli.main(["validate-file-budget", "--file", str(f)])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["violations"][0]["lines"] == 501
def test_validate_file_budget_aggregates_multiple_files(cli, tmp_path, capsys):
a = tmp_path / "a.py"
b = tmp_path / "b.py"
c = tmp_path / "c.py"
a.write_text("x\n" * 100)
b.write_text("x\n" * 600)
c.write_text("x\n" * 700)
rc = cli.main(
[
"validate-file-budget",
"--file",
str(a),
"--file",
str(b),
"--file",
str(c),
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
paths = {v["path"] for v in out["violations"]}
assert str(b) in paths and str(c) in paths
assert str(a) not in paths
def test_validate_file_budget_custom_limit(cli, tmp_path, capsys):
f = tmp_path / "med.py"
f.write_text("x\n" * 250)
rc = cli.main(
["validate-file-budget", "--file", str(f), "--max-lines", "200"]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["limit"] == 200
assert out["violations"][0]["lines"] == 250
def test_validate_file_budget_returns_2_on_missing_file(cli, tmp_path, capsys):
rc = cli.main(
["validate-file-budget", "--file", str(tmp_path / "nope.py")]
)
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
def test_validate_file_budget_requires_at_least_one_file(cli):
with pytest.raises(SystemExit) as exc_info:
cli.main(["validate-file-budget"])
assert exc_info.value.code == 2
# ─── validate-changelog ──────────────────────────────────────────────────
def _stub_diff(text: str | None, error_kind: str | None = None, stderr: str = ""):
"""Build a stub :class:`_validate_cli_common.DiffResult`-like object."""
class _D:
def __init__(self):
self.text = text
self.error_kind = error_kind
self.stderr = stderr
return _D()
def test_validate_changelog_passes_with_added_unreleased_entry(
cli, monkeypatch, capsys
):
diff = (
"diff --git a/CHANGELOG.md b/CHANGELOG.md\n"
"index 0000..1111 100644\n"
"--- a/CHANGELOG.md\n"
"+++ b/CHANGELOG.md\n"
"@@ -1,3 +1,5 @@\n"
" ## [Unreleased]\n"
"+\n"
"+- 2026-05-08 Did a thing.\n"
" \n"
" ## [1.2.0]\n"
)
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(diff),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "origin/master"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["added_lines"] == 1
def test_validate_changelog_rejects_when_no_diff(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(None, error_kind="empty-output"),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "origin/master"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["violations"][0]["rule"] == "changelog-unreleased-entry"
assert "not modified" in out["violations"][0]["details"]
def test_validate_changelog_rejects_edits_outside_unreleased(cli, monkeypatch, capsys):
diff = (
"@@ -100,3 +100,4 @@\n"
" ## [1.2.0]\n"
" \n"
"+- 2026-05-08 historical edit (not under Unreleased)\n"
" ## [1.1.0]\n"
)
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(diff),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "origin/master"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert "no '+'-lines" in out["violations"][0]["details"]
def test_validate_changelog_skips_blank_added_lines(cli, monkeypatch, capsys):
diff = (
"@@ -1,2 +1,4 @@\n"
" ## [Unreleased]\n"
"+\n"
"+\n"
" \n"
)
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(diff),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "origin/master"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
def test_validate_changelog_handles_git_error(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(
None, error_kind="git-error", stderr="bad ref\n"
),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "bogus/ref"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["error_kind"] == "git-error"
def test_validate_changelog_handles_git_timeout(cli, monkeypatch, capsys):
monkeypatch.setattr(
cli._validate_common,
"diff_from_worktree",
lambda *a, **kw: _stub_diff(None, error_kind="git-timeout"),
)
monkeypatch.setattr(
cli._validate_common, "resolve_base_ref", lambda x: "origin/master"
)
rc = cli.main(["validate-changelog", "--worktree", "/tmp/w"])
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["error_kind"] == "git-timeout"
# ─── argparse / surface tests ────────────────────────────────────────────
def test_main_requires_subcommand(cli):
with pytest.raises(SystemExit) as exc_info:
cli.main([])
assert exc_info.value.code == 2
def test_added_lines_helper_recognises_unreleased_block(cli):
diff = (
"@@ -0,0 +1,3 @@\n"
" ## [Unreleased]\n"
"+- entry one\n"
"+- entry two\n"
)
assert cli._added_lines_under_unreleased(diff) == 2
def test_added_lines_helper_returns_zero_outside_block(cli):
diff = (
"@@ -1,3 +1,3 @@\n"
" ## [1.0.0]\n"
"-old\n"
"+new\n"
)
assert cli._added_lines_under_unreleased(diff) == 0
def test_added_lines_helper_resets_on_next_version_heading(cli):
diff = (
"@@ -1,5 +1,7 @@\n"
" ## [Unreleased]\n"
"+- entry-A\n"
" ## [1.2.0]\n"
"+- this-should-not-count\n"
)
assert cli._added_lines_under_unreleased(diff) == 1
# ─── Integration: real ``git init`` repo ─────────────────────────────────
def test_validate_commit_message_against_real_repo(cli, real_git_repo, capsys):
"""Smoke-test the worktree path against a real git repo. Confirms
the same code path :func:`_commit_from_worktree` exercises in
production reaches the lint and produces a valid JSON verdict."""
rc = cli.main(
[
"validate-commit-message",
"--worktree",
str(real_git_repo.path),
"--sha",
real_git_repo.head_sha,
"--is-head",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["exempt_bot"] is False
def test_validate_changelog_against_real_repo_no_changelog(cli, real_git_repo, capsys):
"""The ``real_git_repo`` fixture has no CHANGELOG.md, so the diff is
empty and the validator must report ``changelog-unreleased-entry``."""
rc = cli.main(
[
"validate-changelog",
"--worktree",
str(real_git_repo.path),
"--base-ref",
"origin/master",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["violations"][0]["rule"] == "changelog-unreleased-entry"
def test_validate_changelog_against_real_repo_with_unreleased_entry(
cli, real_git_repo, capsys
):
"""Add a CHANGELOG.md on the feature branch with one ``+``-line
under ``## [Unreleased]`` and confirm the validator passes."""
(real_git_repo.path / "CHANGELOG.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n"
"- 2026-05-08 Implemented feature X.\n\n"
"## [1.0.0]\n\n- Initial release.\n",
encoding="utf-8",
)
real_git_repo.run("add", "CHANGELOG.md")
real_git_repo.run(
"commit", "-m", "docs(changelog): add unreleased entry\n\nISSUES CLOSED: #42\n"
)
rc = cli.main(
[
"validate-changelog",
"--worktree",
str(real_git_repo.path),
"--base-ref",
"origin/master",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["added_lines"] >= 1
def test_validate_changelog_against_real_repo_bad_base_ref(
cli, real_git_repo, capsys
):
"""A typo'd base ref must surface as ``error_kind: git-error`` exit
code 2 — NOT as ``ok: false`` with the violation message."""
rc = cli.main(
[
"validate-changelog",
"--worktree",
str(real_git_repo.path),
"--base-ref",
"origin/this-ref-does-not-exist",
]
)
assert rc == 2
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["error_kind"] == "git-error"
# ─── Subprocess smoke test ───────────────────────────────────────────────
def test_subprocess_invocation_validate_pr_compliance(tmp_path):
"""Run the CLI as an actual subprocess (not via ``cli.main``) so a
regression in the shebang, ``__main__`` block, or argparse plumbing
surfaces in CI. Mirrors the same protection
``test_review_validate_integration.py`` adds for the reviewer CLI.
"""
import subprocess
from pathlib import Path as _P
script = (
_P(__file__).resolve().parents[2]
/ "tools"
/ "implementer_validate.py"
)
body = tmp_path / "pr-body.md"
body.write_text("Summary\n\nEpic: #42\n", encoding="utf-8")
result = subprocess.run(
[
"python3",
str(script),
"validate-pr-compliance",
"--pr-body-file",
str(body),
],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, (
f"non-zero exit; stderr={result.stderr!r}"
)
out = json.loads(result.stdout)
assert out["ok"] is True
assert out["epic_number"] == 42
# ─── validate-bdd-touched (G6 harvest, 2026-05-15) ───────────────────────
def test_validate_bdd_touched_flags_src_without_features(cli, capsys):
"""Failure case: a PR that changed src/ files but no features/
files should flag the BDD-coverage hint."""
rc = cli.main(
[
"validate-bdd-touched",
"--src-changed", "src/cleveragents/foo.py",
"--src-changed", "src/cleveragents/bar.py",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is False
assert out["violations"][0]["rule"] == "bdd-touched"
assert "src/cleveragents/foo.py" in out["violations"][0]["details"]
def test_validate_bdd_touched_passes_with_features_touched(cli, capsys):
"""Happy path: src/ change + features/ change → no violation."""
rc = cli.main(
[
"validate-bdd-touched",
"--src-changed", "src/cleveragents/foo.py",
"--features-changed", "features/foo.feature",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True
assert out["violations"] == []
def test_validate_bdd_touched_passes_with_no_src_changes(cli, capsys):
"""Empty-input case: a PR that doesn't touch src/ at all (e.g.
docs-only or features-only change) is trivially fine — the
rule only kicks in when source behaviour changes."""
rc = cli.main(["validate-bdd-touched"])
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["ok"] is True