Beemer
Deploy: the 2026-09-21 code audit and its three verification rounds -- the quantity and relief allowlists, the five Canadian outcome readings, the IRPR s. 17 clock, the retrieval fixes and the performance work (approved_code 82525e1; curated baseline c32bd27 unchanged)
901bf8c Download tools/predeploy.py from Beemer0/CanLex: direct link, hf CLI and curl.
- Browser
- Download file 44.1 kB
-
https://huggingface.co/spaces/Beemer0/CanLex/resolve/main/tools/predeploy.py
- Command line
-
hf download hf://spaces/Beemer0/CanLex/tools/predeploy.py
-
curl -L -o predeploy.py https://huggingface.co/spaces/Beemer0/CanLex/resolve/main/tools/predeploy.py
44.1 kB
| #!/usr/bin/env python3 | |
| """Pre-deploy gate for the CanLex MCP Space (roadmap T0.1). | |
| python tools/predeploy.py [--tree REV ...] [--skip-tests] [--allow-held] | |
| python tools/predeploy.py --print-review-state COMMIT | |
| Checks, each printed PASS / WARN / FAIL (exit status 1 on any FAIL): | |
| 1. tests the offline suite (`unittest discover -s tests`) in this working tree | |
| 2. curated refresh.check_curated on this working tree (dataset age, regen drift) | |
| 3. blobs no NON-LFS blob over 10 MiB in the deploy tree (Hugging Face rejects the | |
| push; RUNBOOK "Deploying to the Hugging Face Space") | |
| 4. commentary the deploy tree's commentary.json is exactly what canlex.commentary | |
| renders from that tree's own data/curated/*.json ... | |
| 5. embeddings ... and every commentary chunk has a current vector in its | |
| embeddings.npz (the index silently degrades a chunk without one to BM25) | |
| 6. review the deploy tree's curated legal JSON against the approved baseline in | |
| data/curated/review_state.json: prints `git diff --stat` and a per-record | |
| summary of what differs, and FAILS when held content would ship. | |
| --allow-held (or CANLEX_PREDEPLOY_ALLOW_HELD=1) downgrades that to a | |
| warning -- for a rollback to an older approved tree, never for shipping | |
| held content. | |
| 7. review-code the deploy tree's officer-facing legal text in code (the definitions | |
| canlex/reviewed_code.py registers, each with every canlex definition it | |
| reaches) against the 'approved_code' commit in review_state.json; FAILS | |
| when held code would ship (--allow-held downgrades it too). | |
| --tree defaults to HEAD, which on main FAILS the review check by design: main carries | |
| held content. Deploy the composed release (tools/compose_release.py) and check it with | |
| `--tree release-composed`; the pre-push hook (tools/hooks/pre-push) passes every commit | |
| being pushed to the 'space' remote. | |
| --print-review-state COMMIT prints the "approved" and "curated_files" blocks for | |
| data/curated/review_state.json when the maintainer approves a new baseline. | |
| """ | |
| import argparse | |
| import ast | |
| import contextlib | |
| import copy | |
| import datetime | |
| import hashlib | |
| import io | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| HF_LIMIT = 10 * 1024 * 1024 # Hugging Face's non-LFS file limit (10 MiB) | |
| CURATED_DIR = "data/curated" | |
| COMMENTARY = "data/processed/commentary.json" | |
| EMBEDDINGS = "data/processed/embeddings.npz" | |
| REVIEW_STATE = "data/curated/review_state.json" | |
| CODE_REGISTRY = "canlex/reviewed_code.py" # officer-facing legal text in code | |
| LEGAL_FILES = ("us_dispositions.json", "us_equivalency.json") # what the renderer reads | |
| # ... plus the authority registry the renderer's pointer lines are read from | |
| # (canlex.authorities.PATH): a render is only reproducible with the tree's own | |
| # copy (2026-09-21 audit: the check rendered with the WORKING tree's). | |
| RENDER_INPUTS = LEGAL_FILES + ("authorities.json",) | |
| # Review memos and the held batches' source drafts. Not curated JSON, never | |
| # read at runtime, but held with their batch: a composed release ships the | |
| # approved commit's subtree, and the review check holds any difference | |
| # (2026-09-21 audit: the next compose would have shipped the held batch-1 | |
| # memo and its 138 KB of source drafts to the public Space). | |
| DRAFTS_DIR = "data/curated/drafts" | |
| # Written by tools/compose_release.py into a composed tree only: the ids and | |
| # matching words of held content (canlex/server.py reads it). Generated from the | |
| # curated files, no content of its own -- so it is not a curated file here. | |
| HELD_MANIFEST = "held_manifest.json" | |
| PASS, WARN, FAIL = "PASS", "WARN", "FAIL" | |
| # ---- git helpers -------------------------------------------------------------- | |
| def git(*args, cwd=ROOT, check=True, stdin=None): | |
| out = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, input=stdin) | |
| if check and out.returncode != 0: | |
| raise RuntimeError(f"git {' '.join(args)}: " | |
| f"{out.stderr.decode('utf-8', 'replace').strip()}") | |
| return out.stdout | |
| def rev(ref, cwd=ROOT): | |
| return git("rev-parse", "--verify", f"{ref}^{{commit}}", cwd=cwd).decode().strip() | |
| def show_bytes(commit, path, cwd=ROOT): | |
| out = subprocess.run(["git", "show", f"{commit}:{path}"], cwd=str(cwd), | |
| capture_output=True) | |
| return out.stdout if out.returncode == 0 else None | |
| def show_json(commit, path, cwd=ROOT): | |
| blob = show_bytes(commit, path, cwd) | |
| return None if blob is None else json.loads(blob.decode("utf-8")) | |
| def subtree(commit, path, cwd=ROOT): | |
| """The tree hash of `path` at a commit, or None when the commit has no | |
| such directory.""" | |
| out = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{commit}:{path}"], | |
| cwd=str(cwd), capture_output=True) | |
| return out.stdout.decode().strip() if out.returncode == 0 else None | |
| def curated_names(commit, cwd=ROOT): | |
| """The *.json files directly in data/curated at a commit -- the curated | |
| content, so never the held manifest a composed tree carries (HELD_MANIFEST).""" | |
| listing = git("ls-tree", "--name-only", commit, CURATED_DIR + "/", cwd=cwd) | |
| return sorted(Path(p).name for p in listing.decode("utf-8").splitlines() | |
| if p.endswith(".json") and p.count("/") == 2 | |
| and Path(p).name != HELD_MANIFEST) | |
| def canonical_sha256(obj): | |
| blob = json.dumps(obj, sort_keys=True, ensure_ascii=False, | |
| separators=(",", ":")).encode("utf-8") | |
| return hashlib.sha256(blob).hexdigest() | |
| def review_state(cwd=ROOT): | |
| return json.loads((Path(cwd) / REVIEW_STATE).read_text(encoding="utf-8")) | |
| def lfs_object(pointer, cwd=ROOT): | |
| """The content an LFS pointer names, read from the LOCAL LFS store (never | |
| fetched), or None when it is not there. A non-pointer blob is returned | |
| as is.""" | |
| if not pointer.startswith(b"version https://git-lfs"): | |
| return pointer | |
| oid = next((l.split(b":", 1)[1].strip().decode() for l in pointer.splitlines() | |
| if l.startswith(b"oid sha256:")), None) | |
| if not oid: | |
| return None | |
| common = Path(git("rev-parse", "--git-common-dir", cwd=cwd).decode().strip()) | |
| if not common.is_absolute(): | |
| common = Path(cwd) / common | |
| path = common / "lfs" / "objects" / oid[:2] / oid[2:4] / oid | |
| return path.read_bytes() if path.exists() else None | |
| # ---- checks ------------------------------------------------------------------- | |
| def _python_env(): | |
| env = dict(os.environ, PYTHONIOENCODING="utf-8") | |
| return env | |
| def run_unittest(args, cwd=ROOT): | |
| """(status, lines) for `python -m unittest <args>` in cwd.""" | |
| out = subprocess.run([sys.executable, "-m", "unittest", *args], cwd=str(cwd), | |
| capture_output=True, env=_python_env()) | |
| text = (out.stderr + out.stdout).decode("utf-8", "replace") | |
| lines = [l for l in text.splitlines() if l.startswith(("FAIL:", "ERROR:", | |
| "UNEXPECTED SUCCESS:"))] | |
| tail = [l for l in text.splitlines() if l.startswith(("Ran ", "OK", "FAILED"))] | |
| return (PASS if out.returncode == 0 else FAIL), lines[:40] + tail | |
| def check_curated_working(): | |
| from canlex import refresh | |
| rows = refresh.check_curated(datetime.date.today().isoformat()) | |
| lines = [f"{r['file']}: {r['status']} {r.get('reviewed', '')} {r.get('error', '')}".rstrip() | |
| for r in rows] | |
| if any(r["status"] in ("drift", "error") for r in rows): | |
| return FAIL, lines | |
| if any(r["status"] == "aging" for r in rows): | |
| return WARN, lines | |
| return PASS, lines | |
| def check_blobs(commit, cwd=ROOT): | |
| listing = git("ls-tree", "-r", "-l", commit, cwd=cwd).decode("utf-8", "replace") | |
| big, near = [], [] | |
| for line in listing.splitlines(): | |
| meta, _, path = line.partition("\t") | |
| parts = meta.split() | |
| if len(parts) < 4 or parts[1] != "blob" or not parts[3].isdigit(): | |
| continue | |
| size = int(parts[3]) | |
| if size > HF_LIMIT: | |
| big.append(f"{size:,} bytes {path} -- track it in LFS (.gitattributes, exact path)") | |
| elif size > 0.9 * HF_LIMIT: | |
| near.append(f"{size:,} bytes {path} -- within 10% of the limit") | |
| if big: | |
| return FAIL, big + near | |
| return (WARN if near else PASS), near or ["no non-LFS blob over 10 MiB"] | |
| def _clear_authority_caches(au): | |
| """Every lru_cache in canlex.authorities (the registry, the corpus files | |
| it reads, the act sections, the statute sentences): a render with another | |
| registry must not see the working tree's through any of them.""" | |
| for value in vars(au).values(): | |
| if hasattr(value, "cache_clear"): | |
| value.cache_clear() | |
| def render_commentary(curated_blobs): | |
| """The chunks canlex.commentary renders from {file name: bytes}, written | |
| to a temporary directory (the repo's own renderer, pointed elsewhere). | |
| The authority registry is pointed at the tree's authorities.json too -- | |
| absent from the blobs, at a path that does not exist, so the render | |
| prints no pointers, as that tree would. Until 2026-09-21 the renderer | |
| read canlex.authorities.PATH, the WORKING tree's file, so the check | |
| passed or failed on a registry the deploy tree does not carry.""" | |
| from canlex import authorities as au, commentary as cm | |
| with tempfile.TemporaryDirectory() as tmp: | |
| tmp = Path(tmp) | |
| for name, blob in curated_blobs.items(): | |
| (tmp / name).write_bytes(blob) | |
| saved = cm.CURATED, cm.EQUIV, cm.OUT, au.PATH | |
| cm.CURATED = tmp / "us_dispositions.json" | |
| cm.EQUIV = tmp / "us_equivalency.json" | |
| cm.OUT = tmp / "commentary.json" | |
| au.PATH = tmp / "authorities.json" | |
| _clear_authority_caches(au) | |
| try: | |
| with contextlib.redirect_stdout(io.StringIO()): | |
| ok = cm.build(allow_shrink=True) | |
| finally: | |
| cm.CURATED, cm.EQUIV, cm.OUT, au.PATH = saved | |
| _clear_authority_caches(au) | |
| if not ok: | |
| raise RuntimeError("canlex.commentary refused to write") | |
| return json.loads((tmp / "commentary.json").read_text(encoding="utf-8")) | |
| def check_commentary(commit, cwd=ROOT): | |
| """(status, lines, stored chunks) -- the tree's commentary.json against a | |
| fresh render of the tree's own curated files (the authority registry | |
| included).""" | |
| blobs = {n: show_bytes(commit, f"{CURATED_DIR}/{n}", cwd) for n in RENDER_INPUTS} | |
| blobs = {n: b for n, b in blobs.items() if b is not None} | |
| stored = show_json(commit, COMMENTARY, cwd) | |
| if stored is None: | |
| return FAIL, [f"{COMMENTARY} missing from the tree"], [] | |
| if "us_dispositions.json" not in blobs: | |
| return WARN, ["no us_dispositions.json in the tree; render not checked"], stored | |
| rendered = render_commentary(blobs) | |
| if rendered == stored: | |
| return PASS, [f"{len(stored)} chunks, identical to a fresh render"], stored | |
| s_ids, r_ids = [c["id"] for c in stored], [c["id"] for c in rendered] | |
| lines = [f"stored {len(stored)} chunks, fresh render {len(rendered)}"] | |
| only_s, only_r = sorted(set(s_ids) - set(r_ids)), sorted(set(r_ids) - set(s_ids)) | |
| if only_s: | |
| lines.append(f"only in stored ({len(only_s)}): {', '.join(only_s[:8])}") | |
| if only_r: | |
| lines.append(f"only in render ({len(only_r)}): {', '.join(only_r[:8])}") | |
| by_id = {c["id"]: c for c in rendered} | |
| diff = [c["id"] for c in stored if c["id"] in by_id and by_id[c["id"]] != c] | |
| if diff: | |
| lines.append(f"text differs ({len(diff)}): {', '.join(diff[:8])}") | |
| lines.append("rerun canlex.commentary (then canlex.embed) on this tree") | |
| return FAIL, lines, stored | |
| def embeddings_gap(chunks, npz_path): | |
| """Chunk ids with no current vector in the npz (canlex.embed's own reuse | |
| rule: same id, same content hash, same window count).""" | |
| from canlex.embed import load_stored, plan | |
| _, to_embed = plan(chunks, load_stored(Path(npz_path))) | |
| return [cid for cid, _, _ in to_embed] | |
| def tree_chunks(commit, cwd=ROOT): | |
| """Every chunk of the tree's data/processed/*.json (LFS objects read | |
| from the local store), or None when one of them is not there. A file | |
| that is there but is not a JSON list raises ValueError naming it.""" | |
| listing = git("ls-tree", "--name-only", commit, "data/processed/", cwd=cwd) | |
| chunks = [] | |
| for path in sorted(p for p in listing.decode("utf-8").splitlines() | |
| if p.endswith(".json")): | |
| blob = show_bytes(commit, path, cwd) | |
| data = None if blob is None else lfs_object(blob, cwd) | |
| if data is None: | |
| return None | |
| try: | |
| parsed = json.loads(data.decode("utf-8")) | |
| except ValueError as exc: # JSONDecodeError, UnicodeDecodeError | |
| raise ValueError(f"{path}: {exc}") from exc | |
| if not isinstance(parsed, list): | |
| raise ValueError(f"{path}: a JSON {type(parsed).__name__}, not a list of chunks") | |
| chunks.extend(parsed) | |
| return chunks | |
| def embeddings_currency(commit, npz_path, cwd=ROOT): | |
| """(missing, stale) over the tree's WHOLE corpus (canlex.embed.currency), | |
| or None when the corpus cannot be read here (audit follow-up 2026-09-21, | |
| Missing 15: only the commentary chunks were checked). A corpus file that | |
| does not parse raises ValueError: until the 2026-09-22 verification | |
| (A-EMB-3) it was swallowed as 'not readable here' and the check PASSed, | |
| though the Space's index json.loads every processed file at start.""" | |
| try: | |
| chunks = tree_chunks(commit, cwd) | |
| except (RuntimeError, OSError): | |
| return None | |
| if chunks is None: | |
| return None | |
| from canlex.embed import currency, load_stored | |
| return currency(chunks, load_stored(Path(npz_path))) | |
| def check_embeddings(commit, chunks, cwd=ROOT): | |
| pointer = show_bytes(commit, EMBEDDINGS, cwd) | |
| if pointer is None: | |
| return FAIL, [f"{EMBEDDINGS} missing from the tree"] | |
| data = lfs_object(pointer, cwd) | |
| if data is None: | |
| return WARN, ["embeddings.npz is not in the local LFS store; coverage not checked"] | |
| try: | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "embeddings.npz" | |
| path.write_bytes(data) | |
| gap = embeddings_gap(chunks, path) | |
| try: | |
| current = embeddings_currency(commit, path, cwd) | |
| except ValueError as exc: # a corpus file that does not parse | |
| return FAIL, [f"the tree's corpus does not parse: {exc}", | |
| "the Space's index reads every data/processed file at " | |
| "start: regenerate or restore that file"] | |
| except ImportError as exc: | |
| return WARN, [f"canlex.embed not importable here ({exc}); coverage not checked"] | |
| if gap: | |
| return FAIL, [f"{len(gap)} commentary chunk(s) have no current vector: " | |
| + ", ".join(gap[:8]), "run canlex.embed on this tree"] | |
| lines = [f"all {len(chunks)} commentary chunks have current vectors"] | |
| if current is None: | |
| # unchecked is not a pass (verification 2026-09-22, A-EMB-3 / C-GATE-3): | |
| # the same WARN as an npz absent from the local LFS store | |
| return WARN, lines + ["whole corpus not readable here; its currency not checked"] | |
| missing, stale = current | |
| if missing or stale: | |
| return FAIL, [f"{missing} chunk(s) have no vector and {stale} a stale one " | |
| "(canlex.embed's plan)", "run canlex.embed on this tree"] | |
| return PASS, lines + ["whole corpus: 0 missing, 0 stale vectors"] | |
| def _by_id(items): | |
| return {x.get("id"): x for x in items or [] if isinstance(x, dict)} | |
| def _records(label, old, new): | |
| o, n = _by_id(old), _by_id(new) | |
| lines = [] | |
| added, removed = sorted(set(n) - set(o)), sorted(set(o) - set(n)) | |
| if added: | |
| lines.append(f" {label} added ({len(added)}): {', '.join(map(str, added))}") | |
| if removed: | |
| lines.append(f" {label} removed ({len(removed)}): {', '.join(map(str, removed))}") | |
| for k in sorted(set(o) & set(n), key=str): | |
| if o[k] == n[k]: | |
| continue | |
| fields = sorted(f for f in set(o[k]) | set(n[k]) if o[k].get(f) != n[k].get(f)) | |
| detail = [] | |
| for f in fields: | |
| if f == "state_variations": | |
| ov = {json.dumps(r, sort_keys=True) for r in o[k].get(f) or []} | |
| nv = {json.dumps(r, sort_keys=True) for r in n[k].get(f) or []} | |
| detail.append(f"state_variations (+{len(nv - ov)} -{len(ov - nv)} rows)") | |
| elif f in ("names", "aliases", "us_terms", "negative_terms", "caveats"): | |
| ov, nv = set(o[k].get(f) or []), set(n[k].get(f) or []) | |
| if ov == nv: # e.g. absent -> [] or null -> []: a formatting change | |
| detail.append(f"{f} {o[k].get(f)!r} -> {n[k].get(f)!r}") | |
| else: | |
| detail.append(f"{f} (+{len(nv - ov)} -{len(ov - nv)})") | |
| elif isinstance(o[k].get(f), (int, float, bool, type(None))) and \ | |
| isinstance(n[k].get(f), (int, float, bool, type(None))): | |
| detail.append(f"{f} {o[k].get(f)!r} -> {n[k].get(f)!r}") | |
| else: | |
| detail.append(f) | |
| lines.append(f" {label} changed: {k}: {'; '.join(detail)}") | |
| return lines | |
| def record_summary(old, new): | |
| """Per-record lines describing how a curated dataset differs.""" | |
| lines = [] | |
| for key in sorted(set(old) | set(new)): | |
| if key in ("pairings", "dispositions", "methodology", "aliases"): | |
| continue | |
| if old.get(key) != new.get(key): | |
| a, b = old.get(key), new.get(key) | |
| short = all(isinstance(x, (str, int, type(None))) and len(str(x)) <= 40 | |
| for x in (a, b)) | |
| lines.append(f" top-level {key}: " + (f"{a!r} -> {b!r}" if short else "changed")) | |
| for key, label in (("pairings", "pairing"), ("dispositions", "entry"), | |
| ("methodology", "methodology")): | |
| if key in old or key in new: | |
| lines += _records(label, old.get(key), new.get(key)) | |
| oa, na = old.get("aliases") or {}, new.get("aliases") or {} | |
| if oa != na: | |
| added = sorted(set(na) - set(oa)) | |
| removed = sorted(set(oa) - set(na)) | |
| changed = sorted(k for k in set(oa) & set(na) if oa[k] != na[k]) | |
| lines.append(f" aliases: +{len(added)} -{len(removed)} ~{len(changed)}" | |
| + (f" (e.g. {', '.join(added[:10])})" if added else "")) | |
| return lines | |
| def check_review(commit, approved, state, allow_held, cwd=ROOT): | |
| lines, held = [], [] | |
| meta = set(state.get("metadata_files", [])) | |
| recorded = state.get("curated_files", {}) | |
| tree_names, appr_names = curated_names(commit, cwd), curated_names(approved, cwd) | |
| for name in sorted(set(tree_names) | set(appr_names)): | |
| if name in meta: | |
| continue | |
| path = f"{CURATED_DIR}/{name}" | |
| new = show_json(commit, path, cwd) if name in tree_names else None | |
| old = show_json(approved, path, cwd) if name in appr_names else None | |
| if old is not None and name in recorded and \ | |
| canonical_sha256(old) != recorded[name].get("canonical_sha256"): | |
| lines.append(f"WARNING: {name} at the approved commit does not match the " | |
| "hash recorded in review_state.json") | |
| if old is None: | |
| held.append(f"{name}: new curated file, not in the approved baseline") | |
| elif new is None: | |
| held.append(f"{name}: approved file missing from the deploy tree") | |
| elif canonical_sha256(old) != canonical_sha256(new): | |
| held.append(f"{name}: differs from the approved baseline") | |
| lines += [f"{name}:"] + record_summary(old, new) | |
| d_new, d_old = subtree(commit, DRAFTS_DIR, cwd), subtree(approved, DRAFTS_DIR, cwd) | |
| if d_new != d_old: | |
| held.append(f"{DRAFTS_DIR}/: " | |
| + ("absent from the deploy tree" if d_new is None else | |
| "not in the approved commit" if d_old is None else | |
| "differs from the approved commit") | |
| + " (review memos and source drafts ship with their approved batch; " | |
| "tools/compose_release.py puts the approved commit's copy in the tree)") | |
| stat = git("diff", "--stat", approved, commit, "--", CURATED_DIR, COMMENTARY, | |
| cwd=cwd).decode("utf-8", "replace").rstrip() | |
| lines = [f"approved baseline {approved[:7]} ({state.get('approved', {}).get('date', '?')}); " | |
| f"deploy tree {commit[:7]}", | |
| f"git diff --stat {approved[:7]} {commit[:7]} -- {CURATED_DIR} {COMMENTARY}:"] \ | |
| + [" " + l for l in (stat.splitlines() or ["(no differences)"])] + lines | |
| if not held: | |
| return PASS, lines + ["curated legal content equals the approved baseline"] | |
| lines += [f"HELD: {h}" for h in held] | |
| if allow_held: | |
| return WARN, lines + ["--allow-held: shipping content that differs from the " | |
| "approved baseline"] | |
| return FAIL, lines + ["held-for-review content would ship: deploy the composed release " | |
| "(tools/compose_release.py), or record the approval in " | |
| "data/curated/review_state.json"] | |
| # ---- officer-facing legal text in code (verifier, 2026-09-12) ----------------- | |
| def _body_without_docstring(node): | |
| body = list(node.body) | |
| if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \ | |
| and isinstance(body[0].value.value, str): | |
| body = body[1:] | |
| return body or [ast.Pass()] | |
| # FastMCP publishes a tool, prompt or resource function's docstring verbatim as | |
| # its `description` -- the text the client model reads (all 14 tools; 16,903 | |
| # characters; canlex_screen_record's states law: a discharge is not a | |
| # conviction, a conditional sentence is not imprisonment...). The docstring | |
| # and the decorator's own keywords (description=, name=) of such a function | |
| # are officer-facing text and count in its fingerprint; every other | |
| # definition's docstring and decorators are documentation and do not. | |
| _MCP_DECORATORS = ("tool", "prompt", "resource") | |
| def _publishes(node): | |
| """True when a decorator is mcp.tool / mcp.prompt / mcp.resource.""" | |
| for d in getattr(node, "decorator_list", []): | |
| f = d.func if isinstance(d, ast.Call) else d | |
| if isinstance(f, ast.Attribute) and f.attr in _MCP_DECORATORS \ | |
| and isinstance(f.value, ast.Name) and f.value.id == "mcp": | |
| return True | |
| return False | |
| def _fingerprint(node): | |
| """sha256 of a definition's AST: a function or class without its | |
| docstring and decorators (kept for an mcp.tool/prompt/resource function, | |
| whose docstring IS its published description), or an assignment's value. | |
| Formatting, comments and string-literal line wrapping do not change it; | |
| words and logic do.""" | |
| if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): | |
| node = copy.deepcopy(node) | |
| if not _publishes(node): | |
| node.body = _body_without_docstring(node) | |
| node.decorator_list = [] | |
| return hashlib.sha256(ast.dump(node, include_attributes=False).encode("utf-8")).hexdigest() | |
| def _base_name(node): | |
| """The Name at the root of a Subscript / Attribute chain (`X[k]`, | |
| `X.update`), or None.""" | |
| while isinstance(node, (ast.Subscript, ast.Attribute)): | |
| node = node.value | |
| return node.id if isinstance(node, ast.Name) else None | |
| def _definitions(source): | |
| """{top-level name: node} of a Python source: a function or class node, | |
| or an assignment's value. A tuple or list target (`A, B = "a", "b"`) | |
| yields the whole assignment under each name -- screening.py's | |
| STRUCTURED_LIMB / STATED_LIMB, the limb keys the registered | |
| _resolve_pairing selects penalty_history rows by, were invisible to the | |
| gate until 2026-09-21. A later top-level mutation of a defined name | |
| (`X[k] = v`, `X += ...`, `X.update(...)`) is folded into that name's node | |
| so it counts too (none in canlex/ today).""" | |
| out, extra = {}, {} | |
| for node in ast.parse(source).body: | |
| if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): | |
| out[node.name] = node | |
| elif isinstance(node, ast.Assign): | |
| for t in node.targets: | |
| if isinstance(t, ast.Name): | |
| out[t.id] = node.value | |
| elif isinstance(t, (ast.Tuple, ast.List)): | |
| for elt in t.elts: | |
| if isinstance(elt, ast.Name): | |
| out[elt.id] = node | |
| elif _base_name(t): | |
| extra.setdefault(_base_name(t), []).append(node) | |
| elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \ | |
| and node.value is not None: | |
| out[node.target.id] = node.value | |
| elif isinstance(node, ast.AugAssign) and _base_name(node.target): | |
| extra.setdefault(_base_name(node.target), []).append(node) | |
| elif isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) \ | |
| and _base_name(node.value.func): | |
| extra.setdefault(_base_name(node.value.func), []).append(node) | |
| for name, nodes in extra.items(): | |
| if name in out: | |
| out[name] = ast.Module(body=[out[name]] + nodes, type_ignores=[]) | |
| return out | |
| def top_level_fingerprints(source): | |
| """{name: fingerprint} for the top-level assignments and definitions of a | |
| Python source.""" | |
| return {name: _fingerprint(node) for name, node in _definitions(source).items()} | |
| class CodeTree: | |
| """The canlex modules of one tree -- a commit (reader: git show) or the | |
| working tree (reader: the file) -- for the review gate's reach (wave 2 | |
| verifier): a registered definition is fingerprinted TOGETHER WITH every | |
| top-level canlex definition it reaches (the names its body uses, in its | |
| own module or imported from another canlex module, and theirs in turn), | |
| so a change to a helper (a unit table, a negation window, a history | |
| reader) holds the registered name that relies on it.""" | |
| def __init__(self, reader): | |
| self._read, self._src = reader, {} | |
| self._mods = {} | |
| def reader(self, path): | |
| if path not in self._src: | |
| self._src[path] = self._read(path) | |
| return self._src[path] | |
| def at(cls, commit, cwd=ROOT): | |
| def read(path): | |
| blob = show_bytes(commit, path, cwd) | |
| return None if blob is None else blob.decode("utf-8") | |
| return cls(read) | |
| def working(cls, root=ROOT): | |
| def read(path): | |
| p = Path(root) / path | |
| return p.read_text(encoding="utf-8") if p.exists() else None | |
| return cls(read) | |
| def module(self, path): | |
| """({name: node}, {local name: (path, name or None for a module)}).""" | |
| if path not in self._mods: | |
| src = self.reader(path) | |
| if src is None: | |
| self._mods[path] = ({}, {}) | |
| else: | |
| tree = ast.parse(src) | |
| self._mods[path] = (_definitions(src), self._imports(tree.body, path)) | |
| return self._mods[path] | |
| def _module_path(self, mod, level, here): | |
| if level: | |
| base = Path(here).parent | |
| for _ in range(level - 1): | |
| base = base.parent | |
| return (base / mod.replace(".", "/")).as_posix() if mod else base.as_posix() | |
| return mod.replace(".", "/") if mod and mod.split(".")[0] == "canlex" else None | |
| def _imports(self, nodes, here): | |
| out = {} | |
| for top in nodes: | |
| for node in ([top] if isinstance(top, (ast.Import, ast.ImportFrom)) | |
| else ast.walk(top)): | |
| if isinstance(node, ast.ImportFrom): | |
| mp = self._module_path(node.module, node.level, here) | |
| if not mp: | |
| continue | |
| is_module = self.reader(mp + ".py") is not None | |
| for a in node.names: | |
| sub = f"{mp}/{a.name}.py" | |
| if not is_module and self.reader(sub) is not None: | |
| out[a.asname or a.name] = (sub, None) # a submodule | |
| else: | |
| out[a.asname or a.name] = (mp + ".py", a.name) | |
| elif isinstance(node, ast.Import): | |
| for a in node.names: | |
| if a.name.split(".")[0] == "canlex" and a.asname: | |
| out[a.asname] = (a.name.replace(".", "/") + ".py", None) | |
| return out | |
| def refs(self, path, name): | |
| """The (path, name) top-level canlex definitions `name`'s body uses.""" | |
| defs, imports = self.module(path) | |
| node = defs.get(name) | |
| if node is None: | |
| return set() | |
| local = dict(imports) | |
| local.update(self._imports([node], path)) | |
| out = set() | |
| for n in ast.walk(node): | |
| if isinstance(n, ast.Name) and n.id != name: | |
| if n.id in defs: | |
| out.add((path, n.id)) | |
| elif n.id in local and local[n.id][1] is not None: | |
| out.add(local[n.id]) | |
| elif isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name): | |
| tgt = local.get(n.value.id) | |
| if tgt and tgt[1] is None: | |
| out.add((tgt[0], n.attr)) | |
| return {r for r in out if r[1] in self.module(r[0])[0]} | |
| def reach(self, path, name): | |
| """Every (path, name) the definition reaches, itself included.""" | |
| seen, todo = set(), [(path, name)] | |
| while todo: | |
| key = todo.pop() | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| todo += [r for r in self.refs(*key) if r not in seen] | |
| return seen | |
| def own(self, path, name): | |
| node = self.module(path)[0].get(name) | |
| return None if node is None else _fingerprint(node) | |
| def fingerprint(self, path, name): | |
| """sha256 over the definition and everything it reaches, or None when | |
| the tree has no such definition.""" | |
| if self.own(path, name) is None: | |
| return None | |
| parts = sorted(f"{p}:{n}:{self.own(p, n)}" for p, n in self.reach(path, name)) | |
| return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest() | |
| def reviewed_registry(commit, cwd=ROOT): | |
| """[(path, name)] from the tree's canlex/reviewed_code.py REVIEWED, read | |
| with ast (never imported); None when the tree has no registry.""" | |
| blob = show_bytes(commit, CODE_REGISTRY, cwd) | |
| if blob is None: | |
| return None | |
| for node in ast.parse(blob.decode("utf-8")).body: | |
| if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "REVIEWED" | |
| for t in node.targets): | |
| return [tuple(e[:2]) for e in ast.literal_eval(node.value)] | |
| raise RuntimeError(f"{CODE_REGISTRY} at {commit[:7]} has no REVIEWED tuple") | |
| def code_fingerprints(commit, entries, cwd=ROOT, tree=None): | |
| """{(path, name): fingerprint or None} at a commit: each registered | |
| definition together with every canlex definition it reaches | |
| (CodeTree.fingerprint).""" | |
| tree = tree or CodeTree.at(commit, cwd) | |
| return {(path, name): tree.fingerprint(path, name) for path, name in entries} | |
| def _changed_members(old_tree, new_tree, key): | |
| """The definitions `key` reaches in the new tree that are new or differ | |
| from the old tree's (itself excluded): why a registered name is held.""" | |
| out = [] | |
| for p, n in sorted(new_tree.reach(*key) - {key}): | |
| before = old_tree.own(p, n) | |
| if before is None: | |
| out.append(f"{p}:{n} (new)") | |
| elif before != new_tree.own(p, n): | |
| out.append(f"{p}:{n}") | |
| gone = sorted(old_tree.reach(*key) - new_tree.reach(*key)) | |
| return out + [f"{p}:{n} (no longer reached)" for p, n in gone] | |
| def check_review_code(commit, state, allow_held, cwd=ROOT): | |
| """The registered officer-facing definitions in the deploy tree against | |
| the approved-code commit in review_state.json ('approved_code'). | |
| The registry is read at BOTH commits and the union is checked: an entry | |
| may be added, never dropped, without a HELD line naming the drop, and a | |
| tree with no registry while the approved code has one FAILS (2026-09-21 | |
| audit: the check read the deploy tree's registry alone, so deleting an | |
| entry that no other registered definition reaches -- canlex_screen_record | |
| was one of 13 such roots -- removed it from the gate, and a tree with no | |
| canlex/reviewed_code.py passed).""" | |
| entries_new = reviewed_registry(commit, cwd) | |
| base = (state.get("approved_code") or {}).get("commit") | |
| base_full = entries_old = None | |
| if base: | |
| try: | |
| base_full = rev(base, cwd) | |
| except RuntimeError: | |
| return FAIL, [f"approved_code commit {base} is not in this repository"] | |
| entries_old = reviewed_registry(base_full, cwd) | |
| if entries_new is None: | |
| if entries_old: | |
| lines = [f"no {CODE_REGISTRY} in this tree while the approved code " | |
| f"{base_full[:7]} registers {len(entries_old)} definitions: the review " | |
| "gate cannot be dropped by removing the registry"] | |
| if allow_held: | |
| return WARN, lines + ["--allow-held: a rollback to a pre-registry release"] | |
| return FAIL, lines + ["a rollback to a pre-registry release needs " | |
| "CANLEX_PREDEPLOY_ALLOW_HELD=1 (RUNBOOK 'Rollback')"] | |
| return PASS, [f"no {CODE_REGISTRY} in this tree (an older release): not checked"] | |
| dropped = [e for e in (entries_old or []) if e not in set(entries_new)] | |
| entries = list(entries_new) + dropped | |
| new_tree = CodeTree.at(commit, cwd) | |
| new = code_fingerprints(commit, entries, cwd, tree=new_tree) | |
| missing = [f"{p}:{n}" for (p, n), h in new.items() if h is None and (p, n) in entries_new] | |
| if missing: | |
| return FAIL, ["the registry names definitions this tree does not have: " | |
| + ", ".join(missing), f"fix {CODE_REGISTRY}"] | |
| held, lines = [], [] | |
| if not base: | |
| lines.append("no 'approved_code' commit in review_state.json") | |
| held = [f"{p}:{n}: no approved code baseline" for p, n in entries] | |
| else: | |
| old_tree = CodeTree.at(base_full, cwd) | |
| old = code_fingerprints(base_full, entries, cwd, tree=old_tree) | |
| lines.append(f"approved code baseline {base_full[:7]} " | |
| f"({state['approved_code'].get('date', '?')}); deploy tree {commit[:7]}; " | |
| f"{len(entries_new)} registered definitions, each with the canlex " | |
| "definitions it reaches" | |
| + (f"; {len(dropped)} more registered at the approved code only" | |
| if dropped else "")) | |
| for key in entries: | |
| what = [] | |
| if key in dropped: | |
| what.append("dropped from the registry since the approved code") | |
| if old[key] is None: | |
| what.append("new since the approved code") | |
| elif new[key] is None: | |
| what.append("removed from the tree since the approved code") | |
| elif old[key] != new[key]: | |
| via = _changed_members(old_tree, new_tree, key) | |
| what.append("differs from the approved code" | |
| + (f" (via {', '.join(via)})" if via else "")) | |
| if what: | |
| held.append(f"{key[0]}:{key[1]}: {'; '.join(what)}") | |
| if not held: | |
| return PASS, lines + ["officer-facing legal text in code equals the approved code"] | |
| files = sorted({p for p, _ in entries}) | |
| lines += [f"HELD: {h}" for h in held] | |
| lines.append(f"review with: git diff {(base or '<approved>')[:7]} {commit[:7]} -- " | |
| + " ".join(files)) | |
| if allow_held: | |
| return WARN, lines + ["--allow-held: shipping officer-facing legal text in code " | |
| "that the maintainer has not approved"] | |
| return FAIL, lines + ["held officer-facing legal text in code would ship: record the " | |
| "reviewed commit as 'approved_code' in data/curated/review_state.json " | |
| "(python tools/predeploy.py --print-review-state <commit>), or pass " | |
| "--allow-held"] | |
| # ---- refresh drift of the approved curated table (wave 2 verifier) ------------- | |
| def _corpus_json(blob, cwd=ROOT): | |
| """A processed act file's chunks from its blob (an LFS pointer is read | |
| from the local store); [] when unreadable.""" | |
| if blob is None: | |
| return [] | |
| blob = lfs_object(blob, cwd) | |
| if blob is None: | |
| return [] | |
| try: | |
| return json.loads(blob.decode("utf-8")) | |
| except ValueError: | |
| return [] | |
| def corpus_reader(commit=None, cwd=ROOT): | |
| """section_text(act, section) over a commit's data/processed (None: the | |
| working tree), whitespace squashed -- the text a curated verify phrase is | |
| checked against.""" | |
| import re | |
| cache = {} | |
| def text(act, section): | |
| if act not in cache: | |
| path = f"data/processed/{act}.json" | |
| if commit is None: | |
| p = Path(cwd) / path | |
| cache[act] = _corpus_json(p.read_bytes() if p.exists() else None, cwd) | |
| else: | |
| cache[act] = _corpus_json(show_bytes(commit, path, cwd), cwd) | |
| return re.sub(r"\s+", " ", " ".join(c.get("text", "") for c in cache[act] | |
| if str(c.get("section")) == section)) | |
| return text | |
| def verify_sections(pairing): | |
| """{(act, section): the maxima the pairing's verify rows (and its limbs') | |
| quote for that section} -- canlex.screening.maxima_stated on each | |
| phrase.""" | |
| from canlex.screening import maxima_stated | |
| out = {} | |
| rows = list(pairing.get("verify") or []) + [ | |
| v for l in pairing.get("limbs") or [] if isinstance(l, dict) for v in l.get("verify") or []] | |
| for v in rows: | |
| out.setdefault((v["act"], v["section"]), set()).update(maxima_stated(v.get("contains"))) | |
| return out | |
| def refresh_drift(equivalency, now_text, then_text): | |
| """The curated table against a corpus refresh: (drift, changed). | |
| drift: [(pairing id, act, section, sorted new maxima)] -- a maximum a | |
| verify section states NOW that it did not state in the corpus the table | |
| was approved against, and that the pairing does not quote (an amendment | |
| the approved text cannot know: 2026, c. 19 raised s. 173(2)(a) to 10 | |
| years). A composed release shipping such a table ships stale law. | |
| changed: [(pairing id, act, section)] -- every verify section whose text | |
| changed at all (re-read before relying on the approved prose).""" | |
| from canlex.screening import maxima_stated | |
| drift, changed = [], [] | |
| for p in (equivalency or {}).get("pairings") or []: | |
| for (act, sec), quoted in sorted(verify_sections(p).items()): | |
| now, then = now_text(act, sec), then_text(act, sec) | |
| if not now or not then: | |
| continue | |
| if now != then: | |
| changed.append((p["id"], act, sec)) | |
| new = maxima_stated(now) - maxima_stated(then) - quoted | |
| if new: | |
| drift.append((p["id"], act, sec, sorted(new))) | |
| return drift, changed | |
| def review_state_for(commit, cwd=ROOT): | |
| full = rev(commit, cwd) | |
| date = git("show", "-s", "--format=%cs", full, cwd=cwd).decode().strip() | |
| files = {n: {"canonical_sha256": canonical_sha256(show_json(full, f"{CURATED_DIR}/{n}", cwd))} | |
| for n in curated_names(full, cwd) if n != "review_state.json"} | |
| return {"approved": {"commit": full, "date": date, | |
| "note": "<who approved it, when, and what the batch was>"}, | |
| "curated_files": files, | |
| "approved_code": {"commit": full, "date": date, | |
| "note": "<who reviewed the officer-facing text in code " | |
| f"({CODE_REGISTRY}), when>"}} | |
| # ---- driver ------------------------------------------------------------------- | |
| def report(name, status, lines): | |
| print(f"== {name}: {status}") | |
| for l in lines: | |
| print(f" {l}") | |
| sys.stdout.flush() | |
| def tree_checks(commit, approved, state, allow_held, cwd=ROOT, allow_held_code=None): | |
| """[(name, status, lines)] for one deploy tree -- also used by | |
| tools/compose_release.py on the release commit. allow_held_code (default: | |
| allow_held) governs the review-code check alone.""" | |
| short = commit[:7] | |
| results = [(f"blobs {short}",) + check_blobs(commit, cwd)] | |
| status, lines, chunks = check_commentary(commit, cwd) | |
| results.append((f"commentary {short}", status, lines)) | |
| if chunks: | |
| results.append((f"embeddings {short}",) + check_embeddings(commit, chunks, cwd)) | |
| results.append((f"review {short}",) + check_review(commit, approved, state, | |
| allow_held, cwd)) | |
| results.append((f"review-code {short}",) + check_review_code( | |
| commit, state, allow_held if allow_held_code is None else allow_held_code, cwd)) | |
| return results | |
| def main(argv=None): | |
| ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| ap.add_argument("--tree", action="append", | |
| help="commit to check as the deploy tree (repeatable; default HEAD)") | |
| ap.add_argument("--approved", help="approved baseline commit (default: review_state.json)") | |
| ap.add_argument("--skip-tests", action="store_true", help="skip the offline suite") | |
| ap.add_argument("--allow-held", action="store_true", | |
| help="warn instead of failing when curated content differs from the baseline") | |
| ap.add_argument("--allow-held-code", action="store_true", | |
| help="warn instead of failing on the review-code check alone (what " | |
| "tools/compose_release.py --allow-held-code passes through)") | |
| ap.add_argument("--print-review-state", metavar="COMMIT") | |
| args = ap.parse_args(argv) | |
| with contextlib.suppress(AttributeError, ValueError): | |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") | |
| if args.print_review_state: | |
| print(json.dumps(review_state_for(args.print_review_state), indent=1)) | |
| return 0 | |
| allow_held = args.allow_held or os.environ.get("CANLEX_PREDEPLOY_ALLOW_HELD") == "1" | |
| results = [] | |
| if not args.skip_tests: | |
| results.append(("tests",) + run_unittest(["discover", "-s", "tests", "-q"])) | |
| report(*results[-1]) | |
| results.append(("curated (working tree)",) + check_curated_working()) | |
| report(*results[-1]) | |
| state = review_state() | |
| approved = rev(args.approved or state["approved"]["commit"]) | |
| for ref in args.tree or ["HEAD"]: | |
| for r in tree_checks(rev(ref), approved, state, allow_held, | |
| allow_held_code=True if args.allow_held_code else None): | |
| results.append(r) | |
| report(*r) | |
| fails = [n for n, s, _ in results if s == FAIL] | |
| warns = [n for n, s, _ in results if s == WARN] | |
| print(f"\npredeploy: {'FAIL' if fails else 'PASS'}" | |
| + (f" -- failed: {', '.join(fails)}" if fails else "") | |
| + (f"; warnings: {', '.join(warns)}" if warns else "")) | |
| return 1 if fails else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |