Files

211 lines
7.0 KiB
Python

"""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)