opencode-config/.opencode/scripts/project-status.py
Sergey 93911f2af6
refactor(infra): eliminate globals + split check_pyproject in project-status (#247)
* refactor(infra): introduce RepoCtx, eliminate globals in project-status

* refactor(infra): split check_pyproject into 13 sub-functions

* test(infra): add RepoCtx + 13 sub-function tests, migrate cookiecutter tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 20:34:04 +03:00

1266 lines
50 KiB
Python

#!/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/<package>/api/v1/`` + ``src/<package>/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)
``<package>`` = ``[project].name`` normalized (``my-project`` → ``my_project``).
Nested ``src/<package>/`` is the standard for ALL types (publishable, reusable
as a git-dependency). Flat ``src/`` is deprecated → WARN.
"""
from __future__ import annotations
import argparse
import ast
import 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 <repo>`` to locate the remote.
"""
git_cmd = ["git"]
if repo is not None:
git_cmd = [*git_cmd, "-C", str(repo)]
rc, out, _ = run_cmd([*git_cmd, "remote", "get-url", "origin"])
if rc != 0:
return None
try:
_host, org, repo_name = parse_remote_url(out.strip())
except ValueError:
return None
else:
return f"{org}/{repo_name}"
# ── auto-detect ──────────────────────────────────────────────────────────────
def _resolve_package_name(ctx: RepoCtx | None = None) -> str | None:
"""Resolve the project's normalized package name from ``[project].name``.
Returns ``None`` if ``pyproject.toml`` is missing or has no ``[project].name``.
Normalization: ``my-project`` → ``my_project`` (PEP 503-ish, ``[-_.]+`` → ``_``).
"""
pyproject = parse_pyproject(ctx)
if not isinstance(pyproject, dict):
return None
project = pyproject.get("project", {})
if not isinstance(project, dict):
return None
name = project.get("name")
if not isinstance(name, str) or not name:
return None
return _normalize_package_name(name)
def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
"""True if nested ``src/<package>/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/<package_name>/...`` where ``<package_name>`` comes from
# ``[project].name`` normalized (``my-project`` → ``my_project``).
ProjectType.FULLSTACK: ["backend", "frontend"],
ProjectType.CLI: ["src"], # src/<package>/ — 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/<package>/`` 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/<package>/ with __init__.py exists (cli-specific)."""
src = ctx.root / "src"
if src.exists() and any(p.is_dir() and (p / "__init__.py").exists() for p in src.iterdir()):
return CheckResult(CheckStatus.OK, "src/<package>/", "пакет найден")
return CheckResult(CheckStatus.FAIL, "src/<package>/", "пакет не найден")
def _normalize_package_name(name: str) -> str:
"""Normalize a project name to a Python package name.
Per PEP 503 / packaging: lowercase + replace runs of ``-_.`` with ``_``.
Example: ``my-project`` → ``my_project``.
"""
return re.sub(r"[-_.]+", "_", name).lower()
def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None:
"""Check for flat ``src/`` layout (no nested package dir).
Applies to ALL types (backend, fullstack, cli, bot, worker, unknown):
nested ``src/<package>/`` is the standard for publishable, reusable
packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
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/<package>/ (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 []
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_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult:
"""Group 2: Тонкие роуты — AST parse, ≤ route_line_limit lines per handler."""
_ = fast # unused here, accepted for signature uniformity
group = GroupResult(name="Тонкие роуты")
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
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.FAIL,
f"route ≤ {limit} lines",
f"превышение: {', '.join(over_limit[:3])}",
)
)
else:
group.checks.append(
CheckResult(
CheckStatus.OK, f"route ≤ {limit} lines", f"макс={longest}, файлов={files_checked}"
)
)
return group
# ── 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 check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 4: Тесты — conftest, no @pytest.mark.asyncio, ≥1 test file."""
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})")
)
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",
)
)
stub_count = sum(
1
for tf in test_files
for line in tf.read_text(encoding="utf-8-sig", errors="ignore").splitlines()
if re.match(r"\s*(def test_|async def test_).*stub", line, re.IGNORECASE)
)
group.checks.append(
CheckResult(
CheckStatus.WARN if stub_count > 0 else CheckStatus.OK,
"stub-detector",
f"{stub_count} stub-тестов",
)
)
return group
# ── check group 5: README (12 delimiter tags) ────────────────────────────────
def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
"""Group 5: README — 12 delimiter tags from create-readme.ts:140-199."""
group = GroupResult(name="README")
content = read_text("README.md", ctx)
if content is None:
group.checks.append(CheckResult(CheckStatus.FAIL, "README.md", "отсутствует"))
return group
missing = [d for d in README_DELIMITERS if f"<!-- {d} -->" 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, "отсутствует"))
if "assets/cover.png" in content:
group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан"))
else:
group.checks.append(
CheckResult(CheckStatus.WARN, "cover.png", "не указан — slaid098.dev showcase требует")
)
return group
# ── check group 6: infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit) ──
def _check_branch_protection(repo: str) -> CheckResult:
"""Read-only ``gh api repos/<repo>/rules/branches/main`` — returns CheckResult."""
rc, out, err = run_cmd(["gh", "api", f"repos/{repo}/rules/branches/main"])
if rc != 0:
return CheckResult(
CheckStatus.WARN, "branch protection", f"gh api не отвечает: {err.strip()[:60]}"
)
if "pull_request" in out and "required_status_checks" in out:
return CheckResult(CheckStatus.OK, "branch protection", "main защищён (PR + checks)")
return CheckResult(CheckStatus.WARN, "branch protection", "правила найдены, но набор неполный")
def check_infra(
ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None
) -> GroupResult:
"""Group 6: Infra — branch protection, ci.yml, dependabot, LICENSE, pre-commit.
If ``repo_root`` is given, uses ``git -C <repo_root>`` to locate the remote
for branch protection detection.
"""
group = GroupResult(name="Infra")
if path_exists(".github/workflows/ci.yml", ctx):
group.checks.append(CheckResult(CheckStatus.OK, ".github/workflows/ci.yml", "есть"))
else:
group.checks.append(
CheckResult(
CheckStatus.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/<pkg>/`` exists.
Returns OK if packages references ``src/<pkg>``; 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/<pkg>/`` is the standard; ``packages=["src"]`` (flat) → WARN;
missing ``src/<pkg>/`` with ``src/`` present → WARN (deprecated flat).
"""
name = "[tool.hatch.build.targets.wheel]"
tools = data.get("tool", {}) if isinstance(data, dict) else {}
project = data.get("project", {}) if isinstance(data, dict) else {}
proj_name = project.get("name", "") if isinstance(project, dict) else ""
expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else ""
src_pkg_path = f"src/{expected_pkg}"
src_pkg_dir_exists = bool(expected_pkg) and (root / "src" / expected_pkg).is_dir()
hatch_targets = (
tools.get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {})
if isinstance(tools, dict)
else {}
)
hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else []
if src_pkg_dir_exists:
return _check_hatch_packages_nested(hatch_packages, src_pkg_path)
if (root / "src").exists():
return CheckResult(
CheckStatus.WARN,
name,
f"нет src/{expected_pkg}/ — deprecated flat layout, use src/<package>/",
)
if isinstance(hatch_packages, list) and hatch_packages:
return CheckResult(CheckStatus.OK, name, f"packages={hatch_packages!r}")
return CheckResult(
CheckStatus.WARN, name, "нет src/ и нет packages — OK для CLI без library-ambitions"
)
def _check_project_fields(project: dict[str, Any]) -> CheckResult:
"""Check 3: ``[project]`` has name, version, description, requires-python."""
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()