gabriel / musehub public
check_routes_registered.py python
180 lines 6.9 KB
Raw
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