#!/usr/bin/env python3 """Validate example loop contracts against the repository schema. This intentionally supports only the JSON Schema keywords used by schemas/loop-contract.schema.json, keeping CI dependency-free. """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any SCHEMA_PATH = Path("schemas/loop-contract.schema.json") EXAMPLE_GLOB = "examples/*-loop.json" def type_matches(value: Any, expected: str) -> bool: if expected == "object": return isinstance(value, dict) if expected == "array": return isinstance(value, list) if expected == "string": return isinstance(value, str) if expected == "integer": return isinstance(value, int) and not isinstance(value, bool) return True def validate(value: Any, schema: dict[str, Any], path: str) -> list[str]: errors: list[str] = [] expected_type = schema.get("type") if isinstance(expected_type, str) and not type_matches(value, expected_type): return [f"{path}: expected {expected_type}, got {type(value).__name__}"] if "enum" in schema and value not in schema["enum"]: errors.append(f"{path}: expected one of {schema['enum']!r}, got {value!r}") if isinstance(value, str): min_length = schema.get("minLength") if isinstance(min_length, int) and len(value) < min_length: errors.append(f"{path}: string is shorter than {min_length}") if isinstance(value, int) and not isinstance(value, bool): minimum = schema.get("minimum") if isinstance(minimum, int) and value < minimum: errors.append(f"{path}: value is less than {minimum}") if isinstance(value, list): min_items = schema.get("minItems") if isinstance(min_items, int) and len(value) < min_items: errors.append(f"{path}: array has fewer than {min_items} items") item_schema = schema.get("items") if isinstance(item_schema, dict): for index, item in enumerate(value): errors.extend(validate(item, item_schema, f"{path}[{index}]")) if isinstance(value, dict): required = schema.get("required", []) for key in required: if key not in value: errors.append(f"{path}: missing required key {key!r}") properties = schema.get("properties", {}) if schema.get("additionalProperties") is False: extra = sorted(set(value) - set(properties)) for key in extra: errors.append(f"{path}: unexpected key {key!r}") for key, child_schema in properties.items(): if key in value and isinstance(child_schema, dict): errors.extend(validate(value[key], child_schema, f"{path}.{key}")) return errors def main() -> int: schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) failures: list[str] = [] examples = sorted(Path().glob(EXAMPLE_GLOB)) if not examples: print(f"No example files found for {EXAMPLE_GLOB}", file=sys.stderr) return 1 for example in examples: data = json.loads(example.read_text(encoding="utf-8")) failures.extend(validate(data, schema, str(example))) if failures: print("Loop contract example validation failed:", file=sys.stderr) for failure in failures: print(f"- {failure}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())