fix(scripts): project-status fullstack backend/ path + db/models + no_db removal (#276)

* refactor(contract): drop NO_DB_SUPPORTED set (issue #274)

* fix(scripts): route backend checks via backend_root + auto-detect db/models from deps

* refactor(templates): drop no_db marker write from cookiecutter hooks

* test(scripts): cover db/models auto-detect + fullstack all-groups + use_db=no detect

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-05 05:44:42 +03:00 committed by GitHub
parent fa6fc9ecbd
commit c9198a40a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 363 additions and 190 deletions

View file

@ -16,7 +16,7 @@ Usage:
Project types (auto-detected): Project types (auto-detected):
fullstack ``frontend/`` dir (SvelteKit) + ``backend/`` dir fullstack ``frontend/`` dir (SvelteKit) + ``backend/`` dir
backend ``src/<package>/api/v1/`` + ``src/<package>/db/models/`` + fastapi backend ``src/<package>/api/v1/`` + fastapi (db/models auto-detected from deps)
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
@ -75,7 +75,6 @@ DEFAULT_CONFIG: dict[str, Any] = {
"route_line_limit": 50, "route_line_limit": 50,
"min_test_count": 1, "min_test_count": 1,
"require_branch_protection": False, "require_branch_protection": False,
"no_db": False,
} }
@ -84,10 +83,6 @@ def load_config(root: Path | None = None) -> dict[str, Any]:
Falls back to ``DEFAULT_CONFIG`` if the section or file is missing. Falls back to ``DEFAULT_CONFIG`` if the section or file is missing.
Uses ``tomllib`` (stdlib, Python 3.11+). Reads only never writes. Uses ``tomllib`` (stdlib, Python 3.11+). Reads only never writes.
Supports ``no_db: bool`` when true, BACKEND skips the ``db/models``
structure check (cookiecutter ``use_db=no`` writes this marker in
``post_gen_project.py``).
""" """
base = root if root is not None else REPO_ROOT base = root if root is not None else REPO_ROOT
cfg: dict[str, Any] = dict(DEFAULT_CONFIG) cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
@ -155,15 +150,22 @@ class GroupResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class RepoCtx: class RepoCtx:
"""Immutable repo context: root path + config thresholds. """Immutable repo context: root path + config thresholds + backend root.
Passed explicitly to all check-functions to avoid module-level globals Passed explicitly to all check-functions to avoid module-level globals
(``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in (``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in
``pipeline-status.py``. ``pipeline-status.py``.
``backend_root`` is the directory where the backend source tree lives:
``ctx.root / "backend"`` for FULLSTACK, ``ctx.root`` for all other types.
Computed in ``main()`` / ``detect_project_type`` after type detection.
Root-level files (ci.yml, README, LICENSE, dependabot, frontend/) stay
under ``ctx.root`` regardless of type.
""" """
root: Path root: Path
config: dict[str, Any] config: dict[str, Any]
backend_root: Path = Path() # overridable; set by main()/detect_project_type
# ── helpers ────────────────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────────────────
@ -175,6 +177,31 @@ def run_cmd(args: list[str]) -> tuple[int, str, str]:
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr
def _backend_root_for(ptype: ProjectType, ctx: RepoCtx) -> Path:
"""Return the backend root dir for the given project type.
FULLSTACK ``ctx.root / "backend"`` (backend source tree lives there);
all other types ``ctx.root``. Used to route backend-source checks
(lifespan, scattered models, pyproject, tests, ...) to the right tree
without touching the root-level checks (ci.yml, README, frontend/).
Mirrors the ``backend_ctx = RepoCtx(root=ctx.root / "backend")`` pattern
in ``_api_dirs_for`` (issue #241 / #274).
"""
if ptype == ProjectType.FULLSTACK:
return ctx.root / "backend"
return ctx.root
def _backend_ctx_for(ptype: ProjectType, ctx: RepoCtx) -> RepoCtx:
"""Build a RepoCtx rooted at the backend root (FULLSTACK → ``backend/``).
Convenience wrapper: returns a fresh context with ``root=backend_root`` so
backend-source checks (pyproject, hatch packages, python-version, ...) read
from the right tree without each function re-deriving the path.
"""
return RepoCtx(root=_backend_root_for(ptype, ctx), config=ctx.config)
def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool: def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool:
"""True if ``root / rel`` exists (``ctx`` preferred, else module global).""" """True if ``root / rel`` exists (``ctx`` preferred, else module global)."""
root = ctx.root if ctx is not None else REPO_ROOT root = ctx.root if ctx is not None else REPO_ROOT
@ -258,9 +285,38 @@ def _resolve_package_name(ctx: RepoCtx | None = None) -> str | None:
return _normalize_package_name(name) return _normalize_package_name(name)
# DB-capability markers — presence of any of these in ``[project.dependencies]``
# marks the project as a db-project (``db/models`` required). Auto-detection
# replaces the former ``[tool.project-status] no_db`` marker (issue #274).
DB_DEP_MARKERS: tuple[str, ...] = (
"tortoise-orm",
"sqlalchemy",
"sqlmodel",
"alembic",
"aerich",
"pony",
"databases",
)
def _is_db_project(deps_lower: str) -> bool:
"""True if any DB-capability marker is present in the deps string.
Used to decide whether ``db/models`` is required: a backend/fullstack
project without a DB dep (cookiecutter ``use_db=no``) does not need
``db/models`` and the structure check yields WARN (not FAIL) when absent.
"""
return any(marker in deps_lower for marker in DB_DEP_MARKERS)
def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool: def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
"""True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps. """True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.
Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the
nested ``src/<package>/api/v1`` dir. It does NOT require ``db/models``
that is a separate DB-capability check (``_is_db_project`` + the
``db/models`` structure check, which is WARN when absent).
Falls back to flat ``src/api/v1`` detection (with the caller surfacing Falls back to flat ``src/api/v1`` detection (with the caller surfacing
a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing. a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
""" """
@ -268,8 +324,8 @@ def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
return False return False
pkg = _resolve_package_name(ctx) pkg = _resolve_package_name(ctx)
if pkg is not None: if pkg is not None:
return path_exists(f"src/{pkg}/api/v1", ctx) and path_exists(f"src/{pkg}/db/models", ctx) return path_exists(f"src/{pkg}/api/v1", ctx)
return path_exists("src/api/v1", ctx) and path_exists("src/db/models", ctx) return path_exists("src/api/v1", ctx)
def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None: def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None:
@ -315,13 +371,13 @@ def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType:
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED
def _expected_backend_paths(pkg: str, no_db: bool = False) -> list[str]: def _expected_backend_paths(pkg: str, db_required: bool) -> list[str]:
"""Return the nested ``src/<package>/`` structure for the backend type. """Return the nested ``src/<package>/`` structure for the backend type.
``pkg`` is the normalized ``[project].name`` (``my-project`` ``my_project``). ``pkg`` is the normalized ``[project].name`` (``my-project`` ``my_project``).
When ``no_db`` is true (``[tool.project-status] no_db = true`` in When ``db_required`` is false (no DB dep in pyproject, cookiecutter
pyproject.toml), ``db/models`` is excluded from the expected paths ``use_db=no``), ``db/models`` is excluded from the expected paths the
cookiecutter ``use_db=no`` removes the ``db/`` dir. caller surfaces a WARN separately when a db-project is missing it.
""" """
paths = [ paths = [
f"src/{pkg}/api/v1", f"src/{pkg}/api/v1",
@ -331,7 +387,7 @@ def _expected_backend_paths(pkg: str, no_db: bool = False) -> list[str]:
f"src/{pkg}/config/settings.py", f"src/{pkg}/config/settings.py",
"main.py", "main.py",
] ]
if no_db: if not db_required:
paths.pop(1) # remove ``src/<pkg>/db/models`` paths.pop(1) # remove ``src/<pkg>/db/models``
return paths return paths
@ -358,12 +414,20 @@ README_DELIMITERS: list[str] = [
# ── check group 1: structure ───────────────────────────────────────────────── # ── check group 1: structure ─────────────────────────────────────────────────
def _check_backend_lifespan(ctx: RepoCtx) -> CheckResult: def _check_backend_lifespan(backend_root: Path) -> CheckResult:
"""Check main.py has a lifespan handler (backend-specific).""" """Check main.py has a lifespan handler (backend-specific).
main = read_text("main.py", ctx)
if main is None: ``backend_root`` is the backend source tree root (``ctx.root`` for BACKEND,
``ctx.root / "backend"`` for FULLSTACK).
"""
main = backend_root / "main.py"
if not main.exists():
return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет")
if "lifespan" in main: try:
content = main.read_text(encoding="utf-8-sig")
except OSError:
return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет")
if "lifespan" in content:
return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден") return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден")
return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден") return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден")
@ -392,14 +456,20 @@ def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None:
nested ``src/<package>/`` is the standard for publishable, reusable nested ``src/<package>/`` is the standard for publishable, reusable
packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated. packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
For FULLSTACK the backend source lives under ``backend/``, so the check
inspects ``backend/src/`` (issue #274). Root-level ``frontend/`` is not
affected.
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.
""" """
src = ctx.root / "src" backend_root = _backend_root_for(ptype, ctx)
src = backend_root / "src"
if not src.exists() or not src.is_dir(): if not src.exists() or not src.is_dir():
return None return None
pyproject = parse_pyproject(ctx) backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
pyproject = parse_pyproject(backend_ctx)
project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {}
proj_name = project.get("name") if isinstance(project, dict) else None proj_name = project.get("name") if isinstance(project, dict) else None
expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None
@ -467,13 +537,17 @@ def _file_imports_tortoise_or_models(source: str, pkg: str) -> bool:
return False return False
def _scan_file_for_models(py_file: Path, ctx: RepoCtx, pkg: str) -> list[CheckResult]: def _scan_file_for_models(py_file: Path, backend_root: Path, pkg: str) -> list[CheckResult]:
"""Scan a single ``.py`` file for ORM ``class X(Model)`` definitions. """Scan a single ``.py`` file for ORM ``class X(Model)`` definitions.
Returns one ``WARN`` CheckResult per offending class (with the file's Returns one ``WARN`` CheckResult per offending class (with the file's
relative path). Returns ``[]`` if the file does not import tortoise or relative path). Returns ``[]`` if the file does not import tortoise or
the package's own ``db.models`` module (false-positive guard), or if no the package's own ``db.models`` module (false-positive guard), or if no
class inherits from ``Model``. class inherits from ``Model``.
``backend_root`` is used for the relative path in the WARN detail (issue
#274): FULLSTACK reports paths relative to ``backend/``, not the repo
root, so the message matches what the developer sees in their tree.
""" """
try: try:
source = py_file.read_text(encoding="utf-8-sig") source = py_file.read_text(encoding="utf-8-sig")
@ -486,7 +560,7 @@ def _scan_file_for_models(py_file: Path, ctx: RepoCtx, pkg: str) -> list[CheckRe
except SyntaxError: except SyntaxError:
return [] return []
results: list[CheckResult] = [] results: list[CheckResult] = []
rel = py_file.relative_to(ctx.root) rel = py_file.relative_to(backend_root)
for node in ast.walk(tree): for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef): if not isinstance(node, ast.ClassDef):
continue continue
@ -513,21 +587,20 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
import ``tortoise`` or the package's own ``db.models`` module are skipped import ``tortoise`` or the package's own ``db.models`` module are skipped
(suppresses false positives from unrelated libraries defining ``Model``). (suppresses false positives from unrelated libraries defining ``Model``).
Skips for non-backend/fullstack types, flat layouts (no ``src/<package>/``), For FULLSTACK the backend lives under ``backend/`` (issue #274): the
and repos without a resolvable package name. Returns ``[]`` in all skip package name is resolved against ``backend/pyproject.toml`` and the scan
cases so the caller appends nothing. targets ``backend/src/<package>/``. Skips for non-backend/fullstack types,
flat layouts (no ``src/<package>/``), and repos without a resolvable
package name. Returns ``[]`` in all skip cases so the caller appends nothing.
""" """
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}: if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
return [] return []
pkg = _resolve_package_name(ctx) backend_root = _backend_root_for(ptype, ctx)
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
pkg = _resolve_package_name(backend_ctx)
if pkg is None: if pkg is None:
return [] return []
# For FULLSTACK, the backend lives under ``backend/``; for BACKEND, at root. src_pkg = backend_root / "src" / pkg
src_pkg = ctx.root / "src" / pkg
if ptype == ProjectType.FULLSTACK:
backend_src = ctx.root / "backend" / "src" / pkg
if backend_src.is_dir():
src_pkg = backend_src
if not src_pkg.is_dir(): if not src_pkg.is_dir():
return [] return []
models_dir = src_pkg / "db" / "models" models_dir = src_pkg / "db" / "models"
@ -535,7 +608,7 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
for py_file in src_pkg.rglob("*.py"): for py_file in src_pkg.rglob("*.py"):
if models_dir in py_file.parents or py_file == models_dir: if models_dir in py_file.parents or py_file == models_dir:
continue continue
results.extend(_scan_file_for_models(py_file, ctx, pkg)) results.extend(_scan_file_for_models(py_file, backend_root, pkg))
return results return results
@ -587,13 +660,15 @@ def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]: def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
"""Type-specific extra checks beyond the expected dirs list.""" """Type-specific extra checks beyond the expected dirs list."""
results: list[CheckResult] = [] results: list[CheckResult] = []
backend_root = _backend_root_for(ptype, ctx)
if ptype == ProjectType.BACKEND: if ptype == ProjectType.BACKEND:
results.append(_check_backend_lifespan(ctx)) results.append(_check_backend_lifespan(backend_root))
if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx): if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx):
results.append( results.append(
CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен") CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")
) )
if ptype == ProjectType.FULLSTACK: if ptype == ProjectType.FULLSTACK:
results.append(_check_backend_lifespan(backend_root))
frontend_stack = _check_frontend_stack(ctx) frontend_stack = _check_frontend_stack(ctx)
if frontend_stack is not None: if frontend_stack is not None:
results.append(frontend_stack) results.append(frontend_stack)
@ -607,10 +682,21 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che
def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 1: Structure — expected dirs/files per project type.""" """Group 1: Structure — expected dirs/files per project type.
For BACKEND and FULLSTACK the expected paths are rooted at the backend
source tree (``ctx.root`` for BACKEND, ``ctx.root / "backend"`` for
FULLSTACK issue #274). ``db/models`` is auto-detected from
``[project.dependencies]``: if any DB marker (tortoise-orm, sqlalchemy,
sqlmodel, alembic, aerich, pony, databases) is present, ``db/models`` is
expected and a missing dir yields WARN (not FAIL issue #275); if no
DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no).
"""
group = GroupResult(name="Структура") group = GroupResult(name="Структура")
if ptype == ProjectType.BACKEND: if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
pkg = _resolve_package_name(ctx) backend_root = _backend_root_for(ptype, ctx)
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
pkg = _resolve_package_name(backend_ctx)
if pkg is None: if pkg is None:
group.checks.append( group.checks.append(
CheckResult( CheckResult(
@ -620,8 +706,29 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
) )
) )
return group return group
no_db = bool(ctx.config.get("no_db", False)) deps_raw = parse_pyproject(backend_ctx).get("project", {}).get("dependencies", [])
expected = _expected_backend_paths(pkg, no_db=no_db) deps_lower = (
" ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else ""
)
db_required = _is_db_project(deps_lower)
expected = _expected_backend_paths(pkg, db_required=db_required)
for rel in expected:
status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.FAIL
detail = "существует" if status == CheckStatus.OK else "отсутствует"
group.checks.append(CheckResult(status, rel, detail))
if db_required:
db_models_rel = f"src/{pkg}/db/models"
if not (backend_root / db_models_rel).exists():
# Replace the FAIL above with a WARN — db/models missing is
# not a contract violation (issue #275: all-WARN contract).
group.checks = [
c
if c.name != db_models_rel
else CheckResult(
CheckStatus.WARN, db_models_rel, "отсутствует (db-проект без db/models)"
)
for c in group.checks
]
else: else:
expected = STRUCTURE_EXPECTED.get(ptype.value, []) expected = STRUCTURE_EXPECTED.get(ptype.value, [])
if not expected: if not expected:
@ -763,7 +870,11 @@ def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult]
def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult: def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult:
"""Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN).""" """Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN).
For FULLSTACK the api dirs live under ``backend/src/<pkg>/api/v1`` and
WARN paths are reported relative to ``backend/`` (issue #274).
"""
_ = fast # unused here, accepted for signature uniformity _ = fast # unused here, accepted for signature uniformity
group = GroupResult(name="Тонкие роуты") group = GroupResult(name="Тонкие роуты")
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}: if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
@ -777,7 +888,8 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G
CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена") CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена")
) )
return group return group
_append_route_checks(group, api_dirs, ctx) backend_ctx = _backend_ctx_for(ptype, ctx)
_append_route_checks(group, api_dirs, backend_ctx)
return group return group
@ -817,9 +929,14 @@ def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx)
def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.""" """Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.
Reads ``pyproject.toml`` from the backend root (FULLSTACK
``backend/pyproject.toml`` issue #274).
"""
group = GroupResult(name="Качество кода") group = GroupResult(name="Качество кода")
pyproject = parse_pyproject(ctx) backend_ctx = _backend_ctx_for(ptype, ctx)
pyproject = parse_pyproject(backend_ctx)
tools = pyproject.get("tool", {}) tools = pyproject.get("tool", {})
for tool_name in ("ruff", "mypy"): for tool_name in ("ruff", "mypy"):
if tool_name in tools: if tool_name in tools:
@ -944,9 +1061,14 @@ def _check_stub_files(test_files: list[Path]) -> CheckResult:
def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.""" """Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.
For BACKEND and FULLSTACK the tests live under the backend root (FULLSTACK
``backend/tests/`` issue #274).
"""
group = GroupResult(name="Тесты") group = GroupResult(name="Тесты")
tests_dir = ctx.root / "tests" backend_root = _backend_root_for(ptype, ctx)
tests_dir = backend_root / "tests"
if not tests_dir.exists(): if not tests_dir.exists():
group.checks.append( group.checks.append(
CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует") CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует")
@ -1157,9 +1279,14 @@ def check_infra(
def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).""" """Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).
Reads ``pyproject.toml`` from the backend root (FULLSTACK
``backend/pyproject.toml`` issue #274).
"""
group = GroupResult(name="Coverage") group = GroupResult(name="Coverage")
pyproject = parse_pyproject(ctx) backend_ctx = _backend_ctx_for(ptype, ctx)
pyproject = parse_pyproject(backend_ctx)
cov = pyproject.get("tool", {}).get("coverage", {}) cov = pyproject.get("tool", {}).get("coverage", {})
run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {} run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {}
sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else [] sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else []
@ -1495,9 +1622,15 @@ def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each
of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the
file is missing single WARN; parse error single FAIL. file is missing single WARN; parse error single FAIL.
Reads ``pyproject.toml`` from the backend root (FULLSTACK
``backend/pyproject.toml`` issue #274); root-level files checked by
sub-checks (``.pre-commit-config.yaml``, ``uv.lock``, ``.python-version``,
``ruff.toml``, ``mypy.ini``) also resolve against the backend root.
""" """
group = GroupResult(name="Pyproject") group = GroupResult(name="Pyproject")
data = _load_pyproject(ctx.root) backend_root = _backend_root_for(ptype, ctx)
data = _load_pyproject(backend_root)
if data is None: if data is None:
group.checks.append( group.checks.append(
CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks") CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks")
@ -1515,7 +1648,7 @@ def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
tools = data.get("tool", {}) if isinstance(data, dict) else {} tools = data.get("tool", {}) if isinstance(data, dict) else {}
pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {}
for check_fn in PYPROJECT_CHECKS: for check_fn in PYPROJECT_CHECKS:
group.checks.append(check_fn(data, ctx.root, ptype, project, tools, pytest_opts)) group.checks.append(check_fn(data, backend_root, ptype, project, tools, pytest_opts))
return group return group
@ -1652,6 +1785,10 @@ def main() -> None:
ctx = RepoCtx(root=root, config=load_config(root)) ctx = RepoCtx(root=root, config=load_config(root))
ptype = detect_project_type(ctx) ptype = detect_project_type(ctx)
# Populate ``backend_root`` (FULLSTACK → ``root / "backend"``, else root)
# so checks reading backend-source files resolve to the right tree. The
# frozen ctx is rebuilt with the resolved field (issue #274).
ctx = RepoCtx(root=ctx.root, config=ctx.config, backend_root=_backend_root_for(ptype, ctx))
groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None) groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None)
print(format_output(ptype, groups)) print(format_output(ptype, groups))
if strict and any(g.overall() == CheckStatus.FAIL for g in groups): if strict and any(g.overall() == CheckStatus.FAIL for g in groups):

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Project contract: single source of truth for project types, stacks, """Project contract: single source of truth for project types, stacks,
expected structure, frontend markers and no_db support. expected structure, frontend markers.
Imported by both ``spec-status.py`` and ``project-status.py`` via Imported by both ``spec-status.py`` and ``project-status.py`` via
``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation). ``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation).
@ -12,7 +12,6 @@ Contract symbols:
STACK_REQUIRED dict[type -> list[str]] of mandatory stack items STACK_REQUIRED dict[type -> list[str]] of mandatory stack items
STRUCTURE_EXPECTED dict[type -> list[str]] of expected top-level dirs/files STRUCTURE_EXPECTED dict[type -> list[str]] of expected top-level dirs/files
FRONTEND_STACK_MARKERS dict with keys for fullstack frontend detection FRONTEND_STACK_MARKERS dict with keys for fullstack frontend detection
NO_DB_SUPPORTED set[str] of types that support no_db=true
""" """
from __future__ import annotations from __future__ import annotations
@ -79,9 +78,3 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
"fullstack_package_deps": ["tailwindcss", "bits-ui"], "fullstack_package_deps": ["tailwindcss", "bits-ui"],
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"], "fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
} }
# Project types that support ``[tool.project-status] no_db = true`` in
# pyproject.toml (skip ``db/models`` check). Backend is the primary case;
# fullstack is included for completeness (latent — _check_scattered_models
# already early-returns when db/models absent).
NO_DB_SUPPORTED: set[str] = {"backend", "fullstack"}

View file

@ -6,6 +6,10 @@ so the rendered tree only contains the parts the user asked for.
- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``, - ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``,
``models/user.py`` (hashed_password), and strip the auth dependency wiring. ``models/user.py`` (hashed_password), and strip the auth dependency wiring.
- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``. - ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``.
The DB dep (``tortoise-orm``/``asyncpg``) is conditionally rendered in
``pyproject.toml`` (jinja if-block on use_db), so no marker needs
to be written: the project-status oracle auto-detects db-projects from
deps (issue #274).
""" """
from __future__ import annotations from __future__ import annotations
@ -33,7 +37,7 @@ def main() -> None:
if use_auth == "no": if use_auth == "no":
_remove(f"src/{pkg}/api/v1/routes/auth.py") _remove(f"src/{pkg}/api/v1/routes/auth.py")
_remove(f"src/{pkg}/services/auth_service.py") _remove(f"src/{pkg}/services/auth_service.py")
_remove(f"tests/test_auth.py") _remove("tests/test_auth.py")
if use_db == "no": if use_db == "no":
# db is the root cause for the broken-conditional findings: files # db is the root cause for the broken-conditional findings: files
@ -46,21 +50,14 @@ def main() -> None:
_remove(f"src/{pkg}/api/v1/routes/users.py") _remove(f"src/{pkg}/api/v1/routes/users.py")
_remove(f"src/{pkg}/api/v1/dependencies.py") _remove(f"src/{pkg}/api/v1/dependencies.py")
_remove(f"src/{pkg}/schemas/user.py") _remove(f"src/{pkg}/schemas/user.py")
_remove(f"tests/unit/test_user.py") _remove("tests/unit/test_user.py")
_remove(f"tests/unit/test_user_service.py") _remove("tests/unit/test_user_service.py")
_remove(f"tests/api/test_users.py") _remove("tests/api/test_users.py")
if use_auth == "yes": if use_auth == "yes":
# auth_service imports User; strip it and its wiring too. # auth_service imports User; strip it and its wiring too.
_remove(f"src/{pkg}/api/v1/routes/auth.py") _remove(f"src/{pkg}/api/v1/routes/auth.py")
_remove(f"src/{pkg}/services/auth_service.py") _remove(f"src/{pkg}/services/auth_service.py")
_remove(f"tests/test_auth.py") _remove("tests/test_auth.py")
# Write ``no_db = true`` into the existing ``[tool.project-status]``
# section so the project-status oracle skips the ``db/models``
# structure check for this no-db project (issue #266: no_db polarity
# fix). Append the key to the section at the end of the file.
pyproject = PROJECT_DIR / "pyproject.toml"
with open(pyproject, "a") as f:
f.write("\nno_db = true\n")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -29,7 +29,7 @@ def main() -> None:
if use_auth == "no": if use_auth == "no":
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py") _remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
_remove(f"backend/src/{pkg}/services/auth_service.py") _remove(f"backend/src/{pkg}/services/auth_service.py")
_remove(f"backend/tests/test_auth.py") _remove("backend/tests/test_auth.py")
if use_db == "no": if use_db == "no":
# db is the root cause for the broken-conditional findings: files # db is the root cause for the broken-conditional findings: files
@ -42,21 +42,14 @@ def main() -> None:
_remove(f"backend/src/{pkg}/api/v1/routes/users.py") _remove(f"backend/src/{pkg}/api/v1/routes/users.py")
_remove(f"backend/src/{pkg}/api/v1/dependencies.py") _remove(f"backend/src/{pkg}/api/v1/dependencies.py")
_remove(f"backend/src/{pkg}/schemas/user.py") _remove(f"backend/src/{pkg}/schemas/user.py")
_remove(f"backend/tests/unit/test_user.py") _remove("backend/tests/unit/test_user.py")
_remove(f"backend/tests/unit/test_user_service.py") _remove("backend/tests/unit/test_user_service.py")
_remove(f"backend/tests/api/test_users.py") _remove("backend/tests/api/test_users.py")
if use_auth == "yes": if use_auth == "yes":
# auth_service imports User; strip it and its wiring too. # auth_service imports User; strip it and its wiring too.
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py") _remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
_remove(f"backend/src/{pkg}/services/auth_service.py") _remove(f"backend/src/{pkg}/services/auth_service.py")
_remove(f"backend/tests/test_auth.py") _remove("backend/tests/test_auth.py")
# Write ``no_db = true`` into the existing ``[tool.project-status]``
# section so the project-status oracle skips the ``db/models``
# structure check for this no-db project (issue #266: no_db polarity
# fix). Append the key to the section at the end of the file.
pyproject = PROJECT_DIR / "backend" / "pyproject.toml"
with open(pyproject, "a") as f:
f.write("\nno_db = true\n")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -613,18 +613,57 @@ def test_infra_checks_present(render):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"template_name, extra_context", "template_name, extra_context",
[("fullstack", {"project_name": "fs"})], [
("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}),
("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}),
],
) )
def test_fullstack_passes_project_status_structure(render): def test_fullstack_passes_project_status_all_groups(render, extra_context):
"""Fullstack is detected as fullstack (backend/ + frontend/ present).""" """Fullstack template passes the project-status groups (issue #274).
With ``use_db=yes`` all backend-source checks (Structure, Тонкие роуты,
Качество кода, Тесты, Pyproject, Coverage, Infra) resolve against
``backend/`` and yield OK/WARN (never FAIL). With ``use_db=no`` the db
dep is absent from ``pyproject.toml``, so ``db/models`` is auto-skipped
(no FAIL) and the project still detects as FULLSTACK; ``Тесты`` may FAIL
because the use_db=no hook strips all db-related test files (test_auth.py
included when use_auth=yes, since auth_service imports the removed User).
README is intentionally absent (issue #269 → WARN, non-blocking) and is
not asserted here. Branch protection is skipped via ``--fast`` (WARN).
"""
ptype = ps.ProjectType.FULLSTACK ptype = ps.ProjectType.FULLSTACK
ctx = ps.RepoCtx(root=render, config=ps.load_config(render)) ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
detected = ps.detect_project_type(ctx) detected = ps.detect_project_type(ctx)
assert detected == ptype assert detected == ptype
_, checks = _run_status_checks(render) ctx = ps.RepoCtx(root=render, config=ctx.config, backend_root=ps._backend_root_for(ptype, ctx))
by_name = {c.name: c for c in checks} groups = ps.run_all_checks(ptype, ctx, fast=True)
assert by_name["backend"].status == ps.CheckStatus.OK by_name = {g.name: g for g in groups}
assert by_name["frontend"].status == ps.CheckStatus.OK # Structure: backend/ + frontend/ present, nested src/<pkg>/api/v1 OK.
structure = by_name["Структура"]
assert structure.overall() != ps.CheckStatus.FAIL, (
f"Структура FAIL: {[c for c in structure.checks if c.status == ps.CheckStatus.FAIL]}"
)
# FULLSTACK backend-source checks are rooted at backend/ (issue #274):
# the expected paths are src/<pkg>/api/v1, schemas, services, ... — and
# root-level backend/ + frontend/ dirs are present (detection requirement).
assert (render / "backend").is_dir(), "fullstack requires backend/ dir"
assert (render / "frontend").is_dir(), "fullstack requires frontend/ dir"
assert any(
c.name == "src/fs/api/v1" and c.status == ps.CheckStatus.OK for c in structure.checks
), f"src/fs/api/v1 not OK: {structure.checks}"
# Groups that must never FAIL: Quality, Pyproject, Coverage, Infra, routes.
# Тесты is OK for use_db=yes (test_auth.py kept); for use_db=no it may FAIL
# (the hook strips db-related tests, leaving no test_*.py at tests/ root).
must_pass = ["Тонкие роуты", "Качество кода", "Pyproject", "Coverage", "Infra"]
if extra_context.get("use_db") == "yes":
must_pass.append("Тесты")
for group_name in must_pass:
g = by_name[group_name]
failed = [c for c in g.checks if c.status == ps.CheckStatus.FAIL]
assert g.overall() != ps.CheckStatus.FAIL, f"{group_name} FAIL: " + ", ".join(
f"{c.name}={c.status.value}" for c in failed
)
@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})]) @pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
@ -968,40 +1007,7 @@ def test_cookiecutter_json_default_is_valid_identifier(template_name):
) )
# ── issue #266: cookiecutter use_db=no writes no_db marker ───────────────── # ── issue #274: cookiecutter use_db=no removes db dep (auto-detect) ─────────
@pytest.mark.parametrize(
"template_name, extra_context",
[("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"})],
)
def test_backend_use_db_no_has_no_db_in_pyproject(render):
"""Backend rendered with ``use_db=no`` contains ``no_db = true`` in
``[tool.project-status]`` section of ``pyproject.toml`` (issue #266).
The post-gen hook appends ``no_db = true`` to the existing
``[tool.project-status]`` section so the project-status oracle skips
the ``db/models`` structure check.
"""
pyproject = (render / "pyproject.toml").read_text()
assert "[tool.project-status]" in pyproject
assert "no_db = true" in pyproject, (
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
)
@pytest.mark.parametrize(
"template_name, extra_context",
[("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"})],
)
def test_fullstack_use_db_no_has_no_db_in_pyproject(render):
"""Fullstack rendered with ``use_db=no`` contains ``no_db = true`` in
``[tool.project-status]`` section of ``backend/pyproject.toml``."""
pyproject = (render / "backend" / "pyproject.toml").read_text()
assert "[tool.project-status]" in pyproject
assert "no_db = true" in pyproject, (
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -1,5 +1,5 @@
"""Tests for ``.opencode/scripts/project_contract.py`` — single source of """Tests for ``.opencode/scripts/project_contract.py`` — single source of
truth for project types, stacks, structure, frontend markers, no_db. truth for project types, stacks, structure, frontend markers.
The contract module is imported via ``importlib.util`` (no ``sys.path`` The contract module is imported via ``importlib.util`` (no ``sys.path``
mutation), mirroring how ``spec-status.py`` and ``project-status.py`` load it. mutation), mirroring how ``spec-status.py`` and ``project-status.py`` load it.
@ -86,12 +86,6 @@ def test_structure_expected_keys_subset():
assert "mcp-server" not in keys assert "mcp-server" not in keys
def test_no_db_supported_subset():
"""NO_DB_SUPPORTED ⊆ VALID_TYPES (backend + fullstack only)."""
assert pc.NO_DB_SUPPORTED.issubset(pc.VALID_TYPES)
assert {"backend", "fullstack"} == pc.NO_DB_SUPPORTED
def test_frontend_stack_markers_keys(): def test_frontend_stack_markers_keys():
"""FRONTEND_STACK_MARKERS has the expected keys with correct marker lists.""" """FRONTEND_STACK_MARKERS has the expected keys with correct marker lists."""
assert set(pc.FRONTEND_STACK_MARKERS.keys()) == { assert set(pc.FRONTEND_STACK_MARKERS.keys()) == {

View file

@ -299,19 +299,6 @@ def test_load_config_defaults_when_no_pyproject(tmp_path):
cfg = ps.load_config() cfg = ps.load_config()
assert cfg["route_line_limit"] == 50 assert cfg["route_line_limit"] == 50
assert cfg["min_test_count"] == 1 assert cfg["min_test_count"] == 1
assert cfg["no_db"] is False
def test_load_config_reads_no_db(tmp_path):
"""``[tool.project-status] no_db = true`` is parsed into config (issue #266)."""
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text(
"[tool.project-status]\nroute_line_limit = 80\nno_db = true\n"
)
cfg = ps.load_config()
assert cfg["no_db"] is True
assert cfg["route_line_limit"] == 80
def test_load_config_reads_section(tmp_path): def test_load_config_reads_section(tmp_path):
@ -2249,43 +2236,109 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
assert "tsconfig.json" in frontend[0].detail assert "tsconfig.json" in frontend[0].detail
# ── issue #266: no_db polarity fix (backend) ──────────────────────────────── # ── issue #274: db/models auto-detect from deps ──────────────────────────────
def test_backend_no_db_skips_db_models(tmp_path, ctx): def test_backend_db_project_missing_db_models_warn(tmp_path, ctx):
"""Backend with ``[tool.project-status] no_db = true`` in pyproject.toml """Backend WITH a db dep (tortoise-orm) but no ``db/models`` dir → WARN.
``db/models`` is NOT in the expected paths (no FAIL when absent)."""
_make_backend_repo(tmp_path) The db-project is auto-detected from ``[project.dependencies]``; a
# Remove the db/models dir (simulating cookiecutter use_db=no) missing ``db/models`` is a WARN (not FAIL issue #275: all-WARN
shutil.rmtree(tmp_path / "src" / "test_repo" / "db") contract). Issue #274 removed the ``no_db`` config marker in favor of
# Write a [tool.project-status] section with no_db=true into pyproject.toml dependency-based auto-detection.
# (cookiecutter post_gen_project.py appends this to the existing section). """
pyproject = tmp_path / "pyproject.toml" pkg = "test_repo"
pyproject.write_text(pyproject.read_text() + "\n[tool.project-status]\nno_db = true\n") for rel in [
# Reload config so no_db is picked up f"src/{pkg}/api/v1",
ctx_fresh = ps.RepoCtx(root=tmp_path, config=ps.load_config(tmp_path)) f"src/{pkg}/schemas",
assert ctx_fresh.config["no_db"] is True, f"no_db not parsed: {ctx_fresh.config}" f"src/{pkg}/services",
group = ps.check_structure(ps.ProjectType.BACKEND, ctx_fresh) f"src/{pkg}/config",
# db/models should NOT be among expected checks (skipped by no_db) ]:
db_models_checks = [c for c in group.checks if "db/models" in c.name] (tmp_path / rel).mkdir(parents=True, exist_ok=True)
assert not db_models_checks, ( (tmp_path / "src" / pkg / "__init__.py").write_text("")
f"db/models should be skipped with no_db=true, got: {db_models_checks}" (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
) )
# And overall should not FAIL on db/models # db dep present → _is_db_project True → db/models required (WARN if absent)
assert group.overall() != ps.CheckStatus.FAIL or not any( _write_pyproject(tmp_path, deps=["fastapi", "uvicorn", "tortoise-orm"], cov_source=["src"])
"db/models" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks
), f"db/models FAIL despite no_db=true: {group.checks}"
def test_backend_with_db_requires_db_models(tmp_path, ctx):
"""Backend WITHOUT no_db marker → ``db/models`` required (FAIL if absent)."""
_make_backend_repo(tmp_path)
# Remove the db/models dir
shutil.rmtree(tmp_path / "src" / "test_repo" / "db")
# No no_db marker in pyproject — default config has no_db=False
assert ctx.config.get("no_db", False) is False
group = ps.check_structure(ps.ProjectType.BACKEND, ctx) group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
db_models_fail = [ db_models = [c for c in group.checks if "db/models" in c.name]
c for c in group.checks if "db/models" in c.name and c.status == ps.CheckStatus.FAIL assert db_models, f"expected db/models check, got: {group.checks}"
] assert db_models[0].status == ps.CheckStatus.WARN, (
assert db_models_fail, f"expected db/models FAIL without no_db marker, got: {group.checks}" f"expected WARN for missing db/models in db-project, got {db_models[0].status}"
)
def test_backend_no_db_project_skips_db_models(tmp_path, ctx):
"""Backend WITHOUT a db dep (cookiecutter use_db=no) → db/models skipped.
No db marker in deps ``_is_db_project`` returns False ``db/models``
is NOT in the expected paths. No FAIL/WARN even if the dir is absent.
"""
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(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(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
)
# use_db=no → deps do NOT include a db marker
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"])
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
db_models_checks = [c for c in group.checks if "db/models" in c.name]
assert not db_models_checks, f"db/models should be skipped (no db dep), got: {db_models_checks}"
def test_fullstack_db_project_missing_db_models_warn(tmp_path, ctx):
"""Fullstack WITH a db dep but no ``backend/src/<pkg>/db/models`` → WARN."""
_make_fullstack_repo(tmp_path)
# _make_fullstack_repo pyproject has fastapi/uvicorn; add a db dep.
pyproject = tmp_path / "backend" / "pyproject.toml"
content = pyproject.read_text()
old_deps = '"fastapi",\n "uvicorn"'
new_deps = '"fastapi",\n "uvicorn",\n "tortoise-orm"'
pyproject.write_text(content.replace(old_deps, new_deps))
shutil.rmtree(tmp_path / "backend" / "src" / "test_repo" / "db")
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
db_models = [c for c in group.checks if "db/models" in c.name]
assert db_models, f"expected db/models check for fullstack, got: {group.checks}"
assert db_models[0].status == ps.CheckStatus.WARN, (
f"expected WARN for missing db/models in fullstack db-project, got {db_models[0].status}"
)
def test_detect_backend_use_db_no(tmp_path, ctx):
"""Backend with use_db=no (no db dep) still detects as BACKEND, not UNKNOWN.
Issue #274: ``_matches_backend`` no longer requires ``db/models`` — only
``fastapi``/``uvicorn`` + ``src/<pkg>/api/v1``. A cookiecutter
``use_db=no`` repo (no tortoise-orm/asyncpg) is still BACKEND.
"""
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(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(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
)
# use_db=no → deps do NOT include tortoise-orm/asyncpg
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND, (
"use_db=no backend must detect as BACKEND (db/models not required for type detection)"
)