opencode-config/.opencode/scripts/project-status.py
opencode-agent 9dabc57d9a
Some checks failed
CI / bootstrap (push) Successful in 9s
CI / lint (push) Successful in 21s
CI / typecheck (push) Successful in 25s
CI / complexity (push) Successful in 30s
CI / test (3.14) (push) Successful in 1m47s
CI / test (3.13) (push) Successful in 1m48s
CI / test (3.12) (push) Failing after 13m11s
feat(oracles): add Forgejo backend to status scripts
2026-08-06 15:25:42 +00:00

1948 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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