"""Minimal JSON-schema-like validation for guide-board contracts. The first core should work from a clean checkout without pulling dependencies. This validator intentionally supports only the schema features used by the project's own draft contracts. """ from __future__ import annotations from pathlib import Path from typing import Any from guide_board.errors import ValidationError from guide_board.io import load_json SCHEMA_DIR = Path(__file__).resolve().parents[2] / "docs" / "schemas" def load_schema(schema_name: str) -> dict[str, Any]: return load_json(SCHEMA_DIR / f"{schema_name}.schema.json") def validate_document(document: Any, schema: dict[str, Any], path: str = "$") -> list[str]: errors: list[str] = [] _validate(document, schema, path, errors) return errors def assert_valid(document: Any, schema_name: str) -> None: schema = load_schema(schema_name) errors = validate_document(document, schema) if errors: formatted = "\n".join(f"- {error}" for error in errors) raise ValidationError(f"{schema_name} validation failed:\n{formatted}") def _validate(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None: if "type" in schema and not _matches_type(value, schema["type"]): errors.append(f"{path}: expected {schema['type']}, got {_type_name(value)}") return if "enum" in schema and value not in schema["enum"]: allowed = ", ".join(repr(item) for item in schema["enum"]) errors.append(f"{path}: expected one of {allowed}, got {value!r}") if isinstance(value, dict): required = schema.get("required", []) for key in required: if key not in value: errors.append(f"{path}: missing required property {key!r}") properties = schema.get("properties", {}) additional_allowed = schema.get("additionalProperties", True) for key, child in value.items(): child_path = f"{path}.{key}" if key in properties: _validate(child, properties[key], child_path, errors) elif additional_allowed is False: errors.append(f"{child_path}: unexpected property") if isinstance(value, list): min_items = schema.get("minItems") if isinstance(min_items, int) and len(value) < min_items: errors.append(f"{path}: expected at least {min_items} item(s)") item_schema = schema.get("items") if isinstance(item_schema, dict): for index, child in enumerate(value): _validate(child, item_schema, f"{path}[{index}]", errors) def _matches_type(value: Any, expected: str | list[str]) -> bool: if isinstance(expected, list): return any(_matches_type(value, item) for item in expected) 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) if expected == "number": return isinstance(value, (int, float)) and not isinstance(value, bool) if expected == "boolean": return isinstance(value, bool) if expected == "null": return value is None return True def _type_name(value: Any) -> str: if isinstance(value, bool): return "boolean" if isinstance(value, dict): return "object" if isinstance(value, list): return "array" if isinstance(value, str): return "string" if isinstance(value, int): return "integer" if isinstance(value, float): return "number" if value is None: return "null" return type(value).__name__