"""Modularity checks: cohesion, thin routes, thin components, god class.""" from __future__ import annotations from roboco.conventions import classify_python, classify_ts from roboco.conventions.modularity import check_modularity from roboco.foundation.policy.conventions.models import ConventionsStandard def _py(src: str) -> set[str]: source = src.encode() defs = classify_python.classify_definitions(source) findings = check_modularity( "app/x.py", defs, source, "python", ConventionsStandard() ) return {f.rule for f in findings} def _ts(src: str, lang: str = "tsx") -> set[str]: source = src.encode() defs = classify_ts.classify_definitions(source, lang) findings = check_modularity("src/x.tsx", defs, source, lang, ConventionsStandard()) return {f.rule for f in findings} # --- Cohesion: one architectural concern per file --------------------------- # def test_cohesion_flags_model_and_route_in_one_file() -> None: rules = _py( "from pydantic import BaseModel\n" "from fastapi import APIRouter\n" "router = APIRouter()\n" "class UserIn(BaseModel):\n" " name: str\n" "@router.post('/users')\n" "def create_user(u):\n" " return u\n" ) assert "modular_cohesion" in rules def test_cohesion_clean_for_a_single_concern() -> None: rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/a')\n" "def a():\n return 1\n" "@router.get('/b')\n" "def b():\n return 2\n" ) assert "modular_cohesion" not in rules # --- Thin routes: a route must delegate, not query the DB ------------------- # def test_thin_routes_flags_db_access_in_route() -> None: rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/users')\n" "def list_users(db):\n" " return db.execute('select 1').scalars().all()\n" ) assert "thin_routes" in rules def test_thin_routes_clean_when_delegating_to_a_service() -> None: rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/users')\n" "def list_users(svc):\n" " return svc.list_users()\n" ) assert "thin_routes" not in rules def test_thin_routes_allows_explicit_commit_in_route() -> None: # Committing the unit of work after delegating is a common, valid pattern — # a bare `db.commit()` must not count as the route doing data access. rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.post('/users')\n" "async def create_user(svc, db):\n" " user = await svc.create()\n" " await db.commit()\n" " return user\n" ) assert "thin_routes" not in rules def test_thin_routes_flags_stream_and_stream_scalars() -> None: # SQLAlchemy 2.0 streaming constructs are data access too (#133): a route # that calls `db.stream(...)` / `db.stream_scalars(...)` is doing a # repository's job, same as `execute`/`scalars`. The statement is passed in # (no `select(...)` in the body) so only the streaming call is the signal. rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/a')\n" "async def a(db, stmt):\n" " async for row in (await db.stream(stmt)):\n" " yield row\n" ) assert "thin_routes" in rules rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/b')\n" "async def b(db, stmt):\n" " async for s in (await db.stream_scalars(stmt)):\n" " yield s\n" ) assert "thin_routes" in rules def test_thin_routes_allows_set_cache_add() -> None: # `seen_tags.add(tag)` and `cache.add(k, v)` are NOT DB calls — must not trip. rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/x')\n" "def x():\n" " seen_tags.add('t')\n" " cache.add('k', 'v')\n" " return []\n" ) assert "thin_routes" not in rules def test_thin_routes_still_flags_db_add() -> None: # `db.add(obj)` IS a DB call — must still trip. rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/x')\n" "def x(db):\n" " db.add(obj)\n" " return []\n" ) assert "thin_routes" in rules def test_thin_routes_still_flags_session_add_all_and_merge() -> None: # `session.add_all(...)` / `session.merge(...)` on a session handle still trip. rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/x')\n" "def x(session):\n" " session.add_all([obj])\n" " session.merge(other)\n" " return []\n" ) assert "thin_routes" in rules def test_thin_routes_still_flags_execute_unambiguous() -> None: # The unambiguous methods still trip on attribute match alone (regression guard). rules = _py( "from fastapi import APIRouter\n" "router = APIRouter()\n" "@router.get('/x')\n" "def x(db):\n" " return db.execute('select 1')\n" ) assert "thin_routes" in rules # --- Thin components: data fetching belongs in a hook ----------------------- # def test_thin_components_flags_fetch_in_component() -> None: rules = _ts( "export function UserList() {\n" " const data = fetch('/api/users');\n" " return