fix(infra): enforce nested src/<package>/ layout in project-status + cookiecutter (#246)

* fix(infra): nested src/<package>/ layout in project-status oracle

* fix(templates): nested packages and drop src. prefix in cookiecutter

* test(infra): cover nested src/<package>/ layout contract #241

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-03 20:06:36 +03:00 committed by GitHub
parent 596aa9b18a
commit 10df459cee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 236 additions and 60 deletions

View file

@ -16,11 +16,15 @@ Usage:
Project types (auto-detected): Project types (auto-detected):
fullstack ``frontend/`` dir (SvelteKit) + ``backend/`` dir fullstack ``frontend/`` dir (SvelteKit) + ``backend/`` dir
backend ``src/api/v1/`` + ``src/db/models/`` + fastapi/uvicorn in deps backend ``src/<package>/api/v1/`` + ``src/<package>/db/models/`` + fastapi
cli ``[project.scripts]`` in pyproject.toml + typer in deps cli ``[project.scripts]`` in pyproject.toml + typer in deps
bot ``src/bot.py`` OR aiogram in deps bot ``src/bot.py`` OR aiogram in deps
worker ``src/flow.py`` OR prefect in deps worker ``src/flow.py`` OR prefect in deps
unknown none of the above matched (still runs a generic check set) unknown none of the above matched (still runs a generic check set)
``<package>`` = ``[project].name`` normalized (``my-project`` ``my_project``).
Nested ``src/<package>/`` is the standard for ALL types (publishable, reusable
as a git-dependency). Flat ``src/`` is deprecated WARN.
""" """
from __future__ import annotations from __future__ import annotations
@ -214,13 +218,36 @@ def get_repo_full_name(repo: Path | None = None) -> str | None:
# ── auto-detect ────────────────────────────────────────────────────────────── # ── auto-detect ──────────────────────────────────────────────────────────────
def _resolve_package_name() -> str | None:
"""Resolve the project's normalized package name from ``[project].name``.
Returns ``None`` if ``pyproject.toml`` is missing or has no ``[project].name``.
Normalization: ``my-project`` ``my_project`` (PEP 503-ish, ``[-_.]+`` ``_``).
"""
pyproject = parse_pyproject()
if not isinstance(pyproject, dict):
return None
project = pyproject.get("project", {})
if not isinstance(project, dict):
return None
name = project.get("name")
if not isinstance(name, str) or not name:
return None
return _normalize_package_name(name)
def _matches_backend(deps_lower: str) -> bool: def _matches_backend(deps_lower: str) -> bool:
"""True if backend contract dirs present + fastapi/uvicorn in deps.""" """True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.
return (
path_exists("src/api/v1") Falls back to flat ``src/api/v1`` detection (with the caller surfacing
and path_exists("src/db/models") a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
and ("fastapi" in deps_lower or "uvicorn" in deps_lower) """
) if "fastapi" not in deps_lower and "uvicorn" not in deps_lower:
return False
pkg = _resolve_package_name()
if pkg is not None:
return path_exists(f"src/{pkg}/api/v1") and path_exists(f"src/{pkg}/db/models")
return path_exists("src/api/v1") and path_exists("src/db/models")
def _detect_simple_type(deps_lower: str) -> ProjectType | None: def _detect_simple_type(deps_lower: str) -> ProjectType | None:
@ -262,14 +289,9 @@ def detect_project_type() -> ProjectType:
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = { STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
ProjectType.BACKEND: [ # BACKEND is resolved dynamically by ``_expected_backend_paths`` — the
"src/api/v1", # paths are ``src/<package_name>/...`` where ``<package_name>`` comes from
"src/db/models", # ``[project].name`` normalized (``my-project`` → ``my_project``).
"src/schemas",
"src/services",
"src/config/settings.py",
"main.py",
],
ProjectType.FULLSTACK: ["backend", "frontend"], ProjectType.FULLSTACK: ["backend", "frontend"],
ProjectType.CLI: ["src"], # src/<package>/ — checked generically ProjectType.CLI: ["src"], # src/<package>/ — checked generically
ProjectType.BOT: ["src/bot.py"], ProjectType.BOT: ["src/bot.py"],
@ -278,6 +300,21 @@ STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
} }
def _expected_backend_paths(pkg: str) -> list[str]:
"""Return the nested ``src/<package>/`` structure for the backend type.
``pkg`` is the normalized ``[project].name`` (``my-project`` ``my_project``).
"""
return [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config/settings.py",
"main.py",
]
# ── README delimiter tags (12) — ported from create-readme.ts:140-199 ─────── # ── README delimiter tags (12) — ported from create-readme.ts:140-199 ───────
@ -330,16 +367,14 @@ def _normalize_package_name(name: str) -> str:
def _check_flat_layout(ptype: ProjectType) -> CheckResult | None: def _check_flat_layout(ptype: ProjectType) -> CheckResult | None:
"""Check for flat ``src/`` layout (no nested package dir). """Check for flat ``src/`` layout (no nested package dir).
Applies only to CLI/UNKNOWN types (publishable-package ambitions). For Applies to ALL types (backend, fullstack, cli, bot, worker, unknown):
backend/bot/worker the ``src/api/``, ``src/bot.py`` layout is an app nested ``src/<package>/`` is the standard for publishable, reusable
contract, not a deprecated flat layout. packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
Returns None if ``src/`` does not exist, has a nested package with Returns None if ``src/`` does not exist, has a nested package with
``__init__.py``, or has a subdir matching ``[project].name`` (normalized). ``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
Returns a WARN CheckResult if flat layout detected. Returns a WARN CheckResult if flat layout detected.
""" """
if ptype not in {ProjectType.CLI, ProjectType.UNKNOWN}:
return None
src = REPO_ROOT / "src" src = REPO_ROOT / "src"
if not src.exists() or not src.is_dir(): if not src.exists() or not src.is_dir():
return None return None
@ -380,7 +415,20 @@ def _check_type_specific_structure(ptype: ProjectType) -> list[CheckResult]:
def check_structure(ptype: ProjectType) -> GroupResult: def check_structure(ptype: ProjectType) -> GroupResult:
"""Group 1: Structure — expected dirs/files per project type.""" """Group 1: Structure — expected dirs/files per project type."""
group = GroupResult(name="Структура") group = GroupResult(name="Структура")
expected = STRUCTURE_EXPECTED.get(ptype, []) if ptype == ProjectType.BACKEND:
pkg = _resolve_package_name()
if pkg is None:
group.checks.append(
CheckResult(
CheckStatus.WARN,
"auto-detect",
"нет pyproject.toml — cannot resolve package name",
)
)
return group
expected = _expected_backend_paths(pkg)
else:
expected = STRUCTURE_EXPECTED.get(ptype, [])
if not expected: if not expected:
group.checks.append( group.checks.append(
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта") CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
@ -436,7 +484,10 @@ def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) ->
def _api_dirs_for(ptype: ProjectType) -> list[Path]: def _api_dirs_for(ptype: ProjectType) -> 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."""
if ptype == ProjectType.BACKEND: if ptype == ProjectType.BACKEND:
root = REPO_ROOT / "src" / "api" / "v1" pkg = _resolve_package_name()
if pkg is None:
return []
root = REPO_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 = REPO_ROOT / "backend" / "src" / "api" / "v1" root = REPO_ROOT / "backend" / "src" / "api" / "v1"
@ -848,10 +899,11 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912,
hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else [] hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else []
src_dir_exists = (REPO_ROOT / "src").exists() src_dir_exists = (REPO_ROOT / "src").exists()
if src_pkg_dir_exists: if src_pkg_dir_exists:
# nested-layout: packages must reference src/<pkg> OR src (hatchling # nested-layout: packages must reference ``src/<pkg>`` — the only
# accepts both — `["src"]` treats src/ as package root when it # valid hatchling config. ``packages = ["src"]`` (flat convention)
# contains a single package dir matching [project].name). # is deprecated → WARN (treats ``src/`` as package root, prevents
valid_packages = {src_pkg_path, "src"} # ``my-lib = { git = "..." }`` reuse).
valid_packages = {src_pkg_path}
if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages): if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages):
group.checks.append( group.checks.append(
CheckResult( CheckResult(
@ -860,22 +912,31 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912,
f"packages={hatch_packages!r}", f"packages={hatch_packages!r}",
) )
) )
elif isinstance(hatch_packages, list) and "src" in hatch_packages:
group.checks.append(
CheckResult(
CheckStatus.WARN,
"[tool.hatch.build.targets.wheel]",
f"deprecated flat layout — use packages=['{src_pkg_path}'], "
f"got={hatch_packages!r}",
)
)
else: else:
group.checks.append( group.checks.append(
CheckResult( CheckResult(
CheckStatus.FAIL, CheckStatus.FAIL,
"[tool.hatch.build.targets.wheel]", "[tool.hatch.build.targets.wheel]",
f'ожидается packages=["{src_pkg_path}"] или ["src"], got={hatch_packages!r}', f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}',
) )
) )
elif src_dir_exists: elif src_dir_exists:
# src/ exists but no src/<pkg>/ — app-layout (backend/bot/worker) or # src/ exists but no src/<pkg>/ — deprecated flat layout for ALL
# flat-layout CLI. WARN: not a publishable package layout. # types. WARN: not a publishable package layout.
group.checks.append( group.checks.append(
CheckResult( CheckResult(
CheckStatus.WARN, CheckStatus.WARN,
"[tool.hatch.build.targets.wheel]", "[tool.hatch.build.targets.wheel]",
f"нет src/{expected_pkg}/ — app-layout (OK для backend/bot/worker)", f"нет src/{expected_pkg}/ — deprecated flat layout, use src/<package>/",
) )
) )
# No src/ — CLI without library-ambitions # No src/ — CLI without library-ambitions

View file

@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src"] packages = ["src/{{cookiecutter.project_name}}"]
# ── Ruff ────────────────────────────────────────────────────────────────── # ── Ruff ──────────────────────────────────────────────────────────────────

View file

@ -12,7 +12,7 @@ from tortoise import Tortoise
from {{ cookiecutter.project_name }}.config.settings import settings from {{ cookiecutter.project_name }}.config.settings import settings
_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" _MODELS_PATH = "{{ cookiecutter.project_name }}.db.models"
async def init_db() -> None: async def init_db() -> None:

View file

@ -42,7 +42,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src"] packages = ["src/{{cookiecutter.project_name}}"]
# ── Ruff ────────────────────────────────────────────────────────────────── # ── Ruff ──────────────────────────────────────────────────────────────────

View file

@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src"] packages = ["src/{{cookiecutter.project_name}}"]
# ── Ruff ────────────────────────────────────────────────────────────────── # ── Ruff ──────────────────────────────────────────────────────────────────

View file

@ -12,7 +12,7 @@ from tortoise import Tortoise
from {{ cookiecutter.project_name }}.config.settings import settings from {{ cookiecutter.project_name }}.config.settings import settings
_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" _MODELS_PATH = "{{ cookiecutter.project_name }}.db.models"
async def init_db() -> None: async def init_db() -> None:

View file

@ -390,6 +390,73 @@ def test_backend_pytest_asyncio_auto(render):
assert 'asyncio_mode = "auto"' in pyproject assert 'asyncio_mode = "auto"' in pyproject
# ── nested src/<package>/ layout (issue #241) ──────────────────────────────────
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "be"}),
("cli", {"project_name": "cl"}),
],
)
def test_pyproject_hatch_packages_nested(render):
"""Hatch packages must be ``["src/<package>"]`` (NOT ``["src"]``).
Issue #241: ``packages = ["src"]`` (flat convention) is deprecated —
only ``packages = ["src/<package>"]`` is valid for a publishable,
reusable-as-git-dep package.
"""
pyproject = (render / "pyproject.toml").read_text()
pkg = extra_context_value(render, "project_name")
assert f'packages = ["src/{pkg}"]' in pyproject, (
f"expected packages=['src/{pkg}'], got flat or wrong packages"
)
assert 'packages = ["src"]' not in pyproject, "flat packages=['src'] is deprecated"
@pytest.mark.parametrize(
"template_name, extra_context",
[("fullstack", {"project_name": "fs", "use_db": "yes"})],
)
def test_pyproject_hatch_packages_nested_fullstack(render):
"""Fullstack backend pyproject must use nested packages (issue #241)."""
pyproject = (render / "backend" / "pyproject.toml").read_text()
assert 'packages = ["src/fs"]' in pyproject
assert 'packages = ["src"]' not in pyproject
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "be", "use_db": "yes"}),
("fullstack", {"project_name": "fs", "use_db": "yes"}),
],
)
def test_db_connection_models_path_no_src_prefix(render, template_name):
"""``_MODELS_PATH`` must be ``<package>.db.models`` (NOT ``src.<package>...``).
Issue #241: with nested ``src/<package>/`` layout the import path is
``<package>.db.models`` (no ``src.`` prefix there is no ``src`` package).
"""
if template_name == "backend":
conn = render / "src" / "be" / "db" / "connection.py"
else:
conn = render / "backend" / "src" / "fs" / "db" / "connection.py"
text = conn.read_text()
assert 'be.db.models"' in text or 'fs.db.models"' in text
assert "src." not in text, "_MODELS_PATH must not use src. prefix (nested layout)"
def extra_context_value(render, key):
"""Recover the extra_context value for ``key`` from the rendered project.
The fixtures pass ``project_name`` explicitly; we infer it back from the
top-level dir name (cookiecutter uses it as the project dir).
"""
return render.name
# ── project-status oracle compatibility ─────────────────────────────────────── # ── project-status oracle compatibility ───────────────────────────────────────

View file

@ -154,22 +154,29 @@ def _write_pyproject(
def _make_backend_repo(tmp_path: Path) -> None: def _make_backend_repo(tmp_path: Path) -> None:
"""Create a minimal backend-type repo skeleton in tmp_path.""" """Create a minimal backend-type repo skeleton in tmp_path.
Uses the nested ``src/<package>/api/v1`` layout (issue #241): the package
name is ``test_repo`` (normalized from the default ``[project].name``
``test-repo``).
"""
pkg = "test_repo"
for rel in [ for rel in [
"src/api/v1", f"src/{pkg}/api/v1",
"src/db/models", f"src/{pkg}/db/models",
"src/schemas", f"src/{pkg}/schemas",
"src/services", f"src/{pkg}/services",
"src/config", f"src/{pkg}/config",
"tests", "tests",
]: ]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "src" / pkg / "__init__.py").write_text("")
(tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text( (tmp_path / "main.py").write_text(
"from contextlib import asynccontextmanager\n" "from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n" "@asynccontextmanager\nasync def lifespan(app): yield\n"
) )
(tmp_path / "src/api/v1/users.py").write_text( (tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n" "from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n" "@router.get('/users')\nasync def list_users():\n return []\n"
) )
@ -325,21 +332,26 @@ def test_check_structure_backend_missing_dir(tmp_path):
) )
group = ps.check_structure(ps.ProjectType.BACKEND) group = ps.check_structure(ps.ProjectType.BACKEND)
assert group.overall() == ps.CheckStatus.FAIL assert group.overall() == ps.CheckStatus.FAIL
assert any(c.name == "src/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks) assert any(
c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
def test_check_structure_backend_no_lifespan(tmp_path): def test_check_structure_backend_no_lifespan(tmp_path):
pkg = "test_repo"
for rel in [ for rel in [
"src/api/v1", f"src/{pkg}/api/v1",
"src/db/models", f"src/{pkg}/db/models",
"src/schemas", f"src/{pkg}/schemas",
"src/services", f"src/{pkg}/services",
"src/config", f"src/{pkg}/config",
"tests", "tests",
]: ]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "src" / pkg / "__init__.py").write_text("")
(tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text("app = None\n") (tmp_path / "main.py").write_text("app = None\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
group = ps.check_structure(ps.ProjectType.BACKEND) group = ps.check_structure(ps.ProjectType.BACKEND)
assert any( assert any(
c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks
@ -376,12 +388,21 @@ def test_thin_routes_ok(tmp_path):
def test_thin_routes_over_limit(tmp_path): def test_thin_routes_over_limit(tmp_path):
for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]: pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True) (tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src/config/settings.py").write_text("settings = {}\n") (tmp_path / "src" / pkg / "__init__.py").write_text("")
(tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text("app = None\n") (tmp_path / "main.py").write_text("app = None\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
body = "\n x = 1\n" * 60 body = "\n x = 1\n" * 60
(tmp_path / "src/api/v1/users.py").write_text( (tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n" "from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():" "@router.get('/users')\nasync def list_users():"
f"{body} return []\n" f"{body} return []\n"
@ -844,21 +865,28 @@ def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path):
) )
def test_check_pyproject_check2_hatch_wheel_ok_with_src_root(tmp_path): def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path):
"""packages=["src"] is accepted when src/<pkg>/ exists (hatchling convention).""" """packages=["src"] is deprecated flat layout → WARN (issue #241).
Inverted: ``packages = ["src"]`` was previously accepted as OK (hatchling
treats ``src/`` as package root). Now only ``packages = ["src/<pkg>"]``
is valid ``["src"]`` is a deprecated flat layout.
"""
(tmp_path / "src").mkdir() (tmp_path / "src").mkdir()
(tmp_path / "src" / "test_repo").mkdir() (tmp_path / "src" / "test_repo").mkdir()
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("") (tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"]) _write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
group = ps.check_pyproject(ps.ProjectType.BACKEND) group = ps.check_pyproject(ps.ProjectType.BACKEND)
assert any( assert any(
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK c.name == "[tool.hatch.build.targets.wheel]"
and c.status == ps.CheckStatus.WARN
and "deprecated flat layout" in c.detail
for c in group.checks for c in group.checks
) )
def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path): def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path):
"""src/ exists but no src/<pkg>/ → WARN (app-layout for backend).""" """src/ exists but no src/<pkg>/ → WARN (deprecated flat layout, issue #241)."""
(tmp_path / "src").mkdir() (tmp_path / "src").mkdir()
(tmp_path / "src" / "api").mkdir() (tmp_path / "src" / "api").mkdir()
_write_full_pyproject(tmp_path, src_pkg_exists=False) _write_full_pyproject(tmp_path, src_pkg_exists=False)
@ -866,7 +894,7 @@ def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path):
assert any( assert any(
c.name == "[tool.hatch.build.targets.wheel]" c.name == "[tool.hatch.build.targets.wheel]"
and c.status == ps.CheckStatus.WARN and c.status == ps.CheckStatus.WARN
and "app-layout" in c.detail and "deprecated flat layout" in c.detail
for c in group.checks for c in group.checks
) )
@ -1176,10 +1204,30 @@ def test_check_structure_flat_layout_warn_for_cli(tmp_path):
) )
def test_check_structure_no_flat_warn_for_backend(tmp_path): def test_check_structure_flat_warn_for_backend(tmp_path):
"""Backend with src/api/ (app-layout) → no flat-layout WARN.""" """Backend with flat src/api/ (no nested package) → WARN flat src/ layout.
Inverted by issue #241: nested ``src/<package>/`` is the standard for
ALL types (backend included). Flat ``src/`` is deprecated WARN.
"""
for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src/config/settings.py").write_text("settings = {}\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
group = ps.check_structure(ps.ProjectType.BACKEND)
assert any(
c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_structure_backend_nested_ok(tmp_path):
"""Backend with nested src/<pkg>/api/v1 → OK (no flat-layout WARN)."""
_make_backend_repo(tmp_path) _make_backend_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.BACKEND) group = ps.check_structure(ps.ProjectType.BACKEND)
assert group.overall() == ps.CheckStatus.OK, (
f"expected OK, got {group.overall()}: "
+ ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
)
assert not any(c.name == "flat src/ layout" for c in group.checks) assert not any(c.name == "flat src/ layout" for c in group.checks)