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:
2026-05-23 16:56:38 +09:00
parent d9d338416a
commit cacc5b30db
14 changed files with 2163 additions and 3 deletions

View File

@@ -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)
)