| |
| """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()) |
|
|