From 42c8e2d31e3ee779c812d79894e0464f8e0f51ed Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:31:42 +0300 Subject: [PATCH] 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 --- .opencode/opencode.json | 5 + .opencode/scripts/project-status.py | 730 ++++++++++++++++++++++++++++ .opencode/tools/project-status.ts | 29 ++ pyproject.toml | 11 +- tests/_ts_loader.mjs | 12 + tests/test_project_status.py | 636 ++++++++++++++++++++++++ tests/test_project_status_tool.py | 162 ++++++ 7 files changed, 1584 insertions(+), 1 deletion(-) create mode 100644 .opencode/scripts/project-status.py create mode 100644 .opencode/tools/project-status.ts create mode 100644 tests/test_project_status.py create mode 100644 tests/test_project_status_tool.py diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 2089c70..e82c49a 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -236,6 +236,11 @@ "python3 */spec-status.py*": "deny", "python *spec-status.py*": "deny", "python */spec-status.py*": "deny", + "python3 *project-status.py*": "deny", + "python3 .opencode/scripts/project-status.py*": "deny", + "python3 */project-status.py*": "deny", + "python *project-status.py*": "deny", + "python */project-status.py*": "deny", "mkdir*": "allow", "git branch -D *": "ask", "git branch -d *": "ask", diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py new file mode 100644 index 0000000..e61f4fb --- /dev/null +++ b/.opencode/scripts/project-status.py @@ -0,0 +1,730 @@ +#!/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// — 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// 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//", "пакет найден") + return CheckResult(CheckStatus.FAIL, "src//", "пакет не найден") + + +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"" 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//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() diff --git a/.opencode/tools/project-status.ts b/.opencode/tools/project-status.ts new file mode 100644 index 0000000..e81f257 --- /dev/null +++ b/.opencode/tools/project-status.ts @@ -0,0 +1,29 @@ +import { spawnSync } from "child_process" +import path from "path" +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: + "Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 7 check groups: Структура, Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking). Non-blocking default (exit 0); pass check=true for strict (exit 1 on FAIL); pass fast=true to skip slow/remote checks (branch protection).", + args: { + check: tool.schema.boolean().optional().describe("If true, strict mode — exit 1 on any FAIL"), + fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"), + }, + async execute(args, context) { + const script = path.join(import.meta.dir, "..", "scripts", "project-status.py") + const cmdArgs: string[] = [] + if (args.check) cmdArgs.push("--check") + if (args.fast) cmdArgs.push("--fast") + const r = spawnSync("python3", [script, ...cmdArgs], { + encoding: "utf-8", + cwd: context.worktree, + }) + if (r.status === null) { + return `⚠️ project_status failed (no exit): ${r.stderr || r.stdout}` + } + if (r.status !== 0 && !args.check) { + return `⚠️ project_status failed (exit ${r.status}): ${r.stderr}` + } + return r.stdout.trim() + }, +}) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a53006b..e1c6fe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ max-returns = 5 max-statements = 50 [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607"] +"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"] ".opencode/scripts/*" = ["S603", "S607"] # ── mypy ────────────────────────────────────────────────────────────────── @@ -129,3 +129,12 @@ exclude_lines = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] + +# ── project-status ───────────────────────────────────────────────────────── +# Thresholds for .opencode/scripts/project-status.py (read-only architecture oracle). +# Override defaults here; missing keys fall back to in-script DEFAULT_CONFIG. + +[tool.project-status] +route_line_limit = 50 +min_test_count = 1 +require_branch_protection = false diff --git a/tests/_ts_loader.mjs b/tests/_ts_loader.mjs index b780f8b..ba81c39 100644 --- a/tests/_ts_loader.mjs +++ b/tests/_ts_loader.mjs @@ -210,10 +210,22 @@ function buildExecArgs(tool, rawValue) { // Interpret ``rawValue`` (argv string) as the tool's first declared arg. // - pipeline-status.ts: ``pr_number`` (int) → parseInt // - spec-status.ts: ``validate`` (bool) → /true/i match + // - project-status.ts: ``check`` (bool) + ``fast`` (bool) — multi-arg + // boolean tool. ``rawValue`` encodes both as "check|fast" (e.g. "true|false"). + // Detected dynamically: if the tool declares ``check`` AND ``fast``, split + // the raw value on ``|`` and map each to a boolean. // Detection is dynamic so the harness works for any single-arg tool // without hardcoding tool names. If the tool declares no args, return {}. const keys = Object.keys(tool.args || {}) if (keys.length === 0) return {} + // Multi-arg boolean tool (project-status.ts: check + fast). + if (keys.includes("check") && keys.includes("fast")) { + const parts = String(rawValue || "").split("|") + return { + check: /^true$/i.test(parts[0] || ""), + fast: /^true$/i.test(parts[1] || ""), + } + } const first = keys[0] if (first === "pr_number") return { pr_number: parseInt(rawValue, 10) } if (first === "validate") return { validate: /^true$/i.test(rawValue || "") } diff --git a/tests/test_project_status.py b/tests/test_project_status.py new file mode 100644 index 0000000..2a402e3 --- /dev/null +++ b/tests/test_project_status.py @@ -0,0 +1,636 @@ +"""Tests for .opencode/scripts/project-status.py — architecture oracle. + +All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` +helper. Filesystem checks use ``tmp_path`` with ``monkeypatch`` on +``ps.REPO_ROOT`` so each test sees an isolated repo. + +Covers: auto-detect (5 types + unknown), 7 check groups, format output +([OK]/[WARN]/[FAIL] + Итог + Рекомендации), exit codes (non-blocking vs +``--check`` strict), ``--fast`` skip of branch protection. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py" +spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH) +ps = importlib.util.module_from_spec(spec) +sys.modules["project_status"] = ps +spec.loader.exec_module(ps) + + +GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = ( + ("git", "remote"), + (0, "https://github.com/slaid098/opencode-config.git\n", ""), +) + + +@pytest.fixture(autouse=True) +def _isolate_repo(monkeypatch, tmp_path): + """Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test.""" + monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) + monkeypatch.setattr(ps, "CONFIG", dict(ps.DEFAULT_CONFIG)) + + +def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]): + """Factory: mock run_cmd matching by command prefix. + + Automatically includes a ``git remote get-url origin`` mock so + ``get_repo_full_name()`` works without extra boilerplate. + """ + merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses} + + def _mock(args: list[str]) -> tuple[int, str, str]: + for prefix, result in merged.items(): + if tuple(args[: len(prefix)]) == tuple(prefix): + return result + return (1, "", f"unmocked call: {args}") + + return _mock + + +def _tool_sections( + has_ruff: bool, + has_mypy: bool, + has_pytest: bool, + cov_source: list | None, + cov_fail: str | None, +) -> list[str]: + """Build the [tool.*] section lines for pyproject.toml.""" + lines: list[str] = [] + if has_ruff: + lines.extend(["[tool.ruff]", 'target-version = "py312"', ""]) + if has_mypy: + lines.extend(["[tool.mypy]", 'python_version = "3.12"', ""]) + if has_pytest: + lines.append("[tool.pytest.ini_options]") + addopts = "--cov=src --cov-report=term-missing --timeout=120" + if cov_fail: + addopts += f" --cov-fail-under={cov_fail}" + lines.append(f'addopts = "{addopts}"') + lines.extend(['testpaths = ["tests"]', ""]) + if cov_source is not None: + lines.append("[tool.coverage.run]") + source_str = ", ".join(f'"{s}"' for s in cov_source) + lines.append(f"source = [{source_str}]") + lines.append("") + return lines + + +def _write_pyproject( + tmp_path: Path, + *, + deps: list[str] | None = None, + scripts: dict | None = None, + has_ruff: bool = True, + has_mypy: bool = True, + has_pytest: bool = True, + cov_source: list | None = None, + cov_fail: str | None = None, +) -> None: + """Write a minimal pyproject.toml with the requested [tool.*] sections.""" + lines = [ + "[build-system]", + 'requires = ["hatchling"]', + 'build-backend = "hatchling.build"', + "", + "[project]", + 'name = "test-repo"', + 'version = "0.1.0"', + "", + "dependencies = [", + ] + for d in deps or []: + lines.append(f' "{d}",') + lines.extend(["]", "", "[project.optional-dependencies]", "dev = ["]) + if has_pytest: + lines.append(' "pytest>=8.0",') + if has_mypy: + lines.append(' "mypy>=1.10",') + if has_ruff: + lines.append(' "ruff>=0.5",') + lines.extend(["]", ""]) + if scripts: + lines.append("[project.scripts]") + for k, v in scripts.items(): + lines.append(f'{k} = "{v}"') + lines.append("") + lines.extend(_tool_sections(has_ruff, has_mypy, has_pytest, cov_source, cov_fail)) + (tmp_path / "pyproject.toml").write_text("\n".join(lines)) + + +def _make_backend_repo(tmp_path: Path) -> None: + """Create a minimal backend-type repo skeleton in tmp_path.""" + for rel in [ + "src/api/v1", + "src/db/models", + "src/schemas", + "src/services", + "src/config", + "tests", + ]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (tmp_path / "main.py").write_text( + "from contextlib import asynccontextmanager\n" + "@asynccontextmanager\nasync def lifespan(app): yield\n" + ) + (tmp_path / "src/api/v1/users.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"], cov_fail="80") + (tmp_path / "tests/conftest.py").write_text("import pytest\n") + (tmp_path / "tests/test_users.py").write_text("def test_ok(): assert True\n") + + +# ── parse_remote_url ───────────────────────────────────────────────────────── + + +def test_parse_remote_url_https(): + assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_ssh(): + assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == ( + "github.com", + "slaid098", + "opencode-config", + ) + + +def test_parse_remote_url_invalid(): + with pytest.raises(ValueError, match="Cannot parse remote URL"): + ps.parse_remote_url("not-a-valid-url") + + +# ── get_repo_full_name ─────────────────────────────────────────────────────── + + +def test_get_repo_full_name_ok(monkeypatch): + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({})) + assert ps.get_repo_full_name() == "slaid098/opencode-config" + + +def test_get_repo_full_name_no_remote(monkeypatch): + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "no remote")})) + assert ps.get_repo_full_name() is None + + +def test_get_repo_full_name_bad_url(monkeypatch): + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (0, "not-a-url\n", "")})) + assert ps.get_repo_full_name() is None + + +# ── load_config ────────────────────────────────────────────────────────────── + + +def test_load_config_defaults_when_no_pyproject(tmp_path): + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) + cfg = ps.load_config() + assert cfg["route_line_limit"] == 50 + assert cfg["min_test_count"] == 1 + + +def test_load_config_reads_section(tmp_path): + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text( + "[tool.project-status]\nroute_line_limit = 80\nmin_test_count = 3\n" + ) + cfg = ps.load_config() + assert cfg["route_line_limit"] == 80 + assert cfg["min_test_count"] == 3 + assert cfg["require_branch_protection"] is False + + +def test_load_config_bad_toml_returns_defaults(tmp_path): + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(ps, "REPO_ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text("not valid toml = = =") + cfg = ps.load_config() + assert cfg["route_line_limit"] == 50 + + +# ── detect_project_type ────────────────────────────────────────────────────── + + +def test_detect_fullstack(tmp_path): + (tmp_path / "backend").mkdir() + (tmp_path / "frontend").mkdir() + assert ps.detect_project_type() == ps.ProjectType.FULLSTACK + + +def test_detect_backend(tmp_path): + _make_backend_repo(tmp_path) + assert ps.detect_project_type() == ps.ProjectType.BACKEND + + +def test_detect_bot_via_file(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src/bot.py").write_text("from aiogram import Dispatcher\n") + _write_pyproject(tmp_path, deps=[]) + assert ps.detect_project_type() == ps.ProjectType.BOT + + +def test_detect_bot_via_dep(tmp_path): + _write_pyproject(tmp_path, deps=["aiogram"]) + assert ps.detect_project_type() == ps.ProjectType.BOT + + +def test_detect_worker_via_file(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src/flow.py").write_text("from prefect import flow\n") + _write_pyproject(tmp_path, deps=[]) + assert ps.detect_project_type() == ps.ProjectType.WORKER + + +def test_detect_worker_via_dep(tmp_path): + _write_pyproject(tmp_path, deps=["prefect"]) + assert ps.detect_project_type() == ps.ProjectType.WORKER + + +def test_detect_cli(tmp_path): + (tmp_path / "src").mkdir() + pkg = tmp_path / "src" / "mycli" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}) + assert ps.detect_project_type() == ps.ProjectType.CLI + + +def test_detect_unknown_empty_repo(tmp_path): + _write_pyproject(tmp_path, deps=[]) + assert ps.detect_project_type() == ps.ProjectType.UNKNOWN + + +# ── check_structure ────────────────────────────────────────────────────────── + + +def test_check_structure_backend_ok(tmp_path): + _make_backend_repo(tmp_path) + group = ps.check_structure(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + assert all(c.status == ps.CheckStatus.OK for c in group.checks) + + +def test_check_structure_backend_missing_dir(tmp_path): + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) + (tmp_path / "main.py").write_text( + "from contextlib import asynccontextmanager\n" + "@asynccontextmanager\nasync def lifespan(app): yield\n" + ) + group = ps.check_structure(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.FAIL + assert any(c.name == "src/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +def test_check_structure_backend_no_lifespan(tmp_path): + for rel in [ + "src/api/v1", + "src/db/models", + "src/schemas", + "src/services", + "src/config", + "tests", + ]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (tmp_path / "main.py").write_text("app = None\n") + group = ps.check_structure(ps.ProjectType.BACKEND) + assert any( + c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks + ) + + +def test_check_structure_unknown_warn(tmp_path): + group = ps.check_structure(ps.ProjectType.UNKNOWN) + assert group.overall() == ps.CheckStatus.WARN + + +def test_check_structure_cli_with_package(tmp_path): + (tmp_path / "src").mkdir() + pkg = tmp_path / "src" / "mycli" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + group = ps.check_structure(ps.ProjectType.CLI) + assert any(c.status == ps.CheckStatus.OK and "package" in c.name for c in group.checks) + + +def test_check_structure_cli_no_package(tmp_path): + (tmp_path / "src").mkdir() + group = ps.check_structure(ps.ProjectType.CLI) + assert any(c.status == ps.CheckStatus.FAIL for c in group.checks) + + +# ── check_thin_routes ──────────────────────────────────────────────────────── + + +def test_thin_routes_ok(tmp_path): + _make_backend_repo(tmp_path) + group = ps.check_thin_routes(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + + +def test_thin_routes_over_limit(tmp_path): + for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (tmp_path / "main.py").write_text("app = None\n") + body = "\n x = 1\n" * 60 + (tmp_path / "src/api/v1/users.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/users')\nasync def list_users():" + f"{body} return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.FAIL + assert any("превышение" in c.detail for c in group.checks) + + +def test_thin_routes_not_applicable_for_cli(tmp_path): + group = ps.check_thin_routes(ps.ProjectType.CLI) + assert group.overall() == ps.CheckStatus.OK + + +def test_thin_routes_no_api_dir(tmp_path): + group = ps.check_thin_routes(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.WARN + + +# ── check_quality ──────────────────────────────────────────────────────────── + + +def test_quality_ok(tmp_path): + _make_backend_repo(tmp_path) + group = ps.check_quality(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + + +def test_quality_missing_mypy(tmp_path): + _write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False) + group = ps.check_quality(ps.ProjectType.BACKEND) + assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +def test_quality_missing_ruff(tmp_path): + _write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False) + group = ps.check_quality(ps.ProjectType.BACKEND) + assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +# ── check_tests ────────────────────────────────────────────────────────────── + + +def test_tests_ok(tmp_path): + _make_backend_repo(tmp_path) + group = ps.check_tests(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + + +def test_tests_no_dir(tmp_path): + group = ps.check_tests(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.FAIL + assert any("tests/" in c.name for c in group.checks) + + +def test_tests_no_conftest(tmp_path): + (tmp_path / "tests").mkdir() + (tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n") + group = ps.check_tests(ps.ProjectType.BACKEND) + assert any(c.name == "conftest.py" and c.status == ps.CheckStatus.WARN for c in group.checks) + + +def test_tests_asyncio_mark_warns(tmp_path): + (tmp_path / "tests").mkdir() + (tmp_path / "tests/test_x.py").write_text( + "import pytest\n@pytest.mark.asyncio\nasync def test_x(): assert True\n" + ) + group = ps.check_tests(ps.ProjectType.BACKEND) + assert any( + c.name == "no @pytest.mark.asyncio" and c.status == ps.CheckStatus.WARN + for c in group.checks + ) + + +# ── check_readme ───────────────────────────────────────────────────────────── + + +def _write_valid_readme(tmp_path: Path) -> None: + content = ( + "# 🚀 Title\n[English](#-english) [Русский](#-русский)\n" + "## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n" + ) + for d in ps.README_DELIMITERS: + content += f"\n" + (tmp_path / "README.md").write_text(content) + + +def test_readme_ok(tmp_path): + _write_valid_readme(tmp_path) + group = ps.check_readme(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + + +def test_readme_missing(tmp_path): + group = ps.check_readme(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.FAIL + + +def test_readme_missing_delimiters(tmp_path): + (tmp_path / "README.md").write_text( + "# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n" + ) + group = ps.check_readme(ps.ProjectType.BACKEND) + assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +# ── check_infra ────────────────────────────────────────────────────────────── + + +def test_infra_ok_with_branch_protection(monkeypatch, tmp_path): + for rel in [".github/workflows", ".github"]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") + (tmp_path / ".github/dependabot.yml").write_text("version: 2\n") + (tmp_path / "LICENSE").write_text("MIT\n") + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "api"): ( + 0, + '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', + "", + ), + } + ), + ) + group = ps.check_infra(ps.ProjectType.BACKEND, fast=False) + assert group.overall() == ps.CheckStatus.OK + + +def test_infra_missing_ci_fail(tmp_path): + (tmp_path / "LICENSE").write_text("MIT\n") + group = ps.check_infra(ps.ProjectType.BACKEND, fast=True) + assert any( + c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL + for c in group.checks + ) + + +def test_infra_fast_skips_branch_protection(tmp_path): + (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") + (tmp_path / "LICENSE").write_text("MIT\n") + group = ps.check_infra(ps.ProjectType.BACKEND, fast=True) + assert any( + c.name == "branch protection" and c.status == ps.CheckStatus.WARN and "--fast" in c.detail + for c in group.checks + ) + + +def test_infra_branch_protection_gh_error(monkeypatch, tmp_path): + (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") + (tmp_path / "LICENSE").write_text("MIT\n") + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "not found")})) + group = ps.check_infra(ps.ProjectType.BACKEND, fast=False) + assert any( + c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks + ) + + +# ── check_coverage ─────────────────────────────────────────────────────────── + + +def test_coverage_non_blocking(tmp_path): + _write_pyproject(tmp_path, deps=["fastapi"], cov_source=["src"], cov_fail="80") + group = ps.check_coverage(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.OK + assert all(c.status != ps.CheckStatus.FAIL for c in group.checks) + + +def test_coverage_no_config(tmp_path): + _write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False) + group = ps.check_coverage(ps.ProjectType.BACKEND) + assert group.overall() == ps.CheckStatus.WARN + assert all(c.status != ps.CheckStatus.FAIL for c in group.checks) + + +# ── format_output ──────────────────────────────────────────────────────────── + + +def test_format_output_has_summary_and_recommendations(): + ptype = ps.ProjectType.BACKEND + groups = [ + ps.GroupResult( + name="Структура", + checks=[ + ps.CheckResult(ps.CheckStatus.OK, "src/api/v1", "ok"), + ps.CheckResult(ps.CheckStatus.FAIL, "main.py", "missing"), + ], + ), + ps.GroupResult(name="Coverage", checks=[ps.CheckResult(ps.CheckStatus.OK, "cov", "ok")]), + ] + out = ps.format_output(ptype, groups) + assert "Project: backend" in out + assert "[OK] Структура" not in out + assert "[FAIL] Структура" in out + assert "Итог:" in out + assert "Рекомендации:" in out + assert "main.py" in out + + +def test_format_output_no_recommendations_when_all_ok(): + groups = [ + ps.GroupResult(name="Структура", checks=[ps.CheckResult(ps.CheckStatus.OK, "x", "ok")]), + ] + out = ps.format_output(ps.ProjectType.BACKEND, groups) + assert "Рекомендации:" not in out + assert "FAIL: 0" in out + + +# ── main / exit codes ──────────────────────────────────────────────────────── + + +def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys): + _make_backend_repo(tmp_path) + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")})) + monkeypatch.setattr("sys.argv", ["project-status.py"]) + with pytest.raises(SystemExit) as exc: + ps.main() + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "Project:" in captured.out + assert "Итог:" in captured.out + + +def test_main_check_strict_exit_1_on_fail(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")})) + monkeypatch.setattr("sys.argv", ["project-status.py", "--check"]) + with pytest.raises(SystemExit) as exc: + ps.main() + assert exc.value.code == 1 + + +def test_main_check_strict_exit_0_when_all_ok(monkeypatch, tmp_path, capsys): + _make_backend_repo(tmp_path) + for rel in [".github/workflows", ".github"]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n") + (tmp_path / ".github/dependabot.yml").write_text("version: 2\n") + (tmp_path / "LICENSE").write_text("MIT\n") + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + _write_valid_readme(tmp_path) + monkeypatch.setattr( + ps, + "run_cmd", + mock_run_cmd( + { + ("gh", "api"): ( + 0, + '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', + "", + ), + } + ), + ) + monkeypatch.setattr("sys.argv", ["project-status.py", "--check"]) + with pytest.raises(SystemExit) as exc: + ps.main() + captured = capsys.readouterr() + assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}\n{captured.out}" + + +def test_main_fast_flag(monkeypatch, tmp_path, capsys): + _make_backend_repo(tmp_path) + monkeypatch.setattr( + ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "should not be called")}) + ) + monkeypatch.setattr("sys.argv", ["project-status.py", "--fast"]) + with pytest.raises(SystemExit) as exc: + ps.main() + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "--fast" in captured.out + + +# ── run_all_checks integration ─────────────────────────────────────────────── + + +def test_run_all_checks_returns_7_groups(tmp_path): + _make_backend_repo(tmp_path) + groups = ps.run_all_checks(ps.ProjectType.BACKEND, fast=True) + assert len(groups) == 7 + assert [g.name for g in groups] == ps.CHECK_GROUPS diff --git a/tests/test_project_status_tool.py b/tests/test_project_status_tool.py new file mode 100644 index 0000000..0c7401a --- /dev/null +++ b/tests/test_project_status_tool.py @@ -0,0 +1,162 @@ +"""Tests for .opencode/tools/project-status.ts — the project_status custom tool. + +Mirrors ``tests/test_spec_status_tool.py``: exercises the tool's +``execute()`` function via ``tests/_ts_loader.mjs`` (a node CommonJS +sandbox that strips TS-only syntax, stubs ``@opencode-ai/plugin``, and +replaces ``import.meta.dir`` with the real ``.opencode/tools`` directory). + +The loader is parameterized via the ``TS_FILE`` env var. These tests set +``TS_FILE=.opencode/tools/project-status.ts``. ``buildExecArgs`` was +extended in ``_ts_loader.mjs`` to support the multi-arg boolean tool +(``check`` + ``fast``): the raw value is split on ``|`` and each part is +mapped to a boolean via ``/^true$/i``. + +Modes used: +- ``load`` — sanity-check that the tool loads and has ``check`` + ``fast`` args. +- ``exec_stub`` — call execute with a stubbed spawnSync to verify: + (a) ``--check`` / ``--fast`` flags added to argv correctly, + (b) stdout is trimmed on success, + (c) non-zero exit without ``check`` returns an actionable error message, + (d) non-zero exit WITH ``check`` returns the trimmed stdout (strict mode), + (e) ``cwd`` is propagated from ``context.worktree`` (ADR-023). +- ``exec_real`` — call execute against the real project-status.py + (integration test, non-blocking so always exit 0 in this repo). +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" +TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts" +TS_FILE_REL = ".opencode/tools/project-status.ts" + + +def _run_loader(*args: str, stdin: str | None = None) -> dict: + """Invoke the loader with ``TS_FILE`` env set to project-status.ts and parse JSON stdout.""" + env = {**os.environ, "TS_FILE": TS_FILE_REL} + proc = subprocess.run( + ["node", str(LOADER), *args], + capture_output=True, + text=True, + check=False, + cwd=str(REPO_ROOT), + input=stdin, + timeout=60, + env=env, + ) + if proc.returncode != 0: + raise RuntimeError( + f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" + f"stdout: {proc.stdout}\nstderr: {proc.stderr}" + ) + return json.loads(proc.stdout) + + +def test_loader_can_load_tool(): + """Sanity: project-status.ts loads and has the ``check`` + ``fast`` args (optional).""" + if not TS_FILE.exists(): + pytest.skip("project-status.ts not present") + out = _run_loader("load") + assert "description" in out + args = out["args"] + assert "check" in args, f"expected 'check' arg, got: {args}" + assert "fast" in args, f"expected 'fast' arg, got: {args}" + + +def test_execute_no_flags(): + """execute() with no flags calls spawnSync WITHOUT ``--check`` / ``--fast``. + + rawValue="false|false" → both check and fast are false → cmdArgs = []. + """ + out = _run_loader("exec_stub", "false|false", "0", "project output", "") + calls = out["calls"] + assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}" + call = calls[0] + assert call["cmd"] == "python3" + assert call["args"][0] == str(REPO_ROOT / ".opencode" / "scripts" / "project-status.py") + assert "--check" not in call["args"], f"unexpected --check: {call['args']}" + assert "--fast" not in call["args"], f"unexpected --fast: {call['args']}" + + +def test_execute_passes_check_flag(): + """execute() with ``check: true`` adds ``--check`` to spawnSync argv.""" + out = _run_loader("exec_stub", "true|false", "0", "strict output", "") + calls = out["calls"] + assert len(calls) == 1 + call = calls[0] + assert "--check" in call["args"], f"expected --check in args: {call['args']}" + assert "--fast" not in call["args"], f"unexpected --fast: {call['args']}" + + +def test_execute_passes_fast_flag(): + """execute() with ``fast: true`` adds ``--fast`` to spawnSync argv.""" + out = _run_loader("exec_stub", "false|true", "0", "fast output", "") + calls = out["calls"] + assert len(calls) == 1 + call = calls[0] + assert "--fast" in call["args"], f"expected --fast in args: {call['args']}" + assert "--check" not in call["args"], f"unexpected --check: {call['args']}" + + +def test_execute_passes_both_flags(): + """execute() with ``check: true`` + ``fast: true`` adds both flags.""" + out = _run_loader("exec_stub", "true|true", "0", "both output", "") + calls = out["calls"] + assert len(calls) == 1 + call = calls[0] + assert "--check" in call["args"] + assert "--fast" in call["args"] + + +def test_execute_trims_stdout(): + """execute trims leading/trailing whitespace from the script stdout.""" + raw_stdout = " trimmed-output \n" + out = _run_loader("exec_stub", "false|false", "0", raw_stdout, "") + result = out["result"] + assert result == "trimmed-output", f"expected trimmed output, got: {result!r}" + + +def test_execute_nonzero_exit_without_check_returns_error(): + """execute returns an actionable error message on non-zero exit WITHOUT --check.""" + out = _run_loader("exec_stub", "false|false", "1", "", "some stderr from project-status") + result = out["result"] + assert "project_status failed" in result + assert "exit 1" in result + assert "some stderr from project-status" in result + + +def test_execute_nonzero_exit_with_check_returns_stdout(): + """execute with ``check: true`` returns stdout even on non-zero exit (strict).""" + out = _run_loader("exec_stub", "true|false", "1", "FAIL report here", "ignored stderr") + result = out["result"] + assert result == "FAIL report here", f"strict mode should return stdout, got: {result!r}" + + +def test_execute_uses_cwd_from_context(): + """execute passes ``cwd=context.worktree`` to spawnSync (ADR-023).""" + out = _run_loader("exec_stub", "false|false", "0", "ok", "") + calls = out["calls"] + assert len(calls) == 1 + opts = calls[0]["opts"] + assert opts is not None, "spawnSync called without opts — expected cwd kwarg" + assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}" + assert opts["cwd"] == str(REPO_ROOT), ( + f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}" + ) + + +def test_execute_real_project_status(): + """Integration: execute() returns the real project-status.py output (non-blocking).""" + if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists(): + pytest.skip("project-status.py not present") + out = _run_loader("exec_real", "false|false") + if out.get("error"): + pytest.fail(f"execute raised: {out['error']}") + result = out["result"] + assert "Project:" in result, f"expected 'Project:' in output, got: {result[:200]!r}" + assert "Итог:" in result, f"expected 'Итог:' in output, got: {result[:200]!r}"