From 06899b3c0192c34e49b13acf04a981ff8522fbbb Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:55:52 +0300 Subject: [PATCH] fix(infra): fullstack api dirs include package name (#261) * fix(infra): fullstack _api_dirs_for includes package name in path * test(infra): fullstack thin-routes check coverage for issue #259 * fix(ci): reformat assert in test_project_status for ruff --------- Co-authored-by: opencode-agent --- .opencode/scripts/project-status.py | 16 +++- tests/test_project_status.py | 139 ++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index 56b5faa..1eef4a9 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -621,7 +621,13 @@ def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) -> def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]: - """Return list of api/v1 dirs to scan for routes, based on project type.""" + """Return list of api/v1 dirs to scan for routes, based on project type. + + Both BACKEND and FULLSTACK use the nested ``src//api/v1`` layout + (issue #241). The package name is resolved from ``[project].name`` in + ``pyproject.toml`` (normalized via ``_normalize_package_name``). FULLSTACK + additionally prefixes the backend root with ``backend/``. + """ if ptype == ProjectType.BACKEND: pkg = _resolve_package_name(ctx) if pkg is None: @@ -629,7 +635,13 @@ def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]: root = ctx.root / "src" / pkg / "api" / "v1" return [root] if root.exists() else [] if ptype == ProjectType.FULLSTACK: - root = ctx.root / "backend" / "src" / "api" / "v1" + # pyproject.toml lives under ``backend/`` in the fullstack template, + # so resolve the package name against a backend-rooted context. + backend_ctx = RepoCtx(root=ctx.root / "backend", config=ctx.config) + pkg = _resolve_package_name(backend_ctx) + if pkg is None: + return [] + root = ctx.root / "backend" / "src" / pkg / "api" / "v1" return [root] if root.exists() else [] return [] diff --git a/tests/test_project_status.py b/tests/test_project_status.py index cd74f48..2f91afe 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -205,6 +205,40 @@ def _make_backend_repo(tmp_path: Path) -> None: (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") +def _make_fullstack_repo(tmp_path: Path) -> None: + """Create a minimal fullstack repo skeleton in tmp_path. + + Mirrors the cookiecutter fullstack template (issue #241 nested layout): + backend ``pyproject.toml`` lives under ``backend/``, the package under + ``backend/src//`` with ``api/v1`` nested. The package name is + ``test_repo`` (normalized from the default ``[project].name`` + ``test-repo``). Frontend is a stub ``package.json``. + """ + pkg = "test_repo" + for rel in [ + f"backend/src/{pkg}/api/v1", + f"backend/src/{pkg}/db/models", + f"backend/src/{pkg}/schemas", + f"backend/src/{pkg}/services", + f"backend/src/{pkg}/config", + "backend/tests", + "frontend", + ]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("") + (tmp_path / "backend" / f"src/{pkg}/config/settings.py").write_text("settings = {}\n") + (tmp_path / "backend" / "main.py").write_text( + "from contextlib import asynccontextmanager\n" + "@asynccontextmanager\nasync def lifespan(app): yield\n" + ) + (tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + _write_pyproject(tmp_path / "backend", deps=["fastapi", "uvicorn"], cov_source=["src"]) + (tmp_path / "frontend" / "package.json").write_text('{"name": "test-frontend"}\n') + + # ── parse_remote_url ───────────────────────────────────────────────────────── @@ -536,6 +570,111 @@ def test_thin_routes_import_ok_no_fail_on_over_limit(tmp_path, ctx): assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) +# ── check_thin_routes: FULLSTACK (issue #259) ────────────────────────────── + + +def test_thin_routes_fullstack_ok(tmp_path, ctx): + """Fullstack nested ``backend/src//api/v1/`` → thin-routes OK (new).""" + _make_fullstack_repo(tmp_path) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert group.overall() == ps.CheckStatus.OK, ( + f"fullstack thin-routes check should work now, got {group.checks}" + ) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) + + +def test_thin_routes_fullstack_no_pkg_dir_skip(tmp_path, ctx): + """Fullstack without ``backend/src//`` (flat) → WARN skip (no api dir).""" + (tmp_path / "backend").mkdir() + (tmp_path / "frontend").mkdir() + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert group.overall() == ps.CheckStatus.WARN + assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks) + + +def test_thin_routes_fullstack_no_backend_pyproject_skip(tmp_path, ctx): + """Fullstack without ``backend/pyproject.toml`` → cannot resolve pkg → skip.""" + pkg = "test_repo" + for rel in [ + f"backend/src/{pkg}/api/v1", + f"backend/src/{pkg}/db/models", + f"backend/src/{pkg}/schemas", + f"backend/src/{pkg}/services", + f"backend/src/{pkg}/config", + "frontend", + ]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("") + (tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert group.overall() == ps.CheckStatus.WARN + assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks), ( + f"expected WARN skip when backend pyproject missing, got {group.checks}" + ) + + +def test_thin_routes_fullstack_import_src_db_models_fail(tmp_path, ctx): + """Fullstack route importing ``src.db.models`` → FAIL (same rule as backend).""" + _make_fullstack_repo(tmp_path) + (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom src.db.models import User\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert any( + c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks + ), f"expected FAIL on fullstack route imports, got: {group.checks}" + + +def test_thin_routes_fullstack_import_tortoise_fail(tmp_path, ctx): + """Fullstack route importing ``tortoise`` → FAIL (same rule as backend).""" + _make_fullstack_repo(tmp_path) + (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom tortoise import fields\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert any( + c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks + ), f"expected FAIL on fullstack route imports, got: {group.checks}" + + +def test_thin_routes_fullstack_import_services_ok(tmp_path, ctx): + """Fullstack route importing ``.services`` → OK (not forbidden).""" + _make_fullstack_repo(tmp_path) + (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom test_repo.services import user_service\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return user_service.list_all()\n" + ) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), ( + f"fullstack services import should be OK, got: {group.checks}" + ) + + +def test_thin_routes_fullstack_over_limit_warn(tmp_path, ctx): + """Fullstack route over line limit → WARN (no FAIL) when imports clean.""" + _make_fullstack_repo(tmp_path) + body = "\n x = 1\n" * 60 + (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom test_repo.services import user_service\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():" + f"{body} return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) + assert group.overall() == ps.CheckStatus.WARN, ( + f"over-limit but no forbidden import → WARN, got {group.checks}" + ) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) + + # ── check_quality ────────────────────────────────────────────────────────────