check_routes_registered.py
python
sha256:f65847c6a4ae29872f3ccbc2420f91a8948949b84a06320bdaf2ac281c89ff8a
docs(F7b): governance sync — BUILT awaiting Gabriel; carry …
Human
patch
26 days ago
| 1 | """Prove every declared APIRouter is actually reachable from the running app. |
| 2 | |
| 3 | Why this exists |
| 4 | --------------- |
| 5 | Phase 9A-4 F7 shipped ``musehub/api/routes/musehub/overseer_provenance.py`` with a |
| 6 | fully implemented handler and six green unit tests. Those tests call the service |
| 7 | function directly and never issue an HTTP request, so nothing in the suite |
| 8 | observes the app's route table. A router that is declared but never included is |
| 9 | therefore invisible to the suite and 404s in production. |
| 10 | |
| 11 | This checker closes that gap by comparing *endpoint function identity*, not path |
| 12 | strings: it imports every module under ``musehub/api/routes/**`` that declares a |
| 13 | module-level ``router``, then asserts each of that router's endpoint callables is |
| 14 | reachable from ``app.routes``. Identity comparison makes the check immune to |
| 15 | prefix drift, tag changes, and duplicated path literals. |
| 16 | |
| 17 | FastAPI 0.141 defers router inclusion behind ``_IncludedRouter`` placeholders |
| 18 | rather than flattening routes onto ``app.routes``, so reachability is resolved by |
| 19 | walking ``original_router`` and ``routes`` recursively with cycle protection. |
| 20 | |
| 21 | Deliberately unmounted routers must be named in ``UNMOUNTED_ALLOWLIST`` with a |
| 22 | non-empty reason, so "not registered" is always an explicit, reviewable decision |
| 23 | rather than an oversight. |
| 24 | |
| 25 | Exit codes |
| 26 | ---------- |
| 27 | ``0`` |
| 28 | Every declared router is reachable. Emits ``ARTIFACT_SHA256`` over the sorted |
| 29 | ``module -> endpoint`` fingerprint so ``ok verify-step`` can record L1 |
| 30 | evidence that is stable across runs but changes when registration changes. |
| 31 | ``1`` |
| 32 | At least one declared router is unreachable, a route module failed to import, |
| 33 | or an allowlist entry has an empty reason. |
| 34 | ``2`` |
| 35 | Harness failure — the app itself could not be imported. Run |
| 36 | ``check_app_imports.py`` for the underlying traceback. |
| 37 | """ |
| 38 | |
| 39 | from __future__ import annotations |
| 40 | |
| 41 | import ast |
| 42 | import hashlib |
| 43 | import importlib |
| 44 | import json |
| 45 | import sys |
| 46 | from pathlib import Path |
| 47 | from typing import Any |
| 48 | |
| 49 | REPO_ROOT = Path(__file__).resolve().parents[2] |
| 50 | ROUTES_ROOT = REPO_ROOT / "musehub" / "api" / "routes" |
| 51 | |
| 52 | # Modules that declare a `router` on purpose but are never mounted directly. |
| 53 | # Every entry needs a non-empty reason; an empty reason fails the check. |
| 54 | UNMOUNTED_ALLOWLIST: dict[str, str] = {} |
| 55 | |
| 56 | |
| 57 | def _module_declares_router(path: Path) -> bool: |
| 58 | """True when the module assigns a top-level name ``router``. |
| 59 | |
| 60 | Parsed with ``ast`` so discovery never imports a module that would crash on |
| 61 | import; import failures are reported separately as findings. |
| 62 | """ |
| 63 | try: |
| 64 | tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) |
| 65 | except (OSError, SyntaxError): |
| 66 | return False |
| 67 | for node in tree.body: |
| 68 | targets: list[ast.expr] = [] |
| 69 | if isinstance(node, ast.Assign): |
| 70 | targets = list(node.targets) |
| 71 | elif isinstance(node, ast.AnnAssign): |
| 72 | targets = [node.target] |
| 73 | for target in targets: |
| 74 | if isinstance(target, ast.Name) and target.id == "router": |
| 75 | return True |
| 76 | return False |
| 77 | |
| 78 | |
| 79 | def _module_name(path: Path) -> str: |
| 80 | return ".".join(path.relative_to(REPO_ROOT).with_suffix("").parts) |
| 81 | |
| 82 | |
| 83 | def _reachable_endpoint_ids(routes: Any, seen: set[int] | None = None) -> set[int]: |
| 84 | """Collect ``id()`` of every endpoint callable reachable from ``routes``. |
| 85 | |
| 86 | Handles three node shapes: plain routes with an ``endpoint``, FastAPI 0.141 |
| 87 | ``_IncludedRouter`` placeholders exposing ``original_router``, and Starlette |
| 88 | ``Mount`` nodes exposing ``routes``. ``seen`` guards against cycles created |
| 89 | by a router included into itself or into two parents. |
| 90 | """ |
| 91 | seen = set() if seen is None else seen |
| 92 | found: set[int] = set() |
| 93 | for route in routes or []: |
| 94 | endpoint = getattr(route, "endpoint", None) |
| 95 | if endpoint is not None: |
| 96 | found.add(id(endpoint)) |
| 97 | original = getattr(route, "original_router", None) |
| 98 | if original is not None: |
| 99 | if id(original) not in seen: |
| 100 | seen.add(id(original)) |
| 101 | found |= _reachable_endpoint_ids(getattr(original, "routes", []), seen) |
| 102 | continue |
| 103 | nested = getattr(route, "routes", None) |
| 104 | if nested and id(route) not in seen: |
| 105 | seen.add(id(route)) |
| 106 | found |= _reachable_endpoint_ids(nested, seen) |
| 107 | return found |
| 108 | |
| 109 | |
| 110 | def main() -> int: |
| 111 | try: |
| 112 | from musehub.main import app |
| 113 | except Exception as exc: # noqa: BLE001 — harness reports, never raises |
| 114 | print(f"FAIL: cannot import musehub.main: {exc!r}", file=sys.stderr) |
| 115 | print("Run tools/verify/check_app_imports.py for the traceback.", file=sys.stderr) |
| 116 | return 2 |
| 117 | |
| 118 | reachable = _reachable_endpoint_ids(app.routes) |
| 119 | findings: list[str] = [] |
| 120 | fingerprint: list[str] = [] |
| 121 | checked = 0 |
| 122 | |
| 123 | for path in sorted(ROUTES_ROOT.rglob("*.py")): |
| 124 | if path.name == "__init__.py" or not _module_declares_router(path): |
| 125 | continue |
| 126 | module_name = _module_name(path) |
| 127 | rel = str(path.relative_to(REPO_ROOT)) |
| 128 | |
| 129 | if module_name in UNMOUNTED_ALLOWLIST: |
| 130 | if not UNMOUNTED_ALLOWLIST[module_name].strip(): |
| 131 | findings.append(f"{rel}: allowlisted with an empty reason") |
| 132 | continue |
| 133 | |
| 134 | try: |
| 135 | module = importlib.import_module(module_name) |
| 136 | except Exception as exc: # noqa: BLE001 |
| 137 | findings.append(f"{rel}: import failed: {exc!r}") |
| 138 | continue |
| 139 | |
| 140 | router = getattr(module, "router", None) |
| 141 | routes = list(getattr(router, "routes", []) or []) |
| 142 | if not routes: |
| 143 | continue |
| 144 | |
| 145 | checked += 1 |
| 146 | unmounted: list[str] = [] |
| 147 | for route in routes: |
| 148 | declared = getattr(route, "path", "<unknown>") |
| 149 | methods = sorted(getattr(route, "methods", None) or ["-"]) |
| 150 | fingerprint.append(f"{module_name} {','.join(methods)} {declared}") |
| 151 | if id(getattr(route, "endpoint", None)) not in reachable: |
| 152 | unmounted.append(declared) |
| 153 | if unmounted: |
| 154 | findings.append( |
| 155 | f"{rel}: router declared but unreachable from app " |
| 156 | f"({', '.join(sorted(set(unmounted)))}) — include it via " |
| 157 | f"musehub/api/routes/musehub/__init__.py auto-discovery or an " |
| 158 | f"explicit app.include_router(...) in musehub/main.py placed " |
| 159 | f"before the /{{owner}}/{{repo_slug}} wildcard" |
| 160 | ) |
| 161 | |
| 162 | if findings: |
| 163 | print("FAIL: unreachable or broken routers", file=sys.stderr) |
| 164 | for finding in findings: |
| 165 | print(f" - {finding}", file=sys.stderr) |
| 166 | return 1 |
| 167 | |
| 168 | digest = hashlib.sha256( |
| 169 | json.dumps(sorted(fingerprint), sort_keys=True).encode("utf-8") |
| 170 | ).hexdigest() |
| 171 | print( |
| 172 | f"OK: {checked} router module(s) reachable; " |
| 173 | f"{len(fingerprint)} declared route(s); {len(reachable)} app endpoints" |
| 174 | ) |
| 175 | print(f"ARTIFACT_SHA256={digest}") |
| 176 | return 0 |
| 177 | |
| 178 | |
| 179 | if __name__ == "__main__": |
| 180 | sys.exit(main()) |
File History
1 commit
sha256:f65847c6a4ae29872f3ccbc2420f91a8948949b84a06320bdaf2ac281c89ff8a
docs(F7b): governance sync — BUILT awaiting Gabriel; carry …
Human
patch
26 days ago