#!/usr/bin/env python from __future__ import annotations import re import sys from pathlib import Path from urllib.parse import unquote import yaml ROOT = Path(__file__).resolve().parent.parent MARKDOWN_LINK = re.compile(r"(? object: path = ROOT / relative try: return yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: raise ValidationError(f"Kan YAML niet lezen: {relative}: {exc}") from exc def validate_required_files() -> None: missing = sorted(path for path in REQUIRED_DOCS if not (ROOT / path).is_file()) if missing: raise ValidationError(f"Verplichte repositorybestanden ontbreken: {missing}") def validate_yaml_contracts() -> None: for relative in ( "docker-compose.yml", "docker-compose.unraid.yml", "config-data/profile.example.yaml", "config-data/seed_sources.yaml", "config-data/source_policy.yaml", "docs/ai/BACKLOG.yaml", "docs/api/openapi.yaml", ): payload = parse_yaml(relative) if payload is None: raise ValidationError(f"YAML is leeg: {relative}") openapi = parse_yaml("docs/api/openapi.yaml") if not isinstance(openapi, dict) or not str(openapi.get("openapi", "")).startswith("3.1"): raise ValidationError("OpenAPI-contract moet versie 3.1 gebruiken") paths = openapi.get("paths") or {} for health_path in ("/health/live/", "/health/ready/"): if health_path not in paths: raise ValidationError(f"OpenAPI mist {health_path}") for relative in ("docker-compose.yml", "docker-compose.unraid.yml"): compose = parse_yaml(relative) if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict): raise ValidationError(f"Composebestand mist services: {relative}") required_services = ( ("app",) if relative == "docker-compose.unraid.yml" else ("web", "worker", "scheduler", "postgres", "redis") ) for service in required_services: if service not in compose["services"]: raise ValidationError(f"{relative} mist service {service}") def validate_env_example() -> None: path = ROOT / ".env.example" keys: list[str] = [] for line in path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in stripped: continue key = stripped.split("=", 1)[0].strip() keys.append(key) duplicates = sorted({key for key in keys if keys.count(key) > 1}) if duplicates: raise ValidationError(f"Dubbele keys in .env.example: {duplicates}") missing = sorted(REQUIRED_ENV_KEYS - set(keys)) if missing: raise ValidationError(f".env.example mist keys: {missing}") def validate_markdown_links() -> None: failures: list[str] = [] markdown_files = [ path for path in ROOT.rglob("*.md") if not any(part in {".venv", ".pytest_cache", ".ruff_cache"} for part in path.parts) ] for path in markdown_files: text = path.read_text(encoding="utf-8") for match in MARKDOWN_LINK.finditer(text): target = match.group(1).strip().strip("<>") if not target or target.startswith(("#", "http://", "https://", "mailto:")): continue target = target.split("#", 1)[0].split("?", 1)[0] if not target: continue resolved = (path.parent / unquote(target)).resolve() try: resolved.relative_to(ROOT.resolve()) except ValueError: failures.append(f"{path.relative_to(ROOT)} -> buiten repository: {target}") continue if not resolved.exists(): failures.append(f"{path.relative_to(ROOT)} -> ontbreekt: {target}") if failures: raise ValidationError("Ongeldige lokale Markdownlinks:\n- " + "\n- ".join(failures)) def validate_skill_frontmatter() -> None: path = ROOT / ".agents/skills/vacatureradar-maintainer/SKILL.md" text = path.read_text(encoding="utf-8") if not text.startswith("---\n"): raise ValidationError("SKILL.md mist YAML-frontmatter") _, frontmatter, _ = text.split("---", 2) payload = yaml.safe_load(frontmatter) if not isinstance(payload, dict): raise ValidationError("SKILL.md-frontmatter is ongeldig") if payload.get("name") != "vacatureradar-maintainer": raise ValidationError("SKILL.md heeft onverwachte name") description = payload.get("description") if not isinstance(description, str) or len(description.strip()) < 40: raise ValidationError("SKILL.md description is te kort") def validate_no_runtime_secrets() -> None: forbidden_files = [ROOT / ".env", ROOT / "local/db.sqlite3"] present = [str(path.relative_to(ROOT)) for path in forbidden_files if path.exists()] # Een lokale ontwikkelcheckout mag deze bestanden hebben; de packagegate verwijdert ze. # Alleen een waarschuwing zodat codex_bootstrap bruikbaar blijft. if present: print(f"Waarschuwing: lokale runtimebestanden aanwezig en uitgesloten van ZIP: {present}") def main() -> int: try: validate_required_files() validate_yaml_contracts() validate_env_example() validate_markdown_links() validate_skill_frontmatter() validate_no_runtime_secrets() except ValidationError as exc: print(f"Repositoryvalidatiefout: {exc}", file=sys.stderr) return 1 print("Repositorydocumentatie en configuratie geldig") return 0 if __name__ == "__main__": raise SystemExit(main())