opencode-config/.opencode/scripts/project-status.py
Sergey 42c8e2d31e
feat(infra): project-status check oracle and ts wrapper (#233)
* feat(infra): project-status python oracle

* feat(infra): project-status ts wrapper

* feat(infra): project-status deny rules and config

* test(infra): project-status oracle and tool tests

* fix(ci): ruff format project-status.py and tests

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 15:31:42 +03:00

730 lines
27 KiB
Python

#!/usr/bin/env python3
"""Project-status oracle: read-only check of repo architecture conformance.
Deterministically inspects the current repository against a standard
architecture for the auto-detected project type and prints a report with
``[OK]/[WARN]/[FAIL]`` lines, an Итог summary, and Рекомендации.
Read-only and stateless — no files are created or modified, no network
calls beyond read-only ``gh api`` for branch protection detection.
Usage:
python3 .opencode/scripts/project-status.py # non-blocking (exit 0)
python3 .opencode/scripts/project-status.py --check # strict (exit 1 on FAIL)
python3 .opencode/scripts/project-status.py --fast # skip slow/remote checks
Project types (auto-detected):
fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
backend — ``src/api/v1/`` + ``src/db/models/`` + fastapi/uvicorn in 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)
"""
from __future__ import annotations
import ast
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any
# ── repo root + config ───────────────────────────────────────────────────────
def _resolve_repo_root() -> Path:
"""Resolve repo root via git (cwd-aware), fallback to script location."""
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() -> 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.
"""
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
pyproject = REPO_ROOT / "pyproject.toml"
if not pyproject.exists():
return cfg
try:
with pyproject.open("rb") as f:
data = tomllib.load(f)
except (OSError, ValueError):
return cfg
section = data.get("tool", {}).get("project-status", {})
if isinstance(section, dict):
for key, default in DEFAULT_CONFIG.items():
val = section.get(key, default)
if isinstance(val, type(default)) or val is None:
cfg[key] = val
return cfg
CONFIG = load_config()
# ── enums + dataclasses ──────────────────────────────────────────────────────
class CheckStatus(StrEnum):
"""Result of a single check."""
OK = "OK"
WARN = "WARN"
FAIL = "FAIL"
class ProjectType(StrEnum):
"""Auto-detected project type."""
FULLSTACK = "fullstack"
BACKEND = "backend"
CLI = "cli"
BOT = "bot"
WORKER = "worker"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class CheckResult:
"""Result of a single check within a group."""
status: CheckStatus
name: str
detail: str
@dataclass
class GroupResult:
"""Result of a single check group (multiple CheckResult items)."""
name: str
checks: list[CheckResult] = field(default_factory=list)
def overall(self) -> CheckStatus:
"""Roll up statuses: FAIL > WARN > OK."""
statuses = [c.status for c in self.checks]
if CheckStatus.FAIL in statuses:
return CheckStatus.FAIL
if CheckStatus.WARN in statuses:
return CheckStatus.WARN
return CheckStatus.OK
# ── helpers ──────────────────────────────────────────────────────────────────
def run_cmd(args: list[str]) -> tuple[int, str, str]:
"""Run a command, return (returncode, stdout, stderr). Read-only intent."""
result = subprocess.run(args, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
def path_exists(rel: str) -> bool:
"""True if ``REPO_ROOT / rel`` exists."""
return (REPO_ROOT / rel).exists()
def read_text(rel: str) -> str | None:
"""Read text content of ``REPO_ROOT / rel`` or None if missing."""
p = REPO_ROOT / rel
if not p.exists():
return None
try:
return p.read_text(encoding="utf-8-sig")
except OSError:
return None
def parse_pyproject() -> dict[str, Any]:
"""Parse pyproject.toml into a dict (or empty dict on failure)."""
raw = read_text("pyproject.toml")
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() -> str | None:
"""Return ``org/repo`` from git remote, or None on error (read-only)."""
rc, out, _ = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0:
return None
try:
_host, org, repo = parse_remote_url(out.strip())
except ValueError:
return None
else:
return f"{org}/{repo}"
# ── auto-detect ──────────────────────────────────────────────────────────────
def _matches_backend(deps_lower: str) -> bool:
"""True if backend contract dirs present + fastapi/uvicorn in deps."""
return (
path_exists("src/api/v1")
and path_exists("src/db/models")
and ("fastapi" in deps_lower or "uvicorn" in deps_lower)
)
def _detect_simple_type(deps_lower: str) -> ProjectType | None:
"""Detect bot/worker types by file or dep marker (or None)."""
if path_exists("src/bot.py") or "aiogram" in deps_lower:
return ProjectType.BOT
if path_exists("src/flow.py") or "prefect" in deps_lower:
return ProjectType.WORKER
return None
def detect_project_type() -> 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") and path_exists("backend"):
return ProjectType.FULLSTACK
pyproject = parse_pyproject()
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):
return ProjectType.BACKEND
simple = _detect_simple_type(deps_lower)
if simple is not None:
return simple
scripts = pyproject.get("project", {}).get("scripts", {})
if isinstance(scripts, dict) and scripts and "typer" in deps_lower:
return ProjectType.CLI
return ProjectType.UNKNOWN
# ── expected structure per type ──────────────────────────────────────────────
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
ProjectType.BACKEND: [
"src/api/v1",
"src/db/models",
"src/schemas",
"src/services",
"src/config/settings.py",
"main.py",
],
ProjectType.FULLSTACK: ["backend", "frontend"],
ProjectType.CLI: ["src"], # src/<package>/ — checked generically
ProjectType.BOT: ["src/bot.py"],
ProjectType.WORKER: ["src/flow.py"],
ProjectType.UNKNOWN: [],
}
# ── 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() -> CheckResult:
"""Check main.py has a lifespan handler (backend-specific)."""
main = read_text("main.py")
if main is None:
return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет")
if "lifespan" in main:
return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден")
return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден")
def _check_cli_package() -> CheckResult:
"""Check src/<package>/ with __init__.py exists (cli-specific)."""
src = REPO_ROOT / "src"
if src.exists() and any(p.is_dir() and (p / "__init__.py").exists() for p in src.iterdir()):
return CheckResult(CheckStatus.OK, "src/<package>/", "пакет найден")
return CheckResult(CheckStatus.FAIL, "src/<package>/", "пакет не найден")
def _check_type_specific_structure(ptype: ProjectType) -> list[CheckResult]:
"""Type-specific extra checks beyond the expected dirs list."""
if ptype == ProjectType.BACKEND:
return [_check_backend_lifespan()]
if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json"):
return [CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")]
if ptype == ProjectType.CLI:
return [_check_cli_package()]
return []
def check_structure(ptype: ProjectType) -> GroupResult:
"""Group 1: Structure — expected dirs/files per project type."""
group = GroupResult(name="Структура")
expected = STRUCTURE_EXPECTED.get(ptype, [])
if not expected:
group.checks.append(
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
)
return group
for rel in expected:
status = CheckStatus.OK if path_exists(rel) else CheckStatus.FAIL
detail = "существует" if status == CheckStatus.OK else "отсутствует"
group.checks.append(CheckResult(status, rel, detail))
group.checks.extend(_check_type_specific_structure(ptype))
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) -> list[Path]:
"""Return list of api/v1 dirs to scan for routes, based on project type."""
if ptype == ProjectType.BACKEND:
root = REPO_ROOT / "src" / "api" / "v1"
return [root] if root.exists() else []
if ptype == ProjectType.FULLSTACK:
root = REPO_ROOT / "backend" / "src" / "api" / "v1"
return [root] if root.exists() else []
return []
def _scan_route_files(api_dirs: list[Path], limit: int) -> 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(REPO_ROOT)}:{n}")
return files_checked, longest, over_limit
def check_thin_routes(ptype: ProjectType, fast: bool = False) -> GroupResult:
"""Group 2: Тонкие роуты — AST parse, ≤ route_line_limit lines per handler."""
_ = fast # unused here, accepted for signature uniformity
group = GroupResult(name="Тонкие роуты")
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
group.checks.append(
CheckResult(CheckStatus.OK, "skip", f"тип={ptype.value}: роуты не применимы")
)
return group
api_dirs = _api_dirs_for(ptype)
if not api_dirs:
group.checks.append(
CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена")
)
return group
limit = int(CONFIG.get("route_line_limit", 50))
files_checked, longest, over_limit = _scan_route_files(api_dirs, limit)
if files_checked == 0:
group.checks.append(CheckResult(CheckStatus.WARN, "AST", "роуты не найдены в src/api/v1/"))
elif over_limit:
group.checks.append(
CheckResult(
CheckStatus.FAIL,
f"route ≤ {limit} lines",
f"превышение: {', '.join(over_limit[:3])}",
)
)
else:
group.checks.append(
CheckResult(
CheckStatus.OK, f"route ≤ {limit} lines", f"макс={longest}, файлов={files_checked}"
)
)
return group
# ── check group 3: code quality (mypy/ruff/pytest presence) ──────────────────
def check_quality(ptype: ProjectType) -> GroupResult:
"""Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml."""
group = GroupResult(name="Качество кода")
pyproject = parse_pyproject()
tools = pyproject.get("tool", {})
for tool_name in ("ruff", "mypy"):
if tool_name in tools:
group.checks.append(CheckResult(CheckStatus.OK, tool_name, "настроен в pyproject.toml"))
else:
group.checks.append(
CheckResult(CheckStatus.FAIL, tool_name, f"[tool.{tool_name}] отсутствует")
)
pytest_cfg = pyproject.get("tool", {}).get("pytest", {})
dev_deps = pyproject.get("project", {}).get("optional-dependencies", {}).get("dev", [])
dev_str = " ".join(str(d).lower() for d in dev_deps) if isinstance(dev_deps, list) else ""
if pytest_cfg or "pytest" in dev_str:
group.checks.append(CheckResult(CheckStatus.OK, "pytest", "настроен"))
else:
group.checks.append(CheckResult(CheckStatus.FAIL, "pytest", "не найден в dev-deps"))
return group
# ── check group 4: tests (conftest, stub-detector, no @pytest.mark.asyncio) ──
def check_tests(ptype: ProjectType) -> GroupResult:
"""Group 4: Тесты — conftest, no @pytest.mark.asyncio, ≥1 test file."""
group = GroupResult(name="Тесты")
tests_dir = REPO_ROOT / "tests"
if not tests_dir.exists():
group.checks.append(
CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует")
)
return group
if (tests_dir / "conftest.py").exists():
group.checks.append(CheckResult(CheckStatus.OK, "conftest.py", "существует"))
else:
group.checks.append(
CheckResult(
CheckStatus.WARN, "conftest.py", "отсутствует — pytest fixtures без общего конфига"
)
)
test_files = list(tests_dir.glob("test_*.py"))
min_tests = int(CONFIG.get("min_test_count", 1))
if len(test_files) >= min_tests:
group.checks.append(CheckResult(CheckStatus.OK, "test files", f"{len(test_files)} файлов"))
else:
group.checks.append(
CheckResult(CheckStatus.FAIL, "test files", f"{len(test_files)} (< {min_tests})")
)
asyncio_marks = 0
for tf in test_files:
try:
content = tf.read_text(encoding="utf-8-sig")
except OSError:
continue
asyncio_marks += content.count("@pytest.mark.asyncio")
if asyncio_marks == 0:
group.checks.append(
CheckResult(CheckStatus.OK, "no @pytest.mark.asyncio", "asyncio_mode=auto используется")
)
else:
group.checks.append(
CheckResult(
CheckStatus.WARN,
"no @pytest.mark.asyncio",
f"{asyncio_marks} маркеров — не нужно при asyncio_mode=auto",
)
)
stub_count = sum(
1
for tf in test_files
for line in tf.read_text(encoding="utf-8-sig", errors="ignore").splitlines()
if re.match(r"\s*(def test_|async def test_).*stub", line, re.IGNORECASE)
)
group.checks.append(
CheckResult(
CheckStatus.WARN if stub_count > 0 else CheckStatus.OK,
"stub-detector",
f"{stub_count} stub-тестов",
)
)
return group
# ── check group 5: README (12 delimiter tags) ────────────────────────────────
def check_readme(ptype: ProjectType) -> GroupResult:
"""Group 5: README — 12 delimiter tags from create-readme.ts:140-199."""
group = GroupResult(name="README")
content = read_text("README.md")
if content is None:
group.checks.append(CheckResult(CheckStatus.FAIL, "README.md", "отсутствует"))
return group
missing = [d for d in README_DELIMITERS if f"<!-- {d} -->" not in content]
if missing:
group.checks.append(
CheckResult(
CheckStatus.FAIL,
"12 delimiter tags",
f"не хватает {len(missing)}: {', '.join(missing[:3])}",
)
)
else:
group.checks.append(
CheckResult(CheckStatus.OK, "12 delimiter tags", "все 12 разделителей присутствуют")
)
for required_text in ("# 🚀 ", "## 🇺🇸 English", "## 🇷🇺 Русский", "[English](#-english)"):
if required_text in content:
group.checks.append(CheckResult(CheckStatus.OK, required_text, "присутствует"))
else:
group.checks.append(CheckResult(CheckStatus.FAIL, required_text, "отсутствует"))
if "assets/cover.png" in content:
group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан"))
else:
group.checks.append(
CheckResult(CheckStatus.WARN, "cover.png", "не указан — slaid098.dev showcase требует")
)
return group
# ── check group 6: infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit) ──
def _check_branch_protection(repo: str) -> CheckResult:
"""Read-only ``gh api repos/<repo>/rules/branches/main`` — returns CheckResult."""
rc, out, err = run_cmd(["gh", "api", f"repos/{repo}/rules/branches/main"])
if rc != 0:
return CheckResult(
CheckStatus.WARN, "branch protection", f"gh api не отвечает: {err.strip()[:60]}"
)
if "pull_request" in out and "required_status_checks" in out:
return CheckResult(CheckStatus.OK, "branch protection", "main защищён (PR + checks)")
return CheckResult(CheckStatus.WARN, "branch protection", "правила найдены, но набор неполный")
def check_infra(ptype: ProjectType, fast: bool = False) -> GroupResult:
"""Group 6: Infra — branch protection, ci.yml, dependabot, LICENSE, pre-commit."""
group = GroupResult(name="Infra")
if path_exists(".github/workflows/ci.yml"):
group.checks.append(CheckResult(CheckStatus.OK, ".github/workflows/ci.yml", "есть"))
else:
group.checks.append(
CheckResult(
CheckStatus.FAIL,
".github/workflows/ci.yml",
"отсутствует — CI не настроен",
)
)
if path_exists(".github/dependabot.yml"):
group.checks.append(CheckResult(CheckStatus.OK, "dependabot.yml", "настроен"))
else:
group.checks.append(
CheckResult(CheckStatus.WARN, "dependabot.yml", "обновления зависимостей вручную")
)
if path_exists("LICENSE"):
group.checks.append(CheckResult(CheckStatus.OK, "LICENSE", "есть"))
else:
group.checks.append(CheckResult(CheckStatus.FAIL, "LICENSE", "отсутствует"))
if path_exists(".pre-commit-config.yaml"):
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()
if repo is None:
group.checks.append(
CheckResult(CheckStatus.WARN, "branch protection", "git remote недоступен")
)
else:
group.checks.append(_check_branch_protection(repo))
return group
# ── check group 7: coverage (non-blocking) ───────────────────────────────────
def check_coverage(ptype: ProjectType) -> GroupResult:
"""Group 7: Coverage — non-blocking (always OK/WARN, never FAIL)."""
group = GroupResult(name="Coverage")
pyproject = parse_pyproject()
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
# ── orchestration ────────────────────────────────────────────────────────────
CHECK_GROUPS: list[str] = [
"Структура",
"Тонкие роуты",
"Качество кода",
"Тесты",
"README",
"Infra",
"Coverage",
]
def run_all_checks(ptype: ProjectType, fast: bool = False) -> list[GroupResult]:
"""Run all 7 check groups, return results in order."""
return [
check_structure(ptype),
check_thin_routes(ptype, fast=fast),
check_quality(ptype),
check_tests(ptype),
check_readme(ptype),
check_infra(ptype, fast=fast),
check_coverage(ptype),
]
STATUS_PREFIX: dict[CheckStatus, str] = {
CheckStatus.OK: "[OK]",
CheckStatus.WARN: "[WARN]",
CheckStatus.FAIL: "[FAIL]",
}
def format_output(ptype: ProjectType, groups: list[GroupResult]) -> str:
"""Format output: 7 group blocks + Итог + Рекомендации."""
lines: list[str] = [f"Project: {ptype.value}", ""]
recommendations: list[str] = []
ok_count = 0
warn_count = 0
fail_count = 0
for group in groups:
overall = group.overall()
prefix = STATUS_PREFIX[overall]
lines.append(f"{prefix} {group.name}")
for chk in group.checks:
sub_prefix = STATUS_PREFIX[chk.status]
lines.append(f" {sub_prefix} {chk.name}: {chk.detail}")
if chk.status == CheckStatus.FAIL:
fail_count += 1
recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}")
elif chk.status == CheckStatus.WARN:
warn_count += 1
else:
ok_count += 1
lines.append("")
lines.append("Итог:")
lines.append(f" OK: {ok_count} WARN: {warn_count} FAIL: {fail_count}")
if recommendations:
lines.append("")
lines.append("Рекомендации:")
lines.extend(recommendations)
return "\n".join(lines)
def main() -> None:
"""Entry point: parse args, run checks, print report, set exit code."""
args = sys.argv[1:]
strict = "--check" in args
fast = "--fast" in args
ptype = detect_project_type()
groups = run_all_checks(ptype, fast=fast)
print(format_output(ptype, groups))
if strict and any(g.overall() == CheckStatus.FAIL for g in groups):
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()