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

* feat(infra): project-status ts wrapper

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

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

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

---------

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

636 lines
23 KiB
Python

"""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