"""Prove every declared APIRouter is actually reachable from the running app. Why this exists --------------- Phase 9A-4 F7 shipped ``musehub/api/routes/musehub/overseer_provenance.py`` with a fully implemented handler and six green unit tests. Those tests call the service function directly and never issue an HTTP request, so nothing in the suite observes the app's route table. A router that is declared but never included is therefore invisible to the suite and 404s in production. This checker closes that gap by comparing *endpoint function identity*, not path strings: it imports every module under ``musehub/api/routes/**`` that declares a module-level ``router``, then asserts each of that router's endpoint callables is reachable from ``app.routes``. Identity comparison makes the check immune to prefix drift, tag changes, and duplicated path literals. FastAPI 0.141 defers router inclusion behind ``_IncludedRouter`` placeholders rather than flattening routes onto ``app.routes``, so reachability is resolved by walking ``original_router`` and ``routes`` recursively with cycle protection. Deliberately unmounted routers must be named in ``UNMOUNTED_ALLOWLIST`` with a non-empty reason, so "not registered" is always an explicit, reviewable decision rather than an oversight. Exit codes ---------- ``0`` Every declared router is reachable. Emits ``ARTIFACT_SHA256`` over the sorted ``module -> endpoint`` fingerprint so ``ok verify-step`` can record L1 evidence that is stable across runs but changes when registration changes. ``1`` At least one declared router is unreachable, a route module failed to import, or an allowlist entry has an empty reason. ``2`` Harness failure — the app itself could not be imported. Run ``check_app_imports.py`` for the underlying traceback. """ from __future__ import annotations import ast import hashlib import importlib import json import sys from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[2] ROUTES_ROOT = REPO_ROOT / "musehub" / "api" / "routes" # Modules that declare a `router` on purpose but are never mounted directly. # Every entry needs a non-empty reason; an empty reason fails the check. UNMOUNTED_ALLOWLIST: dict[str, str] = {} def _module_declares_router(path: Path) -> bool: """True when the module assigns a top-level name ``router``. Parsed with ``ast`` so discovery never imports a module that would crash on import; import failures are reported separately as findings. """ try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) except (OSError, SyntaxError): return False for node in tree.body: targets: list[ast.expr] = [] if isinstance(node, ast.Assign): targets = list(node.targets) elif isinstance(node, ast.AnnAssign): targets = [node.target] for target in targets: if isinstance(target, ast.Name) and target.id == "router": return True return False def _module_name(path: Path) -> str: return ".".join(path.relative_to(REPO_ROOT).with_suffix("").parts) def _reachable_endpoint_ids(routes: Any, seen: set[int] | None = None) -> set[int]: """Collect ``id()`` of every endpoint callable reachable from ``routes``. Handles three node shapes: plain routes with an ``endpoint``, FastAPI 0.141 ``_IncludedRouter`` placeholders exposing ``original_router``, and Starlette ``Mount`` nodes exposing ``routes``. ``seen`` guards against cycles created by a router included into itself or into two parents. """ seen = set() if seen is None else seen found: set[int] = set() for route in routes or []: endpoint = getattr(route, "endpoint", None) if endpoint is not None: found.add(id(endpoint)) original = getattr(route, "original_router", None) if original is not None: if id(original) not in seen: seen.add(id(original)) found |= _reachable_endpoint_ids(getattr(original, "routes", []), seen) continue nested = getattr(route, "routes", None) if nested and id(route) not in seen: seen.add(id(route)) found |= _reachable_endpoint_ids(nested, seen) return found def main() -> int: try: from musehub.main import app except Exception as exc: # noqa: BLE001 — harness reports, never raises print(f"FAIL: cannot import musehub.main: {exc!r}", file=sys.stderr) print("Run tools/verify/check_app_imports.py for the traceback.", file=sys.stderr) return 2 reachable = _reachable_endpoint_ids(app.routes) findings: list[str] = [] fingerprint: list[str] = [] checked = 0 for path in sorted(ROUTES_ROOT.rglob("*.py")): if path.name == "__init__.py" or not _module_declares_router(path): continue module_name = _module_name(path) rel = str(path.relative_to(REPO_ROOT)) if module_name in UNMOUNTED_ALLOWLIST: if not UNMOUNTED_ALLOWLIST[module_name].strip(): findings.append(f"{rel}: allowlisted with an empty reason") continue try: module = importlib.import_module(module_name) except Exception as exc: # noqa: BLE001 findings.append(f"{rel}: import failed: {exc!r}") continue router = getattr(module, "router", None) routes = list(getattr(router, "routes", []) or []) if not routes: continue checked += 1 unmounted: list[str] = [] for route in routes: declared = getattr(route, "path", "") methods = sorted(getattr(route, "methods", None) or ["-"]) fingerprint.append(f"{module_name} {','.join(methods)} {declared}") if id(getattr(route, "endpoint", None)) not in reachable: unmounted.append(declared) if unmounted: findings.append( f"{rel}: router declared but unreachable from app " f"({', '.join(sorted(set(unmounted)))}) — include it via " f"musehub/api/routes/musehub/__init__.py auto-discovery or an " f"explicit app.include_router(...) in musehub/main.py placed " f"before the /{{owner}}/{{repo_slug}} wildcard" ) if findings: print("FAIL: unreachable or broken routers", file=sys.stderr) for finding in findings: print(f" - {finding}", file=sys.stderr) return 1 digest = hashlib.sha256( json.dumps(sorted(fingerprint), sort_keys=True).encode("utf-8") ).hexdigest() print( f"OK: {checked} router module(s) reachable; " f"{len(fingerprint)} declared route(s); {len(reachable)} app endpoints" ) print(f"ARTIFACT_SHA256={digest}") return 0 if __name__ == "__main__": sys.exit(main())