54a701fec2
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>
667 lines
21 KiB
Python
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
|