#!/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/`` + ``src//db/models/`` + fastapi 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 re import subprocess import sys import tomllib from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path from typing import Any # ── 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" class ProjectType(StrEnum): """Auto-detected project type.""" FULLSTACK = "fullstack" BACKEND = "backend" CLI = "cli" BOT = "bot" WORKER = "worker" UNKNOWN = "unknown" @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. Passed explicitly to all check-functions to avoid module-level globals (``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in ``pipeline-status.py``. """ root: Path config: dict[str, Any] # ── helpers ────────────────────────────────────────────────────────────────── 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 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) def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool: """True if nested ``src//api/v1`` + fastapi/uvicorn in deps. Falls back to flat ``src/api/v1`` detection (with the caller surfacing 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) and path_exists(f"src/{pkg}/db/models", ctx) return path_exists("src/api/v1", ctx) and path_exists("src/db/models", ctx) def _detect_simple_type(deps_lower: str, 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 ────────────────────────────────────────────── STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = { # BACKEND is resolved dynamically by ``_expected_backend_paths`` — the # paths are ``src//...`` where ```` comes from # ``[project].name`` normalized (``my-project`` → ``my_project``). ProjectType.FULLSTACK: ["backend", "frontend"], ProjectType.CLI: ["src"], # src// — checked generically ProjectType.BOT: ["src/bot.py"], ProjectType.WORKER: ["src/flow.py"], ProjectType.UNKNOWN: [], } def _expected_backend_paths(pkg: str) -> list[str]: """Return the nested ``src//`` structure for the backend type. ``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``). """ return [ 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", ] # ── 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(ctx: RepoCtx) -> CheckResult: """Check main.py has a lifespan handler (backend-specific).""" main = read_text("main.py", ctx) if main is None: return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") if "lifespan" in main: 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. 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. """ src = ctx.root / "src" if not src.exists() or not src.is_dir(): return None pyproject = parse_pyproject(ctx) project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} proj_name = project.get("name") if isinstance(project, dict) else None expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None 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 _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]: """Type-specific extra checks beyond the expected dirs list.""" results: list[CheckResult] = [] if ptype == ProjectType.BACKEND: results.append(_check_backend_lifespan(ctx)) elif ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx): results.append( CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен") ) elif ptype == ProjectType.CLI: results.append(_check_cli_package(ctx)) flat = _check_flat_layout(ptype, ctx) if flat is not None: results.append(flat) return results def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 1: Structure — expected dirs/files per project type.""" group = GroupResult(name="Структура") if ptype == ProjectType.BACKEND: pkg = _resolve_package_name(ctx) if pkg is None: group.checks.append( CheckResult( CheckStatus.WARN, "auto-detect", "нет pyproject.toml — cannot resolve package name", ) ) return group expected = _expected_backend_paths(pkg) else: expected = STRUCTURE_EXPECTED.get(ptype, []) 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.""" 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: root = ctx.root / "backend" / "src" / "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).""" _ = 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 _append_route_checks(group, api_dirs, 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.""" group = GroupResult(name="Качество кода") pyproject = parse_pyproject(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.""" group = GroupResult(name="Тесты") tests_dir = ctx.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/support" not in content: results.append( CheckResult(CheckStatus.FAIL, "slaid098.dev/support", "отсутствует support link") ) else: results.append(CheckResult(CheckStatus.OK, "slaid098.dev/support", "присутствует")) 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).""" group = GroupResult(name="Coverage") pyproject = parse_pyproject(ctx) cov = pyproject.get("tool", {}).get("coverage", {}) run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {} sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else [] 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. """ group = GroupResult(name="Pyproject") data = _load_pyproject(ctx.root) if data is None: group.checks.append( CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks") ) return group if "__parse_error__" in data: group.checks.append( CheckResult( CheckStatus.FAIL, "pyproject.toml", f"парсинг failed: {data['__parse_error__']}" ) ) return group project = data.get("project", {}) if isinstance(data, dict) else {} tools = data.get("tool", {}) if isinstance(data, dict) else {} pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} for check_fn in PYPROJECT_CHECKS: group.checks.append(check_fn(data, ctx.root, ptype, project, tools, pytest_opts)) return group PYPROJECT_CHECKS = [ lambda d, r, pt, p, t, po: _check_build_system(d), lambda d, r, pt, p, t, po: _check_hatch_packages(d, r, pt), lambda d, r, pt, p, t, po: _check_project_fields(p), lambda d, r, pt, p, t, po: _check_ruff_section(d, r), lambda d, r, pt, p, t, po: _check_mypy_section(d, r), lambda d, r, pt, p, t, po: _check_pytest_ini_options(t), lambda d, r, pt, p, t, po: _check_coverage_run(t), lambda d, r, pt, p, t, po: _check_coverage_report(t), lambda d, r, pt, p, t, po: _check_addopts_cov(po), lambda d, r, pt, p, t, po: _check_project_status_section(t), lambda d, r, pt, p, t, po: _check_pre_commit(r), lambda d, r, pt, p, t, po: _check_uv_lock(r), lambda d, r, pt, p, t, po: _check_python_version_compat(p, r), ] # ── orchestration ──────────────────────────────────────────────────────────── 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) 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()