#!/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]/[FAIL]`` lines, an Итог summary, and Рекомендации. 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 # non-blocking (exit 0) python3 .opencode/scripts/project-status.py --check # strict (exit 1 on FAIL) 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//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) ```` = ``[project].name`` normalized (``my-project`` → ``my_project``). Nested ``src//`` 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.""" 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.""" 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 `` 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//api/v1`` + fastapi/uvicorn in deps. Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the nested ``src//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//`` 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//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.FAIL, "main.py lifespan", "main.py нет") try: content = main.read_text(encoding="utf-8-sig") except OSError: return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") if "lifespan" in content: return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден") return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден") def _check_cli_package(ctx: RepoCtx) -> CheckResult: """Check src// 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//", "пакет найден") return CheckResult(CheckStatus.FAIL, "src//", "пакет не найден") 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//`` 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// (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//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//db/models/", ) ) break return results def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResult]: """AST-чек: ORM models должны жить в ``src//db/models/``. Scans ``.py`` files in ``src//`` (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//``. Skips for non-backend/fullstack types, flat layouts (no ``src//``), 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_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) 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 (not FAIL — issue #275); 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.FAIL detail = "существует" if status == CheckStatus.OK else "отсутствует" group.checks.append(CheckResult(status, rel, detail)) if db_required: db_models_rel = f"src/{pkg}/db/models" if not (backend_root / db_models_rel).exists(): # Replace the FAIL above with a WARN — db/models missing is # not a contract violation (issue #275: all-WARN contract). group.checks = [ c if c.name != db_models_rel else CheckResult( CheckStatus.WARN, db_models_rel, "отсутствует (db-проект без db/models)" ) for c in group.checks ] else: 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.FAIL 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//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 ``FAIL`` status naming the offending module. ``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.FAIL, 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 (FAIL) + line count (WARN). For FULLSTACK the api dirs live under ``backend/src//api/v1`` and WARN paths are reported relative to ``backend/`` (issue #274). """ _ = 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).""" 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.FAIL, "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). """ 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.FAIL, 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.FAIL, "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). """ 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.FAIL, "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.FAIL, "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 → FAIL. 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.FAIL, "[Русский](#-русский)", "отсутствует 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.FAIL, "slaid098.dev/contacts", "отсутствует support link") ) if "Quick Start" not in content: results.append( CheckResult(CheckStatus.FAIL, "Quick Start", "отсутствует EN секция Quick Start") ) else: results.append(CheckResult(CheckStatus.OK, "Quick Start", "присутствует")) if "Быстрый старт" not in content: results.append( CheckResult(CheckStatus.FAIL, "Быстрый старт", "отсутствует RU секция Быстрый старт") ) else: results.append(CheckResult(CheckStatus.OK, "Быстрый старт", "присутствует")) if re.search(r"^##\s+(License|LICENSE|Лицензия)\s*$", content, re.MULTILINE): results.append( CheckResult( CheckStatus.FAIL, "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.""" 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"" not in content] if missing: group.checks.append( CheckResult( CheckStatus.FAIL, "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.FAIL, 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//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 `` 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.FAIL, ".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.FAIL, "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). """ 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.FAIL, 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.FAIL, 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.""" 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.FAIL, "[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//`` 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 = 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//", ) 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.""" 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 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 обнаружен") 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 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"])', ) 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 (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. 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.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, 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: 7 group blocks + Итог + Рекомендации.""" lines: list[str] = [f"Project: {ptype.value}", ""] recommendations: list[str] = [] ok_count = 0 warn_count = 0 fail_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: fail_count += 1 recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}") elif chk.status == CheckStatus.WARN: warn_count += 1 else: ok_count += 1 lines.append("") lines.append("Итог:") lines.append(f" OK: {ok_count} WARN: {warn_count} FAIL: {fail_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="strict mode — exit 1 on any FAIL", ) 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.""" args = _parse_args(sys.argv[1:]) strict = args.check fast = args.fast repo_arg = args.repo 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)) if strict and any(g.overall() == CheckStatus.FAIL for g in groups): sys.exit(1) sys.exit(0) if __name__ == "__main__": main()