From c9198a40a7dd221429c21575f3c22d00f2e0e642 Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:44:42 +0300 Subject: [PATCH] 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 --- .opencode/scripts/project-status.py | 255 ++++++++++++++---- .opencode/scripts/project_contract.py | 9 +- .../backend/hooks/post_gen_project.py | 23 +- .../fullstack/hooks/post_gen_project.py | 19 +- tests/test_cookiecutter_templates.py | 88 +++--- tests/test_project_contract.py | 8 +- tests/test_project_status.py | 151 +++++++---- 7 files changed, 363 insertions(+), 190 deletions(-) diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index 442439d..b4a87e5 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -16,7 +16,7 @@ Usage: Project types (auto-detected): fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir - backend — ``src//api/v1/`` + ``src//db/models/`` + fastapi + backend — ``src//api/v1/`` + fastapi (db/models auto-detected from deps) cli — ``[project.scripts]`` in pyproject.toml + typer in deps bot — ``src/bot.py`` OR aiogram in deps worker — ``src/flow.py`` OR prefect in deps @@ -75,7 +75,6 @@ DEFAULT_CONFIG: dict[str, Any] = { "route_line_limit": 50, "min_test_count": 1, "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. 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 cfg: dict[str, Any] = dict(DEFAULT_CONFIG) @@ -155,15 +150,22 @@ class GroupResult: @dataclass(frozen=True) 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 (``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in ``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 config: dict[str, Any] + backend_root: Path = Path() # overridable; set by main()/detect_project_type # ── helpers ────────────────────────────────────────────────────────────────── @@ -175,6 +177,31 @@ def run_cmd(args: list[str]) -> tuple[int, str, str]: 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: """True if ``root / rel`` exists (``ctx`` preferred, else module global).""" 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) +# 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: """True if nested ``src//api/v1`` + fastapi/uvicorn in deps. + Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the + nested ``src//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 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 pkg = _resolve_package_name(ctx) 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("src/api/v1", ctx) and path_exists("src/db/models", ctx) + return path_exists(f"src/{pkg}/api/v1", ctx) + return path_exists("src/api/v1", ctx) 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 -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//`` structure for the backend type. ``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``). - When ``no_db`` is true (``[tool.project-status] no_db = true`` in - pyproject.toml), ``db/models`` is excluded from the expected paths — - cookiecutter ``use_db=no`` removes the ``db/`` dir. + When ``db_required`` is false (no DB dep in pyproject, cookiecutter + ``use_db=no``), ``db/models`` is excluded from the expected paths — the + caller surfaces a WARN separately when a db-project is missing it. """ paths = [ 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", "main.py", ] - if no_db: + if not db_required: paths.pop(1) # remove ``src//db/models`` return paths @@ -358,12 +414,20 @@ README_DELIMITERS: list[str] = [ # ── check group 1: structure ───────────────────────────────────────────────── -def _check_backend_lifespan(ctx: RepoCtx) -> CheckResult: - """Check main.py has a lifespan handler (backend-specific).""" - main = read_text("main.py", ctx) - if main is None: +def _check_backend_lifespan(backend_root: Path) -> CheckResult: + """Check main.py has a lifespan handler (backend-specific). + + ``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 нет") - 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.WARN, "main.py lifespan", "lifespan не найден") @@ -392,14 +456,20 @@ def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None: nested ``src//`` is the standard for publishable, reusable 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 ``__init__.py``, or has a subdir matching ``[project].name`` (normalized). 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(): 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 {} proj_name = project.get("name") if isinstance(project, dict) 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 -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. Returns one ``WARN`` CheckResult per offending class (with the file's relative path). Returns ``[]`` if the file does not import tortoise or the package's own ``db.models`` module (false-positive guard), or if no 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: 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: return [] results: list[CheckResult] = [] - rel = py_file.relative_to(ctx.root) + rel = py_file.relative_to(backend_root) for node in ast.walk(tree): if not isinstance(node, ast.ClassDef): 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 (suppresses false positives from unrelated libraries defining ``Model``). - Skips for non-backend/fullstack types, flat layouts (no ``src//``), - and repos without a resolvable package name. Returns ``[]`` in all skip - cases so the caller appends nothing. + For FULLSTACK the backend lives under ``backend/`` (issue #274): the + package name is resolved against ``backend/pyproject.toml`` and the scan + targets ``backend/src//``. Skips for non-backend/fullstack types, + flat layouts (no ``src//``), 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}: 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: return [] - # For FULLSTACK, the backend lives under ``backend/``; for BACKEND, at root. - 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 + src_pkg = backend_root / "src" / pkg if not src_pkg.is_dir(): return [] 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"): if models_dir in py_file.parents or py_file == models_dir: continue - results.extend(_scan_file_for_models(py_file, ctx, pkg)) + results.extend(_scan_file_for_models(py_file, backend_root, pkg)) 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]: """Type-specific extra checks beyond the expected dirs list.""" results: list[CheckResult] = [] + backend_root = _backend_root_for(ptype, ctx) 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): results.append( CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен") ) if ptype == ProjectType.FULLSTACK: + results.append(_check_backend_lifespan(backend_root)) frontend_stack = _check_frontend_stack(ctx) if frontend_stack is not None: 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: - """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="Структура") - if ptype == ProjectType.BACKEND: - pkg = _resolve_package_name(ctx) + if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}: + 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: group.checks.append( CheckResult( @@ -620,19 +706,40 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: ) ) return group - no_db = bool(ctx.config.get("no_db", False)) - expected = _expected_backend_paths(pkg, no_db=no_db) + deps_raw = parse_pyproject(backend_ctx).get("project", {}).get("dependencies", []) + 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: expected = STRUCTURE_EXPECTED.get(ptype.value, []) - if not expected: - group.checks.append( - CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта") - ) - return group - for rel in expected: - status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL - detail = "существует" if status == CheckStatus.OK else "отсутствует" - group.checks.append(CheckResult(status, rel, detail)) + if not expected: + group.checks.append( + CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта") + ) + return group + for rel in expected: + status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL + detail = "существует" if status == CheckStatus.OK else "отсутствует" + group.checks.append(CheckResult(status, rel, detail)) group.checks.extend(_check_type_specific_structure(ptype, ctx)) return group @@ -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: - """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//api/v1`` and + WARN paths are reported relative to ``backend/`` (issue #274). + """ _ = fast # unused here, accepted for signature uniformity group = GroupResult(name="Тонкие роуты") 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/", "директория роутов не найдена") ) 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 @@ -817,9 +929,14 @@ def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx) 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="Качество кода") - pyproject = parse_pyproject(ctx) + backend_ctx = _backend_ctx_for(ptype, ctx) + pyproject = parse_pyproject(backend_ctx) tools = pyproject.get("tool", {}) for tool_name in ("ruff", "mypy"): 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: - """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="Тесты") - tests_dir = ctx.root / "tests" + backend_root = _backend_root_for(ptype, ctx) + tests_dir = backend_root / "tests" if not tests_dir.exists(): group.checks.append( CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует") @@ -1157,9 +1279,14 @@ def check_infra( 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") - pyproject = parse_pyproject(ctx) + backend_ctx = _backend_ctx_for(ptype, ctx) + pyproject = parse_pyproject(backend_ctx) cov = pyproject.get("tool", {}).get("coverage", {}) run_cfg = cov.get("run", {}) if isinstance(cov, 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 of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the 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") - data = _load_pyproject(ctx.root) + backend_root = _backend_root_for(ptype, ctx) + data = _load_pyproject(backend_root) if data is None: group.checks.append( 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 {} pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} 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 @@ -1652,6 +1785,10 @@ def main() -> None: ctx = RepoCtx(root=root, config=load_config(root)) 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) print(format_output(ptype, groups)) if strict and any(g.overall() == CheckStatus.FAIL for g in groups): diff --git a/.opencode/scripts/project_contract.py b/.opencode/scripts/project_contract.py index 9df4a30..c4a3c0b 100644 --- a/.opencode/scripts/project_contract.py +++ b/.opencode/scripts/project_contract.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """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 ``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 STRUCTURE_EXPECTED — dict[type -> list[str]] of expected top-level dirs/files 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 @@ -79,9 +78,3 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = { "fullstack_package_deps": ["tailwindcss", "bits-ui"], "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"} diff --git a/.opencode/templates/backend/hooks/post_gen_project.py b/.opencode/templates/backend/hooks/post_gen_project.py index 4706e84..f198c99 100644 --- a/.opencode/templates/backend/hooks/post_gen_project.py +++ b/.opencode/templates/backend/hooks/post_gen_project.py @@ -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``, ``models/user.py`` (hashed_password), and strip the auth dependency wiring. - ``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 @@ -33,7 +37,7 @@ def main() -> None: if use_auth == "no": _remove(f"src/{pkg}/api/v1/routes/auth.py") _remove(f"src/{pkg}/services/auth_service.py") - _remove(f"tests/test_auth.py") + _remove("tests/test_auth.py") if use_db == "no": # db is the root cause for the broken-conditional findings: files @@ -46,22 +50,15 @@ def main() -> None: _remove(f"src/{pkg}/api/v1/routes/users.py") _remove(f"src/{pkg}/api/v1/dependencies.py") _remove(f"src/{pkg}/schemas/user.py") - _remove(f"tests/unit/test_user.py") - _remove(f"tests/unit/test_user_service.py") - _remove(f"tests/api/test_users.py") + _remove("tests/unit/test_user.py") + _remove("tests/unit/test_user_service.py") + _remove("tests/api/test_users.py") if use_auth == "yes": # auth_service imports User; strip it and its wiring too. _remove(f"src/{pkg}/api/v1/routes/auth.py") _remove(f"src/{pkg}/services/auth_service.py") - _remove(f"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") + _remove("tests/test_auth.py") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/.opencode/templates/fullstack/hooks/post_gen_project.py b/.opencode/templates/fullstack/hooks/post_gen_project.py index c25b5b5..451fa1a 100644 --- a/.opencode/templates/fullstack/hooks/post_gen_project.py +++ b/.opencode/templates/fullstack/hooks/post_gen_project.py @@ -29,7 +29,7 @@ def main() -> None: if use_auth == "no": _remove(f"backend/src/{pkg}/api/v1/routes/auth.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": # db is the root cause for the broken-conditional findings: files @@ -42,22 +42,15 @@ def main() -> None: _remove(f"backend/src/{pkg}/api/v1/routes/users.py") _remove(f"backend/src/{pkg}/api/v1/dependencies.py") _remove(f"backend/src/{pkg}/schemas/user.py") - _remove(f"backend/tests/unit/test_user.py") - _remove(f"backend/tests/unit/test_user_service.py") - _remove(f"backend/tests/api/test_users.py") + _remove("backend/tests/unit/test_user.py") + _remove("backend/tests/unit/test_user_service.py") + _remove("backend/tests/api/test_users.py") if use_auth == "yes": # 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}/services/auth_service.py") - _remove(f"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") + _remove("backend/tests/test_auth.py") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index a19cf79..a286604 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -613,18 +613,57 @@ def test_infra_checks_present(render): @pytest.mark.parametrize( "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): - """Fullstack is detected as fullstack (backend/ + frontend/ present).""" +def test_fullstack_passes_project_status_all_groups(render, extra_context): + """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 ctx = ps.RepoCtx(root=render, config=ps.load_config(render)) detected = ps.detect_project_type(ctx) assert detected == ptype - _, checks = _run_status_checks(render) - by_name = {c.name: c for c in checks} - assert by_name["backend"].status == ps.CheckStatus.OK - assert by_name["frontend"].status == ps.CheckStatus.OK + ctx = ps.RepoCtx(root=render, config=ctx.config, backend_root=ps._backend_root_for(ptype, ctx)) + groups = ps.run_all_checks(ptype, ctx, fast=True) + by_name = {g.name: g for g in groups} + # Structure: backend/ + frontend/ present, nested src//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//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"})]) @@ -968,40 +1007,7 @@ def test_cookiecutter_json_default_is_valid_identifier(template_name): ) -# ── issue #266: cookiecutter use_db=no writes no_db marker ───────────────── - - -@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}" - ) +# ── issue #274: cookiecutter use_db=no removes db dep (auto-detect) ───────── @pytest.mark.parametrize( diff --git a/tests/test_project_contract.py b/tests/test_project_contract.py index c920932..18867f3 100644 --- a/tests/test_project_contract.py +++ b/tests/test_project_contract.py @@ -1,5 +1,5 @@ """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`` 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 -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(): """FRONTEND_STACK_MARKERS has the expected keys with correct marker lists.""" assert set(pc.FRONTEND_STACK_MARKERS.keys()) == { diff --git a/tests/test_project_status.py b/tests/test_project_status.py index c10e29a..0f1d1e2 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -299,19 +299,6 @@ def test_load_config_defaults_when_no_pyproject(tmp_path): cfg = ps.load_config() assert cfg["route_line_limit"] == 50 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): @@ -2249,43 +2236,109 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx): 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): - """Backend with ``[tool.project-status] no_db = true`` in pyproject.toml - → ``db/models`` is NOT in the expected paths (no FAIL when absent).""" - _make_backend_repo(tmp_path) - # Remove the db/models dir (simulating cookiecutter use_db=no) - shutil.rmtree(tmp_path / "src" / "test_repo" / "db") - # Write a [tool.project-status] section with no_db=true into pyproject.toml - # (cookiecutter post_gen_project.py appends this to the existing section). - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text(pyproject.read_text() + "\n[tool.project-status]\nno_db = true\n") - # Reload config so no_db is picked up - ctx_fresh = ps.RepoCtx(root=tmp_path, config=ps.load_config(tmp_path)) - assert ctx_fresh.config["no_db"] is True, f"no_db not parsed: {ctx_fresh.config}" - group = ps.check_structure(ps.ProjectType.BACKEND, ctx_fresh) - # 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] - assert not db_models_checks, ( - f"db/models should be skipped with no_db=true, got: {db_models_checks}" +def test_backend_db_project_missing_db_models_warn(tmp_path, ctx): + """Backend WITH a db dep (tortoise-orm) but no ``db/models`` dir → WARN. + + The db-project is auto-detected from ``[project.dependencies]``; a + missing ``db/models`` is a WARN (not FAIL — issue #275: all-WARN + contract). Issue #274 removed the ``no_db`` config marker in favor of + dependency-based auto-detection. + """ + 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" ) - # And overall should not FAIL on db/models - assert group.overall() != ps.CheckStatus.FAIL or not any( - "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 + # db dep present → _is_db_project True → db/models required (WARN if absent) + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn", "tortoise-orm"], cov_source=["src"]) group = ps.check_structure(ps.ProjectType.BACKEND, ctx) - db_models_fail = [ - c for c in group.checks if "db/models" in c.name and c.status == ps.CheckStatus.FAIL - ] - assert db_models_fail, f"expected db/models FAIL without no_db marker, got: {group.checks}" + db_models = [c for c in group.checks if "db/models" in c.name] + assert db_models, f"expected db/models check, got: {group.checks}" + assert db_models[0].status == ps.CheckStatus.WARN, ( + 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//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//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)" + )