File size: 44,086 Bytes
61d7581 5e976f9 61d7581 5e976f9 61d7581 5e976f9 61d7581 5e976f9 61d7581 5e976f9 61d7581 901bf8c ab834d1 61d7581 901bf8c 61d7581 ab834d1 61d7581 ab834d1 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 901bf8c 61d7581 5e976f9 61d7581 901bf8c 61d7581 901bf8c 61d7581 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 | #!/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]
@classmethod
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)
@classmethod
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())
|