from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
sys.dont_write_bytecode = True
from validate_protocol_docs import ( check_against_schema,
sourced_documented_events,
)
ROOT = Path(__file__).resolve().parents[1]
SCHEMA = ROOT / "spec" / "protocol-v1.schema.json"
FIXTURES = ROOT / "spec" / "fixtures"
KINDS = frozenset({"result", "error", "progress", "log"})
PROFILES = (
("base", {}, False),
("strict", {"$ref": "#/$defs/strictEvent"}, True),
)
def collect(node, where: str, found: list[tuple[str, dict]]) -> None:
if isinstance(node, dict):
if node.get("kind") in KINDS:
found.append((where, node))
for key, child in node.items():
collect(child, f"{where}.{key}", found)
elif isinstance(node, list):
for index, child in enumerate(node):
collect(child, f"{where}[{index}]", found)
def fixture_events() -> list[tuple[str, dict]]:
found: list[tuple[str, dict]] = []
for path in sorted(FIXTURES.glob("*.json")):
document = json.loads(path.read_text(encoding="utf-8"))
collect(document, path.relative_to(ROOT).as_posix(), found)
return found
def implementation_verdicts(events: list[dict], strict: bool) -> list[str | None]:
with tempfile.TemporaryDirectory() as directory:
corpus = Path(directory) / "events.json"
corpus.write_text(json.dumps(events), encoding="utf-8")
command = [
"cargo",
"run",
"--quiet",
"--bin",
"afdata",
"--",
"validate",
str(corpus),
"--per-event",
"--output-to",
"stdout",
]
if strict:
command.append("--strict")
completed = subprocess.run(
command, cwd=ROOT, capture_output=True, text=True, encoding="utf-8"
)
try:
event = json.loads(completed.stdout)
except json.JSONDecodeError:
raise SystemExit(
f"afdata validate produced no event (exit {completed.returncode}):\n"
f"{completed.stderr.strip()}"
) from None
payload = event.get("error") if event.get("kind") == "error" else event.get("result")
verdicts: list[str | None] = [None] * len(events)
for finding in (payload or {}).get("findings", []):
if finding.get("severity") != "error":
continue
index = int(finding["pointer"].split("/")[1])
if verdicts[index] is None:
verdicts[index] = f"{finding['rule_id']}: {finding['message']}"
return verdicts
def main() -> int:
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
fixtures = fixture_events()
documented = sourced_documented_events()
corpus = fixtures + documented
if not corpus:
print("no protocol events found to validate", file=sys.stderr)
return 1
failures: list[str] = []
rejections = 0
for profile, selector, strict in PROFILES:
verdicts = implementation_verdicts([event for _, event in corpus], strict)
rejections += sum(verdict is not None for verdict in verdicts)
for (where, event), verdict in zip(corpus, verdicts):
problems = check_against_schema(event, selector or schema, schema, "event")
body = json.dumps(event)
if problems and verdict is None:
failures.append(
f"{where}: the {profile} profile accepts this event, "
f"protocol-v1.schema.json rejects it\n {body}\n"
+ "\n".join(f" {problem}" for problem in problems)
)
elif not problems and verdict is not None:
failures.append(
f"{where}: the {profile} profile rejects this event "
f"({verdict}), protocol-v1.schema.json accepts it\n {body}"
)
kinds = {event["kind"] for _, event in corpus}
if kinds != KINDS:
failures.append(
f"the corpus covers {sorted(kinds)}; every event kind must be covered "
f"or the schema's dispatch is untested for {sorted(KINDS - kinds)}"
)
if not rejections:
failures.append(
"no event in the corpus is rejected by afdata validate, so this gate "
"proves only that the schema accepts valid events"
)
if failures:
print("protocol-v1.schema.json disagrees with the implementation:\n", file=sys.stderr)
print("\n\n".join(f" - {failure}" for failure in failures), file=sys.stderr)
return 1
print(
f"protocol-v1 schema ok: {len(corpus)} events "
f"({len(fixtures)} fixture, {len(documented)} documented) "
f"agree with afdata validate in both profiles"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())