"""Dependency-free validation against the published portable-project schema.""" from __future__ import annotations import math from pathlib import Path import re from .common import META_ROOT, canonical, read_json def _schema_value(value, schema: dict, definitions: dict, path: str) -> None: if "$ref" in schema: return _schema_value( value, definitions[schema["$ref"].removeprefix("#/$defs/")], definitions, path, ) kind = schema.get("type") numeric = type(value) is int or type(value) is float and math.isfinite(value) matches = { "object": isinstance(value, dict), "array": isinstance(value, list), "string": isinstance(value, str), "number": numeric, "integer": numeric and int(value) == value, } if kind and not matches[kind]: raise ValueError(f"{path} must be a {kind}") if "const" in schema and value != schema["const"]: raise ValueError(f"{path} has an unsupported value") if "enum" in schema and value not in schema["enum"]: raise ValueError(f"{path} has an unsupported value") if isinstance(value, dict): properties = schema.get("properties", {}) unknown = set(value) - set(properties) if schema.get("additionalProperties") is False and unknown: names = ", ".join(key[:80] for key in sorted(unknown)[:4]) raise ValueError(f"Unknown field in {path}: {names}") if set(schema.get("required", [])) - set(value): raise ValueError(f"{path} is missing a required field") for key, item in value.items(): if key in properties: _schema_value(item, properties[key], definitions, f"{path}.{key}") if isinstance(value, list): if ( not schema.get("minItems", 0) <= len(value) <= schema.get("maxItems", len(value)) ): raise ValueError(f"{path} exceeds its item bounds") if schema.get("uniqueItems") and len( {canonical(item) for item in value} ) != len(value): raise ValueError(f"Duplicate value in {path}") prefix = schema.get("prefixItems", []) for index, item in enumerate(value): _schema_value( item, prefix[index] if index < len(prefix) else schema.get("items", {}), definitions, f"{path}[{index}]", ) if isinstance(value, str): if ( not schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", len(value)) ): raise ValueError(f"{path} exceeds its string bounds") if "pattern" in schema and re.search(schema["pattern"], value) is None: raise ValueError( f"{path} contains an invalid identifier, path or character" ) if type(value) in {int, float}: if ( "maximum" in schema and value > schema["maximum"] or "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"] ): raise ValueError(f"{path} exceeds its numeric bounds") def validate_project(payload: dict, workspace_root: Path) -> None: """Validate every declaration, not just the selected profile/dependencies.""" schema = read_json(META_ROOT / "tools/devkit/project.schema.json") _schema_value(payload, schema, schema["$defs"], "project") root = workspace_root.resolve() records = payload["repositories"] names = {record["name"] for record in records} if len(names) != len(records): raise ValueError("Duplicate project repository name") paths, aliases = set(), set() for record in records: path = (root / record["path"]).resolve() if not path.is_relative_to(root): raise ValueError("Repository path escapes the workspace") if path in paths: raise ValueError("Duplicate project repository path") paths.add(path) keys = {record["name"], *record.get("aliases", [])} if aliases & keys: raise ValueError("Repository names and aliases must be unambiguous") aliases.update(keys) checks = payload.get("checks", []) identities = {item["id"] for item in checks} if len(identities) != len(checks): raise ValueError("Duplicate project check ID") for item in checks: if set(item.get("repos", [])) - names: raise ValueError(f"Unknown repository reference in check {item['id']}") if set(item.get("deps", [])) - identities: raise ValueError(f"Unknown check dependency in {item['id']}") if set(item.get("after", [])) - identities: raise ValueError(f"Unknown check ordering reference in {item['id']}") if set(item.get("deps", [])) & set(item.get("after", [])): raise ValueError(f"Duplicate dependency/ordering reference in {item['id']}") if set(item.get("inputs", {}).get("repos", [])) - names: raise ValueError(f"Unknown input repository reference in {item['id']}") if not (root / item.get("cwd", ".")).resolve().is_relative_to(root): raise ValueError(f"Check {item['id']} cwd escapes the workspace") for name, selected in payload.get("profiles", {}).items(): if set(selected) - identities: raise ValueError(f"Unknown profile check in {name}") remaining = { item["id"]: set(item.get("deps", [])) | set(item.get("after", [])) for item in checks } while remaining: ready = {identity for identity, deps in remaining.items() if not deps} if not ready: raise ValueError("Cyclic check dependency in project declarations") remaining = { identity: deps - ready for identity, deps in remaining.items() if identity not in ready } for value in payload.get("review", {}).values(): if not (root / value).resolve().is_relative_to(root): raise ValueError("Project review path escapes the workspace")