Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions autobot-backend/api/codebase_analytics/api_endpoint_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,44 @@
# =============================================================================


#: Directory names that have held the FastAPI backend across layouts. Ordered
#: most-specific-first so a repo containing both is resolved deterministically.
_BACKEND_DIR_CANDIDATES = ("autobot-backend", "backend", ".")

#: What makes a directory *the* backend rather than any directory holding an
#: ``api`` folder — the router registry lives beside the routes it mounts.
_ROUTER_REGISTRY_RELPATH = Path("initialization") / "router_registry"


def find_backend_dir(project_root: Path) -> Path:
"""Locate the FastAPI backend package inside *project_root* (#12853).

Callers pass the root of the tree being analysed — a source clone, or
AutoBot's own checkout — and the backend sits at different depths in each.
Assuming ``project_root / "api"`` silently produced an empty scan for every
layout that nests it (this repo's ``autobot-backend/``), which reads as
"this backend has no routes" rather than as a failure to look in the right
place.

Prefers a candidate that also carries the router registry, so a directory
that merely happens to contain ``api/`` does not win over the real backend.
Falls back to *project_root* unchanged when nothing matches, preserving the
previous behaviour for callers that already point straight at the backend.
"""
with_registry: Path | None = None
with_api: Path | None = None

for name in _BACKEND_DIR_CANDIDATES:
candidate = project_root if name == "." else project_root / name
if not (candidate / "api").is_dir():
continue
if (candidate / _ROUTER_REGISTRY_RELPATH).is_dir():
with_registry = with_registry or candidate
with_api = with_api or candidate

return with_registry or with_api or project_root


class BackendEndpointScanner:
"""Scans backend Python files for FastAPI route definitions."""

Expand All @@ -132,7 +170,13 @@ def __init__(self, project_root: Path | None = None):
# resolves to /opt/autobot -- not the analyzable repo -- in the deployed
# standalone rsync layout).
self.project_root = project_root or Path(resolve_project_root())
self.backend_path = self.project_root / "api"
# Issue #12853: the backend is not always directly under the scan root.
# This repo keeps it in autobot-backend/, so `project_root / "api"` found
# nothing and the scan reported 0 endpoints against a ~2000-route backend
# -- which then made every frontend call look like it targeted a missing
# endpoint. Locate the backend package instead of assuming its depth.
self.backend_dir = find_backend_dir(self.project_root)
self.backend_path = self.backend_dir / "api"
self._router_prefixes: Dict[str, str] = {}
# Map module name to router prefix (e.g., "chat" -> "", "system" -> "/system")
self._module_prefix_map: Dict[str, str] = {}
Expand Down Expand Up @@ -181,7 +225,11 @@ def _collect_router_prefixes(self) -> None:
- backend/initialization/router_registry/terminal_routers.py (config tuples)
- backend/initialization/router_registry/mcp_routers.py (config tuples)
"""
router_registry_path = self.project_root / "backend" / "initialization" / "router_registry"
# Issue #12853: this was `project_root / "backend" / ...`, a path that
# exists in no current layout -- so no prefix was ever parsed and every
# endpoint was recorded without its router prefix. The registry lives
# beside the routes, under the resolved backend directory.
router_registry_path = self.backend_dir / _ROUTER_REGISTRY_RELPATH

# Parse core_routers.py for module -> prefix mapping (uses tuple format)
core_routers_file = router_registry_path / "core_routers.py"
Expand All @@ -190,13 +238,12 @@ def _collect_router_prefixes(self) -> None:

# Issue #552: Parse all *_routers.py files that use config tuple format
# These files use ROUTER_CONFIGS pattern: ("module_path", "router", "/prefix", [...], "name")
config_tuple_files = [
"analytics_routers.py",
"monitoring_routers.py",
"feature_routers.py",
"terminal_routers.py",
"mcp_routers.py",
]
# Issue #12853: integration_routers.py was missing from this list, so
# its routers carried no prefix. Parse every *_routers.py present
# instead of a hand-maintained list that drifts as registries are added.
config_tuple_files = sorted(
p.name for p in router_registry_path.glob("*_routers.py") if p.name != "core_routers.py"
)

for config_file in config_tuple_files:
file_path = router_registry_path / config_file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,80 @@ def test_explicit_project_root_is_not_overridden(self, tmp_path):
backend_scanner = scanner_mod.BackendEndpointScanner(project_root=explicit_root)

assert backend_scanner.project_root == explicit_root


class TestBackendDirResolution:
"""Issue #12853: the backend is not always directly under the scan root.

``project_root / "api"`` found nothing for any layout that nests the
backend, so the scan reported 0 endpoints against a ~2000-route backend --
which made every frontend call look like it targeted a missing endpoint.
"""

@staticmethod
def _make_backend(root, name):
"""Create a backend package (routes + router registry) under *root*."""
base = root if name == "." else root / name
(base / "api").mkdir(parents=True)
(base / "initialization" / "router_registry").mkdir(parents=True)
return base

def test_finds_backend_nested_under_the_scan_root(self, tmp_path):
backend = self._make_backend(tmp_path, "autobot-backend")

assert scanner_mod.find_backend_dir(tmp_path) == backend

def test_finds_backend_directly_at_the_scan_root(self, tmp_path):
"""Callers that already point at the backend keep working."""
backend = self._make_backend(tmp_path, ".")

assert scanner_mod.find_backend_dir(tmp_path) == backend

def test_registry_bearing_candidate_wins_over_a_bare_api_dir(self, tmp_path):
"""A directory that merely contains api/ is not the backend."""
(tmp_path / "api").mkdir() # decoy at the root
backend = self._make_backend(tmp_path, "autobot-backend")

assert scanner_mod.find_backend_dir(tmp_path) == backend

def test_unrecognised_layout_falls_back_to_the_scan_root(self, tmp_path):
assert scanner_mod.find_backend_dir(tmp_path) == tmp_path

def test_scanner_points_at_the_nested_backend(self, tmp_path):
backend = self._make_backend(tmp_path, "autobot-backend")
scanner = scanner_mod.BackendEndpointScanner(project_root=tmp_path)

assert scanner.backend_path == backend / "api"

def test_router_registry_is_read_from_the_backend_not_the_scan_root(self, tmp_path):
"""#12853: the registry path was `project_root / "backend" / ...`.

That exists in no current layout, so no prefix was ever parsed and every
endpoint was recorded without its router prefix.
"""
backend = self._make_backend(tmp_path, "autobot-backend")
registry = backend / "initialization" / "router_registry"
(registry / "core_routers.py").write_text('("chat", "router", "/chat")\n', encoding="utf-8")

scanner = scanner_mod.BackendEndpointScanner(project_root=tmp_path)
scanner._collect_router_prefixes()

assert (scanner.backend_dir / "initialization" / "router_registry") == registry

def test_every_router_registry_file_is_parsed(self, tmp_path):
"""A hand-maintained list silently skipped integration_routers.py."""
backend = self._make_backend(tmp_path, "autobot-backend")
registry = backend / "initialization" / "router_registry"
for name in ("analytics_routers.py", "integration_routers.py", "brand_new_routers.py"):
(registry / name).write_text("ROUTER_CONFIGS = []\n", encoding="utf-8")

scanner = scanner_mod.BackendEndpointScanner(project_root=tmp_path)
parsed = []
with patch.object(scanner, "_parse_config_tuple_registry", side_effect=parsed.append):
scanner._collect_router_prefixes()

assert {p.name for p in parsed} == {
"analytics_routers.py",
"integration_routers.py",
"brand_new_routers.py",
}
Loading