287 lines
8.9 KiB
Python
287 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
|
|
def _load_module():
|
|
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_governance_assets.py"
|
|
spec = importlib.util.spec_from_file_location("validate_governance_assets", 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_is_policy_file_detects_ouroboros_policy_docs() -> None:
|
|
module = _load_module()
|
|
assert module.is_policy_file("docs/ouroboros/85_loss_recovery_action_plan.md")
|
|
assert not module.is_policy_file("docs/ouroboros/01_requirements_registry.md")
|
|
assert not module.is_policy_file("docs/workflow.md")
|
|
assert not module.is_policy_file("docs/ouroboros/notes.txt")
|
|
|
|
|
|
def test_validate_registry_sync_requires_registry_update_when_policy_changes() -> None:
|
|
module = _load_module()
|
|
errors: list[str] = []
|
|
module.validate_registry_sync(
|
|
["docs/ouroboros/85_loss_recovery_action_plan.md"],
|
|
errors,
|
|
)
|
|
assert errors
|
|
assert "policy file changed without updating" in errors[0]
|
|
|
|
|
|
def test_validate_registry_sync_passes_when_registry_included() -> None:
|
|
module = _load_module()
|
|
errors: list[str] = []
|
|
module.validate_registry_sync(
|
|
[
|
|
"docs/ouroboros/85_loss_recovery_action_plan.md",
|
|
"docs/ouroboros/01_requirements_registry.md",
|
|
],
|
|
errors,
|
|
)
|
|
assert errors == []
|
|
|
|
|
|
def test_load_changed_files_supports_explicit_paths() -> None:
|
|
module = _load_module()
|
|
errors: list[str] = []
|
|
changed = module.load_changed_files(
|
|
["./docs/ouroboros/85_loss_recovery_action_plan.md", " src/main.py "],
|
|
errors,
|
|
)
|
|
assert errors == []
|
|
assert changed == [
|
|
"docs/ouroboros/85_loss_recovery_action_plan.md",
|
|
"src/main.py",
|
|
]
|
|
|
|
|
|
def test_load_changed_files_with_range_uses_git_diff(monkeypatch) -> None:
|
|
module = _load_module()
|
|
errors: list[str] = []
|
|
|
|
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
|
assert cmd[:3] == ["git", "diff", "--name-only"]
|
|
assert check is True
|
|
assert capture_output is True
|
|
assert text is True
|
|
return SimpleNamespace(
|
|
stdout="docs/ouroboros/85_loss_recovery_action_plan.md\nsrc/main.py\n"
|
|
)
|
|
|
|
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
changed = module.load_changed_files(["abc...def"], errors)
|
|
assert errors == []
|
|
assert changed == [
|
|
"docs/ouroboros/85_loss_recovery_action_plan.md",
|
|
"src/main.py",
|
|
]
|
|
|
|
|
|
def test_validate_task_req_mapping_reports_missing_req_reference(tmp_path) -> None:
|
|
module = _load_module()
|
|
doc = tmp_path / "work_orders.md"
|
|
doc.write_text(
|
|
"- `TASK-OPS-999` no req mapping line\n",
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.validate_task_req_mapping(errors, task_doc=doc)
|
|
assert errors
|
|
assert "TASK without REQ mapping" in errors[0]
|
|
|
|
|
|
def test_validate_task_req_mapping_passes_when_req_present(tmp_path) -> None:
|
|
module = _load_module()
|
|
doc = tmp_path / "work_orders.md"
|
|
doc.write_text(
|
|
"- `TASK-OPS-999` (`REQ-OPS-001`): enforce timezone labels\n",
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.validate_task_req_mapping(errors, task_doc=doc)
|
|
assert errors == []
|
|
|
|
|
|
def test_validate_pr_traceability_warns_when_req_missing(monkeypatch) -> None:
|
|
module = _load_module()
|
|
monkeypatch.setenv("GOVERNANCE_PR_TITLE", "feat: update policy checker")
|
|
monkeypatch.setenv("GOVERNANCE_PR_BODY", "Refs: TASK-OPS-001 TEST-ACC-007")
|
|
warnings: list[str] = []
|
|
module.validate_pr_traceability(warnings)
|
|
assert warnings
|
|
assert "PR text missing REQ-ID reference" in warnings
|
|
|
|
|
|
def test_validate_read_only_approval_requires_evidence(monkeypatch) -> None:
|
|
module = _load_module()
|
|
changed_files = ["src/core/risk_manager.py"]
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
monkeypatch.setenv(
|
|
"GOVERNANCE_PR_BODY",
|
|
"\n".join(
|
|
[
|
|
"## READ-ONLY Approval (Required when touching READ-ONLY files)",
|
|
"- Touched READ-ONLY files: src/core/risk_manager.py",
|
|
"- Human approval: TBD",
|
|
"- Test suite 1: pytest -q",
|
|
"- Test suite 2: TBD",
|
|
]
|
|
),
|
|
)
|
|
|
|
module.validate_read_only_approval(changed_files, errors, warnings)
|
|
assert warnings == []
|
|
assert any("Human approval" in err for err in errors)
|
|
assert any("Test suite 2" in err for err in errors)
|
|
|
|
|
|
def test_validate_read_only_approval_passes_with_complete_evidence(monkeypatch) -> None:
|
|
module = _load_module()
|
|
changed_files = ["src/core/risk_manager.py"]
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
monkeypatch.setenv(
|
|
"GOVERNANCE_PR_BODY",
|
|
"\n".join(
|
|
[
|
|
"## READ-ONLY Approval (Required when touching READ-ONLY files)",
|
|
"- Touched READ-ONLY files: src/core/risk_manager.py",
|
|
"- Human approval: https://example.com/review/123",
|
|
"- Test suite 1: pytest -q tests/test_risk.py",
|
|
"- Test suite 2: pytest -q tests/test_main.py -k risk",
|
|
]
|
|
),
|
|
)
|
|
|
|
module.validate_read_only_approval(changed_files, errors, warnings)
|
|
assert errors == []
|
|
assert warnings == []
|
|
|
|
|
|
def test_validate_read_only_approval_warns_without_pr_body(monkeypatch) -> None:
|
|
module = _load_module()
|
|
changed_files = ["src/core/risk_manager.py"]
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
monkeypatch.delenv("GOVERNANCE_PR_BODY", raising=False)
|
|
|
|
module.validate_read_only_approval(changed_files, errors, warnings)
|
|
assert errors == []
|
|
assert warnings
|
|
assert "approval evidence check skipped" in warnings[0]
|
|
|
|
|
|
def test_validate_read_only_approval_skips_when_no_readonly_file_changed() -> None:
|
|
module = _load_module()
|
|
changed_files = ["src/main.py"]
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
module.validate_read_only_approval(changed_files, errors, warnings)
|
|
assert errors == []
|
|
assert warnings == []
|
|
|
|
|
|
def test_must_contain_enforces_workflow_newline_helper_tokens(tmp_path) -> None:
|
|
module = _load_module()
|
|
workflow_doc = tmp_path / "workflow.md"
|
|
workflow_doc.write_text(
|
|
"\n".join(
|
|
[
|
|
"Session Handover Gate (Mandatory)",
|
|
"python3 scripts/session_handover_check.py --strict",
|
|
"scripts/tea_comment.sh",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.must_contain(
|
|
workflow_doc,
|
|
[
|
|
"Session Handover Gate (Mandatory)",
|
|
"session_handover_check.py --strict",
|
|
"scripts/tea_comment.sh",
|
|
],
|
|
errors,
|
|
)
|
|
assert errors == []
|
|
|
|
|
|
def test_must_contain_fails_when_workflow_missing_newline_helper_token(tmp_path) -> None:
|
|
module = _load_module()
|
|
workflow_doc = tmp_path / "workflow.md"
|
|
workflow_doc.write_text(
|
|
"\n".join(
|
|
[
|
|
"Session Handover Gate (Mandatory)",
|
|
"python3 scripts/session_handover_check.py --strict",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.must_contain(
|
|
workflow_doc,
|
|
["scripts/tea_comment.sh"],
|
|
errors,
|
|
)
|
|
assert any("scripts/tea_comment.sh" in err for err in errors)
|
|
|
|
|
|
def test_must_contain_enforces_commands_newline_section_tokens(tmp_path) -> None:
|
|
module = _load_module()
|
|
commands_doc = tmp_path / "commands.md"
|
|
commands_doc.write_text(
|
|
"\n".join(
|
|
[
|
|
"Session Handover Preflight (Mandatory)",
|
|
"python3 scripts/session_handover_check.py --strict",
|
|
"Comment Newline Escaping",
|
|
"scripts/tea_comment.sh",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.must_contain(
|
|
commands_doc,
|
|
[
|
|
"Session Handover Preflight (Mandatory)",
|
|
"session_handover_check.py --strict",
|
|
"Comment Newline Escaping",
|
|
"scripts/tea_comment.sh",
|
|
],
|
|
errors,
|
|
)
|
|
assert errors == []
|
|
|
|
|
|
def test_must_contain_fails_when_commands_missing_newline_section_token(tmp_path) -> None:
|
|
module = _load_module()
|
|
commands_doc = tmp_path / "commands.md"
|
|
commands_doc.write_text(
|
|
"\n".join(
|
|
[
|
|
"Session Handover Preflight (Mandatory)",
|
|
"python3 scripts/session_handover_check.py --strict",
|
|
"scripts/tea_comment.sh",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
errors: list[str] = []
|
|
module.must_contain(
|
|
commands_doc,
|
|
["Comment Newline Escaping"],
|
|
errors,
|
|
)
|
|
assert any("Comment Newline Escaping" in err for err in errors)
|