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 <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-04 13:55:52 +03:00 committed by GitHub
parent 8b05fa74f7
commit 06899b3c01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 153 additions and 2 deletions

View file

@ -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]: 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/<package>/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: if ptype == ProjectType.BACKEND:
pkg = _resolve_package_name(ctx) pkg = _resolve_package_name(ctx)
if pkg is None: 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" root = ctx.root / "src" / pkg / "api" / "v1"
return [root] if root.exists() else [] return [root] if root.exists() else []
if ptype == ProjectType.FULLSTACK: 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 [root] if root.exists() else []
return [] return []

View file

@ -205,6 +205,40 @@ def _make_backend_repo(tmp_path: Path) -> None:
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") (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/<package>/`` 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 ───────────────────────────────────────────────────────── # ── 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) 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/<pkg>/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/<pkg>/`` (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 ``<pkg>.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 ──────────────────────────────────────────────────────────── # ── check_quality ────────────────────────────────────────────────────────────