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:
@@ -79,3 +79,149 @@ def test_catalog_entry_count_matches_frame_count():
|
||||
f"catalog shape inconsistent: entries={entry_count} "
|
||||
f"templates={template_count} frames={frame_count}"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────── IMP-#85 u5 regression coverage ────────────────────────
|
||||
#
|
||||
# Scope (Stage 2 lock):
|
||||
# - Prod catalog passes the audit CLI (run_audit) end-to-end.
|
||||
# - Non-VP fixture catalogs reproduce the boot invariant (u2) + audit (u3a/u3b)
|
||||
# negative paths: missing payload.builder, missing partial, undeclared
|
||||
# slot_payload reference (I4 generated-key-orphan).
|
||||
# - Same fixtures with `visual_pending: true` MUST be silently skipped — the
|
||||
# data-driven VP scope guard from u2/u3a/u3b must not regress.
|
||||
#
|
||||
# Out of scope:
|
||||
# - Implementing the 17 missing VP builders (별 P0 / IMP-04b backlog).
|
||||
# - Visual rendering of fixture frames.
|
||||
#
|
||||
# Path-convention note (tests/CLAUDE.md §F-5):
|
||||
# Stage 2 plan named `tests/fixtures/catalog/` but the project convention
|
||||
# reserves the root `tests/fixtures/` for non-Phase-Z fixtures (creation
|
||||
# requires a separate issue). Phase-Z YAML fixtures live under
|
||||
# `tests/phase_z2/fixtures/`. The u5 fixtures therefore live at
|
||||
# `tests/phase_z2/fixtures/catalog/`.
|
||||
|
||||
import yaml
|
||||
|
||||
from scripts.audit_frame_invariants import (
|
||||
DEFAULT_CATALOG_PATH,
|
||||
DEFAULT_PARTIALS_DIR,
|
||||
run_audit,
|
||||
)
|
||||
from src import phase_z2_mapper
|
||||
from src.phase_z2_mapper import (
|
||||
CatalogInvariantError,
|
||||
PAYLOAD_BUILDERS,
|
||||
_check_catalog_builder_invariant,
|
||||
)
|
||||
|
||||
_IMP85_FIXTURES_DIR = Path(__file__).parent / "phase_z2" / "fixtures" / "catalog"
|
||||
_MISSING_BUILDER_FIXTURE = _IMP85_FIXTURES_DIR / "missing_builder_non_vp.yaml"
|
||||
_UNDECLARED_SLOT_FIXTURE = _IMP85_FIXTURES_DIR / "undeclared_slot_ref_non_vp.yaml"
|
||||
|
||||
|
||||
def _load_fixture_catalog(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _reset_catalog_cache_for_imp85():
|
||||
"""Some tests below load fixture YAMLs into the boot invariant; ensure the
|
||||
prod cache is untouched on entry/exit so other tests stay deterministic."""
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
yield
|
||||
phase_z2_mapper._CATALOG_CACHE = None
|
||||
|
||||
|
||||
def test_prod_catalog_audit_clean():
|
||||
"""IMP-#85 u5 — prod catalog + prod partials dir pass audit (I1-I4 clean)."""
|
||||
violations = run_audit(DEFAULT_CATALOG_PATH, DEFAULT_PARTIALS_DIR)
|
||||
assert violations == [], (
|
||||
f"Prod catalog audit reported {len(violations)} violation(s):\n - "
|
||||
+ "\n - ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
def test_missing_builder_fixture_raises_catalog_invariant(
|
||||
_reset_catalog_cache_for_imp85,
|
||||
):
|
||||
"""Fixture: non-VP contract with unregistered builder → u2 invariant raise."""
|
||||
catalog = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
|
||||
with pytest.raises(CatalogInvariantError) as exc:
|
||||
_check_catalog_builder_invariant(catalog)
|
||||
msg = str(exc.value)
|
||||
assert "imp85_u5_missing_builder_frame" in msg
|
||||
assert "definitely_not_a_registered_builder_imp85_u5" in msg
|
||||
|
||||
|
||||
def test_missing_builder_fixture_audit_reports_i3(tmp_path):
|
||||
"""Fixture: non-VP contract with unregistered builder → audit I3 + I1.
|
||||
|
||||
The fixture frame's template_id has no partial on disk (tmp_path is empty),
|
||||
so I1 fires as well. I3 is the primary assertion target; I1 surfacing is
|
||||
expected and asserted to lock both audit paths together.
|
||||
"""
|
||||
violations = run_audit(_MISSING_BUILDER_FIXTURE, tmp_path)
|
||||
joined = "\n".join(violations)
|
||||
assert any(
|
||||
v.startswith("I3 builder-unregistered:")
|
||||
and "imp85_u5_missing_builder_frame" in v
|
||||
for v in violations
|
||||
), f"expected I3 builder-unregistered violation, got:\n{joined}"
|
||||
assert any(
|
||||
v.startswith("I1 partial-missing:")
|
||||
and "imp85_u5_missing_builder_frame" in v
|
||||
for v in violations
|
||||
), f"expected I1 partial-missing violation, got:\n{joined}"
|
||||
|
||||
|
||||
def test_undeclared_slot_fixture_audit_reports_i4(tmp_path):
|
||||
"""Fixture: non-VP contract with valid builder but orphan generated key.
|
||||
|
||||
`items_with_role` + `array_root: orphan_array_root_imp85_u5` produces
|
||||
`slot_payload.orphan_array_root_imp85_u5`. The temp partial below contains
|
||||
`slot_payload.title` only (no bracket access), so I4 must fire on the
|
||||
orphan array_root key.
|
||||
"""
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
partial = partials_dir / "imp85_u5_undeclared_slot_frame.html"
|
||||
partial.write_text(
|
||||
"<div>{{ slot_payload.title }}</div>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
violations = run_audit(_UNDECLARED_SLOT_FIXTURE, partials_dir)
|
||||
joined = "\n".join(violations)
|
||||
assert any(
|
||||
v.startswith("I4 generated-key-orphan:")
|
||||
and "imp85_u5_undeclared_slot_frame" in v
|
||||
and "orphan_array_root_imp85_u5" in v
|
||||
for v in violations
|
||||
), f"expected I4 generated-key-orphan violation, got:\n{joined}"
|
||||
|
||||
|
||||
def test_fixtures_with_visual_pending_true_are_skipped(
|
||||
tmp_path, _reset_catalog_cache_for_imp85,
|
||||
):
|
||||
"""VP scope guard — flipping `visual_pending: true` on fixture frames must
|
||||
silence both the boot invariant (u2) and the audit CLI (I1-I4)."""
|
||||
missing = _load_fixture_catalog(_MISSING_BUILDER_FIXTURE)
|
||||
undeclared = _load_fixture_catalog(_UNDECLARED_SLOT_FIXTURE)
|
||||
for entry in (*missing.values(), *undeclared.values()):
|
||||
entry["visual_pending"] = True
|
||||
|
||||
_check_catalog_builder_invariant(missing)
|
||||
_check_catalog_builder_invariant(undeclared)
|
||||
|
||||
vp_yaml = tmp_path / "vp_only.yaml"
|
||||
vp_yaml.write_text(yaml.safe_dump({**missing, **undeclared}), encoding="utf-8")
|
||||
partials_dir = tmp_path / "families"
|
||||
partials_dir.mkdir()
|
||||
violations = run_audit(vp_yaml, partials_dir)
|
||||
assert violations == [], (
|
||||
f"VP frames must be silently skipped, got:\n - "
|
||||
+ "\n - ".join(violations)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user