"""Orphaned endpoint detection — cross-references backend routes with usage. Extracts all registered API routes from the backend (decorator and add_api_route patterns) and checks whether each route's static path segments appear in the frontend source or in other backend files (e.g. MCP servers that call endpoints internally). Routes with no matching reference anywhere are flagged. Limitations (v1): - Routes that end with a path parameter (e.g. /{id}) and have no trailing static segment are skipped — they're too ambiguous to match. - WebSocket routes in main.py are not checked. - Backend-only endpoints (health checks, OAuth callbacks) should be excluded via the exceptions list or endpoint-ignore-routes in config.json. """ from __future__ import annotations import fnmatch import re from pathlib import Path from . import is_excepted, is_lintignored _DECORATOR_RE = re.compile( r"@(\w+)\.router\.\w+\(\s*[\"']([^\"']+)[\"']" ) _ADD_ROUTE_RE = re.compile( r"(\w+)\.router\.add_api_route\(\s*[\"']([^\"']+)[\"']" ) _SUBAPP_RE = re.compile( r"(\w+)\s*=\s*SubApp\(\s*[\"']([^\"']+)[\"']" ) _FUNC_DEF_RE = re.compile(r"\s*(?:async\s+)?def\s+(\w+)") _ADD_ROUTE_FUNC_RE = re.compile(r"add_api_route\([^,]+,\s*(?:\w+\.)*(\w+)") _TEMPLATE_ASSIGN_RE = re.compile( r"""(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*`([^`]*)`""" ) _STRING_ASSIGN_RE = re.compile( r"""(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(['"])(.*?)\2""" ) _TEMPLATE_REF_RE = re.compile(r"\$\{(\w+)\}") def _static_tail(route_path: str) -> str: """Return the trailing contiguous static segments of a route path. >>> _static_tail("/sessions/{id}/message") '/message' >>> _static_tail("/usage-summary") '/usage-summary' >>> _static_tail("/{id}") '' """ parts = route_path.strip("/").split("/") tail: list[str] = [] for part in reversed(parts): if part.startswith("{"): break tail.append(part) tail.reverse() return "/" + "/".join(tail) if tail else "" def _resolve_frontend_vars(files: list[tuple[str, str]]) -> dict[str, str]: """Collect const/let/var string assignments across files and resolve refs. Handles patterns like: const API_BASE = "/api"; const WORKSPACE_API = `${API_BASE}/outputs/workspace`; """ raw: dict[str, str] = {} for _, text in files: for m in _STRING_ASSIGN_RE.finditer(text): raw.setdefault(m.group(1), m.group(3)) for m in _TEMPLATE_ASSIGN_RE.finditer(text): raw.setdefault(m.group(1), m.group(2)) resolved = dict(raw) for _ in range(5): changed = False for name, val in list(resolved.items()): new_val = _TEMPLATE_REF_RE.sub( lambda m: resolved.get(m.group(1), m.group(0)), val ) if new_val != val: resolved[name] = new_val changed = True if not changed: break return resolved def _expand_template_refs(text: str, resolved: dict[str, str]) -> str: """Replace ``${VAR}`` references in *text* with resolved values.""" return _TEMPLATE_REF_RE.sub( lambda m: resolved.get(m.group(1), m.group(0)), text ) def _find_func_name(lines: list[str], decorator_idx: int) -> str: for j in range(decorator_idx + 1, min(decorator_idx + 5, len(lines))): m = _FUNC_DEF_RE.match(lines[j]) if m: return m.group(1) return "" def run_endpoint_check( root: Path, exceptions: dict[str, list[str]], ignore_routes: list[str] | None = None, ignores: dict[Path, set[str]] | None = None, ) -> list[str]: """Find backend API endpoints with no matching frontend or backend reference.""" backend_dir = root / "backend" frontend_dir = root / "frontend" / "src" if not backend_dir.exists() or not frontend_dir.exists(): return [] _ignore_routes = ignore_routes or [] var_to_name: dict[str, str] = {} for py in backend_dir.rglob("*.py"): if ".venv" in py.parts: continue for m in _SUBAPP_RE.finditer(py.read_text(errors="ignore")): var_to_name[m.group(1)] = m.group(2) routes: list[tuple[str, str, str, int, str]] = [] for py in backend_dir.rglob("*.py"): if ".venv" in py.parts: continue text = py.read_text(errors="ignore") lines = text.splitlines() rel = str(py.relative_to(root)) for i, line in enumerate(lines): m = _DECORATOR_RE.search(line) if m: var, path = m.group(1), m.group(2) name = var_to_name.get(var) if name: func = _find_func_name(lines, i) routes.append((name, path, rel, i + 1, func)) m2 = _ADD_ROUTE_RE.search(line) if m2: var, path = m2.group(1), m2.group(2) name = var_to_name.get(var) if name: fm = _ADD_ROUTE_FUNC_RE.search(line) func = fm.group(1) if fm else "" routes.append((name, path, rel, i + 1, func)) frontend_files: list[tuple[str, str]] = [] for ext in ("*.ts", "*.tsx"): for f in frontend_dir.rglob(ext): frontend_files.append((str(f.relative_to(root)), f.read_text(errors="ignore"))) backend_files: list[tuple[str, str]] = [] for py in backend_dir.rglob("*.py"): if ".venv" in py.parts: continue backend_files.append((str(py.relative_to(root)), py.read_text(errors="ignore"))) resolved_vars = _resolve_frontend_vars(frontend_files) errors: list[str] = [] for subapp_name, route_path, filepath, lineno, func_name in routes: if is_excepted(filepath, "endpoints", exceptions): continue if ignores and is_lintignored(root / filepath, root, "endpoints", ignores): continue full_path = f"{subapp_name}{route_path}" if any(fnmatch.fnmatch(full_path, p) for p in _ignore_routes): continue tail = _static_tail(route_path) if not tail: continue found = False for _fe_path, fe_text in frontend_files: expanded = _expand_template_refs(fe_text, resolved_vars) if full_path in expanded: found = True break if subapp_name in expanded and tail in expanded: found = True break if not found: for be_path, be_text in backend_files: if be_path == filepath: continue if full_path in be_text: found = True break if not found: label = func_name or route_path errors.append( f"{filepath}:{lineno}:1: warning: " f"[endpoints] orphaned endpoint '{label}' " f"(/api/{full_path}) — no frontend or backend reference found" ) return sorted(errors)