138 lines
5.6 KiB
Python
138 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate DoGaMa specification schemas, examples and internal links."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from jsonschema import Draft202012Validator
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def load_json(path: Path):
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def load_yaml(path: Path):
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
return yaml.safe_load(handle)
|
|
|
|
|
|
def validate(schema_path: Path, document_path: Path) -> dict:
|
|
schema = load_json(schema_path)
|
|
Draft202012Validator.check_schema(schema)
|
|
document = load_yaml(document_path)
|
|
errors = sorted(Draft202012Validator(schema).iter_errors(document), key=lambda item: list(item.path))
|
|
if errors:
|
|
rendered = "\n".join(f" {document_path}:{'/'.join(map(str, error.path))}: {error.message}" for error in errors)
|
|
raise ValueError(f"Schema validation failed:\n{rendered}")
|
|
return document
|
|
|
|
|
|
def validate_links() -> None:
|
|
pattern = re.compile(r"\[[^]]+\]\(([^)]+)\)")
|
|
failures = []
|
|
for markdown in ROOT.rglob("*.md"):
|
|
text = markdown.read_text(encoding="utf-8")
|
|
for target in pattern.findall(text):
|
|
target = target.split("#", 1)[0].strip()
|
|
if not target or target.startswith(("http://", "https://", "mailto:")):
|
|
continue
|
|
resolved = (markdown.parent / target).resolve()
|
|
if not resolved.exists():
|
|
failures.append(f"{markdown.relative_to(ROOT)} -> {target}")
|
|
if failures:
|
|
raise ValueError("Broken internal links:\n " + "\n ".join(failures))
|
|
|
|
|
|
def validate_cross_references(template: dict, manifest: dict) -> list[str]:
|
|
warnings = []
|
|
port_ids = {item["id"] for item in template["container"]["ports"]}
|
|
mount_ids = {item["id"] for item in template["storage"]["mounts"]}
|
|
capabilities = set(template["capabilities"])
|
|
|
|
integration = template.get("integration")
|
|
if integration:
|
|
assert integration["port_id"] in port_ids, "Template integration references an unknown port"
|
|
assert integration["module_id"] == manifest["id"], "Template and manifest module IDs disagree"
|
|
assert template["game"]["id"] in manifest["game_ids"], "Manifest does not support template game ID"
|
|
assert set(manifest["permissions"]["network"]["port_ids"]) <= port_ids, "Manifest references an unknown template port"
|
|
assert set(manifest["capabilities"]) == capabilities, "Template and reference manifest capabilities disagree"
|
|
|
|
assert set(template["backup"]["source_mounts"]) <= mount_ids, "Backup references an unknown mount"
|
|
assert template["imports"]["destination_mount"] in mount_ids, "Import references an unknown mount"
|
|
mods = template.get("mods", {})
|
|
if mods.get("supported"):
|
|
assert mods.get("destination_mount") in mount_ids, "Mods reference an unknown mount"
|
|
|
|
for asset in template["container"].get("assets", []):
|
|
path = (ROOT / "catalog" / "palworld" / asset["source"]).resolve()
|
|
assert path.is_file(), f"Missing packaged asset: {path}"
|
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
assert digest == asset["sha256"], f"Asset checksum mismatch: {path}"
|
|
|
|
wasm_digest = manifest["artifacts"]["sha256"]
|
|
wasm_path = ROOT / "modules" / manifest["id"] / manifest["artifacts"]["wasm"]
|
|
if wasm_digest == "0" * 64 and not wasm_path.exists():
|
|
warnings.append("Palworld module is a source specification: module.wasm and its final checksum are intentionally pending.")
|
|
elif wasm_path.is_file():
|
|
assert hashlib.sha256(wasm_path.read_bytes()).hexdigest() == wasm_digest, "WASM checksum mismatch"
|
|
else:
|
|
raise AssertionError("Manifest names a missing WASM artifact without the documented placeholder")
|
|
|
|
return warnings
|
|
|
|
|
|
def validate_coverage() -> None:
|
|
required = {
|
|
"Docker agent": "docs/architecture/docker-agent.md",
|
|
"WebAssembly": "docs/architecture/wasm-modules.md",
|
|
"threat model": "docs/security/security-and-threat-model.md",
|
|
"SQLite": "docs/domain/data-model.md",
|
|
"backup": "docs/operations/backups-import-export.md",
|
|
"Discord": "docs/operations/notifications-and-audit.md",
|
|
"30 days": "docs/operations/notifications-and-audit.md",
|
|
"manager": "docs/domain/authorization.md",
|
|
"Palworld": "catalog/palworld/README.md",
|
|
"acceptance": "docs/product/acceptance-criteria.md",
|
|
"roadmap": "docs/product/roadmap.md",
|
|
"Codex": "docs/contributing/ai-codex-guide.md",
|
|
}
|
|
missing = []
|
|
for needle, relative in required.items():
|
|
if needle.casefold() not in (ROOT / relative).read_text(encoding="utf-8").casefold():
|
|
missing.append(f"{needle!r} in {relative}")
|
|
if missing:
|
|
raise ValueError("Missing required coverage: " + ", ".join(missing))
|
|
|
|
|
|
def main() -> int:
|
|
template = validate(ROOT / "specs/template.schema.json", ROOT / "catalog/palworld/template.yaml")
|
|
manifest = validate(ROOT / "specs/module-manifest.schema.json", ROOT / "modules/palworld-rest/manifest.yaml")
|
|
load_yaml(ROOT / "compose.yaml")
|
|
for fixture in ROOT.rglob("*.json"):
|
|
load_json(fixture)
|
|
validate_links()
|
|
warnings = validate_cross_references(template, manifest)
|
|
validate_coverage()
|
|
print("DoGaMa specification validation passed.")
|
|
for warning in warnings:
|
|
print(f"WARNING: {warning}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as error:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|