diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index 1a8466b..b94f7a3 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -40,7 +40,7 @@ from enum import StrEnum from pathlib import Path from typing import Any -# ── repo root + config ─────────────────────────────────────────────────────── +# ── repo root + config ───────────────────────────────────────────────────────── def _resolve_repo_root(repo_override: str | None = None) -> Path: @@ -50,8 +50,7 @@ def _resolve_repo_root(repo_override: str | None = None) -> Path: Otherwise, resolve via git (cwd-aware), fallback to script location. """ if repo_override: - p = Path(repo_override).resolve() - return p + return Path(repo_override).resolve() result = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False ) @@ -71,14 +70,15 @@ DEFAULT_CONFIG: dict[str, Any] = { } -def load_config() -> dict[str, Any]: +def load_config(root: Path | None = None) -> dict[str, Any]: """Load thresholds from ``[tool.project-status]`` in pyproject.toml. Falls back to ``DEFAULT_CONFIG`` if the section or file is missing. Uses ``tomllib`` (stdlib, Python 3.11+). Reads only — never writes. """ + base = root if root is not None else REPO_ROOT cfg: dict[str, Any] = dict(DEFAULT_CONFIG) - pyproject = REPO_ROOT / "pyproject.toml" + pyproject = base / "pyproject.toml" if not pyproject.exists(): return cfg try: @@ -146,6 +146,19 @@ class GroupResult: return CheckStatus.OK +@dataclass(frozen=True) +class RepoCtx: + """Immutable repo context: root path + config thresholds. + + Passed explicitly to all check-functions to avoid module-level globals + (``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in + ``pipeline-status.py``. + """ + + root: Path + config: dict[str, Any] + + # ── helpers ────────────────────────────────────────────────────────────────── @@ -155,14 +168,16 @@ def run_cmd(args: list[str]) -> tuple[int, str, str]: return result.returncode, result.stdout, result.stderr -def path_exists(rel: str) -> bool: - """True if ``REPO_ROOT / rel`` exists.""" - return (REPO_ROOT / rel).exists() +def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool: + """True if ``root / rel`` exists (``ctx`` preferred, else module global).""" + root = ctx.root if ctx is not None else REPO_ROOT + return (root / rel).exists() -def read_text(rel: str) -> str | None: - """Read text content of ``REPO_ROOT / rel`` or None if missing.""" - p = REPO_ROOT / rel +def read_text(rel: str, ctx: RepoCtx | None = None) -> str | None: + """Read text content of ``root / rel`` or None if missing.""" + root = ctx.root if ctx is not None else REPO_ROOT + p = root / rel if not p.exists(): return None try: @@ -171,9 +186,9 @@ def read_text(rel: str) -> str | None: return None -def parse_pyproject() -> dict[str, Any]: +def parse_pyproject(ctx: RepoCtx | None = None) -> dict[str, Any]: """Parse pyproject.toml into a dict (or empty dict on failure).""" - raw = read_text("pyproject.toml") + raw = read_text("pyproject.toml", ctx) if raw is None: return {} try: @@ -218,13 +233,13 @@ def get_repo_full_name(repo: Path | None = None) -> str | None: # ── auto-detect ────────────────────────────────────────────────────────────── -def _resolve_package_name() -> str | None: +def _resolve_package_name(ctx: RepoCtx | None = None) -> str | None: """Resolve the project's normalized package name from ``[project].name``. Returns ``None`` if ``pyproject.toml`` is missing or has no ``[project].name``. Normalization: ``my-project`` → ``my_project`` (PEP 503-ish, ``[-_.]+`` → ``_``). """ - pyproject = parse_pyproject() + pyproject = parse_pyproject(ctx) if not isinstance(pyproject, dict): return None project = pyproject.get("project", {}) @@ -236,7 +251,7 @@ def _resolve_package_name() -> str | None: return _normalize_package_name(name) -def _matches_backend(deps_lower: str) -> bool: +def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool: """True if nested ``src//api/v1`` + fastapi/uvicorn in deps. Falls back to flat ``src/api/v1`` detection (with the caller surfacing @@ -244,37 +259,37 @@ def _matches_backend(deps_lower: str) -> bool: """ if "fastapi" not in deps_lower and "uvicorn" not in deps_lower: return False - pkg = _resolve_package_name() + pkg = _resolve_package_name(ctx) if pkg is not None: - return path_exists(f"src/{pkg}/api/v1") and path_exists(f"src/{pkg}/db/models") - return path_exists("src/api/v1") and path_exists("src/db/models") + return path_exists(f"src/{pkg}/api/v1", ctx) and path_exists(f"src/{pkg}/db/models", ctx) + return path_exists("src/api/v1", ctx) and path_exists("src/db/models", ctx) -def _detect_simple_type(deps_lower: str) -> ProjectType | None: +def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None: """Detect bot/worker types by file or dep marker (or None).""" - if path_exists("src/bot.py") or "aiogram" in deps_lower: + if path_exists("src/bot.py", ctx) or "aiogram" in deps_lower: return ProjectType.BOT - if path_exists("src/flow.py") or "prefect" in deps_lower: + if path_exists("src/flow.py", ctx) or "prefect" in deps_lower: return ProjectType.WORKER return None -def detect_project_type() -> ProjectType: +def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType: """Auto-detect project type from filesystem + pyproject.toml. Order matters: fullstack (most specific) → backend → bot → worker → cli. Falls back to ``UNKNOWN`` if nothing matches. """ - if path_exists("frontend") and path_exists("backend"): + if path_exists("frontend", ctx) and path_exists("backend", ctx): return ProjectType.FULLSTACK - pyproject = parse_pyproject() + pyproject = parse_pyproject(ctx) deps_raw = pyproject.get("project", {}).get("dependencies", []) deps_lower = " ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else "" - if _matches_backend(deps_lower): + if _matches_backend(deps_lower, ctx): return ProjectType.BACKEND - simple = _detect_simple_type(deps_lower) + simple = _detect_simple_type(deps_lower, ctx) if simple is not None: return simple @@ -337,9 +352,9 @@ README_DELIMITERS: list[str] = [ # ── check group 1: structure ───────────────────────────────────────────────── -def _check_backend_lifespan() -> CheckResult: +def _check_backend_lifespan(ctx: RepoCtx) -> CheckResult: """Check main.py has a lifespan handler (backend-specific).""" - main = read_text("main.py") + main = read_text("main.py", ctx) if main is None: return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") if "lifespan" in main: @@ -347,9 +362,9 @@ def _check_backend_lifespan() -> CheckResult: return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден") -def _check_cli_package() -> CheckResult: +def _check_cli_package(ctx: RepoCtx) -> CheckResult: """Check src// with __init__.py exists (cli-specific).""" - src = REPO_ROOT / "src" + src = ctx.root / "src" if src.exists() and any(p.is_dir() and (p / "__init__.py").exists() for p in src.iterdir()): return CheckResult(CheckStatus.OK, "src//", "пакет найден") return CheckResult(CheckStatus.FAIL, "src//", "пакет не найден") @@ -364,7 +379,7 @@ def _normalize_package_name(name: str) -> str: return re.sub(r"[-_.]+", "_", name).lower() -def _check_flat_layout(ptype: ProjectType) -> CheckResult | None: +def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None: """Check for flat ``src/`` layout (no nested package dir). Applies to ALL types (backend, fullstack, cli, bot, worker, unknown): @@ -375,10 +390,10 @@ def _check_flat_layout(ptype: ProjectType) -> CheckResult | None: ``__init__.py``, or has a subdir matching ``[project].name`` (normalized). Returns a WARN CheckResult if flat layout detected. """ - src = REPO_ROOT / "src" + src = ctx.root / "src" if not src.exists() or not src.is_dir(): return None - pyproject = parse_pyproject() + pyproject = parse_pyproject(ctx) project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} proj_name = project.get("name") if isinstance(project, dict) else None expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None @@ -395,28 +410,28 @@ def _check_flat_layout(ptype: ProjectType) -> CheckResult | None: ) -def _check_type_specific_structure(ptype: ProjectType) -> list[CheckResult]: +def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]: """Type-specific extra checks beyond the expected dirs list.""" results: list[CheckResult] = [] if ptype == ProjectType.BACKEND: - results.append(_check_backend_lifespan()) - elif ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json"): + results.append(_check_backend_lifespan(ctx)) + elif ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx): results.append( CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен") ) elif ptype == ProjectType.CLI: - results.append(_check_cli_package()) - flat = _check_flat_layout(ptype) + results.append(_check_cli_package(ctx)) + flat = _check_flat_layout(ptype, ctx) if flat is not None: results.append(flat) return results -def check_structure(ptype: ProjectType) -> GroupResult: +def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 1: Structure — expected dirs/files per project type.""" group = GroupResult(name="Структура") if ptype == ProjectType.BACKEND: - pkg = _resolve_package_name() + pkg = _resolve_package_name(ctx) if pkg is None: group.checks.append( CheckResult( @@ -435,10 +450,10 @@ def check_structure(ptype: ProjectType) -> GroupResult: ) return group for rel in expected: - status = CheckStatus.OK if path_exists(rel) else CheckStatus.FAIL + status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL detail = "существует" if status == CheckStatus.OK else "отсутствует" group.checks.append(CheckResult(status, rel, detail)) - group.checks.extend(_check_type_specific_structure(ptype)) + group.checks.extend(_check_type_specific_structure(ptype, ctx)) return group @@ -481,21 +496,21 @@ def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) -> return False -def _api_dirs_for(ptype: ProjectType) -> list[Path]: +def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]: """Return list of api/v1 dirs to scan for routes, based on project type.""" if ptype == ProjectType.BACKEND: - pkg = _resolve_package_name() + pkg = _resolve_package_name(ctx) if pkg is None: return [] - root = REPO_ROOT / "src" / pkg / "api" / "v1" + root = ctx.root / "src" / pkg / "api" / "v1" return [root] if root.exists() else [] if ptype == ProjectType.FULLSTACK: - root = REPO_ROOT / "backend" / "src" / "api" / "v1" + root = ctx.root / "backend" / "src" / "api" / "v1" return [root] if root.exists() else [] return [] -def _scan_route_files(api_dirs: list[Path], limit: int) -> tuple[int, int, list[str]]: +def _scan_route_files(api_dirs: list[Path], limit: int, ctx: RepoCtx) -> tuple[int, int, list[str]]: """Scan api dirs for route handlers; return (files_checked, longest, over_limit).""" files_checked = 0 longest = 0 @@ -509,11 +524,11 @@ def _scan_route_files(api_dirs: list[Path], limit: int) -> tuple[int, int, list[ files_checked += 1 longest = max(longest, n) if n > limit: - over_limit.append(f"{py.relative_to(REPO_ROOT)}:{n}") + over_limit.append(f"{py.relative_to(ctx.root)}:{n}") return files_checked, longest, over_limit -def check_thin_routes(ptype: ProjectType, fast: bool = False) -> GroupResult: +def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult: """Group 2: Тонкие роуты — AST parse, ≤ route_line_limit lines per handler.""" _ = fast # unused here, accepted for signature uniformity group = GroupResult(name="Тонкие роуты") @@ -522,14 +537,14 @@ def check_thin_routes(ptype: ProjectType, fast: bool = False) -> GroupResult: CheckResult(CheckStatus.OK, "skip", f"тип={ptype.value}: роуты не применимы") ) return group - api_dirs = _api_dirs_for(ptype) + api_dirs = _api_dirs_for(ptype, ctx) if not api_dirs: group.checks.append( CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена") ) return group - limit = int(CONFIG.get("route_line_limit", 50)) - files_checked, longest, over_limit = _scan_route_files(api_dirs, limit) + limit = int(ctx.config.get("route_line_limit", 50)) + files_checked, longest, over_limit = _scan_route_files(api_dirs, limit, ctx) if files_checked == 0: group.checks.append(CheckResult(CheckStatus.WARN, "AST", "роуты не найдены в src/api/v1/")) elif over_limit: @@ -552,10 +567,10 @@ def check_thin_routes(ptype: ProjectType, fast: bool = False) -> GroupResult: # ── check group 3: code quality (mypy/ruff/pytest presence) ────────────────── -def check_quality(ptype: ProjectType) -> GroupResult: +def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.""" group = GroupResult(name="Качество кода") - pyproject = parse_pyproject() + pyproject = parse_pyproject(ctx) tools = pyproject.get("tool", {}) for tool_name in ("ruff", "mypy"): if tool_name in tools: @@ -577,10 +592,10 @@ def check_quality(ptype: ProjectType) -> GroupResult: # ── check group 4: tests (conftest, stub-detector, no @pytest.mark.asyncio) ── -def check_tests(ptype: ProjectType) -> GroupResult: +def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 4: Тесты — conftest, no @pytest.mark.asyncio, ≥1 test file.""" group = GroupResult(name="Тесты") - tests_dir = REPO_ROOT / "tests" + tests_dir = ctx.root / "tests" if not tests_dir.exists(): group.checks.append( CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует") @@ -595,7 +610,7 @@ def check_tests(ptype: ProjectType) -> GroupResult: ) ) test_files = list(tests_dir.glob("test_*.py")) - min_tests = int(CONFIG.get("min_test_count", 1)) + min_tests = int(ctx.config.get("min_test_count", 1)) if len(test_files) >= min_tests: group.checks.append(CheckResult(CheckStatus.OK, "test files", f"{len(test_files)} файлов")) else: @@ -640,10 +655,10 @@ def check_tests(ptype: ProjectType) -> GroupResult: # ── check group 5: README (12 delimiter tags) ──────────────────────────────── -def check_readme(ptype: ProjectType) -> GroupResult: +def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 5: README — 12 delimiter tags from create-readme.ts:140-199.""" group = GroupResult(name="README") - content = read_text("README.md") + content = read_text("README.md", ctx) if content is None: group.checks.append(CheckResult(CheckStatus.FAIL, "README.md", "отсутствует")) return group @@ -690,7 +705,7 @@ def _check_branch_protection(repo: str) -> CheckResult: def check_infra( - ptype: ProjectType, fast: bool = False, repo_root: Path | None = None + ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None ) -> GroupResult: """Group 6: Infra — branch protection, ci.yml, dependabot, LICENSE, pre-commit. @@ -698,7 +713,7 @@ def check_infra( for branch protection detection. """ group = GroupResult(name="Infra") - if path_exists(".github/workflows/ci.yml"): + if path_exists(".github/workflows/ci.yml", ctx): group.checks.append(CheckResult(CheckStatus.OK, ".github/workflows/ci.yml", "есть")) else: group.checks.append( @@ -708,17 +723,17 @@ def check_infra( "отсутствует — CI не настроен", ) ) - if path_exists(".github/dependabot.yml"): + if path_exists(".github/dependabot.yml", ctx): group.checks.append(CheckResult(CheckStatus.OK, "dependabot.yml", "настроен")) else: group.checks.append( CheckResult(CheckStatus.WARN, "dependabot.yml", "обновления зависимостей вручную") ) - if path_exists("LICENSE"): + if path_exists("LICENSE", ctx): group.checks.append(CheckResult(CheckStatus.OK, "LICENSE", "есть")) else: group.checks.append(CheckResult(CheckStatus.FAIL, "LICENSE", "отсутствует")) - if path_exists(".pre-commit-config.yaml"): + if path_exists(".pre-commit-config.yaml", ctx): group.checks.append(CheckResult(CheckStatus.OK, "pre-commit", "настроен")) else: group.checks.append( @@ -744,10 +759,10 @@ def check_infra( # ── check group 7: coverage (non-blocking) ─────────────────────────────────── -def check_coverage(ptype: ProjectType) -> GroupResult: +def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).""" group = GroupResult(name="Coverage") - pyproject = parse_pyproject() + pyproject = parse_pyproject(ctx) cov = pyproject.get("tool", {}).get("coverage", {}) run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {} sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else [] @@ -802,282 +817,224 @@ def _mypy_strict(pyproject: dict[str, Any]) -> bool: return bool(mypy.get("strict")) or bool(mypy.get("disallow_untyped_defs")) -def _check_python_version_compat(requires_python: str, python_version_file: str) -> CheckResult: +def _check_python_version_compat(project: dict[str, Any], root: Path) -> CheckResult: """Check 13: requires-python vs .python-version compatibility. Uses ``packaging.specifiers.SpecifierSet.contains()``. FAIL if the pinned version in ``.python-version`` is not contained in the requires-python set. + WARN if ``.python-version`` or ``requires-python`` is missing. """ + name = "requires-python vs .python-version" + python_version_path = root / ".python-version" + requires_python = project.get("requires-python", "") if isinstance(project, dict) else "" + if not python_version_path.exists(): + return CheckResult(CheckStatus.WARN, name, ".python-version отсутствует — skip") + if not requires_python: + return CheckResult(CheckStatus.WARN, name, "requires-python не задан — skip") + try: + pv_content = python_version_path.read_text(encoding="utf-8-sig") + except OSError: + pv_content = "" + return _python_version_compat_impl(str(requires_python), pv_content) + + +def _python_version_compat_impl(requires_python: str, python_version_file: str) -> CheckResult: + """Impl for ``_check_python_version_compat``: packaging-based version check. + + Soft-dependency on ``packaging`` — WARN on ImportError (legitimate + ``# noqa: PLC0415`` for lazy import). + """ + name = "requires-python vs .python-version" try: from packaging.specifiers import SpecifierSet # noqa: PLC0415 except ImportError: - return CheckResult( - CheckStatus.WARN, - "requires-python vs .python-version", - "packaging не установлен — проверка пропущена", - ) + return CheckResult(CheckStatus.WARN, name, "packaging не установлен — проверка пропущена") pinned = python_version_file.strip() - # Strip possible prefix like "3.13" from "python3.13" m = re.search(r"(\d+\.\d+)", pinned) if not m: return CheckResult( - CheckStatus.WARN, - "requires-python vs .python-version", - f"не удалось распарсить версию из .python-version: {pinned!r}", + CheckStatus.WARN, name, f"не удалось распарсить версию из .python-version: {pinned!r}" ) version = m.group(1) try: spec = SpecifierSet(requires_python) except ValueError as e: - return CheckResult( - CheckStatus.FAIL, - "requires-python vs .python-version", - f"неверный requires-python: {e}", - ) + return CheckResult(CheckStatus.FAIL, name, f"неверный requires-python: {e}") if spec.contains(version, prereleases=True): return CheckResult( - CheckStatus.OK, - "requires-python vs .python-version", - f"requires-python={requires_python!r} включает {version}", + CheckStatus.OK, name, f"requires-python={requires_python!r} включает {version}" ) return CheckResult( - CheckStatus.FAIL, - "requires-python vs .python-version", - f"requires-python={requires_python!r} не включает {version}", + CheckStatus.FAIL, name, f"requires-python={requires_python!r} не включает {version}" ) -def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912, PLR0915 - """Group 8: pyproject.toml — 13 checks (FAIL/WARN). +def _load_pyproject(root: Path) -> dict[str, Any] | None: + """Load and parse ``pyproject.toml``; return None on missing or parse error. - Parses ``pyproject.toml`` via ``tomllib`` (stdlib, Python 3.11+). If the - file is missing → single WARN and skip. See issue #234 for the full spec. + Returns the parsed dict on success, ``None`` if the file is missing, + or ``{}``-sentinel handled by caller on parse error. The caller + distinguishes missing (WARN) from unparseable (FAIL) via a sentinel: + ``None`` = missing, ``{"__parse_error__": str}`` = failed parse. """ - group = GroupResult(name="Pyproject") - pyproject_path = REPO_ROOT / "pyproject.toml" + pyproject_path = root / "pyproject.toml" if not pyproject_path.exists(): - group.checks.append( - CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks") - ) - return group + return None try: with pyproject_path.open("rb") as f: - data = tomllib.load(f) + return tomllib.load(f) except (OSError, ValueError) as e: - group.checks.append(CheckResult(CheckStatus.FAIL, "pyproject.toml", f"парсинг failed: {e}")) - return group + return {"__parse_error__": str(e)} - tools = data.get("tool", {}) if isinstance(data, dict) else {} - project = data.get("project", {}) if isinstance(data, dict) else {} + +def _check_build_system(data: dict[str, Any]) -> CheckResult: + """Check 1: ``[build-system]`` requires hatchling + hatchling.build backend.""" build = data.get("build-system", {}) if isinstance(data, dict) else {} - - # ── Check 1: [build-system] ── requires = build.get("requires", []) if isinstance(build, dict) else [] build_backend = build.get("build-backend", "") if isinstance(build, dict) else "" requires_ok = isinstance(requires, list) and any("hatchling" in str(r) for r in requires) if requires_ok and build_backend == "hatchling.build": - group.checks.append(CheckResult(CheckStatus.OK, "[build-system]", "hatchling настроен")) - else: - group.checks.append( - CheckResult( - CheckStatus.FAIL, - "[build-system]", - f"требуется hatchling (requires={requires!r}, backend={build_backend!r})", - ) - ) + return CheckResult(CheckStatus.OK, "[build-system]", "hatchling настроен") + return CheckResult( + CheckStatus.FAIL, + "[build-system]", + f"требуется hatchling (requires={requires!r}, backend={build_backend!r})", + ) - # ── Check 2: [tool.hatch.build.targets.wheel] packages ── + +def _check_hatch_packages_nested(hatch_packages: object, src_pkg_path: str) -> CheckResult: + """Sub-check of check 2: classify hatch packages when ``src//`` exists. + + Returns OK if packages references ``src/``; WARN if ``["src"]`` flat + layout; FAIL otherwise. + """ + name = "[tool.hatch.build.targets.wheel]" + valid_packages = {src_pkg_path} + if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages): + return CheckResult(CheckStatus.OK, name, f"packages={hatch_packages!r}") + if isinstance(hatch_packages, list) and "src" in hatch_packages: + return CheckResult( + CheckStatus.WARN, + name, + f"deprecated flat layout — use packages=['{src_pkg_path}'], got={hatch_packages!r}", + ) + return CheckResult( + CheckStatus.FAIL, name, f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}' + ) + + +def _check_hatch_packages(data: dict[str, Any], root: Path, ptype: ProjectType) -> CheckResult: + """Check 2: ``[tool.hatch.build.targets.wheel]`` packages layout. + + Nested ``src//`` is the standard; ``packages=["src"]`` (flat) → WARN; + missing ``src//`` with ``src/`` present → WARN (deprecated flat). + """ + name = "[tool.hatch.build.targets.wheel]" + tools = data.get("tool", {}) if isinstance(data, dict) else {} + project = data.get("project", {}) if isinstance(data, dict) else {} proj_name = project.get("name", "") if isinstance(project, dict) else "" expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else "" src_pkg_path = f"src/{expected_pkg}" - src_pkg_dir_exists = expected_pkg and (REPO_ROOT / "src" / expected_pkg).is_dir() + src_pkg_dir_exists = bool(expected_pkg) and (root / "src" / expected_pkg).is_dir() hatch_targets = ( tools.get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}) if isinstance(tools, dict) else {} ) hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else [] - src_dir_exists = (REPO_ROOT / "src").exists() if src_pkg_dir_exists: - # nested-layout: packages must reference ``src/`` — the only - # valid hatchling config. ``packages = ["src"]`` (flat convention) - # is deprecated → WARN (treats ``src/`` as package root, prevents - # ``my-lib = { git = "..." }`` reuse). - valid_packages = {src_pkg_path} - if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages): - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.hatch.build.targets.wheel]", - f"packages={hatch_packages!r}", - ) - ) - elif isinstance(hatch_packages, list) and "src" in hatch_packages: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.hatch.build.targets.wheel]", - f"deprecated flat layout — use packages=['{src_pkg_path}'], " - f"got={hatch_packages!r}", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.FAIL, - "[tool.hatch.build.targets.wheel]", - f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}', - ) - ) - elif src_dir_exists: - # src/ exists but no src// — deprecated flat layout for ALL - # types. WARN: not a publishable package layout. - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.hatch.build.targets.wheel]", - f"нет src/{expected_pkg}/ — deprecated flat layout, use src//", - ) - ) - # No src/ — CLI without library-ambitions - elif isinstance(hatch_packages, list) and hatch_packages: - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.hatch.build.targets.wheel]", - f"packages={hatch_packages!r}", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.hatch.build.targets.wheel]", - "нет src/ и нет packages — OK для CLI без library-ambitions", - ) + return _check_hatch_packages_nested(hatch_packages, src_pkg_path) + if (root / "src").exists(): + return CheckResult( + CheckStatus.WARN, + name, + f"нет src/{expected_pkg}/ — deprecated flat layout, use src//", ) + if isinstance(hatch_packages, list) and hatch_packages: + return CheckResult(CheckStatus.OK, name, f"packages={hatch_packages!r}") + return CheckResult( + CheckStatus.WARN, name, "нет src/ и нет packages — OK для CLI без library-ambitions" + ) - # ── Check 3: [project] name, version, description, requires-python ── - required_project = ["name", "version", "description", "requires-python"] - missing_project = [ - f for f in required_project if not project.get(f) if isinstance(project, dict) - ] - if not missing_project: - group.checks.append( - CheckResult( - CheckStatus.OK, - "[project]", - f"name={project.get('name')!r}, version={project.get('version')!r}", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.FAIL, - "[project]", - f"отсутствуют поля: {', '.join(missing_project)}", - ) - ) - # ── Check 4: [tool.ruff] or ruff.toml ── +def _check_project_fields(project: dict[str, Any]) -> CheckResult: + """Check 3: ``[project]`` has name, version, description, requires-python.""" + required = ["name", "version", "description", "requires-python"] + missing = [f for f in required if not project.get(f)] if isinstance(project, dict) else required + if not missing: + return CheckResult( + CheckStatus.OK, + "[project]", + f"name={project.get('name')!r}, version={project.get('version')!r}", + ) + return CheckResult(CheckStatus.FAIL, "[project]", f"отсутствуют поля: {', '.join(missing)}") + + +def _check_ruff_section(data: dict[str, Any], root: Path) -> CheckResult: + """Check 4: ``[tool.ruff]`` section or ``ruff.toml`` with line-length + target-version.""" + name = "[tool.ruff]" + tools = data.get("tool", {}) if isinstance(data, dict) else {} ruff_section = tools.get("ruff", {}) if isinstance(tools, dict) else {} - if _has_ruff_config(data, REPO_ROOT): - if isinstance(ruff_section, dict) and ruff_section: - has_ll = "line-length" in ruff_section - has_tv = "target-version" in ruff_section - if has_ll and has_tv: - group.checks.append( - CheckResult( - CheckStatus.OK, "[tool.ruff]", "line-length + target-version настроены" - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.ruff]", - f"минимум: line-length, target-version (есть: " - f"{'ll' if has_ll else ''}{'+' if has_ll and has_tv else ''}" - f"{'tv' if has_tv else ''})", - ) - ) - else: - group.checks.append(CheckResult(CheckStatus.OK, "[tool.ruff]", "ruff.toml обнаружен")) - else: - group.checks.append( - CheckResult(CheckStatus.FAIL, "[tool.ruff]", "секция отсутствует (и нет ruff.toml)") + if not _has_ruff_config(data, root): + return CheckResult(CheckStatus.FAIL, name, "секция отсутствует (и нет ruff.toml)") + if isinstance(ruff_section, dict) and ruff_section: + has_ll = "line-length" in ruff_section + has_tv = "target-version" in ruff_section + if has_ll and has_tv: + return CheckResult(CheckStatus.OK, name, "line-length + target-version настроены") + return CheckResult( + CheckStatus.WARN, + name, + f"минимум: line-length, target-version (есть: " + f"{'ll' if has_ll else ''}{'+' if has_ll and has_tv else ''}" + f"{'tv' if has_tv else ''})", ) + return CheckResult(CheckStatus.OK, name, "ruff.toml обнаружен") - # ── Check 5: [tool.mypy] or mypy.ini ── - has_mypy_ini = (REPO_ROOT / "mypy.ini").exists() or (REPO_ROOT / ".mypy.ini").exists() - if "mypy" in tools or has_mypy_ini: - if has_mypy_ini and "mypy" not in tools: - # mypy.ini present, [tool.mypy] absent — assume strict in ini - group.checks.append(CheckResult(CheckStatus.OK, "[tool.mypy]", "mypy.ini обнаружен")) - elif _mypy_strict(data): - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.mypy]", - "strict=true (или disallow_untyped_defs)", - ) - ) - else: - group.checks.append( - CheckResult(CheckStatus.WARN, "[tool.mypy]", "не strict — добавьте strict=true") - ) - else: - group.checks.append( - CheckResult(CheckStatus.FAIL, "[tool.mypy]", "секция отсутствует (и нет mypy.ini)") - ) - # ── Check 6: [tool.pytest.ini_options] ── +def _check_mypy_section(data: dict[str, Any], root: Path) -> CheckResult: + """Check 5: ``[tool.mypy]`` strict or ``mypy.ini`` present.""" + name = "[tool.mypy]" + tools = data.get("tool", {}) if isinstance(data, dict) else {} + has_mypy_ini = (root / "mypy.ini").exists() or (root / ".mypy.ini").exists() + if "mypy" not in tools and not has_mypy_ini: + return CheckResult(CheckStatus.FAIL, name, "секция отсутствует (и нет mypy.ini)") + if has_mypy_ini and "mypy" not in tools: + return CheckResult(CheckStatus.OK, name, "mypy.ini обнаружен") + if _mypy_strict(data): + return CheckResult(CheckStatus.OK, name, "strict=true (или disallow_untyped_defs)") + return CheckResult(CheckStatus.WARN, name, "не strict — добавьте strict=true") + + +def _check_pytest_ini_options(tools: dict[str, Any]) -> CheckResult: + """Check 6: ``[tool.pytest.ini_options]`` asyncio_mode=auto, testpaths=["tests"].""" + name = "[tool.pytest.ini_options]" pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} - if isinstance(pytest_opts, dict) and pytest_opts: - asyncio_mode = pytest_opts.get("asyncio_mode", "") - testpaths = pytest_opts.get("testpaths", []) - if asyncio_mode == "auto" and testpaths == ["tests"]: - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.pytest.ini_options]", - 'asyncio_mode=auto, testpaths=["tests"]', - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.pytest.ini_options]", - f"asyncio_mode={asyncio_mode!r}, testpaths={testpaths!r} " - '(рекомендуется auto + ["tests"])', - ) - ) - else: - group.checks.append( - CheckResult(CheckStatus.FAIL, "[tool.pytest.ini_options]", "секция отсутствует") - ) + if not (isinstance(pytest_opts, dict) and pytest_opts): + return CheckResult(CheckStatus.FAIL, name, "секция отсутствует") + asyncio_mode = pytest_opts.get("asyncio_mode", "") + testpaths = pytest_opts.get("testpaths", []) + if asyncio_mode == "auto" and testpaths == ["tests"]: + return CheckResult(CheckStatus.OK, name, 'asyncio_mode=auto, testpaths=["tests"]') + return CheckResult( + CheckStatus.WARN, + name, + f'asyncio_mode={asyncio_mode!r}, testpaths={testpaths!r} (рекомендуется auto + ["tests"])', + ) - # ── Check 7: [tool.coverage.run] ── + +def _check_coverage_run(tools: dict[str, Any]) -> CheckResult: + """Check 7: ``[tool.coverage.run]`` has source and branch=true.""" + name = "[tool.coverage.run]" cov_run = tools.get("coverage", {}).get("run", {}) if isinstance(tools, dict) else {} if isinstance(cov_run, dict) and cov_run.get("source") and cov_run.get("branch") is True: - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.coverage.run]", - f"source={cov_run.get('source')!r}, branch=true", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.coverage.run]", - "нужны source и branch=true", - ) - ) + return CheckResult(CheckStatus.OK, name, f"source={cov_run.get('source')!r}, branch=true") + return CheckResult(CheckStatus.WARN, name, "нужны source и branch=true") - # ── Check 8: [tool.coverage.report] exclude_lines ── + +def _check_coverage_report(tools: dict[str, Any]) -> CheckResult: + """Check 8: ``[tool.coverage.report]`` exclude_lines includes defaults.""" + name = "[tool.coverage.report]" cov_report = tools.get("coverage", {}).get("report", {}) if isinstance(tools, dict) else {} exclude_lines = cov_report.get("exclude_lines", []) if isinstance(cov_report, dict) else [] exclude_strs = [str(e) for e in exclude_lines] if isinstance(exclude_lines, list) else [] @@ -1085,107 +1042,103 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912, e for e in DEFAULT_COVERAGE_EXCLUDE_LINES if not any(e in s for s in exclude_strs) ] if not missing_exclude and exclude_strs: - group.checks.append( - CheckResult( - CheckStatus.OK, - "[tool.coverage.report]", - f"exclude_lines содержит {len(exclude_strs)} паттернов", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.coverage.report]", - f"exclude_lines не хватает: {', '.join(missing_exclude)}", - ) + return CheckResult( + CheckStatus.OK, name, f"exclude_lines содержит {len(exclude_strs)} паттернов" ) + return CheckResult( + CheckStatus.WARN, name, f"exclude_lines не хватает: {', '.join(missing_exclude)}" + ) - # ── Check 9: addopts --cov-fail-under=N ── + +def _check_addopts_cov(pytest_opts: dict[str, Any]) -> CheckResult: + """Check 9: addopts ``--cov-fail-under=N`` threshold set.""" + name = "addopts --cov-fail-under" addopts_val = pytest_opts.get("addopts", "") if isinstance(pytest_opts, dict) else "" m_cov = re.search(r"--cov-fail-under=(\d+)", str(addopts_val)) if m_cov: - group.checks.append( - CheckResult(CheckStatus.OK, "addopts --cov-fail-under", f"порог={m_cov.group(1)}%") - ) - else: - group.checks.append( - CheckResult(CheckStatus.WARN, "addopts --cov-fail-under", "порог coverage не задан") - ) + return CheckResult(CheckStatus.OK, name, f"порог={m_cov.group(1)}%") + return CheckResult(CheckStatus.WARN, name, "порог coverage не задан") - # ── Check 10: [tool.project-status] thresholds ── + +def _check_project_status_section(tools: dict[str, Any]) -> CheckResult: + """Check 10: ``[tool.project-status]`` thresholds section (uses defaults if absent).""" + name = "[tool.project-status]" ps_section = tools.get("project-status", {}) if isinstance(tools, dict) else {} expected_keys = ["thin_routes_max_lines", "cov_fail_under", "required_dirs_backend"] if isinstance(ps_section, dict) and ps_section: missing_keys = [k for k in expected_keys if k not in ps_section] if not missing_keys: - group.checks.append( - CheckResult( - CheckStatus.OK, "[tool.project-status]", "пороги заданы (uses defaults)" - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "[tool.project-status]", - f"не заданы пороги: {', '.join(missing_keys)} (uses defaults)", - ) - ) - else: + return CheckResult(CheckStatus.OK, name, "пороги заданы (uses defaults)") + return CheckResult( + CheckStatus.WARN, name, f"не заданы пороги: {', '.join(missing_keys)} (uses defaults)" + ) + return CheckResult(CheckStatus.WARN, name, "секция отсутствует — uses defaults") + + +def _check_pre_commit(root: Path) -> CheckResult: + """Check 11: ``.pre-commit-config.yaml`` exists.""" + if (root / ".pre-commit-config.yaml").exists(): + return CheckResult(CheckStatus.OK, ".pre-commit-config.yaml", "настроен") + return CheckResult(CheckStatus.WARN, ".pre-commit-config.yaml", "отсутствует (Python-проект)") + + +def _check_uv_lock(root: Path) -> CheckResult: + """Check 12: ``uv.lock`` exists.""" + if (root / "uv.lock").exists(): + return CheckResult(CheckStatus.OK, "uv.lock", "существует") + return CheckResult(CheckStatus.WARN, "uv.lock", "отсутствует — запусти `uv lock` и закоммить") + + +PYPROJECT_CHECKS: list[Any] = [] # populated below; kept here for discoverability. + + +def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: + """Group 8: pyproject.toml — 13 checks (FAIL/WARN). + + Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each + of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the + file is missing → single WARN; parse error → single FAIL. + """ + group = GroupResult(name="Pyproject") + data = _load_pyproject(ctx.root) + if data is None: + group.checks.append( + CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks") + ) + return group + if "__parse_error__" in data: group.checks.append( CheckResult( - CheckStatus.WARN, - "[tool.project-status]", - "секция отсутствует — uses defaults", - ) - ) - - # ── Check 11: .pre-commit-config.yaml ── - if (REPO_ROOT / ".pre-commit-config.yaml").exists(): - group.checks.append(CheckResult(CheckStatus.OK, ".pre-commit-config.yaml", "настроен")) - else: - group.checks.append( - CheckResult(CheckStatus.WARN, ".pre-commit-config.yaml", "отсутствует (Python-проект)") - ) - - # ── Check 12: uv.lock exists ── - if (REPO_ROOT / "uv.lock").exists(): - group.checks.append(CheckResult(CheckStatus.OK, "uv.lock", "существует")) - else: - group.checks.append( - CheckResult(CheckStatus.WARN, "uv.lock", "отсутствует — запусти `uv lock` и закоммить") - ) - - # ── Check 13: requires-python vs .python-version ── - python_version_path = REPO_ROOT / ".python-version" - requires_python_val = project.get("requires-python", "") if isinstance(project, dict) else "" - if python_version_path.exists() and requires_python_val: - try: - pv_content = python_version_path.read_text(encoding="utf-8-sig") - except OSError: - pv_content = "" - group.checks.append(_check_python_version_compat(str(requires_python_val), pv_content)) - elif not python_version_path.exists(): - group.checks.append( - CheckResult( - CheckStatus.WARN, - "requires-python vs .python-version", - ".python-version отсутствует — skip", - ) - ) - else: - group.checks.append( - CheckResult( - CheckStatus.WARN, - "requires-python vs .python-version", - "requires-python не задан — skip", + CheckStatus.FAIL, "pyproject.toml", f"парсинг failed: {data['__parse_error__']}" ) ) + return group + project = data.get("project", {}) if isinstance(data, dict) else {} + tools = data.get("tool", {}) if isinstance(data, dict) else {} + pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} + for check_fn in PYPROJECT_CHECKS: + group.checks.append(check_fn(data, ctx.root, ptype, project, tools, pytest_opts)) return group +PYPROJECT_CHECKS = [ + lambda d, r, pt, p, t, po: _check_build_system(d), + lambda d, r, pt, p, t, po: _check_hatch_packages(d, r, pt), + lambda d, r, pt, p, t, po: _check_project_fields(p), + lambda d, r, pt, p, t, po: _check_ruff_section(d, r), + lambda d, r, pt, p, t, po: _check_mypy_section(d, r), + lambda d, r, pt, p, t, po: _check_pytest_ini_options(t), + lambda d, r, pt, p, t, po: _check_coverage_run(t), + lambda d, r, pt, p, t, po: _check_coverage_report(t), + lambda d, r, pt, p, t, po: _check_addopts_cov(po), + lambda d, r, pt, p, t, po: _check_project_status_section(t), + lambda d, r, pt, p, t, po: _check_pre_commit(r), + lambda d, r, pt, p, t, po: _check_uv_lock(r), + lambda d, r, pt, p, t, po: _check_python_version_compat(p, r), +] + + # ── orchestration ──────────────────────────────────────────────────────────── @@ -1202,7 +1155,7 @@ CHECK_GROUPS: list[str] = [ def run_all_checks( - ptype: ProjectType, fast: bool = False, repo_root: Path | None = None + ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None ) -> list[GroupResult]: """Run all 8 check groups, return results in order. @@ -1210,14 +1163,14 @@ def run_all_checks( ``git -C`` based remote detection (used with ``--repo`` flag). """ return [ - check_structure(ptype), - check_thin_routes(ptype, fast=fast), - check_quality(ptype), - check_tests(ptype), - check_readme(ptype), - check_infra(ptype, fast=fast, repo_root=repo_root), - check_coverage(ptype), - check_pyproject(ptype), + check_structure(ptype, ctx), + check_thin_routes(ptype, ctx, fast=fast), + check_quality(ptype, ctx), + check_tests(ptype, ctx), + check_readme(ptype, ctx), + check_infra(ptype, ctx, fast=fast, repo_root=repo_root), + check_coverage(ptype, ctx), + check_pyproject(ptype, ctx), ] @@ -1286,7 +1239,6 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: def main() -> None: """Entry point: parse args, run checks, print report, set exit code.""" - global REPO_ROOT, CONFIG # noqa: PLW0603 args = _parse_args(sys.argv[1:]) strict = args.check fast = args.fast @@ -1297,11 +1249,13 @@ def main() -> None: if not repo_path.exists(): print(f"FAIL: repo path not found: {repo_path}") sys.exit(1) - REPO_ROOT = repo_path - CONFIG = load_config() + root = repo_path + else: + root = REPO_ROOT + ctx = RepoCtx(root=root, config=load_config(root)) - ptype = detect_project_type() - groups = run_all_checks(ptype, fast=fast, repo_root=REPO_ROOT if repo_arg else None) + ptype = detect_project_type(ctx) + groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None) print(format_output(ptype, groups)) if strict and any(g.overall() == CheckStatus.FAIL for g in groups): sys.exit(1) diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index fcaf5e9..6d98646 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -461,16 +461,16 @@ def extra_context_value(render, key): def _run_status_checks(repo_root: Path, fast: bool = True) -> tuple[str, list[ps.CheckResult]]: - """Run the project-status checks against ``repo_root`` (in-process).""" - original_root = ps.REPO_ROOT - ps.REPO_ROOT = repo_root - try: - ptype = ps.detect_project_type() - groups = ps.run_all_checks(ptype, fast=fast) - all_checks = [c for g in groups for c in g.checks] - report = ps.format_output(ptype, groups) - finally: - ps.REPO_ROOT = original_root + """Run the project-status checks against ``repo_root`` (in-process). + + Builds a ``RepoCtx`` rooted at ``repo_root`` so check-functions get an + explicit context (no module-global mutation after the #242 refactor). + """ + ctx = ps.RepoCtx(root=repo_root, config=ps.load_config(repo_root)) + ptype = ps.detect_project_type(ctx) + groups = ps.run_all_checks(ptype, ctx, fast=fast) + all_checks = [c for g in groups for c in g.checks] + report = ps.format_output(ptype, groups) return report, all_checks @@ -553,12 +553,8 @@ def test_infra_checks_present(render): def test_fullstack_passes_project_status_structure(render): """Fullstack is detected as fullstack (backend/ + frontend/ present).""" ptype = ps.ProjectType.FULLSTACK - original_root = ps.REPO_ROOT - ps.REPO_ROOT = render - try: - detected = ps.detect_project_type() - finally: - ps.REPO_ROOT = original_root + ctx = ps.RepoCtx(root=render, config=ps.load_config(render)) + detected = ps.detect_project_type(ctx) assert detected == ptype _, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 6b74936..e813c26 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -30,11 +30,21 @@ GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( @pytest.fixture(autouse=True) def _isolate_repo(monkeypatch, tmp_path): - """Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test.""" + """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. @@ -265,79 +275,79 @@ def test_load_config_bad_toml_returns_defaults(tmp_path): # ── detect_project_type ────────────────────────────────────────────────────── -def test_detect_fullstack(tmp_path): +def test_detect_fullstack(tmp_path, ctx): (tmp_path / "backend").mkdir() (tmp_path / "frontend").mkdir() - assert ps.detect_project_type() == ps.ProjectType.FULLSTACK + assert ps.detect_project_type(ctx) == ps.ProjectType.FULLSTACK -def test_detect_backend(tmp_path): +def test_detect_backend(tmp_path, ctx): _make_backend_repo(tmp_path) - assert ps.detect_project_type() == ps.ProjectType.BACKEND + assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND -def test_detect_bot_via_file(tmp_path): +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() == ps.ProjectType.BOT + assert ps.detect_project_type(ctx) == ps.ProjectType.BOT -def test_detect_bot_via_dep(tmp_path): +def test_detect_bot_via_dep(tmp_path, ctx): _write_pyproject(tmp_path, deps=["aiogram"]) - assert ps.detect_project_type() == ps.ProjectType.BOT + assert ps.detect_project_type(ctx) == ps.ProjectType.BOT -def test_detect_worker_via_file(tmp_path): +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() == ps.ProjectType.WORKER + assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER -def test_detect_worker_via_dep(tmp_path): +def test_detect_worker_via_dep(tmp_path, ctx): _write_pyproject(tmp_path, deps=["prefect"]) - assert ps.detect_project_type() == ps.ProjectType.WORKER + assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER -def test_detect_cli(tmp_path): +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() == ps.ProjectType.CLI + assert ps.detect_project_type(ctx) == ps.ProjectType.CLI -def test_detect_unknown_empty_repo(tmp_path): +def test_detect_unknown_empty_repo(tmp_path, ctx): _write_pyproject(tmp_path, deps=[]) - assert ps.detect_project_type() == ps.ProjectType.UNKNOWN + assert ps.detect_project_type(ctx) == ps.ProjectType.UNKNOWN # ── check_structure ────────────────────────────────────────────────────────── -def test_check_structure_backend_ok(tmp_path): +def test_check_structure_backend_ok(tmp_path, ctx): _make_backend_repo(tmp_path) - group = ps.check_structure(ps.ProjectType.BACKEND) + 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): +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) + group = ps.check_structure(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.FAIL assert any( c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks ) -def test_check_structure_backend_no_lifespan(tmp_path): +def test_check_structure_backend_no_lifespan(tmp_path, ctx): pkg = "test_repo" for rel in [ f"src/{pkg}/api/v1", @@ -352,42 +362,42 @@ def test_check_structure_backend_no_lifespan(tmp_path): (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) + 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): - group = ps.check_structure(ps.ProjectType.UNKNOWN) +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): +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) + 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): +def test_check_structure_cli_no_package(tmp_path, ctx): (tmp_path / "src").mkdir() - group = ps.check_structure(ps.ProjectType.CLI) + group = ps.check_structure(ps.ProjectType.CLI, ctx) assert any(c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_thin_routes ──────────────────────────────────────────────────────── -def test_thin_routes_ok(tmp_path): +def test_thin_routes_ok(tmp_path, ctx): _make_backend_repo(tmp_path) - group = ps.check_thin_routes(ps.ProjectType.BACKEND) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.OK -def test_thin_routes_over_limit(tmp_path): +def test_thin_routes_over_limit(tmp_path, ctx): pkg = "test_repo" for rel in [ f"src/{pkg}/api/v1", @@ -407,70 +417,70 @@ def test_thin_routes_over_limit(tmp_path): "@router.get('/users')\nasync def list_users():" f"{body} return []\n" ) - group = ps.check_thin_routes(ps.ProjectType.BACKEND) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.FAIL assert any("превышение" in c.detail for c in group.checks) -def test_thin_routes_not_applicable_for_cli(tmp_path): - group = ps.check_thin_routes(ps.ProjectType.CLI) +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): - group = ps.check_thin_routes(ps.ProjectType.BACKEND) +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 # ── check_quality ──────────────────────────────────────────────────────────── -def test_quality_ok(tmp_path): +def test_quality_ok(tmp_path, ctx): _make_backend_repo(tmp_path) - group = ps.check_quality(ps.ProjectType.BACKEND) + group = ps.check_quality(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.OK -def test_quality_missing_mypy(tmp_path): +def test_quality_missing_mypy(tmp_path, ctx): _write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False) - group = ps.check_quality(ps.ProjectType.BACKEND) + group = ps.check_quality(ps.ProjectType.BACKEND, ctx) assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks) -def test_quality_missing_ruff(tmp_path): +def test_quality_missing_ruff(tmp_path, ctx): _write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False) - group = ps.check_quality(ps.ProjectType.BACKEND) + group = ps.check_quality(ps.ProjectType.BACKEND, ctx) assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_tests ────────────────────────────────────────────────────────────── -def test_tests_ok(tmp_path): +def test_tests_ok(tmp_path, ctx): _make_backend_repo(tmp_path) - group = ps.check_tests(ps.ProjectType.BACKEND) + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.OK -def test_tests_no_dir(tmp_path): - group = ps.check_tests(ps.ProjectType.BACKEND) +def test_tests_no_dir(tmp_path, ctx): + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.FAIL assert any("tests/" in c.name for c in group.checks) -def test_tests_no_conftest(tmp_path): +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) + 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): +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) + 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 @@ -490,29 +500,29 @@ def _write_valid_readme(tmp_path: Path) -> None: (tmp_path / "README.md").write_text(content) -def test_readme_ok(tmp_path): +def test_readme_ok(tmp_path, ctx): _write_valid_readme(tmp_path) - group = ps.check_readme(ps.ProjectType.BACKEND) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.OK -def test_readme_missing(tmp_path): - group = ps.check_readme(ps.ProjectType.BACKEND) +def test_readme_missing(tmp_path, ctx): + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.FAIL -def test_readme_missing_delimiters(tmp_path): +def test_readme_missing_delimiters(tmp_path, ctx): (tmp_path / "README.md").write_text( "# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n" ) - group = ps.check_readme(ps.ProjectType.BACKEND) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks) # ── check_infra ────────────────────────────────────────────────────────────── -def test_infra_ok_with_branch_protection(monkeypatch, tmp_path): +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") @@ -532,36 +542,36 @@ def test_infra_ok_with_branch_protection(monkeypatch, tmp_path): } ), ) - group = ps.check_infra(ps.ProjectType.BACKEND, fast=False) + group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False) assert group.overall() == ps.CheckStatus.OK -def test_infra_missing_ci_fail(tmp_path): +def test_infra_missing_ci_fail(tmp_path, ctx): (tmp_path / "LICENSE").write_text("MIT\n") - group = ps.check_infra(ps.ProjectType.BACKEND, fast=True) + group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True) assert any( c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL for c in group.checks ) -def test_infra_fast_skips_branch_protection(tmp_path): +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, fast=True) + 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): +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, fast=False) + 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 ) @@ -570,16 +580,16 @@ def test_infra_branch_protection_gh_error(monkeypatch, tmp_path): # ── check_coverage ─────────────────────────────────────────────────────────── -def test_coverage_non_blocking(tmp_path): +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) + 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): +def test_coverage_no_config(tmp_path, ctx): _write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False) - group = ps.check_coverage(ps.ProjectType.BACKEND) + 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) @@ -685,9 +695,9 @@ def test_main_fast_flag(monkeypatch, tmp_path, capsys): # ── run_all_checks integration ─────────────────────────────────────────────── -def test_run_all_checks_returns_8_groups(tmp_path): +def test_run_all_checks_returns_8_groups(tmp_path, ctx): _make_backend_repo(tmp_path) - groups = ps.run_all_checks(ps.ProjectType.BACKEND, fast=True) + 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 @@ -813,9 +823,9 @@ def _write_full_pyproject( # noqa: C901, PLR0912, PLR0915 (tmp_path / ".python-version").write_text(python_version_content) -def test_check_pyproject_all_ok(tmp_path): +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) + 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) @@ -823,49 +833,49 @@ def test_check_pyproject_all_ok(tmp_path): assert len(group.checks) == 13, f"expected 13 checks, got {len(group.checks)}" -def test_check_pyproject_no_pyproject_warn(tmp_path): - group = ps.check_pyproject(ps.ProjectType.BACKEND) +def test_check_pyproject_no_pyproject_warn(tmp_path, ctx): + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.WARN assert any(c.name == "pyproject.toml" and c.status == ps.CheckStatus.WARN for c in group.checks) assert len(group.checks) == 1 -def test_check_pyproject_invalid_toml_fail(tmp_path): +def test_check_pyproject_invalid_toml_fail(tmp_path, ctx): (tmp_path / "pyproject.toml").write_text("not valid = = =") - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.FAIL assert any("парсинг" in c.detail for c in group.checks) -def test_check_pyproject_check1_build_system_fail(tmp_path): +def test_check_pyproject_check1_build_system_fail(tmp_path, ctx): _write_full_pyproject(tmp_path, has_build_system=False, src_pkg_exists=True) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.FAIL for c in group.checks) -def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK for c in group.checks ) -def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path): +def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path, ctx): """src// exists but packages doesn't reference it → FAIL.""" (tmp_path / "src").mkdir() (tmp_path / "src" / "test_repo").mkdir() (tmp_path / "src" / "test_repo" / "__init__.py").write_text("") _write_full_pyproject(tmp_path, src_pkg_exists=False) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.FAIL for c in group.checks ) -def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path): +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 @@ -876,7 +886,7 @@ def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path): (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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.WARN @@ -885,12 +895,12 @@ def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path): ) -def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path): +def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path, ctx): """src/ exists but no src// → 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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.WARN @@ -899,204 +909,204 @@ def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path): ) -def test_check_pyproject_check3_project_fields_fail(tmp_path): +def test_check_pyproject_check3_project_fields_fail(tmp_path, ctx): _write_full_pyproject(tmp_path, has_project_fields=False, src_pkg_exists=True) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[project]" and c.status == ps.CheckStatus.FAIL for c in group.checks) -def test_check_pyproject_check4_ruff_ok(tmp_path): +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) + 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): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks) -def test_check_pyproject_check4_ruff_missing_fail(tmp_path): +def test_check_pyproject_check4_ruff_missing_fail(tmp_path, ctx): _write_full_pyproject(tmp_path, has_ruff=False, src_pkg_exists=True) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.FAIL for c in group.checks) -def test_check_pyproject_check5_mypy_strict_ok(tmp_path): +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) + 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): +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) + 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): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks) -def test_check_pyproject_check5_mypy_missing_fail(tmp_path): +def test_check_pyproject_check5_mypy_missing_fail(tmp_path, ctx): _write_full_pyproject(tmp_path, has_mypy=False, src_pkg_exists=True) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.FAIL for c in group.checks) -def test_check_pyproject_check6_pytest_ok(tmp_path): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.OK for c in group.checks ) -def test_check_pyproject_check6_pytest_missing_fail(tmp_path): +def test_check_pyproject_check6_pytest_missing_fail(tmp_path, ctx): _write_full_pyproject(tmp_path, has_pytest=False, src_pkg_exists=True) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.FAIL for c in group.checks ) -def test_check_pyproject_check6_pytest_wrong_mode_warn(tmp_path): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + 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): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.OK for c in group.checks ) -def test_check_pyproject_check13_python_version_fail_incompat(tmp_path): +def test_check_pyproject_check13_python_version_fail_incompat(tmp_path, ctx): _write_full_pyproject( tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True ) - group = ps.check_pyproject(ps.ProjectType.BACKEND) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.FAIL for c in group.checks ) -def test_check_pyproject_check13_skip_when_no_python_version(tmp_path): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.WARN @@ -1177,10 +1187,10 @@ def test_main_repo_flag_relative_path(monkeypatch, tmp_path, capsys): assert "Project:" in captured.out -def test_check_pyproject_uses_repo_root(tmp_path): +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) + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert group.overall() == ps.CheckStatus.OK, ( f"expected OK, got {group.overall()}: " + ", ".join( @@ -1192,19 +1202,19 @@ def test_check_pyproject_uses_repo_root(tmp_path): # ── flat-layout check in check_structure ────────────────────────────────────── -def test_check_structure_flat_layout_warn_for_cli(tmp_path): +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) + 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): +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//`` is the standard for @@ -1214,16 +1224,16 @@ def test_check_structure_flat_warn_for_backend(tmp_path): (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) + 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): +def test_check_structure_backend_nested_ok(tmp_path, ctx): """Backend with nested src//api/v1 → OK (no flat-layout WARN).""" _make_backend_repo(tmp_path) - group = ps.check_structure(ps.ProjectType.BACKEND) + 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) @@ -1231,14 +1241,14 @@ def test_check_structure_backend_nested_ok(tmp_path): 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): +def test_check_structure_no_flat_warn_when_nested_pkg(tmp_path, ctx): """CLI with src//__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) + group = ps.check_structure(ps.ProjectType.CLI, ctx) assert not any(c.name == "flat src/ layout" for c in group.checks) @@ -1268,7 +1278,7 @@ def test_get_repo_full_name_no_repo_arg_uses_cwd(monkeypatch): # ── check_infra with repo_root (git -C) ───────────────────────────────────── -def test_check_infra_repo_root_git_c(monkeypatch, tmp_path): +def test_check_infra_repo_root_git_c(monkeypatch, tmp_path, ctx): """check_infra(repo_root=...) → git -C for branch protection.""" for rel in [".github/workflows", ".github"]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) @@ -1283,13 +1293,13 @@ def test_check_infra_repo_root_git_c(monkeypatch, tmp_path): return (1, "", f"unmocked: {args}") monkeypatch.setattr(ps, "run_cmd", _mock) - group = ps.check_infra(ps.ProjectType.BACKEND, fast=False, repo_root=tmp_path) + 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): +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") @@ -1301,7 +1311,262 @@ def test_check_infra_repo_root_not_git_repo_warn(monkeypatch, tmp_path): return (1, "", f"unmocked: {args}") monkeypatch.setattr(ps, "run_cmd", _mock) - group = ps.check_infra(ps.ProjectType.BACKEND, fast=False, repo_root=tmp_path) + group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False, repo_root=tmp_path) assert any( c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks ) + + +# ── RepoCtx (no globals) ──────────────────────────────────────────────────── + + +def test_repo_ctx_is_frozen(tmp_path): + """RepoCtx is a frozen dataclass — immutable after construction.""" + ctx = ps.RepoCtx(root=tmp_path, config={"route_line_limit": 50}) + with pytest.raises((AttributeError, Exception)): + ctx.root = tmp_path / "other" # type: ignore[misc] + + +def test_repo_ctx_carries_root_and_config(tmp_path): + """RepoCtx stores root path + config dict verbatim.""" + cfg = {"route_line_limit": 80, "min_test_count": 3} + ctx = ps.RepoCtx(root=tmp_path, config=cfg) + assert ctx.root == tmp_path + assert ctx.config is cfg + + +def test_path_exists_uses_ctx_root(tmp_path, ctx): + """path_exists(rel, ctx) checks ``ctx.root / rel``, not module global.""" + (tmp_path / "marker.txt").write_text("x") + assert ps.path_exists("marker.txt", ctx) is True + assert ps.path_exists("absent.txt", ctx) is False + + +def test_read_text_uses_ctx_root(tmp_path, ctx): + """read_text(rel, ctx) reads from ``ctx.root / rel``.""" + (tmp_path / "f.txt").write_text("hello", encoding="utf-8") + assert ps.read_text("f.txt", ctx) == "hello" + assert ps.read_text("missing.txt", ctx) is None + + +def test_parse_pyproject_uses_ctx_root(tmp_path, ctx): + """parse_pyproject(ctx) parses ``ctx.root / pyproject.toml``.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + data = ps.parse_pyproject(ctx) + assert data.get("project", {}).get("name") == "x" + empty_ctx = ps.RepoCtx(root=tmp_path / "nope", config={}) + assert ps.parse_pyproject(empty_ctx) == {} + + +# ── check_pyproject 13 sub-functions (independent) ───────────────────────── + + +def _full_data(tmp_path, **overrides): + """Build a fully-valid parsed pyproject dict + write side files.""" + _write_full_pyproject(tmp_path, src_pkg_exists=True, **overrides) + return ps._load_pyproject(tmp_path) + + +def test_subfunc_check_build_system_ok(tmp_path, ctx): + data = _full_data(tmp_path) + assert ps._check_build_system(data).status == ps.CheckStatus.OK + + +def test_subfunc_check_build_system_fail(tmp_path, ctx): + data = _full_data(tmp_path, has_build_system=False) + assert ps._check_build_system(data).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_hatch_packages_ok_nested(tmp_path, ctx): + data = _full_data(tmp_path) + assert ( + ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND).status == ps.CheckStatus.OK + ) + + +def test_subfunc_check_hatch_packages_warn_flat_src(tmp_path, ctx): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "test_repo").mkdir() + (tmp_path / "src" / "test_repo" / "__init__.py").write_text("") + _write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"]) + data = ps._load_pyproject(tmp_path) + res = ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND) + assert res.status == ps.CheckStatus.WARN + assert "deprecated flat layout" in res.detail + + +def test_subfunc_check_project_fields_ok(tmp_path, ctx): + data = _full_data(tmp_path) + project = data.get("project", {}) + assert ps._check_project_fields(project).status == ps.CheckStatus.OK + + +def test_subfunc_check_project_fields_fail(tmp_path, ctx): + data = _full_data(tmp_path, has_project_fields=False) + project = data.get("project", {}) + assert ps._check_project_fields(project).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_ruff_section_ok(tmp_path, ctx): + data = _full_data(tmp_path) + assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_ruff_section_ruff_toml_ok(tmp_path, ctx): + data = _full_data(tmp_path, has_ruff=False, ruff_toml=True) + assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_ruff_section_missing_fail(tmp_path, ctx): + data = _full_data(tmp_path, has_ruff=False) + assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_mypy_section_strict_ok(tmp_path, ctx): + data = _full_data(tmp_path) + assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_mypy_section_ini_ok(tmp_path, ctx): + data = _full_data(tmp_path, has_mypy=False, mypy_ini=True) + assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_mypy_section_not_strict_warn(tmp_path, ctx): + data = _full_data(tmp_path, mypy_strict=False) + assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN + + +def test_subfunc_check_mypy_section_missing_fail(tmp_path, ctx): + data = _full_data(tmp_path, has_mypy=False) + assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_pytest_ini_options_ok(tmp_path, ctx): + data = _full_data(tmp_path) + tools = data.get("tool", {}) + assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.OK + + +def test_subfunc_check_pytest_ini_options_missing_fail(tmp_path, ctx): + data = _full_data(tmp_path, has_pytest=False) + tools = data.get("tool", {}) + assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_coverage_run_ok(tmp_path, ctx): + data = _full_data(tmp_path) + tools = data.get("tool", {}) + assert ps._check_coverage_run(tools).status == ps.CheckStatus.OK + + +def test_subfunc_check_coverage_run_warn_no_branch(tmp_path, ctx): + data = _full_data(tmp_path, cov_branch=False) + tools = data.get("tool", {}) + assert ps._check_coverage_run(tools).status == ps.CheckStatus.WARN + + +def test_subfunc_check_coverage_report_ok(tmp_path, ctx): + data = _full_data(tmp_path) + tools = data.get("tool", {}) + assert ps._check_coverage_report(tools).status == ps.CheckStatus.OK + + +def test_subfunc_check_coverage_report_warn(tmp_path, ctx): + data = _full_data(tmp_path, has_cov_report_exclude=False) + tools = data.get("tool", {}) + assert ps._check_coverage_report(tools).status == ps.CheckStatus.WARN + + +def test_subfunc_check_addopts_cov_ok(tmp_path, ctx): + data = _full_data(tmp_path) + tools = data.get("tool", {}) + pytest_opts = tools.get("pytest", {}).get("ini_options", {}) + assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.OK + + +def test_subfunc_check_addopts_cov_warn(tmp_path, ctx): + data = _full_data(tmp_path, has_cov_fail_under=False) + tools = data.get("tool", {}) + pytest_opts = tools.get("pytest", {}).get("ini_options", {}) + assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.WARN + + +def test_subfunc_check_project_status_section_ok(tmp_path, ctx): + data = _full_data(tmp_path, has_project_status_section=True) + tools = data.get("tool", {}) + assert ps._check_project_status_section(tools).status == ps.CheckStatus.OK + + +def test_subfunc_check_project_status_section_warn_default(tmp_path, ctx): + data = _full_data(tmp_path, has_project_status_section=False) + tools = data.get("tool", {}) + assert ps._check_project_status_section(tools).status == ps.CheckStatus.WARN + + +def test_subfunc_check_pre_commit_ok(tmp_path, ctx): + _full_data(tmp_path) + assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_pre_commit_warn(tmp_path, ctx): + _full_data(tmp_path, has_pre_commit=False) + assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.WARN + + +def test_subfunc_check_uv_lock_ok(tmp_path, ctx): + _full_data(tmp_path) + assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_uv_lock_warn(tmp_path, ctx): + _full_data(tmp_path, has_uv_lock=False) + assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.WARN + + +def test_subfunc_check_python_version_compat_ok(tmp_path, ctx): + _write_full_pyproject( + tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True + ) + data = ps._load_pyproject(tmp_path) + project = data.get("project", {}) + assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.OK + + +def test_subfunc_check_python_version_compat_fail_incompat(tmp_path, ctx): + _write_full_pyproject( + tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True + ) + data = ps._load_pyproject(tmp_path) + project = data.get("project", {}) + assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.FAIL + + +def test_subfunc_check_python_version_compat_skip_no_file(tmp_path, ctx): + _write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True) + data = ps._load_pyproject(tmp_path) + project = data.get("project", {}) + res = ps._check_python_version_compat(project, ctx.root) + assert res.status == ps.CheckStatus.WARN + assert "skip" in res.detail + + +def test_pyproject_checks_has_13_entries(tmp_path, ctx): + """PYPROJECT_CHECKS dispatch table has exactly 13 entries (one per check).""" + assert len(ps.PYPROJECT_CHECKS) == 13 + + +def test_load_pyproject_missing_returns_none(tmp_path, ctx): + """_load_pyproject returns None when pyproject.toml absent.""" + assert ps._load_pyproject(tmp_path) is None + + +def test_load_pyproject_parse_error_sentinel(tmp_path, ctx): + """_load_pyproject returns dict with __parse_error__ key on bad TOML.""" + (tmp_path / "pyproject.toml").write_text("not valid = = =") + data = ps._load_pyproject(tmp_path) + assert data is not None + assert "__parse_error__" in data + group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) + assert group.overall() == ps.CheckStatus.FAIL + assert any("парсинг" in c.detail for c in group.checks)