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>
This commit is contained in:
parent
cc2c896290
commit
42c8e2d31e
7 changed files with 1584 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
730
.opencode/scripts/project-status.py
Normal file
730
.opencode/scripts/project-status.py
Normal file
|
|
@ -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/<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()
|
||||
29
.opencode/tools/project-status.ts
Normal file
29
.opencode/tools/project-status.ts
Normal file
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 || "") }
|
||||
|
|
|
|||
636
tests/test_project_status.py
Normal file
636
tests/test_project_status.py
Normal file
|
|
@ -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"<!-- {d} -->\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
|
||||
162
tests/test_project_status_tool.py
Normal file
162
tests/test_project_status_tool.py
Normal file
|
|
@ -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}"
|
||||
Loading…
Add table
Reference in a new issue