open-standards/scripts/check-wai-core-freeze.py
Transaction Science 6d30693eb2
Some checks failed
WAI operation catalogue / does this change touch it (push) Successful in 19s
wai-fleet service / does this change touch it (push) Successful in 14s
wai-key-proxy / does this change touch it (push) Successful in 13s
wai-ledger service / does this change touch it (push) Successful in 15s
wai-quantum for JavaScript / does this change touch it (push) Successful in 15s
wai-quantum studio and desktop / does this change touch it (push) Successful in 12s
wai-sink-android native sink library / does this change touch it (push) Successful in 18s
wai-sink-swift native sink package / does this change touch it (push) Successful in 14s
WAI Studio / does this change touch it (push) Successful in 13s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 3m45s
wai-app and Create / wai-app · does this change touch it (push) Successful in 3m15s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 3m18s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 3m26s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 3m28s
wai-audit collector and player integration / does this change touch it (push) Successful in 3m15s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 11s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 7s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 5m57s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 6m47s
wai-key-proxy / proxy · linux (push) Successful in 1m41s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 7m55s
wai-fleet service / service · linux (push) Successful in 3m55s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 50s
wai-fleet service / service · macos (push) Successful in 39s
wai-ledger service / service · linux (push) Successful in 3m24s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m50s
wai-quantum for JavaScript / javascript · linux (push) Successful in 4m55s
wai-ledger service / service · macos (push) Successful in 56s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 9m1s
wai-quantum studio and desktop / studio · browser (push) Successful in 5m40s
wai-audit collector and player integration / collector · linux (push) Successful in 2m25s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m42s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 12m34s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 9m12s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 5m34s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 7m6s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 8m6s
WAI Studio / Studio in WebKit · linux (push) Successful in 16m11s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 13m11s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 9m47s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 10m54s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 16m53s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 49s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 2m6s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 8m3s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 9m22s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 20m47s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 6m19s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 22m54s
WAI core 1.0 candidate: the core named and pinned, its freeze checked, profiles versioned apart
Chips, players and services cannot build on a specification that changes
several times a day. The core, the part meant to stop moving, is now named,
pinned and held: the envelope (WAI1, WAI2), the manifest, capability
dispatch, the model pin, receipts and the determinism tiers, with the
floor capabilities, record formats and conformance suites they need.
Everything else is a profile with its own version.

- wai/CORE.md names the core: spans of SPEC.md and of five extensions between
  <!-- wai-core:begin/end <id> --> markers (determinism-tiers §2,
  prior-carriage §2, jwp-receipts' delivery receipts, claim envelope, binary
  form, verifier, relay rules and key discovery, energy-measurement §2.7,
  conformance-results); the mandatory floor, video floor, recommended set and
  wai.prior.set; the envelopes' magics, the key log and module records and
  conform-result/1; and the suites that hold them. Each core-to-profile
  reference was read and is informative or binds only an implementation of
  that profile; what still resists the split is listed there.
- wai/core/lock.json pins each item by SHA-256; wai/core/profiles.json gives
  each of thirteen profiles its version (0.1), its extensions and the core
  items it builds on; wai/CORE-ERRATA.md is the errata list.
- scripts/check-wai-core-freeze.py fails a change to a core item unless it is
  an erratum row with the item's new digest or the core version is raised and
  the lock rewritten; against the commit a change starts from, it also fails
  a lock rewritten without a bump and an erratum edited. It runs in the
  repository checks after its self-test, which plants each fault and each
  change that must pass.
- SPEC.md, wai/README.md, VERSIONING.md, CONTRIBUTING.md and the repository's
  README say the core is a 1.0 candidate and profiles are versioned
  separately. Not a release: 1.0 is declared by the maintainers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 13:42:23 -05:00

403 lines
17 KiB
Python
Executable file

#!/usr/bin/env python3
"""check-wai-core-freeze.py: WAI's core does not change except by an erratum or a version bump.
check-wai-core-freeze.py [--root <repo>] [--against <git-ref>]
check-wai-core-freeze.py [--root <repo>] --item <id> the digest of one item, now
check-wai-core-freeze.py [--root <repo>] --write-lock rewrite the lock's digests
wai/CORE.md names what is core; wai/core/lock.json pins it, item by item, each with the
SHA-256 it had when the core version was set:
unit a span of the specification's text between the markers
`<!-- wai-core:begin <id> -->` and `<!-- wai-core:end <id> -->`, in wai/SPEC.md
or an extension (`file`); its digest is over the lines between them, leading
and trailing blank lines left out, joined by line feeds, as UTF-8
cap:<name> a core capability's entry in wai/registry/capabilities.json: its kind,
status, media, role, classes, sets, section, edition and date, as canonical
JSON (keys sorted, no insignificant whitespace, UTF-8)
wire:<name> a core wire identifier's entries there, the same way
corpus:<p> a core conformance corpus's data (a file or a directory under wai/): the
listing of its files in SPEC §3.1's `wai.prior.set` form, leaving out
README.md, *.py, .gitattributes and __pycache__ (its prose and its verifiers)
A run fails when an item's digest is not what the lock gives it, or, when the item has
errata, what the last of them gives it (wai/CORE-ERRATA.md: rows of the lock's core version
alone). So a change to a core item passes only
- as an erratum: a clarification that changes no meaning, recorded as a new row of
CORE-ERRATA.md naming the item and its new digest (`--item <id>` prints it); or
- with the core version bumped in lock.json and its digests rewritten (`--write-lock`).
It also fails when a marker is unpaired, nested, repeated or names a unit the lock does not
hold (adding to the core is a version bump), when a lock item is gone, when an erratum names
no item of the lock or is malformed, when wai/CORE.md does not state the lock's version and
status, and when an extension of wai/extensions/ is in no profile of wai/core/profiles.json,
or in two (a profile is free to change under its own version; every extension has one).
With --against <git-ref> (CI: a pull request's base, or the commit a push to main came
after) it also reads the history: the lock's items and digests at the ref and now are the
same unless the core version rose, and the errata at the ref are the first rows of the
errata now (they are appended, never edited). A `**Breaking:**` line in the changelog does
not acknowledge a change to the core: `wai breaking` reads those, this check does not.
Exit 0 when the core is as pinned, 1 when it is not, 2 on a wrong command line.
Standard library only; runs on Python 3.9.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
import sys
LOCK = "wai/core/lock.json"
PROFILES = "wai/core/profiles.json"
ERRATA = "wai/CORE-ERRATA.md"
CORE_MD = "wai/CORE.md"
REGISTRY = "wai/registry/capabilities.json"
EXTENSIONS = "wai/extensions"
MARKER = re.compile(r"^<!-- wai-core:(begin|end) ([A-Za-z0-9._:-]+) -->$")
ROW = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*`([^`]*)`\s*\|\s*([^|]*)\|\s*(.*?)\s*\|\s*$")
HEX64 = re.compile(r"^[0-9a-f]{64}$")
SKIP_IN_CORPUS = ("README.md", ".gitattributes")
class Bad(Exception):
"""Something the check needs and cannot read: it fails, saying what."""
def sha(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def canon(v) -> bytes:
return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def read(root, rel):
try:
with open(os.path.join(root, rel), encoding="utf-8") as f:
return f.read()
except OSError as e:
raise Bad(f"{rel}: {e.strerror}")
def load_json(root, rel):
try:
return json.loads(read(root, rel))
except ValueError as e:
raise Bad(f"{rel}: not JSON ({e})")
# ---------------------------------------------------------------- the items, as they are now
def spans(text, rel):
"""{unit id: digest} of every marked span of a file, and the marker faults found."""
out, faults, open_id, buf = {}, [], None, []
for n, line in enumerate(text.split("\n"), 1):
m = MARKER.match(line.strip())
if not m:
if open_id is not None:
buf.append(line)
elif "wai-core:" in line:
faults.append(f"{rel}:{n}: a line that names wai-core is not a marker: {line.strip()[:80]}")
continue
kind, uid = m.groups()
if kind == "begin":
if open_id is not None:
faults.append(f"{rel}:{n}: unit {uid} begins inside unit {open_id}")
continue
if uid in out:
faults.append(f"{rel}:{n}: unit {uid} is marked twice")
open_id, buf = uid, []
else:
if open_id != uid:
faults.append(f"{rel}:{n}: end of {uid} where {open_id or 'no unit'} is open")
continue
while buf and not buf[0].strip():
buf.pop(0)
while buf and not buf[-1].strip():
buf.pop()
out[uid] = sha("\n".join(buf).encode("utf-8"))
open_id = None
if open_id is not None:
faults.append(f"{rel}: unit {open_id} has no end marker")
return out, faults
def marked_files(root):
files = ["wai/SPEC.md"]
d = os.path.join(root, EXTENSIONS)
if os.path.isdir(d):
files += [f"{EXTENSIONS}/{n}" for n in sorted(os.listdir(d)) if n.endswith(".md")]
return files
def listing(root, rel):
"""SPEC §3.1's listing of a corpus's data files (a file, or every file below a directory)."""
p = os.path.join(root, "wai", rel)
if os.path.isfile(p):
files = [(os.path.basename(rel), p)]
elif os.path.isdir(p):
files = []
for d, dirs, names in os.walk(p):
dirs[:] = sorted(x for x in dirs if x != "__pycache__")
for n in names:
if n in SKIP_IN_CORPUS or n.endswith((".py", ".pyc")):
continue
full = os.path.join(d, n)
files.append((os.path.relpath(full, p).replace(os.sep, "/"), full))
else:
return None
lines = []
for path, full in sorted(files, key=lambda x: x[0].encode("utf-8")):
with open(full, "rb") as f:
lines.append(f"{sha(f.read())} {path}\n")
return "".join(lines).encode("utf-8")
def current(root, lock):
"""{item id: digest now, or None where the item is gone}, and the marker faults."""
now, faults, found = {}, [], {}
for rel in marked_files(root):
got, fl = spans(read(root, rel), rel)
faults += fl
for uid, d in got.items():
if uid in found:
faults.append(f"unit {uid} is marked in {found[uid][0]} and in {rel}")
found[uid] = (rel, d)
lock_units = {u["id"]: u for u in lock.get("units", [])}
for uid, u in lock_units.items():
at = found.get(uid)
if at is not None and at[0] != f"wai/{u['file']}":
faults.append(f"unit {uid} is marked in {at[0]}, and the lock places it in wai/{u['file']}")
now[uid] = at[1] if at else None
for uid, (rel, _) in found.items():
if uid not in lock_units:
faults.append(f"{rel}: unit {uid} is marked and the lock holds no such unit: adding to the core is a "
f"version bump (a marker of a profile's text does not belong here)")
reg = load_json(root, REGISTRY)
caps = {c.get("capability"): c for c in reg.get("capabilities", [])}
for c in lock.get("capabilities", []):
e = caps.get(c["capability"])
now["cap:" + c["capability"]] = sha(canon(e)) if e is not None else None
for w in lock.get("wire_identifiers", []):
es = sorted((x for x in reg.get("wire_identifiers", []) if x.get("name") == w["name"]), key=canon)
now["wire:" + w["name"]] = sha(canon(es)) if es else None
for c in lock.get("corpora", []):
l = listing(root, c["path"])
now["corpus:" + c["path"]] = sha(l) if l else None
return now, faults
def pinned(lock):
"""{item id: digest} the lock gives."""
out = {u["id"]: u["sha256"] for u in lock.get("units", [])}
out.update({"cap:" + c["capability"]: c["sha256"] for c in lock.get("capabilities", [])})
out.update({"wire:" + w["name"]: w["sha256"] for w in lock.get("wire_identifiers", [])})
out.update({"corpus:" + c["path"]: c["sha256"] for c in lock.get("corpora", [])})
return out
# ---------------------------------------------------------------- errata
def errata(text):
"""The rows of CORE-ERRATA.md's table, in order: (erratum, item, sha256, date, what)."""
rows = []
for n, line in enumerate(text.split("\n"), 1):
if not line.startswith("| `"):
continue
m = ROW.match(line)
if not m:
raise Bad(f"{ERRATA}:{n}: a row that is not `| \\`<erratum>\\` | \\`<item>\\` | \\`<sha256>\\` | <date> | <what> |`")
rows.append(tuple(x.strip() for x in m.groups()))
return rows
def version_key(v):
try:
return tuple(int(x) for x in v.split("."))
except ValueError:
raise Bad(f"core version {v!r} is not dotted integers")
# ---------------------------------------------------------------- the checks
def check_tree(root):
"""Findings about the tree alone, and the lock and errata read."""
findings = []
lock = load_json(root, LOCK)
version, status = lock.get("core"), lock.get("status")
if not isinstance(version, str) or not isinstance(status, str):
raise Bad(f"{LOCK}: no core version or status")
version_key(version)
rows = errata(read(root, ERRATA))
want = pinned(lock)
now, faults = current(root, lock)
findings += faults
seen = set()
expect = dict(want)
for e, item, d, date, what in rows:
if e in seen:
findings.append(f"{ERRATA}: erratum {e} is listed twice")
seen.add(e)
if not e.startswith(version + "-E"):
continue
if item not in want:
findings.append(f"{ERRATA}: erratum {e} names {item}, which the lock of core {version} does not hold")
continue
if not HEX64.match(d):
findings.append(f"{ERRATA}: erratum {e}'s digest is not 64 lowercase hexadecimal digits")
continue
if not what:
findings.append(f"{ERRATA}: erratum {e} says nothing of what it clarifies")
expect[item] = d
for item in sorted(want):
got = now.get(item)
if got is None:
findings.append(f"core item {item} is gone. Removing from the core changes its meaning: bump the core "
f"version in {LOCK} and rewrite its digests (--write-lock), or restore it")
elif got != expect[item]:
findings.append(f"core item {item} changed (its digest is now {got}). A clarification that changes no "
f"meaning: add a row to {ERRATA} naming `{item}` with that digest. A change of meaning: "
f"bump the core version in {LOCK} and rewrite its digests (--write-lock)")
md = read(root, CORE_MD)
if f"core {version} {status}" not in md:
findings.append(f"{CORE_MD} does not state `core {version} {status}`, the lock's version and status")
prof = load_json(root, PROFILES)
exts = sorted(n[:-3] for n in os.listdir(os.path.join(root, EXTENSIONS)) if n.endswith(".md"))
owner = {}
for p in prof.get("profiles", []):
for x in p.get("extensions", []):
owner.setdefault(x, []).append(p["id"])
for x in prof.get("wholly_core", []):
owner.setdefault(x, []).append("the core")
for x in exts:
if x not in owner:
findings.append(f"extensions/{x}.md is in no profile: name it in {PROFILES} (a profile changes freely)")
elif len(owner[x]) > 1:
findings.append(f"extensions/{x}.md is in {', '.join(owner[x])}: an extension is in one profile")
for x in owner:
if x not in exts:
findings.append(f"{PROFILES} names extensions/{x}.md, which does not exist")
for p in prof.get("profiles", []):
if not isinstance(p.get("version"), str) or not p.get("id"):
findings.append(f"{PROFILES}: a profile without an id or a version: {p.get('id')}")
for b in p.get("builds_on", []):
if b not in want:
findings.append(f"{PROFILES}: profile {p.get('id')} builds on {b}, which is not a core item")
return findings, lock, rows
def git_show(root, ref, rel):
r = subprocess.run(["git", "-C", root, "show", f"{ref}:{rel}"], capture_output=True)
if r.returncode == 0:
return r.stdout.decode("utf-8")
v = subprocess.run(["git", "-C", root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], capture_output=True)
if v.returncode != 0:
raise Bad(f"{ref} is not a commit of this clone")
return None
def check_history(root, ref, lock, rows):
findings, notes = [], []
old_text = git_show(root, ref, LOCK)
if old_text is None:
notes.append(f"{ref} holds no {LOCK}: the freeze starts with this change")
return findings, notes
old = json.loads(old_text)
if old["core"] == lock["core"]:
if pinned(old) != pinned(lock):
changed = sorted(k for k in set(pinned(old)) | set(pinned(lock)) if pinned(old).get(k) != pinned(lock).get(k))
findings.append(f"{LOCK} changed its items or digests ({', '.join(changed[:6])}{' …' if len(changed) > 6 else ''}) "
f"and kept core {lock['core']}: the lock is rewritten only with a version bump; a "
f"clarification is an erratum")
elif version_key(lock["core"]) < version_key(old["core"]):
findings.append(f"{LOCK}: core {lock['core']} is below core {old['core']} at {ref}")
else:
notes.append(f"core {old['core']} → {lock['core']}: a version bump, the lock rewritten")
old_rows_text = git_show(root, ref, ERRATA)
old_rows = errata(old_rows_text) if old_rows_text is not None else []
if rows[:len(old_rows)] != old_rows:
findings.append(f"{ERRATA}: a row {ref} holds was edited or removed; errata are appended")
return findings, notes
def write_lock(root):
lock = load_json(root, LOCK)
now, faults = current(root, lock)
if faults:
raise Bad("; ".join(faults))
for u in lock["units"]:
u["sha256"] = now[u["id"]]
for c in lock["capabilities"]:
c["sha256"] = now["cap:" + c["capability"]]
for w in lock["wire_identifiers"]:
w["sha256"] = now["wire:" + w["name"]]
for c in lock["corpora"]:
c["sha256"] = now["corpus:" + c["path"]]
gone = [k for k, v in now.items() if v is None]
if gone:
raise Bad(f"items the lock names and the tree lacks: {', '.join(gone)}")
with open(os.path.join(root, LOCK), "w", encoding="utf-8") as f:
json.dump(lock, f, indent=1, ensure_ascii=False)
f.write("\n")
print(f"wrote {LOCK}: core {lock['core']} {lock['status']}, {len(now)} items")
def main(argv):
args = list(argv)
root, against, item, write = ".", None, None, False
while args:
a = args.pop(0)
if a in ("--root", "--against", "--item") and args:
v = args.pop(0)
if a == "--root":
root = v
elif a == "--against":
against = v
else:
item = v
elif a == "--write-lock":
write = True
else:
print(__doc__.split("\n\n")[1], file=sys.stderr)
return 2
try:
if write:
write_lock(root)
return 0
if item is not None:
now, _ = current(root, load_json(root, LOCK))
if item not in now or now[item] is None:
print(f"no item {item} in the lock, or it is gone", file=sys.stderr)
return 1
print(now[item])
return 0
findings, lock, rows = check_tree(root)
notes = []
if against:
f2, notes = check_history(root, against, lock, rows)
findings += f2
except Bad as e:
print(f"check-wai-core-freeze: FAIL: {e}")
return 1
for n in notes:
print(f"note: {n}")
for f in findings:
print(f"FAIL: {f}")
if findings:
print(f"check-wai-core-freeze: {len(findings)} finding(s) against core {lock['core']} {lock['status']}")
return 1
print(f"check-wai-core-freeze: core {lock['core']} {lock['status']} is as pinned: "
f"{len(pinned(lock))} items, {sum(1 for r in rows if r[0].startswith(lock['core'] + '-E'))} errata")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))