diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py index 80ed62e6..d7964f81 100644 --- a/scripts/validate_schemas.py +++ b/scripts/validate_schemas.py @@ -8,6 +8,7 @@ import json import re import sys import zipfile +from collections.abc import Callable from pathlib import Path, PurePosixPath import yaml @@ -123,7 +124,11 @@ def _checks_without_values(path: Path, document: object) -> list[str]: return out -def _validate_json_files(paths: list[Path], schema_name: str) -> list[str]: +def _validate_json_files( + paths: list[Path], + schema_name: str, + semantic_check: Callable[[Path, object], list[str]] | None = None, +) -> list[str]: validator = _validator(schema_name) out: list[str] = [] for path in paths: @@ -132,7 +137,10 @@ def _validate_json_files(paths: list[Path], schema_name: str) -> list[str]: except (OSError, json.JSONDecodeError) as exc: out.append(f"{path.relative_to(ROOT)}: {exc}") continue - out.extend(_errors(validator, data, str(path.relative_to(ROOT)))) + validation_errors = _errors(validator, data, str(path.relative_to(ROOT))) + out.extend(validation_errors) + if not validation_errors and semantic_check is not None: + out.extend(semantic_check(path, data)) return out @@ -407,16 +415,20 @@ def main() -> int: if not args.source_only: database_path = ROOT / "database.json" - errors.extend(_validate_json_files([database_path], "database.schema.json")) - if database_path.exists(): - errors.extend(_semantic_database_checks(_load_json(database_path))) + errors.extend( + _validate_json_files( + [database_path], + "database.schema.json", + lambda _path, database: _semantic_database_checks(database), + ) + ) install_paths = sorted((ROOT / "install").glob("*.json")) errors.extend( - _validate_json_files(install_paths, "install-manifest.schema.json") + _validate_json_files( + install_paths, "install-manifest.schema.json", _semantic_install_checks + ) ) - for path in install_paths: - errors.extend(_semantic_install_checks(path, _load_json(path))) target_paths = sorted((ROOT / "install" / "targets").glob("*.json")) errors.extend( @@ -448,10 +460,12 @@ def main() -> int: (api_dir / "systems").glob("*.json") ) + sorted((api_dir / "lookup").glob("*.json")) errors.extend( - _validate_json_files(envelope_paths, "site-api-envelope.schema.json") + _validate_json_files( + envelope_paths, + "site-api-envelope.schema.json", + _semantic_envelope_checks, + ) ) - for path in envelope_paths: - errors.extend(_semantic_envelope_checks(path, _load_json(path))) errors.extend(_validate_pack_manifests(ROOT / "dist")) diff --git a/tests/test_schema_semantic_gate.py b/tests/test_schema_semantic_gate.py new file mode 100644 index 00000000..a8022cc6 --- /dev/null +++ b/tests/test_schema_semantic_gate.py @@ -0,0 +1,98 @@ +"""Schema-invalid synthetic manifests must not crash semantic validation.""" + +from __future__ import annotations + +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +try: + import validate_schemas +except ImportError as exc: + validate_schemas = None + IMPORT_ERROR = str(exc) +else: + IMPORT_ERROR = "" + + +@unittest.skipIf(validate_schemas is None, f"validate_schemas unavailable: {IMPORT_ERROR}") +class SemanticManifestValidation(unittest.TestCase): + def test_invalid_synthetic_manifest_reports_schema_errors_without_crashing(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as directory: + root = Path(directory) + (root / "emulators").mkdir() + platforms = root / "platforms" + platforms.mkdir() + (platforms / "_data_dirs.yml").write_text( + "data_directories: {}\n", encoding="utf-8" + ) + (root / "database.json").write_text( + json.dumps({"files": None}), encoding="utf-8" + ) + install = root / "install" + install.mkdir() + (install / "synthetic.json").write_text( + json.dumps({"files": None}), encoding="utf-8" + ) + api = root / "docs" / "api" / "v1" + api.mkdir(parents=True) + (api / "catalog.json").write_text( + json.dumps({"count": 1, "items": None}), encoding="utf-8" + ) + + output = io.StringIO() + with mock.patch.object(validate_schemas, "ROOT", root), mock.patch.object( + sys, "argv", ["validate_schemas.py"] + ), redirect_stdout(output): + result = validate_schemas.main() + + self.assertEqual(result, 1) + text = output.getvalue().replace("\\", "/") + self.assertIn("database.json", text) + self.assertIn("install/synthetic.json", text) + self.assertIn("docs/api/v1/catalog.json", text) + self.assertIn("files", text) + self.assertIn("items", text) + self.assertNotIn("Traceback", text) + + def test_valid_synthetic_manifest_still_runs_semantic_checks(self): + manifest = { + "manifest_version": 2, + "source": "full", + "regions": [], + "platform": "synthetic", + "display_name": "Synthetic", + "version": "test", + "generated": "2026-10-21T00:00:00Z", + "base_destination": "", + "detect": [], + "standalone_copies": [], + "total_files": 1, + "total_size": 0, + "total_omitted": 0, + "omitted_files": [], + "files": [], + } + with tempfile.TemporaryDirectory(dir=REPO_ROOT) as directory: + path = Path(directory) / "synthetic.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + errors = validate_schemas._validate_json_files( + [path], + "install-manifest.schema.json", + validate_schemas._semantic_install_checks, + ) + + self.assertEqual(len(errors), 1) + self.assertIn("total_files does not equal len(files)", errors[0]) + + +if __name__ == "__main__": + unittest.main()