diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index b94f7a3..8969e2e 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -510,6 +510,9 @@ def _api_dirs_for(ptype: ProjectType, ctx: RepoCtx) -> list[Path]: return [] +FORBIDDEN_ROUTE_IMPORTS: tuple[str, ...] = ("src.db.models", "tortoise") + + def _scan_route_files(api_dirs: list[Path], limit: int, ctx: RepoCtx) -> tuple[int, int, list[str]]: """Scan api dirs for route handlers; return (files_checked, longest, over_limit).""" files_checked = 0 @@ -528,8 +531,47 @@ def _scan_route_files(api_dirs: list[Path], limit: int, ctx: RepoCtx) -> tuple[i return files_checked, longest, over_limit +def _check_route_imports(route_file: Path, ctx: RepoCtx) -> CheckResult | None: + """AST-чек: routes не должны импортировать ``src.db.models`` или ``tortoise``. + + Routes должны идти через services/schemas, а не тянуть модели БД напрямую + (digital_factory anti-pattern: route bypasses the service layer). + + Returns ``None`` if no forbidden import found (OK), or a ``CheckResult`` + with ``FAIL`` status naming the offending module. ``ast`` is used so the + check is robust to comments/strings mentioning those names. + """ + try: + tree = ast.parse(route_file.read_text(encoding="utf-8-sig", errors="ignore")) + except SyntaxError: + return None + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + module = node.module or "" + if any(module == f or module.startswith(f + ".") for f in FORBIDDEN_ROUTE_IMPORTS): + rel = route_file.relative_to(ctx.root) + return CheckResult( + CheckStatus.FAIL, + str(rel), + f"импортирует {module} — роут должен идти через services", + ) + return None + + +def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult]: + """Scan api dirs for forbidden route imports (``src.db.models``/``tortoise``).""" + bad: list[CheckResult] = [] + for api_dir in api_dirs: + for py in api_dir.rglob("*.py"): + res = _check_route_imports(py, ctx) + if res is not None: + bad.append(res) + return bad + + def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult: - """Group 2: Тонкие роуты — AST parse, ≤ route_line_limit lines per handler.""" + """Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN).""" _ = fast # unused here, accepted for signature uniformity group = GroupResult(name="Тонкие роуты") if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}: @@ -543,6 +585,22 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена") ) return group + _append_route_checks(group, api_dirs, ctx) + return group + + +def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx) -> None: + """Append import + line-count checks to ``group`` (split for ≤50 lines).""" + bad_imports = _scan_route_imports(api_dirs, ctx) + if bad_imports: + detail = ", ".join(f"{c.name}: {c.detail}" for c in bad_imports[:3]) + group.checks.append( + CheckResult(CheckStatus.FAIL, "route imports", f"запрещённые: {detail}") + ) + else: + group.checks.append( + CheckResult(CheckStatus.OK, "route imports", "роуты не импортируют модели БД") + ) limit = int(ctx.config.get("route_line_limit", 50)) files_checked, longest, over_limit = _scan_route_files(api_dirs, limit, ctx) if files_checked == 0: @@ -550,7 +608,7 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G elif over_limit: group.checks.append( CheckResult( - CheckStatus.FAIL, + CheckStatus.WARN, f"route ≤ {limit} lines", f"превышение: {', '.join(over_limit[:3])}", ) @@ -561,7 +619,6 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G CheckStatus.OK, f"route ≤ {limit} lines", f"макс={longest}, файлов={files_checked}" ) ) - return group # ── check group 3: code quality (mypy/ruff/pytest presence) ────────────────── @@ -592,8 +649,110 @@ def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: # ── check group 4: tests (conftest, stub-detector, no @pytest.mark.asyncio) ── +def _has_test_func(source: str) -> bool: + """True if source declares ``def test_*`` or ``async def test_*``. + + Used by the stub-detector: ``test_*.py`` files without any test function + are stub files (digital_factory anti-pattern: test file that asserts + nothing). + """ + try: + tree = ast.parse(source) + except SyntaxError: + return True + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name.startswith("test"): + return True + return False + + +def _check_test_structure(ptype: ProjectType, tests_dir: Path, group: GroupResult) -> None: + """Append backend-specific ``tests/unit/`` + ``tests/api/`` + integration checks. + + Backend projects are expected to have a layered test layout mirroring the + source layering (unit tests for services, api tests for routes, integration + tests for cross-cutting flows). CLI/BOT/WORKER have no api layer so the + ``tests/api/`` check is skipped for them. + """ + if ptype != ProjectType.BACKEND: + return + if not (tests_dir / "unit").exists(): + group.checks.append( + CheckResult( + CheckStatus.WARN, "tests/unit/", "отсутствует (рекомендуется для unit-тестов)" + ) + ) + else: + group.checks.append(CheckResult(CheckStatus.OK, "tests/unit/", "существует")) + if not (tests_dir / "api").exists(): + group.checks.append( + CheckResult( + CheckStatus.WARN, "tests/api/", "отсутствует (рекомендуется для route-тестов)" + ) + ) + else: + group.checks.append(CheckResult(CheckStatus.OK, "tests/api/", "существует")) + _check_integration_dir(tests_dir, group) + + +def _check_integration_dir(tests_dir: Path, group: GroupResult) -> None: + """Append checks for ``tests/integration/`` (presence + pytestmark).""" + integration_dir = tests_dir / "integration" + if not integration_dir.exists(): + return + test_files = list(integration_dir.glob("test_*.py")) + if not test_files: + group.checks.append( + CheckResult(CheckStatus.WARN, "tests/integration/", "директория пустая") + ) + return + without_mark = [ + f.name + for f in test_files + if "pytest.mark.integration" not in f.read_text(encoding="utf-8-sig") + ] + if without_mark: + group.checks.append( + CheckResult( + CheckStatus.WARN, + "tests/integration/ pytestmark", + f"без pytest.mark.integration: {', '.join(without_mark[:3])}", + ) + ) + else: + group.checks.append( + CheckResult( + CheckStatus.OK, "tests/integration/ pytestmark", "все файлы имеют pytestmark" + ) + ) + + +def _check_stub_files(test_files: list[Path]) -> CheckResult: + """Stub-detector: ``test_*.py`` without ``def test_*``/``async def test_*``. + + Replaces the previous name-based "stub" heuristic (which matched ``stub`` + in the function name) with an AST check for test functions: a file that + declares no ``def test_*``/``async def test_*`` is a stub (asserts nothing). + """ + stubs = [] + for tf in test_files: + try: + source = tf.read_text(encoding="utf-8-sig") + except OSError: + continue + if not _has_test_func(source): + stubs.append(tf.name) + if stubs: + return CheckResult( + CheckStatus.WARN, "stub-detector", f"{len(stubs)} stub-файлов: {', '.join(stubs[:3])}" + ) + return CheckResult(CheckStatus.OK, "stub-detector", "0 stub-файлов") + + def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: - """Group 4: Тесты — conftest, no @pytest.mark.asyncio, ≥1 test file.""" + """Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.""" group = GroupResult(name="Тесты") tests_dir = ctx.root / "tests" if not tests_dir.exists(): @@ -617,6 +776,7 @@ def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: group.checks.append( CheckResult(CheckStatus.FAIL, "test files", f"{len(test_files)} (< {min_tests})") ) + _check_test_structure(ptype, tests_dir, group) asyncio_marks = 0 for tf in test_files: try: @@ -636,31 +796,66 @@ def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: 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-тестов", - ) - ) + group.checks.append(_check_stub_files(test_files)) return group # ── check group 5: README (12 delimiter tags) ──────────────────────────────── +def _readme_required_sections(content: str) -> list[CheckResult]: + """Check required README sections ported from ``validateReadme`` (create-readme.ts). + + Each missing section → FAIL. Covers: RU switcher link, support link, + Quick Start (EN), Быстрый старт (RU), and a manual ``## License`` section + (duplicates the GitHub sidebar). + """ + results: list[CheckResult] = [] + if "[Русский](#-русский)" not in content: + results.append( + CheckResult( + CheckStatus.FAIL, + "[Русский](#-русский)", + "отсутствует RU switcher (должен быть #-русский)", + ) + ) + else: + results.append(CheckResult(CheckStatus.OK, "[Русский](#-русский)", "присутствует")) + if "slaid098.dev/support" not in content: + results.append( + CheckResult(CheckStatus.FAIL, "slaid098.dev/support", "отсутствует support link") + ) + else: + results.append(CheckResult(CheckStatus.OK, "slaid098.dev/support", "присутствует")) + if "Quick Start" not in content: + results.append( + CheckResult(CheckStatus.FAIL, "Quick Start", "отсутствует EN секция Quick Start") + ) + else: + results.append(CheckResult(CheckStatus.OK, "Quick Start", "присутствует")) + if "Быстрый старт" not in content: + results.append( + CheckResult(CheckStatus.FAIL, "Быстрый старт", "отсутствует RU секция Быстрый старт") + ) + else: + results.append(CheckResult(CheckStatus.OK, "Быстрый старт", "присутствует")) + if re.search(r"^##\s+(License|LICENSE|Лицензия)\s*$", content, re.MULTILINE): + results.append( + CheckResult( + CheckStatus.FAIL, + "Manual License section", + "найден — удалить (GitHub рендерит из LICENSE файла)", + ) + ) + return results + + def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: - """Group 5: README — 12 delimiter tags from create-readme.ts:140-199.""" + """Group 5: README — full port of ``validateReadme`` from create-readme.ts.""" group = GroupResult(name="README") content = read_text("README.md", ctx) if content is None: - group.checks.append(CheckResult(CheckStatus.FAIL, "README.md", "отсутствует")) + group.checks.append(CheckResult(CheckStatus.WARN, "README.md", "отсутствует")) return group missing = [d for d in README_DELIMITERS if f"" not in content] if missing: @@ -680,6 +875,7 @@ def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: group.checks.append(CheckResult(CheckStatus.OK, required_text, "присутствует")) else: group.checks.append(CheckResult(CheckStatus.FAIL, required_text, "отсутствует")) + group.checks.extend(_readme_required_sections(content)) if "assets/cover.png" in content: group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан")) else: diff --git a/.opencode/tools/create-readme.ts b/.opencode/tools/create-readme.ts index 9fcb68c..e15f0f3 100644 --- a/.opencode/tools/create-readme.ts +++ b/.opencode/tools/create-readme.ts @@ -1,5 +1,5 @@ import { spawnSync } from "child_process" -import { readFileSync, writeFileSync } from "fs" +import { writeFileSync } from "fs" import path from "path" import { tool } from "@opencode-ai/plugin" @@ -28,13 +28,6 @@ type CreateArgs = { quick_start_steps_ru?: string[] } -function extractBetween(text: string, start: string, end: string): string | null { - const s = text.indexOf(start) - const e = text.indexOf(end) - if (s === -1 || e === -1 || e <= s) return null - return text.substring(s + start.length, e) -} - function renderFeaturesTable(features: Feature[], isRu: boolean): string { const header = isRu ? "### Фичи\n\n| Фича | Описание |\n|------|----------|\n" @@ -137,78 +130,14 @@ ${bashBlockRu}${stepsRu}${accessLineRu}${developmentBlockRu} ` } -export function validateReadme(content: string): { ok: boolean; issues: string[] } { - const issues: string[] = [] - - const delimiters = [ - "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", - ] - for (const d of delimiters) { - if (!content.includes(``)) - issues.push(`Missing delimiter`) - } - - const pairs = [ - { start: "", end: "", label: "EN tagline" }, - { start: "", end: "", label: "RU tagline" }, - { start: "", end: "", label: "EN summary" }, - { start: "", end: "", label: "EN features" }, - { start: "", end: "", label: "RU summary" }, - { start: "", end: "", label: "RU features" }, - ] - for (const p of pairs) { - const between = extractBetween(content, p.start, p.end) - if (between !== null && !between.trim()) - issues.push(`${p.label} content between delimiters is empty`) - } - - if (!content.includes("assets/cover.png")) - issues.push("Missing cover image reference (assets/cover.png)") - - if (!content.includes("# 🚀 ")) - issues.push("Missing H1 title prefix '# 🚀 '") - if (/^##\s+(License|LICENSE|Лицензия)\s*$/m.test(content)) - issues.push("Manual License section found — remove it (GitHub renders license from LICENSE file)") - if (!content.includes("slaid098.dev/support")) - issues.push("Missing Support link (slaid098.dev/support)") - if (!content.includes("Quick Start")) - issues.push("Missing 'Quick Start' section (English)") - if (!content.includes("Быстрый старт")) - issues.push("Missing 'Быстрый старт' section (Russian)") - if (!content.includes("[English](#-english)")) - issues.push("Missing or wrong [English](#-english) switcher link (should be #-english)") - if (!content.includes("## 🇷🇺 Русский")) - issues.push("Missing '## 🇷🇺 Русский' header (should be 'Русский', not 'Русская версия')") - if (!content.includes("## 🇺🇸 English")) - issues.push("Missing '## 🇺🇸 English' header") - if (!content.includes("[Русский](#-русский)")) - issues.push("Missing or wrong [Русский](#-русский) switcher link (should be #-русский, not #-русская-версия)") - - return { ok: issues.length === 0, issues } -} - export default tool({ description: - "Create or validate README.md for slaid098 repositories. 'create' mode generates a standardized bilingual README with delimiter tags (, , , ) parsed by the slaid098.dev showcase. 'validate' mode checks an existing README against the standard, including the '## 🇷🇺 Русский' header and the [Русский](#-русский) switcher anchor. Supports local file (fs) and remote (gh api repos/{owner}/{repo}/contents/README.md) operation.", + "Create README.md for slaid098 repositories. Generates a standardized bilingual README with delimiter tags (, , , ) parsed by the slaid098.dev showcase. Includes the '## 🇷🇺 Русский' header and the [Русский](#-русский) switcher anchor. Supports local file (fs) and remote (gh api repos/{owner}/{repo}/contents/README.md) operation. README validation is handled by .opencode/scripts/project-status.py:check_readme (read-only architecture oracle).", args: { - mode: tool.schema - .enum(["create", "validate"]) - .describe("Operation mode: 'create' generates README, 'validate' checks existing README structure"), repo_name: tool.schema .string() .optional() - .describe("Repository name (e.g. 'anti-detect-mcp'). Required for create mode."), + .describe("Repository name (e.g. 'anti-detect-mcp'). Required."), tagline_en: tool.schema .string() .optional() @@ -313,16 +242,15 @@ export default tool({ const file_path = args.file_path ?? "README.md" const absPath = path.resolve(context.worktree, file_path) - if (args.mode === "create") { - const required: Record = { - repo_name: args.repo_name, - tagline_en: args.tagline_en, - tagline_ru: args.tagline_ru, - why_en: args.why_en, - what_en: args.what_en, - why_ru: args.why_ru, - what_ru: args.what_ru, - } + const required: Record = { + repo_name: args.repo_name, + tagline_en: args.tagline_en, + tagline_ru: args.tagline_ru, + why_en: args.why_en, + what_en: args.what_en, + why_ru: args.why_ru, + what_ru: args.what_ru, + } for (const [k, v] of Object.entries(required)) { if (!v) return `❌ ${k} is required for create mode` } @@ -405,27 +333,6 @@ export default tool({ writeFileSync(absPath, content, "utf-8") return `README.md created at ${file_path}` - } - - let content: string - if (args.repo) { - const getRes = spawnSync( - "gh", - ["api", `repos/${args.repo}/contents/README.md`], - { encoding: "utf-8", cwd: context.worktree }, - ) - if (getRes.status !== 0) { - return `⚠️ create-readme failed: gh api GET failed (exit ${getRes.status}): ${getRes.stderr || getRes.stdout}` - } - const data = JSON.parse(getRes.stdout) - content = Buffer.from(data.content, "base64").toString("utf-8") - } else { - content = readFileSync(absPath, "utf-8") - } - - const result = validateReadme(content) - if (result.ok) return "✅ README structure is valid" - return `❌ Validation issues:\n${result.issues.map((i) => `- ${i}`).join("\n")}` } catch (e) { return `⚠️ create-readme failed: ${e instanceof Error ? e.message : String(e)}` } diff --git a/tests/test_create_readme_tool.py b/tests/test_create_readme_tool.py index 93b3ef9..7e22264 100644 --- a/tests/test_create_readme_tool.py +++ b/tests/test_create_readme_tool.py @@ -8,7 +8,7 @@ The loader is parameterized via the ``TS_FILE`` env var. These tests set ``TS_FILE=.opencode/tools/create-readme.ts``. Modes used: -- ``load`` — sanity-check that the tool loads and declares mode, repo_name, +- ``load`` — sanity-check that the tool loads and declares repo_name, tagline_en/ru, features_en/ru, repo, file_path args. - ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: (a) local create: writes README to file_path (absolute path in tmp dir), @@ -17,8 +17,11 @@ Modes used: against the plugin process CWD, not context.worktree). (b) local create: creates a new file when none exists. (c) remote create: 2 spawnSync calls (GET sha + PUT), returns API message. - (d) local validate: reads file_path and returns validation result. - (e) validation errors: missing required args, bad repo_name, latin tagline_ru. + (d) validation errors: missing required args, bad repo_name, latin tagline_ru. + +Note: the ``validate`` mode was removed in issue #243 — README validation now +lives exclusively in ``project-status.py:check_readme`` (read-only oracle). +These tests cover only the ``create`` mode. Regression note (issue #148): the local mode previously called ``writeFileSync(file_path, ...)`` / ``readFileSync(file_path, ...)`` with a @@ -45,7 +48,6 @@ TS_FILE = REPO_ROOT / ".opencode" / "tools" / "create-readme.ts" TS_FILE_REL = ".opencode/tools/create-readme.ts" VALID_CREATE_ARGS = { - "mode": "create", "repo_name": "test-repo", "tagline_en": "One-line tagline.", "tagline_ru": "Короткий теглайн.", @@ -180,7 +182,6 @@ def test_loader_can_load_tool(): assert "description" in out args = out["args"] for key in ( - "mode", "repo_name", "tagline_en", "tagline_ru", @@ -194,6 +195,7 @@ def test_loader_can_load_tool(): "file_path", ): assert key in args, f"missing {key} arg: {args}" + assert "mode" not in args, f"mode arg should be removed (validate mode dropped in #243): {args}" def test_local_create_overwrites_existing_readme(): @@ -290,27 +292,6 @@ def test_remote_create_passes_worktree_cwd(): ) -def test_local_validate_reads_file_path(): - """Local validate reads the README from file_path and returns ok.""" - with tempfile.TemporaryDirectory() as tmp: - readme = Path(tmp) / "README.md" - readme.write_text(VALID_README, encoding="utf-8") - out = _run_exec({"mode": "validate", "file_path": str(readme)}, []) - result = out["result"] - assert "valid" in result.lower(), f"expected valid, got: {result!r}" - - -def test_local_validate_reports_missing_delimiters(): - """Local validate on a malformed README reports missing delimiter issues.""" - with tempfile.TemporaryDirectory() as tmp: - readme = Path(tmp) / "README.md" - readme.write_text("# wrong\nno delimiters here\n", encoding="utf-8") - out = _run_exec({"mode": "validate", "file_path": str(readme)}, []) - result = out["result"] - assert "Validation issues" in result, f"expected issues, got: {result!r}" - assert "Missing" in result - - def test_create_missing_required_repo_name(): """create without repo_name → error mentioning required.""" args = {**VALID_CREATE_ARGS} diff --git a/tests/test_project_status.py b/tests/test_project_status.py index e813c26..a85f595 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -193,6 +193,12 @@ def _make_backend_repo(tmp_path: Path) -> None: _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") + (tmp_path / "tests/unit").mkdir(parents=True, exist_ok=True) + (tmp_path / "tests/unit/test_user_service.py").write_text( + "def test_user_service(): assert True\n" + ) + (tmp_path / "tests/api").mkdir(parents=True, exist_ok=True) + (tmp_path / "tests/api/test_users.py").write_text("def test_users_api(): assert True\n") # Extras for check_pyproject (group 8) to pass: (tmp_path / ".python-version").write_text("3.12\n") (tmp_path / "uv.lock").write_text("# minimal lockfile stub\n") @@ -418,7 +424,7 @@ def test_thin_routes_over_limit(tmp_path, ctx): f"{body} return []\n" ) group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) - assert group.overall() == ps.CheckStatus.FAIL + assert group.overall() == ps.CheckStatus.WARN assert any("превышение" in c.detail for c in group.checks) @@ -432,6 +438,104 @@ def test_thin_routes_no_api_dir(tmp_path, ctx): assert group.overall() == ps.CheckStatus.WARN +def test_thin_routes_import_src_db_models_fail(tmp_path, ctx): + """Route импортирует ``src.db.models`` → FAIL (bypasses services).""" + _make_backend_repo(tmp_path) + (tmp_path / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom src.db.models import User\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks + ), f"expected FAIL on route imports, got: {group.checks}" + + +def test_thin_routes_import_tortoise_fail(tmp_path, ctx): + """Route импортирует ``tortoise`` → FAIL (bypasses services).""" + _make_backend_repo(tmp_path) + (tmp_path / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom tortoise import fields\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks + ), f"expected FAIL on route imports, got: {group.checks}" + + +def test_thin_routes_import_submodule_of_src_db_models_fail(tmp_path, ctx): + """``from src.db.models.user import User`` → FAIL (submodule of forbidden).""" + _make_backend_repo(tmp_path) + (tmp_path / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom src.db.models.user import User\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +def test_thin_routes_import_services_ok(tmp_path, ctx): + """Route импортирует ``from .services import user_service`` → OK.""" + _make_backend_repo(tmp_path) + (tmp_path / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom test_repo.services import user_service\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return user_service.list_all()\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), ( + f"expected no FAIL, got: {group.checks}" + ) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) + + +def test_thin_routes_import_db_connection_ok(tmp_path, ctx): + """``from .db.connection import init_db`` → OK (connection, not model).""" + _make_backend_repo(tmp_path) + (tmp_path / "src/test_repo/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom test_repo.db.connection import init_db\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():\n return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), ( + f"db.connection is allowed (not a model), got: {group.checks}" + ) + + +def test_thin_routes_import_ok_no_fail_on_over_limit(tmp_path, ctx): + """OK imports + over-limit line count → WARN only (no FAIL).""" + pkg = "test_repo" + for rel in [ + f"src/{pkg}/api/v1", + f"src/{pkg}/db/models", + f"src/{pkg}/schemas", + f"src/{pkg}/services", + f"src/{pkg}/config", + ]: + (tmp_path / rel).mkdir(parents=True, exist_ok=True) + (tmp_path / "src" / pkg / "__init__.py").write_text("") + (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n") + (tmp_path / "main.py").write_text("app = None\n") + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) + body = "\n x = 1\n" * 60 + (tmp_path / f"src/{pkg}/api/v1/users.py").write_text( + "from fastapi import APIRouter\nfrom test_repo.services import user_service\n" + "router = APIRouter()\n" + "@router.get('/users')\nasync def list_users():" + f"{body} return []\n" + ) + group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx) + assert group.overall() == ps.CheckStatus.WARN, ( + f"over-limit but no forbidden import → WARN, got {group.checks}" + ) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) + + # ── check_quality ──────────────────────────────────────────────────────────── @@ -487,6 +591,107 @@ def test_tests_asyncio_mark_warns(tmp_path, ctx): ) +def test_tests_backend_missing_unit_warn(tmp_path, ctx): + """Backend без ``tests/unit/`` → WARN (не FAIL).""" + _make_backend_repo(tmp_path) + (tmp_path / "tests/unit/test_user_service.py").unlink() + (tmp_path / "tests/unit").rmdir() + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.WARN for c in group.checks) + + +def test_tests_backend_missing_api_warn(tmp_path, ctx): + """Backend без ``tests/api/`` → WARN.""" + _make_backend_repo(tmp_path) + (tmp_path / "tests/api/test_users.py").unlink() + (tmp_path / "tests/api").rmdir() + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.WARN for c in group.checks) + + +def test_tests_backend_with_unit_api_ok(tmp_path, ctx): + """Backend с tests/unit/ + tests/api/ → OK on structure (default _make_backend_repo).""" + _make_backend_repo(tmp_path) + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.OK for c in group.checks) + assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.OK for c in group.checks) + + +def test_tests_cli_no_unit_api_ok(tmp_path, ctx): + """CLI без tests/unit/ + tests/api/ → OK (cli не имеет API layer).""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests/conftest.py").write_text("import pytest\n") + (tmp_path / "tests/test_cli.py").write_text("def test_cli(): assert True\n") + group = ps.check_tests(ps.ProjectType.CLI, ctx) + assert not any(c.name == "tests/unit/" for c in group.checks) + assert not any(c.name == "tests/api/" for c in group.checks) + assert not any(c.name == "tests/integration/ pytestmark" for c in group.checks) + + +def test_tests_integration_without_pytestmark_warn(tmp_path, ctx): + """tests/integration/test_*.py без pytestmark → WARN.""" + _make_backend_repo(tmp_path) + (tmp_path / "tests/integration").mkdir() + (tmp_path / "tests/integration/test_flow.py").write_text("def test_flow(): assert True\n") + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.WARN + for c in group.checks + ) + + +def test_tests_integration_with_pytestmark_ok(tmp_path, ctx): + """tests/integration/test_*.py с pytest.mark.integration → OK.""" + _make_backend_repo(tmp_path) + (tmp_path / "tests/integration").mkdir() + (tmp_path / "tests/integration/test_flow.py").write_text( + "import pytest\npytestmark = [pytest.mark.integration]\ndef test_flow(): assert True\n" + ) + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.OK + for c in group.checks + ) + + +def test_tests_integration_empty_dir_warn(tmp_path, ctx): + """tests/integration/ пустая → WARN.""" + _make_backend_repo(tmp_path) + (tmp_path / "tests/integration").mkdir() + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "tests/integration/" and c.status == ps.CheckStatus.WARN for c in group.checks + ) + + +def test_tests_stub_file_without_def_test_warn(tmp_path, ctx): + """test_*.py без ``def test_*``/``async def test_*`` → WARN (stub).""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests/conftest.py").write_text("import pytest\n") + (tmp_path / "tests/test_stub.py").write_text("# empty stub file\nx = 1\n") + (tmp_path / "tests/test_real.py").write_text("def test_real(): assert True\n") + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.WARN for c in group.checks) + + +def test_tests_stub_detector_async_def_test_ok(tmp_path, ctx): + """``async def test_*`` распознаётся как реальный тест (не stub).""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests/conftest.py").write_text("import pytest\n") + (tmp_path / "tests/test_async.py").write_text("async def test_async(): assert True\n") + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks) + + +def test_tests_stub_detector_real_test_ok(tmp_path, ctx): + """test_*.py с ``def test_*`` → OK (не stub).""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests/conftest.py").write_text("import pytest\n") + (tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n") + group = ps.check_tests(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks) + + # ── check_readme ───────────────────────────────────────────────────────────── @@ -494,6 +699,7 @@ def _write_valid_readme(tmp_path: Path) -> None: content = ( "# 🚀 Title\n[English](#-english) [Русский](#-русский)\n" "## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n" + "Quick Start\nБыстрый старт\nslaid098.dev/support\n" ) for d in ps.README_DELIMITERS: content += f"\n" @@ -503,22 +709,98 @@ def _write_valid_readme(tmp_path: Path) -> None: def test_readme_ok(tmp_path, ctx): _write_valid_readme(tmp_path) group = ps.check_readme(ps.ProjectType.BACKEND, ctx) - assert group.overall() == ps.CheckStatus.OK + assert group.overall() == ps.CheckStatus.OK, ( + f"expected OK, got {group.overall()}: " + + ", ".join( + f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK + ) + ) def test_readme_missing(tmp_path, ctx): group = ps.check_readme(ps.ProjectType.BACKEND, ctx) - assert group.overall() == ps.CheckStatus.FAIL + assert group.overall() == ps.CheckStatus.WARN + assert any(c.name == "README.md" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_readme_missing_delimiters(tmp_path, ctx): (tmp_path / "README.md").write_text( "# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n" + "[Русский](#-русский)\nQuick Start\nБыстрый старт\nslaid098.dev/support\n" ) group = ps.check_readme(ps.ProjectType.BACKEND, ctx) assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks) +def test_readme_missing_ru_switcher_fail(tmp_path, ctx): + """README без ``[Русский](#-русский)`` → FAIL.""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text().replace("[Русский](#-русский)", "") + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "[Русский](#-русский)" and c.status == ps.CheckStatus.FAIL for c in group.checks + ) + + +def test_readme_missing_support_link_fail(tmp_path, ctx): + """README без ``slaid098.dev/support`` → FAIL.""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text().replace("slaid098.dev/support", "") + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "slaid098.dev/support" and c.status == ps.CheckStatus.FAIL for c in group.checks + ) + + +def test_readme_missing_quick_start_fail(tmp_path, ctx): + """README без ``Quick Start`` (EN) → FAIL.""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text().replace("Quick Start", "") + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "Quick Start" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +def test_readme_missing_bystriy_start_fail(tmp_path, ctx): + """README без ``Быстрый старт`` (RU) → FAIL.""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text().replace("Быстрый старт", "") + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any(c.name == "Быстрый старт" and c.status == ps.CheckStatus.FAIL for c in group.checks) + + +def test_readme_manual_license_section_fail(tmp_path, ctx): + """README с ручной секцией ``## License`` → FAIL (дубль GitHub sidebar).""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text() + "\n## License\nMIT\n" + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "Manual License section" and c.status == ps.CheckStatus.FAIL for c in group.checks + ) + + +def test_readme_manual_license_ru_section_fail(tmp_path, ctx): + """README с ``## Лицензия`` (RU) → FAIL.""" + _write_valid_readme(tmp_path) + content = (tmp_path / "README.md").read_text() + "\n## Лицензия\nMIT\n" + (tmp_path / "README.md").write_text(content) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert any( + c.name == "Manual License section" and c.status == ps.CheckStatus.FAIL for c in group.checks + ) + + +def test_readme_no_manual_license_ok(tmp_path, ctx): + """README без ручной License секции → нет FAIL для ``Manual License section``.""" + _write_valid_readme(tmp_path) + group = ps.check_readme(ps.ProjectType.BACKEND, ctx) + assert not any(c.name == "Manual License section" for c in group.checks) + + # ── check_infra ──────────────────────────────────────────────────────────────