opencode-config/tests/test_project_status.py
Sergey c9198a40a7
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>
2026-08-05 05:44:42 +03:00

2344 lines
96 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for .opencode/scripts/project-status.py — architecture oracle.
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. Filesystem checks use ``tmp_path`` with ``monkeypatch`` on
``ps.REPO_ROOT`` so each test sees an isolated repo.
Covers: auto-detect (5 types + unknown), 7 check groups, format output
([OK]/[WARN]/[FAIL] + Итог + Рекомендации), exit codes (non-blocking vs
``--check`` strict), ``--fast`` skip of branch protection.
"""
import importlib.util
import shutil
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py"
spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["project_status"] = ps
spec.loader.exec_module(ps)
GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
("git", "remote"),
(0, "https://github.com/slaid098/opencode-config.git\n", ""),
)
@pytest.fixture(autouse=True)
def _isolate_repo(monkeypatch, tmp_path):
"""Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test.
Also builds a ``RepoCtx`` available as ``ctx`` fixture (autouse-isolated)
so check-functions get an explicit context instead of touching globals.
"""
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
monkeypatch.setattr(ps, "CONFIG", dict(ps.DEFAULT_CONFIG))
@pytest.fixture
def ctx(tmp_path) -> ps.RepoCtx:
"""Fresh RepoCtx rooted at ``tmp_path`` with default config thresholds."""
return ps.RepoCtx(root=tmp_path, config=dict(ps.DEFAULT_CONFIG))
def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
"""Factory: mock run_cmd matching by command prefix.
Automatically includes a ``git remote get-url origin`` mock so
``get_repo_full_name()`` works without extra boilerplate.
"""
merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}
def _mock(args: list[str]) -> tuple[int, str, str]:
for prefix, result in merged.items():
if tuple(args[: len(prefix)]) == tuple(prefix):
return result
return (1, "", f"unmocked call: {args}")
return _mock
def _tool_sections(
has_ruff: bool,
has_mypy: bool,
has_pytest: bool,
cov_source: list | None,
cov_fail: str | None,
) -> list[str]:
"""Build the [tool.*] section lines for pyproject.toml."""
lines: list[str] = []
if has_ruff:
lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
if has_mypy:
lines.extend(["[tool.mypy]", 'python_version = "3.12"', "strict = true", ""])
if has_pytest:
lines.append("[tool.pytest.ini_options]")
addopts = "--cov=src --cov-report=term-missing --timeout=120"
if cov_fail:
addopts += f" --cov-fail-under={cov_fail}"
lines.append(f'addopts = "{addopts}"')
lines.extend(['asyncio_mode = "auto"', 'testpaths = ["tests"]', ""])
if cov_source is not None:
lines.append("[tool.coverage.run]")
source_str = ", ".join(f'"{s}"' for s in cov_source)
lines.append(f"source = [{source_str}]")
lines.append("branch = true")
lines.append("")
lines.append("[tool.coverage.report]")
lines.append("exclude_lines = [")
lines.append(' "pragma: no cover",')
lines.append(' "if __name__ == .__main__.:",')
lines.append(' "if TYPE_CHECKING:",')
lines.append("]")
lines.append("")
return lines
def _write_pyproject(
tmp_path: Path,
*,
deps: list[str] | None = None,
scripts: dict | None = None,
has_ruff: bool = True,
has_mypy: bool = True,
has_pytest: bool = True,
cov_source: list | None = None,
cov_fail: str | None = None,
name: str = "test-repo",
description: str = "test repo for project-status",
requires_python: str = ">=3.12",
hatch_packages: list | None = None,
) -> None:
"""Write a minimal pyproject.toml with the requested [tool.*] sections."""
norm_name = (
ps._normalize_package_name(name)
if hasattr(ps, "_normalize_package_name")
else name.replace("-", "_")
)
lines = [
"[build-system]",
'requires = ["hatchling"]',
'build-backend = "hatchling.build"',
"",
"[project]",
f'name = "{name}"',
'version = "0.1.0"',
f'description = "{description}"',
f'requires-python = "{requires_python}"',
"",
"dependencies = [",
]
for d in deps or []:
lines.append(f' "{d}",')
lines.extend(["]", "", "[project.optional-dependencies]", "dev = ["])
if has_pytest:
lines.append(' "pytest>=8.0",')
if has_mypy:
lines.append(' "mypy>=1.10",')
if has_ruff:
lines.append(' "ruff>=0.5",')
lines.extend(["]", ""])
if scripts:
lines.append("[project.scripts]")
for k, v in scripts.items():
lines.append(f'{k} = "{v}"')
lines.append("")
# [tool.hatch.build.targets.wheel] packages — only emit if src/<pkg>/ exists
# or caller explicitly passes hatch_packages.
src_pkg = tmp_path / "src" / norm_name
if hatch_packages is not None:
lines.append("[tool.hatch.build.targets.wheel]")
pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages)
lines.append(f"packages = [{pkgs_str}]")
lines.append("")
elif src_pkg.exists():
lines.append("[tool.hatch.build.targets.wheel]")
lines.append(f'packages = ["src/{norm_name}"]')
lines.append("")
lines.extend(_tool_sections(has_ruff, has_mypy, has_pytest, cov_source, cov_fail))
(tmp_path / "pyproject.toml").write_text("\n".join(lines))
def _make_backend_repo(tmp_path: Path) -> None:
"""Create a minimal backend-type repo skeleton in tmp_path.
Uses the nested ``src/<package>/api/v1`` layout (issue #241): the package
name is ``test_repo`` (normalized from the default ``[project].name``
``test-repo``).
"""
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
"tests",
]:
(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"
)
(tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"], cov_fail="80")
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
(tmp_path / "tests/test_users.py").write_text("def test_ok(): assert True\n")
(tmp_path / "tests/unit").mkdir(parents=True, exist_ok=True)
(tmp_path / "tests/unit/test_user_service.py").write_text(
"def test_user_service(): assert True\n"
)
(tmp_path / "tests/api").mkdir(parents=True, exist_ok=True)
(tmp_path / "tests/api/test_users.py").write_text("def test_users_api(): assert True\n")
# Extras for check_pyproject (group 8) to pass:
(tmp_path / ".python-version").write_text("3.12\n")
(tmp_path / "uv.lock").write_text("# minimal lockfile stub\n")
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
def _make_fullstack_repo(tmp_path: Path) -> None:
"""Create a minimal fullstack repo skeleton in tmp_path.
Mirrors the cookiecutter fullstack template (issue #241 nested layout):
backend ``pyproject.toml`` lives under ``backend/``, the package under
``backend/src/<package>/`` with ``api/v1`` nested. The package name is
``test_repo`` (normalized from the default ``[project].name``
``test-repo``). Frontend is a stub ``package.json``.
"""
pkg = "test_repo"
for rel in [
f"backend/src/{pkg}/api/v1",
f"backend/src/{pkg}/db/models",
f"backend/src/{pkg}/schemas",
f"backend/src/{pkg}/services",
f"backend/src/{pkg}/config",
"backend/tests",
"frontend",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("")
(tmp_path / "backend" / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "backend" / "main.py").write_text(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
)
(tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
_write_pyproject(tmp_path / "backend", deps=["fastapi", "uvicorn"], cov_source=["src"])
# Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
# (issue #266): package.json deps + components.json + tsconfig.json are the
# 4 frontend stack markers checked by ``_check_frontend_stack``.
(tmp_path / "frontend" / "package.json").write_text(
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
'"bits-ui": "^1.0.0"}}\n'
)
(tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
(tmp_path / "frontend" / "components.json").write_text("{}\n")
# ── parse_remote_url ─────────────────────────────────────────────────────────
def test_parse_remote_url_https():
assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_ssh():
assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_invalid():
with pytest.raises(ValueError, match="Cannot parse remote URL"):
ps.parse_remote_url("not-a-valid-url")
# ── get_repo_full_name ───────────────────────────────────────────────────────
def test_get_repo_full_name_ok(monkeypatch):
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
assert ps.get_repo_full_name() == "slaid098/opencode-config"
def test_get_repo_full_name_no_remote(monkeypatch):
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "no remote")}))
assert ps.get_repo_full_name() is None
def test_get_repo_full_name_bad_url(monkeypatch):
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (0, "not-a-url\n", "")}))
assert ps.get_repo_full_name() is None
# ── load_config ──────────────────────────────────────────────────────────────
def test_load_config_defaults_when_no_pyproject(tmp_path):
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
cfg = ps.load_config()
assert cfg["route_line_limit"] == 50
assert cfg["min_test_count"] == 1
def test_load_config_reads_section(tmp_path):
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text(
"[tool.project-status]\nroute_line_limit = 80\nmin_test_count = 3\n"
)
cfg = ps.load_config()
assert cfg["route_line_limit"] == 80
assert cfg["min_test_count"] == 3
assert cfg["require_branch_protection"] is False
def test_load_config_bad_toml_returns_defaults(tmp_path):
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("not valid toml = = =")
cfg = ps.load_config()
assert cfg["route_line_limit"] == 50
# ── detect_project_type ──────────────────────────────────────────────────────
def test_detect_fullstack(tmp_path, ctx):
(tmp_path / "backend").mkdir()
(tmp_path / "frontend").mkdir()
assert ps.detect_project_type(ctx) == ps.ProjectType.FULLSTACK
def test_detect_backend(tmp_path, ctx):
_make_backend_repo(tmp_path)
assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND
def test_detect_bot_via_file(tmp_path, ctx):
(tmp_path / "src").mkdir()
(tmp_path / "src/bot.py").write_text("from aiogram import Dispatcher\n")
_write_pyproject(tmp_path, deps=[])
assert ps.detect_project_type(ctx) == ps.ProjectType.BOT
def test_detect_bot_via_dep(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["aiogram"])
assert ps.detect_project_type(ctx) == ps.ProjectType.BOT
def test_detect_worker_via_file(tmp_path, ctx):
(tmp_path / "src").mkdir()
(tmp_path / "src/flow.py").write_text("from prefect import flow\n")
_write_pyproject(tmp_path, deps=[])
assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER
def test_detect_worker_via_dep(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["prefect"])
assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER
def test_detect_cli(tmp_path, ctx):
(tmp_path / "src").mkdir()
pkg = tmp_path / "src" / "mycli"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
assert ps.detect_project_type(ctx) == ps.ProjectType.CLI
def test_detect_unknown_empty_repo(tmp_path, ctx):
_write_pyproject(tmp_path, deps=[])
assert ps.detect_project_type(ctx) == ps.ProjectType.UNKNOWN
# ── check_structure ──────────────────────────────────────────────────────────
def test_check_structure_backend_ok(tmp_path, ctx):
_make_backend_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK
assert all(c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_structure_backend_missing_dir(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
(tmp_path / "main.py").write_text(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
)
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.FAIL
assert any(
c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
def test_check_structure_backend_no_lifespan(tmp_path, ctx):
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
"tests",
]:
(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("app = None\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_structure_unknown_warn(tmp_path, ctx):
group = ps.check_structure(ps.ProjectType.UNKNOWN, ctx)
assert group.overall() == ps.CheckStatus.WARN
def test_check_structure_cli_with_package(tmp_path, ctx):
(tmp_path / "src").mkdir()
pkg = tmp_path / "src" / "mycli"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
group = ps.check_structure(ps.ProjectType.CLI, ctx)
assert any(c.status == ps.CheckStatus.OK and "package" in c.name for c in group.checks)
def test_check_structure_cli_no_package(tmp_path, ctx):
(tmp_path / "src").mkdir()
group = ps.check_structure(ps.ProjectType.CLI, ctx)
assert any(c.status == ps.CheckStatus.FAIL for c in group.checks)
# ── check_thin_routes ────────────────────────────────────────────────────────
def test_thin_routes_ok(tmp_path, ctx):
_make_backend_repo(tmp_path)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK
def test_thin_routes_over_limit(tmp_path, ctx):
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / pkg / "__init__.py").write_text("")
(tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text("app = None\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
body = "\n x = 1\n" * 60
(tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():"
f"{body} return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any("превышение" in c.detail for c in group.checks)
def test_thin_routes_not_applicable_for_cli(tmp_path, ctx):
group = ps.check_thin_routes(ps.ProjectType.CLI, ctx)
assert group.overall() == ps.CheckStatus.OK
def test_thin_routes_no_api_dir(tmp_path, ctx):
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
def test_thin_routes_import_src_db_models_fail(tmp_path, ctx):
"""Route импортирует ``src.db.models`` → FAIL (bypasses services)."""
_make_backend_repo(tmp_path)
(tmp_path / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom src.db.models import User\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks
), f"expected FAIL on route imports, got: {group.checks}"
def test_thin_routes_import_tortoise_fail(tmp_path, ctx):
"""Route импортирует ``tortoise`` → FAIL (bypasses services)."""
_make_backend_repo(tmp_path)
(tmp_path / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom tortoise import fields\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks
), f"expected FAIL on route imports, got: {group.checks}"
def test_thin_routes_import_submodule_of_src_db_models_fail(tmp_path, ctx):
"""``from src.db.models.user import User`` → FAIL (submodule of forbidden)."""
_make_backend_repo(tmp_path)
(tmp_path / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom src.db.models.user import User\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_thin_routes_import_services_ok(tmp_path, ctx):
"""Route импортирует ``from <pkg>.services import user_service`` → OK."""
_make_backend_repo(tmp_path)
(tmp_path / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return user_service.list_all()\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), (
f"expected no FAIL, got: {group.checks}"
)
assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_thin_routes_import_db_connection_ok(tmp_path, ctx):
"""``from <pkg>.db.connection import init_db`` → OK (connection, not model)."""
_make_backend_repo(tmp_path)
(tmp_path / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom test_repo.db.connection import init_db\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), (
f"db.connection is allowed (not a model), got: {group.checks}"
)
def test_thin_routes_import_ok_no_fail_on_over_limit(tmp_path, ctx):
"""OK imports + over-limit line count → WARN only (no FAIL)."""
pkg = "test_repo"
for rel in [
f"src/{pkg}/api/v1",
f"src/{pkg}/db/models",
f"src/{pkg}/schemas",
f"src/{pkg}/services",
f"src/{pkg}/config",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / pkg / "__init__.py").write_text("")
(tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
(tmp_path / "main.py").write_text("app = None\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
body = "\n x = 1\n" * 60
(tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():"
f"{body} return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN, (
f"over-limit but no forbidden import → WARN, got {group.checks}"
)
assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)
# ── check_thin_routes: FULLSTACK (issue #259) ──────────────────────────────
def test_thin_routes_fullstack_ok(tmp_path, ctx):
"""Fullstack nested ``backend/src/<pkg>/api/v1/`` → thin-routes OK (new)."""
_make_fullstack_repo(tmp_path)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert group.overall() == ps.CheckStatus.OK, (
f"fullstack thin-routes check should work now, got {group.checks}"
)
assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_thin_routes_fullstack_no_pkg_dir_skip(tmp_path, ctx):
"""Fullstack without ``backend/src/<pkg>/`` (flat) → WARN skip (no api dir)."""
(tmp_path / "backend").mkdir()
(tmp_path / "frontend").mkdir()
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks)
def test_thin_routes_fullstack_no_backend_pyproject_skip(tmp_path, ctx):
"""Fullstack without ``backend/pyproject.toml`` → cannot resolve pkg → skip."""
pkg = "test_repo"
for rel in [
f"backend/src/{pkg}/api/v1",
f"backend/src/{pkg}/db/models",
f"backend/src/{pkg}/schemas",
f"backend/src/{pkg}/services",
f"backend/src/{pkg}/config",
"frontend",
]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("")
(tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks), (
f"expected WARN skip when backend pyproject missing, got {group.checks}"
)
def test_thin_routes_fullstack_import_src_db_models_fail(tmp_path, ctx):
"""Fullstack route importing ``src.db.models`` → FAIL (same rule as backend)."""
_make_fullstack_repo(tmp_path)
(tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom src.db.models import User\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert any(
c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks
), f"expected FAIL on fullstack route imports, got: {group.checks}"
def test_thin_routes_fullstack_import_tortoise_fail(tmp_path, ctx):
"""Fullstack route importing ``tortoise`` → FAIL (same rule as backend)."""
_make_fullstack_repo(tmp_path)
(tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom tortoise import fields\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert any(
c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks
), f"expected FAIL on fullstack route imports, got: {group.checks}"
def test_thin_routes_fullstack_import_services_ok(tmp_path, ctx):
"""Fullstack route importing ``<pkg>.services`` → OK (not forbidden)."""
_make_fullstack_repo(tmp_path)
(tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():\n return user_service.list_all()\n"
)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), (
f"fullstack services import should be OK, got: {group.checks}"
)
def test_thin_routes_fullstack_over_limit_warn(tmp_path, ctx):
"""Fullstack route over line limit → WARN (no FAIL) when imports clean."""
_make_fullstack_repo(tmp_path)
body = "\n x = 1\n" * 60
(tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
"from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
"router = APIRouter()\n"
"@router.get('/users')\nasync def list_users():"
f"{body} return []\n"
)
group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
assert group.overall() == ps.CheckStatus.WARN, (
f"over-limit but no forbidden import → WARN, got {group.checks}"
)
assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)
# ── check_quality ────────────────────────────────────────────────────────────
def test_quality_ok(tmp_path, ctx):
_make_backend_repo(tmp_path)
group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK
def test_quality_missing_mypy(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False)
group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_quality_missing_ruff(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False)
group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks)
# ── check_tests ──────────────────────────────────────────────────────────────
def test_tests_ok(tmp_path, ctx):
_make_backend_repo(tmp_path)
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK
def test_tests_no_dir(tmp_path, ctx):
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.FAIL
assert any("tests/" in c.name for c in group.checks)
def test_tests_no_conftest(tmp_path, ctx):
(tmp_path / "tests").mkdir()
(tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n")
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "conftest.py" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_tests_asyncio_mark_warns(tmp_path, ctx):
(tmp_path / "tests").mkdir()
(tmp_path / "tests/test_x.py").write_text(
"import pytest\n@pytest.mark.asyncio\nasync def test_x(): assert True\n"
)
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "no @pytest.mark.asyncio" and c.status == ps.CheckStatus.WARN
for c in group.checks
)
def test_tests_backend_missing_unit_warn(tmp_path, ctx):
"""Backend без ``tests/unit/`` → WARN (не FAIL)."""
_make_backend_repo(tmp_path)
(tmp_path / "tests/unit/test_user_service.py").unlink()
(tmp_path / "tests/unit").rmdir()
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_tests_backend_missing_api_warn(tmp_path, ctx):
"""Backend без ``tests/api/`` → WARN."""
_make_backend_repo(tmp_path)
(tmp_path / "tests/api/test_users.py").unlink()
(tmp_path / "tests/api").rmdir()
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_tests_backend_with_unit_api_ok(tmp_path, ctx):
"""Backend с tests/unit/ + tests/api/ → OK on structure (default _make_backend_repo)."""
_make_backend_repo(tmp_path)
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.OK for c in group.checks)
assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_tests_cli_no_unit_api_ok(tmp_path, ctx):
"""CLI без tests/unit/ + tests/api/ → OK (cli не имеет API layer)."""
(tmp_path / "tests").mkdir()
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
(tmp_path / "tests/test_cli.py").write_text("def test_cli(): assert True\n")
group = ps.check_tests(ps.ProjectType.CLI, ctx)
assert not any(c.name == "tests/unit/" for c in group.checks)
assert not any(c.name == "tests/api/" for c in group.checks)
assert not any(c.name == "tests/integration/ pytestmark" for c in group.checks)
def test_tests_integration_without_pytestmark_warn(tmp_path, ctx):
"""tests/integration/test_*.py без pytestmark → WARN."""
_make_backend_repo(tmp_path)
(tmp_path / "tests/integration").mkdir()
(tmp_path / "tests/integration/test_flow.py").write_text("def test_flow(): assert True\n")
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.WARN
for c in group.checks
)
def test_tests_integration_with_pytestmark_ok(tmp_path, ctx):
"""tests/integration/test_*.py с pytest.mark.integration → OK."""
_make_backend_repo(tmp_path)
(tmp_path / "tests/integration").mkdir()
(tmp_path / "tests/integration/test_flow.py").write_text(
"import pytest\npytestmark = [pytest.mark.integration]\ndef test_flow(): assert True\n"
)
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.OK
for c in group.checks
)
def test_tests_integration_empty_dir_warn(tmp_path, ctx):
"""tests/integration/ пустая → WARN."""
_make_backend_repo(tmp_path)
(tmp_path / "tests/integration").mkdir()
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "tests/integration/" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_tests_stub_file_without_def_test_warn(tmp_path, ctx):
"""test_*.py без ``def test_*``/``async def test_*`` → WARN (stub)."""
(tmp_path / "tests").mkdir()
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
(tmp_path / "tests/test_stub.py").write_text("# empty stub file\nx = 1\n")
(tmp_path / "tests/test_real.py").write_text("def test_real(): assert True\n")
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_tests_stub_detector_async_def_test_ok(tmp_path, ctx):
"""``async def test_*`` распознаётся как реальный тест (не stub)."""
(tmp_path / "tests").mkdir()
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
(tmp_path / "tests/test_async.py").write_text("async def test_async(): assert True\n")
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_tests_stub_detector_real_test_ok(tmp_path, ctx):
"""test_*.py с ``def test_*`` → OK (не stub)."""
(tmp_path / "tests").mkdir()
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
(tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n")
group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks)
# ── check_readme ─────────────────────────────────────────────────────────────
def _write_valid_readme(tmp_path: Path) -> None:
content = (
"# 🚀 Title\n[English](#-english) [Русский](#-русский)\n"
"## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n"
"Quick Start\nБыстрый старт\nslaid098.dev/contacts\n"
)
for d in ps.README_DELIMITERS:
content += f"<!-- {d} -->\n"
(tmp_path / "README.md").write_text(content)
def test_readme_ok(tmp_path, ctx):
_write_valid_readme(tmp_path)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK, (
f"expected OK, got {group.overall()}: "
+ ", ".join(
f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK
)
)
def test_readme_missing(tmp_path, ctx):
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any(c.name == "README.md" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_readme_missing_delimiters(tmp_path, ctx):
(tmp_path / "README.md").write_text(
"# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n"
"[Русский](#-русский)\nQuick Start\nБыстрый старт\nslaid098.dev/contacts\n"
)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_readme_missing_ru_switcher_fail(tmp_path, ctx):
"""README без ``[Русский](#-русский)`` → FAIL."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text().replace("[Русский](#-русский)", "")
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[Русский](#-русский)" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
def test_readme_missing_support_link_fail(tmp_path, ctx):
"""README без обеих ссылок (contacts/support) → FAIL."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text().replace("slaid098.dev/contacts", "")
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
assert group.overall() == ps.CheckStatus.FAIL
def test_readme_deprecated_support_link_warn(tmp_path, ctx):
"""README только со старой ссылкой ``slaid098.dev/support`` → WARN (не FAIL)."""
_write_valid_readme(tmp_path)
content = (
(tmp_path / "README.md")
.read_text()
.replace("slaid098.dev/contacts", "slaid098.dev/support")
)
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.WARN for c in group.checks
)
assert group.overall() == ps.CheckStatus.WARN
def test_readme_contacts_link_ok(tmp_path, ctx):
"""README со ссылкой ``slaid098.dev/contacts`` → OK."""
_write_valid_readme(tmp_path)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_readme_missing_quick_start_fail(tmp_path, ctx):
"""README без ``Quick Start`` (EN) → FAIL."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text().replace("Quick Start", "")
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "Quick Start" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_readme_missing_bystriy_start_fail(tmp_path, ctx):
"""README без ``Быстрый старт`` (RU) → FAIL."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text().replace("Быстрый старт", "")
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "Быстрый старт" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_readme_manual_license_section_fail(tmp_path, ctx):
"""README с ручной секцией ``## License`` → FAIL (дубль GitHub sidebar)."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text() + "\n## License\nMIT\n"
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "Manual License section" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
def test_readme_manual_license_ru_section_fail(tmp_path, ctx):
"""README с ``## Лицензия`` (RU) → FAIL."""
_write_valid_readme(tmp_path)
content = (tmp_path / "README.md").read_text() + "\n## Лицензия\nMIT\n"
(tmp_path / "README.md").write_text(content)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "Manual License section" and c.status == ps.CheckStatus.FAIL for c in group.checks
)
def test_readme_no_manual_license_ok(tmp_path, ctx):
"""README без ручной License секции → нет FAIL для ``Manual License section``."""
_write_valid_readme(tmp_path)
group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
assert not any(c.name == "Manual License section" for c in group.checks)
# ── check_infra ──────────────────────────────────────────────────────────────
def test_infra_ok_with_branch_protection(monkeypatch, tmp_path, ctx):
for rel in [".github/workflows", ".github"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
(tmp_path / "LICENSE").write_text("MIT\n")
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "api"): (
0,
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
"",
),
}
),
)
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False)
assert group.overall() == ps.CheckStatus.OK
def test_infra_missing_ci_fail(tmp_path, ctx):
(tmp_path / "LICENSE").write_text("MIT\n")
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True)
assert any(
c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL
for c in group.checks
)
def test_infra_fast_skips_branch_protection(tmp_path, ctx):
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / "LICENSE").write_text("MIT\n")
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True)
assert any(
c.name == "branch protection" and c.status == ps.CheckStatus.WARN and "--fast" in c.detail
for c in group.checks
)
def test_infra_branch_protection_gh_error(monkeypatch, tmp_path, ctx):
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / "LICENSE").write_text("MIT\n")
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "not found")}))
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False)
assert any(
c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
)
# ── check_coverage ───────────────────────────────────────────────────────────
def test_coverage_non_blocking(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["fastapi"], cov_source=["src"], cov_fail="80")
group = ps.check_coverage(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)
def test_coverage_no_config(tmp_path, ctx):
_write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False)
group = ps.check_coverage(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)
# ── format_output ────────────────────────────────────────────────────────────
def test_format_output_has_summary_and_recommendations():
ptype = ps.ProjectType.BACKEND
groups = [
ps.GroupResult(
name="Структура",
checks=[
ps.CheckResult(ps.CheckStatus.OK, "src/api/v1", "ok"),
ps.CheckResult(ps.CheckStatus.FAIL, "main.py", "missing"),
],
),
ps.GroupResult(name="Coverage", checks=[ps.CheckResult(ps.CheckStatus.OK, "cov", "ok")]),
]
out = ps.format_output(ptype, groups)
assert "Project: backend" in out
assert "[OK] Структура" not in out
assert "[FAIL] Структура" in out
assert "Итог:" in out
assert "Рекомендации:" in out
assert "main.py" in out
def test_format_output_no_recommendations_when_all_ok():
groups = [
ps.GroupResult(name="Структура", checks=[ps.CheckResult(ps.CheckStatus.OK, "x", "ok")]),
]
out = ps.format_output(ps.ProjectType.BACKEND, groups)
assert "Рекомендации:" not in out
assert "FAIL: 0" in out
# ── main / exit codes ────────────────────────────────────────────────────────
def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys):
_make_backend_repo(tmp_path)
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
monkeypatch.setattr("sys.argv", ["project-status.py"])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 0
captured = capsys.readouterr()
assert "Project:" in captured.out
assert "Итог:" in captured.out
def test_main_check_strict_exit_1_on_fail(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 1
def test_main_check_strict_exit_0_when_all_ok(monkeypatch, tmp_path, capsys):
_make_backend_repo(tmp_path)
for rel in [".github/workflows", ".github"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
(tmp_path / "LICENSE").write_text("MIT\n")
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
_write_valid_readme(tmp_path)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "api"): (
0,
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
"",
),
}
),
)
monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
with pytest.raises(SystemExit) as exc:
ps.main()
captured = capsys.readouterr()
assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}\n{captured.out}"
def test_main_fast_flag(monkeypatch, tmp_path, capsys):
_make_backend_repo(tmp_path)
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "should not be called")})
)
monkeypatch.setattr("sys.argv", ["project-status.py", "--fast"])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 0
captured = capsys.readouterr()
assert "--fast" in captured.out
# ── run_all_checks integration ───────────────────────────────────────────────
def test_run_all_checks_returns_8_groups(tmp_path, ctx):
_make_backend_repo(tmp_path)
groups = ps.run_all_checks(ps.ProjectType.BACKEND, ctx, fast=True)
assert len(groups) == 8
assert [g.name for g in groups] == ps.CHECK_GROUPS
# ── check_pyproject (group 8, 13 checks) ─────────────────────────────────────
def _write_full_pyproject( # noqa: C901, PLR0912, PLR0915
tmp_path: Path,
*,
name: str = "test-repo",
description: str = "test repo",
requires_python: str = ">=3.12",
has_build_system: bool = True,
has_project_fields: bool = True,
has_ruff: bool = True,
has_mypy: bool = True,
mypy_strict: bool = True,
has_pytest: bool = True,
pytest_asyncio_auto: bool = True,
pytest_testpaths_tests: bool = True,
has_cov_run: bool = True,
cov_branch: bool = True,
has_cov_report_exclude: bool = True,
has_cov_fail_under: bool = True,
cov_fail: str = "80",
has_project_status_section: bool = False,
has_pre_commit: bool = True,
has_uv_lock: bool = True,
has_python_version: bool = True,
python_version_content: str = "3.12\n",
ruff_toml: bool = False,
mypy_ini: bool = False,
src_pkg_exists: bool = False,
hatch_packages_override: list | None = None,
) -> None:
"""Write a pyproject.toml with fine-grained control over every check section.
Used by the 13-check test suite for ``check_pyproject``. Each kwarg
toggles a specific check's pass/warn/fail condition.
"""
norm_name = ps._normalize_package_name(name)
lines: list[str] = []
if has_build_system:
lines.extend(
[
"[build-system]",
'requires = ["hatchling"]',
'build-backend = "hatchling.build"',
"",
]
)
project_lines = ["[project]", f'name = "{name}"', 'version = "0.1.0"']
if has_project_fields:
project_lines.append(f'description = "{description}"')
project_lines.append(f'requires-python = "{requires_python}"')
lines.extend([*project_lines, "", "dependencies = []", ""])
if src_pkg_exists:
(tmp_path / "src" / norm_name).mkdir(parents=True, exist_ok=True)
(tmp_path / "src" / norm_name / "__init__.py").write_text("")
lines.append("[tool.hatch.build.targets.wheel]")
lines.append(f'packages = ["src/{norm_name}"]')
lines.append("")
elif hatch_packages_override is not None:
lines.append("[tool.hatch.build.targets.wheel]")
pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages_override)
lines.append(f"packages = [{pkgs_str}]")
lines.append("")
if has_ruff and not ruff_toml:
lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
if ruff_toml:
(tmp_path / "ruff.toml").write_text("line-length = 100\ntarget-version = py312\n")
if has_mypy and not mypy_ini:
lines.extend(["[tool.mypy]"])
if mypy_strict:
lines.append("strict = true")
else:
lines.append('python_version = "3.12"')
lines.append("")
if mypy_ini:
(tmp_path / "mypy.ini").write_text("[mypy]\nstrict = True\n")
if has_pytest:
lines.append("[tool.pytest.ini_options]")
if pytest_asyncio_auto:
lines.append('asyncio_mode = "auto"')
if pytest_testpaths_tests:
lines.append('testpaths = ["tests"]')
addopts = "--cov=src --cov-report=term-missing"
if has_cov_fail_under and cov_fail:
addopts += f" --cov-fail-under={cov_fail}"
lines.append(f'addopts = "{addopts}"')
lines.append("")
if has_cov_run:
lines.append("[tool.coverage.run]")
lines.append('source = ["src"]')
if cov_branch:
lines.append("branch = true")
lines.append("")
if has_cov_report_exclude:
lines.append("[tool.coverage.report]")
lines.append("exclude_lines = [")
lines.append(' "pragma: no cover",')
lines.append(' "if __name__ == .__main__.:",')
lines.append(' "if TYPE_CHECKING:",')
lines.append("]")
lines.append("")
if has_project_status_section:
lines.extend(
[
"[tool.project-status]",
"thin_routes_max_lines = 50",
"cov_fail_under = 80",
'required_dirs_backend = ["src/api/v1"]',
"",
]
)
(tmp_path / "pyproject.toml").write_text("\n".join(lines))
if has_pre_commit:
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
if has_uv_lock:
(tmp_path / "uv.lock").write_text("# lockfile\n")
if has_python_version:
(tmp_path / ".python-version").write_text(python_version_content)
def test_check_pyproject_all_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK, (
f"expected OK, got {group.overall()}: "
+ ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
)
assert len(group.checks) == 13, f"expected 13 checks, got {len(group.checks)}"
def test_check_pyproject_no_pyproject_warn(tmp_path, ctx):
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any(c.name == "pyproject.toml" and c.status == ps.CheckStatus.WARN for c in group.checks)
assert len(group.checks) == 1
def test_check_pyproject_invalid_toml_fail(tmp_path, ctx):
(tmp_path / "pyproject.toml").write_text("not valid = = =")
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.FAIL
assert any("парсинг" in c.detail for c in group.checks)
def test_check_pyproject_check1_build_system_fail(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_build_system=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK
for c in group.checks
)
def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path, ctx):
"""src/<pkg>/ exists but packages doesn't reference it → FAIL."""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "test_repo").mkdir()
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
_write_full_pyproject(tmp_path, src_pkg_exists=False)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.FAIL
for c in group.checks
)
def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path, ctx):
"""packages=["src"] is deprecated flat layout → WARN (issue #241).
Inverted: ``packages = ["src"]`` was previously accepted as OK (hatchling
treats ``src/`` as package root). Now only ``packages = ["src/<pkg>"]``
is valid — ``["src"]`` is a deprecated flat layout.
"""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "test_repo").mkdir()
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.hatch.build.targets.wheel]"
and c.status == ps.CheckStatus.WARN
and "deprecated flat layout" in c.detail
for c in group.checks
)
def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path, ctx):
"""src/ exists but no src/<pkg>/ → WARN (deprecated flat layout, issue #241)."""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "api").mkdir()
_write_full_pyproject(tmp_path, src_pkg_exists=False)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.hatch.build.targets.wheel]"
and c.status == ps.CheckStatus.WARN
and "deprecated flat layout" in c.detail
for c in group.checks
)
def test_check_pyproject_check3_project_fields_fail(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_project_fields=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[project]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_check_pyproject_check4_ruff_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_pyproject_check4_ruff_toml_ok(tmp_path, ctx):
"""ruff.toml is an accepted alternative to [tool.ruff]."""
_write_full_pyproject(tmp_path, has_ruff=False, ruff_toml=True, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_pyproject_check4_ruff_missing_fail(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_ruff=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_check_pyproject_check5_mypy_strict_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_pyproject_check5_mypy_ini_ok(tmp_path, ctx):
"""mypy.ini is an accepted alternative to [tool.mypy]."""
_write_full_pyproject(tmp_path, has_mypy=False, mypy_ini=True, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_pyproject_check5_mypy_not_strict_warn(tmp_path, ctx):
_write_full_pyproject(tmp_path, mypy_strict=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_check_pyproject_check5_mypy_missing_fail(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_mypy=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
def test_check_pyproject_check6_pytest_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.OK
for c in group.checks
)
def test_check_pyproject_check6_pytest_missing_fail(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_pytest=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.FAIL
for c in group.checks
)
def test_check_pyproject_check6_pytest_wrong_mode_warn(tmp_path, ctx):
_write_full_pyproject(
tmp_path, pytest_asyncio_auto=False, pytest_testpaths_tests=False, src_pkg_exists=True
)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.WARN
for c in group.checks
)
def test_check_pyproject_check7_cov_run_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_pyproject_check7_cov_run_warn_no_branch(tmp_path, ctx):
_write_full_pyproject(tmp_path, cov_branch=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_pyproject_check8_cov_report_exclude_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_pyproject_check8_cov_report_exclude_warn(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_cov_report_exclude=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_pyproject_check9_cov_fail_under_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_pyproject_check9_cov_fail_under_warn(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_cov_fail_under=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.WARN
for c in group.checks
)
def test_check_pyproject_check10_project_status_section_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.project-status]" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_pyproject_check10_project_status_section_warn_default(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_project_status_section=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "[tool.project-status]" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_pyproject_check11_pre_commit_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_pyproject_check11_pre_commit_warn(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_pre_commit=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.WARN
for c in group.checks
)
def test_check_pyproject_check12_uv_lock_ok(tmp_path, ctx):
_write_full_pyproject(tmp_path, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.OK for c in group.checks)
def test_check_pyproject_check12_uv_lock_warn(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_uv_lock=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.WARN for c in group.checks)
def test_check_pyproject_check13_python_version_ok(tmp_path, ctx):
_write_full_pyproject(
tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True
)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.OK
for c in group.checks
)
def test_check_pyproject_check13_python_version_fail_incompat(tmp_path, ctx):
_write_full_pyproject(
tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True
)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.FAIL
for c in group.checks
)
def test_check_pyproject_check13_skip_when_no_python_version(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "requires-python vs .python-version"
and c.status == ps.CheckStatus.WARN
and "skip" in c.detail
for c in group.checks
)
# ── normalize_package_name ───────────────────────────────────────────────────
def test_normalize_package_name():
assert ps._normalize_package_name("my-project") == "my_project"
assert ps._normalize_package_name("My.Project") == "my_project"
assert ps._normalize_package_name("my_project") == "my_project"
assert ps._normalize_package_name("MY-PROJECT") == "my_project"
# ── --repo flag ──────────────────────────────────────────────────────────────
def test_main_repo_flag_nonexistent_path_exits_1(monkeypatch, tmp_path, capsys):
"""--repo with non-existent path → exit 1 + error message."""
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path / "nonexistent")])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 1
captured = capsys.readouterr()
assert "repo path not found" in captured.out
def test_main_repo_flag_overrides_repo_root(monkeypatch, tmp_path, capsys):
"""--repo with valid path → REPO_ROOT overridden, checks run against it."""
_make_backend_repo(tmp_path)
for rel in [".github/workflows", ".github"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
(tmp_path / "LICENSE").write_text("MIT\n")
_write_valid_readme(tmp_path)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "api"): (
0,
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
"",
),
}
),
)
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path)])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}"
captured = capsys.readouterr()
assert "Project:" in captured.out
assert "Итог:" in captured.out
def test_main_repo_flag_relative_path(monkeypatch, tmp_path, capsys):
"""--repo with relative path → resolved against cwd."""
_make_backend_repo(tmp_path)
for rel in [".github/workflows", ".github"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / "LICENSE").write_text("MIT\n")
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
# cwd = tmp_path, repo = "." (relative)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", "."])
with pytest.raises(SystemExit) as exc:
ps.main()
assert exc.value.code == 0
captured = capsys.readouterr()
assert "Project:" in captured.out
def test_check_pyproject_uses_repo_root(tmp_path, ctx):
"""check_pyproject reads pyproject.toml from REPO_ROOT (tmp_path fixture)."""
_write_full_pyproject(tmp_path, src_pkg_exists=True, has_project_status_section=True)
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK, (
f"expected OK, got {group.overall()}: "
+ ", ".join(
f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK
)
)
# ── flat-layout check in check_structure ──────────────────────────────────────
def test_check_structure_flat_layout_warn_for_cli(tmp_path, ctx):
"""CLI/UNKNOWN with src/ but no nested package → WARN flat src/."""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "api").mkdir()
(tmp_path / "src" / "db").mkdir()
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
group = ps.check_structure(ps.ProjectType.CLI, ctx)
assert any(
c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_structure_flat_warn_for_backend(tmp_path, ctx):
"""Backend with flat src/api/ (no nested package) → WARN flat src/ layout.
Inverted by issue #241: nested ``src/<package>/`` is the standard for
ALL types (backend included). Flat ``src/`` is deprecated → WARN.
"""
for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / "src/config/settings.py").write_text("settings = {}\n")
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
assert any(
c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
)
def test_check_structure_backend_nested_ok(tmp_path, ctx):
"""Backend with nested src/<pkg>/api/v1 → OK (no flat-layout WARN)."""
_make_backend_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.OK, (
f"expected OK, got {group.overall()}: "
+ ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
)
assert not any(c.name == "flat src/ layout" for c in group.checks)
def test_check_structure_no_flat_warn_when_nested_pkg(tmp_path, ctx):
"""CLI with src/<pkg>/__init__.py → no flat-layout WARN."""
(tmp_path / "src").mkdir()
pkg = tmp_path / "src" / "mycli"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}, name="mycli")
group = ps.check_structure(ps.ProjectType.CLI, ctx)
assert not any(c.name == "flat src/ layout" for c in group.checks)
# ── get_repo_full_name with repo arg ─────────────────────────────────────────
def test_get_repo_full_name_with_repo_arg(monkeypatch, tmp_path):
"""get_repo_full_name(repo=Path) uses git -C <repo>."""
(tmp_path / ".git").mkdir() # mark as git repo (not actually needed for mock)
# Override the default GIT_REMOTE_MOCK so only the -C form matches
def _mock(args: list[str]) -> tuple[int, str, str]:
if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
return (0, "https://github.com/slaid098/foo.git\n", "")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ps, "run_cmd", _mock)
assert ps.get_repo_full_name(tmp_path) == "slaid098/foo"
def test_get_repo_full_name_no_repo_arg_uses_cwd(monkeypatch):
"""get_repo_full_name() without repo arg → plain git remote (cwd)."""
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
assert ps.get_repo_full_name() == "slaid098/opencode-config"
# ── check_infra with repo_root (git -C) ─────────────────────────────────────
def test_check_infra_repo_root_git_c(monkeypatch, tmp_path, ctx):
"""check_infra(repo_root=...) → git -C <repo_root> for branch protection."""
for rel in [".github/workflows", ".github"]:
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / "LICENSE").write_text("MIT\n")
def _mock(args: list[str]) -> tuple[int, str, str]:
if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
return (0, "https://github.com/slaid098/bar.git\n", "")
if args[:2] == ["gh", "api"] and len(args) >= 3 and "repos/slaid098/bar" in args[2]:
return (0, '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', "")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ps, "run_cmd", _mock)
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False, repo_root=tmp_path)
assert any(
c.name == "branch protection" and c.status == ps.CheckStatus.OK for c in group.checks
)
def test_check_infra_repo_root_not_git_repo_warn(monkeypatch, tmp_path, ctx):
"""check_infra(repo_root=non-git) → WARN 'git remote недоступен'."""
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
(tmp_path / "LICENSE").write_text("MIT\n")
def _mock(args: list[str]) -> tuple[int, str, str]:
if args[:3] == ["git", "-C", str(tmp_path)]:
return (1, "", "not a git repo")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ps, "run_cmd", _mock)
group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False, repo_root=tmp_path)
assert any(
c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
)
# ── RepoCtx (no globals) ────────────────────────────────────────────────────
def test_repo_ctx_is_frozen(tmp_path):
"""RepoCtx is a frozen dataclass — immutable after construction."""
ctx = ps.RepoCtx(root=tmp_path, config={"route_line_limit": 50})
with pytest.raises((AttributeError, Exception)):
ctx.root = tmp_path / "other" # type: ignore[misc]
def test_repo_ctx_carries_root_and_config(tmp_path):
"""RepoCtx stores root path + config dict verbatim."""
cfg = {"route_line_limit": 80, "min_test_count": 3}
ctx = ps.RepoCtx(root=tmp_path, config=cfg)
assert ctx.root == tmp_path
assert ctx.config is cfg
def test_path_exists_uses_ctx_root(tmp_path, ctx):
"""path_exists(rel, ctx) checks ``ctx.root / rel``, not module global."""
(tmp_path / "marker.txt").write_text("x")
assert ps.path_exists("marker.txt", ctx) is True
assert ps.path_exists("absent.txt", ctx) is False
def test_read_text_uses_ctx_root(tmp_path, ctx):
"""read_text(rel, ctx) reads from ``ctx.root / rel``."""
(tmp_path / "f.txt").write_text("hello", encoding="utf-8")
assert ps.read_text("f.txt", ctx) == "hello"
assert ps.read_text("missing.txt", ctx) is None
def test_parse_pyproject_uses_ctx_root(tmp_path, ctx):
"""parse_pyproject(ctx) parses ``ctx.root / pyproject.toml``."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n')
data = ps.parse_pyproject(ctx)
assert data.get("project", {}).get("name") == "x"
empty_ctx = ps.RepoCtx(root=tmp_path / "nope", config={})
assert ps.parse_pyproject(empty_ctx) == {}
# ── check_pyproject 13 sub-functions (independent) ─────────────────────────
def _full_data(tmp_path, **overrides):
"""Build a fully-valid parsed pyproject dict + write side files."""
_write_full_pyproject(tmp_path, src_pkg_exists=True, **overrides)
return ps._load_pyproject(tmp_path)
def test_subfunc_check_build_system_ok(tmp_path, ctx):
data = _full_data(tmp_path)
assert ps._check_build_system(data).status == ps.CheckStatus.OK
def test_subfunc_check_build_system_fail(tmp_path, ctx):
data = _full_data(tmp_path, has_build_system=False)
assert ps._check_build_system(data).status == ps.CheckStatus.FAIL
def test_subfunc_check_hatch_packages_ok_nested(tmp_path, ctx):
data = _full_data(tmp_path)
assert (
ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND).status == ps.CheckStatus.OK
)
def test_subfunc_check_hatch_packages_warn_flat_src(tmp_path, ctx):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "test_repo").mkdir()
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
data = ps._load_pyproject(tmp_path)
res = ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND)
assert res.status == ps.CheckStatus.WARN
assert "deprecated flat layout" in res.detail
def test_subfunc_check_project_fields_ok(tmp_path, ctx):
data = _full_data(tmp_path)
project = data.get("project", {})
assert ps._check_project_fields(project).status == ps.CheckStatus.OK
def test_subfunc_check_project_fields_fail(tmp_path, ctx):
data = _full_data(tmp_path, has_project_fields=False)
project = data.get("project", {})
assert ps._check_project_fields(project).status == ps.CheckStatus.FAIL
def test_subfunc_check_ruff_section_ok(tmp_path, ctx):
data = _full_data(tmp_path)
assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_ruff_section_ruff_toml_ok(tmp_path, ctx):
data = _full_data(tmp_path, has_ruff=False, ruff_toml=True)
assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_ruff_section_missing_fail(tmp_path, ctx):
data = _full_data(tmp_path, has_ruff=False)
assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.FAIL
def test_subfunc_check_mypy_section_strict_ok(tmp_path, ctx):
data = _full_data(tmp_path)
assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_mypy_section_ini_ok(tmp_path, ctx):
data = _full_data(tmp_path, has_mypy=False, mypy_ini=True)
assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_mypy_section_not_strict_warn(tmp_path, ctx):
data = _full_data(tmp_path, mypy_strict=False)
assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN
def test_subfunc_check_mypy_section_missing_fail(tmp_path, ctx):
data = _full_data(tmp_path, has_mypy=False)
assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.FAIL
def test_subfunc_check_pytest_ini_options_ok(tmp_path, ctx):
data = _full_data(tmp_path)
tools = data.get("tool", {})
assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.OK
def test_subfunc_check_pytest_ini_options_missing_fail(tmp_path, ctx):
data = _full_data(tmp_path, has_pytest=False)
tools = data.get("tool", {})
assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.FAIL
def test_subfunc_check_coverage_run_ok(tmp_path, ctx):
data = _full_data(tmp_path)
tools = data.get("tool", {})
assert ps._check_coverage_run(tools).status == ps.CheckStatus.OK
def test_subfunc_check_coverage_run_warn_no_branch(tmp_path, ctx):
data = _full_data(tmp_path, cov_branch=False)
tools = data.get("tool", {})
assert ps._check_coverage_run(tools).status == ps.CheckStatus.WARN
def test_subfunc_check_coverage_report_ok(tmp_path, ctx):
data = _full_data(tmp_path)
tools = data.get("tool", {})
assert ps._check_coverage_report(tools).status == ps.CheckStatus.OK
def test_subfunc_check_coverage_report_warn(tmp_path, ctx):
data = _full_data(tmp_path, has_cov_report_exclude=False)
tools = data.get("tool", {})
assert ps._check_coverage_report(tools).status == ps.CheckStatus.WARN
def test_subfunc_check_addopts_cov_ok(tmp_path, ctx):
data = _full_data(tmp_path)
tools = data.get("tool", {})
pytest_opts = tools.get("pytest", {}).get("ini_options", {})
assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.OK
def test_subfunc_check_addopts_cov_warn(tmp_path, ctx):
data = _full_data(tmp_path, has_cov_fail_under=False)
tools = data.get("tool", {})
pytest_opts = tools.get("pytest", {}).get("ini_options", {})
assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.WARN
def test_subfunc_check_project_status_section_ok(tmp_path, ctx):
data = _full_data(tmp_path, has_project_status_section=True)
tools = data.get("tool", {})
assert ps._check_project_status_section(tools).status == ps.CheckStatus.OK
def test_subfunc_check_project_status_section_warn_default(tmp_path, ctx):
data = _full_data(tmp_path, has_project_status_section=False)
tools = data.get("tool", {})
assert ps._check_project_status_section(tools).status == ps.CheckStatus.WARN
def test_subfunc_check_pre_commit_ok(tmp_path, ctx):
_full_data(tmp_path)
assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_pre_commit_warn(tmp_path, ctx):
_full_data(tmp_path, has_pre_commit=False)
assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.WARN
def test_subfunc_check_uv_lock_ok(tmp_path, ctx):
_full_data(tmp_path)
assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_uv_lock_warn(tmp_path, ctx):
_full_data(tmp_path, has_uv_lock=False)
assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.WARN
def test_subfunc_check_python_version_compat_ok(tmp_path, ctx):
_write_full_pyproject(
tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True
)
data = ps._load_pyproject(tmp_path)
project = data.get("project", {})
assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.OK
def test_subfunc_check_python_version_compat_fail_incompat(tmp_path, ctx):
_write_full_pyproject(
tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True
)
data = ps._load_pyproject(tmp_path)
project = data.get("project", {})
assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.FAIL
def test_subfunc_check_python_version_compat_skip_no_file(tmp_path, ctx):
_write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True)
data = ps._load_pyproject(tmp_path)
project = data.get("project", {})
res = ps._check_python_version_compat(project, ctx.root)
assert res.status == ps.CheckStatus.WARN
assert "skip" in res.detail
def test_pyproject_checks_has_13_entries(tmp_path, ctx):
"""PYPROJECT_CHECKS dispatch table has exactly 13 entries (one per check)."""
assert len(ps.PYPROJECT_CHECKS) == 13
def test_load_pyproject_missing_returns_none(tmp_path, ctx):
"""_load_pyproject returns None when pyproject.toml absent."""
assert ps._load_pyproject(tmp_path) is None
def test_load_pyproject_parse_error_sentinel(tmp_path, ctx):
"""_load_pyproject returns dict with __parse_error__ key on bad TOML."""
(tmp_path / "pyproject.toml").write_text("not valid = = =")
data = ps._load_pyproject(tmp_path)
assert data is not None
assert "__parse_error__" in data
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.FAIL
assert any("парсинг" in c.detail for c in group.checks)
# ── scattered-models AST check (issue #251) ─────────────────────────────────
def _make_centralized_models_repo(tmp_path: Path) -> None:
"""Backend repo with all ORM models centralized in ``src/<pkg>/db/models/``.
Helper for scattered-models tests: produces the GOOD layout (no WARN).
"""
_make_backend_repo(tmp_path)
pkg = "test_repo"
(tmp_path / f"src/{pkg}/db/models/__init__.py").write_text("")
(tmp_path / f"src/{pkg}/db/models/user.py").write_text(
"from tortoise import Model, fields\nclass User(Model):\n name = fields.CharField()\n"
)
def test_scattered_models_centralized_ok(tmp_path, ctx):
"""All ORM models in ``src/<pkg>/db/models/`` → no scattered-models WARN."""
_make_centralized_models_repo(tmp_path)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert results == [], f"expected no WARN, got: {results}"
def test_scattered_models_feature_scatter_warn(tmp_path, ctx):
"""``class X(Model)`` in ``src/<pkg>/channels/models.py`` → WARN per file."""
_make_centralized_models_repo(tmp_path)
pkg = "test_repo"
scattered_files = [
f"src/{pkg}/channels/models.py",
f"src/{pkg}/monitor/models.py",
f"src/{pkg}/logs/models.py",
f"src/{pkg}/models.py",
]
for rel in scattered_files:
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"from tortoise import Model, fields\n"
"class Thing(Model):\n name = fields.CharField()\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert len(results) == 4, f"expected 4 WARN, got {len(results)}: {results}"
warned_paths = {r.name for r in results}
for rel in scattered_files:
assert rel in warned_paths, f"missing WARN for {rel}"
assert all(r.status == ps.CheckStatus.WARN for r in results)
def test_scattered_models_in_db_connection_warn(tmp_path, ctx):
"""Model in ``src/<pkg>/db/connection.py`` (inside db/, not models/) → WARN."""
_make_centralized_models_repo(tmp_path)
pkg = "test_repo"
(tmp_path / f"src/{pkg}/db/connection.py").write_text(
"from tortoise import Model, fields\nclass Internal(Model):\n x = fields.IntField()\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert len(results) == 1, f"expected 1 WARN, got: {results}"
assert results[0].name == f"src/{pkg}/db/connection.py"
assert results[0].status == ps.CheckStatus.WARN
def test_scattered_models_in_tests_ok(tmp_path, ctx):
"""Model in ``tests/`` (test model) → OK (skip, tests/ not in src/<pkg>/)."""
_make_centralized_models_repo(tmp_path)
(tmp_path / "tests/test_models.py").write_text(
"from tortoise import Model, fields\n"
"class TestModel(Model):\n x = fields.IntField()\n"
"def test_model(): assert TestModel\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert results == [], f"expected no WARN, got: {results}"
def test_scattered_models_flat_layout_skip(tmp_path, ctx):
"""Flat layout (no ``src/<pkg>/``) → skip check, no results."""
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
(tmp_path / "src").mkdir()
(tmp_path / "src/models.py").write_text(
"from tortoise import Model, fields\nclass Flat(Model):\n x = fields.IntField()\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert results == [], f"flat layout should skip, got: {results}"
def test_scattered_models_cli_skip(tmp_path, ctx):
"""CLI (no db) → skip scattered-models check (returns [])."""
(tmp_path / "src").mkdir()
pkg = tmp_path / "src" / "mycli"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "cli.py").write_text("from tortoise import Model\nclass CliModel(Model):\n pass\n")
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}, name="mycli")
results = ps._check_scattered_models(ctx, ps.ProjectType.CLI)
assert results == [], f"CLI should skip, got: {results}"
def test_scattered_models_no_tortoise_import_skip(tmp_path, ctx):
"""File with ``class X(Model)`` but no tortoise import → skip (false positive guard)."""
_make_centralized_models_repo(tmp_path)
pkg = "test_repo"
(tmp_path / f"src/{pkg}/services/other.py").write_text(
"# class from another lib defining its own Model\n"
"from some_other_lib import Model\n"
"class Other(Model):\n pass\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert results == [], f"non-tortoise Model should not WARN, got: {results}"
def test_scattered_models_via_import_from_models_module(tmp_path, ctx):
"""``class X(Model)`` where ``Model`` comes from own ``db.models`` → WARN."""
_make_centralized_models_repo(tmp_path)
pkg = "test_repo"
(tmp_path / f"src/{pkg}/services/user_service.py").write_text(
f"from {pkg}.db.models import Model\nclass ServiceModel(Model):\n pass\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert len(results) == 1, f"expected 1 WARN, got: {results}"
assert results[0].name == f"src/{pkg}/services/user_service.py"
assert results[0].status == ps.CheckStatus.WARN
def test_scattered_models_check_structure_integration(tmp_path, ctx):
"""``check_structure`` for BACKEND includes scattered-models WARNs."""
_make_centralized_models_repo(tmp_path)
pkg = "test_repo"
(tmp_path / f"src/{pkg}/channels").mkdir(parents=True, exist_ok=True)
(tmp_path / f"src/{pkg}/channels/models.py").write_text(
"from tortoise import Model, fields\nclass Chan(Model):\n name = fields.CharField()\n"
)
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
scattered_warns = [
c
for c in group.checks
if "channels/models.py" in c.name and c.status == ps.CheckStatus.WARN
]
assert scattered_warns, "expected scattered WARN in check_structure, got: " + ", ".join(
f"{c.name}={c.status.value}" for c in group.checks
)
def test_scattered_models_no_pyproject_skip(tmp_path, ctx):
"""No pyproject.toml (cannot resolve package name) → skip, no results."""
(tmp_path / "src").mkdir()
(tmp_path / "src/whatever.py").write_text(
"from tortoise import Model\nclass X(Model):\n pass\n"
)
results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
assert results == [], f"no pyproject should skip, got: {results}"
# ── issue #266: frontend stack detection (fullstack) ────────────────────────
def test_fullstack_frontend_stack_ok(tmp_path, ctx):
"""All 4 frontend markers present (tailwindcss + bits-ui + components.json
+ tsconfig.json) → OK."""
_make_fullstack_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
frontend_checks = [c for c in group.checks if c.name == "frontend stack"]
assert frontend_checks, f"expected 'frontend stack' check, got: {group.checks}"
assert frontend_checks[0].status == ps.CheckStatus.OK, (
f"expected OK, got {frontend_checks[0].status}: {frontend_checks[0].detail}"
)
def test_fullstack_frontend_stack_missing_tailwind(tmp_path, ctx):
"""package.json without ``tailwindcss`` dep → WARN."""
_make_fullstack_repo(tmp_path)
pkg = tmp_path / "frontend" / "package.json"
pkg.write_text('{"name": "test-frontend", "dependencies": {"bits-ui": "^1.0.0"}}\n')
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
frontend = [c for c in group.checks if c.name == "frontend stack"]
assert frontend and frontend[0].status == ps.CheckStatus.WARN
assert "tailwindcss" in frontend[0].detail
def test_fullstack_frontend_stack_missing_shadcn(tmp_path, ctx):
"""package.json without ``bits-ui`` dep (shadcn-svelte proxy) → WARN."""
_make_fullstack_repo(tmp_path)
pkg = tmp_path / "frontend" / "package.json"
pkg.write_text('{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0"}}\n')
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
frontend = [c for c in group.checks if c.name == "frontend stack"]
assert frontend and frontend[0].status == ps.CheckStatus.WARN
assert "bits-ui" in frontend[0].detail
def test_fullstack_frontend_stack_missing_components_json(tmp_path, ctx):
"""Missing ``frontend/components.json`` → WARN."""
_make_fullstack_repo(tmp_path)
(tmp_path / "frontend" / "components.json").unlink()
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
frontend = [c for c in group.checks if c.name == "frontend stack"]
assert frontend and frontend[0].status == ps.CheckStatus.WARN
assert "components.json" in frontend[0].detail
def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
"""Missing ``frontend/tsconfig.json`` → WARN."""
_make_fullstack_repo(tmp_path)
(tmp_path / "frontend" / "tsconfig.json").unlink()
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
frontend = [c for c in group.checks if c.name == "frontend stack"]
assert frontend and frontend[0].status == ps.CheckStatus.WARN
assert "tsconfig.json" in frontend[0].detail
# ── issue #274: db/models auto-detect from deps ──────────────────────────────
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"
)
# 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 = [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/<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)"
)