feat(#85): IMP catalog builder invariant + VP runtime gate (u1~u7)
- u1: BuilderMissingError(FitError) — narrow exception aligned with pipeline catch - u2: load_frame_contracts catalog invariant + VP skip + CatalogInvariantError - u3a: audit CLI I1~I3 (partial existence / declared builder / registry membership) - u3b: audit CLI I4 (slot_payload refs vs declared/generated payload keys) - u4: lookup_v4_candidates VP filter (lookup_v4_all_judgments raw telemetry untouched) - u5: catalog invariant regression coverage + temp non-VP failure fixtures - u6: mdx04 VP routing fixture tests (sw_dependency_four_problems excluded from live) - u7: tests/conftest.py env isolation + mdx03/mdx04/mdx05 subprocess smoke Targeted 74 PASS (12.31s). Full regression 1063 PASS (87.70s). Audit CLI clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
249
tests/test_audit_frame_invariants_i1_i3.py
Normal file
249
tests/test_audit_frame_invariants_i1_i3.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""IMP-#85 u3a — Audit CLI invariants I1-I3.
|
||||
|
||||
Scope (Stage 2 lock):
|
||||
I1 partial existence — `templates/phase_z2/families/{template_id}.html`
|
||||
must exist for live (non-VP) contracts.
|
||||
I2 builder declared — live contracts must declare non-empty
|
||||
`payload.builder`.
|
||||
I3 builder registered — declared builders must be in PAYLOAD_BUILDERS.
|
||||
|
||||
`visual_pending: true` skipped for all of I1-I3 (data-driven from catalog,
|
||||
no hard-coded frame allow-list; matches u2 invariant scope).
|
||||
|
||||
Out of scope (별 axis):
|
||||
- I4 slot_payload references (u3b).
|
||||
- V4 runtime VP filter (u4).
|
||||
- Implementing the 17 missing VP builders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "audit_frame_invariants.py"
|
||||
|
||||
|
||||
def _write_yaml(path: Path, payload: dict) -> Path:
|
||||
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _run_cli(catalog: Path, partials: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT_PATH),
|
||||
"--catalog",
|
||||
str(catalog),
|
||||
"--partials-dir",
|
||||
str(partials),
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_prod_catalog_audit_passes(tmp_path):
|
||||
"""Prod catalog + prod partials dir → I1-I3 PASS (live contracts clean)."""
|
||||
from scripts.audit_frame_invariants import (
|
||||
DEFAULT_CATALOG_PATH,
|
||||
DEFAULT_PARTIALS_DIR,
|
||||
run_audit,
|
||||
)
|
||||
|
||||
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
|
||||
assert violations == [], (
|
||||
"Prod live contracts (non-VP) must satisfy I1-I3 invariants. "
|
||||
f"Got: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_i1_partial_missing_for_live_contract(tmp_path):
|
||||
"""Live contract without families/{template_id}.html → I1 violation."""
|
||||
from src.phase_z2_mapper import PAYLOAD_BUILDERS
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
sample_builder = next(iter(PAYLOAD_BUILDERS.keys()))
|
||||
catalog = {
|
||||
"missing_partial_frame": {
|
||||
"template_id": "missing_partial_frame",
|
||||
"payload": {"builder": sample_builder},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
violations = check_i1_partial_existence(catalog, partials_dir)
|
||||
assert len(violations) == 1
|
||||
assert "I1 partial-missing" in violations[0]
|
||||
assert "missing_partial_frame" in violations[0]
|
||||
|
||||
|
||||
def test_i1_partial_present_no_violation(tmp_path):
|
||||
"""Live contract with partial on disk → no I1 violation."""
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
catalog = {
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
|
||||
assert check_i1_partial_existence(catalog, partials_dir) == []
|
||||
|
||||
|
||||
def test_i1_skips_visual_pending(tmp_path):
|
||||
"""visual_pending: true with no partial → I1 skip (no violation)."""
|
||||
from scripts.audit_frame_invariants import check_i1_partial_existence
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "definitely_not_registered"},
|
||||
},
|
||||
}
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
assert check_i1_partial_existence(catalog, partials_dir) == []
|
||||
|
||||
|
||||
def test_i2_missing_builder_field():
|
||||
"""Live contract without payload.builder → I2 violation."""
|
||||
from scripts.audit_frame_invariants import check_i2_builder_declared
|
||||
|
||||
catalog = {
|
||||
"no_builder_frame": {
|
||||
"template_id": "no_builder_frame",
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
violations = check_i2_builder_declared(catalog)
|
||||
assert len(violations) == 1
|
||||
assert "I2 builder-undeclared" in violations[0]
|
||||
assert "no_builder_frame" in violations[0]
|
||||
|
||||
|
||||
def test_i2_skips_visual_pending():
|
||||
"""visual_pending: true without builder → I2 skip."""
|
||||
from scripts.audit_frame_invariants import check_i2_builder_declared
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
assert check_i2_builder_declared(catalog) == []
|
||||
|
||||
|
||||
def test_i3_unregistered_builder():
|
||||
"""Live contract with unknown builder → I3 violation."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"ghost_frame": {
|
||||
"template_id": "ghost_frame",
|
||||
"payload": {"builder": "ghost_builder_xyz"},
|
||||
},
|
||||
}
|
||||
violations = check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
)
|
||||
assert len(violations) == 1
|
||||
assert "I3 builder-unregistered" in violations[0]
|
||||
assert "ghost_frame" in violations[0]
|
||||
assert "ghost_builder_xyz" in violations[0]
|
||||
|
||||
|
||||
def test_i3_registered_builder_passes():
|
||||
"""Live contract with registered builder → no I3 violation."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
}
|
||||
assert check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
) == []
|
||||
|
||||
|
||||
def test_i3_skips_visual_pending():
|
||||
"""visual_pending: true with unregistered builder → I3 skip."""
|
||||
from scripts.audit_frame_invariants import check_i3_builder_registered
|
||||
|
||||
catalog = {
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "vp_only_builder"},
|
||||
},
|
||||
}
|
||||
assert check_i3_builder_registered(
|
||||
catalog, registered_builders={"items_with_role"}
|
||||
) == []
|
||||
|
||||
|
||||
def test_cli_exit_zero_on_clean_catalog(tmp_path):
|
||||
"""CLI exit code 0 + PASS line on clean (live) catalog."""
|
||||
catalog_path = tmp_path / "catalog.yaml"
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
(partials_dir / "ok_frame.html").write_text("<div/>", encoding="utf-8")
|
||||
_write_yaml(
|
||||
catalog_path,
|
||||
{
|
||||
"ok_frame": {
|
||||
"template_id": "ok_frame",
|
||||
"payload": {"builder": "items_with_role"},
|
||||
},
|
||||
"vp_frame": {
|
||||
"template_id": "vp_frame",
|
||||
"visual_pending": True,
|
||||
"payload": {"builder": "unregistered_xyz"},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _run_cli(catalog_path, partials_dir)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS" in result.stdout
|
||||
|
||||
|
||||
def test_cli_exit_one_on_violations(tmp_path):
|
||||
"""CLI exit code 1 + aggregated violations listed on drift."""
|
||||
catalog_path = tmp_path / "catalog.yaml"
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
_write_yaml(
|
||||
catalog_path,
|
||||
{
|
||||
"frame_a": {
|
||||
"template_id": "frame_a",
|
||||
"payload": {"builder": "ghost_builder"},
|
||||
},
|
||||
"frame_b": {
|
||||
"template_id": "frame_b",
|
||||
"payload": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
result = _run_cli(catalog_path, partials_dir)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "FAIL" in result.stdout
|
||||
assert "frame_a" in result.stdout
|
||||
assert "frame_b" in result.stdout
|
||||
assert "I1" in result.stdout
|
||||
assert "I2" in result.stdout or "I3" in result.stdout
|
||||
Reference in New Issue
Block a user