Sleight §13.4 reconciled with the 8-point adapter surface: 14 event kinds (13 SoH 1:1 + lane_state), 6 live channels (gate cross-checks envelope.go), adapter points 2/5/7 bound (stable (channel, call_id) keys, seq stream position, all 8 §13.6 trace lines mapped). Envelope: event_id, parent_id/task_id causal chain, payload_hash, actor/device/ authorization. Kind payload minimums frozen via allOf. 23/23 red-first; adjacent legacy 26 green; boundary gate unregressed. Open-question register Q1.1 -> RESOLVED.
179 lines
7.3 KiB
Python
179 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""check_events_schema.py — spec 0.5 gate: ONE frozen typed-event vocabulary.
|
|
|
|
Mirror of the boundary codegen gate for the events namespace
|
|
(schemas/events is a SEPARATE namespace from schemas/boundary):
|
|
|
|
1. Schema law: event.schema.json exists at the events/v1 $id namespace
|
|
and the boundary registry stays free of event kinds (no leakage in
|
|
either direction).
|
|
2. Regeneration: schemas/events/v1/vocabulary.json byte-matches a fresh
|
|
extraction from the schema (a hand-edited generated file, or a schema
|
|
change without regeneration, trips RED).
|
|
3. Corpus: every schemas/events/corpus/*.json validates against the
|
|
schema (JSON Schema 2020-12 via jsonschema) and is byte-canonical
|
|
(sorted keys, compact separators, trailing newline). Every frozen
|
|
kind has at least one fixture.
|
|
4. Reconciliation law: the enum is exactly Sleight-of-Hand section
|
|
13.4's thirteen (mapped 1:1 via x-soh-13-4) plus lane_state; every
|
|
section 13.6 trace line maps into the enum; the six channels match
|
|
the live event-bus eeChannels table in
|
|
src/go/event-bus/internal/eventbus/envelope.go.
|
|
|
|
Exit 0 + "GREEN" summary, or exit 1 with the violation list.
|
|
|
|
Spec: docs/openspec/changes/add-native-rune-tui/tasks.md 0.5 (Q1.1).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMA = ROOT / "schemas" / "events" / "v1" / "event.schema.json"
|
|
VOCAB_GEN = ROOT / "scripts" / "gen_events_vocabulary.py"
|
|
CORPUS_DIR = ROOT / "schemas" / "events" / "corpus"
|
|
BOUNDARY_DIR = ROOT / "schemas" / "boundary" / "v1"
|
|
ENVELOPE_GO = (ROOT / "src" / "go" / "event-bus" / "internal" /
|
|
"eventbus" / "envelope.go")
|
|
|
|
CHANNELS = ["conversation", "work", "system", "tool", "background", "sensor"]
|
|
ADAPTER_ADDITIONS = ["lane_state"]
|
|
|
|
|
|
def canonical(obj) -> str:
|
|
return json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n"
|
|
|
|
|
|
def check_schema(violations: list[str]) -> dict | None:
|
|
if not SCHEMA.is_file():
|
|
violations.append(f"schema missing: {SCHEMA}")
|
|
return None
|
|
doc = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
|
if doc.get("$id") != "https://rune.dev/schemas/events/v1/event.schema.json":
|
|
violations.append(f"schema $id is not the events/v1 namespace: "
|
|
f"{doc.get('$id')!r}")
|
|
if "boundary" in doc.get("$id", ""):
|
|
violations.append("events schema must not live in the boundary "
|
|
"namespace")
|
|
if not BOUNDARY_DIR.is_dir():
|
|
violations.append("schemas/boundary/v1 missing (gate context)")
|
|
else:
|
|
for f in sorted(BOUNDARY_DIR.glob("*.schema.json")):
|
|
bid = json.loads(f.read_text(encoding="utf-8")).get("$id", "")
|
|
if "events" in bid:
|
|
violations.append(f"boundary schema {f.name} leaks the "
|
|
f"events namespace: {bid}")
|
|
return doc
|
|
|
|
|
|
def check_regen(violations: list[str]) -> None:
|
|
r = subprocess.run([sys.executable, str(VOCAB_GEN), "--check"],
|
|
capture_output=True, text=True, timeout=120)
|
|
if r.returncode != 0:
|
|
violations.append("vocabulary.json regeneration drifted: "
|
|
f"{r.stderr.strip()}")
|
|
|
|
|
|
def check_corpus(doc: dict, violations: list[str]) -> None:
|
|
try:
|
|
import jsonschema
|
|
except ImportError:
|
|
violations.append("jsonschema library unavailable — cannot "
|
|
"validate corpus")
|
|
return
|
|
if not CORPUS_DIR.is_dir():
|
|
violations.append("schemas/events/corpus missing")
|
|
return
|
|
validator = jsonschema.Draft202012Validator(doc)
|
|
kinds_seen: set[str] = set()
|
|
for f in sorted(CORPUS_DIR.glob("*.json")):
|
|
try:
|
|
obj = json.loads(f.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
violations.append(f"corpus {f.name}: not valid JSON ({exc})")
|
|
continue
|
|
try:
|
|
validator.validate(obj)
|
|
except jsonschema.ValidationError as exc:
|
|
violations.append(f"corpus {f.name}: schema violation: "
|
|
f"{exc.message}")
|
|
continue
|
|
raw = f.read_text(encoding="utf-8")
|
|
if raw != canonical(obj):
|
|
violations.append(f"corpus {f.name}: not canonical bytes "
|
|
"(sorted keys, compact, trailing newline)")
|
|
kinds_seen.add(obj.get("event", ""))
|
|
enum = set(doc["properties"]["event"]["enum"])
|
|
missing = sorted(enum - kinds_seen)
|
|
if missing:
|
|
violations.append(f"kinds without a corpus fixture: {missing}")
|
|
|
|
|
|
def check_reconciliation(doc: dict, violations: list[str]) -> None:
|
|
enum = doc["properties"]["event"]["enum"]
|
|
soh = doc.get("x-soh-13-4", {})
|
|
if len(soh) != 13:
|
|
violations.append(f"x-soh-13-4 must carry section 13.4's thirteen "
|
|
f"objects, got {len(soh)}")
|
|
wires = sorted(soh.values())
|
|
if len(wires) != len(set(wires)):
|
|
violations.append("x-soh-13-4 is not 1:1 (duplicate wire names)")
|
|
if sorted(enum) != sorted(list(soh.values()) + ADAPTER_ADDITIONS):
|
|
violations.append("event enum is not exactly section 13.4's "
|
|
f"thirteen + {ADAPTER_ADDITIONS}: {sorted(enum)}")
|
|
trace = doc.get("x-soh-13-6-trace", {})
|
|
if len(trace) != 8:
|
|
violations.append(f"x-soh-13-6-trace must map all eight illustrative "
|
|
f"trace lines, got {len(trace)}")
|
|
for wire in trace.values():
|
|
if wire not in enum:
|
|
violations.append(f"13.6 trace maps to unknown kind {wire!r}")
|
|
chans = doc["properties"]["channel"].get("enum", [])
|
|
if sorted(chans) != sorted(CHANNELS):
|
|
violations.append(f"channel enum drifted from the event-bus six: "
|
|
f"{sorted(chans)}")
|
|
else:
|
|
src = ENVELOPE_GO.read_text(encoding="utf-8") \
|
|
if ENVELOPE_GO.is_file() else ""
|
|
if not src:
|
|
violations.append("live event-bus envelope.go missing — cannot "
|
|
"cross-check channels")
|
|
for ch in CHANNELS:
|
|
# whitespace-tolerant: gofmt aligns map-literal values
|
|
if not re.search(rf'"{ch}":\s+ordKV\(', src):
|
|
violations.append(f"channel {ch} missing from the live "
|
|
f"event-bus eeChannels table")
|
|
points = sorted(doc.get("x-adapter-surface", {}).get("points", []))
|
|
if points != [2, 5, 7]:
|
|
violations.append(f"adapter-surface binding must be points 2/5/7, "
|
|
f"got {points}")
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
violations: list[str] = []
|
|
doc = check_schema(violations)
|
|
if doc is not None:
|
|
check_regen(violations)
|
|
check_corpus(doc, violations)
|
|
check_reconciliation(doc, violations)
|
|
if violations:
|
|
print("RED: events schema gate —")
|
|
for v in violations:
|
|
print(f" - {v}")
|
|
return 1
|
|
n_kinds = len(doc["properties"]["event"]["enum"])
|
|
n_fixtures = len(list(CORPUS_DIR.glob("*.json")))
|
|
print(f"GREEN: events schema gate — {n_kinds} frozen kinds, "
|
|
f"{n_fixtures} corpus fixtures, vocabulary regeneration-clean, "
|
|
f"13.4/13.6/event-bus reconciliation intact")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|