opencode-config/tests/test_project_status.py
Sergey 6bb115e706
feat(fullstack): mobile-first guarantee — cookiecutter + project-status (#280)
* feat(scripts): add MOBILE_FIRST_MARKERS + _check_mobile_first check

* feat(templates): fullstack mobile-first PWA manifest + mobile Playwright + axe

* test(scripts): cover mobile-first markers, check, cookiecutter structure

* fix(ci): reformat test_project_status.py string literals

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 06:46:20 +03:00

2468 lines
102 KiB
Python
Raw Permalink 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), 8 check groups, format output
([OK]/[WARN] + Итог + Рекомендации), exit codes (issue #275: exit code is
always 0 — ``--check`` is accepted but no longer forces exit 1), ``--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``.
# Issue #278: mobile-first markers (manifest, mobile.spec.ts, a11y.spec.ts,
# app.html viewport+manifest, @axe-core/playwright) are also present so the
# default fixture mirrors a freshly generated fullstack cookiecutter repo.
(tmp_path / "frontend" / "package.json").write_text(
'{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
'"bits-ui": "^1.0.0"}, "devDependencies": {"@axe-core/playwright": "^4.10.0"}}\n'
)
(tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
(tmp_path / "frontend" / "components.json").write_text("{}\n")
(tmp_path / "frontend" / "static").mkdir(parents=True, exist_ok=True)
(tmp_path / "frontend" / "static" / "manifest.webmanifest").write_text("{}\n")
(tmp_path / "frontend" / "static" / "icon.svg").write_text("<svg></svg>\n")
(tmp_path / "frontend" / "src").mkdir(parents=True, exist_ok=True)
(tmp_path / "frontend" / "src" / "app.html").write_text(
"<!doctype html><html><head>"
'<meta name="viewport" content="width=device-width, initial-scale=1" />'
'<link rel="manifest" href="/manifest.webmanifest" />'
"</head><body></body></html>\n"
)
(tmp_path / "frontend" / "tests" / "e2e").mkdir(parents=True, exist_ok=True)
(tmp_path / "frontend" / "tests" / "e2e" / "mobile.spec.ts").write_text("// mobile\n")
(tmp_path / "frontend" / "tests" / "e2e" / "accessibility.spec.ts").write_text("// a11y\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.WARN
assert any(
c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.WARN 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.WARN 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_warn(tmp_path, ctx):
"""Route импортирует ``src.db.models`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
), f"expected WARN on route imports, got: {group.checks}"
def test_thin_routes_import_tortoise_warn(tmp_path, ctx):
"""Route импортирует ``tortoise`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
), f"expected WARN on route imports, got: {group.checks}"
def test_thin_routes_import_submodule_of_src_db_models_warn(tmp_path, ctx):
"""``from src.db.models.user import User`` → WARN (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.WARN 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.WARN or c.name != "route imports" for c in group.checks
), f"expected no WARN on route imports, 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.WARN or c.name != "route imports" 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_warn(tmp_path, ctx):
"""Fullstack route importing ``src.db.models`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
), f"expected WARN on fullstack route imports, got: {group.checks}"
def test_thin_routes_fullstack_import_tortoise_warn(tmp_path, ctx):
"""Fullstack route importing ``tortoise`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
), f"expected WARN 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.WARN or c.name != "route imports" 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.WARN 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.WARN 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.WARN
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.WARN for c in group.checks)
def test_readme_missing_ru_switcher_warn(tmp_path, ctx):
"""README без ``[Русский](#-русский)`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
)
def test_readme_missing_support_link_warn(tmp_path, ctx):
"""README без обеих ссылок (contacts/support) → WARN (issue #275)."""
_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.WARN for c in group.checks
)
assert group.overall() == ps.CheckStatus.WARN
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_warn(tmp_path, ctx):
"""README без ``Quick Start`` (EN) → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks)
def test_readme_missing_bystriy_start_warn(tmp_path, ctx):
"""README без ``Быстрый старт`` (RU) → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks)
def test_readme_manual_license_section_warn(tmp_path, ctx):
"""README с ручной секцией ``## License`` → WARN (issue #275: non-blocking)."""
_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.WARN for c in group.checks
)
def test_readme_manual_license_ru_section_warn(tmp_path, ctx):
"""README с ``## Лицензия`` (RU) → WARN (issue #275: non-blocking)."""
_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.WARN 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_warn(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.WARN
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 "WARN: 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_flag_exit_0_always(monkeypatch, tmp_path, capsys):
"""``--check`` is accepted but exit code is always 0 (issue #275).
Previously ``--check`` forced exit 1 on FAIL; with the all-WARN contract
no check returns FAIL and the exit code is informational (always 0).
"""
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 == 0
def test_main_check_flag_exit_0_when_all_ok(monkeypatch, tmp_path, capsys):
"""``--check`` with a healthy repo → exit 0 (issue #275: always 0)."""
_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_warn(tmp_path, ctx):
(tmp_path / "pyproject.toml").write_text("not valid = = =")
group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
assert group.overall() == ps.CheckStatus.WARN
assert any("парсинг" in c.detail for c in group.checks)
def test_check_pyproject_check1_build_system_warn(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.WARN 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_warn_missing_packages(tmp_path, ctx):
"""src/<pkg>/ exists but packages doesn't reference it → WARN (issue #275)."""
(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.WARN
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_warn(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.WARN 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_warn(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.WARN 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_warn(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.WARN 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_warn(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.WARN
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_warn_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.WARN
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_warn(tmp_path, ctx):
data = _full_data(tmp_path, has_build_system=False)
assert ps._check_build_system(data).status == ps.CheckStatus.WARN
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_warn(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.WARN
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_warn(tmp_path, ctx):
data = _full_data(tmp_path, has_ruff=False)
assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.WARN
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_warn(tmp_path, ctx):
data = _full_data(tmp_path, has_mypy=False)
assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN
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_warn(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.WARN
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_warn_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.WARN
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.WARN
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 #278: mobile-first guarantee (fullstack only) ─────────────────────
def test_fullstack_mobile_first_ok(tmp_path, ctx):
"""All mobile-first markers present (manifest, mobile.spec.ts, a11y.spec.ts,
app.html viewport+manifest, @axe-core/playwright) → OK."""
_make_fullstack_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
mobile = [c for c in group.checks if c.name == "mobile-first"]
assert mobile, f"expected 'mobile-first' check, got: {group.checks}"
assert mobile[0].status == ps.CheckStatus.OK, (
f"expected OK, got {mobile[0].status}: {mobile[0].detail}"
)
def test_fullstack_mobile_first_warns_missing_manifest(tmp_path, ctx):
"""Missing ``frontend/static/manifest.webmanifest`` → WARN (non-blocking)."""
_make_fullstack_repo(tmp_path)
(tmp_path / "frontend" / "static" / "manifest.webmanifest").unlink()
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
mobile = [c for c in group.checks if c.name == "mobile-first"]
assert mobile and mobile[0].status == ps.CheckStatus.WARN, (
f"expected WARN for missing manifest, got: {mobile}"
)
assert "manifest.webmanifest" in mobile[0].detail
def test_fullstack_mobile_first_skips_backend(tmp_path, ctx):
"""Backend project type → ``_check_mobile_first`` not invoked (skip).
Mobile-first is a FULLSTACK-only guarantee; the structure group for
backend must not contain a "mobile-first" check.
"""
_make_backend_repo(tmp_path)
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
mobile = [c for c in group.checks if c.name == "mobile-first"]
assert not mobile, f"backend must skip mobile-first, got: {mobile}"
# ── 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)"
)
# ── issue #275: all-WARN non-blocking contract ──────────────────────────────
def test_all_checks_are_warn(tmp_path, ctx):
"""No check-function returns FAIL — every problem is WARN (issue #275).
Builds a backend repo missing several expected pieces (no tests dir,
missing ruff/mypy sections, missing README) and asserts that across all
8 groups no CheckResult carries ``CheckStatus.FAIL``. ``FAIL`` is kept
in the enum for backward compatibility (tests import it, externally
constructed results roll up to FAIL), but no check-function emits it.
"""
# minimal pyproject with fastapi dep but NO ruff/mypy/pytest sections
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], has_ruff=False, has_mypy=False)
(tmp_path / "main.py").write_text(
"from contextlib import asynccontextmanager\n"
"@asynccontextmanager\nasync def lifespan(app): yield\n"
)
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")
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
try:
groups = ps.run_all_checks(ps.ProjectType.BACKEND, ctx, fast=True)
finally:
monkeypatch.undo()
failures = [
(g.name, c.name, c.status.value)
for g in groups
for c in g.checks
if c.status == ps.CheckStatus.FAIL
]
assert not failures, f"issue #275 violation — FAIL checks found: {failures}"
def test_exit_code_always_zero(monkeypatch, tmp_path, capsys):
"""``main()`` exits 0 even when the repo has problems (issue #275).
A repo with no pyproject, no tests, no README → many WARN, but exit code
is 0 (informational mode). ``--check`` is accepted but no longer forces 1.
"""
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 "WARN" in captured.out
# Итог line present and shows OK/WARN only (no FAIL — issue #275)
assert "Итог:" in captured.out
assert "WARN: " in captured.out
assert "FAIL:" not in captured.out