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:
parent
fa6fc9ecbd
commit
c9198a40a7
7 changed files with 363 additions and 190 deletions
|
|
@ -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,19 +706,40 @@ 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:
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
||||||
)
|
)
|
||||||
return group
|
return group
|
||||||
for rel in expected:
|
for rel in expected:
|
||||||
status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL
|
status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL
|
||||||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||||||
group.checks.append(CheckResult(status, rel, detail))
|
group.checks.append(CheckResult(status, rel, detail))
|
||||||
group.checks.extend(_check_type_specific_structure(ptype, ctx))
|
group.checks.extend(_check_type_specific_structure(ptype, ctx))
|
||||||
return group
|
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:
|
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):
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
|
||||||
|
|
@ -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,22 +50,15 @@ 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__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -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,22 +42,15 @@ 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__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()) == {
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue