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:
parent
c9198a40a7
commit
43975cb593
6 changed files with 327 additions and 201 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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, НЕ хардкод.
|
||||
|
|
|
|||
|
|
@ -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 отчёта.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue