diff --git a/src/spawn/utils/doctor.py b/src/spawn/utils/doctor.py index 59857c2..76d4382 100644 --- a/src/spawn/utils/doctor.py +++ b/src/spawn/utils/doctor.py @@ -6,25 +6,77 @@ from dataclasses import dataclass from pathlib import Path -from typing import List, Callable +from typing import Callable, List from rich.panel import Panel from rich.text import Text from spawn.utils.console import console +# --------------------------------------------------------------------------- +# Tier sets for recommendations +# --------------------------------------------------------------------------- + +_CRITICAL: set[str] = {"Git Repository", "Tests"} + +_RECOMMENDED: set[str] = { + "README.md", + ".gitignore", + "LICENSE", + "Ruff", + "Pytest", + "GitHub Actions", + "pyproject.toml", +} + +_OPTIONAL: set[str] = { + "Dockerfile", + ".env.example", + "CHANGELOG.md", + "Type Checking", + "Pre-commit", +} + +# Complete-sentence recommendation text keyed by check name +_REC_TEXT: dict[str, str] = { + "Git Repository": "Initialize a git repository with 'git init'.", + "Tests": "Create a tests/ directory and add test files.", + "README.md": "Add a README.md file to document your project.", + ".gitignore": "Add a .gitignore file to exclude generated files from version control.", + "LICENSE": "Add a LICENSE file to specify usage terms for your project.", + "Ruff": "Configure Ruff in pyproject.toml for automated code quality checks.", + "Pytest": "Configure Pytest in pyproject.toml or create pytest.ini.", + "GitHub Actions": "Set up GitHub Actions in .github/workflows/ for automated CI/CD.", + "pyproject.toml": "Add a pyproject.toml to centralise project configuration.", + "Dockerfile": "Add a Dockerfile for reproducible, containerised deployment.", + ".env.example": "Create a .env.example file to document required environment variables.", + "CHANGELOG.md": "Add a CHANGELOG.md to document project history and releases.", + "Type Checking": "Configure a type checker (mypy or pyright) to catch type errors early.", + "Pre-commit": "Add a .pre-commit-config.yaml to enforce checks before every commit.", +} + +# Estimated effort per check name +_EFFORT: dict[str, str] = { + "Git Repository": "30 seconds", + "README.md": "2 minutes", + "LICENSE": "1 minute", + "CHANGELOG.md": "2 minutes", + ".gitignore": "1 minute", + "pyproject.toml": "2 minutes", + ".env.example": "2 minutes", + "Tests": "10 minutes", + "Pytest": "5 minutes", + "Ruff": "5 minutes", + "Type Checking": "5 minutes", + "Pre-commit": "5 minutes", + "GitHub Actions": "10 minutes", + "Dockerfile": "15 minutes", +} + @dataclass class HealthCheck: - """Represents a single health check result. - - Attributes: - name: Display name of the check - category: Category this check belongs to - passed: Whether the check passed - message: Descriptive message about the result - weight: Weight for scoring (higher = more important) - """ + """Represents a single health check result.""" name: str category: str @@ -34,438 +86,429 @@ class HealthCheck: class ProjectHealthChecker: - """Analyzes project health and best practices compliance. - - This class performs various checks on a project directory to ensure - it follows common best practices and has essential configurations. - """ + """Analyzes project health and best-practices compliance.""" def __init__(self, project_path: Path | None = None): - """Initialize the health checker. - - Args: - project_path: Path to the project directory. Defaults to current directory. - """ self.project_path = project_path or Path.cwd() + # ------------------------------------------------------------------ # Documentation Checks + # ------------------------------------------------------------------ def check_readme(self) -> HealthCheck: - """Check if README.md exists.""" - readme_path = self.project_path / "README.md" - passed = readme_path.exists() and readme_path.is_file() + p = self.project_path / "README.md" + ok = p.exists() and p.is_file() return HealthCheck( name="README.md", category="Documentation", - passed=passed, - message="Documentation file present" if passed else "Missing README.md", + passed=ok, + message="Documentation file present" if ok else "Missing README.md", weight=10, ) def check_license(self) -> HealthCheck: - """Check if LICENSE exists.""" - license_path = self.project_path / "LICENSE" - passed = license_path.exists() and license_path.is_file() + p = self.project_path / "LICENSE" + ok = p.exists() and p.is_file() return HealthCheck( name="LICENSE", category="Documentation", - passed=passed, - message="License file present" if passed else "Missing LICENSE file", + passed=ok, + message="License file present" if ok else "Missing LICENSE file", weight=5, ) + def check_changelog(self) -> HealthCheck: + p = self.project_path / "CHANGELOG.md" + ok = p.exists() and p.is_file() + return HealthCheck( + name="CHANGELOG.md", + category="Documentation", + passed=ok, + message="Changelog present" if ok else "Missing CHANGELOG.md", + weight=5, + ) + + # ------------------------------------------------------------------ # Version Control Checks + # ------------------------------------------------------------------ def check_git_repository(self) -> HealthCheck: - """Check if project is a git repository.""" - git_path = self.project_path / ".git" - passed = git_path.exists() and git_path.is_dir() + p = self.project_path / ".git" + ok = p.exists() and p.is_dir() return HealthCheck( name="Git Repository", category="Version Control", - passed=passed, - message="Git initialized" - if passed - else "Not a git repository", + passed=ok, + message="Git initialized" if ok else "Not a git repository", weight=15, ) def check_gitignore(self) -> HealthCheck: - """Check if .gitignore exists.""" - gitignore_path = self.project_path / ".gitignore" - passed = gitignore_path.exists() and gitignore_path.is_file() + p = self.project_path / ".gitignore" + ok = p.exists() and p.is_file() return HealthCheck( name=".gitignore", category="Version Control", - passed=passed, - message="Git ignore configured" if passed else "Missing .gitignore", + passed=ok, + message="Git ignore configured" if ok else "Missing .gitignore", weight=10, ) - # Quality Checks + # ------------------------------------------------------------------ + # Configuration Checks + # ------------------------------------------------------------------ - def check_tests_directory(self) -> HealthCheck: - """Check if tests directory exists.""" - tests_path = self.project_path / "tests" - passed = tests_path.exists() and tests_path.is_dir() + def check_env_example(self) -> HealthCheck: + p = self.project_path / ".env.example" + ok = p.exists() and p.is_file() return HealthCheck( - name="Tests", - category="Quality", - passed=passed, - message="Test directory configured" - if passed - else "Missing tests directory", - weight=15, + name=".env.example", + category="Configuration", + passed=ok, + message="Environment template present" if ok else "Missing .env.example", + weight=5, ) - def check_ruff_configured(self) -> HealthCheck: - """Check if Ruff linter is configured. - - Checks for Ruff configuration in: - - pyproject.toml ([tool.ruff] or ruff dependency) - - ruff.toml - - .ruff.toml - """ - pyproject_path = self.project_path / "pyproject.toml" - ruff_config_path = self.project_path / "ruff.toml" - ruff_config_alt_path = self.project_path / ".ruff.toml" - - passed = False - config_location = None - - if ruff_config_path.exists(): - passed = True - config_location = "ruff.toml" - elif ruff_config_alt_path.exists(): - passed = True - config_location = ".ruff.toml" - elif pyproject_path.exists(): - try: - content = pyproject_path.read_text(encoding="utf-8") - if "[tool.ruff]" in content: - passed = True - config_location = "pyproject.toml [tool.ruff]" - elif "ruff>=" in content or '"ruff"' in content: - passed = True - config_location = "pyproject.toml (dependency)" - except (OSError, ValueError): - pass - - message = ( - f"Ruff configured in {config_location}" - if passed - else "Ruff not configured" + def check_pyproject(self) -> HealthCheck: + p = self.project_path / "pyproject.toml" + ok = p.exists() and p.is_file() + return HealthCheck( + name="pyproject.toml", + category="Configuration", + passed=ok, + message="Project configuration present" if ok else "Missing pyproject.toml", + weight=10, ) + # ------------------------------------------------------------------ + # Testing Checks + # ------------------------------------------------------------------ + + def check_tests_directory(self) -> HealthCheck: + p = self.project_path / "tests" + ok = p.exists() and p.is_dir() return HealthCheck( - name="Ruff", - category="Quality", - passed=passed, - message=message, - weight=10, + name="Tests", + category="Testing", + passed=ok, + message="Test directory configured" if ok else "Missing tests directory", + weight=15, ) def check_pytest_configured(self) -> HealthCheck: - """Check if Pytest is configured. - - Checks for Pytest configuration in: - - pyproject.toml ([tool.pytest] or pytest dependency) - - pytest.ini - - setup.cfg - """ - pyproject_path = self.project_path / "pyproject.toml" - pytest_ini_path = self.project_path / "pytest.ini" - setup_cfg_path = self.project_path / "setup.cfg" + pyproject = self.project_path / "pyproject.toml" + pytest_ini = self.project_path / "pytest.ini" + setup_cfg = self.project_path / "setup.cfg" - passed = False - config_location = None + ok = False + location = None - if pytest_ini_path.exists(): - passed = True - config_location = "pytest.ini" - elif setup_cfg_path.exists(): + if pytest_ini.exists(): + ok, location = True, "pytest.ini" + elif setup_cfg.exists(): try: - content = setup_cfg_path.read_text(encoding="utf-8") + content = setup_cfg.read_text(encoding="utf-8") if "[pytest]" in content or "[tool:pytest]" in content: - passed = True - config_location = "setup.cfg" + ok, location = True, "setup.cfg" except (OSError, ValueError): pass - - if not passed and pyproject_path.exists(): + + if not ok and pyproject.exists(): try: - content = pyproject_path.read_text(encoding="utf-8") + content = pyproject.read_text(encoding="utf-8") if "[tool.pytest" in content: - passed = True - config_location = "pyproject.toml [tool.pytest]" + ok, location = True, "pyproject.toml [tool.pytest]" elif "pytest>=" in content or '"pytest"' in content: - passed = True - config_location = "pyproject.toml (dependency)" + ok, location = True, "pyproject.toml (dependency)" except (OSError, ValueError): pass - message = ( - f"Pytest configured in {config_location}" - if passed - else "Pytest not configured" - ) - - return HealthCheck( - name="Pytest", - category="Quality", - passed=passed, - message=message, - weight=10, - ) + msg = f"Pytest configured in {location}" if ok else "Pytest not configured" + return HealthCheck(name="Pytest", category="Testing", passed=ok, message=msg, weight=10) - # Deployment Checks + # ------------------------------------------------------------------ + # Automation Checks + # ------------------------------------------------------------------ def check_dockerfile(self) -> HealthCheck: - """Check if Dockerfile exists.""" - dockerfile_path = self.project_path / "Dockerfile" - passed = dockerfile_path.exists() and dockerfile_path.is_file() + p = self.project_path / "Dockerfile" + ok = p.exists() and p.is_file() return HealthCheck( name="Dockerfile", - category="Deployment", - passed=passed, - message="Docker configuration present" - if passed - else "Missing Dockerfile", + category="Automation", + passed=ok, + message="Docker configuration present" if ok else "Missing Dockerfile", weight=10, ) def check_github_actions(self) -> HealthCheck: - """Check if GitHub Actions workflow exists. + workflows = self.project_path / ".github" / "workflows" + count = 0 + if workflows.exists() and workflows.is_dir(): + count = len(list(workflows.glob("*.yml")) + list(workflows.glob("*.yaml"))) + + ok = count > 0 + msg = ( + f"GitHub Actions configured ({count} workflow{'s' if count != 1 else ''})" + if ok + else "GitHub Actions not configured" + ) + return HealthCheck( + name="GitHub Actions", + category="Automation", + passed=ok, + message=msg, + weight=10, + ) - Checks for workflow files (.yml or .yaml) in .github/workflows/ - """ - workflows_path = self.project_path / ".github" / "workflows" - passed = False - workflow_count = 0 + # ------------------------------------------------------------------ + # Code Quality Checks + # ------------------------------------------------------------------ - if workflows_path.exists() and workflows_path.is_dir(): - workflow_files = list(workflows_path.glob("*.yml")) + list( - workflows_path.glob("*.yaml") - ) - workflow_count = len(workflow_files) - passed = workflow_count > 0 + def check_ruff_configured(self) -> HealthCheck: + pyproject = self.project_path / "pyproject.toml" + ruff_toml = self.project_path / "ruff.toml" + ruff_toml_alt = self.project_path / ".ruff.toml" + + ok = False + location = None + + if ruff_toml.exists(): + ok, location = True, "ruff.toml" + elif ruff_toml_alt.exists(): + ok, location = True, ".ruff.toml" + elif pyproject.exists(): + try: + content = pyproject.read_text(encoding="utf-8") + if "[tool.ruff]" in content: + ok, location = True, "pyproject.toml [tool.ruff]" + elif "ruff>=" in content or '"ruff"' in content: + ok, location = True, "pyproject.toml (dependency)" + except (OSError, ValueError): + pass - message = ( - f"GitHub Actions configured ({workflow_count} workflow{'s' if workflow_count != 1 else ''})" - if passed - else "GitHub Actions not configured" + msg = f"Ruff configured in {location}" if ok else "Ruff not configured" + return HealthCheck( + name="Ruff", category="Code Quality", passed=ok, message=msg, weight=10 ) + def check_type_checker_configured(self) -> HealthCheck: + """Check for mypy or pyright configuration.""" + pyproject = self.project_path / "pyproject.toml" + mypy_ini = self.project_path / "mypy.ini" + pyright_cfg = self.project_path / "pyrightconfig.json" + + ok = False + location = None + + if mypy_ini.exists(): + ok, location = True, "mypy.ini" + elif pyright_cfg.exists(): + ok, location = True, "pyrightconfig.json" + elif pyproject.exists(): + try: + content = pyproject.read_text(encoding="utf-8") + if "[tool.mypy]" in content: + ok, location = True, "pyproject.toml [tool.mypy]" + elif "[tool.pyright]" in content: + ok, location = True, "pyproject.toml [tool.pyright]" + except (OSError, ValueError): + pass + + msg = f"Type checking configured ({location})" if ok else "Type checking not configured" return HealthCheck( - name="GitHub Actions", - category="Deployment", - passed=passed, - message=message, + name="Type Checking", + category="Code Quality", + passed=ok, + message=msg, weight=10, ) - # Configuration Checks - - def check_env_example(self) -> HealthCheck: - """Check if .env.example exists.""" - env_example_path = self.project_path / ".env.example" - passed = env_example_path.exists() and env_example_path.is_file() + def check_precommit_configured(self) -> HealthCheck: + """Check for .pre-commit-config.yaml.""" + p = self.project_path / ".pre-commit-config.yaml" + ok = p.exists() and p.is_file() return HealthCheck( - name=".env.example", - category="Configuration", - passed=passed, - message="Environment template present" - if passed - else "Missing .env.example", + name="Pre-commit", + category="Code Quality", + passed=ok, + message="Pre-commit configured" if ok else "Pre-commit not configured", weight=5, ) - def get_all_checks(self) -> List[Callable[[], HealthCheck]]: - """Get all health check methods. + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ - Returns: - List of health check methods to run - """ + def get_all_checks(self) -> List[Callable[[], HealthCheck]]: return [ self.check_readme, self.check_license, + self.check_changelog, self.check_git_repository, self.check_gitignore, + self.check_env_example, + self.check_pyproject, self.check_tests_directory, - self.check_ruff_configured, self.check_pytest_configured, self.check_dockerfile, self.check_github_actions, - self.check_env_example, + self.check_ruff_configured, + self.check_type_checker_configured, + self.check_precommit_configured, ] def run_all_checks(self) -> List[HealthCheck]: - """Run all health checks and return results. - - Returns: - List of HealthCheck results - """ return [check() for check in self.get_all_checks()] def calculate_score(self, checks: List[HealthCheck]) -> tuple[int, int]: - """Calculate overall health score using weighted scoring. - - Args: - checks: List of health check results - - Returns: - Tuple of (score, max_score) where score is the weighted sum - """ if not checks: return 0, 0 + total = sum(c.weight for c in checks) + earned = sum(c.weight for c in checks if c.passed) + return earned, total - total_weight = sum(check.weight for check in checks) - earned_weight = sum(check.weight for check in checks if check.passed) - - return earned_weight, total_weight + def calculate_category_scores( + self, checks: List[HealthCheck] + ) -> dict[str, tuple[int, int]]: + """Return {category: (earned_weight, total_weight)} per category.""" + cats = self.group_checks_by_category(checks) + return { + cat: ( + sum(c.weight for c in cs if c.passed), + sum(c.weight for c in cs), + ) + for cat, cs in cats.items() + } def group_checks_by_category( self, checks: List[HealthCheck] ) -> dict[str, List[HealthCheck]]: - """Group health checks by category. - - Args: - checks: List of health check results - - Returns: - Dictionary mapping category names to lists of checks - """ - categories: dict[str, List[HealthCheck]] = {} + result: dict[str, List[HealthCheck]] = {} for check in checks: - if check.category not in categories: - categories[check.category] = [] - categories[check.category].append(check) - return categories - - def generate_recommendations(self, checks: List[HealthCheck]) -> List[str]: - """Generate actionable recommendations for failed checks. - - Args: - checks: List of health check results + result.setdefault(check.category, []).append(check) + return result + + def get_health_rating(self, score_percent: int) -> tuple[str, str, str]: + """Return (emoji, label, description) for a 0-100 score.""" + if score_percent >= 90: + return ("🟢", "Excellent", "Project follows most recommended practices.") + if score_percent >= 70: + return ("🟡", "Good", "Some improvements recommended.") + if score_percent >= 50: + return ("🟠", "Fair", "Several recommended practices are missing.") + return ("🔴", "Needs Attention", "Core project setup is incomplete.") + + def get_next_best_step( + self, checks: List[HealthCheck] + ) -> tuple[str, str] | None: + """Return (recommendation, effort) for the single highest-priority failed check.""" + failed = [c for c in checks if not c.passed] + if not failed: + return None + + def _tier_key(c: HealthCheck) -> tuple[int, int]: + if c.name in _CRITICAL: + return (0, -c.weight) + if c.name in _RECOMMENDED: + return (1, -c.weight) + return (2, -c.weight) + + top = min(failed, key=_tier_key) + rec = _REC_TEXT.get(top.name, f"Address: {top.name}.") + effort = _EFFORT.get(top.name, "a few minutes") + return (rec, effort) + + def generate_recommendations( + self, checks: List[HealthCheck] + ) -> List[str]: + """Return tiered recommendations for all failed checks. - Returns: - List of recommendation strings + Returns a flat list ordered Critical → Recommended → Optional, + with each item sorted by weight (descending) within its tier. + Only failed checks are included. """ - recommendations = [] - failed_checks = [check for check in checks if not check.passed] - - # Priority order for recommendations - priority_map = { - "Git Repository": ( - "Initialize a git repository with 'git init'", - 1, - ), - "README.md": ( - "Add a README.md file to document your project", - 2, - ), - "Tests": ( - "Create a tests/ directory and add test files", - 3, - ), - ".gitignore": ( - "Add a .gitignore file to exclude unnecessary files from version control", - 4, - ), - "Pytest": ( - "Configure Pytest in pyproject.toml or create pytest.ini", - 5, - ), - "Ruff": ( - "Configure Ruff linter in pyproject.toml for code quality", - 6, - ), - "GitHub Actions": ( - "Set up GitHub Actions in .github/workflows/ for CI/CD", - 7, - ), - "Dockerfile": ( - "Add a Dockerfile for containerized deployment", - 8, - ), - "LICENSE": ( - "Add a LICENSE file to specify usage terms", - 9, - ), - ".env.example": ( - "Create a .env.example file to document required environment variables", - 10, - ), - } + failed = [c for c in checks if not c.passed] - # Sort by priority and generate recommendations - sorted_failed = sorted( - failed_checks, - key=lambda c: priority_map.get(c.name, (c.name, 999))[1], - ) + def _sort_key(c: HealthCheck) -> tuple[int, int]: + if c.name in _CRITICAL: + tier = 0 + elif c.name in _RECOMMENDED: + tier = 1 + else: + tier = 2 + return (tier, -c.weight) - for check in sorted_failed: - rec = priority_map.get(check.name, (f"Address: {check.name}", 999))[0] - recommendations.append(rec) + return [ + _REC_TEXT.get(c.name, f"Address: {c.name}.") + for c in sorted(failed, key=_sort_key) + ] - return recommendations + def _tiered_recs( + self, checks: List[HealthCheck] + ) -> tuple[List[str], List[str], List[str]]: + """Return (critical_recs, recommended_recs, optional_recs) — failed checks only.""" + failed = [c for c in checks if not c.passed] + critical, recommended, optional = [], [], [] + for c in sorted(failed, key=lambda x: -x.weight): + text = _REC_TEXT.get(c.name, f"Address: {c.name}.") + if c.name in _CRITICAL: + critical.append(text) + elif c.name in _RECOMMENDED: + recommended.append(text) + else: + optional.append(text) + return critical, recommended, optional def format_report(self, checks: List[HealthCheck]) -> None: - """Display formatted health check report using Rich. - - Args: - checks: List of health check results - """ score, max_score = self.calculate_score(checks) score_percent = int((score / max_score * 100) if max_score > 0 else 0) + emoji, label, description = self.get_health_rating(score_percent) categories = self.group_checks_by_category(checks) + category_scores = self.calculate_category_scores(checks) - # Create main content content = Text() - # Add category sections + # Health rating + content.append(f"{emoji} {label}\n", style="bold") + content.append(f"{description}\n", style="dim") + content.append("\n") + + # Score + if score_percent >= 80: + score_color = "green" + elif score_percent >= 50: + score_color = "yellow" + else: + score_color = "red" + content.append("Project Score: ", style="bold") + content.append(f"{score_percent}% ({score}/{max_score})\n", style=f"bold {score_color}") + category_order = [ "Documentation", "Version Control", - "Quality", - "Deployment", "Configuration", + "Testing", + "Automation", + "Code Quality", ] for category in category_order: if category not in categories: continue - - content.append(f"\n{category}\n", style="bold cyan") + cat_earned, cat_total = category_scores.get(category, (0, 0)) + cat_pct = int((cat_earned / cat_total * 100) if cat_total else 0) + content.append(f"\n{category} — {cat_pct}%\n", style="bold cyan") for check in categories[category]: if check.passed: - status = "✓" - style = "green" + status, style = "✓", "green" else: - status = "⚠" - style = "yellow" - + status, style = "⚠", "yellow" content.append(f" {status} ", style=style) - content.append(f"{check.name}", style=f"bold {style}") + content.append(check.name, style=f"bold {style}") content.append(f" — {check.message}\n", style=style) - # Add score - content.append("\n") - if score_percent >= 80: - score_color = "green" - elif score_percent >= 50: - score_color = "yellow" - else: - score_color = "red" - - content.append("Project Score: ", style="bold") - content.append( - f"{score}/{max_score} ({score_percent}%)", - style=f"bold {score_color}", - ) - - # Display main panel console.print() console.print( Panel( @@ -476,15 +519,28 @@ def format_report(self, checks: List[HealthCheck]) -> None: ) ) - # Display recommendations if any - recommendations = self.generate_recommendations(checks) - if recommendations: - console.print() + # Tiered recommendations panel + critical, recommended, optional = self._tiered_recs(checks) + if critical or recommended or optional: rec_content = Text() - for i, rec in enumerate(recommendations, 1): - rec_content.append(f"{i}. ", style="bold yellow") - rec_content.append(f"{rec}\n", style="yellow") + if critical: + rec_content.append("Critical\n", style="bold red") + for r in critical: + rec_content.append(f" • {r}\n", style="red") + if recommended: + if critical: + rec_content.append("\n") + rec_content.append("Recommended\n", style="bold yellow") + for r in recommended: + rec_content.append(f" • {r}\n", style="yellow") + if optional: + if critical or recommended: + rec_content.append("\n") + rec_content.append("Optional\n", style="bold dim") + for r in optional: + rec_content.append(f" • {r}\n", style="dim") + console.print() console.print( Panel( rec_content, @@ -494,17 +550,29 @@ def format_report(self, checks: List[HealthCheck]) -> None: ) ) + # Next Best Step panel + nbs = self.get_next_best_step(checks) + if nbs: + rec_text, effort = nbs + nbs_content = Text() + nbs_content.append(f"{rec_text}\n\n", style="bold") + nbs_content.append("Estimated effort: ", style="dim") + nbs_content.append(effort, style="bold dim") + console.print() + console.print( + Panel( + nbs_content, + title="[bold green]🎯 Next Best Step[/bold green]", + border_style="green", + padding=(1, 2), + ) + ) + console.print() def run_health_check(project_path: Path | None = None) -> None: - """Run project health check and display report. - - This is the main entry point for the doctor command. - - Args: - project_path: Path to the project directory. Defaults to current directory. - """ + """CLI entry point for `spawn doctor`.""" checker = ProjectHealthChecker(project_path) checks = checker.run_all_checks() checker.format_report(checks) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 627e87d..c30c105 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -24,6 +24,7 @@ def complete_project(temp_project_dir): # Documentation (temp_project_dir / "README.md").write_text("# Test Project") (temp_project_dir / "LICENSE").write_text("MIT License") + (temp_project_dir / "CHANGELOG.md").write_text("# Changelog") # Version control (temp_project_dir / ".git").mkdir() @@ -33,7 +34,7 @@ def complete_project(temp_project_dir): (temp_project_dir / "tests").mkdir() (temp_project_dir / "tests" / "__init__.py").touch() - # Deployment + # Automation (temp_project_dir / "Dockerfile").write_text("FROM python:3.12") workflows_dir = temp_project_dir / ".github" / "workflows" workflows_dir.mkdir(parents=True) @@ -42,9 +43,9 @@ def complete_project(temp_project_dir): # Configuration (temp_project_dir / ".env.example").write_text("API_KEY=") - # Python project files with ruff and pytest + # Python project files with ruff, pytest, mypy, and pre-commit pyproject = temp_project_dir / "pyproject.toml" - pyproject.write_text(""" + pyproject.write_text("""\ [project] name = "test-project" dependencies = [] @@ -54,8 +55,16 @@ def complete_project(temp_project_dir): [tool.ruff] line-length = 88 + +[tool.mypy] +strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] """) + (temp_project_dir / ".pre-commit-config.yaml").write_text("repos: []") + return temp_project_dir @@ -71,7 +80,6 @@ class TestHealthCheck: """Tests for HealthCheck dataclass.""" def test_health_check_creation(self): - """Test creating a HealthCheck instance.""" check = HealthCheck( name="Test Check", category="Testing", @@ -86,7 +94,6 @@ def test_health_check_creation(self): assert check.weight == 10 def test_health_check_default_weight(self): - """Test HealthCheck with default weight.""" check = HealthCheck( name="Test", category="Testing", @@ -100,434 +107,572 @@ class TestProjectHealthChecker: """Tests for ProjectHealthChecker class.""" def test_checker_initialization_default_path(self): - """Test checker initializes with current directory by default.""" checker = ProjectHealthChecker() assert checker.project_path == Path.cwd() def test_checker_initialization_custom_path(self, temp_project_dir): - """Test checker initializes with custom path.""" checker = ProjectHealthChecker(temp_project_dir) assert checker.project_path == temp_project_dir # Documentation Checks def test_check_readme_exists(self, temp_project_dir): - """Test README.md check when file exists.""" (temp_project_dir / "README.md").write_text("# Test") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_readme() - assert result.name == "README.md" assert result.category == "Documentation" assert result.passed is True assert "present" in result.message.lower() def test_check_readme_missing(self, temp_project_dir): - """Test README.md check when file is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_readme() - assert result.passed is False assert "missing" in result.message.lower() def test_check_license_exists(self, temp_project_dir): - """Test LICENSE check when file exists.""" (temp_project_dir / "LICENSE").write_text("MIT") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_license() - assert result.name == "LICENSE" assert result.passed is True def test_check_license_missing(self, temp_project_dir): - """Test LICENSE check when file is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_license() - assert result.passed is False # Version Control Checks def test_check_git_repository_exists(self, temp_project_dir): - """Test git repository check when .git exists.""" (temp_project_dir / ".git").mkdir() checker = ProjectHealthChecker(temp_project_dir) result = checker.check_git_repository() - assert result.name == "Git Repository" assert result.category == "Version Control" assert result.passed is True assert "initialized" in result.message.lower() def test_check_git_repository_missing(self, temp_project_dir): - """Test git repository check when .git is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_git_repository() - assert result.passed is False assert "not a git repository" in result.message.lower() def test_check_gitignore_exists(self, temp_project_dir): - """Test .gitignore check when file exists.""" (temp_project_dir / ".gitignore").write_text("*.pyc") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_gitignore() - assert result.name == ".gitignore" assert result.passed is True def test_check_gitignore_missing(self, temp_project_dir): - """Test .gitignore check when file is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_gitignore() - assert result.passed is False - # Quality Checks + # Testing Checks def test_check_tests_directory_exists(self, temp_project_dir): - """Test tests directory check when directory exists.""" (temp_project_dir / "tests").mkdir() checker = ProjectHealthChecker(temp_project_dir) result = checker.check_tests_directory() - assert result.name == "Tests" - assert result.category == "Quality" + assert result.category == "Testing" assert result.passed is True def test_check_tests_directory_missing(self, temp_project_dir): - """Test tests directory check when directory is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_tests_directory() - assert result.passed is False def test_check_ruff_in_pyproject_tool_section(self, temp_project_dir): - """Test Ruff check with [tool.ruff] in pyproject.toml.""" - pyproject = temp_project_dir / "pyproject.toml" - pyproject.write_text("[tool.ruff]\nline-length = 88") + (temp_project_dir / "pyproject.toml").write_text("[tool.ruff]\nline-length = 88") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_ruff_configured() - assert result.name == "Ruff" assert result.passed is True assert "pyproject.toml" in result.message def test_check_ruff_in_pyproject_dependency(self, temp_project_dir): - """Test Ruff check with ruff as dependency in pyproject.toml.""" - pyproject = temp_project_dir / "pyproject.toml" - pyproject.write_text('[dependency-groups]\ndev = ["ruff>=0.15.0"]') + (temp_project_dir / "pyproject.toml").write_text('[dependency-groups]\ndev = ["ruff>=0.15.0"]') checker = ProjectHealthChecker(temp_project_dir) result = checker.check_ruff_configured() - assert result.passed is True assert "dependency" in result.message def test_check_ruff_in_ruff_toml(self, temp_project_dir): - """Test Ruff check with ruff.toml file.""" (temp_project_dir / "ruff.toml").write_text("line-length = 88") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_ruff_configured() - assert result.passed is True assert "ruff.toml" in result.message def test_check_ruff_not_configured(self, temp_project_dir): - """Test Ruff check when not configured.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_ruff_configured() - assert result.passed is False assert "not configured" in result.message def test_check_pytest_in_pyproject_tool_section(self, temp_project_dir): - """Test Pytest check with [tool.pytest] in pyproject.toml.""" - pyproject = temp_project_dir / "pyproject.toml" - pyproject.write_text("[tool.pytest.ini_options]\ntestpaths = ['tests']") + (temp_project_dir / "pyproject.toml").write_text("[tool.pytest.ini_options]\ntestpaths = ['tests']") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_pytest_configured() - assert result.name == "Pytest" assert result.passed is True assert "pyproject.toml" in result.message def test_check_pytest_in_pyproject_dependency(self, temp_project_dir): - """Test Pytest check with pytest as dependency.""" - pyproject = temp_project_dir / "pyproject.toml" - pyproject.write_text('[dependency-groups]\ndev = ["pytest>=9.0.0"]') + (temp_project_dir / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest>=9.0.0"]') checker = ProjectHealthChecker(temp_project_dir) result = checker.check_pytest_configured() - assert result.passed is True def test_check_pytest_in_pytest_ini(self, temp_project_dir): - """Test Pytest check with pytest.ini file.""" (temp_project_dir / "pytest.ini").write_text("[pytest]\ntestpaths = tests") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_pytest_configured() - assert result.passed is True assert "pytest.ini" in result.message def test_check_pytest_not_configured(self, temp_project_dir): - """Test Pytest check when not configured.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_pytest_configured() - assert result.passed is False - # Deployment Checks + # Automation Checks def test_check_dockerfile_exists(self, temp_project_dir): - """Test Dockerfile check when file exists.""" (temp_project_dir / "Dockerfile").write_text("FROM python:3.12") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_dockerfile() - assert result.name == "Dockerfile" - assert result.category == "Deployment" + assert result.category == "Automation" assert result.passed is True def test_check_dockerfile_missing(self, temp_project_dir): - """Test Dockerfile check when file is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_dockerfile() - assert result.passed is False def test_check_github_actions_with_workflows(self, temp_project_dir): - """Test GitHub Actions check with workflow files.""" workflows_dir = temp_project_dir / ".github" / "workflows" workflows_dir.mkdir(parents=True) (workflows_dir / "ci.yml").write_text("name: CI") (workflows_dir / "deploy.yaml").write_text("name: Deploy") - checker = ProjectHealthChecker(temp_project_dir) result = checker.check_github_actions() - assert result.name == "GitHub Actions" assert result.passed is True assert "2 workflows" in result.message def test_check_github_actions_without_workflows(self, temp_project_dir): - """Test GitHub Actions check without workflow files.""" workflows_dir = temp_project_dir / ".github" / "workflows" workflows_dir.mkdir(parents=True) - checker = ProjectHealthChecker(temp_project_dir) result = checker.check_github_actions() - assert result.passed is False assert "not configured" in result.message def test_check_github_actions_missing_directory(self, temp_project_dir): - """Test GitHub Actions check when directory is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_github_actions() - assert result.passed is False # Configuration Checks def test_check_env_example_exists(self, temp_project_dir): - """Test .env.example check when file exists.""" (temp_project_dir / ".env.example").write_text("API_KEY=") checker = ProjectHealthChecker(temp_project_dir) result = checker.check_env_example() - assert result.name == ".env.example" assert result.category == "Configuration" assert result.passed is True def test_check_env_example_missing(self, temp_project_dir): - """Test .env.example check when file is missing.""" checker = ProjectHealthChecker(temp_project_dir) result = checker.check_env_example() - assert result.passed is False # Integration Tests def test_run_all_checks_complete_project(self, complete_project): - """Test running all checks on a complete project.""" checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() - - assert len(checks) == 10 - assert all(isinstance(check, HealthCheck) for check in checks) - # All checks should pass for complete project - assert all(check.passed for check in checks) + assert len(checks) == 14 + assert all(isinstance(c, HealthCheck) for c in checks) + assert all(c.passed for c in checks), [c for c in checks if not c.passed] def test_run_all_checks_minimal_project(self, minimal_project): - """Test running all checks on a minimal project.""" checker = ProjectHealthChecker(minimal_project) checks = checker.run_all_checks() - - assert len(checks) == 10 - # Only README and Git should pass - passed_checks = [check for check in checks if check.passed] - assert len(passed_checks) == 2 + assert len(checks) == 14 + passed = [c for c in checks if c.passed] + assert len(passed) == 2 # README + Git def test_run_all_checks_empty_project(self, temp_project_dir): - """Test running all checks on an empty project.""" checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() - - assert len(checks) == 10 - # No checks should pass - assert all(not check.passed for check in checks) + assert len(checks) == 14 + assert all(not c.passed for c in checks) # Scoring Tests def test_calculate_score_all_passed(self, complete_project): - """Test score calculation when all checks pass.""" checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() score, max_score = checker.calculate_score(checks) - assert score == max_score - assert max_score == 100 # Sum of all weights + assert max_score == 130 def test_calculate_score_none_passed(self, temp_project_dir): - """Test score calculation when no checks pass.""" checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() score, max_score = checker.calculate_score(checks) - assert score == 0 - assert max_score == 100 + assert max_score == 130 def test_calculate_score_empty_checks(self): - """Test score calculation with empty checks list.""" checker = ProjectHealthChecker() score, max_score = checker.calculate_score([]) - assert score == 0 assert max_score == 0 def test_calculate_score_weighted(self, temp_project_dir): - """Test that scoring is properly weighted.""" - # Create a project with only high-weight items - (temp_project_dir / ".git").mkdir() # Weight 15 - (temp_project_dir / "tests").mkdir() # Weight 15 - + (temp_project_dir / ".git").mkdir() # weight 15 + (temp_project_dir / "tests").mkdir() # weight 15 checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() score, max_score = checker.calculate_score(checks) - - assert score == 30 # 15 + 15 - assert max_score == 100 + assert score == 30 + assert max_score == 130 # Grouping Tests def test_group_checks_by_category(self, complete_project): - """Test grouping checks by category.""" checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() - categories = checker.group_checks_by_category(checks) - - assert "Documentation" in categories - assert "Version Control" in categories - assert "Quality" in categories - assert "Deployment" in categories - assert "Configuration" in categories - - assert len(categories["Documentation"]) == 2 - assert len(categories["Version Control"]) == 2 - assert len(categories["Quality"]) == 3 - assert len(categories["Deployment"]) == 2 - assert len(categories["Configuration"]) == 1 + cats = checker.group_checks_by_category(checks) + + assert "Documentation" in cats + assert "Version Control" in cats + assert "Testing" in cats + assert "Automation" in cats + assert "Configuration" in cats + assert "Code Quality" in cats + + assert len(cats["Documentation"]) == 3 # README + LICENSE + CHANGELOG + assert len(cats["Version Control"]) == 2 # git + .gitignore + assert len(cats["Testing"]) == 2 # Tests + Pytest + assert len(cats["Automation"]) == 2 # Dockerfile + GitHub Actions + assert len(cats["Configuration"]) == 2 # .env.example + pyproject.toml + assert len(cats["Code Quality"]) == 3 # Ruff + TypeChecker + Pre-commit # Recommendations Tests def test_generate_recommendations_complete_project(self, complete_project): - """Test recommendations for a complete project.""" checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() - recommendations = checker.generate_recommendations(checks) - - # Complete project should have no recommendations - assert len(recommendations) == 0 + recs = checker.generate_recommendations(checks) + assert len(recs) == 0 def test_generate_recommendations_empty_project(self, temp_project_dir): - """Test recommendations for an empty project.""" checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() - recommendations = checker.generate_recommendations(checks) - - # All 10 checks should generate recommendations - assert len(recommendations) == 10 - # Git should be first priority - assert "git init" in recommendations[0].lower() + recs = checker.generate_recommendations(checks) + assert len(recs) == 14 + assert "git init" in recs[0].lower() def test_generate_recommendations_prioritization(self, temp_project_dir): - """Test that recommendations are prioritized correctly.""" - # Create a project missing only low-priority items (temp_project_dir / ".git").mkdir() (temp_project_dir / "README.md").write_text("# Test") (temp_project_dir / "tests").mkdir() (temp_project_dir / ".gitignore").write_text("*.pyc") - checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() - recommendations = checker.generate_recommendations(checks) - - # Should have 6 recommendations (missing pytest, ruff, gh actions, etc.) - assert len(recommendations) == 6 - # Higher priority items should come first - assert any("pytest" in rec.lower() for rec in recommendations[:3]) + recs = checker.generate_recommendations(checks) + assert len(recs) == 10 # 14 - 4 passing + assert any("pytest" in r.lower() for r in recs[:3]) def test_format_report_runs_without_error(self, complete_project, capsys): - """Test that format_report executes without errors.""" checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() - - # Should not raise any exceptions checker.format_report(checks) - - # Verify some output was generated captured = capsys.readouterr() - # Rich output goes to stdout assert len(captured.out) > 0 or len(captured.err) > 0 class TestRunHealthCheck: - """Tests for the run_health_check function.""" - def test_run_health_check_default_path(self, capsys): - """Test run_health_check with default path.""" from spawn.utils.doctor import run_health_check - - # Should run without errors on current directory run_health_check() - captured = capsys.readouterr() - # Should produce output assert len(captured.out) > 0 or len(captured.err) > 0 def test_run_health_check_custom_path(self, complete_project, capsys): - """Test run_health_check with custom path.""" from spawn.utils.doctor import run_health_check - run_health_check(complete_project) - captured = capsys.readouterr() - # Should produce output assert len(captured.out) > 0 or len(captured.err) > 0 def test_doctor_with_valid_path(tmp_path): - """Test doctor command accepts a valid path argument.""" from spawn.utils.doctor import ProjectHealthChecker checker = ProjectHealthChecker(tmp_path) checks = checker.run_all_checks() assert isinstance(checks, list) - assert len(checks) == 10 + assert len(checks) == 14 def test_doctor_with_invalid_path(tmp_path): - """Test doctor raises correctly on non-existent path.""" from spawn.utils.doctor import ProjectHealthChecker checker = ProjectHealthChecker(Path("/nonexistent/path/xyz")) checks = checker.run_all_checks() - assert all(not check.passed for check in checks) + assert all(not c.passed for c in checks) + + +# --------------------------------------------------------------------------- +# New checks: CHANGELOG.md and pyproject.toml +# --------------------------------------------------------------------------- + +def test_check_changelog_passes_when_present(tmp_path): + (tmp_path / "CHANGELOG.md").write_text("# Changelog") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_changelog() + assert result.name == "CHANGELOG.md" + assert result.category == "Documentation" + assert result.passed is True + assert "present" in result.message.lower() + + +def test_check_changelog_fails_when_missing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_changelog() + assert result.passed is False + assert "missing" in result.message.lower() + + +def test_check_pyproject_passes_when_present(tmp_path): + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_pyproject() + assert result.name == "pyproject.toml" + assert result.category == "Configuration" + assert result.passed is True + + +def test_check_pyproject_fails_when_missing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_pyproject() + assert result.passed is False + assert "missing" in result.message.lower() + + +# --------------------------------------------------------------------------- +# Code Quality new checks +# --------------------------------------------------------------------------- + +def test_check_type_checker_detects_mypy_ini(tmp_path): + (tmp_path / "mypy.ini").write_text("[mypy]\nstrict = True") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_type_checker_configured() + assert result.category == "Code Quality" + assert result.passed is True + assert "mypy.ini" in result.message + + +def test_check_type_checker_detects_pyproject_section(tmp_path): + (tmp_path / "pyproject.toml").write_text("[tool.mypy]\nstrict = true\n") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_type_checker_configured() + assert result.passed is True + assert "mypy" in result.message.lower() + + +def test_check_type_checker_detects_pyrightconfig(tmp_path): + (tmp_path / "pyrightconfig.json").write_text("{}") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_type_checker_configured() + assert result.passed is True + assert "pyrightconfig.json" in result.message + + +def test_check_type_checker_fails_when_missing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_type_checker_configured() + assert result.passed is False + assert "not configured" in result.message.lower() + + +def test_check_precommit_configured(tmp_path): + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []") + checker = ProjectHealthChecker(tmp_path) + result = checker.check_precommit_configured() + assert result.name == "Pre-commit" + assert result.category == "Code Quality" + assert result.passed is True + + +def test_check_precommit_fails_when_missing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_precommit_configured() + assert result.passed is False + + +# --------------------------------------------------------------------------- +# Category assertions +# --------------------------------------------------------------------------- + +def test_ruff_check_category_is_code_quality(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_ruff_configured() + assert result.category == "Code Quality" + + +def test_tests_check_category_is_testing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_tests_directory() + assert result.category == "Testing" + + +def test_dockerfile_check_category_is_automation(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_dockerfile() + assert result.category == "Automation" + + +# --------------------------------------------------------------------------- +# Per-category scoring +# --------------------------------------------------------------------------- + +def test_calculate_category_scores_returns_all_categories(tmp_path): + checker = ProjectHealthChecker(tmp_path) + checks = checker.run_all_checks() + scores = checker.calculate_category_scores(checks) + for cat in ( + "Documentation", "Version Control", "Configuration", + "Testing", "Automation", "Code Quality", + ): + assert cat in scores, f"Missing category: {cat}" + + +def test_calculate_category_scores_correct_math(): + checks = [ + HealthCheck("A", "Documentation", passed=True, message="", weight=10), + HealthCheck("B", "Documentation", passed=False, message="", weight=5), + HealthCheck("C", "Testing", passed=True, message="", weight=15), + ] + checker = ProjectHealthChecker() + scores = checker.calculate_category_scores(checks) + assert scores["Documentation"] == (10, 15) + assert scores["Testing"] == (15, 15) + + +# --------------------------------------------------------------------------- +# Health rating +# --------------------------------------------------------------------------- + +def test_health_rating_excellent(): + checker = ProjectHealthChecker() + emoji, label, _ = checker.get_health_rating(90) + assert emoji == "🟢" + assert label == "Excellent" + emoji2, label2, _ = checker.get_health_rating(100) + assert label2 == "Excellent" + + +def test_health_rating_good(): + checker = ProjectHealthChecker() + emoji, label, _ = checker.get_health_rating(70) + assert emoji == "🟡" + assert label == "Good" + emoji2, label2, _ = checker.get_health_rating(89) + assert label2 == "Good" + + +def test_health_rating_fair(): + checker = ProjectHealthChecker() + emoji, label, _ = checker.get_health_rating(50) + assert emoji == "🟠" + assert label == "Fair" + emoji2, label2, _ = checker.get_health_rating(69) + assert label2 == "Fair" + + +def test_health_rating_needs_attention(): + checker = ProjectHealthChecker() + emoji, label, _ = checker.get_health_rating(49) + assert emoji == "🔴" + assert label == "Needs Attention" + emoji2, label2, _ = checker.get_health_rating(0) + assert label2 == "Needs Attention" + + +# --------------------------------------------------------------------------- +# Tiered recommendations +# --------------------------------------------------------------------------- + +def test_recommendations_are_tiered(): + """Critical items must appear before Recommended before Optional.""" + checks = [ + HealthCheck("Git Repository", "Version Control", passed=False, message="", weight=15), + HealthCheck("Ruff", "Code Quality", passed=False, message="", weight=10), + HealthCheck("Dockerfile", "Automation", passed=False, message="", weight=10), + ] + checker = ProjectHealthChecker() + recs = checker.generate_recommendations(checks) + assert len(recs) == 3 + # Git Repository is Critical → must be first + assert "git" in recs[0].lower() + # Ruff is Recommended, Dockerfile is Optional → Ruff before Dockerfile + ruff_idx = next(i for i, r in enumerate(recs) if "ruff" in r.lower()) + docker_idx = next(i for i, r in enumerate(recs) if "docker" in r.lower()) + assert ruff_idx < docker_idx + + +def test_passed_check_never_recommended(): + """A passing Ruff check must never appear in recommendations.""" + checks = [ + HealthCheck("Ruff", "Code Quality", passed=True, message="ok", weight=10), + HealthCheck("Tests","Testing", passed=False, message="", weight=15), + ] + checker = ProjectHealthChecker() + recs = checker.generate_recommendations(checks) + assert all("ruff" not in r.lower() for r in recs) + assert len(recs) == 1 + + +# --------------------------------------------------------------------------- +# Next Best Step +# --------------------------------------------------------------------------- + +def test_next_best_step_returns_none_when_all_pass(): + checks = [ + HealthCheck("Git Repository", "Version Control", passed=True, message="ok", weight=15), + HealthCheck("README.md", "Documentation", passed=True, message="ok", weight=10), + ] + checker = ProjectHealthChecker() + result = checker.get_next_best_step(checks) + assert result is None + + +def test_next_best_step_prioritizes_critical(): + """Git failing + LICENSE failing → next best step must be about Git.""" + checks = [ + HealthCheck("Git Repository", "Version Control", passed=False, message="", weight=15), + HealthCheck("LICENSE", "Documentation", passed=False, message="", weight=5), + ] + checker = ProjectHealthChecker() + rec_text, effort = checker.get_next_best_step(checks) + assert "git" in rec_text.lower() + + +def test_next_best_step_has_effort_estimate(): + checks = [ + HealthCheck("Tests", "Testing", passed=False, message="", weight=15), + ] + checker = ProjectHealthChecker() + rec_text, effort = checker.get_next_best_step(checks) + assert isinstance(effort, str) + assert len(effort) > 0