Files
VacatureRadar/scripts/validate_repository.py
T
Jens 8648b4f4d1
deploy / deploy (push) Canceled after 0s
deploy: consolidate Unraid stack in Dockerman
2026-07-25 23:17:01 +02:00

183 lines
6.4 KiB
Python

#!/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"(?<!!)\[[^\]]+\]\(([^)]+)\)")
REQUIRED_ENV_KEYS = {
"DJANGO_SECRET_KEY",
"DJANGO_DEBUG",
"DJANGO_ALLOWED_HOSTS",
"DATABASE_URL",
"POSTGRES_PASSWORD",
"REDIS_URL",
"SOURCE_POLICY_MODE",
"MAILBOX_CREDENTIAL_KEYS",
"IMAP_CONNECT_TIMEOUT_SECONDS",
"IMAP_MAX_MESSAGES_PER_POLL",
"IMAP_MAX_MESSAGE_BYTES",
"OLLAMA_ENABLED",
}
REQUIRED_DOCS = {
"README.md",
"AGENTS.md",
"CODEX_START_HERE.md",
"docs/ai/BACKLOG.yaml",
"docs/ai/PROJECT_STATE.md",
"docs/quality/DEFINITION_OF_DONE.md",
"docs/quality/THREAT_MODEL.md",
"docs/quality/TRACEABILITY_MATRIX.md",
"docs/operations/UNRAID_DEPLOYMENT.md",
"docs/api/openapi.yaml",
".agents/skills/vacatureradar-maintainer/SKILL.md",
}
class ValidationError(ValueError):
pass
def parse_yaml(relative: str) -> 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())