183 lines
6.4 KiB
Python
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())
|