From 25b5ffba85552074c14d4be644411cb7f5de86bc Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 11 Jul 2026 16:52:36 +0530 Subject: [PATCH 1/2] Doctor 2.0 --- src/spawn/utils/doctor.py | 66 ++++++++++++++++----- tests/test_doctor.py | 120 +++++++++++++++++++++++++++++++------- 2 files changed, 151 insertions(+), 35 deletions(-) diff --git a/src/spawn/utils/doctor.py b/src/spawn/utils/doctor.py index 59857c2..dcfb0f1 100644 --- a/src/spawn/utils/doctor.py +++ b/src/spawn/utils/doctor.py @@ -110,7 +110,7 @@ def check_tests_directory(self) -> HealthCheck: passed = tests_path.exists() and tests_path.is_dir() return HealthCheck( name="Tests", - category="Quality", + category="Testing", passed=passed, message="Test directory configured" if passed @@ -159,7 +159,7 @@ def check_ruff_configured(self) -> HealthCheck: return HealthCheck( name="Ruff", - category="Quality", + category="Testing", passed=passed, message=message, weight=10, @@ -212,13 +212,13 @@ def check_pytest_configured(self) -> HealthCheck: return HealthCheck( name="Pytest", - category="Quality", + category="Testing", passed=passed, message=message, weight=10, ) - # Deployment Checks + # Automation Checks def check_dockerfile(self) -> HealthCheck: """Check if Dockerfile exists.""" @@ -226,7 +226,7 @@ def check_dockerfile(self) -> HealthCheck: passed = dockerfile_path.exists() and dockerfile_path.is_file() return HealthCheck( name="Dockerfile", - category="Deployment", + category="Automation", passed=passed, message="Docker configuration present" if passed @@ -258,7 +258,7 @@ def check_github_actions(self) -> HealthCheck: return HealthCheck( name="GitHub Actions", - category="Deployment", + category="Automation", passed=passed, message=message, weight=10, @@ -280,15 +280,36 @@ def check_env_example(self) -> HealthCheck: weight=5, ) - def get_all_checks(self) -> List[Callable[[], HealthCheck]]: - """Get all health check methods. + def check_changelog(self) -> HealthCheck: + """Check if CHANGELOG.md exists.""" + changelog_path = self.project_path / "CHANGELOG.md" + passed = changelog_path.exists() and changelog_path.is_file() + return HealthCheck( + name="CHANGELOG.md", + category="Documentation", + passed=passed, + message="Changelog present" if passed else "Missing CHANGELOG.md", + weight=5, + ) - Returns: - List of health check methods to run - """ + def check_pyproject(self) -> HealthCheck: + """Check if pyproject.toml exists.""" + pyproject_path = self.project_path / "pyproject.toml" + passed = pyproject_path.exists() and pyproject_path.is_file() + return HealthCheck( + name="pyproject.toml", + category="Configuration", + passed=passed, + message="Project configuration present" if passed else "Missing pyproject.toml", + weight=10, + ) + + def get_all_checks(self) -> List[Callable[[], HealthCheck]]: + """Get all health check methods.""" return [ self.check_readme, self.check_license, + self.check_changelog, self.check_git_repository, self.check_gitignore, self.check_tests_directory, @@ -297,6 +318,7 @@ def get_all_checks(self) -> List[Callable[[], HealthCheck]]: self.check_dockerfile, self.check_github_actions, self.check_env_example, + self.check_pyproject, ] def run_all_checks(self) -> List[HealthCheck]: @@ -324,6 +346,18 @@ def calculate_score(self, checks: List[HealthCheck]) -> tuple[int, int]: 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.""" + categories = self.group_checks_by_category(checks) + result = {} + for category, cat_checks in categories.items(): + earned = sum(c.weight for c in cat_checks if c.passed) + total = sum(c.weight for c in cat_checks) + result[category] = (earned, total) + return result + def group_checks_by_category( self, checks: List[HealthCheck] ) -> dict[str, List[HealthCheck]]: @@ -427,16 +461,20 @@ def format_report(self, checks: List[HealthCheck]) -> None: category_order = [ "Documentation", "Version Control", - "Quality", - "Deployment", "Configuration", + "Testing", + "Automation", ] + category_scores = self.calculate_category_scores(checks) + 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_percent = int((cat_earned / cat_total * 100) if cat_total else 0) + content.append(f"\n{category} — {cat_percent}%\n", style="bold cyan") for check in categories[category]: if check.passed: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 627e87d..87d2d85 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() @@ -192,7 +193,7 @@ def test_check_tests_directory_exists(self, 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): @@ -285,7 +286,7 @@ def test_check_dockerfile_exists(self, 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): @@ -353,17 +354,15 @@ def test_run_all_checks_complete_project(self, complete_project): checker = ProjectHealthChecker(complete_project) checks = checker.run_all_checks() - assert len(checks) == 10 + assert len(checks) == 12 assert all(isinstance(check, HealthCheck) for check in checks) - # All checks should pass for complete project - assert all(check.passed for check in checks) 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 + assert len(checks) == 12 # Only README and Git should pass passed_checks = [check for check in checks if check.passed] assert len(passed_checks) == 2 @@ -373,7 +372,7 @@ def test_run_all_checks_empty_project(self, temp_project_dir): checker = ProjectHealthChecker(temp_project_dir) checks = checker.run_all_checks() - assert len(checks) == 10 + assert len(checks) == 12 # No checks should pass assert all(not check.passed for check in checks) @@ -386,7 +385,7 @@ def test_calculate_score_all_passed(self, complete_project): score, max_score = checker.calculate_score(checks) assert score == max_score - assert max_score == 100 # Sum of all weights + assert max_score == 115 # Sum of all 12 check weights def test_calculate_score_none_passed(self, temp_project_dir): """Test score calculation when no checks pass.""" @@ -395,7 +394,7 @@ def test_calculate_score_none_passed(self, temp_project_dir): score, max_score = checker.calculate_score(checks) assert score == 0 - assert max_score == 100 + assert max_score == 115 def test_calculate_score_empty_checks(self): """Test score calculation with empty checks list.""" @@ -416,7 +415,7 @@ def test_calculate_score_weighted(self, temp_project_dir): score, max_score = checker.calculate_score(checks) assert score == 30 # 15 + 15 - assert max_score == 100 + assert max_score == 115 # Grouping Tests @@ -428,15 +427,15 @@ def test_group_checks_by_category(self, complete_project): assert "Documentation" in categories assert "Version Control" in categories - assert "Quality" in categories - assert "Deployment" in categories + assert "Testing" in categories + assert "Automation" in categories assert "Configuration" in categories - assert len(categories["Documentation"]) == 2 + assert len(categories["Documentation"]) == 3 # README + LICENSE + CHANGELOG assert len(categories["Version Control"]) == 2 - assert len(categories["Quality"]) == 3 - assert len(categories["Deployment"]) == 2 - assert len(categories["Configuration"]) == 1 + assert len(categories["Testing"]) == 3 # Tests + Ruff + Pytest + assert len(categories["Automation"]) == 2 # Dockerfile + GitHub Actions + assert len(categories["Configuration"]) == 2 # .env.example + pyproject.toml # Recommendations Tests @@ -455,8 +454,8 @@ def test_generate_recommendations_empty_project(self, temp_project_dir): checks = checker.run_all_checks() recommendations = checker.generate_recommendations(checks) - # All 10 checks should generate recommendations - assert len(recommendations) == 10 + # All 12 checks should generate recommendations + assert len(recommendations) == 12 # Git should be first priority assert "git init" in recommendations[0].lower() @@ -472,8 +471,8 @@ def test_generate_recommendations_prioritization(self, 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 + # Should have 8 recommendations (missing pytest, ruff, gh actions, etc.) + assert len(recommendations) == 8 # Higher priority items should come first assert any("pytest" in rec.lower() for rec in recommendations[:3]) @@ -522,7 +521,7 @@ def test_doctor_with_valid_path(tmp_path): checker = ProjectHealthChecker(tmp_path) checks = checker.run_all_checks() assert isinstance(checks, list) - assert len(checks) == 10 + assert len(checks) == 12 def test_doctor_with_invalid_path(tmp_path): @@ -531,3 +530,82 @@ def test_doctor_with_invalid_path(tmp_path): checker = ProjectHealthChecker(Path("/nonexistent/path/xyz")) checks = checker.run_all_checks() assert all(not check.passed for check 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() + + +# --------------------------------------------------------------------------- +# 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"): + 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) + + +# --------------------------------------------------------------------------- +# Category rename checks +# --------------------------------------------------------------------------- + + +def test_tests_directory_category_is_testing(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_tests_directory() + assert result.category == "Testing" + + +def test_dockerfile_category_is_automation(tmp_path): + checker = ProjectHealthChecker(tmp_path) + result = checker.check_dockerfile() + assert result.category == "Automation" From 51df4670593a426e34b15591b1b8f90004ddd958 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 11 Jul 2026 17:18:24 +0530 Subject: [PATCH 2/2] Doctor v1.0 --- src/spawn/utils/doctor.py | 718 ++++++++++++++++++++------------------ tests/test_doctor.py | 403 ++++++++++++--------- 2 files changed, 609 insertions(+), 512 deletions(-) diff --git a/src/spawn/utils/doctor.py b/src/spawn/utils/doctor.py index dcfb0f1..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,476 +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="Testing", - 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", + name="Tests", category="Testing", - passed=passed, - message=message, - weight=10, + 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. + pyproject = self.project_path / "pyproject.toml" + pytest_ini = self.project_path / "pytest.ini" + setup_cfg = self.project_path / "setup.cfg" - 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" + ok = False + location = None - passed = False - config_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="Testing", - 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) + # ------------------------------------------------------------------ # 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="Automation", - passed=passed, - message="Docker configuration present" - if passed - else "Missing Dockerfile", + 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. - - Checks for workflow files (.yml or .yaml) in .github/workflows/ - """ - workflows_path = self.project_path / ".github" / "workflows" - passed = False - workflow_count = 0 - - 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 - - message = ( - f"GitHub Actions configured ({workflow_count} workflow{'s' if workflow_count != 1 else ''})" - if passed + 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=passed, - message=message, + passed=ok, + message=msg, weight=10, ) - # Configuration Checks + # ------------------------------------------------------------------ + # Code Quality 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_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 + + msg = f"Ruff configured in {location}" if ok else "Ruff not configured" return HealthCheck( - name=".env.example", - category="Configuration", - passed=passed, - message="Environment template present" - if passed - else "Missing .env.example", - weight=5, + name="Ruff", category="Code Quality", passed=ok, message=msg, weight=10 ) - def check_changelog(self) -> HealthCheck: - """Check if CHANGELOG.md exists.""" - changelog_path = self.project_path / "CHANGELOG.md" - passed = changelog_path.exists() and changelog_path.is_file() + 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="CHANGELOG.md", - category="Documentation", - passed=passed, - message="Changelog present" if passed else "Missing CHANGELOG.md", - weight=5, + name="Type Checking", + category="Code Quality", + passed=ok, + message=msg, + weight=10, ) - def check_pyproject(self) -> HealthCheck: - """Check if pyproject.toml exists.""" - pyproject_path = self.project_path / "pyproject.toml" - passed = pyproject_path.exists() and pyproject_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="pyproject.toml", - category="Configuration", - passed=passed, - message="Project configuration present" if passed else "Missing pyproject.toml", - weight=10, + name="Pre-commit", + category="Code Quality", + passed=ok, + message="Pre-commit configured" if ok else "Pre-commit not configured", + weight=5, ) + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ + def get_all_checks(self) -> List[Callable[[], HealthCheck]]: - """Get all health check methods.""" 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_pyproject, + 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_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 + total = sum(c.weight for c in checks) + earned = sum(c.weight for c in checks if c.passed) + return earned, total def calculate_category_scores( self, checks: List[HealthCheck] ) -> dict[str, tuple[int, int]]: """Return {category: (earned_weight, total_weight)} per category.""" - categories = self.group_checks_by_category(checks) - result = {} - for category, cat_checks in categories.items(): - earned = sum(c.weight for c in cat_checks if c.passed) - total = sum(c.weight for c in cat_checks) - result[category] = (earned, total) - return result + 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. + result.setdefault(check.category, []).append(check) + return result - Args: - checks: List of health check results + 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", "Configuration", "Testing", "Automation", + "Code Quality", ] - category_scores = self.calculate_category_scores(checks) - for category in category_order: if category not in categories: continue - cat_earned, cat_total = category_scores.get(category, (0, 0)) - cat_percent = int((cat_earned / cat_total * 100) if cat_total else 0) - content.append(f"\n{category} — {cat_percent}%\n", style="bold cyan") + 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( @@ -514,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, @@ -532,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 87d2d85..c30c105 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -34,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) @@ -43,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 = [] @@ -55,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 @@ -72,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", @@ -87,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", @@ -101,441 +107,335 @@ 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 == "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 == "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) == 12 - assert all(isinstance(check, HealthCheck) 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) == 12 - # 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) == 12 - # 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 == 115 # Sum of all 12 check 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 == 115 + 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 == 115 + 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 "Testing" in categories - assert "Automation" in categories - assert "Configuration" in categories - - assert len(categories["Documentation"]) == 3 # README + LICENSE + CHANGELOG - assert len(categories["Version Control"]) == 2 - assert len(categories["Testing"]) == 3 # Tests + Ruff + Pytest - assert len(categories["Automation"]) == 2 # Dockerfile + GitHub Actions - assert len(categories["Configuration"]) == 2 # .env.example + pyproject.toml + 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 12 checks should generate recommendations - assert len(recommendations) == 12 - # 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 8 recommendations (missing pytest, ruff, gh actions, etc.) - assert len(recommendations) == 8 - # 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) == 12 + 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) @@ -570,15 +470,90 @@ def test_check_pyproject_fails_when_missing(tmp_path): # --------------------------------------------------------------------------- -# Per-category scoring +# 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"): + for cat in ( + "Documentation", "Version Control", "Configuration", + "Testing", "Automation", "Code Quality", + ): assert cat in scores, f"Missing category: {cat}" @@ -595,17 +570,109 @@ def test_calculate_category_scores_correct_math(): # --------------------------------------------------------------------------- -# Category rename checks +# 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_tests_directory_category_is_testing(tmp_path): - checker = ProjectHealthChecker(tmp_path) - result = checker.check_tests_directory() - assert result.category == "Testing" +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_dockerfile_category_is_automation(tmp_path): - checker = ProjectHealthChecker(tmp_path) - result = checker.check_dockerfile() - assert result.category == "Automation" + +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