diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 39fb10d..9fa9522 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: run: python3 scripts/session_handover_check.py --strict - name: Validate governance assets + env: + GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }} + GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }} run: | RANGE="" if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67cf621..da84fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,21 @@ jobs: run: python3 scripts/session_handover_check.py --strict - name: Validate governance assets - run: python3 scripts/validate_governance_assets.py + env: + GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }} + GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }} + run: | + RANGE="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}" + elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + RANGE="${{ github.event.before }}...${{ github.sha }}" + fi + if [ -n "$RANGE" ]; then + python3 scripts/validate_governance_assets.py "$RANGE" + else + python3 scripts/validate_governance_assets.py + fi - name: Validate Ouroboros docs run: python3 scripts/validate_ouroboros_docs.py diff --git a/scripts/validate_governance_assets.py b/scripts/validate_governance_assets.py index 82e94d4..5872de1 100644 --- a/scripts/validate_governance_assets.py +++ b/scripts/validate_governance_assets.py @@ -5,9 +5,16 @@ from __future__ import annotations import subprocess import sys +import os +import re from pathlib import Path REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md" +TASK_WORK_ORDERS_DOC = "docs/ouroboros/30_code_level_work_orders.md" +TASK_DEF_LINE = re.compile(r"^-\s+`(?PTASK-[A-Z0-9-]+-\d{3})`(?P.*)$") +REQ_ID_IN_LINE = re.compile(r"\bREQ-[A-Z0-9-]+-\d{3}\b") +TASK_ID_IN_TEXT = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b") +TEST_ID_IN_TEXT = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b") def must_contain(path: Path, required: list[str], errors: list[str]) -> None: @@ -75,8 +82,45 @@ def validate_registry_sync(changed_files: list[str], errors: list[str]) -> None: ) +def validate_task_req_mapping(errors: list[str], *, task_doc: Path | None = None) -> None: + path = task_doc or Path(TASK_WORK_ORDERS_DOC) + if not path.exists(): + errors.append(f"missing file: {path}") + return + + text = path.read_text(encoding="utf-8") + found_task = False + for line in text.splitlines(): + m = TASK_DEF_LINE.match(line.strip()) + if not m: + continue + found_task = True + if not REQ_ID_IN_LINE.search(m.group("body")): + errors.append( + f"{path}: TASK without REQ mapping -> {m.group('task_id')}" + ) + if not found_task: + errors.append(f"{path}: no TASK definitions found") + + +def validate_pr_traceability(warnings: list[str]) -> None: + title = os.getenv("GOVERNANCE_PR_TITLE", "").strip() + body = os.getenv("GOVERNANCE_PR_BODY", "").strip() + if not title and not body: + return + + text = f"{title}\n{body}" + if not REQ_ID_IN_LINE.search(text): + warnings.append("PR text missing REQ-ID reference") + if not TASK_ID_IN_TEXT.search(text): + warnings.append("PR text missing TASK-ID reference") + if not TEST_ID_IN_TEXT.search(text): + warnings.append("PR text missing TEST-ID reference") + + def main() -> int: errors: list[str] = [] + warnings: list[str] = [] changed_files = load_changed_files(sys.argv[1:], errors) pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md") @@ -141,6 +185,8 @@ def main() -> int: errors.append(f"missing file: {handover_script}") validate_registry_sync(changed_files, errors) + validate_task_req_mapping(errors) + validate_pr_traceability(warnings) if errors: print("[FAIL] governance asset validation failed") @@ -149,6 +195,10 @@ def main() -> int: return 1 print("[OK] governance assets validated") + if warnings: + print(f"[WARN] governance advisory: {len(warnings)}") + for warn in warnings: + print(f"- {warn}") return 0 diff --git a/tests/test_validate_governance_assets.py b/tests/test_validate_governance_assets.py index a3a8519..3a0bc0b 100644 --- a/tests/test_validate_governance_assets.py +++ b/tests/test_validate_governance_assets.py @@ -79,3 +79,38 @@ def test_load_changed_files_with_range_uses_git_diff(monkeypatch) -> None: "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