diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index b4a87e5..1a26365 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -3,14 +3,18 @@ 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 Рекомендации. +``[OK]/[WARN]`` lines, an Итог summary, and Рекомендации. + +All checks are non-blocking (issue #275): every problem is surfaced as WARN +and the exit code is always 0 (informational mode). The ``--check`` flag is +accepted for CLI backward compatibility but no longer forces exit 1. 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 # informational (exit 0) + python3 .opencode/scripts/project-status.py --check # accepted, still exit 0 python3 .opencode/scripts/project-status.py --fast # skip slow/remote checks python3 .opencode/scripts/project-status.py --repo /path/to/repo @@ -110,7 +114,13 @@ CONFIG = load_config() class CheckStatus(StrEnum): - """Result of a single check.""" + """Result of a single check. + + All checks are non-blocking (issue #275): problems are surfaced as WARN + and the exit code is always 0. ``FAIL`` is kept as a value for backward + compatibility (existing tests import it), but no check-function returns + ``FAIL`` anymore — every problem is WARN. + """ OK = "OK" WARN = "WARN" @@ -139,7 +149,13 @@ class GroupResult: checks: list[CheckResult] = field(default_factory=list) def overall(self) -> CheckStatus: - """Roll up statuses: FAIL > WARN > OK.""" + """Roll up statuses: FAIL > WARN > OK (FAIL kept for compat, unused by checks). + + No check-function returns ``FAIL`` after issue #275 — the rollup is + effectively WARN > OK. ``FAIL`` is retained in the comparison so any + externally-constructed ``CheckResult(FAIL, ...)`` still rolls up to + FAIL (defensive, does not happen in normal use). + """ statuses = [c.status for c in self.checks] if CheckStatus.FAIL in statuses: return CheckStatus.FAIL @@ -422,11 +438,11 @@ def _check_backend_lifespan(backend_root: Path) -> CheckResult: """ main = backend_root / "main.py" if not main.exists(): - return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") + return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет") try: content = main.read_text(encoding="utf-8-sig") except OSError: - return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет") + return CheckResult(CheckStatus.WARN, "main.py lifespan", "main.py нет") if "lifespan" in content: return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден") return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден") @@ -437,7 +453,7 @@ def _check_cli_package(ctx: RepoCtx) -> CheckResult: src = ctx.root / "src" if src.exists() and any(p.is_dir() and (p / "__init__.py").exists() for p in src.iterdir()): return CheckResult(CheckStatus.OK, "src//", "пакет найден") - return CheckResult(CheckStatus.FAIL, "src//", "пакет не найден") + return CheckResult(CheckStatus.WARN, "src//", "пакет не найден") def _normalize_package_name(name: str) -> str: @@ -689,8 +705,8 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: FULLSTACK — issue #274). ``db/models`` is auto-detected from ``[project.dependencies]``: if any DB marker (tortoise-orm, sqlalchemy, sqlmodel, alembic, aerich, pony, databases) is present, ``db/models`` is - expected and a missing dir yields WARN (not FAIL — issue #275); if no - DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no). + expected and a missing dir yields WARN (issue #275: all-WARN contract); + if no DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no). """ group = GroupResult(name="Структура") if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}: @@ -713,14 +729,15 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: db_required = _is_db_project(deps_lower) expected = _expected_backend_paths(pkg, db_required=db_required) for rel in expected: - status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.FAIL + status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.WARN detail = "существует" if status == CheckStatus.OK else "отсутствует" group.checks.append(CheckResult(status, rel, detail)) if db_required: db_models_rel = f"src/{pkg}/db/models" if not (backend_root / db_models_rel).exists(): - # Replace the FAIL above with a WARN — db/models missing is - # not a contract violation (issue #275: all-WARN contract). + # Ensure the db/models check carries the db-project detail even + # though the generic loop above already set it to WARN. The + # detail here is more informative (issue #275: all-WARN). group.checks = [ c if c.name != db_models_rel @@ -737,7 +754,7 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: ) return group for rel in expected: - status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL + status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.WARN detail = "существует" if status == CheckStatus.OK else "отсутствует" group.checks.append(CheckResult(status, rel, detail)) group.checks.extend(_check_type_specific_structure(ptype, ctx)) @@ -837,8 +854,9 @@ def _check_route_imports(route_file: Path, ctx: RepoCtx) -> CheckResult | None: (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. + with ``WARN`` status naming the offending module (issue #275: all-WARN + contract — forbidden imports are surfaced, never blocking). ``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")) @@ -851,7 +869,7 @@ def _check_route_imports(route_file: Path, ctx: RepoCtx) -> CheckResult | None: 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, + CheckStatus.WARN, str(rel), f"импортирует {module} — роут должен идти через services", ) @@ -870,10 +888,11 @@ def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult] def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult: - """Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN). + """Group 2: Тонкие роуты — AST imports (WARN) + line count (WARN). For FULLSTACK the api dirs live under ``backend/src//api/v1`` and - WARN paths are reported relative to ``backend/`` (issue #274). + WARN paths are reported relative to ``backend/`` (issue #274). Issue #275: + forbidden imports are WARN (non-blocking), not FAIL. """ _ = fast # unused here, accepted for signature uniformity group = GroupResult(name="Тонкие роуты") @@ -894,12 +913,15 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx) -> None: - """Append import + line-count checks to ``group`` (split for ≤50 lines).""" + """Append import + line-count checks to ``group`` (split for ≤50 lines). + + Issue #275: forbidden imports surface as WARN (non-blocking), not FAIL. + """ 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}") + CheckResult(CheckStatus.WARN, "route imports", f"запрещённые: {detail}") ) else: group.checks.append( @@ -932,7 +954,8 @@ def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml. Reads ``pyproject.toml`` from the backend root (FULLSTACK → - ``backend/pyproject.toml`` — issue #274). + ``backend/pyproject.toml`` — issue #274). Issue #275: missing tools are + WARN (non-blocking), not FAIL. """ group = GroupResult(name="Качество кода") backend_ctx = _backend_ctx_for(ptype, ctx) @@ -943,7 +966,7 @@ def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: group.checks.append(CheckResult(CheckStatus.OK, tool_name, "настроен в pyproject.toml")) else: group.checks.append( - CheckResult(CheckStatus.FAIL, tool_name, f"[tool.{tool_name}] отсутствует") + CheckResult(CheckStatus.WARN, tool_name, f"[tool.{tool_name}] отсутствует") ) pytest_cfg = pyproject.get("tool", {}).get("pytest", {}) dev_deps = pyproject.get("project", {}).get("optional-dependencies", {}).get("dev", []) @@ -951,7 +974,7 @@ def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: 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")) + group.checks.append(CheckResult(CheckStatus.WARN, "pytest", "не найден в dev-deps")) return group @@ -1064,14 +1087,15 @@ def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: """Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs. For BACKEND and FULLSTACK the tests live under the backend root (FULLSTACK - → ``backend/tests/`` — issue #274). + → ``backend/tests/`` — issue #274). Issue #275: missing tests dir / too + few test files are WARN (non-blocking), not FAIL. """ group = GroupResult(name="Тесты") backend_root = _backend_root_for(ptype, ctx) tests_dir = backend_root / "tests" if not tests_dir.exists(): group.checks.append( - CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует") + CheckResult(CheckStatus.WARN, "tests/", "директория tests/ отсутствует") ) return group if (tests_dir / "conftest.py").exists(): @@ -1088,7 +1112,7 @@ def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: 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})") + CheckResult(CheckStatus.WARN, "test files", f"{len(test_files)} (< {min_tests})") ) _check_test_structure(ptype, tests_dir, group) asyncio_marks = 0 @@ -1120,15 +1144,15 @@ def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: 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). + Each missing section → WARN (issue #275: all-WARN contract, non-blocking). + 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, + CheckStatus.WARN, "[Русский](#-русский)", "отсутствует RU switcher (должен быть #-русский)", ) @@ -1148,24 +1172,24 @@ def _readme_required_sections(content: str) -> list[CheckResult]: ) else: results.append( - CheckResult(CheckStatus.FAIL, "slaid098.dev/contacts", "отсутствует support link") + CheckResult(CheckStatus.WARN, "slaid098.dev/contacts", "отсутствует support link") ) if "Quick Start" not in content: results.append( - CheckResult(CheckStatus.FAIL, "Quick Start", "отсутствует EN секция Quick Start") + CheckResult(CheckStatus.WARN, "Quick Start", "отсутствует EN секция Quick Start") ) else: results.append(CheckResult(CheckStatus.OK, "Quick Start", "присутствует")) if "Быстрый старт" not in content: results.append( - CheckResult(CheckStatus.FAIL, "Быстрый старт", "отсутствует RU секция Быстрый старт") + CheckResult(CheckStatus.WARN, "Быстрый старт", "отсутствует RU секция Быстрый старт") ) else: results.append(CheckResult(CheckStatus.OK, "Быстрый старт", "присутствует")) if re.search(r"^##\s+(License|LICENSE|Лицензия)\s*$", content, re.MULTILINE): results.append( CheckResult( - CheckStatus.FAIL, + CheckStatus.WARN, "Manual License section", "найден — удалить (GitHub рендерит из LICENSE файла)", ) @@ -1174,7 +1198,10 @@ def _readme_required_sections(content: str) -> list[CheckResult]: def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: - """Group 5: README — full port of ``validateReadme`` from create-readme.ts.""" + """Group 5: README — full port of ``validateReadme`` from create-readme.ts. + + Issue #275: all problems are WARN (non-blocking), never FAIL. + """ group = GroupResult(name="README") content = read_text("README.md", ctx) if content is None: @@ -1184,7 +1211,7 @@ def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: if missing: group.checks.append( CheckResult( - CheckStatus.FAIL, + CheckStatus.WARN, "12 delimiter tags", f"не хватает {len(missing)}: {', '.join(missing[:3])}", ) @@ -1197,7 +1224,7 @@ def check_readme(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: if required_text in content: group.checks.append(CheckResult(CheckStatus.OK, required_text, "присутствует")) else: - group.checks.append(CheckResult(CheckStatus.FAIL, required_text, "отсутствует")) + group.checks.append(CheckResult(CheckStatus.WARN, required_text, "отсутствует")) group.checks.extend(_readme_required_sections(content)) if "assets/cover.png" in content: group.checks.append(CheckResult(CheckStatus.OK, "cover.png", "указан")) @@ -1237,7 +1264,7 @@ def check_infra( else: group.checks.append( CheckResult( - CheckStatus.FAIL, + CheckStatus.WARN, ".github/workflows/ci.yml", "отсутствует — CI не настроен", ) @@ -1251,7 +1278,7 @@ def check_infra( if path_exists("LICENSE", ctx): group.checks.append(CheckResult(CheckStatus.OK, "LICENSE", "есть")) else: - group.checks.append(CheckResult(CheckStatus.FAIL, "LICENSE", "отсутствует")) + group.checks.append(CheckResult(CheckStatus.WARN, "LICENSE", "отсутствует")) if path_exists(".pre-commit-config.yaml", ctx): group.checks.append(CheckResult(CheckStatus.OK, "pre-commit", "настроен")) else: @@ -1366,7 +1393,8 @@ def _python_version_compat_impl(requires_python: str, python_version_file: str) """Impl for ``_check_python_version_compat``: packaging-based version check. Soft-dependency on ``packaging`` — WARN on ImportError (legitimate - ``# noqa: PLC0415`` for lazy import). + ``# noqa: PLC0415`` for lazy import). Issue #275: incompat / parse errors + are WARN (non-blocking), not FAIL. """ name = "requires-python vs .python-version" try: @@ -1383,13 +1411,13 @@ def _python_version_compat_impl(requires_python: str, python_version_file: str) try: spec = SpecifierSet(requires_python) except ValueError as e: - return CheckResult(CheckStatus.FAIL, name, f"неверный requires-python: {e}") + return CheckResult(CheckStatus.WARN, name, f"неверный requires-python: {e}") if spec.contains(version, prereleases=True): return CheckResult( CheckStatus.OK, name, f"requires-python={requires_python!r} включает {version}" ) return CheckResult( - CheckStatus.FAIL, name, f"requires-python={requires_python!r} не включает {version}" + CheckStatus.WARN, name, f"requires-python={requires_python!r} не включает {version}" ) @@ -1412,7 +1440,10 @@ def _load_pyproject(root: Path) -> dict[str, Any] | None: def _check_build_system(data: dict[str, Any]) -> CheckResult: - """Check 1: ``[build-system]`` requires hatchling + hatchling.build backend.""" + """Check 1: ``[build-system]`` requires hatchling + hatchling.build backend. + + Issue #275: missing/incorrect build-system is WARN (non-blocking), not FAIL. + """ build = data.get("build-system", {}) if isinstance(data, dict) else {} requires = build.get("requires", []) if isinstance(build, dict) else [] build_backend = build.get("build-backend", "") if isinstance(build, dict) else "" @@ -1420,7 +1451,7 @@ def _check_build_system(data: dict[str, Any]) -> CheckResult: if requires_ok and build_backend == "hatchling.build": return CheckResult(CheckStatus.OK, "[build-system]", "hatchling настроен") return CheckResult( - CheckStatus.FAIL, + CheckStatus.WARN, "[build-system]", f"требуется hatchling (requires={requires!r}, backend={build_backend!r})", ) @@ -1430,7 +1461,7 @@ def _check_hatch_packages_nested(hatch_packages: object, src_pkg_path: str) -> C """Sub-check of check 2: classify hatch packages when ``src//`` exists. Returns OK if packages references ``src/``; WARN if ``["src"]`` flat - layout; FAIL otherwise. + layout; WARN otherwise (issue #275: all-WARN contract, non-blocking). """ name = "[tool.hatch.build.targets.wheel]" valid_packages = {src_pkg_path} @@ -1443,7 +1474,7 @@ def _check_hatch_packages_nested(hatch_packages: object, src_pkg_path: str) -> C f"deprecated flat layout — use packages=['{src_pkg_path}'], got={hatch_packages!r}", ) return CheckResult( - CheckStatus.FAIL, name, f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}' + CheckStatus.WARN, name, f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}' ) @@ -1482,7 +1513,10 @@ def _check_hatch_packages(data: dict[str, Any], root: Path, ptype: ProjectType) def _check_project_fields(project: dict[str, Any]) -> CheckResult: - """Check 3: ``[project]`` has name, version, description, requires-python.""" + """Check 3: ``[project]`` has name, version, description, requires-python. + + Issue #275: missing fields are WARN (non-blocking), not FAIL. + """ required = ["name", "version", "description", "requires-python"] missing = [f for f in required if not project.get(f)] if isinstance(project, dict) else required if not missing: @@ -1491,16 +1525,19 @@ def _check_project_fields(project: dict[str, Any]) -> CheckResult: "[project]", f"name={project.get('name')!r}, version={project.get('version')!r}", ) - return CheckResult(CheckStatus.FAIL, "[project]", f"отсутствуют поля: {', '.join(missing)}") + return CheckResult(CheckStatus.WARN, "[project]", f"отсутствуют поля: {', '.join(missing)}") def _check_ruff_section(data: dict[str, Any], root: Path) -> CheckResult: - """Check 4: ``[tool.ruff]`` section or ``ruff.toml`` with line-length + target-version.""" + """Check 4: ``[tool.ruff]`` section or ``ruff.toml`` with line-length + target-version. + + Issue #275: missing ruff config is WARN (non-blocking), not FAIL. + """ name = "[tool.ruff]" tools = data.get("tool", {}) if isinstance(data, dict) else {} ruff_section = tools.get("ruff", {}) if isinstance(tools, dict) else {} if not _has_ruff_config(data, root): - return CheckResult(CheckStatus.FAIL, name, "секция отсутствует (и нет ruff.toml)") + return CheckResult(CheckStatus.WARN, name, "секция отсутствует (и нет ruff.toml)") if isinstance(ruff_section, dict) and ruff_section: has_ll = "line-length" in ruff_section has_tv = "target-version" in ruff_section @@ -1517,12 +1554,15 @@ def _check_ruff_section(data: dict[str, Any], root: Path) -> CheckResult: def _check_mypy_section(data: dict[str, Any], root: Path) -> CheckResult: - """Check 5: ``[tool.mypy]`` strict or ``mypy.ini`` present.""" + """Check 5: ``[tool.mypy]`` strict or ``mypy.ini`` present. + + Issue #275: missing mypy config is WARN (non-blocking), not FAIL. + """ name = "[tool.mypy]" tools = data.get("tool", {}) if isinstance(data, dict) else {} has_mypy_ini = (root / "mypy.ini").exists() or (root / ".mypy.ini").exists() if "mypy" not in tools and not has_mypy_ini: - return CheckResult(CheckStatus.FAIL, name, "секция отсутствует (и нет mypy.ini)") + return CheckResult(CheckStatus.WARN, name, "секция отсутствует (и нет mypy.ini)") if has_mypy_ini and "mypy" not in tools: return CheckResult(CheckStatus.OK, name, "mypy.ini обнаружен") if _mypy_strict(data): @@ -1531,11 +1571,14 @@ def _check_mypy_section(data: dict[str, Any], root: Path) -> CheckResult: def _check_pytest_ini_options(tools: dict[str, Any]) -> CheckResult: - """Check 6: ``[tool.pytest.ini_options]`` asyncio_mode=auto, testpaths=["tests"].""" + """Check 6: ``[tool.pytest.ini_options]`` asyncio_mode=auto, testpaths=["tests"]. + + Issue #275: missing pytest config is WARN (non-blocking), not FAIL. + """ name = "[tool.pytest.ini_options]" pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {} if not (isinstance(pytest_opts, dict) and pytest_opts): - return CheckResult(CheckStatus.FAIL, name, "секция отсутствует") + return CheckResult(CheckStatus.WARN, name, "секция отсутствует") asyncio_mode = pytest_opts.get("asyncio_mode", "") testpaths = pytest_opts.get("testpaths", []) if asyncio_mode == "auto" and testpaths == ["tests"]: @@ -1617,11 +1660,12 @@ PYPROJECT_CHECKS: list[Any] = [] # populated below; kept here for discoverabili def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: - """Group 8: pyproject.toml — 13 checks (FAIL/WARN). + """Group 8: pyproject.toml — 13 checks (OK/WARN). Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the - file is missing → single WARN; parse error → single FAIL. + file is missing → single WARN; parse error → single WARN (issue #275: + all-WARN contract, non-blocking). Reads ``pyproject.toml`` from the backend root (FULLSTACK → ``backend/pyproject.toml`` — issue #274); root-level files checked by @@ -1639,7 +1683,7 @@ def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult: if "__parse_error__" in data: group.checks.append( CheckResult( - CheckStatus.FAIL, "pyproject.toml", f"парсинг failed: {data['__parse_error__']}" + CheckStatus.WARN, "pyproject.toml", f"парсинг failed: {data['__parse_error__']}" ) ) return group @@ -1712,12 +1756,19 @@ STATUS_PREFIX: dict[CheckStatus, str] = { def format_output(ptype: ProjectType, groups: list[GroupResult]) -> str: - """Format output: 7 group blocks + Итог + Рекомендации.""" + """Format output: 8 group blocks + Итог + Рекомендации. + + Issue #275: all checks are non-blocking (WARN). The ``Итог:`` line shows + only ``OK`` and ``WARN`` counts (no ``FAIL`` — no check returns FAIL, so a + FAIL count would always be 0 and is omitted per the issue #275 contract). + ``Рекомендации:`` lists every WARN with its group + name + detail so the + audit / project-template skills can parse the report without relying on + the exit code. + """ 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] @@ -1726,15 +1777,15 @@ def format_output(ptype: ProjectType, groups: list[GroupResult]) -> str: 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 + recommendations.append(f"- {group.name} / {chk.name}: {chk.detail}") else: ok_count += 1 lines.append("") lines.append("Итог:") - lines.append(f" OK: {ok_count} WARN: {warn_count} FAIL: {fail_count}") + lines.append(f" OK: {ok_count} WARN: {warn_count}") if recommendations: lines.append("") lines.append("Рекомендации:") @@ -1751,7 +1802,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument( "--check", action="store_true", - help="strict mode — exit 1 on any FAIL", + help="accepted for CLI compatibility — exit code is always 0 (issue #275)", ) parser.add_argument( "--fast", @@ -1768,11 +1819,18 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: def main() -> None: - """Entry point: parse args, run checks, print report, set exit code.""" + """Entry point: parse args, run checks, print report, set exit code. + + Issue #275: all checks are non-blocking — the exit code is always 0 + (informational mode). The ``--check`` flag is accepted for CLI backward + compatibility but no longer forces exit 1. The only non-zero exit is for + an invalid ``--repo`` path (argument validation, not a check result). + """ args = _parse_args(sys.argv[1:]) strict = args.check fast = args.fast repo_arg = args.repo + _ = strict # accepted for CLI compat, no longer forces exit 1 (issue #275) if repo_arg: repo_path = Path(repo_arg).resolve() @@ -1791,8 +1849,6 @@ def main() -> None: ctx = RepoCtx(root=ctx.root, config=ctx.config, backend_root=_backend_root_for(ptype, ctx)) groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None) print(format_output(ptype, groups)) - if strict and any(g.overall() == CheckStatus.FAIL for g in groups): - sys.exit(1) sys.exit(0) diff --git a/.opencode/skills/audit/SKILL.md b/.opencode/skills/audit/SKILL.md index f16da78..cc83e21 100644 --- a/.opencode/skills/audit/SKILL.md +++ b/.opencode/skills/audit/SKILL.md @@ -25,20 +25,20 @@ REVIEW → MERGE). oracle, ALLOWED — как `pipeline-status` / `spec-status`). Если вернул `⚠️ ...failed` → WARN, продолжай без детерминированных находок (explore всё равно работает). -2. **Парсит отчёт** (текст): `Итог:` (OK/WARN/FAIL counts) + `Рекомендации:` - (список FAIL с путями). +2. **Парсит отчёт** (текст): `Итог:` (OK/WARN counts — issue #275: FAIL убран, + exit code всегда 0) + `Рекомендации:` (список WARN с путями). Источник + находок = секция `Рекомендации:` (каждая строка `- / : `). 3. **`skill({ name: "code-standards" })`** — load skill (НЕ хардкод правил в audit skill — `code-standards` источник правды). 4. **Delegate `explore` subagent** (Template EXPLORE) — проверяет структуру и код против `code-standards`, возвращает `[{category, problem, path, severity}, ...]`. -5. **Комбинирует** находки: FAIL/WARN из `project-status` + качественные из - explore. **Дедупликация**: если `project-status` FAIL и explore нашли +5. **Комбинирует** находки: WARN из `project-status` + качественные из + explore. **Дедупликация**: если `project-status` WARN и explore нашли одну и ту же проблему → 1 issue (не 2). -6. **Бинарный вердикт**: - - `≥1 FAIL` ИЛИ `≥1 qualitative finding` → `❌ Найдено N проблем` - - `0 FAIL` + `0 qualitative` + `0 WARN` → `✅ Проект здоров` → STOP - - `0 FAIL` + `0 qualitative` + `≥1 WARN` → `⚠️ N замечаний` → вопрос +6. **Бинарный вердикт** (issue #275: парсит WARN, не exit code — exit всегда 0): + - `≥1 WARN` ИЛИ `≥1 qualitative finding` → `❌ Найдено N проблем` + - `0 WARN` + `0 qualitative` → `✅ Проект здоров` → STOP 7. **Список проблем** (сгруппированный: Структура / Качество / Тесты / Infra / Code-standards) — покажи юзеру. 8. **Вопрос юзеру**: @@ -67,6 +67,8 @@ REVIEW → MERGE). - Трогать `project-status.py` (агент парсит текст — JSON не нужен). - Создавать `audit-status` tool (audit — линейный, не фазный loop). - Группировать проблемы в один issue (1 проблема = 1 issue для `/run-pipeline`). +- Парсить exit code `project-status` для вердикта (issue #275: exit всегда 0, + парси WARN в `Рекомендации:`). ## Граничные случаи @@ -74,8 +76,8 @@ REVIEW → MERGE). `code-standards`, вердикт по качественным находкам. - **Репо без `src//`** (flat layout) → `project-status` WARNs, explore проверяет по `code-standards` (если применимо). -- **Только WARN** (0 FAIL, 0 qualitative) → `⚠️ N замечаний`, вопрос (да/нет - — на усмотрение юзера). +- **Только WARN** (0 qualitative) → `❌ Найдено N проблем`, вопрос (да/нет — + на усмотрение юзера). - **Юзер "нет"** → STOP, отчёт у юзера. - **create-issue валидация упала** → subagent сообщает, continue к следующей. - **Дублирующие проблемы** → дедупликация оркестратором (1 issue, не 2). @@ -141,7 +143,7 @@ Severity: edit/read/`gh issue create` сам. - `project-status` — read-only oracle, ALLOWED для оркестратора. - Audit — read-only (НЕ редактирует код, только создаёт issues). -- FAIL/WARN из `project-status` → issues, НЕ FIX напрямую. +- WARN из `project-status` → issues, НЕ FIX напрямую. - 1 проблема = 1 issue (для отдельного `/run-pipeline`). - Issue body self-contained (8 headings, `create-issue` валидация). - `code-standards` — через `skill()` tool, НЕ хардкод. diff --git a/.opencode/skills/project-template/SKILL.md b/.opencode/skills/project-template/SKILL.md index b1fea9a..7582112 100644 --- a/.opencode/skills/project-template/SKILL.md +++ b/.opencode/skills/project-template/SKILL.md @@ -108,14 +108,14 @@ protection на main. Требует локальный git-репо с initial project-status({}) ``` -Tool вернёт отчёт: `Project: `, 7 групп `[OK]/[WARN]/[FAIL]`, `Итог:`, +Tool вернёт отчёт: `Project: `, 8 групп `[OK]/[WARN]`, `Итог:`, `Рекомендации:`. Покажи отчёт юзеру. Если tool вернул `⚠️ ...failed` → WARN, продолжай без отчёта. > README у свежего проекта отсутствует (issue #269) — README генерируется > позже через repo-readme skill по ручному вызову. Поэтому группа README -> даст WARN «README.md отсутствует» (не FAIL, exit code 0 в non-blocking -> режиме). Это ожидаемое поведение, не ошибка — упомяни в отчёте. +> даст WARN «README.md отсутствует» (issue #275: exit code всегда 0, +> все проверки WARN). Это ожидаемое поведение, не ошибка — упомяни в отчёте. ### Шаг 6: Финальный репорт @@ -136,28 +136,28 @@ README: отсутствует (WARN у project-status) — появится п project-status({}) ``` -Для строгого режима (exit 1 на FAIL) — `project-status({ check: true })`. -Для пропуска медленных remote-проверок (branch protection via gh) — -`project-status({ fast: true })`. По умолчанию — non-blocking (exit 0). +Issue #275: exit code всегда 0 (информационный режим). ``--check`` принимается +для CLI совместимости, но больше не форсирует exit 1. Для пропуска медленных +remote-проверок (branch protection via gh) — `project-status({ fast: true })`. ### Шаг 2: Оркестратор — отчёт + рекомендации -Покажи полный отчёт юзеру. В разделе `Рекомендации:` — список FAIL-чеков с -путями. Сгруппируй по категориям (Структура / Качество кода / Тесты / README / -Infra / Coverage). +Покажи полный отчёт юзеру. В разделе `Рекомендации:` — список WARN-чеков с +путями (issue #275: все проверки WARN, не FAIL). Сгруппируй по категориям +(Структура / Качество кода / Тесты / README / Infra / Coverage). ### Шаг 3: Вопрос — чинить? ``` -Найдены проблемы: . +Найдены проблемы: . Запустить fix-subagents для рекомендаций? -[1] да — делегируй subagent(ов) для каждого FAIL +[1] да — делегируй subagent(ов) для каждого WARN [2] нет — только отчёт, я починю сам ``` -Если `да` → для каждого FAIL из `Рекомендации:` создай subagent (general) с +Если `да` → для каждого WARN из `Рекомендации:` создай subagent (general) с Template FIX (ниже), передав путь и описание проблемы. Subagent чинит, коммитит -через `commit` tool, push. Один FAIL = один subagent (последовательно, не +через `commit` tool, push. Один WARN = один subagent (последовательно, не параллельно — см. AGENTS.md Linear Execution). После всех фиксов → re-run `project-status` для верификации. @@ -303,7 +303,7 @@ owner = , visibility = public (или private если internal). `skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам. ``` -### Template FIX (check flow — починить FAIL из project-status) +### Template FIX (check flow — починить WARN из project-status) ``` Почини проблему из project-status отчёта. diff --git a/.opencode/tools/project-status.ts b/.opencode/tools/project-status.ts index fdc137c..d5ae0b3 100644 --- a/.opencode/tools/project-status.ts +++ b/.opencode/tools/project-status.ts @@ -4,9 +4,9 @@ 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 8 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), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Non-blocking default (exit 0); pass check=true for strict (exit 1 on FAIL); pass fast=true to skip slow/remote checks (branch protection); pass repo= to check an arbitrary repo instead of the current worktree.", + "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 8 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), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo= to check an arbitrary repo instead of the current worktree.", args: { - check: tool.schema.boolean().optional().describe("If true, strict mode — exit 1 on any FAIL"), + check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"), fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"), repo: tool.schema.string().optional().describe("Path to repo to check (default: current worktree). Use to run against an arbitrary repo without switching cwd, e.g. project-status --repo /root/workspace/youtube-kit"), }, diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index a286604..7ca4cf9 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -619,15 +619,19 @@ def test_infra_checks_present(render): ], ) def test_fullstack_passes_project_status_all_groups(render, extra_context): - """Fullstack template passes the project-status groups (issue #274). + """Fullstack template passes the project-status groups (issue #274/#275). With ``use_db=yes`` all backend-source checks (Structure, Тонкие роуты, Качество кода, Тесты, Pyproject, Coverage, Infra) resolve against - ``backend/`` and yield OK/WARN (never FAIL). With ``use_db=no`` the db - dep is absent from ``pyproject.toml``, so ``db/models`` is auto-skipped - (no FAIL) and the project still detects as FULLSTACK; ``Тесты`` may FAIL - because the use_db=no hook strips all db-related test files (test_auth.py - included when use_auth=yes, since auth_service imports the removed User). + ``backend/`` and yield OK/WARN. With ``use_db=no`` the db dep is absent + from ``pyproject.toml``, so ``db/models`` is auto-skipped and the project + still detects as FULLSTACK; ``Тесты`` may WARN because the use_db=no hook + strips all db-related test files (test_auth.py included when + use_auth=yes, since auth_service imports the removed User). + + Issue #275: all checks are non-blocking — no group ever returns FAIL. The + exit code is always 0 (informational mode); this test asserts that no + group contains a FAIL check, which is now guaranteed by the contract. README is intentionally absent (issue #269 → WARN, non-blocking) and is not asserted here. Branch protection is skipped via ``--fast`` (WARN). @@ -652,16 +656,11 @@ def test_fullstack_passes_project_status_all_groups(render, extra_context): assert any( c.name == "src/fs/api/v1" and c.status == ps.CheckStatus.OK for c in structure.checks ), f"src/fs/api/v1 not OK: {structure.checks}" - # Groups that must never FAIL: Quality, Pyproject, Coverage, Infra, routes. - # Тесты is OK for use_db=yes (test_auth.py kept); for use_db=no it may FAIL - # (the hook strips db-related tests, leaving no test_*.py at tests/ root). - must_pass = ["Тонкие роуты", "Качество кода", "Pyproject", "Coverage", "Infra"] - if extra_context.get("use_db") == "yes": - must_pass.append("Тесты") - for group_name in must_pass: - g = by_name[group_name] + # Issue #275: all checks are WARN (non-blocking) — no group can FAIL. + # Assert every group stays WARN-or-OK (no FAIL check anywhere). + for group_name, g in by_name.items(): failed = [c for c in g.checks if c.status == ps.CheckStatus.FAIL] - assert g.overall() != ps.CheckStatus.FAIL, f"{group_name} FAIL: " + ", ".join( + assert not failed, f"{group_name} has FAIL checks (issue #275 violation): " + ", ".join( f"{c.name}={c.status.value}" for c in failed ) diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 0f1d1e2..1163e55 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -4,9 +4,10 @@ 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. +Covers: auto-detect (5 types + unknown), 8 check groups, format output +([OK]/[WARN] + Итог + Рекомендации), exit codes (issue #275: exit code is +always 0 — ``--check`` is accepted but no longer forces exit 1), ``--fast`` +skip of branch protection. """ import importlib.util @@ -390,9 +391,9 @@ def test_check_structure_backend_missing_dir(tmp_path, ctx): "@asynccontextmanager\nasync def lifespan(app): yield\n" ) group = ps.check_structure(ps.ProjectType.BACKEND, ctx) - assert group.overall() == ps.CheckStatus.FAIL + assert group.overall() == ps.CheckStatus.WARN assert any( - c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks + c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -434,7 +435,7 @@ def test_check_structure_cli_with_package(tmp_path, ctx): def test_check_structure_cli_no_package(tmp_path, ctx): (tmp_path / "src").mkdir() group = ps.check_structure(ps.ProjectType.CLI, ctx) - assert any(c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.status == ps.CheckStatus.WARN for c in group.checks) # ── check_thin_routes ──────────────────────────────────────────────────────── @@ -481,8 +482,8 @@ 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).""" +def test_thin_routes_import_src_db_models_warn(tmp_path, ctx): + """Route импортирует ``src.db.models`` → WARN (issue #275: non-blocking).""" _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" @@ -491,12 +492,12 @@ def test_thin_routes_import_src_db_models_fail(tmp_path, ctx): ) 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}" + c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks + ), f"expected WARN on route imports, got: {group.checks}" -def test_thin_routes_import_tortoise_fail(tmp_path, ctx): - """Route импортирует ``tortoise`` → FAIL (bypasses services).""" +def test_thin_routes_import_tortoise_warn(tmp_path, ctx): + """Route импортирует ``tortoise`` → WARN (issue #275: non-blocking).""" _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" @@ -505,12 +506,12 @@ def test_thin_routes_import_tortoise_fail(tmp_path, ctx): ) 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}" + c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks + ), f"expected WARN 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).""" +def test_thin_routes_import_submodule_of_src_db_models_warn(tmp_path, ctx): + """``from src.db.models.user import User`` → WARN (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" @@ -518,7 +519,7 @@ def test_thin_routes_import_submodule_of_src_db_models_fail(tmp_path, ctx): "@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) + assert any(c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_thin_routes_import_services_ok(tmp_path, ctx): @@ -530,9 +531,9 @@ def test_thin_routes_import_services_ok(tmp_path, ctx): "@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 all( + c.status != ps.CheckStatus.WARN or c.name != "route imports" for c in group.checks + ), f"expected no WARN on route imports, got: {group.checks}" assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks) @@ -545,9 +546,9 @@ def test_thin_routes_import_db_connection_ok(tmp_path, ctx): "@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}" - ) + assert all( + c.status != ps.CheckStatus.WARN or c.name != "route imports" 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): @@ -625,8 +626,8 @@ def test_thin_routes_fullstack_no_backend_pyproject_skip(tmp_path, ctx): ) -def test_thin_routes_fullstack_import_src_db_models_fail(tmp_path, ctx): - """Fullstack route importing ``src.db.models`` → FAIL (same rule as backend).""" +def test_thin_routes_fullstack_import_src_db_models_warn(tmp_path, ctx): + """Fullstack route importing ``src.db.models`` → WARN (issue #275: non-blocking).""" _make_fullstack_repo(tmp_path) (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( "from fastapi import APIRouter\nfrom src.db.models import User\n" @@ -635,12 +636,12 @@ def test_thin_routes_fullstack_import_src_db_models_fail(tmp_path, ctx): ) group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) assert any( - c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks - ), f"expected FAIL on fullstack route imports, got: {group.checks}" + c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks + ), f"expected WARN on fullstack route imports, got: {group.checks}" -def test_thin_routes_fullstack_import_tortoise_fail(tmp_path, ctx): - """Fullstack route importing ``tortoise`` → FAIL (same rule as backend).""" +def test_thin_routes_fullstack_import_tortoise_warn(tmp_path, ctx): + """Fullstack route importing ``tortoise`` → WARN (issue #275: non-blocking).""" _make_fullstack_repo(tmp_path) (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text( "from fastapi import APIRouter\nfrom tortoise import fields\n" @@ -649,8 +650,8 @@ def test_thin_routes_fullstack_import_tortoise_fail(tmp_path, ctx): ) group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) assert any( - c.name == "route imports" and c.status == ps.CheckStatus.FAIL for c in group.checks - ), f"expected FAIL on fullstack route imports, got: {group.checks}" + c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks + ), f"expected WARN on fullstack route imports, got: {group.checks}" def test_thin_routes_fullstack_import_services_ok(tmp_path, ctx): @@ -662,9 +663,9 @@ def test_thin_routes_fullstack_import_services_ok(tmp_path, ctx): "@router.get('/users')\nasync def list_users():\n return user_service.list_all()\n" ) group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx) - assert all(c.status != ps.CheckStatus.FAIL for c in group.checks), ( - f"fullstack services import should be OK, got: {group.checks}" - ) + assert all( + c.status != ps.CheckStatus.WARN or c.name != "route imports" for c in group.checks + ), f"fullstack services import should be OK, got: {group.checks}" def test_thin_routes_fullstack_over_limit_warn(tmp_path, ctx): @@ -696,13 +697,13 @@ def test_quality_ok(tmp_path, ctx): def test_quality_missing_mypy(tmp_path, ctx): _write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False) group = ps.check_quality(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "mypy" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_quality_missing_ruff(tmp_path, ctx): _write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False) group = ps.check_quality(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "ruff" and c.status == ps.CheckStatus.WARN for c in group.checks) # ── check_tests ────────────────────────────────────────────────────────────── @@ -716,7 +717,7 @@ def test_tests_ok(tmp_path, ctx): def test_tests_no_dir(tmp_path, ctx): group = ps.check_tests(ps.ProjectType.BACKEND, ctx) - assert group.overall() == ps.CheckStatus.FAIL + assert group.overall() == ps.CheckStatus.WARN assert any("tests/" in c.name for c in group.checks) @@ -877,30 +878,30 @@ def test_readme_missing_delimiters(tmp_path, ctx): "[Русский](#-русский)\nQuick Start\nБыстрый старт\nslaid098.dev/contacts\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) + assert any("delimiter" in c.name and c.status == ps.CheckStatus.WARN for c in group.checks) -def test_readme_missing_ru_switcher_fail(tmp_path, ctx): - """README без ``[Русский](#-русский)`` → FAIL.""" +def test_readme_missing_ru_switcher_warn(tmp_path, ctx): + """README без ``[Русский](#-русский)`` → WARN (issue #275: non-blocking).""" _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 + c.name == "[Русский](#-русский)" and c.status == ps.CheckStatus.WARN for c in group.checks ) -def test_readme_missing_support_link_fail(tmp_path, ctx): - """README без обеих ссылок (contacts/support) → FAIL.""" +def test_readme_missing_support_link_warn(tmp_path, ctx): + """README без обеих ссылок (contacts/support) → WARN (issue #275).""" _write_valid_readme(tmp_path) content = (tmp_path / "README.md").read_text().replace("slaid098.dev/contacts", "") (tmp_path / "README.md").write_text(content) group = ps.check_readme(ps.ProjectType.BACKEND, ctx) assert any( - c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.FAIL for c in group.checks + c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.WARN for c in group.checks ) - assert group.overall() == ps.CheckStatus.FAIL + assert group.overall() == ps.CheckStatus.WARN def test_readme_deprecated_support_link_warn(tmp_path, ctx): @@ -928,43 +929,43 @@ def test_readme_contacts_link_ok(tmp_path, ctx): ) -def test_readme_missing_quick_start_fail(tmp_path, ctx): - """README без ``Quick Start`` (EN) → FAIL.""" +def test_readme_missing_quick_start_warn(tmp_path, ctx): + """README без ``Quick Start`` (EN) → WARN (issue #275: non-blocking).""" _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) + assert any(c.name == "Quick Start" and c.status == ps.CheckStatus.WARN for c in group.checks) -def test_readme_missing_bystriy_start_fail(tmp_path, ctx): - """README без ``Быстрый старт`` (RU) → FAIL.""" +def test_readme_missing_bystriy_start_warn(tmp_path, ctx): + """README без ``Быстрый старт`` (RU) → WARN (issue #275: non-blocking).""" _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) + assert any(c.name == "Быстрый старт" and c.status == ps.CheckStatus.WARN for c in group.checks) -def test_readme_manual_license_section_fail(tmp_path, ctx): - """README с ручной секцией ``## License`` → FAIL (дубль GitHub sidebar).""" +def test_readme_manual_license_section_warn(tmp_path, ctx): + """README с ручной секцией ``## License`` → WARN (issue #275: non-blocking).""" _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 + c.name == "Manual License section" and c.status == ps.CheckStatus.WARN for c in group.checks ) -def test_readme_manual_license_ru_section_fail(tmp_path, ctx): - """README с ``## Лицензия`` (RU) → FAIL.""" +def test_readme_manual_license_ru_section_warn(tmp_path, ctx): + """README с ``## Лицензия`` (RU) → WARN (issue #275: non-blocking).""" _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 + c.name == "Manual License section" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -1002,11 +1003,11 @@ def test_infra_ok_with_branch_protection(monkeypatch, tmp_path, ctx): assert group.overall() == ps.CheckStatus.OK -def test_infra_missing_ci_fail(tmp_path, ctx): +def test_infra_missing_ci_warn(tmp_path, ctx): (tmp_path / "LICENSE").write_text("MIT\n") group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True) assert any( - c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL + c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -1080,7 +1081,7 @@ def test_format_output_no_recommendations_when_all_ok(): ] out = ps.format_output(ps.ProjectType.BACKEND, groups) assert "Рекомендации:" not in out - assert "FAIL: 0" in out + assert "WARN: 0" in out # ── main / exit codes ──────────────────────────────────────────────────────── @@ -1098,15 +1099,21 @@ def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys): assert "Итог:" in captured.out -def test_main_check_strict_exit_1_on_fail(monkeypatch, tmp_path, capsys): +def test_main_check_flag_exit_0_always(monkeypatch, tmp_path, capsys): + """``--check`` is accepted but exit code is always 0 (issue #275). + + Previously ``--check`` forced exit 1 on FAIL; with the all-WARN contract + no check returns FAIL and the exit code is informational (always 0). + """ 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 + assert exc.value.code == 0 -def test_main_check_strict_exit_0_when_all_ok(monkeypatch, tmp_path, capsys): +def test_main_check_flag_exit_0_when_all_ok(monkeypatch, tmp_path, capsys): + """``--check`` with a healthy repo → exit 0 (issue #275: always 0).""" _make_backend_repo(tmp_path) for rel in [".github/workflows", ".github"]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) @@ -1296,17 +1303,17 @@ def test_check_pyproject_no_pyproject_warn(tmp_path, ctx): assert len(group.checks) == 1 -def test_check_pyproject_invalid_toml_fail(tmp_path, ctx): +def test_check_pyproject_invalid_toml_warn(tmp_path, ctx): (tmp_path / "pyproject.toml").write_text("not valid = = =") group = ps.check_pyproject(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) -def test_check_pyproject_check1_build_system_fail(tmp_path, ctx): +def test_check_pyproject_check1_build_system_warn(tmp_path, ctx): _write_full_pyproject(tmp_path, has_build_system=False, src_pkg_exists=True) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path, ctx): @@ -1318,15 +1325,15 @@ def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path, ctx): ) -def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path, ctx): - """src// exists but packages doesn't reference it → FAIL.""" +def test_check_pyproject_check2_hatch_wheel_warn_missing_packages(tmp_path, ctx): + """src// exists but packages doesn't reference it → WARN (issue #275).""" (tmp_path / "src").mkdir() (tmp_path / "src" / "test_repo").mkdir() (tmp_path / "src" / "test_repo" / "__init__.py").write_text("") _write_full_pyproject(tmp_path, src_pkg_exists=False) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( - c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.FAIL + c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -1365,10 +1372,10 @@ def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path, ctx): ) -def test_check_pyproject_check3_project_fields_fail(tmp_path, ctx): +def test_check_pyproject_check3_project_fields_warn(tmp_path, ctx): _write_full_pyproject(tmp_path, has_project_fields=False, src_pkg_exists=True) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "[project]" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "[project]" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_check_pyproject_check4_ruff_ok(tmp_path, ctx): @@ -1384,10 +1391,10 @@ def test_check_pyproject_check4_ruff_toml_ok(tmp_path, ctx): assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks) -def test_check_pyproject_check4_ruff_missing_fail(tmp_path, ctx): +def test_check_pyproject_check4_ruff_missing_warn(tmp_path, ctx): _write_full_pyproject(tmp_path, has_ruff=False, src_pkg_exists=True) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_check_pyproject_check5_mypy_strict_ok(tmp_path, ctx): @@ -1409,10 +1416,10 @@ def test_check_pyproject_check5_mypy_not_strict_warn(tmp_path, ctx): assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks) -def test_check_pyproject_check5_mypy_missing_fail(tmp_path, ctx): +def test_check_pyproject_check5_mypy_missing_warn(tmp_path, ctx): _write_full_pyproject(tmp_path, has_mypy=False, src_pkg_exists=True) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) - assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.FAIL for c in group.checks) + assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks) def test_check_pyproject_check6_pytest_ok(tmp_path, ctx): @@ -1424,11 +1431,11 @@ def test_check_pyproject_check6_pytest_ok(tmp_path, ctx): ) -def test_check_pyproject_check6_pytest_missing_fail(tmp_path, ctx): +def test_check_pyproject_check6_pytest_missing_warn(tmp_path, ctx): _write_full_pyproject(tmp_path, has_pytest=False, src_pkg_exists=True) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( - c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.FAIL + c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -1549,13 +1556,13 @@ def test_check_pyproject_check13_python_version_ok(tmp_path, ctx): ) -def test_check_pyproject_check13_python_version_fail_incompat(tmp_path, ctx): +def test_check_pyproject_check13_python_version_warn_incompat(tmp_path, ctx): _write_full_pyproject( tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True ) group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx) assert any( - c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.FAIL + c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.WARN for c in group.checks ) @@ -1828,9 +1835,9 @@ def test_subfunc_check_build_system_ok(tmp_path, ctx): assert ps._check_build_system(data).status == ps.CheckStatus.OK -def test_subfunc_check_build_system_fail(tmp_path, ctx): +def test_subfunc_check_build_system_warn(tmp_path, ctx): data = _full_data(tmp_path, has_build_system=False) - assert ps._check_build_system(data).status == ps.CheckStatus.FAIL + assert ps._check_build_system(data).status == ps.CheckStatus.WARN def test_subfunc_check_hatch_packages_ok_nested(tmp_path, ctx): @@ -1857,10 +1864,10 @@ def test_subfunc_check_project_fields_ok(tmp_path, ctx): assert ps._check_project_fields(project).status == ps.CheckStatus.OK -def test_subfunc_check_project_fields_fail(tmp_path, ctx): +def test_subfunc_check_project_fields_warn(tmp_path, ctx): data = _full_data(tmp_path, has_project_fields=False) project = data.get("project", {}) - assert ps._check_project_fields(project).status == ps.CheckStatus.FAIL + assert ps._check_project_fields(project).status == ps.CheckStatus.WARN def test_subfunc_check_ruff_section_ok(tmp_path, ctx): @@ -1873,9 +1880,9 @@ def test_subfunc_check_ruff_section_ruff_toml_ok(tmp_path, ctx): assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK -def test_subfunc_check_ruff_section_missing_fail(tmp_path, ctx): +def test_subfunc_check_ruff_section_missing_warn(tmp_path, ctx): data = _full_data(tmp_path, has_ruff=False) - assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.FAIL + assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.WARN def test_subfunc_check_mypy_section_strict_ok(tmp_path, ctx): @@ -1893,9 +1900,9 @@ def test_subfunc_check_mypy_section_not_strict_warn(tmp_path, ctx): assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN -def test_subfunc_check_mypy_section_missing_fail(tmp_path, ctx): +def test_subfunc_check_mypy_section_missing_warn(tmp_path, ctx): data = _full_data(tmp_path, has_mypy=False) - assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.FAIL + assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN def test_subfunc_check_pytest_ini_options_ok(tmp_path, ctx): @@ -1904,10 +1911,10 @@ def test_subfunc_check_pytest_ini_options_ok(tmp_path, ctx): assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.OK -def test_subfunc_check_pytest_ini_options_missing_fail(tmp_path, ctx): +def test_subfunc_check_pytest_ini_options_missing_warn(tmp_path, ctx): data = _full_data(tmp_path, has_pytest=False) tools = data.get("tool", {}) - assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.FAIL + assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.WARN def test_subfunc_check_coverage_run_ok(tmp_path, ctx): @@ -1989,13 +1996,13 @@ def test_subfunc_check_python_version_compat_ok(tmp_path, ctx): assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.OK -def test_subfunc_check_python_version_compat_fail_incompat(tmp_path, ctx): +def test_subfunc_check_python_version_compat_warn_incompat(tmp_path, ctx): _write_full_pyproject( tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True ) data = ps._load_pyproject(tmp_path) project = data.get("project", {}) - assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.FAIL + assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.WARN def test_subfunc_check_python_version_compat_skip_no_file(tmp_path, ctx): @@ -2024,7 +2031,7 @@ def test_load_pyproject_parse_error_sentinel(tmp_path, ctx): assert data is not None assert "__parse_error__" in data group = ps.check_pyproject(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) @@ -2342,3 +2349,65 @@ def test_detect_backend_use_db_no(tmp_path, ctx): assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND, ( "use_db=no backend must detect as BACKEND (db/models not required for type detection)" ) + + +# ── issue #275: all-WARN non-blocking contract ────────────────────────────── + + +def test_all_checks_are_warn(tmp_path, ctx): + """No check-function returns FAIL — every problem is WARN (issue #275). + + Builds a backend repo missing several expected pieces (no tests dir, + missing ruff/mypy sections, missing README) and asserts that across all + 8 groups no CheckResult carries ``CheckStatus.FAIL``. ``FAIL`` is kept + in the enum for backward compatibility (tests import it, externally + constructed results roll up to FAIL), but no check-function emits it. + """ + # minimal pyproject with fastapi dep but NO ruff/mypy/pytest sections + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], has_ruff=False, has_mypy=False) + (tmp_path / "main.py").write_text( + "from contextlib import asynccontextmanager\n" + "@asynccontextmanager\nasync def lifespan(app): yield\n" + ) + pkg = "test_repo" + for rel in [ + f"src/{pkg}/api/v1", + 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") + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")})) + try: + groups = ps.run_all_checks(ps.ProjectType.BACKEND, ctx, fast=True) + finally: + monkeypatch.undo() + failures = [ + (g.name, c.name, c.status.value) + for g in groups + for c in g.checks + if c.status == ps.CheckStatus.FAIL + ] + assert not failures, f"issue #275 violation — FAIL checks found: {failures}" + + +def test_exit_code_always_zero(monkeypatch, tmp_path, capsys): + """``main()`` exits 0 even when the repo has problems (issue #275). + + A repo with no pyproject, no tests, no README → many WARN, but exit code + is 0 (informational mode). ``--check`` is accepted but no longer forces 1. + """ + 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 "WARN" in captured.out + # Итог line present and shows OK/WARN only (no FAIL — issue #275) + assert "Итог:" in captured.out + assert "WARN: " in captured.out + assert "FAIL:" not in captured.out