From 2e394cd17c52fae3d86798da4951d1dff147d8b0 Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 28 Feb 2026 14:36:05 +0900 Subject: [PATCH 1/2] infra: enforce governance registry sync checks in CI (#330) --- .gitea/workflows/ci.yml | 15 ++++++- scripts/validate_governance_assets.py | 61 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f73be37..39fb10d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -26,7 +28,18 @@ jobs: run: python3 scripts/session_handover_check.py --strict - name: Validate governance assets - run: python3 scripts/validate_governance_assets.py + run: | + RANGE="" + if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then + RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}" + elif [ -n "${{ github.event.before }}" ] && [ "${{ 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 80680d7..82e94d4 100644 --- a/scripts/validate_governance_assets.py +++ b/scripts/validate_governance_assets.py @@ -3,9 +3,12 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path +REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md" + def must_contain(path: Path, required: list[str], errors: list[str]) -> None: if not path.exists(): @@ -17,8 +20,64 @@ def must_contain(path: Path, required: list[str], errors: list[str]) -> None: errors.append(f"{path}: missing required token -> {token}") +def normalize_changed_path(path: str) -> str: + normalized = path.strip().replace("\\", "/") + if normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + +def is_policy_file(path: str) -> bool: + normalized = normalize_changed_path(path) + if not normalized.endswith(".md"): + return False + if not normalized.startswith("docs/ouroboros/"): + return False + return normalized != REQUIREMENTS_REGISTRY + + +def load_changed_files(args: list[str], errors: list[str]) -> list[str]: + if not args: + return [] + + # Single range input (e.g. BASE..HEAD or BASE...HEAD) + if len(args) == 1 and ".." in args[0]: + range_spec = args[0] + try: + completed = subprocess.run( + ["git", "diff", "--name-only", range_spec], + check=True, + capture_output=True, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + errors.append(f"failed to load changed files from range '{range_spec}': {exc}") + return [] + return [ + normalize_changed_path(line) + for line in completed.stdout.splitlines() + if line.strip() + ] + + return [normalize_changed_path(path) for path in args if path.strip()] + + +def validate_registry_sync(changed_files: list[str], errors: list[str]) -> None: + if not changed_files: + return + + changed_set = set(changed_files) + policy_changed = any(is_policy_file(path) for path in changed_set) + registry_changed = REQUIREMENTS_REGISTRY in changed_set + if policy_changed and not registry_changed: + errors.append( + "policy file changed without updating docs/ouroboros/01_requirements_registry.md" + ) + + def main() -> int: errors: list[str] = [] + changed_files = load_changed_files(sys.argv[1:], errors) pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md") issue_template = Path(".gitea/ISSUE_TEMPLATE/runtime_verification.md") @@ -81,6 +140,8 @@ def main() -> int: if not handover_script.exists(): errors.append(f"missing file: {handover_script}") + validate_registry_sync(changed_files, errors) + if errors: print("[FAIL] governance asset validation failed") for err in errors: -- 2.49.1 From 2406a80782d99104f3342a63f61c47e1379347be Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 28 Feb 2026 17:40:51 +0900 Subject: [PATCH 2/2] test: add governance validator unit coverage (#330) --- tests/test_validate_governance_assets.py | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_validate_governance_assets.py diff --git a/tests/test_validate_governance_assets.py b/tests/test_validate_governance_assets.py new file mode 100644 index 0000000..a3a8519 --- /dev/null +++ b/tests/test_validate_governance_assets.py @@ -0,0 +1,81 @@ +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", + ] -- 2.49.1