|
| 1 | +"""Cross-registry dispatch sufficiency: real HOOK_REGISTRY × real BackendCapabilities. |
| 2 | +
|
| 3 | +Regression suite for issue #4082 — guards against a class of bug where HOOK_REGISTRY |
| 4 | +fix-required entries silently brick dispatch on backends whose applicable_guards do |
| 5 | +not cover the hook's script stems. The dispatch gate in tools_execution._check_backend_compat |
| 6 | +correctly refuses dispatch in this state, but the bug class had no test that crossed the |
| 7 | +HOOK_REGISTRY ↔ BackendCapabilities boundary until now. |
| 8 | +
|
| 9 | +These tests exercise the real registries through the real gate logic, not synthetic |
| 10 | +monkeypatched copies. This is the defense-in-depth layer that catches reclassifications |
| 11 | +which would brick dispatch silently. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +from pathlib import Path |
| 17 | + |
| 18 | +import pytest |
| 19 | + |
| 20 | +from autoskillit.execution.backends import BACKEND_REGISTRY |
| 21 | +from autoskillit.hook_registry import HOOK_REGISTRY |
| 22 | +from autoskillit.server.tools.tools_execution import _get_fix_required_hook_matchers |
| 23 | + |
| 24 | +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] |
| 25 | + |
| 26 | + |
| 27 | +def _collect_fix_required_stems() -> set[str]: |
| 28 | + """Extract unique script stems from all fix-required hooks in HOOK_REGISTRY.""" |
| 29 | + stems: set[str] = set() |
| 30 | + for h in HOOK_REGISTRY: |
| 31 | + if h.codex_status == "fix-required": |
| 32 | + stems.update(Path(s).stem for s in h.scripts) |
| 33 | + return stems |
| 34 | + |
| 35 | + |
| 36 | +def _all_fix_required_matchers() -> list[str]: |
| 37 | + """Extract matchers from all fix-required hooks in HOOK_REGISTRY.""" |
| 38 | + return [h.matcher for h in HOOK_REGISTRY if h.codex_status == "fix-required"] |
| 39 | + |
| 40 | + |
| 41 | +@pytest.mark.parametrize("backend_name,backend_cls", sorted(BACKEND_REGISTRY.items())) |
| 42 | +def test_no_backend_bricked_by_fix_required_hooks(backend_name: str, backend_cls: type) -> None: |
| 43 | + """Every backend's applicable_guards must cover all fix-required hook scripts. |
| 44 | +
|
| 45 | + Runs the exact same function the dispatch gate uses — _get_fix_required_hook_matchers — |
| 46 | + against the real HOOK_REGISTRY and the real backend's real applicable_guards set. |
| 47 | + A non-empty result means this backend would be bricked at dispatch time. |
| 48 | + """ |
| 49 | + backend = backend_cls() |
| 50 | + blockers = _get_fix_required_hook_matchers(backend.capabilities.applicable_guards) |
| 51 | + assert not blockers, ( |
| 52 | + f"Backend {backend_name!r} would be bricked at dispatch by fix-required " |
| 53 | + f"hooks with matchers {blockers}. Either reclassify the hook's codex_status " |
| 54 | + f"or add the missing guard to applicable_guards." |
| 55 | + ) |
| 56 | + |
| 57 | + |
| 58 | +@pytest.mark.parametrize("backend_name,backend_cls", sorted(BACKEND_REGISTRY.items())) |
| 59 | +def test_applicable_guards_covers_all_fix_required_scripts( |
| 60 | + backend_name: str, backend_cls: type |
| 61 | +) -> None: |
| 62 | + """Set-level invariant: applicable_guards ⊇ fix-required hook script stems. |
| 63 | +
|
| 64 | + A parallel, structural assertion that doesn't go through the gate function. |
| 65 | + Detects the same class of bug from the registry-composition angle. |
| 66 | + """ |
| 67 | + backend = backend_cls() |
| 68 | + fix_required_stems = _collect_fix_required_stems() |
| 69 | + missing = fix_required_stems - backend.capabilities.applicable_guards |
| 70 | + assert not missing, ( |
| 71 | + f"Backend {backend_name!r} applicable_guards is missing script stems " |
| 72 | + f"{sorted(missing)} required by fix-required hooks in HOOK_REGISTRY." |
| 73 | + ) |
| 74 | + |
| 75 | + |
| 76 | +def test_test_matrix_covers_all_registered_backends() -> None: |
| 77 | + """Meta-test: the parametrized matrix must cover every backend in BACKEND_REGISTRY. |
| 78 | +
|
| 79 | + Prevents silent matrix shrinkage if a backend is removed from parametrization. |
| 80 | + """ |
| 81 | + expected = {"claude-code", "codex"} |
| 82 | + actual = set(BACKEND_REGISTRY.keys()) |
| 83 | + assert len(actual) >= 2, ( |
| 84 | + f"BACKEND_REGISTRY has only {len(actual)} backend(s) — " |
| 85 | + f"expected at least 2 (claude-code + codex). If a backend was removed, " |
| 86 | + f"update this lower bound." |
| 87 | + ) |
| 88 | + assert actual == expected, ( |
| 89 | + f"BACKEND_REGISTRY keys {sorted(actual)} != expected {sorted(expected)}. " |
| 90 | + f"Update the expected set in this meta-test when backends are added or removed." |
| 91 | + ) |
0 commit comments