fix(infra): AST route imports + check_tests structure + check_readme full port (#248)

* feat(infra): AST route imports check in project-status

* feat(infra): check_tests structure + AST stub-detector

* feat(infra): check_readme full port from validateReadme

* refactor(infra): drop validate mode from create-readme tool

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-03 20:50:04 +03:00 committed by GitHub
parent 93911f2af6
commit baf6b30547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 519 additions and 153 deletions

View file

@ -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"<!-- {d} -->" 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:

View file

@ -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(`<!-- ${d} -->`))
issues.push(`Missing <!-- ${d} --> delimiter`)
}
const pairs = [
{ start: "<!-- tagline-en:start -->", end: "<!-- tagline-en:end -->", label: "EN tagline" },
{ start: "<!-- tagline-ru:start -->", end: "<!-- tagline-ru:end -->", label: "RU tagline" },
{ start: "<!-- summary-en:start -->", end: "<!-- summary-en:end -->", label: "EN summary" },
{ start: "<!-- features-en:start -->", end: "<!-- features-en:end -->", label: "EN features" },
{ start: "<!-- summary-ru:start -->", end: "<!-- summary-ru:end -->", label: "RU summary" },
{ start: "<!-- features-ru:start -->", end: "<!-- features-ru: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 (<!-- summary-en:start/end -->, <!-- features-en:start/end -->, <!-- summary-ru:start/end -->, <!-- features-ru:start/end -->) 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 (<!-- summary-en:start/end -->, <!-- features-en:start/end -->, <!-- summary-ru:start/end -->, <!-- features-ru:start/end -->) 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,7 +242,6 @@ 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<string, string | undefined> = {
repo_name: args.repo_name,
tagline_en: args.tagline_en,
@ -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)}`
}

View file

@ -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}

View file

@ -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 <pkg>.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 <pkg>.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"<!-- {d} -->\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 ──────────────────────────────────────────────────────────────