From 8be1e4834160468689acfefcc7eabf702ce9e0b6 Mon Sep 17 00:00:00 2001 From: haikdc Date: Mon, 30 Mar 2026 22:00:36 -0700 Subject: [PATCH] [Haik]: removed unused endpoints, also made linter compatible with endpoint parsing --- backend/apps/agents/agents.py | 10 - backend/apps/analytics/analytics.py | 40 +--- backend/apps/outputs/outputs.py | 1 - backend/apps/settings/settings.py | 5 - backend/apps/subscriptions/subscriptions.py | 34 ---- backend/apps/templates/templates.py | 14 -- linter/checks/endpoints.py | 207 ++++++++++++++++++++ linter/checks/vulture.py | 2 + linter/config/config.json | 9 +- linter/lint.py | 9 +- 10 files changed, 223 insertions(+), 108 deletions(-) create mode 100644 linter/checks/endpoints.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 996f9604..e7319fc7 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -123,16 +123,6 @@ async def update_session(session_id: str, body: dict): await agent_manager.update_session(session_id, **body) return {"ok": True} -@agents.router.get("/sessions/{session_id}/branches") -async def get_branches(session_id: str): - session = agent_manager.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - return { - "branches": {k: v.model_dump(mode="json") for k, v in session.branches.items()}, - "active_branch_id": session.active_branch_id, - } - @agents.router.post("/sessions/{session_id}/duplicate") async def duplicate_session(session_id: str, body: dict = {}): try: diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py index 75e75c53..082e7625 100644 --- a/backend/apps/analytics/analytics.py +++ b/backend/apps/analytics/analytics.py @@ -141,42 +141,4 @@ async def usage_summary(): stats = compute_session_stats(sessions) nine_router_stats = await get_usage_stats() if _9r_running() else None - return enrich_with_nine_router(stats, nine_router_stats) - - -@analytics.router.get("/cost-breakdown") -async def cost_breakdown(period: str = "7d"): - """Get detailed cost breakdown from 9Router.""" - from backend.apps.nine_router import get_usage_stats, is_running as _9r_running - if not _9r_running(): - return {"available": False, "by_model": {}, "by_provider": {}} - stats = await get_usage_stats(period) - if not stats: - return {"available": False, "by_model": {}, "by_provider": {}} - return { - "available": True, - "period": period, - "total_cost": stats.get("totalCost", 0), - "total_requests": stats.get("totalRequests", 0), - "total_prompt_tokens": stats.get("totalPromptTokens", 0), - "total_completion_tokens": stats.get("totalCompletionTokens", 0), - "by_model": stats.get("byModel", {}), - "by_provider": stats.get("byProvider", {}), - } - - -@analytics.router.get("/status") -async def analytics_status(): - return {"status": "posthog", "enabled": True} - - -@analytics.router.post("/event") -async def record_event(body: dict): - """Accept analytics events from the frontend (e.g. feature.time_spent).""" - event_type = body.get("event_type", "") - properties = body.get("properties", {}) - if event_type: - record(event_type, properties, - session_id=body.get("session_id"), - dashboard_id=body.get("dashboard_id")) - return {"ok": True} + return enrich_with_nine_router(stats, nine_router_stats) \ No newline at end of file diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 47a904e3..91e95bee 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -230,7 +230,6 @@ async def execute_output(body: OutputExecute): # -- AI generation routes -- -outputs.router.add_api_route("/vibe-code", ai_generation.vibe_code, methods=["POST"]) outputs.router.add_api_route("/auto-run", ai_generation.auto_run_output, methods=["POST"]) outputs.router.add_api_route("/auto-run-agent", ai_generation.auto_run_agent, methods=["POST"]) outputs.router.add_api_route("/auto-run-agent/{session_id}", ai_generation.cleanup_auto_run_agent, methods=["DELETE"]) diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index c2889c8d..54c78292 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -91,11 +91,6 @@ async def update_settings(body: AppSettings): return {"ok": True, "settings": body.model_dump()} -@settings.router.get("/default-system-prompt") -async def get_default_system_prompt(): - return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT} - - @settings.router.post("/reset-system-prompt") async def reset_system_prompt(): current = load_settings() diff --git a/backend/apps/subscriptions/subscriptions.py b/backend/apps/subscriptions/subscriptions.py index 65bc89f9..31d535ea 100644 --- a/backend/apps/subscriptions/subscriptions.py +++ b/backend/apps/subscriptions/subscriptions.py @@ -95,40 +95,6 @@ async def subscriptions_poll(body: dict): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@subscriptions.router.post("/exchange") -async def subscriptions_exchange(body: dict): - """Exchange OAuth code for tokens via 9Router.""" - from backend.apps.nine_router import exchange_oauth - provider = body.get("provider", "") - code = body.get("code", "") - redirect_uri = body.get("redirect_uri", "") - code_verifier = body.get("code_verifier", "") - state = body.get("state", "") - - if not provider or not code: - raise HTTPException(status_code=400, detail="provider and code required") - - try: - result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state) - if result.get("success"): - from backend.apps.analytics.collector import record as _analytics - _analytics("subscription.connected", {"provider": provider}) - return result - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@subscriptions.router.get("/models") -async def subscriptions_models(): - """List all models available through connected subscriptions.""" - from backend.apps.nine_router import is_running, get_models - if not is_running(): - return {"models": []} - models = await get_models() - return {"models": models} - - @subscriptions.router.post("/disconnect") async def subscriptions_disconnect(body: dict): """Disconnect a subscription provider via 9Router.""" diff --git a/backend/apps/templates/templates.py b/backend/apps/templates/templates.py index df7e522b..e895fef9 100644 --- a/backend/apps/templates/templates.py +++ b/backend/apps/templates/templates.py @@ -60,17 +60,3 @@ async def update_template(template_id: str, body: PromptTemplateUpdate): async def delete_template(template_id: str): _delete(template_id) return {"ok": True} - -@templates.router.post("/render") -async def render_template(body: dict): - template_id = body.get("template_id", "") - values = body.get("values", {}) - template = _load(template_id) - rendered = template.template - for field in template.fields: - placeholder = "{{" + field.name + "}}" - value = values.get(field.name, field.default or "") - rendered = rendered.replace(placeholder, str(value)) - from backend.apps.analytics.collector import record as _analytics - _analytics("feature.used", {"feature": "template.used"}) - return {"rendered": rendered} diff --git a/linter/checks/endpoints.py b/linter/checks/endpoints.py new file mode 100644 index 00000000..4c9f9985 --- /dev/null +++ b/linter/checks/endpoints.py @@ -0,0 +1,207 @@ +"""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 + +_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, +) -> 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 + + 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) diff --git a/linter/checks/vulture.py b/linter/checks/vulture.py index f300af3b..df40f4bc 100644 --- a/linter/checks/vulture.py +++ b/linter/checks/vulture.py @@ -34,6 +34,8 @@ def run_vulture( cmd.extend([ "--min-confidence", str(min_confidence), "--exclude", ".venv,__pycache__,data,uv-bin", + "--ignore-decorators", "@*.router.*", + "--ignore-names", "cls", ]) try: diff --git a/linter/config/config.json b/linter/config/config.json index 0614fda7..09a369d8 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -5,14 +5,16 @@ "no-nested-imports": false, "vulture": true, "eslint": false, - "knip": false + "knip": false, + "endpoints": true }, "rules": { "max-file-lines": 250, "max-folder-items": 7, "vulture-min-confidence": 1, "vulture-error-threshold": 1, - "no-nested-imports": true + "no-nested-imports": true, + "endpoint-ignore-routes": ["*/callback", "*/callback/*"] }, "include_extensions": [".py", ".ts", ".tsx", ".js", ".jsx"], "exclude": [ @@ -38,6 +40,7 @@ "backend" ], "no-nested-imports": ["linter/lint.py"], - "vulture": [] + "vulture": [], + "endpoints": ["backend/apps/health/*"] } } diff --git a/linter/lint.py b/linter/lint.py index 44444e9d..b9000db1 100644 --- a/linter/lint.py +++ b/linter/lint.py @@ -15,6 +15,7 @@ from checks.structural import check_file_lines, check_folder_items, check_nested from checks.vulture import run_vulture from checks.eslint import run_eslint from checks.knip import run_knip +from checks.endpoints import run_endpoint_check SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_FILE = SCRIPT_DIR / "config" / "config.json" @@ -25,7 +26,7 @@ def load_config() -> dict[str, Any]: return json.load(f) -def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str]]: +def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], list[str]]: config = load_config() enabled: dict[str, bool] = config.get("enabled", {}) rules: dict[str, int] = config["rules"] @@ -80,8 +81,10 @@ def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str]]: eslint_errors = run_eslint(root) if enabled.get("eslint", True) else [] knip_errors = run_knip(root) if enabled.get("knip", True) else [] + endpoint_ignore_routes: list[str] = rules.get("endpoint-ignore-routes", []) + endpoint_errors = run_endpoint_check(root, exceptions, endpoint_ignore_routes) if enabled.get("endpoints", True) else [] - return sorted(structural_errors), sorted(vulture_errors), sorted(eslint_errors), sorted(knip_errors) + return sorted(structural_errors), sorted(vulture_errors), sorted(eslint_errors), sorted(knip_errors), sorted(endpoint_errors) def _print_section(name: str, errors: list[str]) -> None: @@ -94,11 +97,13 @@ def _print_section(name: str, errors: list[str]) -> None: def print_results( structural_errors: list[str], vulture_errors: list[str], eslint_errors: list[str], knip_errors: list[str], + endpoint_errors: list[str], ) -> None: _print_section("structural", structural_errors) _print_section("vulture", vulture_errors) _print_section("eslint", eslint_errors) _print_section("knip", knip_errors) + _print_section("endpoints", endpoint_errors) def watch_loop(root: Path) -> None: