#!/usr/bin/env python3
"""Project-status oracle: read-only check of repo architecture conformance.

Deterministically inspects the current repository against a standard
architecture for the auto-detected project type and prints a report with
``[OK]/[WARN]`` lines, an Итог summary, and Рекомендации.

All checks are non-blocking (issue #275): every problem is surfaced as WARN
and the exit code is always 0 (informational mode). The ``--check`` flag is
accepted for CLI backward compatibility but no longer forces exit 1.

Read-only and stateless — no files are created or modified, no network
calls beyond read-only ``gh api`` for branch protection detection.

Usage:
    python3 .opencode/scripts/project-status.py           # informational (exit 0)
    python3 .opencode/scripts/project-status.py --check   # accepted, still exit 0
    python3 .opencode/scripts/project-status.py --fast    # skip slow/remote checks
    python3 .opencode/scripts/project-status.py --repo /path/to/repo

Project types (auto-detected):
    fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
    backend   — ``src/<package>/api/v1/`` + fastapi (db/models auto-detected from deps)
    cli       — ``[project.scripts]`` in pyproject.toml + typer in deps
    bot       — ``src/bot.py`` OR aiogram in deps
    worker    — ``src/flow.py`` OR prefect in deps
    unknown   — none of the above matched (still runs a generic check set)

``<package>`` = ``[project].name`` normalized (``my-project`` → ``my_project``).
Nested ``src/<package>/`` is the standard for ALL types (publishable, reusable
as a git-dependency). Flat ``src/`` is deprecated → WARN.
"""

from __future__ import annotations

import argparse
import ast
import importlib.util
import json
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any

# Load project_contract.py via importlib.util (no sys.path mutation).
_contract_path = Path(__file__).resolve().parent / "project_contract.py"
_pc_spec = importlib.util.spec_from_file_location("project_contract", _contract_path)
project_contract = importlib.util.module_from_spec(_pc_spec)  # type: ignore[arg-type]
_pc_spec.loader.exec_module(project_contract)  # type: ignore[union-attr]

# ── repo root + config ─────────────────────────────────────────────────────────


def _resolve_repo_root(repo_override: str | None = None) -> Path:
    """Resolve repo root.

    If ``repo_override`` is given, resolve it (relative to cwd) and return.
    Otherwise, resolve via git (cwd-aware), fallback to script location.
    """
    if repo_override:
        return Path(repo_override).resolve()
    result = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False
    )
    if result.returncode == 0 and result.stdout.strip():
        return Path(result.stdout.strip()).resolve()
    return Path(__file__).resolve().parent.parent.parent


REPO_ROOT = _resolve_repo_root()


# Default thresholds — overridable via ``[tool.project-status]`` in pyproject.toml.
DEFAULT_CONFIG: dict[str, Any] = {
    "route_line_limit": 50,
    "min_test_count": 1,
    "require_branch_protection": False,
}


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 = base / "pyproject.toml"
    if not pyproject.exists():
        return cfg
    try:
        with pyproject.open("rb") as f:
            data = tomllib.load(f)
    except (OSError, ValueError):
        return cfg
    section = data.get("tool", {}).get("project-status", {})
    if isinstance(section, dict):
        for key, default in DEFAULT_CONFIG.items():
            val = section.get(key, default)
            if isinstance(val, type(default)) or val is None:
                cfg[key] = val
    return cfg


CONFIG = load_config()


# ── enums + dataclasses ──────────────────────────────────────────────────────


class CheckStatus(StrEnum):
    """Result of a single check.

    All checks are non-blocking (issue #275): problems are surfaced as WARN
    and the exit code is always 0. ``FAIL`` is kept as a value for backward
    compatibility (existing tests import it), but no check-function returns
    ``FAIL`` anymore — every problem is WARN.
    """

    OK = "OK"
    WARN = "WARN"
    FAIL = "FAIL"


# Re-exported from project_contract.py for backward compatibility
# (tests use ``ps.ProjectType.X``).
ProjectType = project_contract.ProjectType


@dataclass(frozen=True)
class CheckResult:
    """Result of a single check within a group."""

    status: CheckStatus
    name: str
    detail: str


@dataclass
class GroupResult:
    """Result of a single check group (multiple CheckResult items)."""

    name: str
    checks: list[CheckResult] = field(default_factory=list)

    def overall(self) -> CheckStatus:
        """Roll up statuses: FAIL > WARN > OK (FAIL kept for compat, unused by checks).

        No check-function returns ``FAIL`` after issue #275 — the rollup is
        effectively WARN > OK. ``FAIL`` is retained in the comparison so any
        externally-constructed ``CheckResult(FAIL, ...)`` still rolls up to
        FAIL (defensive, does not happen in normal use).
        """
        statuses = [c.status for c in self.checks]
        if CheckStatus.FAIL in statuses:
            return CheckStatus.FAIL
        if CheckStatus.WARN in statuses:
            return CheckStatus.WARN
        return CheckStatus.OK


@dataclass(frozen=True)
class RepoCtx:
    """Immutable repo context: root path + config thresholds + backend root.

    Passed explicitly to all check-functions to avoid module-level globals
    (``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in
    ``pipeline-status.py``.

    ``backend_root`` is the directory where the backend source tree lives:
    ``ctx.root / "backend"`` for FULLSTACK, ``ctx.root`` for all other types.
    Computed in ``main()`` / ``detect_project_type`` after type detection.
    Root-level files (ci.yml, README, LICENSE, dependabot, frontend/) stay
    under ``ctx.root`` regardless of type.
    """

    root: Path
    config: dict[str, Any]
    backend_root: Path = Path()  # overridable; set by main()/detect_project_type


# ── helpers ──────────────────────────────────────────────────────────────────


def run_cmd(args: list[str]) -> tuple[int, str, str]:
    """Run a command, return (returncode, stdout, stderr). Read-only intent."""
    result = subprocess.run(args, capture_output=True, text=True, check=False)
    return result.returncode, result.stdout, result.stderr


def _backend_root_for(ptype: ProjectType, ctx: RepoCtx) -> Path:
    """Return the backend root dir for the given project type.

    FULLSTACK → ``ctx.root / "backend"`` (backend source tree lives there);
    all other types → ``ctx.root``. Used to route backend-source checks
    (lifespan, scattered models, pyproject, tests, ...) to the right tree
    without touching the root-level checks (ci.yml, README, frontend/).
    Mirrors the ``backend_ctx = RepoCtx(root=ctx.root / "backend")`` pattern
    in ``_api_dirs_for`` (issue #241 / #274).
    """
    if ptype == ProjectType.FULLSTACK:
        return ctx.root / "backend"
    return ctx.root


def _backend_ctx_for(ptype: ProjectType, ctx: RepoCtx) -> RepoCtx:
    """Build a RepoCtx rooted at the backend root (FULLSTACK → ``backend/``).

    Convenience wrapper: returns a fresh context with ``root=backend_root`` so
    backend-source checks (pyproject, hatch packages, python-version, ...) read
    from the right tree without each function re-deriving the path.
    """
    return RepoCtx(root=_backend_root_for(ptype, ctx), config=ctx.config)


def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool:
    """True if ``root / rel`` exists (``ctx`` preferred, else module global)."""
    root = ctx.root if ctx is not None else REPO_ROOT
    return (root / rel).exists()


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:
        return p.read_text(encoding="utf-8-sig")
    except OSError:
        return None


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", ctx)
    if raw is None:
        return {}
    try:
        return tomllib.loads(raw)
    except ValueError:
        return {}


def parse_remote_url(url: str) -> tuple[str, str, str]:
    """Parse git remote URL into (host, org, repo).

    Supports both HTTPS and SSH formats and optional userinfo (insteadOf).
    """
    ssh = re.match(r"git@([^:]+):([^/]+)/(.+?)(?:\.git)?$", url)
    if ssh:
        return ssh.group(1), ssh.group(2), ssh.group(3)
    https = re.match(r"https?://(?:[^/@]*@)?([^/]+)/([^/]+)/(.+?)(?:\.git)?$", url)
    if https:
        return https.group(1), https.group(2), https.group(3)
    raise ValueError(f"Cannot parse remote URL: {url}")


def get_repo_full_name(repo: Path | None = None) -> str | None:
    """Return ``org/repo`` from git remote, or None on error (read-only).

    If ``repo`` is given, use ``git -C <repo>`` to locate the remote.
    """
    git_cmd = ["git"]
    if repo is not None:
        git_cmd = [*git_cmd, "-C", str(repo)]
    rc, out, _ = run_cmd([*git_cmd, "remote", "get-url", "origin"])
    if rc != 0:
        return None
    try:
        _host, org, repo_name = parse_remote_url(out.strip())
    except ValueError:
        return None
    else:
        return f"{org}/{repo_name}"


# ── auto-detect ──────────────────────────────────────────────────────────────


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(ctx)
    if not isinstance(pyproject, dict):
        return None
    project = pyproject.get("project", {})
    if not isinstance(project, dict):
        return None
    name = project.get("name")
    if not isinstance(name, str) or not name:
        return None
    return _normalize_package_name(name)


# DB-capability markers — presence of any of these in ``[project.dependencies]``
# marks the project as a db-project (``db/models`` required). Auto-detection
# replaces the former ``[tool.project-status] no_db`` marker (issue #274).
DB_DEP_MARKERS: tuple[str, ...] = (
    "tortoise-orm",
    "sqlalchemy",
    "sqlmodel",
    "alembic",
    "aerich",
    "pony",
    "databases",
)


def _is_db_project(deps_lower: str) -> bool:
    """True if any DB-capability marker is present in the deps string.

    Used to decide whether ``db/models`` is required: a backend/fullstack
    project without a DB dep (cookiecutter ``use_db=no``) does not need
    ``db/models`` and the structure check yields WARN (not FAIL) when absent.
    """
    return any(marker in deps_lower for marker in DB_DEP_MARKERS)


def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
    """True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.

    Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the
    nested ``src/<package>/api/v1`` dir. It does NOT require ``db/models`` —
    that is a separate DB-capability check (``_is_db_project`` + the
    ``db/models`` structure check, which is WARN when absent).

    Falls back to flat ``src/api/v1`` detection (with the caller surfacing
    a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
    """
    if "fastapi" not in deps_lower and "uvicorn" not in deps_lower:
        return False
    pkg = _resolve_package_name(ctx)
    if pkg is not None:
        return path_exists(f"src/{pkg}/api/v1", ctx)
    return path_exists("src/api/v1", ctx)


def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None:
    """Detect bot/worker types by file or dep marker (or None)."""
    if path_exists("src/bot.py", ctx) or "aiogram" in deps_lower:
        return ProjectType.BOT
    if path_exists("src/flow.py", ctx) or "prefect" in deps_lower:
        return ProjectType.WORKER
    return None


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", ctx) and path_exists("backend", ctx):
        return ProjectType.FULLSTACK

    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, ctx):
        return ProjectType.BACKEND
    simple = _detect_simple_type(deps_lower, ctx)
    if simple is not None:
        return simple

    scripts = pyproject.get("project", {}).get("scripts", {})
    if isinstance(scripts, dict) and scripts and "typer" in deps_lower:
        return ProjectType.CLI

    return ProjectType.UNKNOWN


# ── expected structure per type ──────────────────────────────────────────────


# Re-exported from project_contract.py for backward compatibility
# (tests use ``ps.STRUCTURE_EXPECTED``).
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED


def _expected_backend_paths(pkg: str, db_required: bool) -> list[str]:
    """Return the nested ``src/<package>/`` structure for the backend type.

    ``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``).
    When ``db_required`` is false (no DB dep in pyproject, cookiecutter
    ``use_db=no``), ``db/models`` is excluded from the expected paths — the
    caller surfaces a WARN separately when a db-project is missing it.
    """
    paths = [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/db/models",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config/settings.py",
        "main.py",
    ]
    if not db_required:
        paths.pop(1)  # remove ``src/<pkg>/db/models``
    return paths


# ── README delimiter tags (12) — ported from create-readme.ts:140-199 ───────


README_DELIMITERS: list[str] = [
    "tagline-en:start",
    "tagline-en:end",
    "tagline-ru:start",
    "tagline-ru:end",
    "summary-en:start",
    "summary-en:end",
    "features-en:start",
    "features-en:end",
    "summary-ru:start",
    "summary-ru:end",
    "features-ru:start",
    "features-ru:end",
]


# ── check group 1: structure ─────────────────────────────────────────────────


def _check_backend_lifespan(backend_root: Path) -> CheckResult:
    """Check main.py has a lifespan handler (backend-specific).

    ``backend_root`` is the backend source tree root (``ctx.root`` for BACKEND,
    ``ctx.root / "backend"`` for FULLSTACK).
    """
    main = backend_root / "main.py"
    if not main.exists():
        return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет")
    try:
        content = main.read_text(encoding="utf-8-sig")
    except OSError:
        return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет")
    if "lifespan" in content:
        return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден")
    return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден")


def _check_cli_package(ctx: RepoCtx) -> CheckResult:
    """Check src/<package>/ with __init__.py exists (cli-specific)."""
    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/<package>/", "пакет найден")
    return CheckResult(CheckStatus.WARN, "src/<package>/", "пакет не найден")


def _normalize_package_name(name: str) -> str:
    """Normalize a project name to a Python package name.

    Per PEP 503 / packaging: lowercase + replace runs of ``-_.`` with ``_``.
    Example: ``my-project`` → ``my_project``.
    """
    return re.sub(r"[-_.]+", "_", name).lower()


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):
    nested ``src/<package>/`` is the standard for publishable, reusable
    packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.

    For FULLSTACK the backend source lives under ``backend/``, so the check
    inspects ``backend/src/`` (issue #274). Root-level ``frontend/`` is not
    affected.

    Returns None if ``src/`` does not exist, has a nested package with
    ``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
    Returns a WARN CheckResult if flat layout detected.
    """
    backend_root = _backend_root_for(ptype, ctx)
    src = backend_root / "src"
    if not src.exists() or not src.is_dir():
        return None
    backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
    pyproject = parse_pyproject(backend_ctx)
    project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {}
    proj_name = project.get("name") if isinstance(project, dict) else None
    expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None
    subdirs = [p for p in src.iterdir() if p.is_dir()]
    if not subdirs:
        return None
    has_nested_pkg = any((p / "__init__.py").exists() or p.name == expected_pkg for p in subdirs)
    if has_nested_pkg:
        return None
    return CheckResult(
        CheckStatus.WARN,
        "flat src/ layout",
        "deprecated — рекомендуется src/<package>/ (publishable, reusable as git-dep)",
    )


def _is_tortoise_model_base(base: ast.expr) -> bool:
    """True if a class base references a Tortoise/ORM ``Model``.

    Matches ``Model``, ``tortoise.Model``, ``models.Model``, ``tortoise.models.Model``.
    Does NOT match unrelated ``Model`` classes from other libraries — the caller
    is expected to have already verified the file imports ``tortoise`` or its
    own ``db.models`` module. Used by ``_check_scattered_models`` to detect
    ORM models living outside ``src/<package>/db/models/``.
    """
    # bare ``Model`` (Name) — common when ``from tortoise import Model``
    if isinstance(base, ast.Name) and base.id == "Model":
        return True
    # ``tortoise.Model`` / ``models.Model`` / ``tortoise.models.Model`` (Attribute)
    if isinstance(base, ast.Attribute):
        # last attribute segment must be ``Model``; accept any qualifier
        # (``tortoise``, ``models``, ``db.models``, ...) — the import is
        # validated separately by the caller's import check.
        return base.attr == "Model"
    return False


def _file_imports_tortoise_or_models(source: str, pkg: str) -> bool:
    """True if the file imports ``tortoise`` or its own ``db.models`` module.

    Used to suppress false positives in ``_check_scattered_models``: a class
    named ``Model`` from an unrelated library (e.g. ``pydantic.BaseModel`` is
    already excluded by name, but other libs may define their own ``Model``)
    should not trigger the WARN unless the file actually uses Tortoise/ORM.

    The check is AST-based (walks ``ast.Import`` and ``ast.ImportFrom``) so it
    is robust to comments and strings mentioning those names.
    """
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return False
    models_module = f"{pkg}.db.models"
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                if alias.name == "tortoise" or alias.name.startswith("tortoise."):
                    return True
        elif isinstance(node, ast.ImportFrom):
            module = node.module or ""
            if module == "tortoise" or module.startswith("tortoise."):
                return True
            if module == models_module or module.startswith(models_module + "."):
                return True
    return False


def _scan_file_for_models(py_file: Path, backend_root: Path, pkg: str) -> list[CheckResult]:
    """Scan a single ``.py`` file for ORM ``class X(Model)`` definitions.

    Returns one ``WARN`` CheckResult per offending class (with the file's
    relative path). Returns ``[]`` if the file does not import tortoise or
    the package's own ``db.models`` module (false-positive guard), or if no
    class inherits from ``Model``.

    ``backend_root`` is used for the relative path in the WARN detail (issue
    #274): FULLSTACK reports paths relative to ``backend/``, not the repo
    root, so the message matches what the developer sees in their tree.
    """
    try:
        source = py_file.read_text(encoding="utf-8-sig")
    except OSError:
        return []
    if not _file_imports_tortoise_or_models(source, pkg):
        return []
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return []
    results: list[CheckResult] = []
    rel = py_file.relative_to(backend_root)
    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        for base in node.bases:
            if _is_tortoise_model_base(base):
                results.append(
                    CheckResult(
                        CheckStatus.WARN,
                        str(rel),
                        f"class {node.name}(Model) вне db/models/ — "
                        "models must live in src/<pkg>/db/models/",
                    )
                )
                break
    return results


def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResult]:
    """AST-чек: ORM models должны жить в ``src/<package>/db/models/``.

    Scans ``.py`` files in ``src/<package>/`` (excluding ``db/models/``) for
    ``class X(Model)`` / ``class X(tortoise.Model)``. If found → WARN with the
    offending relative path. Models in ``db/models/`` are OK; files that do not
    import ``tortoise`` or the package's own ``db.models`` module are skipped
    (suppresses false positives from unrelated libraries defining ``Model``).

    For FULLSTACK the backend lives under ``backend/`` (issue #274): the
    package name is resolved against ``backend/pyproject.toml`` and the scan
    targets ``backend/src/<package>/``. Skips for non-backend/fullstack types,
    flat layouts (no ``src/<package>/``), and repos without a resolvable
    package name. Returns ``[]`` in all skip cases so the caller appends nothing.
    """
    if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
        return []
    backend_root = _backend_root_for(ptype, ctx)
    backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
    pkg = _resolve_package_name(backend_ctx)
    if pkg is None:
        return []
    src_pkg = backend_root / "src" / pkg
    if not src_pkg.is_dir():
        return []
    models_dir = src_pkg / "db" / "models"
    results: list[CheckResult] = []
    for py_file in src_pkg.rglob("*.py"):
        if models_dir in py_file.parents or py_file == models_dir:
            continue
        results.extend(_scan_file_for_models(py_file, backend_root, pkg))
    return results


def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
    """Fullstack frontend stack detection: tailwindcss + bits-ui in
    ``frontend/package.json`` deps + ``components.json`` + ``tsconfig.json``
    existence (4 markers).

    Returns ``None`` if ``frontend/package.json`` does not exist (caller
    surfaces the missing-package.json WARN separately). Returns a WARN
    ``CheckResult`` listing the missing markers when any are absent. Returns
    ``None`` (no CheckResult) when all 4 markers are present — the caller
    emits an OK in the structure loop is not needed; we return ``None`` so
    nothing extra is appended, keeping the structure group clean.

    Actually: returns an OK ``CheckResult`` when all markers present, so the
    audit explicitly confirms the frontend stack is up-to-date.
    """
    pkg_path = ctx.root / "frontend" / "package.json"
    if not pkg_path.exists():
        return None  # surfaced by the existing ``frontend/package.json`` check
    required_deps = project_contract.FRONTEND_STACK_MARKERS["fullstack_package_deps"]
    required_files = project_contract.FRONTEND_STACK_MARKERS["fullstack_files"]
    missing: list[str] = []
    try:
        pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig"))
    except (json.JSONDecodeError, OSError):
        missing.extend(f"{d} in package.json" for d in required_deps)
        pkg = {}
    if not missing:
        deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
        for dep in required_deps:
            if dep not in deps:
                missing.append(dep)
    for rel in required_files:
        if not (ctx.root / rel).exists():
            missing.append(rel)
    if missing:
        return CheckResult(
            CheckStatus.WARN,
            "frontend stack",
            "frontend stack outdated: missing "
            + ", ".join(missing)
            + ". Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS.",
        )
    return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected")


def _check_mobile_first(ctx: RepoCtx) -> CheckResult | None:
    """Fullstack mobile-first guarantee (issue #278): PWA manifest + mobile
    Playwright + axe-core accessibility.

    Returns ``None`` if ``frontend/package.json`` does not exist (the missing
    package.json WARN is surfaced separately by ``_check_type_specific_structure``).
    Returns ``None`` for non-fullstack types — the caller only invokes this for
    FULLSTACK. Otherwise returns OK when all markers are present, or WARN with
    the missing marker list (never FAIL — issue #275 all-WARN contract).

    Markers (``MOBILE_FIRST_MARKERS``):
        * ``frontend/static/manifest.webmanifest`` exists
        * ``frontend/src/app.html`` contains both "viewport" and "manifest"
        * ``frontend/tests/e2e/mobile.spec.ts`` exists
        * ``frontend/tests/e2e/accessibility.spec.ts`` exists
        * ``@axe-core/playwright`` in ``frontend/package.json`` devDependencies
    """
    pkg_path = ctx.root / "frontend" / "package.json"
    if not pkg_path.exists():
        return None
    missing: list[str] = []
    for rel in project_contract.MOBILE_FIRST_MARKERS["fullstack_files"]:
        if not (ctx.root / rel).exists():
            missing.append(rel)
    app_html = read_text("frontend/src/app.html", ctx) or ""
    for marker in project_contract.MOBILE_FIRST_MARKERS["fullstack_app_html_markers"]:
        if marker not in app_html:
            missing.append(f"frontend/src/app.html ({marker})")
    try:
        pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig"))
    except (json.JSONDecodeError, OSError):
        pkg = {}
    dev_deps = pkg.get("devDependencies", {}) if isinstance(pkg, dict) else {}
    if "@axe-core/playwright" not in dev_deps:
        missing.append("@axe-core/playwright in package.json devDependencies")
    if missing:
        return CheckResult(
            CheckStatus.WARN,
            "mobile-first",
            "missing " + ", ".join(missing),
        )
    return CheckResult(CheckStatus.OK, "mobile-first", "PWA + mobile Playwright + a11y detected")


def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
    """Type-specific extra checks beyond the expected dirs list."""
    results: list[CheckResult] = []
    backend_root = _backend_root_for(ptype, ctx)
    if ptype == ProjectType.BACKEND:
        results.append(_check_backend_lifespan(backend_root))
    if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx):
        results.append(
            CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")
        )
    if ptype == ProjectType.FULLSTACK:
        results.append(_check_backend_lifespan(backend_root))
        frontend_stack = _check_frontend_stack(ctx)
        if frontend_stack is not None:
            results.append(frontend_stack)
        mobile = _check_mobile_first(ctx)
        if mobile is not None:
            results.append(mobile)
    if ptype == ProjectType.CLI:
        results.append(_check_cli_package(ctx))
    flat = _check_flat_layout(ptype, ctx)
    if flat is not None:
        results.append(flat)
    results.extend(_check_scattered_models(ctx, ptype))
    return results


def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
    """Group 1: Structure — expected dirs/files per project type.

    For BACKEND and FULLSTACK the expected paths are rooted at the backend
    source tree (``ctx.root`` for BACKEND, ``ctx.root / "backend"`` for
    FULLSTACK — issue #274). ``db/models`` is auto-detected from
    ``[project.dependencies]``: if any DB marker (tortoise-orm, sqlalchemy,
    sqlmodel, alembic, aerich, pony, databases) is present, ``db/models`` is
    expected and a missing dir yields WARN (issue #275: all-WARN contract);
    if no DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no).
    """
    group = GroupResult(name="Структура")
    if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
        backend_root = _backend_root_for(ptype, ctx)
        backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
        pkg = _resolve_package_name(backend_ctx)
        if pkg is None:
            group.checks.append(
                CheckResult(
                    CheckStatus.WARN,
                    "auto-detect",
                    "нет pyproject.toml — cannot resolve package name",
                )
            )
            return group
        deps_raw = parse_pyproject(backend_ctx).get("project", {}).get("dependencies", [])
        deps_lower = (
            " ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else ""
        )
        db_required = _is_db_project(deps_lower)
        expected = _expected_backend_paths(pkg, db_required=db_required)
        for rel in expected:
            status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.WARN
            detail = "существует" if status == CheckStatus.OK else "отсутствует"
            group.checks.append(CheckResult(status, rel, detail))
        if db_required:
            db_models_rel = f"src/{pkg}/db/models"
            if not (backend_root / db_models_rel).exists():
                # Ensure the db/models check carries the db-project detail even
                # though the generic loop above already set it to WARN. The
                # detail here is more informative (issue #275: all-WARN).
                group.checks = [
                    c
                    if c.name != db_models_rel
                    else CheckResult(
                        CheckStatus.WARN, db_models_rel, "отсутствует (db-проект без db/models)"
                    )
                    for c in group.checks
                ]
    else:
        expected = STRUCTURE_EXPECTED.get(ptype.value, [])
        if not expected:
            group.checks.append(
                CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
            )
            return group
        for rel in expected:
            status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.WARN
            detail = "существует" if status == CheckStatus.OK else "отсутствует"
            group.checks.append(CheckResult(status, rel, detail))
    group.checks.extend(_check_type_specific_structure(ptype, ctx))
    return group


# ── check group 2: thin routes (AST, ≤ route_line_limit lines) ───────────────


def _route_line_count(source: str) -> int:
    """Count lines for a route handler function (body span in source)."""
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return -1
    route_methods = {"get", "post", "put", "delete", "patch"}
    max_lines = 0
    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        if _has_route_decorator(node.decorator_list, route_methods):
            lines = node.end_lineno - node.lineno + 1 if node.end_lineno else 0
            max_lines = max(max_lines, lines)
    return max_lines


def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) -> bool:
    """True if any decorator is a route method (@router.get, @get, etc.)."""
    for dec in decorators:
        # @router.get(...) → Call(func=Attribute(attr='get'))
        if (
            isinstance(dec, ast.Call)
            and isinstance(dec.func, ast.Attribute)
            and dec.func.attr in route_methods
        ):
            return True
        # @app.get / @router.get used bare → Attribute(attr='get')
        if isinstance(dec, ast.Attribute) and dec.attr in route_methods:
            return True
        # @get / @post (simple name) → Name(id='get')
        if isinstance(dec, ast.Name) and dec.id in route_methods:
            return True
    return False


def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]:
    """Return list of api/v1 dirs to scan for routes, based on project type.

    Both BACKEND and FULLSTACK use the nested ``src/<package>/api/v1`` layout
    (issue #241). The package name is resolved from ``[project].name`` in
    ``pyproject.toml`` (normalized via ``_normalize_package_name``). FULLSTACK
    additionally prefixes the backend root with ``backend/``.
    """
    if ptype == ProjectType.BACKEND:
        pkg = _resolve_package_name(ctx)
        if pkg is None:
            return []
        root = ctx.root / "src" / pkg / "api" / "v1"
        return [root] if root.exists() else []
    if ptype == ProjectType.FULLSTACK:
        # pyproject.toml lives under ``backend/`` in the fullstack template,
        # so resolve the package name against a backend-rooted context.
        backend_ctx = RepoCtx(root=ctx.root / "backend", config=ctx.config)
        pkg = _resolve_package_name(backend_ctx)
        if pkg is None:
            return []
        root = ctx.root / "backend" / "src" / pkg / "api" / "v1"
        return [root] if root.exists() else []
    return []


FORBIDDEN_ROUTE_IMPORTS: tuple[str, ...] = ("src.db.models", "tortoise")


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
    over_limit: list[str] = []
    for api_dir in api_dirs:
        for py in api_dir.rglob("*.py"):
            source = py.read_text(encoding="utf-8-sig", errors="ignore")
            n = _route_line_count(source)
            if n < 0:
                continue
            files_checked += 1
            longest = max(longest, n)
            if n > limit:
                over_limit.append(f"{py.relative_to(ctx.root)}:{n}")
    return files_checked, longest, over_limit


def _check_route_imports(route_file: Path, ctx: RepoCtx) -> CheckResult | None:
    """AST-чек: routes не должны импортировать ``src.db.models`` или ``tortoise``.

    Routes должны идти через services/schemas, а не тянуть модели БД напрямую
    (digital_factory anti-pattern: route bypasses the service layer).

    Returns ``None`` if no forbidden import found (OK), or a ``CheckResult``
    with ``WARN`` status naming the offending module (issue #275: all-WARN
    contract — forbidden imports are surfaced, never blocking). ``ast`` is
    used so the check is robust to comments/strings mentioning those names.
    """
    try:
        tree = ast.parse(route_file.read_text(encoding="utf-8-sig", errors="ignore"))
    except SyntaxError:
        return None
    for node in ast.walk(tree):
        if not isinstance(node, ast.ImportFrom):
            continue
        module = node.module or ""
        if any(module == f or module.startswith(f + ".") for f in FORBIDDEN_ROUTE_IMPORTS):
            rel = route_file.relative_to(ctx.root)
            return CheckResult(
                CheckStatus.WARN,
                str(rel),
                f"импортирует {module} — роут должен идти через services",
            )
    return None


def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult]:
    """Scan api dirs for forbidden route imports (``src.db.models``/``tortoise``)."""
    bad: list[CheckResult] = []
    for api_dir in api_dirs:
        for py in api_dir.rglob("*.py"):
            res = _check_route_imports(py, ctx)
            if res is not None:
                bad.append(res)
    return bad


def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult:
    """Group 2: Тонкие роуты — AST imports (WARN) + line count (WARN).

    For FULLSTACK the api dirs live under ``backend/src/<pkg>/api/v1`` and
    WARN paths are reported relative to ``backend/`` (issue #274). Issue #275:
    forbidden imports are WARN (non-blocking), not FAIL.
    """
    _ = fast  # unused here, accepted for signature uniformity
    group = GroupResult(name="Тонкие роуты")
    if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
        group.checks.append(
            CheckResult(CheckStatus.OK, "skip", f"тип={ptype.value}: роуты не применимы")
        )
        return group
    api_dirs = _api_dirs_for(ptype, ctx)
    if not api_dirs:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена")
        )
        return group
    backend_ctx = _backend_ctx_for(ptype, ctx)
    _append_route_checks(group, api_dirs, backend_ctx)
    return group


def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx) -> None:
    """Append import + line-count checks to ``group`` (split for ≤50 lines).

    Issue #275: forbidden imports surface as WARN (non-blocking), not FAIL.
    """
    bad_imports = _scan_route_imports(api_dirs, ctx)
    if bad_imports:
        detail = ", ".join(f"{c.name}: {c.detail}" for c in bad_imports[:3])
        group.checks.append(
            CheckResult(CheckStatus.WARN, "route imports", f"запрещённые: {detail}")
        )
    else:
        group.checks.append(
            CheckResult(CheckStatus.OK, "route imports", "роуты не импортируют модели БД")
        )
    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:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN,
                f"route ≤ {limit} lines",
                f"превышение: {', '.join(over_limit[:3])}",
            )
        )
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.OK, f"route ≤ {limit} lines", f"макс={longest}, файлов={files_checked}"
            )
        )


# ── check group 3: code quality (mypy/ruff/pytest presence) ──────────────────


def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
    """Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.

    Reads ``pyproject.toml`` from the backend root (FULLSTACK →
    ``backend/pyproject.toml`` — issue #274). Issue #275: missing tools are
    WARN (non-blocking), not FAIL.
    """
    group = GroupResult(name="Качество кода")
    backend_ctx = _backend_ctx_for(ptype, ctx)
    pyproject = parse_pyproject(backend_ctx)
    tools = pyproject.get("tool", {})
    for tool_name in ("ruff", "mypy"):
        if tool_name in tools:
            group.checks.append(CheckResult(CheckStatus.OK, tool_name, "настроен в pyproject.toml"))
        else:
            group.checks.append(
                CheckResult(CheckStatus.WARN, tool_name, f"[tool.{tool_name}] отсутствует")
            )
    pytest_cfg = pyproject.get("tool", {}).get("pytest", {})
    dev_deps = pyproject.get("project", {}).get("optional-dependencies", {}).get("dev", [])
    dev_str = " ".join(str(d).lower() for d in dev_deps) if isinstance(dev_deps, list) else ""
    if pytest_cfg or "pytest" in dev_str:
        group.checks.append(CheckResult(CheckStatus.OK, "pytest", "настроен"))
    else:
        group.checks.append(CheckResult(CheckStatus.WARN, "pytest", "не найден в dev-deps"))
    return group


# ── check group 4: tests (conftest, stub-detector, no @pytest.mark.asyncio) ──


def _has_test_func(source: str) -> bool:
    """True if source declares ``def test_*`` or ``async def test_*``.

    Used by the stub-detector: ``test_*.py`` files without any test function
    are stub files (digital_factory anti-pattern: test file that asserts
    nothing).
    """
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return True
    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        if node.name.startswith("test"):
            return True
    return False


def _check_test_structure(ptype: ProjectType, tests_dir: Path, group: GroupResult) -> None:
    """Append backend-specific ``tests/unit/`` + ``tests/api/`` + integration checks.

    Backend projects are expected to have a layered test layout mirroring the
    source layering (unit tests for services, api tests for routes, integration
    tests for cross-cutting flows). CLI/BOT/WORKER have no api layer so the
    ``tests/api/`` check is skipped for them.
    """
    if ptype != ProjectType.BACKEND:
        return
    if not (tests_dir / "unit").exists():
        group.checks.append(
            CheckResult(
                CheckStatus.WARN, "tests/unit/", "отсутствует (рекомендуется для unit-тестов)"
            )
        )
    else:
        group.checks.append(CheckResult(CheckStatus.OK, "tests/unit/", "существует"))
    if not (tests_dir / "api").exists():
        group.checks.append(
            CheckResult(
                CheckStatus.WARN, "tests/api/", "отсутствует (рекомендуется для route-тестов)"
            )
        )
    else:
        group.checks.append(CheckResult(CheckStatus.OK, "tests/api/", "существует"))
    _check_integration_dir(tests_dir, group)


def _check_integration_dir(tests_dir: Path, group: GroupResult) -> None:
    """Append checks for ``tests/integration/`` (presence + pytestmark)."""
    integration_dir = tests_dir / "integration"
    if not integration_dir.exists():
        return
    test_files = list(integration_dir.glob("test_*.py"))
    if not test_files:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "tests/integration/", "директория пустая")
        )
        return
    without_mark = [
        f.name
        for f in test_files
        if "pytest.mark.integration" not in f.read_text(encoding="utf-8-sig")
    ]
    if without_mark:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN,
                "tests/integration/ pytestmark",
                f"без pytest.mark.integration: {', '.join(without_mark[:3])}",
            )
        )
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.OK, "tests/integration/ pytestmark", "все файлы имеют pytestmark"
            )
        )


def _check_stub_files(test_files: list[Path]) -> CheckResult:
    """Stub-detector: ``test_*.py`` without ``def test_*``/``async def test_*``.

    Replaces the previous name-based "stub" heuristic (which matched ``stub``
    in the function name) with an AST check for test functions: a file that
    declares no ``def test_*``/``async def test_*`` is a stub (asserts nothing).
    """
    stubs = []
    for tf in test_files:
        try:
            source = tf.read_text(encoding="utf-8-sig")
        except OSError:
            continue
        if not _has_test_func(source):
            stubs.append(tf.name)
    if stubs:
        return CheckResult(
            CheckStatus.WARN, "stub-detector", f"{len(stubs)} stub-файлов: {', '.join(stubs[:3])}"
        )
    return CheckResult(CheckStatus.OK, "stub-detector", "0 stub-файлов")


def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
    """Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.

    For BACKEND and FULLSTACK the tests live under the backend root (FULLSTACK
    → ``backend/tests/`` — issue #274). Issue #275: missing tests dir / too
    few test files are WARN (non-blocking), not FAIL.
    """
    group = GroupResult(name="Тесты")
    backend_root = _backend_root_for(ptype, ctx)
    tests_dir = backend_root / "tests"
    if not tests_dir.exists():
        group.checks.append(
            CheckResult(CheckStatus.WARN, "tests/", "директория tests/ отсутствует")
        )
        return group
    if (tests_dir / "conftest.py").exists():
        group.checks.append(CheckResult(CheckStatus.OK, "conftest.py", "существует"))
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN, "conftest.py", "отсутствует — pytest fixtures без общего конфига"
            )
        )
    test_files = list(tests_dir.glob("test_*.py"))
    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:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "test files", f"{len(test_files)} (< {min_tests})")
        )
    _check_test_structure(ptype, tests_dir, group)
    asyncio_marks = 0
    for tf in test_files:
        try:
            content = tf.read_text(encoding="utf-8-sig")
        except OSError:
            continue
        asyncio_marks += content.count("@pytest.mark.asyncio")
    if asyncio_marks == 0:
        group.checks.append(
            CheckResult(CheckStatus.OK, "no @pytest.mark.asyncio", "asyncio_mode=auto используется")
        )
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN,
                "no @pytest.mark.asyncio",
                f"{asyncio_marks} маркеров — не нужно при asyncio_mode=auto",
            )
        )
    group.checks.append(_check_stub_files(test_files))
    return group


# ── check group 5: README (12 delimiter tags) ────────────────────────────────


def _readme_required_sections(content: str) -> list[CheckResult]:
    """Check required README sections ported from ``validateReadme`` (create-readme.ts).

    Each missing section → WARN (issue #275: all-WARN contract, non-blocking).
    Covers: RU switcher link, support link, Quick Start (EN), Быстрый старт
    (RU), and a manual ``## License`` section (duplicates the GitHub sidebar).
    """
    results: list[CheckResult] = []
    if "[Русский](#-русский)" not in content:
        results.append(
            CheckResult(
                CheckStatus.WARN,
                "[Русский](#-русский)",
                "отсутствует RU switcher (должен быть #-русский)",
            )
        )
    else:
        results.append(CheckResult(CheckStatus.OK, "[Русский](#-русский)", "присутствует"))
    if "slaid098.dev/contacts" in content:
        results.append(CheckResult(CheckStatus.OK, "slaid098.dev/contacts", "присутствует"))
    elif "slaid098.dev/support" in content:
        results.append(
            CheckResult(
                CheckStatus.WARN,
                "slaid098.dev/contacts",
                "deprecated: найдена старая ссылка slaid098.dev/support, "
                "перегенерируй README через create-readme",
            )
        )
    else:
        results.append(
            CheckResult(CheckStatus.WARN, "slaid098.dev/contacts", "отсутствует support link")
        )
    if "Quick Start" not in content:
        results.append(
            CheckResult(CheckStatus.WARN, "Quick Start", "отсутствует EN секция Quick Start")
        )
    else:
        results.append(CheckResult(CheckStatus.OK, "Quick Start", "присутствует"))
    if "Быстрый старт" not in content:
        results.append(
            CheckResult(CheckStatus.WARN, "Быстрый старт", "отсутствует RU секция Быстрый старт")
        )
    else:
        results.append(CheckResult(CheckStatus.OK, "Быстрый старт", "присутствует"))
    if re.search(r"^##\s+(License|LICENSE|Лицензия)\s*$", content, re.MULTILINE):
        results.append(
            CheckResult(
                CheckStatus.WARN,
                "Manual License section",
                "найден — удалить (GitHub рендерит из LICENSE файла)",
            )
        )
    return results


def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
    """Group 5: README — full port of ``validateReadme`` from create-readme.ts.

    Issue #275: all problems are WARN (non-blocking), never FAIL.
    """
    group = GroupResult(name="README")
    content = read_text("README.md", ctx)
    if content is None:
        group.checks.append(CheckResult(CheckStatus.WARN, "README.md", "отсутствует"))
        return group
    missing = [d for d in README_DELIMITERS if f"<!-- {d} -->" not in content]
    if missing:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN,
                "12 delimiter tags",
                f"не хватает {len(missing)}: {', '.join(missing[:3])}",
            )
        )
    else:
        group.checks.append(
            CheckResult(CheckStatus.OK, "12 delimiter tags", "все 12 разделителей присутствуют")
        )
    for required_text in ("# 🚀 ", "## 🇺🇸 English", "## 🇷🇺 Русский", "[English](#-english)"):
        if required_text in content:
            group.checks.append(CheckResult(CheckStatus.OK, required_text, "присутствует"))
        else:
            group.checks.append(CheckResult(CheckStatus.WARN, required_text, "отсутствует"))
    group.checks.extend(_readme_required_sections(content))
    if "assets/cover.png" in content:
        group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан"))
    else:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "cover.png", "не указан — slaid098.dev showcase требует")
        )
    return group


# ── check group 6: infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit) ──


def _check_branch_protection(repo: str) -> CheckResult:
    """Read-only ``gh api repos/<repo>/rules/branches/main`` — returns CheckResult."""
    rc, out, err = run_cmd(["gh", "api", f"repos/{repo}/rules/branches/main"])
    if rc != 0:
        return CheckResult(
            CheckStatus.WARN, "branch protection", f"gh api не отвечает: {err.strip()[:60]}"
        )
    if "pull_request" in out and "required_status_checks" in out:
        return CheckResult(CheckStatus.OK, "branch protection", "main защищён (PR + checks)")
    return CheckResult(CheckStatus.WARN, "branch protection", "правила найдены, но набор неполный")


def check_infra(
    ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None
) -> GroupResult:
    """Group 6: Infra — branch protection, ci.yml, dependabot, LICENSE, pre-commit.

    If ``repo_root`` is given, uses ``git -C <repo_root>`` to locate the remote
    for branch protection detection.
    """
    group = GroupResult(name="Infra")
    if path_exists(".github/workflows/ci.yml", ctx):
        group.checks.append(CheckResult(CheckStatus.OK, ".github/workflows/ci.yml", "есть"))
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN,
                ".github/workflows/ci.yml",
                "отсутствует — CI не настроен",
            )
        )
    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", ctx):
        group.checks.append(CheckResult(CheckStatus.OK, "LICENSE", "есть"))
    else:
        group.checks.append(CheckResult(CheckStatus.WARN, "LICENSE", "отсутствует"))
    if path_exists(".pre-commit-config.yaml", ctx):
        group.checks.append(CheckResult(CheckStatus.OK, "pre-commit", "настроен"))
    else:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "pre-commit", "отсутствует — quality gate только в CI")
        )
    if fast:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "branch protection", "пропущено (--fast)")
        )
    else:
        repo = get_repo_full_name(repo_root)
        if repo is None:
            group.checks.append(
                CheckResult(
                    CheckStatus.WARN, "branch protection", "git remote недоступен (не git repo?)"
                )
            )
        else:
            group.checks.append(_check_branch_protection(repo))
    return group


# ── check group 7: coverage (non-blocking) ───────────────────────────────────


def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
    """Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).

    Reads ``pyproject.toml`` from the backend root (FULLSTACK →
    ``backend/pyproject.toml`` — issue #274).
    """
    group = GroupResult(name="Coverage")
    backend_ctx = _backend_ctx_for(ptype, ctx)
    pyproject = parse_pyproject(backend_ctx)
    cov = pyproject.get("tool", {}).get("coverage", {})
    run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {}
    sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else []
    if sources:
        group.checks.append(CheckResult(CheckStatus.OK, "[tool.coverage.run]", f"source={sources}"))
    else:
        group.checks.append(CheckResult(CheckStatus.WARN, "[tool.coverage.run]", "source не задан"))
    pytest_opts = pyproject.get("tool", {}).get("pytest", {}).get("ini_options", {})
    addopts = pytest_opts.get("addopts", "") if isinstance(pytest_opts, dict) else ""
    if "--cov-fail-under" in str(addopts):
        m = re.search(r"--cov-fail-under=(\d+)", str(addopts))
        threshold = m.group(1) if m else "?"
        group.checks.append(CheckResult(CheckStatus.OK, "cov-fail-under", f"порог={threshold}%"))
    else:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN, "cov-fail-under", "порог не задан (coverage non-blocking)"
            )
        )
    return group


# ── check group 8: pyproject.toml validity (13 checks) ───────────────────────


DEFAULT_COVERAGE_EXCLUDE_LINES: list[str] = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]


def _has_ruff_config(pyproject: dict[str, Any], repo_root: Path) -> bool:
    """True if [tool.ruff] section exists or ruff.toml file is present."""
    if "ruff" in pyproject.get("tool", {}):
        return True
    return (repo_root / "ruff.toml").exists()


def _has_mypy_config(pyproject: dict[str, Any], repo_root: Path) -> bool:
    """True if [tool.mypy] section exists or mypy.ini file is present."""
    if "mypy" in pyproject.get("tool", {}):
        return True
    return (repo_root / "mypy.ini").exists() or (repo_root / ".mypy.ini").exists()


def _mypy_strict(pyproject: dict[str, Any]) -> bool:
    """True if mypy is strict (strict=true or disallow_untyped_defs=true)."""
    mypy = pyproject.get("tool", {}).get("mypy", {})
    if not isinstance(mypy, dict):
        return False
    return bool(mypy.get("strict")) or bool(mypy.get("disallow_untyped_defs"))


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). Issue #275: incompat / parse errors
    are WARN (non-blocking), not FAIL.
    """
    name = "requires-python vs .python-version"
    try:
        from packaging.specifiers import SpecifierSet  # noqa: PLC0415
    except ImportError:
        return CheckResult(CheckStatus.WARN, name, "packaging не установлен — проверка пропущена")
    pinned = python_version_file.strip()
    m = re.search(r"(\d+\.\d+)", pinned)
    if not m:
        return CheckResult(
            CheckStatus.WARN, name, f"не удалось распарсить версию из .python-version: {pinned!r}"
        )
    version = m.group(1)
    try:
        spec = SpecifierSet(requires_python)
    except ValueError as e:
        return CheckResult(CheckStatus.WARN, name, f"неверный requires-python: {e}")
    if spec.contains(version, prereleases=True):
        return CheckResult(
            CheckStatus.OK, name, f"requires-python={requires_python!r} включает {version}"
        )
    return CheckResult(
        CheckStatus.WARN, name, f"requires-python={requires_python!r} не включает {version}"
    )


def _load_pyproject(root: Path) -> dict[str, Any] | None:
    """Load and parse ``pyproject.toml``; return None on missing or parse error.

    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.
    """
    pyproject_path = root / "pyproject.toml"
    if not pyproject_path.exists():
        return None
    try:
        with pyproject_path.open("rb") as f:
            return tomllib.load(f)
    except (OSError, ValueError) as e:
        return {"__parse_error__": str(e)}


def _check_build_system(data: dict[str, Any]) -> CheckResult:
    """Check 1: ``[build-system]`` requires hatchling + hatchling.build backend.

    Issue #275: missing/incorrect build-system is WARN (non-blocking), not FAIL.
    """
    build = data.get("build-system", {}) if isinstance(data, dict) else {}
    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":
        return CheckResult(CheckStatus.OK, "[build-system]", "hatchling настроен")
    return CheckResult(
        CheckStatus.WARN,
        "[build-system]",
        f"требуется hatchling (requires={requires!r}, backend={build_backend!r})",
    )


def _check_hatch_packages_nested(hatch_packages: object, src_pkg_path: str) -> CheckResult:
    """Sub-check of check 2: classify hatch packages when ``src/<pkg>/`` exists.

    Returns OK if packages references ``src/<pkg>``; WARN if ``["src"]`` flat
    layout; WARN otherwise (issue #275: all-WARN contract, non-blocking).
    """
    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.WARN, 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/<pkg>/`` is the standard; ``packages=["src"]`` (flat) → WARN;
    missing ``src/<pkg>/`` 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 = 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 []
    if src_pkg_dir_exists:
        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/<package>/",
        )
    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"
    )


def _check_project_fields(project: dict[str, Any]) -> CheckResult:
    """Check 3: ``[project]`` has name, version, description, requires-python.

    Issue #275: missing fields are WARN (non-blocking), not FAIL.
    """
    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.WARN, "[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.

    Issue #275: missing ruff config is WARN (non-blocking), not FAIL.
    """
    name = "[tool.ruff]"
    tools = data.get("tool", {}) if isinstance(data, dict) else {}
    ruff_section = tools.get("ruff", {}) if isinstance(tools, dict) else {}
    if not _has_ruff_config(data, root):
        return CheckResult(CheckStatus.WARN, 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 обнаружен")


def _check_mypy_section(data: dict[str, Any], root: Path) -> CheckResult:
    """Check 5: ``[tool.mypy]`` strict or ``mypy.ini`` present.

    Issue #275: missing mypy config is WARN (non-blocking), not FAIL.
    """
    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.WARN, 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"].

    Issue #275: missing pytest config is WARN (non-blocking), not FAIL.
    """
    name = "[tool.pytest.ini_options]"
    pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {}
    if not (isinstance(pytest_opts, dict) and pytest_opts):
        return CheckResult(CheckStatus.WARN, 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"])',
    )


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:
        return CheckResult(CheckStatus.OK, name, f"source={cov_run.get('source')!r}, branch=true")
    return CheckResult(CheckStatus.WARN, name, "нужны source и branch=true")


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 []
    missing_exclude = [
        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:
        return CheckResult(
            CheckStatus.OK, name, f"exclude_lines содержит {len(exclude_strs)} паттернов"
        )
    return CheckResult(
        CheckStatus.WARN, name, f"exclude_lines не хватает: {', '.join(missing_exclude)}"
    )


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:
        return CheckResult(CheckStatus.OK, name, f"порог={m_cov.group(1)}%")
    return CheckResult(CheckStatus.WARN, name, "порог coverage не задан")


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:
            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 (OK/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 WARN (issue #275:
    all-WARN contract, non-blocking).

    Reads ``pyproject.toml`` from the backend root (FULLSTACK →
    ``backend/pyproject.toml`` — issue #274); root-level files checked by
    sub-checks (``.pre-commit-config.yaml``, ``uv.lock``, ``.python-version``,
    ``ruff.toml``, ``mypy.ini``) also resolve against the backend root.
    """
    group = GroupResult(name="Pyproject")
    backend_root = _backend_root_for(ptype, ctx)
    data = _load_pyproject(backend_root)
    if data is None:
        group.checks.append(
            CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks")
        )
        return group
    if "__parse_error__" in data:
        group.checks.append(
            CheckResult(
                CheckStatus.WARN, "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, backend_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 ────────────────────────────────────────────────────────────


CHECK_GROUPS: list[str] = [
    "Структура",
    "Тонкие роуты",
    "Качество кода",
    "Тесты",
    "README",
    "Infra",
    "Coverage",
    "Pyproject",
]


def run_all_checks(
    ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None
) -> list[GroupResult]:
    """Run all 8 check groups, return results in order.

    If ``repo_root`` is given, it is forwarded to ``check_infra`` for
    ``git -C`` based remote detection (used with ``--repo`` flag).
    """
    return [
        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),
    ]


STATUS_PREFIX: dict[CheckStatus, str] = {
    CheckStatus.OK: "[OK]",
    CheckStatus.WARN: "[WARN]",
    CheckStatus.FAIL: "[FAIL]",
}


def format_output(ptype: ProjectType, groups: list[GroupResult]) -> str:
    """Format output: 8 group blocks + Итог + Рекомендации.

    Issue #275: all checks are non-blocking (WARN). The ``Итог:`` line shows
    only ``OK`` and ``WARN`` counts (no ``FAIL`` — no check returns FAIL, so a
    FAIL count would always be 0 and is omitted per the issue #275 contract).
    ``Рекомендации:`` lists every WARN with its group + name + detail so the
    audit / project-template skills can parse the report without relying on
    the exit code.
    """
    lines: list[str] = [f"Project: {ptype.value}", ""]
    recommendations: list[str] = []
    ok_count = 0
    warn_count = 0
    for group in groups:
        overall = group.overall()
        prefix = STATUS_PREFIX[overall]
        lines.append(f"{prefix} {group.name}")
        for chk in group.checks:
            sub_prefix = STATUS_PREFIX[chk.status]
            lines.append(f"  {sub_prefix} {chk.name}: {chk.detail}")
            if chk.status == CheckStatus.FAIL:
                recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}")
            elif chk.status == CheckStatus.WARN:
                warn_count += 1
                recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}")
            else:
                ok_count += 1
        lines.append("")
    lines.append("Итог:")
    lines.append(f"  OK: {ok_count}  WARN: {warn_count}")
    if recommendations:
        lines.append("")
        lines.append("Рекомендации:")
        lines.extend(recommendations)
    return "\n".join(lines)


def _parse_args(argv: list[str]) -> argparse.Namespace:
    """Parse CLI args."""
    parser = argparse.ArgumentParser(
        prog="project-status.py",
        description="Read-only check of repo architecture conformance.",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="accepted for CLI compatibility — exit code is always 0 (issue #275)",
    )
    parser.add_argument(
        "--fast",
        action="store_true",
        help="skip slow/remote checks (branch protection via gh)",
    )
    parser.add_argument(
        "--repo",
        type=str,
        default=None,
        help="path to repo to check (default: cwd / current repo)",
    )
    return parser.parse_args(argv)


def main() -> None:
    """Entry point: parse args, run checks, print report, set exit code.

    Issue #275: all checks are non-blocking — the exit code is always 0
    (informational mode). The ``--check`` flag is accepted for CLI backward
    compatibility but no longer forces exit 1. The only non-zero exit is for
    an invalid ``--repo`` path (argument validation, not a check result).
    """
    args = _parse_args(sys.argv[1:])
    strict = args.check
    fast = args.fast
    repo_arg = args.repo
    _ = strict  # accepted for CLI compat, no longer forces exit 1 (issue #275)

    if repo_arg:
        repo_path = Path(repo_arg).resolve()
        if not repo_path.exists():
            print(f"FAIL: repo path not found: {repo_path}")
            sys.exit(1)
        root = repo_path
    else:
        root = REPO_ROOT
    ctx = RepoCtx(root=root, config=load_config(root))

    ptype = detect_project_type(ctx)
    # Populate ``backend_root`` (FULLSTACK → ``root / "backend"``, else root)
    # so checks reading backend-source files resolve to the right tree. The
    # frozen ctx is rebuilt with the resolved field (issue #274).
    ctx = RepoCtx(root=ctx.root, config=ctx.config, backend_root=_backend_root_for(ptype, ctx))
    groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None)
    print(format_output(ptype, groups))
    sys.exit(0)


if __name__ == "__main__":
    main()
