"""Load and validate the HUX (Hermes user-experience) contract schemas. The schemas under ``services/hermes/contracts/hux`` are plain JSON Schema 2020-12 so browser and Go consumers can validate with their usual libraries. CI has no ``jsonschema`` package, so this module carries a small validator for the keyword subset the contracts actually use. Unsupported keywords fail loudly rather than silently passing. """ from __future__ import annotations import json import re from pathlib import Path from typing import Any CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux" SCHEMA_FILES = ( "common.schema.json", "event.schema.json", "memory.schema.json", "project.schema.json", "artifact.schema.json", "permission.schema.json", "mode.schema.json", "citation.schema.json", "suggestion.schema.json", "privacy.schema.json", "release.schema.json", ) SUPPORTED_KEYWORDS = frozenset( { "$schema", "$id", "$defs", "$ref", "title", "description", "type", "const", "enum", "required", "properties", "additionalProperties", "items", "minItems", "maxItems", "uniqueItems", "minLength", "maxLength", "pattern", "minimum", "maximum", "oneOf", } ) _TYPE_CHECKS = { "object": lambda v: isinstance(v, dict), "array": lambda v: isinstance(v, list), "string": lambda v: isinstance(v, str), "boolean": lambda v: isinstance(v, bool), "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), "null": lambda v: v is None, } class ContractError(ValueError): """Raised when a schema uses something this validator does not support.""" def load_schema(name: str, directory: Path = CONTRACT_DIR) -> dict[str, Any]: """Read one schema file by name.""" return json.loads((directory / name).read_text(encoding="utf-8")) def load_all(directory: Path = CONTRACT_DIR) -> dict[str, dict[str, Any]]: """Read every contract schema keyed by file name.""" return {name: load_schema(name, directory) for name in SCHEMA_FILES} def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]: """Read the feature flag registry.""" return json.loads((directory / "flags.json").read_text(encoding="utf-8")) def _walk(node: Any, path: str, problems: list[str]) -> None: if isinstance(node, dict): for key, value in node.items(): if path.endswith("/properties") or path.endswith("/$defs"): _walk(value, f"{path}/{key}", problems) continue if key not in SUPPORTED_KEYWORDS: problems.append(f"{path}/{key}") continue _walk(value, f"{path}/{key}", problems) elif isinstance(node, list): for index, value in enumerate(node): _walk(value, f"{path}/{index}", problems) def unsupported_keywords(schema: dict[str, Any]) -> list[str]: """Return JSON-pointer style paths of keywords the validator ignores.""" problems: list[str] = [] _walk(schema, "#", problems) return problems def _resolve_ref(ref: str, current: str, schemas: dict[str, dict[str, Any]]) -> tuple[dict[str, Any], str]: file_part, _, pointer = ref.partition("#") file_name = file_part or current if file_name not in schemas: raise ContractError(f"unknown schema reference {ref!r}") node: Any = schemas[file_name] for token in [t for t in pointer.split("/") if t]: if not isinstance(node, dict) or token not in node: raise ContractError(f"unresolvable pointer {ref!r}") node = node[token] return node, file_name def _check_type(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> bool: expected = schema.get("type") if expected is None: return True if expected not in _TYPE_CHECKS: raise ContractError(f"unsupported type {expected!r} at {path}") if not _TYPE_CHECKS[expected](value): errors.append(f"{path}: expected {expected}") return False return True def _check_scalars(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> None: if "const" in schema and value != schema["const"]: errors.append(f"{path}: expected constant {schema['const']!r}") if "enum" in schema and value not in schema["enum"]: errors.append(f"{path}: {value!r} not in enum") if isinstance(value, str): if "minLength" in schema and len(value) < schema["minLength"]: errors.append(f"{path}: shorter than {schema['minLength']}") if "maxLength" in schema and len(value) > schema["maxLength"]: errors.append(f"{path}: longer than {schema['maxLength']}") if "pattern" in schema and not re.search(schema["pattern"], value): errors.append(f"{path}: does not match {schema['pattern']!r}") if isinstance(value, (int, float)) and not isinstance(value, bool): if "minimum" in schema and value < schema["minimum"]: errors.append(f"{path}: below minimum {schema['minimum']}") if "maximum" in schema and value > schema["maximum"]: errors.append(f"{path}: above maximum {schema['maximum']}") def _check_object(schema, value, path, errors, current, schemas) -> None: properties = schema.get("properties", {}) for key in schema.get("required", []): if key not in value: errors.append(f"{path}: missing required {key!r}") for key, item in value.items(): if key in properties: _validate(properties[key], item, f"{path}/{key}", errors, current, schemas) elif schema.get("additionalProperties") is False: errors.append(f"{path}: unexpected property {key!r}") def _check_array(schema, value, path, errors, current, schemas) -> None: if "minItems" in schema and len(value) < schema["minItems"]: errors.append(f"{path}: fewer than {schema['minItems']} items") if "maxItems" in schema and len(value) > schema["maxItems"]: errors.append(f"{path}: more than {schema['maxItems']} items") if schema.get("uniqueItems"): seen = [json.dumps(item, sort_keys=True) for item in value] if len(set(seen)) != len(seen): errors.append(f"{path}: items are not unique") if "items" in schema: for index, item in enumerate(value): _validate(schema["items"], item, f"{path}/{index}", errors, current, schemas) def _check_one_of(schema, value, path, errors, current, schemas) -> None: attempts: list[list[str]] = [] for option in schema["oneOf"]: sub: list[str] = [] _validate(option, value, path, sub, current, schemas) attempts.append(sub) matches = sum(not sub for sub in attempts) if matches != 1: errors.append(f"{path}: matched {matches} oneOf branches, expected exactly 1") if matches == 0: errors.extend(min(attempts, key=len)) def _validate(schema, value, path, errors, current, schemas) -> None: if "$ref" in schema: target, file_name = _resolve_ref(schema["$ref"], current, schemas) _validate(target, value, path, errors, file_name, schemas) return if not _check_type(schema, value, path, errors): return _check_scalars(schema, value, path, errors) if isinstance(value, dict): _check_object(schema, value, path, errors, current, schemas) if isinstance(value, list): _check_array(schema, value, path, errors, current, schemas) if "oneOf" in schema: _check_one_of(schema, value, path, errors, current, schemas) def validate( schema_name: str, value: Any, schemas: dict[str, dict[str, Any]] | None = None, pointer: str = "", ) -> list[str]: """Validate ``value`` against a schema file, or a ``#/$defs/...`` pointer inside it. Returns a list of human-readable problems; an empty list means valid. """ schemas = schemas or load_all() schema, file_name = _resolve_ref(f"{schema_name}#{pointer}", schema_name, schemas) errors: list[str] = [] _validate(schema, value, "$", errors, file_name, schemas) return errors def record_schema_names(schemas: dict[str, dict[str, Any]] | None = None) -> dict[str, str]: """Map every ``schema`` constant (e.g. ``hux.event.v1``) to its file name.""" schemas = schemas or load_all() found: dict[str, str] = {} def visit(node: Any, file_name: str) -> None: if isinstance(node, dict): const = node.get("properties", {}).get("schema", {}).get("const") if isinstance(const, str): found[const] = file_name for child in node.values(): visit(child, file_name) elif isinstance(node, list): for child in node: visit(child, file_name) for file_name, schema in schemas.items(): visit(schema, file_name) return found def validate_record(value: Any, schemas: dict[str, dict[str, Any]] | None = None) -> list[str]: """Validate a record by its own ``schema`` field.""" schemas = schemas or load_all() if not isinstance(value, dict) or not isinstance(value.get("schema"), str): return ["$: record has no string 'schema' field"] file_name = record_schema_names(schemas).get(value["schema"]) if file_name is None: return [f"$: unknown record schema {value['schema']!r}"] return validate(file_name, value, schemas)