REQ/TASK/TEST ID 패턴 매칭을 _strip_code_segments() 결과에 적용하여 코드 펜스/인라인 코드 안에만 ID를 넣어 검증을 우회하는 케이스를 차단. 회귀 테스트 추가: test_validate_pr_body_text_rejects_governance_ids_in_code_block_only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
def _load_module():
|
|
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_pr_body.py"
|
|
spec = importlib.util.spec_from_file_location("validate_pr_body", script_path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_validate_pr_body_text_detects_escaped_newline() -> None:
|
|
module = _load_module()
|
|
errors = module.validate_pr_body_text("## Summary\\n- item")
|
|
assert any("escaped newline" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_detects_escaped_newline_in_multiline_body() -> None:
|
|
module = _load_module()
|
|
text = "## Summary\n- first line\n- broken line with \\n literal"
|
|
errors = module.validate_pr_body_text(text)
|
|
assert any("escaped newline" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_allows_escaped_newline_in_code_blocks() -> None:
|
|
module = _load_module()
|
|
text = "\n".join(
|
|
[
|
|
"## Summary",
|
|
"- example uses `\\n` for explanation",
|
|
"- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001",
|
|
"```bash",
|
|
"printf 'line1\\nline2\\n'",
|
|
"```",
|
|
]
|
|
)
|
|
assert module.validate_pr_body_text(text) == []
|
|
|
|
|
|
def test_validate_pr_body_text_detects_unbalanced_code_fence() -> None:
|
|
module = _load_module()
|
|
errors = module.validate_pr_body_text("## Summary\n- item\n```bash\necho hi\n")
|
|
assert any("unbalanced fenced code blocks" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_detects_missing_structure() -> None:
|
|
module = _load_module()
|
|
errors = module.validate_pr_body_text("plain text only")
|
|
assert any("missing markdown section headers" in err for err in errors)
|
|
assert any("missing markdown list items" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_passes_with_valid_markdown() -> None:
|
|
module = _load_module()
|
|
text = "\n".join(
|
|
[
|
|
"## Summary",
|
|
"- REQ-OPS-001 / TASK-OPS-001 / TEST-OPS-001",
|
|
"",
|
|
"## Validation",
|
|
"```bash",
|
|
"pytest -q",
|
|
"```",
|
|
]
|
|
)
|
|
assert module.validate_pr_body_text(text) == []
|
|
|
|
|
|
def test_validate_pr_body_text_detects_missing_req_id() -> None:
|
|
module = _load_module()
|
|
text = "## Summary\n- TASK-OPS-001 / TEST-OPS-001 item\n"
|
|
errors = module.validate_pr_body_text(text)
|
|
assert any("REQ-ID" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_detects_missing_task_id() -> None:
|
|
module = _load_module()
|
|
text = "## Summary\n- REQ-OPS-001 / TEST-OPS-001 item\n"
|
|
errors = module.validate_pr_body_text(text)
|
|
assert any("TASK-ID" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_detects_missing_test_id() -> None:
|
|
module = _load_module()
|
|
text = "## Summary\n- REQ-OPS-001 / TASK-OPS-001 item\n"
|
|
errors = module.validate_pr_body_text(text)
|
|
assert any("TEST-ID" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_skips_governance_when_disabled() -> None:
|
|
module = _load_module()
|
|
text = "## Summary\n- item without any IDs\n"
|
|
errors = module.validate_pr_body_text(text, check_governance=False)
|
|
assert not any("REQ-ID" in err or "TASK-ID" in err or "TEST-ID" in err for err in errors)
|
|
|
|
|
|
def test_validate_pr_body_text_rejects_governance_ids_in_code_block_only() -> None:
|
|
"""Regression for review comment: IDs inside code fences must not count."""
|
|
module = _load_module()
|
|
text = "\n".join(
|
|
[
|
|
"## Summary",
|
|
"- no governance IDs in narrative text",
|
|
"```text",
|
|
"REQ-FAKE-999",
|
|
"TASK-FAKE-999",
|
|
"TEST-FAKE-999",
|
|
"```",
|
|
]
|
|
)
|
|
errors = module.validate_pr_body_text(text)
|
|
assert any("REQ-ID" in err for err in errors)
|
|
assert any("TASK-ID" in err for err in errors)
|
|
assert any("TEST-ID" in err for err in errors)
|
|
|
|
|
|
def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None:
|
|
module = _load_module()
|
|
|
|
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
|
assert cmd[0] == "/tmp/tea-bin"
|
|
assert check is True
|
|
assert capture_output is True
|
|
assert text is True
|
|
return SimpleNamespace(stdout=json.dumps({"body": "## Summary\n- item"}))
|
|
|
|
monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin")
|
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
assert module.fetch_pr_body(391) == "## Summary\n- item"
|
|
|
|
|
|
def test_fetch_pr_body_rejects_non_string_body(monkeypatch) -> None:
|
|
module = _load_module()
|
|
|
|
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
|
return SimpleNamespace(stdout=json.dumps({"body": 123}))
|
|
|
|
monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin")
|
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
with pytest.raises(RuntimeError):
|
|
module.fetch_pr_body(391)
|
|
|
|
|
|
def test_resolve_tea_binary_falls_back_to_home_bin(monkeypatch, tmp_path) -> None:
|
|
module = _load_module()
|
|
tea_home = tmp_path / "bin" / "tea"
|
|
tea_home.parent.mkdir(parents=True)
|
|
tea_home.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
tea_home.chmod(0o755)
|
|
|
|
monkeypatch.setattr(module.shutil, "which", lambda _: None)
|
|
monkeypatch.setattr(module.Path, "home", lambda: tmp_path)
|
|
assert module.resolve_tea_binary() == str(tea_home)
|
|
|
|
|
|
def test_resolve_tea_binary_rejects_non_executable_home_bin(monkeypatch, tmp_path) -> None:
|
|
module = _load_module()
|
|
tea_home = tmp_path / "bin" / "tea"
|
|
tea_home.parent.mkdir(parents=True)
|
|
tea_home.write_text("not executable\n", encoding="utf-8")
|
|
tea_home.chmod(0o644)
|
|
|
|
monkeypatch.setattr(module.shutil, "which", lambda _: None)
|
|
monkeypatch.setattr(module.Path, "home", lambda: tmp_path)
|
|
with pytest.raises(RuntimeError):
|
|
module.resolve_tea_binary()
|