infra: CI 자동 검증 강화 (정책 레지스트리 + TASK-REQ 매핑) (#330) #347
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -25,7 +25,19 @@ jobs:
|
|||||||
run: python3 scripts/session_handover_check.py --strict
|
run: python3 scripts/session_handover_check.py --strict
|
||||||
|
|
||||||
- name: Validate governance assets
|
- 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: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
|
||||||
|
python3 scripts/validate_governance_assets.py "$RANGE"
|
||||||
|
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
RANGE="${{ github.event.before }}...${{ github.sha }}"
|
||||||
|
python3 scripts/validate_governance_assets.py "$RANGE"
|
||||||
|
else
|
||||||
|
python3 scripts/validate_governance_assets.py
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Validate Ouroboros docs
|
- name: Validate Ouroboros docs
|
||||||
run: python3 scripts/validate_ouroboros_docs.py
|
run: python3 scripts/validate_ouroboros_docs.py
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md"
|
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+`(?P<task_id>TASK-[A-Z0-9-]+-\d{3})`(?P<body>.*)$")
|
||||||
|
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:
|
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:
|
def main() -> int:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
changed_files = load_changed_files(sys.argv[1:], errors)
|
changed_files = load_changed_files(sys.argv[1:], errors)
|
||||||
|
|
||||||
pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md")
|
pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md")
|
||||||
@@ -141,6 +185,8 @@ def main() -> int:
|
|||||||
errors.append(f"missing file: {handover_script}")
|
errors.append(f"missing file: {handover_script}")
|
||||||
|
|
||||||
validate_registry_sync(changed_files, errors)
|
validate_registry_sync(changed_files, errors)
|
||||||
|
validate_task_req_mapping(errors)
|
||||||
|
validate_pr_traceability(warnings)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
print("[FAIL] governance asset validation failed")
|
print("[FAIL] governance asset validation failed")
|
||||||
@@ -149,6 +195,10 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
print("[OK] governance assets validated")
|
print("[OK] governance assets validated")
|
||||||
|
if warnings:
|
||||||
|
print(f"[WARN] governance advisory: {len(warnings)}")
|
||||||
|
for warn in warnings:
|
||||||
|
print(f"- {warn}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,38 @@ def test_load_changed_files_with_range_uses_git_diff(monkeypatch) -> None:
|
|||||||
"docs/ouroboros/85_loss_recovery_action_plan.md",
|
"docs/ouroboros/85_loss_recovery_action_plan.md",
|
||||||
"src/main.py",
|
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user