"""Phase Z family-template ↔ frame_contracts.yaml baseline invariant. #52 F-2 option (c) lock (2026-05-19) — locks active contracted family count at 11/11 with a WIP allowlist for the 2 untracked WIP family templates documented in `templates/phase_z2/families/_WIP_FILES.md`. Any drift after this point (new family file on disk without a contract entry, or a contract entry pointing to a missing file) fails fast in CI. References: - docs/architecture/INTEGRATION-AUDIT-01-REPORT.md §10.2 F-2 - docs/architecture/IMP-18-SVG-GAP-REPORT.md L28/L30/L51 - templates/phase_z2/families/_WIP_FILES.md (WIP allowlist source) - Gitea #52 (this reconciliation), #42 (promote/remove gate) Pattern mirrors `tests/test_catalog_invariant.py` — fail fast with explicit diff message if family ↔ contract surfaces drift. """ from __future__ import annotations import re from pathlib import Path import yaml PROJECT_ROOT = Path(__file__).parent.parent FAMILIES_DIR = PROJECT_ROOT / "templates" / "phase_z2" / "families" CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml" WIP_DOC_PATH = FAMILIES_DIR / "_WIP_FILES.md" def _load_contract_keys() -> set[str]: with CATALOG_PATH.open(encoding="utf-8") as f: catalog = yaml.safe_load(f) return {k for k, v in catalog.items() if isinstance(v, dict)} def _load_disk_family_stems() -> set[str]: return {p.stem for p in FAMILIES_DIR.glob("*.html")} def _load_wip_allowlist() -> set[str]: text = WIP_DOC_PATH.read_text(encoding="utf-8") return {m.group(1) for m in re.finditer(r"`([A-Za-z0-9_\-]+)\.html`", text)} def test_contracts_set_equals_disk_families_minus_wip(): """`frame_contracts.yaml` keys ↔ disk family stems minus WIP allowlist.""" contracts = _load_contract_keys() disk = _load_disk_family_stems() wip = _load_wip_allowlist() expected = disk - wip missing = expected - contracts extra = contracts - expected assert not missing, ( f"Family files on disk without frame_contracts.yaml entry " f"(and not in _WIP_FILES.md): {sorted(missing)}. " "Add a contract entry, or list the file in " "templates/phase_z2/families/_WIP_FILES.md as WIP." ) assert not extra, ( f"frame_contracts.yaml has entries with no matching family file: " f"{sorted(extra)}." ) def test_wip_allowlist_is_disk_only_and_uncontracted(): """WIP allowlist names must exist on disk AND have no contract entry.""" contracts = _load_contract_keys() disk = _load_disk_family_stems() wip = _load_wip_allowlist() missing_on_disk = wip - disk leaked_into_contracts = wip & contracts assert not missing_on_disk, ( f"_WIP_FILES.md names files not on disk: {sorted(missing_on_disk)}." ) assert not leaked_into_contracts, ( f"_WIP_FILES.md names files that already have a contract entry: " f"{sorted(leaked_into_contracts)}. Promote via #42 instead — " "WIP allowlist must be disk-only / uncontracted." )