diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b392c0e..8781056 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,12 @@ repos: language: python pass_filenames: false always_run: true + - id: guard-openapi-sync + name: guard generated OpenAPI sync (staged) + entry: python -B scripts/check_openapi_sync.py --staged + language: python + pass_filenames: false + always_run: true # Secret detection - repo: https://github.com/Yelp/detect-secrets diff --git a/scripts/check_openapi_sync.py b/scripts/check_openapi_sync.py new file mode 100644 index 0000000..b67c6aa --- /dev/null +++ b/scripts/check_openapi_sync.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import Callable, Iterable + +OPENAPI_SYNC_TRIGGER_PATHS = { + "docs/openapi.yaml", + "docs/release/api_contract.md", + "scripts/generate_openapi_spec.py", + "services/openapi_generation.py", +} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _normalize_path(path: str) -> str: + return path.replace("\\", "/").strip() + + +def _get_staged_paths() -> list[str]: + result = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + return [] + return [ + _normalize_path(line) + for line in result.stdout.splitlines() + if _normalize_path(line) + ] + + +def should_validate_openapi(paths: Iterable[str]) -> bool: + normalized = {_normalize_path(path) for path in paths if _normalize_path(path)} + if not normalized: + return False + return any(path in OPENAPI_SYNC_TRIGGER_PATHS for path in normalized) + + +def _load_generate_openapi_yaml() -> Callable[[str | Path | None], str]: + root = str(_repo_root()) + if root not in sys.path: + sys.path.insert(0, root) + from services.openapi_generation import generate_openapi_yaml + + return generate_openapi_yaml + + +def validate_openapi_sync( + *, + openapi_path: str | Path | None = None, + contract_path: str | Path | None = None, + generate_openapi_yaml: Callable[[str | Path | None], str] | None = None, +) -> tuple[bool, str]: + root = _repo_root() + openapi_file = Path(openapi_path) if openapi_path else root / "docs" / "openapi.yaml" + contract_file = ( + Path(contract_path) + if contract_path + else root / "docs" / "release" / "api_contract.md" + ) + if not openapi_file.exists(): + return False, f"[OpenClaw] OpenAPI sync check failed: missing {openapi_file}" + + generator = generate_openapi_yaml or _load_generate_openapi_yaml() + expected = generator(contract_file) + actual = openapi_file.read_text(encoding="utf-8") + if actual == expected: + return True, "" + + message = "\n".join( + [ + "[OpenClaw] Commit/push blocked: docs/openapi.yaml is out of sync.", + "", + "Generated specs must not be edited by hand without regenerating them.", + "", + "Fix:", + " 1. Update generator/contract inputs as needed.", + " 2. Regenerate with: python scripts/generate_openapi_spec.py", + " 3. Review and stage docs/openapi.yaml together with the source change.", + ] + ) + return False, message + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Guard that docs/openapi.yaml stays in sync with the generator." + ) + parser.add_argument( + "--staged", + action="store_true", + help="Only validate when staged changes touch OpenAPI generator/spec sources.", + ) + args = parser.parse_args(argv) + + if args.staged and not should_validate_openapi(_get_staged_paths()): + return 0 + + ok, message = validate_openapi_sync() + if ok: + return 0 + print(message, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pre_push_checks.sh b/scripts/pre_push_checks.sh index 67cc73d..096bb43 100644 --- a/scripts/pre_push_checks.sh +++ b/scripts/pre_push_checks.sh @@ -339,6 +339,10 @@ else fi fi +# IMPORTANT: generated spec drift must fail before backend tests so docs-only +# edits cannot hide until deep in the pre-push unit suite. +"$VENV_PY" scripts/check_openapi_sync.py + echo "[pre-push] 3/7 backend unit tests" MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_unit" \ "$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json diff --git a/services/openapi_generation.py b/services/openapi_generation.py index e9c226b..5804934 100644 --- a/services/openapi_generation.py +++ b/services/openapi_generation.py @@ -322,7 +322,7 @@ def build_openapi_document( "in": "header", "name": "X-OpenClaw-Bridge-Token", }, - } + }, }, } diff --git a/tests/test_openapi_sync_guard.py b/tests/test_openapi_sync_guard.py new file mode 100644 index 0000000..f707fa8 --- /dev/null +++ b/tests/test_openapi_sync_guard.py @@ -0,0 +1,64 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +def _load_module(): + root = Path(__file__).resolve().parents[1] + module_path = root / "scripts" / "check_openapi_sync.py" + spec = importlib.util.spec_from_file_location("openapi_sync_guard_mod", module_path) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load check_openapi_sync.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestOpenApiSyncGuard(unittest.TestCase): + def setUp(self): + self.mod = _load_module() + + def test_should_validate_openapi_true_for_generated_spec(self): + self.assertTrue(self.mod.should_validate_openapi(["docs/openapi.yaml"])) + + def test_should_validate_openapi_true_for_generator_source(self): + self.assertTrue( + self.mod.should_validate_openapi(["services/openapi_generation.py"]) + ) + + def test_should_validate_openapi_false_for_unrelated_paths(self): + self.assertFalse(self.mod.should_validate_openapi(["README.md"])) + + def test_validate_openapi_sync_passes_when_content_matches(self): + with tempfile.TemporaryDirectory() as td: + openapi_path = Path(td) / "openapi.yaml" + contract_path = Path(td) / "api_contract.md" + contract_path.write_text("dummy", encoding="utf-8") + openapi_path.write_text("expected-yaml\n", encoding="utf-8") + ok, message = self.mod.validate_openapi_sync( + openapi_path=openapi_path, + contract_path=contract_path, + generate_openapi_yaml=lambda _: "expected-yaml\n", + ) + self.assertTrue(ok) + self.assertEqual(message, "") + + def test_validate_openapi_sync_fails_when_content_drifts(self): + with tempfile.TemporaryDirectory() as td: + openapi_path = Path(td) / "openapi.yaml" + contract_path = Path(td) / "api_contract.md" + contract_path.write_text("dummy", encoding="utf-8") + openapi_path.write_text("hand-edited\n", encoding="utf-8") + ok, message = self.mod.validate_openapi_sync( + openapi_path=openapi_path, + contract_path=contract_path, + generate_openapi_yaml=lambda _: "generated\n", + ) + self.assertFalse(ok) + self.assertIn("docs/openapi.yaml is out of sync", message) + self.assertIn("python scripts/generate_openapi_spec.py", message) + + +if __name__ == "__main__": + unittest.main()