Audit follow-up F-2 (INTEGRATION-AUDIT-01 §10.2). Phase Z families surface showed 11 tracked / 11 contracted / 13 on disk. The 2 untracked WIP files (app_sw_package_vs_solution.html, pre_construction_model_info_stacked.html) are now declared in _WIP_FILES.md as uncontracted and out-of-scope for the runtime matcher; promote/remove is gated on #42. The 11/11 tracked + contracted baseline is unchanged. A new pytest enforces tracked families ↔ frame_contracts.yaml set-equality modulo the WIP allowlist parsed from _WIP_FILES.md, so future drift fails fast in CI before #42 expands to 32 frames. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""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."
|
|
)
|