refactor(scripts): project-status all-WARN non-blocking contract (#277)

* refactor(scripts): project-status all-WARN non-blocking contract

* refactor(skills): parse WARN instead of FAIL in audit and project-template

* test(scripts): update project-status asserts FAIL to WARN, exit code always 0

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-05 06:10:06 +03:00 committed by GitHub
parent c9198a40a7
commit 43975cb593
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 327 additions and 201 deletions

View file

@ -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/<package>/", "пакет найден")
return CheckResult(CheckStatus.FAIL, "src/<package>/", "пакет не найден")
return CheckResult(CheckStatus.WARN, "src/<package>/", "пакет не найден")
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/<pkg>/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/<pkg>/`` exists.
Returns OK if packages references ``src/<pkg>``; 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)

View file

@ -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 с путями). Источник
находок = секция `Рекомендации:` (каждая строка `- <group> / <name>: <detail>`).
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/<pkg>/`** (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: <warn|fail>
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, НЕ хардкод.

View file

@ -108,14 +108,14 @@ protection на main. Требует локальный git-репо с initial
project-status({})
```
Tool вернёт отчёт: `Project: <type>`, 7 групп `[OK]/[WARN]/[FAIL]`, `Итог:`,
Tool вернёт отчёт: `Project: <type>`, 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: Вопрос — чинить?
```
Найдены проблемы: <N FAIL, M WARN>.
Найдены проблемы: <N WARN>.
Запустить 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 = <owner>, visibility = public (или private если internal).
`skill("bug-discovery")` и следуй протоколу. НЕ чини баг сам.
```
### Template FIX (check flow — починить FAIL из project-status)
### Template FIX (check flow — починить WARN из project-status)
```
Почини проблему из project-status отчёта.

View file

@ -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=<path> 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=<path> 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"),
},

View file

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

View file

@ -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/<pkg>/ exists but packages doesn't reference it → FAIL."""
def test_check_pyproject_check2_hatch_wheel_warn_missing_packages(tmp_path, ctx):
"""src/<pkg>/ 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