* refactor(scripts): project-status all-WARN non-blocking contract * refactor(skills): parse WARN instead of FAIL in audit and project-template * test(scripts): update project-status asserts FAIL to WARN, exit code always 0 --------- Co-authored-by: opencode-agent <agent@opencode.local>
1856 lines
76 KiB
Python
1856 lines
76 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]`` lines, an Итог summary, and Рекомендации.
|
||
|
||
All checks are non-blocking (issue #275): every problem is surfaced as WARN
|
||
and the exit code is always 0 (informational mode). The ``--check`` flag is
|
||
accepted for CLI backward compatibility but no longer forces exit 1.
|
||
|
||
Read-only and stateless — no files are created or modified, no network
|
||
calls beyond read-only ``gh api`` for branch protection detection.
|
||
|
||
Usage:
|
||
python3 .opencode/scripts/project-status.py # informational (exit 0)
|
||
python3 .opencode/scripts/project-status.py --check # accepted, still exit 0
|
||
python3 .opencode/scripts/project-status.py --fast # skip slow/remote checks
|
||
python3 .opencode/scripts/project-status.py --repo /path/to/repo
|
||
|
||
Project types (auto-detected):
|
||
fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
|
||
backend — ``src/<package>/api/v1/`` + fastapi (db/models auto-detected from deps)
|
||
cli — ``[project.scripts]`` in pyproject.toml + typer in deps
|
||
bot — ``src/bot.py`` OR aiogram in deps
|
||
worker — ``src/flow.py`` OR prefect in deps
|
||
unknown — none of the above matched (still runs a generic check set)
|
||
|
||
``<package>`` = ``[project].name`` normalized (``my-project`` → ``my_project``).
|
||
Nested ``src/<package>/`` is the standard for ALL types (publishable, reusable
|
||
as a git-dependency). Flat ``src/`` is deprecated → WARN.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ast
|
||
import importlib.util
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tomllib
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# Load project_contract.py via importlib.util (no sys.path mutation).
|
||
_contract_path = Path(__file__).resolve().parent / "project_contract.py"
|
||
_pc_spec = importlib.util.spec_from_file_location("project_contract", _contract_path)
|
||
project_contract = importlib.util.module_from_spec(_pc_spec) # type: ignore[arg-type]
|
||
_pc_spec.loader.exec_module(project_contract) # type: ignore[union-attr]
|
||
|
||
# ── repo root + config ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def _resolve_repo_root(repo_override: str | None = None) -> Path:
|
||
"""Resolve repo root.
|
||
|
||
If ``repo_override`` is given, resolve it (relative to cwd) and return.
|
||
Otherwise, resolve via git (cwd-aware), fallback to script location.
|
||
"""
|
||
if repo_override:
|
||
return Path(repo_override).resolve()
|
||
result = subprocess.run(
|
||
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False
|
||
)
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
return Path(result.stdout.strip()).resolve()
|
||
return Path(__file__).resolve().parent.parent.parent
|
||
|
||
|
||
REPO_ROOT = _resolve_repo_root()
|
||
|
||
|
||
# Default thresholds — overridable via ``[tool.project-status]`` in pyproject.toml.
|
||
DEFAULT_CONFIG: dict[str, Any] = {
|
||
"route_line_limit": 50,
|
||
"min_test_count": 1,
|
||
"require_branch_protection": False,
|
||
}
|
||
|
||
|
||
def load_config(root: Path | None = None) -> dict[str, Any]:
|
||
"""Load thresholds from ``[tool.project-status]`` in pyproject.toml.
|
||
|
||
Falls back to ``DEFAULT_CONFIG`` if the section or file is missing.
|
||
Uses ``tomllib`` (stdlib, Python 3.11+). Reads only — never writes.
|
||
"""
|
||
base = root if root is not None else REPO_ROOT
|
||
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
|
||
pyproject = base / "pyproject.toml"
|
||
if not pyproject.exists():
|
||
return cfg
|
||
try:
|
||
with pyproject.open("rb") as f:
|
||
data = tomllib.load(f)
|
||
except (OSError, ValueError):
|
||
return cfg
|
||
section = data.get("tool", {}).get("project-status", {})
|
||
if isinstance(section, dict):
|
||
for key, default in DEFAULT_CONFIG.items():
|
||
val = section.get(key, default)
|
||
if isinstance(val, type(default)) or val is None:
|
||
cfg[key] = val
|
||
return cfg
|
||
|
||
|
||
CONFIG = load_config()
|
||
|
||
|
||
# ── enums + dataclasses ──────────────────────────────────────────────────────
|
||
|
||
|
||
class CheckStatus(StrEnum):
|
||
"""Result of a single check.
|
||
|
||
All checks are non-blocking (issue #275): problems are surfaced as WARN
|
||
and the exit code is always 0. ``FAIL`` is kept as a value for backward
|
||
compatibility (existing tests import it), but no check-function returns
|
||
``FAIL`` anymore — every problem is WARN.
|
||
"""
|
||
|
||
OK = "OK"
|
||
WARN = "WARN"
|
||
FAIL = "FAIL"
|
||
|
||
|
||
# Re-exported from project_contract.py for backward compatibility
|
||
# (tests use ``ps.ProjectType.X``).
|
||
ProjectType = project_contract.ProjectType
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CheckResult:
|
||
"""Result of a single check within a group."""
|
||
|
||
status: CheckStatus
|
||
name: str
|
||
detail: str
|
||
|
||
|
||
@dataclass
|
||
class GroupResult:
|
||
"""Result of a single check group (multiple CheckResult items)."""
|
||
|
||
name: str
|
||
checks: list[CheckResult] = field(default_factory=list)
|
||
|
||
def overall(self) -> CheckStatus:
|
||
"""Roll up statuses: FAIL > WARN > OK (FAIL kept for compat, unused by checks).
|
||
|
||
No check-function returns ``FAIL`` after issue #275 — the rollup is
|
||
effectively WARN > OK. ``FAIL`` is retained in the comparison so any
|
||
externally-constructed ``CheckResult(FAIL, ...)`` still rolls up to
|
||
FAIL (defensive, does not happen in normal use).
|
||
"""
|
||
statuses = [c.status for c in self.checks]
|
||
if CheckStatus.FAIL in statuses:
|
||
return CheckStatus.FAIL
|
||
if CheckStatus.WARN in statuses:
|
||
return CheckStatus.WARN
|
||
return CheckStatus.OK
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RepoCtx:
|
||
"""Immutable repo context: root path + config thresholds + backend root.
|
||
|
||
Passed explicitly to all check-functions to avoid module-level globals
|
||
(``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in
|
||
``pipeline-status.py``.
|
||
|
||
``backend_root`` is the directory where the backend source tree lives:
|
||
``ctx.root / "backend"`` for FULLSTACK, ``ctx.root`` for all other types.
|
||
Computed in ``main()`` / ``detect_project_type`` after type detection.
|
||
Root-level files (ci.yml, README, LICENSE, dependabot, frontend/) stay
|
||
under ``ctx.root`` regardless of type.
|
||
"""
|
||
|
||
root: Path
|
||
config: dict[str, Any]
|
||
backend_root: Path = Path() # overridable; set by main()/detect_project_type
|
||
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||
"""Run a command, return (returncode, stdout, stderr). Read-only intent."""
|
||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||
return result.returncode, result.stdout, result.stderr
|
||
|
||
|
||
def _backend_root_for(ptype: ProjectType, ctx: RepoCtx) -> Path:
|
||
"""Return the backend root dir for the given project type.
|
||
|
||
FULLSTACK → ``ctx.root / "backend"`` (backend source tree lives there);
|
||
all other types → ``ctx.root``. Used to route backend-source checks
|
||
(lifespan, scattered models, pyproject, tests, ...) to the right tree
|
||
without touching the root-level checks (ci.yml, README, frontend/).
|
||
Mirrors the ``backend_ctx = RepoCtx(root=ctx.root / "backend")`` pattern
|
||
in ``_api_dirs_for`` (issue #241 / #274).
|
||
"""
|
||
if ptype == ProjectType.FULLSTACK:
|
||
return ctx.root / "backend"
|
||
return ctx.root
|
||
|
||
|
||
def _backend_ctx_for(ptype: ProjectType, ctx: RepoCtx) -> RepoCtx:
|
||
"""Build a RepoCtx rooted at the backend root (FULLSTACK → ``backend/``).
|
||
|
||
Convenience wrapper: returns a fresh context with ``root=backend_root`` so
|
||
backend-source checks (pyproject, hatch packages, python-version, ...) read
|
||
from the right tree without each function re-deriving the path.
|
||
"""
|
||
return RepoCtx(root=_backend_root_for(ptype, ctx), config=ctx.config)
|
||
|
||
|
||
def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool:
|
||
"""True if ``root / rel`` exists (``ctx`` preferred, else module global)."""
|
||
root = ctx.root if ctx is not None else REPO_ROOT
|
||
return (root / rel).exists()
|
||
|
||
|
||
def read_text(rel: str, ctx: RepoCtx | None = None) -> str | None:
|
||
"""Read text content of ``root / rel`` or None if missing."""
|
||
root = ctx.root if ctx is not None else REPO_ROOT
|
||
p = root / rel
|
||
if not p.exists():
|
||
return None
|
||
try:
|
||
return p.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def parse_pyproject(ctx: RepoCtx | None = None) -> dict[str, Any]:
|
||
"""Parse pyproject.toml into a dict (or empty dict on failure)."""
|
||
raw = read_text("pyproject.toml", ctx)
|
||
if raw is None:
|
||
return {}
|
||
try:
|
||
return tomllib.loads(raw)
|
||
except ValueError:
|
||
return {}
|
||
|
||
|
||
def parse_remote_url(url: str) -> tuple[str, str, str]:
|
||
"""Parse git remote URL into (host, org, repo).
|
||
|
||
Supports both HTTPS and SSH formats and optional userinfo (insteadOf).
|
||
"""
|
||
ssh = re.match(r"git@([^:]+):([^/]+)/(.+?)(?:\.git)?$", url)
|
||
if ssh:
|
||
return ssh.group(1), ssh.group(2), ssh.group(3)
|
||
https = re.match(r"https?://(?:[^/@]*@)?([^/]+)/([^/]+)/(.+?)(?:\.git)?$", url)
|
||
if https:
|
||
return https.group(1), https.group(2), https.group(3)
|
||
raise ValueError(f"Cannot parse remote URL: {url}")
|
||
|
||
|
||
def get_repo_full_name(repo: Path | None = None) -> str | None:
|
||
"""Return ``org/repo`` from git remote, or None on error (read-only).
|
||
|
||
If ``repo`` is given, use ``git -C <repo>`` to locate the remote.
|
||
"""
|
||
git_cmd = ["git"]
|
||
if repo is not None:
|
||
git_cmd = [*git_cmd, "-C", str(repo)]
|
||
rc, out, _ = run_cmd([*git_cmd, "remote", "get-url", "origin"])
|
||
if rc != 0:
|
||
return None
|
||
try:
|
||
_host, org, repo_name = parse_remote_url(out.strip())
|
||
except ValueError:
|
||
return None
|
||
else:
|
||
return f"{org}/{repo_name}"
|
||
|
||
|
||
# ── auto-detect ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _resolve_package_name(ctx: RepoCtx | None = None) -> str | None:
|
||
"""Resolve the project's normalized package name from ``[project].name``.
|
||
|
||
Returns ``None`` if ``pyproject.toml`` is missing or has no ``[project].name``.
|
||
Normalization: ``my-project`` → ``my_project`` (PEP 503-ish, ``[-_.]+`` → ``_``).
|
||
"""
|
||
pyproject = parse_pyproject(ctx)
|
||
if not isinstance(pyproject, dict):
|
||
return None
|
||
project = pyproject.get("project", {})
|
||
if not isinstance(project, dict):
|
||
return None
|
||
name = project.get("name")
|
||
if not isinstance(name, str) or not name:
|
||
return None
|
||
return _normalize_package_name(name)
|
||
|
||
|
||
# DB-capability markers — presence of any of these in ``[project.dependencies]``
|
||
# marks the project as a db-project (``db/models`` required). Auto-detection
|
||
# replaces the former ``[tool.project-status] no_db`` marker (issue #274).
|
||
DB_DEP_MARKERS: tuple[str, ...] = (
|
||
"tortoise-orm",
|
||
"sqlalchemy",
|
||
"sqlmodel",
|
||
"alembic",
|
||
"aerich",
|
||
"pony",
|
||
"databases",
|
||
)
|
||
|
||
|
||
def _is_db_project(deps_lower: str) -> bool:
|
||
"""True if any DB-capability marker is present in the deps string.
|
||
|
||
Used to decide whether ``db/models`` is required: a backend/fullstack
|
||
project without a DB dep (cookiecutter ``use_db=no``) does not need
|
||
``db/models`` and the structure check yields WARN (not FAIL) when absent.
|
||
"""
|
||
return any(marker in deps_lower for marker in DB_DEP_MARKERS)
|
||
|
||
|
||
def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
|
||
"""True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.
|
||
|
||
Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the
|
||
nested ``src/<package>/api/v1`` dir. It does NOT require ``db/models`` —
|
||
that is a separate DB-capability check (``_is_db_project`` + the
|
||
``db/models`` structure check, which is WARN when absent).
|
||
|
||
Falls back to flat ``src/api/v1`` detection (with the caller surfacing
|
||
a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
|
||
"""
|
||
if "fastapi" not in deps_lower and "uvicorn" not in deps_lower:
|
||
return False
|
||
pkg = _resolve_package_name(ctx)
|
||
if pkg is not None:
|
||
return path_exists(f"src/{pkg}/api/v1", ctx)
|
||
return path_exists("src/api/v1", ctx)
|
||
|
||
|
||
def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None:
|
||
"""Detect bot/worker types by file or dep marker (or None)."""
|
||
if path_exists("src/bot.py", ctx) or "aiogram" in deps_lower:
|
||
return ProjectType.BOT
|
||
if path_exists("src/flow.py", ctx) or "prefect" in deps_lower:
|
||
return ProjectType.WORKER
|
||
return None
|
||
|
||
|
||
def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType:
|
||
"""Auto-detect project type from filesystem + pyproject.toml.
|
||
|
||
Order matters: fullstack (most specific) → backend → bot → worker → cli.
|
||
Falls back to ``UNKNOWN`` if nothing matches.
|
||
"""
|
||
if path_exists("frontend", ctx) and path_exists("backend", ctx):
|
||
return ProjectType.FULLSTACK
|
||
|
||
pyproject = parse_pyproject(ctx)
|
||
deps_raw = pyproject.get("project", {}).get("dependencies", [])
|
||
deps_lower = " ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else ""
|
||
|
||
if _matches_backend(deps_lower, ctx):
|
||
return ProjectType.BACKEND
|
||
simple = _detect_simple_type(deps_lower, ctx)
|
||
if simple is not None:
|
||
return simple
|
||
|
||
scripts = pyproject.get("project", {}).get("scripts", {})
|
||
if isinstance(scripts, dict) and scripts and "typer" in deps_lower:
|
||
return ProjectType.CLI
|
||
|
||
return ProjectType.UNKNOWN
|
||
|
||
|
||
# ── expected structure per type ──────────────────────────────────────────────
|
||
|
||
|
||
# Re-exported from project_contract.py for backward compatibility
|
||
# (tests use ``ps.STRUCTURE_EXPECTED``).
|
||
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED
|
||
|
||
|
||
def _expected_backend_paths(pkg: str, db_required: bool) -> list[str]:
|
||
"""Return the nested ``src/<package>/`` structure for the backend type.
|
||
|
||
``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``).
|
||
When ``db_required`` is false (no DB dep in pyproject, cookiecutter
|
||
``use_db=no``), ``db/models`` is excluded from the expected paths — the
|
||
caller surfaces a WARN separately when a db-project is missing it.
|
||
"""
|
||
paths = [
|
||
f"src/{pkg}/api/v1",
|
||
f"src/{pkg}/db/models",
|
||
f"src/{pkg}/schemas",
|
||
f"src/{pkg}/services",
|
||
f"src/{pkg}/config/settings.py",
|
||
"main.py",
|
||
]
|
||
if not db_required:
|
||
paths.pop(1) # remove ``src/<pkg>/db/models``
|
||
return paths
|
||
|
||
|
||
# ── README delimiter tags (12) — ported from create-readme.ts:140-199 ───────
|
||
|
||
|
||
README_DELIMITERS: list[str] = [
|
||
"tagline-en:start",
|
||
"tagline-en:end",
|
||
"tagline-ru:start",
|
||
"tagline-ru:end",
|
||
"summary-en:start",
|
||
"summary-en:end",
|
||
"features-en:start",
|
||
"features-en:end",
|
||
"summary-ru:start",
|
||
"summary-ru:end",
|
||
"features-ru:start",
|
||
"features-ru:end",
|
||
]
|
||
|
||
|
||
# ── check group 1: structure ─────────────────────────────────────────────────
|
||
|
||
|
||
def _check_backend_lifespan(backend_root: Path) -> CheckResult:
|
||
"""Check main.py has a lifespan handler (backend-specific).
|
||
|
||
``backend_root`` is the backend source tree root (``ctx.root`` for BACKEND,
|
||
``ctx.root / "backend"`` for FULLSTACK).
|
||
"""
|
||
main = backend_root / "main.py"
|
||
if not main.exists():
|
||
return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет")
|
||
try:
|
||
content = main.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет")
|
||
if "lifespan" in content:
|
||
return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден")
|
||
return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден")
|
||
|
||
|
||
def _check_cli_package(ctx: RepoCtx) -> CheckResult:
|
||
"""Check src/<package>/ with __init__.py exists (cli-specific)."""
|
||
src = ctx.root / "src"
|
||
if src.exists() and any(p.is_dir() and (p / "__init__.py").exists() for p in src.iterdir()):
|
||
return CheckResult(CheckStatus.OK, "src/<package>/", "пакет найден")
|
||
return CheckResult(CheckStatus.WARN, "src/<package>/", "пакет не найден")
|
||
|
||
|
||
def _normalize_package_name(name: str) -> str:
|
||
"""Normalize a project name to a Python package name.
|
||
|
||
Per PEP 503 / packaging: lowercase + replace runs of ``-_.`` with ``_``.
|
||
Example: ``my-project`` → ``my_project``.
|
||
"""
|
||
return re.sub(r"[-_.]+", "_", name).lower()
|
||
|
||
|
||
def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None:
|
||
"""Check for flat ``src/`` layout (no nested package dir).
|
||
|
||
Applies to ALL types (backend, fullstack, cli, bot, worker, unknown):
|
||
nested ``src/<package>/`` is the standard for publishable, reusable
|
||
packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
|
||
|
||
For FULLSTACK the backend source lives under ``backend/``, so the check
|
||
inspects ``backend/src/`` (issue #274). Root-level ``frontend/`` is not
|
||
affected.
|
||
|
||
Returns None if ``src/`` does not exist, has a nested package with
|
||
``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
|
||
Returns a WARN CheckResult if flat layout detected.
|
||
"""
|
||
backend_root = _backend_root_for(ptype, ctx)
|
||
src = backend_root / "src"
|
||
if not src.exists() or not src.is_dir():
|
||
return None
|
||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||
pyproject = parse_pyproject(backend_ctx)
|
||
project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {}
|
||
proj_name = project.get("name") if isinstance(project, dict) else None
|
||
expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None
|
||
subdirs = [p for p in src.iterdir() if p.is_dir()]
|
||
if not subdirs:
|
||
return None
|
||
has_nested_pkg = any((p / "__init__.py").exists() or p.name == expected_pkg for p in subdirs)
|
||
if has_nested_pkg:
|
||
return None
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
"flat src/ layout",
|
||
"deprecated — рекомендуется src/<package>/ (publishable, reusable as git-dep)",
|
||
)
|
||
|
||
|
||
def _is_tortoise_model_base(base: ast.expr) -> bool:
|
||
"""True if a class base references a Tortoise/ORM ``Model``.
|
||
|
||
Matches ``Model``, ``tortoise.Model``, ``models.Model``, ``tortoise.models.Model``.
|
||
Does NOT match unrelated ``Model`` classes from other libraries — the caller
|
||
is expected to have already verified the file imports ``tortoise`` or its
|
||
own ``db.models`` module. Used by ``_check_scattered_models`` to detect
|
||
ORM models living outside ``src/<package>/db/models/``.
|
||
"""
|
||
# bare ``Model`` (Name) — common when ``from tortoise import Model``
|
||
if isinstance(base, ast.Name) and base.id == "Model":
|
||
return True
|
||
# ``tortoise.Model`` / ``models.Model`` / ``tortoise.models.Model`` (Attribute)
|
||
if isinstance(base, ast.Attribute):
|
||
# last attribute segment must be ``Model``; accept any qualifier
|
||
# (``tortoise``, ``models``, ``db.models``, ...) — the import is
|
||
# validated separately by the caller's import check.
|
||
return base.attr == "Model"
|
||
return False
|
||
|
||
|
||
def _file_imports_tortoise_or_models(source: str, pkg: str) -> bool:
|
||
"""True if the file imports ``tortoise`` or its own ``db.models`` module.
|
||
|
||
Used to suppress false positives in ``_check_scattered_models``: a class
|
||
named ``Model`` from an unrelated library (e.g. ``pydantic.BaseModel`` is
|
||
already excluded by name, but other libs may define their own ``Model``)
|
||
should not trigger the WARN unless the file actually uses Tortoise/ORM.
|
||
|
||
The check is AST-based (walks ``ast.Import`` and ``ast.ImportFrom``) so it
|
||
is robust to comments and strings mentioning those names.
|
||
"""
|
||
try:
|
||
tree = ast.parse(source)
|
||
except SyntaxError:
|
||
return False
|
||
models_module = f"{pkg}.db.models"
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
for alias in node.names:
|
||
if alias.name == "tortoise" or alias.name.startswith("tortoise."):
|
||
return True
|
||
elif isinstance(node, ast.ImportFrom):
|
||
module = node.module or ""
|
||
if module == "tortoise" or module.startswith("tortoise."):
|
||
return True
|
||
if module == models_module or module.startswith(models_module + "."):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _scan_file_for_models(py_file: Path, backend_root: Path, pkg: str) -> list[CheckResult]:
|
||
"""Scan a single ``.py`` file for ORM ``class X(Model)`` definitions.
|
||
|
||
Returns one ``WARN`` CheckResult per offending class (with the file's
|
||
relative path). Returns ``[]`` if the file does not import tortoise or
|
||
the package's own ``db.models`` module (false-positive guard), or if no
|
||
class inherits from ``Model``.
|
||
|
||
``backend_root`` is used for the relative path in the WARN detail (issue
|
||
#274): FULLSTACK reports paths relative to ``backend/``, not the repo
|
||
root, so the message matches what the developer sees in their tree.
|
||
"""
|
||
try:
|
||
source = py_file.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
return []
|
||
if not _file_imports_tortoise_or_models(source, pkg):
|
||
return []
|
||
try:
|
||
tree = ast.parse(source)
|
||
except SyntaxError:
|
||
return []
|
||
results: list[CheckResult] = []
|
||
rel = py_file.relative_to(backend_root)
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.ClassDef):
|
||
continue
|
||
for base in node.bases:
|
||
if _is_tortoise_model_base(base):
|
||
results.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
str(rel),
|
||
f"class {node.name}(Model) вне db/models/ — "
|
||
"models must live in src/<pkg>/db/models/",
|
||
)
|
||
)
|
||
break
|
||
return results
|
||
|
||
|
||
def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResult]:
|
||
"""AST-чек: ORM models должны жить в ``src/<package>/db/models/``.
|
||
|
||
Scans ``.py`` files in ``src/<package>/`` (excluding ``db/models/``) for
|
||
``class X(Model)`` / ``class X(tortoise.Model)``. If found → WARN with the
|
||
offending relative path. Models in ``db/models/`` are OK; files that do not
|
||
import ``tortoise`` or the package's own ``db.models`` module are skipped
|
||
(suppresses false positives from unrelated libraries defining ``Model``).
|
||
|
||
For FULLSTACK the backend lives under ``backend/`` (issue #274): the
|
||
package name is resolved against ``backend/pyproject.toml`` and the scan
|
||
targets ``backend/src/<package>/``. Skips for non-backend/fullstack types,
|
||
flat layouts (no ``src/<package>/``), and repos without a resolvable
|
||
package name. Returns ``[]`` in all skip cases so the caller appends nothing.
|
||
"""
|
||
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||
return []
|
||
backend_root = _backend_root_for(ptype, ctx)
|
||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||
pkg = _resolve_package_name(backend_ctx)
|
||
if pkg is None:
|
||
return []
|
||
src_pkg = backend_root / "src" / pkg
|
||
if not src_pkg.is_dir():
|
||
return []
|
||
models_dir = src_pkg / "db" / "models"
|
||
results: list[CheckResult] = []
|
||
for py_file in src_pkg.rglob("*.py"):
|
||
if models_dir in py_file.parents or py_file == models_dir:
|
||
continue
|
||
results.extend(_scan_file_for_models(py_file, backend_root, pkg))
|
||
return results
|
||
|
||
|
||
def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
|
||
"""Fullstack frontend stack detection: tailwindcss + bits-ui in
|
||
``frontend/package.json`` deps + ``components.json`` + ``tsconfig.json``
|
||
existence (4 markers).
|
||
|
||
Returns ``None`` if ``frontend/package.json`` does not exist (caller
|
||
surfaces the missing-package.json WARN separately). Returns a WARN
|
||
``CheckResult`` listing the missing markers when any are absent. Returns
|
||
``None`` (no CheckResult) when all 4 markers are present — the caller
|
||
emits an OK in the structure loop is not needed; we return ``None`` so
|
||
nothing extra is appended, keeping the structure group clean.
|
||
|
||
Actually: returns an OK ``CheckResult`` when all markers present, so the
|
||
audit explicitly confirms the frontend stack is up-to-date.
|
||
"""
|
||
pkg_path = ctx.root / "frontend" / "package.json"
|
||
if not pkg_path.exists():
|
||
return None # surfaced by the existing ``frontend/package.json`` check
|
||
required_deps = project_contract.FRONTEND_STACK_MARKERS["fullstack_package_deps"]
|
||
required_files = project_contract.FRONTEND_STACK_MARKERS["fullstack_files"]
|
||
missing: list[str] = []
|
||
try:
|
||
pkg = json.loads(pkg_path.read_text(encoding="utf-8-sig"))
|
||
except (json.JSONDecodeError, OSError):
|
||
missing.extend(f"{d} in package.json" for d in required_deps)
|
||
pkg = {}
|
||
if not missing:
|
||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||
for dep in required_deps:
|
||
if dep not in deps:
|
||
missing.append(dep)
|
||
for rel in required_files:
|
||
if not (ctx.root / rel).exists():
|
||
missing.append(rel)
|
||
if missing:
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
"frontend stack",
|
||
"frontend stack outdated: missing "
|
||
+ ", ".join(missing)
|
||
+ ". Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS.",
|
||
)
|
||
return CheckResult(CheckStatus.OK, "frontend stack", "Tailwind + shadcn-svelte + TS detected")
|
||
|
||
|
||
def _check_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 (issue #275: all-WARN contract);
|
||
if no DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no).
|
||
"""
|
||
group = GroupResult(name="Структура")
|
||
if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||
backend_root = _backend_root_for(ptype, ctx)
|
||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||
pkg = _resolve_package_name(backend_ctx)
|
||
if pkg is None:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"auto-detect",
|
||
"нет pyproject.toml — cannot resolve package name",
|
||
)
|
||
)
|
||
return group
|
||
deps_raw = parse_pyproject(backend_ctx).get("project", {}).get("dependencies", [])
|
||
deps_lower = (
|
||
" ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else ""
|
||
)
|
||
db_required = _is_db_project(deps_lower)
|
||
expected = _expected_backend_paths(pkg, db_required=db_required)
|
||
for rel in expected:
|
||
status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.WARN
|
||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||
group.checks.append(CheckResult(status, rel, detail))
|
||
if db_required:
|
||
db_models_rel = f"src/{pkg}/db/models"
|
||
if not (backend_root / db_models_rel).exists():
|
||
# Ensure the db/models check carries the db-project detail even
|
||
# though the generic loop above already set it to WARN. The
|
||
# detail here is more informative (issue #275: all-WARN).
|
||
group.checks = [
|
||
c
|
||
if c.name != db_models_rel
|
||
else CheckResult(
|
||
CheckStatus.WARN, db_models_rel, "отсутствует (db-проект без db/models)"
|
||
)
|
||
for c in group.checks
|
||
]
|
||
else:
|
||
expected = STRUCTURE_EXPECTED.get(ptype.value, [])
|
||
if not expected:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
||
)
|
||
return group
|
||
for rel in expected:
|
||
status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.WARN
|
||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||
group.checks.append(CheckResult(status, rel, detail))
|
||
group.checks.extend(_check_type_specific_structure(ptype, ctx))
|
||
return group
|
||
|
||
|
||
# ── check group 2: thin routes (AST, ≤ route_line_limit lines) ───────────────
|
||
|
||
|
||
def _route_line_count(source: str) -> int:
|
||
"""Count lines for a route handler function (body span in source)."""
|
||
try:
|
||
tree = ast.parse(source)
|
||
except SyntaxError:
|
||
return -1
|
||
route_methods = {"get", "post", "put", "delete", "patch"}
|
||
max_lines = 0
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
continue
|
||
if _has_route_decorator(node.decorator_list, route_methods):
|
||
lines = node.end_lineno - node.lineno + 1 if node.end_lineno else 0
|
||
max_lines = max(max_lines, lines)
|
||
return max_lines
|
||
|
||
|
||
def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) -> bool:
|
||
"""True if any decorator is a route method (@router.get, @get, etc.)."""
|
||
for dec in decorators:
|
||
# @router.get(...) → Call(func=Attribute(attr='get'))
|
||
if (
|
||
isinstance(dec, ast.Call)
|
||
and isinstance(dec.func, ast.Attribute)
|
||
and dec.func.attr in route_methods
|
||
):
|
||
return True
|
||
# @app.get / @router.get used bare → Attribute(attr='get')
|
||
if isinstance(dec, ast.Attribute) and dec.attr in route_methods:
|
||
return True
|
||
# @get / @post (simple name) → Name(id='get')
|
||
if isinstance(dec, ast.Name) and dec.id in route_methods:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]:
|
||
"""Return list of api/v1 dirs to scan for routes, based on project type.
|
||
|
||
Both BACKEND and FULLSTACK use the nested ``src/<package>/api/v1`` layout
|
||
(issue #241). The package name is resolved from ``[project].name`` in
|
||
``pyproject.toml`` (normalized via ``_normalize_package_name``). FULLSTACK
|
||
additionally prefixes the backend root with ``backend/``.
|
||
"""
|
||
if ptype == ProjectType.BACKEND:
|
||
pkg = _resolve_package_name(ctx)
|
||
if pkg is None:
|
||
return []
|
||
root = ctx.root / "src" / pkg / "api" / "v1"
|
||
return [root] if root.exists() else []
|
||
if ptype == ProjectType.FULLSTACK:
|
||
# pyproject.toml lives under ``backend/`` in the fullstack template,
|
||
# so resolve the package name against a backend-rooted context.
|
||
backend_ctx = RepoCtx(root=ctx.root / "backend", config=ctx.config)
|
||
pkg = _resolve_package_name(backend_ctx)
|
||
if pkg is None:
|
||
return []
|
||
root = ctx.root / "backend" / "src" / pkg / "api" / "v1"
|
||
return [root] if root.exists() else []
|
||
return []
|
||
|
||
|
||
FORBIDDEN_ROUTE_IMPORTS: tuple[str, ...] = ("src.db.models", "tortoise")
|
||
|
||
|
||
def _scan_route_files(api_dirs: list[Path], limit: int, ctx: RepoCtx) -> tuple[int, int, list[str]]:
|
||
"""Scan api dirs for route handlers; return (files_checked, longest, over_limit)."""
|
||
files_checked = 0
|
||
longest = 0
|
||
over_limit: list[str] = []
|
||
for api_dir in api_dirs:
|
||
for py in api_dir.rglob("*.py"):
|
||
source = py.read_text(encoding="utf-8-sig", errors="ignore")
|
||
n = _route_line_count(source)
|
||
if n < 0:
|
||
continue
|
||
files_checked += 1
|
||
longest = max(longest, n)
|
||
if n > limit:
|
||
over_limit.append(f"{py.relative_to(ctx.root)}:{n}")
|
||
return files_checked, longest, over_limit
|
||
|
||
|
||
def _check_route_imports(route_file: Path, ctx: RepoCtx) -> CheckResult | None:
|
||
"""AST-чек: routes не должны импортировать ``src.db.models`` или ``tortoise``.
|
||
|
||
Routes должны идти через services/schemas, а не тянуть модели БД напрямую
|
||
(digital_factory anti-pattern: route bypasses the service layer).
|
||
|
||
Returns ``None`` if no forbidden import found (OK), or a ``CheckResult``
|
||
with ``WARN`` status naming the offending module (issue #275: all-WARN
|
||
contract — forbidden imports are surfaced, never blocking). ``ast`` is
|
||
used so the check is robust to comments/strings mentioning those names.
|
||
"""
|
||
try:
|
||
tree = ast.parse(route_file.read_text(encoding="utf-8-sig", errors="ignore"))
|
||
except SyntaxError:
|
||
return None
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, ast.ImportFrom):
|
||
continue
|
||
module = node.module or ""
|
||
if any(module == f or module.startswith(f + ".") for f in FORBIDDEN_ROUTE_IMPORTS):
|
||
rel = route_file.relative_to(ctx.root)
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
str(rel),
|
||
f"импортирует {module} — роут должен идти через services",
|
||
)
|
||
return None
|
||
|
||
|
||
def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult]:
|
||
"""Scan api dirs for forbidden route imports (``src.db.models``/``tortoise``)."""
|
||
bad: list[CheckResult] = []
|
||
for api_dir in api_dirs:
|
||
for py in api_dir.rglob("*.py"):
|
||
res = _check_route_imports(py, ctx)
|
||
if res is not None:
|
||
bad.append(res)
|
||
return bad
|
||
|
||
|
||
def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult:
|
||
"""Group 2: Тонкие роуты — AST imports (WARN) + line count (WARN).
|
||
|
||
For FULLSTACK the api dirs live under ``backend/src/<pkg>/api/v1`` and
|
||
WARN paths are reported relative to ``backend/`` (issue #274). Issue #275:
|
||
forbidden imports are WARN (non-blocking), not FAIL.
|
||
"""
|
||
_ = fast # unused here, accepted for signature uniformity
|
||
group = GroupResult(name="Тонкие роуты")
|
||
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.OK, "skip", f"тип={ptype.value}: роуты не применимы")
|
||
)
|
||
return group
|
||
api_dirs = _api_dirs_for(ptype, ctx)
|
||
if not api_dirs:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена")
|
||
)
|
||
return group
|
||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||
_append_route_checks(group, api_dirs, backend_ctx)
|
||
return group
|
||
|
||
|
||
def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx) -> None:
|
||
"""Append import + line-count checks to ``group`` (split for ≤50 lines).
|
||
|
||
Issue #275: forbidden imports surface as WARN (non-blocking), not FAIL.
|
||
"""
|
||
bad_imports = _scan_route_imports(api_dirs, ctx)
|
||
if bad_imports:
|
||
detail = ", ".join(f"{c.name}: {c.detail}" for c in bad_imports[:3])
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "route imports", f"запрещённые: {detail}")
|
||
)
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.OK, "route imports", "роуты не импортируют модели БД")
|
||
)
|
||
limit = int(ctx.config.get("route_line_limit", 50))
|
||
files_checked, longest, over_limit = _scan_route_files(api_dirs, limit, ctx)
|
||
if files_checked == 0:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, "AST", "роуты не найдены в src/api/v1/"))
|
||
elif over_limit:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
f"route ≤ {limit} lines",
|
||
f"превышение: {', '.join(over_limit[:3])}",
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.OK, f"route ≤ {limit} lines", f"макс={longest}, файлов={files_checked}"
|
||
)
|
||
)
|
||
|
||
|
||
# ── check group 3: code quality (mypy/ruff/pytest presence) ──────────────────
|
||
|
||
|
||
def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||
"""Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.
|
||
|
||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||
``backend/pyproject.toml`` — issue #274). Issue #275: missing tools are
|
||
WARN (non-blocking), not FAIL.
|
||
"""
|
||
group = GroupResult(name="Качество кода")
|
||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||
pyproject = parse_pyproject(backend_ctx)
|
||
tools = pyproject.get("tool", {})
|
||
for tool_name in ("ruff", "mypy"):
|
||
if tool_name in tools:
|
||
group.checks.append(CheckResult(CheckStatus.OK, tool_name, "настроен в pyproject.toml"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, tool_name, f"[tool.{tool_name}] отсутствует")
|
||
)
|
||
pytest_cfg = pyproject.get("tool", {}).get("pytest", {})
|
||
dev_deps = pyproject.get("project", {}).get("optional-dependencies", {}).get("dev", [])
|
||
dev_str = " ".join(str(d).lower() for d in dev_deps) if isinstance(dev_deps, list) else ""
|
||
if pytest_cfg or "pytest" in dev_str:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "pytest", "настроен"))
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, "pytest", "не найден в dev-deps"))
|
||
return group
|
||
|
||
|
||
# ── check group 4: tests (conftest, stub-detector, no @pytest.mark.asyncio) ──
|
||
|
||
|
||
def _has_test_func(source: str) -> bool:
|
||
"""True if source declares ``def test_*`` or ``async def test_*``.
|
||
|
||
Used by the stub-detector: ``test_*.py`` files without any test function
|
||
are stub files (digital_factory anti-pattern: test file that asserts
|
||
nothing).
|
||
"""
|
||
try:
|
||
tree = ast.parse(source)
|
||
except SyntaxError:
|
||
return True
|
||
for node in ast.walk(tree):
|
||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||
continue
|
||
if node.name.startswith("test"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _check_test_structure(ptype: ProjectType, tests_dir: Path, group: GroupResult) -> None:
|
||
"""Append backend-specific ``tests/unit/`` + ``tests/api/`` + integration checks.
|
||
|
||
Backend projects are expected to have a layered test layout mirroring the
|
||
source layering (unit tests for services, api tests for routes, integration
|
||
tests for cross-cutting flows). CLI/BOT/WORKER have no api layer so the
|
||
``tests/api/`` check is skipped for them.
|
||
"""
|
||
if ptype != ProjectType.BACKEND:
|
||
return
|
||
if not (tests_dir / "unit").exists():
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "tests/unit/", "отсутствует (рекомендуется для unit-тестов)"
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "tests/unit/", "существует"))
|
||
if not (tests_dir / "api").exists():
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "tests/api/", "отсутствует (рекомендуется для route-тестов)"
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "tests/api/", "существует"))
|
||
_check_integration_dir(tests_dir, group)
|
||
|
||
|
||
def _check_integration_dir(tests_dir: Path, group: GroupResult) -> None:
|
||
"""Append checks for ``tests/integration/`` (presence + pytestmark)."""
|
||
integration_dir = tests_dir / "integration"
|
||
if not integration_dir.exists():
|
||
return
|
||
test_files = list(integration_dir.glob("test_*.py"))
|
||
if not test_files:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "tests/integration/", "директория пустая")
|
||
)
|
||
return
|
||
without_mark = [
|
||
f.name
|
||
for f in test_files
|
||
if "pytest.mark.integration" not in f.read_text(encoding="utf-8-sig")
|
||
]
|
||
if without_mark:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"tests/integration/ pytestmark",
|
||
f"без pytest.mark.integration: {', '.join(without_mark[:3])}",
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.OK, "tests/integration/ pytestmark", "все файлы имеют pytestmark"
|
||
)
|
||
)
|
||
|
||
|
||
def _check_stub_files(test_files: list[Path]) -> CheckResult:
|
||
"""Stub-detector: ``test_*.py`` without ``def test_*``/``async def test_*``.
|
||
|
||
Replaces the previous name-based "stub" heuristic (which matched ``stub``
|
||
in the function name) with an AST check for test functions: a file that
|
||
declares no ``def test_*``/``async def test_*`` is a stub (asserts nothing).
|
||
"""
|
||
stubs = []
|
||
for tf in test_files:
|
||
try:
|
||
source = tf.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
continue
|
||
if not _has_test_func(source):
|
||
stubs.append(tf.name)
|
||
if stubs:
|
||
return CheckResult(
|
||
CheckStatus.WARN, "stub-detector", f"{len(stubs)} stub-файлов: {', '.join(stubs[:3])}"
|
||
)
|
||
return CheckResult(CheckStatus.OK, "stub-detector", "0 stub-файлов")
|
||
|
||
|
||
def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||
"""Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.
|
||
|
||
For BACKEND and FULLSTACK the tests live under the backend root (FULLSTACK
|
||
→ ``backend/tests/`` — issue #274). Issue #275: missing tests dir / too
|
||
few test files are WARN (non-blocking), not FAIL.
|
||
"""
|
||
group = GroupResult(name="Тесты")
|
||
backend_root = _backend_root_for(ptype, ctx)
|
||
tests_dir = backend_root / "tests"
|
||
if not tests_dir.exists():
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "tests/", "директория tests/ отсутствует")
|
||
)
|
||
return group
|
||
if (tests_dir / "conftest.py").exists():
|
||
group.checks.append(CheckResult(CheckStatus.OK, "conftest.py", "существует"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "conftest.py", "отсутствует — pytest fixtures без общего конфига"
|
||
)
|
||
)
|
||
test_files = list(tests_dir.glob("test_*.py"))
|
||
min_tests = int(ctx.config.get("min_test_count", 1))
|
||
if len(test_files) >= min_tests:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "test files", f"{len(test_files)} файлов"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "test files", f"{len(test_files)} (< {min_tests})")
|
||
)
|
||
_check_test_structure(ptype, tests_dir, group)
|
||
asyncio_marks = 0
|
||
for tf in test_files:
|
||
try:
|
||
content = tf.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
continue
|
||
asyncio_marks += content.count("@pytest.mark.asyncio")
|
||
if asyncio_marks == 0:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.OK, "no @pytest.mark.asyncio", "asyncio_mode=auto используется")
|
||
)
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"no @pytest.mark.asyncio",
|
||
f"{asyncio_marks} маркеров — не нужно при asyncio_mode=auto",
|
||
)
|
||
)
|
||
group.checks.append(_check_stub_files(test_files))
|
||
return group
|
||
|
||
|
||
# ── check group 5: README (12 delimiter tags) ────────────────────────────────
|
||
|
||
|
||
def _readme_required_sections(content: str) -> list[CheckResult]:
|
||
"""Check required README sections ported from ``validateReadme`` (create-readme.ts).
|
||
|
||
Each missing section → WARN (issue #275: all-WARN contract, non-blocking).
|
||
Covers: RU switcher link, support link, Quick Start (EN), Быстрый старт
|
||
(RU), and a manual ``## License`` section (duplicates the GitHub sidebar).
|
||
"""
|
||
results: list[CheckResult] = []
|
||
if "[Русский](#-русский)" not in content:
|
||
results.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"[Русский](#-русский)",
|
||
"отсутствует RU switcher (должен быть #-русский)",
|
||
)
|
||
)
|
||
else:
|
||
results.append(CheckResult(CheckStatus.OK, "[Русский](#-русский)", "присутствует"))
|
||
if "slaid098.dev/contacts" in content:
|
||
results.append(CheckResult(CheckStatus.OK, "slaid098.dev/contacts", "присутствует"))
|
||
elif "slaid098.dev/support" in content:
|
||
results.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"slaid098.dev/contacts",
|
||
"deprecated: найдена старая ссылка slaid098.dev/support, "
|
||
"перегенерируй README через create-readme",
|
||
)
|
||
)
|
||
else:
|
||
results.append(
|
||
CheckResult(CheckStatus.WARN, "slaid098.dev/contacts", "отсутствует support link")
|
||
)
|
||
if "Quick Start" not in content:
|
||
results.append(
|
||
CheckResult(CheckStatus.WARN, "Quick Start", "отсутствует EN секция Quick Start")
|
||
)
|
||
else:
|
||
results.append(CheckResult(CheckStatus.OK, "Quick Start", "присутствует"))
|
||
if "Быстрый старт" not in content:
|
||
results.append(
|
||
CheckResult(CheckStatus.WARN, "Быстрый старт", "отсутствует RU секция Быстрый старт")
|
||
)
|
||
else:
|
||
results.append(CheckResult(CheckStatus.OK, "Быстрый старт", "присутствует"))
|
||
if re.search(r"^##\s+(License|LICENSE|Лицензия)\s*$", content, re.MULTILINE):
|
||
results.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"Manual License section",
|
||
"найден — удалить (GitHub рендерит из LICENSE файла)",
|
||
)
|
||
)
|
||
return results
|
||
|
||
|
||
def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||
"""Group 5: README — full port of ``validateReadme`` from create-readme.ts.
|
||
|
||
Issue #275: all problems are WARN (non-blocking), never FAIL.
|
||
"""
|
||
group = GroupResult(name="README")
|
||
content = read_text("README.md", ctx)
|
||
if content is None:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, "README.md", "отсутствует"))
|
||
return group
|
||
missing = [d for d in README_DELIMITERS if f"<!-- {d} -->" not in content]
|
||
if missing:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
"12 delimiter tags",
|
||
f"не хватает {len(missing)}: {', '.join(missing[:3])}",
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.OK, "12 delimiter tags", "все 12 разделителей присутствуют")
|
||
)
|
||
for required_text in ("# 🚀 ", "## 🇺🇸 English", "## 🇷🇺 Русский", "[English](#-english)"):
|
||
if required_text in content:
|
||
group.checks.append(CheckResult(CheckStatus.OK, required_text, "присутствует"))
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, required_text, "отсутствует"))
|
||
group.checks.extend(_readme_required_sections(content))
|
||
if "assets/cover.png" in content:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "cover.png", "не указан — slaid098.dev showcase требует")
|
||
)
|
||
return group
|
||
|
||
|
||
# ── check group 6: infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit) ──
|
||
|
||
|
||
def _check_branch_protection(repo: str) -> CheckResult:
|
||
"""Read-only ``gh api repos/<repo>/rules/branches/main`` — returns CheckResult."""
|
||
rc, out, err = run_cmd(["gh", "api", f"repos/{repo}/rules/branches/main"])
|
||
if rc != 0:
|
||
return CheckResult(
|
||
CheckStatus.WARN, "branch protection", f"gh api не отвечает: {err.strip()[:60]}"
|
||
)
|
||
if "pull_request" in out and "required_status_checks" in out:
|
||
return CheckResult(CheckStatus.OK, "branch protection", "main защищён (PR + checks)")
|
||
return CheckResult(CheckStatus.WARN, "branch protection", "правила найдены, но набор неполный")
|
||
|
||
|
||
def check_infra(
|
||
ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None
|
||
) -> GroupResult:
|
||
"""Group 6: Infra — branch protection, ci.yml, dependabot, LICENSE, pre-commit.
|
||
|
||
If ``repo_root`` is given, uses ``git -C <repo_root>`` to locate the remote
|
||
for branch protection detection.
|
||
"""
|
||
group = GroupResult(name="Infra")
|
||
if path_exists(".github/workflows/ci.yml", ctx):
|
||
group.checks.append(CheckResult(CheckStatus.OK, ".github/workflows/ci.yml", "есть"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN,
|
||
".github/workflows/ci.yml",
|
||
"отсутствует — CI не настроен",
|
||
)
|
||
)
|
||
if path_exists(".github/dependabot.yml", ctx):
|
||
group.checks.append(CheckResult(CheckStatus.OK, "dependabot.yml", "настроен"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "dependabot.yml", "обновления зависимостей вручную")
|
||
)
|
||
if path_exists("LICENSE", ctx):
|
||
group.checks.append(CheckResult(CheckStatus.OK, "LICENSE", "есть"))
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, "LICENSE", "отсутствует"))
|
||
if path_exists(".pre-commit-config.yaml", ctx):
|
||
group.checks.append(CheckResult(CheckStatus.OK, "pre-commit", "настроен"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "pre-commit", "отсутствует — quality gate только в CI")
|
||
)
|
||
if fast:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "branch protection", "пропущено (--fast)")
|
||
)
|
||
else:
|
||
repo = get_repo_full_name(repo_root)
|
||
if repo is None:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "branch protection", "git remote недоступен (не git repo?)"
|
||
)
|
||
)
|
||
else:
|
||
group.checks.append(_check_branch_protection(repo))
|
||
return group
|
||
|
||
|
||
# ── check group 7: coverage (non-blocking) ───────────────────────────────────
|
||
|
||
|
||
def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||
"""Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).
|
||
|
||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||
``backend/pyproject.toml`` — issue #274).
|
||
"""
|
||
group = GroupResult(name="Coverage")
|
||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||
pyproject = parse_pyproject(backend_ctx)
|
||
cov = pyproject.get("tool", {}).get("coverage", {})
|
||
run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {}
|
||
sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else []
|
||
if sources:
|
||
group.checks.append(CheckResult(CheckStatus.OK, "[tool.coverage.run]", f"source={sources}"))
|
||
else:
|
||
group.checks.append(CheckResult(CheckStatus.WARN, "[tool.coverage.run]", "source не задан"))
|
||
pytest_opts = pyproject.get("tool", {}).get("pytest", {}).get("ini_options", {})
|
||
addopts = pytest_opts.get("addopts", "") if isinstance(pytest_opts, dict) else ""
|
||
if "--cov-fail-under" in str(addopts):
|
||
m = re.search(r"--cov-fail-under=(\d+)", str(addopts))
|
||
threshold = m.group(1) if m else "?"
|
||
group.checks.append(CheckResult(CheckStatus.OK, "cov-fail-under", f"порог={threshold}%"))
|
||
else:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "cov-fail-under", "порог не задан (coverage non-blocking)"
|
||
)
|
||
)
|
||
return group
|
||
|
||
|
||
# ── check group 8: pyproject.toml validity (13 checks) ───────────────────────
|
||
|
||
|
||
DEFAULT_COVERAGE_EXCLUDE_LINES: list[str] = [
|
||
"pragma: no cover",
|
||
"if __name__ == .__main__.:",
|
||
"if TYPE_CHECKING:",
|
||
]
|
||
|
||
|
||
def _has_ruff_config(pyproject: dict[str, Any], repo_root: Path) -> bool:
|
||
"""True if [tool.ruff] section exists or ruff.toml file is present."""
|
||
if "ruff" in pyproject.get("tool", {}):
|
||
return True
|
||
return (repo_root / "ruff.toml").exists()
|
||
|
||
|
||
def _has_mypy_config(pyproject: dict[str, Any], repo_root: Path) -> bool:
|
||
"""True if [tool.mypy] section exists or mypy.ini file is present."""
|
||
if "mypy" in pyproject.get("tool", {}):
|
||
return True
|
||
return (repo_root / "mypy.ini").exists() or (repo_root / ".mypy.ini").exists()
|
||
|
||
|
||
def _mypy_strict(pyproject: dict[str, Any]) -> bool:
|
||
"""True if mypy is strict (strict=true or disallow_untyped_defs=true)."""
|
||
mypy = pyproject.get("tool", {}).get("mypy", {})
|
||
if not isinstance(mypy, dict):
|
||
return False
|
||
return bool(mypy.get("strict")) or bool(mypy.get("disallow_untyped_defs"))
|
||
|
||
|
||
def _check_python_version_compat(project: dict[str, Any], root: Path) -> CheckResult:
|
||
"""Check 13: requires-python vs .python-version compatibility.
|
||
|
||
Uses ``packaging.specifiers.SpecifierSet.contains()``. FAIL if the pinned
|
||
version in ``.python-version`` is not contained in the requires-python set.
|
||
WARN if ``.python-version`` or ``requires-python`` is missing.
|
||
"""
|
||
name = "requires-python vs .python-version"
|
||
python_version_path = root / ".python-version"
|
||
requires_python = project.get("requires-python", "") if isinstance(project, dict) else ""
|
||
if not python_version_path.exists():
|
||
return CheckResult(CheckStatus.WARN, name, ".python-version отсутствует — skip")
|
||
if not requires_python:
|
||
return CheckResult(CheckStatus.WARN, name, "requires-python не задан — skip")
|
||
try:
|
||
pv_content = python_version_path.read_text(encoding="utf-8-sig")
|
||
except OSError:
|
||
pv_content = ""
|
||
return _python_version_compat_impl(str(requires_python), pv_content)
|
||
|
||
|
||
def _python_version_compat_impl(requires_python: str, python_version_file: str) -> CheckResult:
|
||
"""Impl for ``_check_python_version_compat``: packaging-based version check.
|
||
|
||
Soft-dependency on ``packaging`` — WARN on ImportError (legitimate
|
||
``# noqa: PLC0415`` for lazy import). Issue #275: incompat / parse errors
|
||
are WARN (non-blocking), not FAIL.
|
||
"""
|
||
name = "requires-python vs .python-version"
|
||
try:
|
||
from packaging.specifiers import SpecifierSet # noqa: PLC0415
|
||
except ImportError:
|
||
return CheckResult(CheckStatus.WARN, name, "packaging не установлен — проверка пропущена")
|
||
pinned = python_version_file.strip()
|
||
m = re.search(r"(\d+\.\d+)", pinned)
|
||
if not m:
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, f"не удалось распарсить версию из .python-version: {pinned!r}"
|
||
)
|
||
version = m.group(1)
|
||
try:
|
||
spec = SpecifierSet(requires_python)
|
||
except ValueError as e:
|
||
return CheckResult(CheckStatus.WARN, name, f"неверный requires-python: {e}")
|
||
if spec.contains(version, prereleases=True):
|
||
return CheckResult(
|
||
CheckStatus.OK, name, f"requires-python={requires_python!r} включает {version}"
|
||
)
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, f"requires-python={requires_python!r} не включает {version}"
|
||
)
|
||
|
||
|
||
def _load_pyproject(root: Path) -> dict[str, Any] | None:
|
||
"""Load and parse ``pyproject.toml``; return None on missing or parse error.
|
||
|
||
Returns the parsed dict on success, ``None`` if the file is missing,
|
||
or ``{}``-sentinel handled by caller on parse error. The caller
|
||
distinguishes missing (WARN) from unparseable (FAIL) via a sentinel:
|
||
``None`` = missing, ``{"__parse_error__": str}`` = failed parse.
|
||
"""
|
||
pyproject_path = root / "pyproject.toml"
|
||
if not pyproject_path.exists():
|
||
return None
|
||
try:
|
||
with pyproject_path.open("rb") as f:
|
||
return tomllib.load(f)
|
||
except (OSError, ValueError) as e:
|
||
return {"__parse_error__": str(e)}
|
||
|
||
|
||
def _check_build_system(data: dict[str, Any]) -> CheckResult:
|
||
"""Check 1: ``[build-system]`` requires hatchling + hatchling.build backend.
|
||
|
||
Issue #275: missing/incorrect build-system is WARN (non-blocking), not FAIL.
|
||
"""
|
||
build = data.get("build-system", {}) if isinstance(data, dict) else {}
|
||
requires = build.get("requires", []) if isinstance(build, dict) else []
|
||
build_backend = build.get("build-backend", "") if isinstance(build, dict) else ""
|
||
requires_ok = isinstance(requires, list) and any("hatchling" in str(r) for r in requires)
|
||
if requires_ok and build_backend == "hatchling.build":
|
||
return CheckResult(CheckStatus.OK, "[build-system]", "hatchling настроен")
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
"[build-system]",
|
||
f"требуется hatchling (requires={requires!r}, backend={build_backend!r})",
|
||
)
|
||
|
||
|
||
def _check_hatch_packages_nested(hatch_packages: object, src_pkg_path: str) -> CheckResult:
|
||
"""Sub-check of check 2: classify hatch packages when ``src/<pkg>/`` exists.
|
||
|
||
Returns OK if packages references ``src/<pkg>``; WARN if ``["src"]`` flat
|
||
layout; WARN otherwise (issue #275: all-WARN contract, non-blocking).
|
||
"""
|
||
name = "[tool.hatch.build.targets.wheel]"
|
||
valid_packages = {src_pkg_path}
|
||
if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages):
|
||
return CheckResult(CheckStatus.OK, name, f"packages={hatch_packages!r}")
|
||
if isinstance(hatch_packages, list) and "src" in hatch_packages:
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
name,
|
||
f"deprecated flat layout — use packages=['{src_pkg_path}'], got={hatch_packages!r}",
|
||
)
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}'
|
||
)
|
||
|
||
|
||
def _check_hatch_packages(data: dict[str, Any], root: Path, ptype: ProjectType) -> CheckResult:
|
||
"""Check 2: ``[tool.hatch.build.targets.wheel]`` packages layout.
|
||
|
||
Nested ``src/<pkg>/`` is the standard; ``packages=["src"]`` (flat) → WARN;
|
||
missing ``src/<pkg>/`` with ``src/`` present → WARN (deprecated flat).
|
||
"""
|
||
name = "[tool.hatch.build.targets.wheel]"
|
||
tools = data.get("tool", {}) if isinstance(data, dict) else {}
|
||
project = data.get("project", {}) if isinstance(data, dict) else {}
|
||
proj_name = project.get("name", "") if isinstance(project, dict) else ""
|
||
expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else ""
|
||
src_pkg_path = f"src/{expected_pkg}"
|
||
src_pkg_dir_exists = bool(expected_pkg) and (root / "src" / expected_pkg).is_dir()
|
||
hatch_targets = (
|
||
tools.get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {})
|
||
if isinstance(tools, dict)
|
||
else {}
|
||
)
|
||
hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else []
|
||
if src_pkg_dir_exists:
|
||
return _check_hatch_packages_nested(hatch_packages, src_pkg_path)
|
||
if (root / "src").exists():
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
name,
|
||
f"нет src/{expected_pkg}/ — deprecated flat layout, use src/<package>/",
|
||
)
|
||
if isinstance(hatch_packages, list) and hatch_packages:
|
||
return CheckResult(CheckStatus.OK, name, f"packages={hatch_packages!r}")
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, "нет src/ и нет packages — OK для CLI без library-ambitions"
|
||
)
|
||
|
||
|
||
def _check_project_fields(project: dict[str, Any]) -> CheckResult:
|
||
"""Check 3: ``[project]`` has name, version, description, requires-python.
|
||
|
||
Issue #275: missing fields are WARN (non-blocking), not FAIL.
|
||
"""
|
||
required = ["name", "version", "description", "requires-python"]
|
||
missing = [f for f in required if not project.get(f)] if isinstance(project, dict) else required
|
||
if not missing:
|
||
return CheckResult(
|
||
CheckStatus.OK,
|
||
"[project]",
|
||
f"name={project.get('name')!r}, version={project.get('version')!r}",
|
||
)
|
||
return CheckResult(CheckStatus.WARN, "[project]", f"отсутствуют поля: {', '.join(missing)}")
|
||
|
||
|
||
def _check_ruff_section(data: dict[str, Any], root: Path) -> CheckResult:
|
||
"""Check 4: ``[tool.ruff]`` section or ``ruff.toml`` with line-length + target-version.
|
||
|
||
Issue #275: missing ruff config is WARN (non-blocking), not FAIL.
|
||
"""
|
||
name = "[tool.ruff]"
|
||
tools = data.get("tool", {}) if isinstance(data, dict) else {}
|
||
ruff_section = tools.get("ruff", {}) if isinstance(tools, dict) else {}
|
||
if not _has_ruff_config(data, root):
|
||
return CheckResult(CheckStatus.WARN, name, "секция отсутствует (и нет ruff.toml)")
|
||
if isinstance(ruff_section, dict) and ruff_section:
|
||
has_ll = "line-length" in ruff_section
|
||
has_tv = "target-version" in ruff_section
|
||
if has_ll and has_tv:
|
||
return CheckResult(CheckStatus.OK, name, "line-length + target-version настроены")
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
name,
|
||
f"минимум: line-length, target-version (есть: "
|
||
f"{'ll' if has_ll else ''}{'+' if has_ll and has_tv else ''}"
|
||
f"{'tv' if has_tv else ''})",
|
||
)
|
||
return CheckResult(CheckStatus.OK, name, "ruff.toml обнаружен")
|
||
|
||
|
||
def _check_mypy_section(data: dict[str, Any], root: Path) -> CheckResult:
|
||
"""Check 5: ``[tool.mypy]`` strict or ``mypy.ini`` present.
|
||
|
||
Issue #275: missing mypy config is WARN (non-blocking), not FAIL.
|
||
"""
|
||
name = "[tool.mypy]"
|
||
tools = data.get("tool", {}) if isinstance(data, dict) else {}
|
||
has_mypy_ini = (root / "mypy.ini").exists() or (root / ".mypy.ini").exists()
|
||
if "mypy" not in tools and not has_mypy_ini:
|
||
return CheckResult(CheckStatus.WARN, name, "секция отсутствует (и нет mypy.ini)")
|
||
if has_mypy_ini and "mypy" not in tools:
|
||
return CheckResult(CheckStatus.OK, name, "mypy.ini обнаружен")
|
||
if _mypy_strict(data):
|
||
return CheckResult(CheckStatus.OK, name, "strict=true (или disallow_untyped_defs)")
|
||
return CheckResult(CheckStatus.WARN, name, "не strict — добавьте strict=true")
|
||
|
||
|
||
def _check_pytest_ini_options(tools: dict[str, Any]) -> CheckResult:
|
||
"""Check 6: ``[tool.pytest.ini_options]`` asyncio_mode=auto, testpaths=["tests"].
|
||
|
||
Issue #275: missing pytest config is WARN (non-blocking), not FAIL.
|
||
"""
|
||
name = "[tool.pytest.ini_options]"
|
||
pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {}
|
||
if not (isinstance(pytest_opts, dict) and pytest_opts):
|
||
return CheckResult(CheckStatus.WARN, name, "секция отсутствует")
|
||
asyncio_mode = pytest_opts.get("asyncio_mode", "")
|
||
testpaths = pytest_opts.get("testpaths", [])
|
||
if asyncio_mode == "auto" and testpaths == ["tests"]:
|
||
return CheckResult(CheckStatus.OK, name, 'asyncio_mode=auto, testpaths=["tests"]')
|
||
return CheckResult(
|
||
CheckStatus.WARN,
|
||
name,
|
||
f'asyncio_mode={asyncio_mode!r}, testpaths={testpaths!r} (рекомендуется auto + ["tests"])',
|
||
)
|
||
|
||
|
||
def _check_coverage_run(tools: dict[str, Any]) -> CheckResult:
|
||
"""Check 7: ``[tool.coverage.run]`` has source and branch=true."""
|
||
name = "[tool.coverage.run]"
|
||
cov_run = tools.get("coverage", {}).get("run", {}) if isinstance(tools, dict) else {}
|
||
if isinstance(cov_run, dict) and cov_run.get("source") and cov_run.get("branch") is True:
|
||
return CheckResult(CheckStatus.OK, name, f"source={cov_run.get('source')!r}, branch=true")
|
||
return CheckResult(CheckStatus.WARN, name, "нужны source и branch=true")
|
||
|
||
|
||
def _check_coverage_report(tools: dict[str, Any]) -> CheckResult:
|
||
"""Check 8: ``[tool.coverage.report]`` exclude_lines includes defaults."""
|
||
name = "[tool.coverage.report]"
|
||
cov_report = tools.get("coverage", {}).get("report", {}) if isinstance(tools, dict) else {}
|
||
exclude_lines = cov_report.get("exclude_lines", []) if isinstance(cov_report, dict) else []
|
||
exclude_strs = [str(e) for e in exclude_lines] if isinstance(exclude_lines, list) else []
|
||
missing_exclude = [
|
||
e for e in DEFAULT_COVERAGE_EXCLUDE_LINES if not any(e in s for s in exclude_strs)
|
||
]
|
||
if not missing_exclude and exclude_strs:
|
||
return CheckResult(
|
||
CheckStatus.OK, name, f"exclude_lines содержит {len(exclude_strs)} паттернов"
|
||
)
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, f"exclude_lines не хватает: {', '.join(missing_exclude)}"
|
||
)
|
||
|
||
|
||
def _check_addopts_cov(pytest_opts: dict[str, Any]) -> CheckResult:
|
||
"""Check 9: addopts ``--cov-fail-under=N`` threshold set."""
|
||
name = "addopts --cov-fail-under"
|
||
addopts_val = pytest_opts.get("addopts", "") if isinstance(pytest_opts, dict) else ""
|
||
m_cov = re.search(r"--cov-fail-under=(\d+)", str(addopts_val))
|
||
if m_cov:
|
||
return CheckResult(CheckStatus.OK, name, f"порог={m_cov.group(1)}%")
|
||
return CheckResult(CheckStatus.WARN, name, "порог coverage не задан")
|
||
|
||
|
||
def _check_project_status_section(tools: dict[str, Any]) -> CheckResult:
|
||
"""Check 10: ``[tool.project-status]`` thresholds section (uses defaults if absent)."""
|
||
name = "[tool.project-status]"
|
||
ps_section = tools.get("project-status", {}) if isinstance(tools, dict) else {}
|
||
expected_keys = ["thin_routes_max_lines", "cov_fail_under", "required_dirs_backend"]
|
||
if isinstance(ps_section, dict) and ps_section:
|
||
missing_keys = [k for k in expected_keys if k not in ps_section]
|
||
if not missing_keys:
|
||
return CheckResult(CheckStatus.OK, name, "пороги заданы (uses defaults)")
|
||
return CheckResult(
|
||
CheckStatus.WARN, name, f"не заданы пороги: {', '.join(missing_keys)} (uses defaults)"
|
||
)
|
||
return CheckResult(CheckStatus.WARN, name, "секция отсутствует — uses defaults")
|
||
|
||
|
||
def _check_pre_commit(root: Path) -> CheckResult:
|
||
"""Check 11: ``.pre-commit-config.yaml`` exists."""
|
||
if (root / ".pre-commit-config.yaml").exists():
|
||
return CheckResult(CheckStatus.OK, ".pre-commit-config.yaml", "настроен")
|
||
return CheckResult(CheckStatus.WARN, ".pre-commit-config.yaml", "отсутствует (Python-проект)")
|
||
|
||
|
||
def _check_uv_lock(root: Path) -> CheckResult:
|
||
"""Check 12: ``uv.lock`` exists."""
|
||
if (root / "uv.lock").exists():
|
||
return CheckResult(CheckStatus.OK, "uv.lock", "существует")
|
||
return CheckResult(CheckStatus.WARN, "uv.lock", "отсутствует — запусти `uv lock` и закоммить")
|
||
|
||
|
||
PYPROJECT_CHECKS: list[Any] = [] # populated below; kept here for discoverability.
|
||
|
||
|
||
def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||
"""Group 8: pyproject.toml — 13 checks (OK/WARN).
|
||
|
||
Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each
|
||
of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the
|
||
file is missing → single WARN; parse error → single WARN (issue #275:
|
||
all-WARN contract, non-blocking).
|
||
|
||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||
``backend/pyproject.toml`` — issue #274); root-level files checked by
|
||
sub-checks (``.pre-commit-config.yaml``, ``uv.lock``, ``.python-version``,
|
||
``ruff.toml``, ``mypy.ini``) also resolve against the backend root.
|
||
"""
|
||
group = GroupResult(name="Pyproject")
|
||
backend_root = _backend_root_for(ptype, ctx)
|
||
data = _load_pyproject(backend_root)
|
||
if data is None:
|
||
group.checks.append(
|
||
CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks")
|
||
)
|
||
return group
|
||
if "__parse_error__" in data:
|
||
group.checks.append(
|
||
CheckResult(
|
||
CheckStatus.WARN, "pyproject.toml", f"парсинг failed: {data['__parse_error__']}"
|
||
)
|
||
)
|
||
return group
|
||
|
||
project = data.get("project", {}) if isinstance(data, dict) else {}
|
||
tools = data.get("tool", {}) if isinstance(data, dict) else {}
|
||
pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {}
|
||
for check_fn in PYPROJECT_CHECKS:
|
||
group.checks.append(check_fn(data, backend_root, ptype, project, tools, pytest_opts))
|
||
return group
|
||
|
||
|
||
PYPROJECT_CHECKS = [
|
||
lambda d, r, pt, p, t, po: _check_build_system(d),
|
||
lambda d, r, pt, p, t, po: _check_hatch_packages(d, r, pt),
|
||
lambda d, r, pt, p, t, po: _check_project_fields(p),
|
||
lambda d, r, pt, p, t, po: _check_ruff_section(d, r),
|
||
lambda d, r, pt, p, t, po: _check_mypy_section(d, r),
|
||
lambda d, r, pt, p, t, po: _check_pytest_ini_options(t),
|
||
lambda d, r, pt, p, t, po: _check_coverage_run(t),
|
||
lambda d, r, pt, p, t, po: _check_coverage_report(t),
|
||
lambda d, r, pt, p, t, po: _check_addopts_cov(po),
|
||
lambda d, r, pt, p, t, po: _check_project_status_section(t),
|
||
lambda d, r, pt, p, t, po: _check_pre_commit(r),
|
||
lambda d, r, pt, p, t, po: _check_uv_lock(r),
|
||
lambda d, r, pt, p, t, po: _check_python_version_compat(p, r),
|
||
]
|
||
|
||
|
||
# ── orchestration ────────────────────────────────────────────────────────────
|
||
|
||
|
||
CHECK_GROUPS: list[str] = [
|
||
"Структура",
|
||
"Тонкие роуты",
|
||
"Качество кода",
|
||
"Тесты",
|
||
"README",
|
||
"Infra",
|
||
"Coverage",
|
||
"Pyproject",
|
||
]
|
||
|
||
|
||
def run_all_checks(
|
||
ptype: ProjectType, ctx: RepoCtx, fast: bool = False, repo_root: Path | None = None
|
||
) -> list[GroupResult]:
|
||
"""Run all 8 check groups, return results in order.
|
||
|
||
If ``repo_root`` is given, it is forwarded to ``check_infra`` for
|
||
``git -C`` based remote detection (used with ``--repo`` flag).
|
||
"""
|
||
return [
|
||
check_structure(ptype, ctx),
|
||
check_thin_routes(ptype, ctx, fast=fast),
|
||
check_quality(ptype, ctx),
|
||
check_tests(ptype, ctx),
|
||
check_readme(ptype, ctx),
|
||
check_infra(ptype, ctx, fast=fast, repo_root=repo_root),
|
||
check_coverage(ptype, ctx),
|
||
check_pyproject(ptype, ctx),
|
||
]
|
||
|
||
|
||
STATUS_PREFIX: dict[CheckStatus, str] = {
|
||
CheckStatus.OK: "[OK]",
|
||
CheckStatus.WARN: "[WARN]",
|
||
CheckStatus.FAIL: "[FAIL]",
|
||
}
|
||
|
||
|
||
def format_output(ptype: ProjectType, groups: list[GroupResult]) -> str:
|
||
"""Format output: 8 group blocks + Итог + Рекомендации.
|
||
|
||
Issue #275: all checks are non-blocking (WARN). The ``Итог:`` line shows
|
||
only ``OK`` and ``WARN`` counts (no ``FAIL`` — no check returns FAIL, so a
|
||
FAIL count would always be 0 and is omitted per the issue #275 contract).
|
||
``Рекомендации:`` lists every WARN with its group + name + detail so the
|
||
audit / project-template skills can parse the report without relying on
|
||
the exit code.
|
||
"""
|
||
lines: list[str] = [f"Project: {ptype.value}", ""]
|
||
recommendations: list[str] = []
|
||
ok_count = 0
|
||
warn_count = 0
|
||
for group in groups:
|
||
overall = group.overall()
|
||
prefix = STATUS_PREFIX[overall]
|
||
lines.append(f"{prefix} {group.name}")
|
||
for chk in group.checks:
|
||
sub_prefix = STATUS_PREFIX[chk.status]
|
||
lines.append(f" {sub_prefix} {chk.name}: {chk.detail}")
|
||
if chk.status == CheckStatus.FAIL:
|
||
recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}")
|
||
elif chk.status == CheckStatus.WARN:
|
||
warn_count += 1
|
||
recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}")
|
||
else:
|
||
ok_count += 1
|
||
lines.append("")
|
||
lines.append("Итог:")
|
||
lines.append(f" OK: {ok_count} WARN: {warn_count}")
|
||
if recommendations:
|
||
lines.append("")
|
||
lines.append("Рекомендации:")
|
||
lines.extend(recommendations)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||
"""Parse CLI args."""
|
||
parser = argparse.ArgumentParser(
|
||
prog="project-status.py",
|
||
description="Read-only check of repo architecture conformance.",
|
||
)
|
||
parser.add_argument(
|
||
"--check",
|
||
action="store_true",
|
||
help="accepted for CLI compatibility — exit code is always 0 (issue #275)",
|
||
)
|
||
parser.add_argument(
|
||
"--fast",
|
||
action="store_true",
|
||
help="skip slow/remote checks (branch protection via gh)",
|
||
)
|
||
parser.add_argument(
|
||
"--repo",
|
||
type=str,
|
||
default=None,
|
||
help="path to repo to check (default: cwd / current repo)",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main() -> None:
|
||
"""Entry point: parse args, run checks, print report, set exit code.
|
||
|
||
Issue #275: all checks are non-blocking — the exit code is always 0
|
||
(informational mode). The ``--check`` flag is accepted for CLI backward
|
||
compatibility but no longer forces exit 1. The only non-zero exit is for
|
||
an invalid ``--repo`` path (argument validation, not a check result).
|
||
"""
|
||
args = _parse_args(sys.argv[1:])
|
||
strict = args.check
|
||
fast = args.fast
|
||
repo_arg = args.repo
|
||
_ = strict # accepted for CLI compat, no longer forces exit 1 (issue #275)
|
||
|
||
if repo_arg:
|
||
repo_path = Path(repo_arg).resolve()
|
||
if not repo_path.exists():
|
||
print(f"FAIL: repo path not found: {repo_path}")
|
||
sys.exit(1)
|
||
root = repo_path
|
||
else:
|
||
root = REPO_ROOT
|
||
ctx = RepoCtx(root=root, config=load_config(root))
|
||
|
||
ptype = detect_project_type(ctx)
|
||
# Populate ``backend_root`` (FULLSTACK → ``root / "backend"``, else root)
|
||
# so checks reading backend-source files resolve to the right tree. The
|
||
# frozen ctx is rebuilt with the resolved field (issue #274).
|
||
ctx = RepoCtx(root=ctx.root, config=ctx.config, backend_root=_backend_root_for(ptype, ctx))
|
||
groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None)
|
||
print(format_output(ptype, groups))
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|