diff --git a/CHANGELOG.md b/CHANGELOG.md index 950faf0..2972ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,64 @@ All notable changes to this project will be documented in this file. --- +## [0.20.1] - 2025-12-20 + +### Fixed (0.20.1) + +- **External Repository Support**: Fixed critical issue where `repro` command only worked on SpecFact CLI's own codebase + - Added automatic environment manager detection (hatch, poetry, uv, pip) + - Made all validation tools optional with clear messaging when unavailable + - Added dynamic source directory detection (src/, lib/, or package name from pyproject.toml) + - Commands now work on external repositories without requiring SpecFact CLI adoption + - Enables OSS validation plan execution as designed +- **`generate contracts-apply` Command**: Fixed hardcoded paths and environment assumptions + - Uses dynamic source directory detection instead of hardcoded `src/` paths + - Uses environment detection for Python/pytest invocations + - Dynamic test file detection (supports multiple test directory structures) + - Works on external repositories with different project structures +- **`generate test-prompt` Command**: Fixed hardcoded source directory detection + - Uses dynamic source directory detection instead of hardcoded `src/` + - Dynamic test file detection for better external repository support + +### Added (0.20.1) + +- **Environment Manager Detection**: New `env_manager` utility module for detecting and working with different Python environment managers +- **Test Directory Detection**: New utilities for detecting test directories and finding test files dynamically +- **Comprehensive Tests**: Added 31 new tests for environment detection, test directory detection, and external repository support +- **`repro setup` Command**: New subcommand to automatically configure CrossHair for contract exploration + - Automatically generates `[tool.crosshair]` configuration in `pyproject.toml` + - Detects source directories and environment managers + - Provides installation guidance for crosshair-tool + - Optional `--install-crosshair` flag to attempt automatic installation +- **`init` Command Environment Warning**: Added warning when no compatible environment manager is detected + - Non-blocking warning that provides guidance on supported tools + - Helps users understand best practices for SpecFact CLI integration + - Lists supported environment managers (hatch, poetry, uv, pip) with detection criteria + +### Improved (0.20.1) + +- **Documentation**: Updated `repro` command documentation to clarify external repository support and environment requirements + - Added `repro setup` command documentation + - Updated all example flows to include CrossHair setup step + - Added "Supported Project Management Tools" section to installation guide +- **Error Messages**: Improved messaging when tools are unavailable, providing clear guidance on installation +- **Code Quality**: All linting/formatting tools in `generate contracts-apply` now use environment detection +- **Test Coverage**: Added comprehensive test suite for `repro setup` command (15 tests) and `init` command environment warning (5 tests) +- **`init --install-deps` Command**: Now uses environment manager detection for package installation + - Automatically detects and uses hatch, poetry, uv, or pip based on project configuration + - Provides environment-specific installation commands and error guidance + - Shows detected environment manager and command being used + - Adds timeout handling and improved error messages + - Tracks environment manager in telemetry + +### Notes (0.20.1) + +This patch release fixes the critical design issue identified during OSS validation planning. The `repro` command can now be used to validate external repositories (Requests, Flask, FastAPI, etc.) without requiring those projects to adopt SpecFact CLI. + +**Reference**: [CRITICAL_DESIGN_ISSUE_EXTERNAL_REPO_SUPPORT.md](docs/internal/analysis/CRITICAL_DESIGN_ISSUE_EXTERNAL_REPO_SUPPORT.md) + +--- + ## [0.20.0] - 2025-12-17 ### πŸŽ‰ Long-Term Stable (LTS) Release diff --git a/docs/examples/integration-showcases/integration-showcases-testing-guide.md b/docs/examples/integration-showcases/integration-showcases-testing-guide.md index 5610389..8f6d4a9 100644 --- a/docs/examples/integration-showcases/integration-showcases-testing-guide.md +++ b/docs/examples/integration-showcases/integration-showcases-testing-guide.md @@ -1035,11 +1035,17 @@ Report written to: .specfact/projects//reports/enforcement/report-< - Type checking (basedpyright) - type annotations and type safety - **Conditionally runs** (only if present): - - Contract exploration (CrossHair) - only if `src/` directory exists (symbolic execution to find counterexamples, not runtime contract validation) + - Contract exploration (CrossHair) - only if `[tool.crosshair]` config exists in `pyproject.toml` (use `specfact repro setup` to generate) and `src/` directory exists (symbolic execution to find counterexamples, not runtime contract validation) - Semgrep async patterns - only if `tools/semgrep/async.yml` exists (requires semgrep installed) - Property tests (pytest) - only if `tests/contracts/` directory exists - Smoke tests (pytest) - only if `tests/smoke/` directory exists +**CrossHair Setup**: Before running `repro` for the first time, set up CrossHair configuration: +```bash +specfact repro setup +``` +This automatically generates `[tool.crosshair]` configuration in `pyproject.toml` to enable contract exploration. + **Important**: `repro` does **not** perform runtime contract validation (checking `@icontract` decorators at runtime). It runs static analysis (linting, type checking) and symbolic execution (CrossHair) for contract exploration. Type mismatches will be detected by the type checking tool (basedpyright) if available. The enforcement configuration determines whether failures block the workflow. ### Example 3 - Step 6: Verify Results diff --git a/docs/examples/quick-examples.md b/docs/examples/quick-examples.md index a87cfbb..7043ac2 100644 --- a/docs/examples/quick-examples.md +++ b/docs/examples/quick-examples.md @@ -174,6 +174,9 @@ specfact enforce sdd ## Validation ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + # Quick validation specfact repro diff --git a/docs/getting-started/first-steps.md b/docs/getting-started/first-steps.md index 97aed9c..fd13c60 100644 --- a/docs/getting-started/first-steps.md +++ b/docs/getting-started/first-steps.md @@ -106,13 +106,17 @@ specfact bridge constitution bootstrap --repo . ### Step 3: Find and Fix Gaps ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + # Analyze and validate your codebase specfact repro --verbose ``` **What happens**: -- Runs the full validation suite (linting, type checking, contracts, tests) +- `repro setup` configures CrossHair for contract exploration (one-time setup) +- `repro` runs the full validation suite (linting, type checking, contracts, tests) - Identifies gaps and issues in your codebase - Generates enforcement reports that downstream tools (like `generate fix-prompt`) can use diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 793924c..7e9e4b3 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -125,6 +125,9 @@ jobs: - name: Install SpecFact CLI run: pip install specfact-cli + - name: Set up CrossHair Configuration + run: specfact repro setup + - name: Run Contract Validation run: specfact repro --verbose --budget 90 @@ -322,6 +325,104 @@ specfact sync repository --repo . --watch - **Bidirectional sync**: Use `sync bridge --adapter ` or `sync repository` for ongoing change management - **Semgrep (optional)**: Install `pip install semgrep` for async pattern detection in `specfact repro` +--- + +## Supported Project Management Tools + +SpecFact CLI automatically detects and works with the following Python project management tools. **No configuration needed** - it detects your project's environment manager automatically! + +### Automatic Detection + +When you run SpecFact CLI commands on a repository, it automatically: + +1. **Detects the environment manager** by checking for configuration files +2. **Detects source directories** (`src/`, `lib/`, or package name from `pyproject.toml`) +3. **Builds appropriate commands** using the detected environment manager +4. **Checks tool availability** and skips with clear messages if tools are missing + +### Supported Tools + +#### 1. **hatch** - Modern Python project manager + +- **Detection**: `[tool.hatch]` section in `pyproject.toml` +- **Command prefix**: `hatch run` +- **Example**: `hatch run pytest tests/` +- **Use case**: Modern Python projects using hatch for build and dependency management + +#### 2. **poetry** - Dependency management and packaging + +- **Detection**: `[tool.poetry]` section in `pyproject.toml` or `poetry.lock` file +- **Command prefix**: `poetry run` +- **Example**: `poetry run pytest tests/` +- **Use case**: Projects using Poetry for dependency management + +#### 3. **uv** - Fast Python package installer and resolver + +- **Detection**: `[tool.uv]` section in `pyproject.toml`, `uv.lock`, or `uv.toml` file +- **Command prefix**: `uv run` +- **Example**: `uv run pytest tests/` +- **Use case**: Projects using uv for fast package management + +#### 4. **pip** - Standard Python package installer + +- **Detection**: `requirements.txt` or `setup.py` file +- **Command prefix**: Direct tool invocation (no prefix) +- **Example**: `pytest tests/` +- **Use case**: Traditional Python projects using pip and virtual environments + +### Detection Priority + +SpecFact CLI checks in this order: + +1. `pyproject.toml` for tool sections (`[tool.hatch]`, `[tool.poetry]`, `[tool.uv]`) +2. Lock files (`poetry.lock`, `uv.lock`, `uv.toml`) +3. Fallback to `requirements.txt` or `setup.py` for pip-based projects + +### Source Directory Detection + +SpecFact CLI automatically detects source directories: + +- **Standard layouts**: `src/`, `lib/` +- **Package name**: Extracted from `pyproject.toml` (e.g., `my-package` β†’ `my_package/`) +- **Root-level**: Falls back to root directory if no standard layout found + +### Example: Working with Different Projects + +```bash +# Hatch project +cd /path/to/hatch-project +specfact repro --repo . # Automatically uses "hatch run" for tools + +# Poetry project +cd /path/to/poetry-project +specfact repro --repo . # Automatically uses "poetry run" for tools + +# UV project +cd /path/to/uv-project +specfact repro --repo . # Automatically uses "uv run" for tools + +# Pip project +cd /path/to/pip-project +specfact repro --repo . # Uses direct tool invocation +``` + +### External Repository Support + +SpecFact CLI works seamlessly on **external repositories** without requiring: + +- ❌ SpecFact CLI adoption +- ❌ Specific project structures +- ❌ Manual configuration +- ❌ Tool installation in global environment + +**All commands automatically adapt to the target repository's environment and structure.** + +This makes SpecFact CLI ideal for: + +- **OSS validation workflows** - Validate external open-source projects +- **Multi-project environments** - Work with different project structures +- **CI/CD pipelines** - Validate any Python project without setup + ## Common Commands ```bash diff --git a/docs/guides/migration-0.16-to-0.19.md b/docs/guides/migration-0.16-to-0.19.md index 2deed98..2d18ac8 100644 --- a/docs/guides/migration-0.16-to-0.19.md +++ b/docs/guides/migration-0.16-to-0.19.md @@ -34,6 +34,9 @@ specfact implement tasks .specfact/projects/my-bundle/tasks.yaml Use the new bridge commands instead: ```bash +# Set up CrossHair for contract exploration (one-time setup, only available since v0.20.1) +specfact repro setup + # Analyze and validate your codebase specfact repro --verbose diff --git a/docs/guides/speckit-journey.md b/docs/guides/speckit-journey.md index 717475a..15e1ccf 100644 --- a/docs/guides/speckit-journey.md +++ b/docs/guides/speckit-journey.md @@ -252,6 +252,9 @@ specfact sync bridge --adapter speckit --bundle --repo . --bidirec # Start in shadow mode (observe only) specfact enforce stage --preset minimal +# Set up CrossHair for contract exploration +specfact repro setup + # Review what would be blocked specfact repro --verbose @@ -285,6 +288,7 @@ specfact repro --fix # Apply Semgrep auto-fixes, then validate specfact enforce stage --preset strict # Full automation (CI/CD, brownfield analysis, etc.) +# (CrossHair setup already done in Week 3) specfact repro --budget 120 --verbose ``` @@ -411,6 +415,9 @@ specfact enforce stage --preset strict ### **Step 6: Validate** ```bash +# Set up CrossHair for contract exploration (one-time setup) +specfact repro setup + # Run all checks specfact repro --verbose diff --git a/docs/guides/use-cases.md b/docs/guides/use-cases.md index 4da7d79..4130d06 100644 --- a/docs/guides/use-cases.md +++ b/docs/guides/use-cases.md @@ -310,6 +310,10 @@ specfact enforce stage --preset strict #### 7. Validate ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + +# Run validation specfact repro --verbose ``` @@ -440,6 +444,9 @@ specfact enforce stage --preset strict #### 5. Validate Continuously ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + # During development specfact repro @@ -505,6 +512,9 @@ jobs: - name: Install SpecFact CLI run: pip install specfact-cli + - name: Set up CrossHair Configuration + run: specfact repro setup + - name: Run Contract Validation run: specfact repro --verbose --budget 90 @@ -625,6 +635,9 @@ specfact plan compare \ #### 4. Enforce Consistency ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + # Add to CI specfact repro specfact plan compare --manual contracts/shared/plan.bundle.yaml --auto . diff --git a/docs/guides/workflows.md b/docs/guides/workflows.md index 8364598..41edfff 100644 --- a/docs/guides/workflows.md +++ b/docs/guides/workflows.md @@ -254,6 +254,9 @@ specfact enforce stage --preset strict ### Running Validation ```bash +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + # Quick validation specfact repro @@ -266,7 +269,8 @@ specfact repro --fix --budget 120 **What it does**: -- Validates contracts +- `repro setup` configures CrossHair for contract exploration (one-time setup) +- `repro` validates contracts - Checks types - Detects async anti-patterns - Validates state machines diff --git a/docs/reference/commands.md b/docs/reference/commands.md index aea4486..8571652 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -28,6 +28,9 @@ specfact plan compare --bundle legacy-api # Sync with external tools (bidirectional) - Secondary use case specfact sync bridge --adapter speckit --bundle legacy-api --bidirectional --watch +# Set up CrossHair for contract exploration (one-time setup) +specfact repro setup + # Validate everything specfact repro --verbose ``` @@ -2240,6 +2243,7 @@ specfact repro [OPTIONS] **Options:** +- `--repo PATH` - Path to repository (default: current directory) - `--verbose` - Show detailed output - `--fix` - Apply auto-fixes where available (Semgrep auto-fixes) - `--fail-fast` - Stop on first failure @@ -2249,12 +2253,26 @@ specfact repro [OPTIONS] - `--budget INT` - Time budget in seconds (default: 120) +**Subcommands:** + +- `repro setup` - Set up CrossHair configuration for contract exploration + - Automatically generates `[tool.crosshair]` configuration in `pyproject.toml` + - Detects source directories and environment manager + - Checks for crosshair-tool availability + - Provides installation guidance if needed + **Example:** ```bash -# Standard validation +# First-time setup: Configure CrossHair for contract exploration +specfact repro setup + +# Standard validation (current directory) specfact repro --verbose --budget 120 +# Validate external repository +specfact repro --repo /path/to/external/repo --verbose + # Apply auto-fixes for violations specfact repro --fix --budget 120 @@ -2271,6 +2289,56 @@ specfact repro --fail-fast 5. **Smoke tests** - Event loop lag, orphaned tasks 6. **Plan validation** - Schema compliance +**External Repository Support:** + +The `repro` command automatically detects the target repository's environment manager and adapts commands accordingly: + +- **Environment Detection**: Automatically detects hatch, poetry, uv, or pip-based projects +- **Tool Availability**: All tools are optional - missing tools are skipped with clear messages +- **Source Detection**: Automatically detects source directories (`src/`, `lib/`, or package name from `pyproject.toml`) +- **Cross-Repository**: Works on external repositories without requiring SpecFact CLI adoption + +**Supported Environment Managers:** + +SpecFact CLI automatically detects and works with the following project management tools: + +- **hatch** - Detected from `[tool.hatch]` in `pyproject.toml` + - Commands prefixed with: `hatch run` + - Example: `hatch run pytest tests/` + +- **poetry** - Detected from `[tool.poetry]` in `pyproject.toml` or `poetry.lock` + - Commands prefixed with: `poetry run` + - Example: `poetry run pytest tests/` + +- **uv** - Detected from `[tool.uv]` in `pyproject.toml`, `uv.lock`, or `uv.toml` + - Commands prefixed with: `uv run` + - Example: `uv run pytest tests/` + +- **pip** - Detected from `requirements.txt` or `setup.py` (uses direct tool invocation) + - Commands use: Direct tool invocation (no prefix) + - Example: `pytest tests/` + +**Detection Priority**: + +1. Checks `pyproject.toml` for tool sections (`[tool.hatch]`, `[tool.poetry]`, `[tool.uv]`) +2. Checks for lock files (`poetry.lock`, `uv.lock`, `uv.toml`) +3. Falls back to `requirements.txt` or `setup.py` for pip-based projects + +**Source Directory Detection**: + +- Automatically detects: `src/`, `lib/`, or package name from `pyproject.toml` +- Works with any project structure without manual configuration + +**Tool Requirements:** + +Tools are checked for availability and skipped if not found: + +- **ruff** - Optional, for linting +- **semgrep** - Optional, only runs if `tools/semgrep/async.yml` config exists +- **basedpyright** - Optional, for type checking +- **crosshair** - Optional, for contract exploration (requires `[tool.crosshair]` config in `pyproject.toml` - use `specfact repro setup` to generate) +- **pytest** - Optional, only runs if `tests/contracts/` or `tests/smoke/` directories exist + **Auto-fixes:** When using `--fix`, Semgrep will automatically apply fixes for violations that have `fix:` fields in the rules. For example, `blocking-sleep-in-async` rule will automatically replace `time.sleep(...)` with `asyncio.sleep(...)` in async functions. diff --git a/pyproject.toml b/pyproject.toml index 2a66cf8..a0f9c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.20.0" +version = "0.20.1" description = "Brownfield-first CLI: Reverse engineer legacy Python β†’ specs β†’ enforced contracts. Automate legacy code documentation and prevent modernization regressions." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 37e8f79..1ad3f68 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.20.0", + version="0.20.1", description="SpecFact CLI - Specβ†’Contractβ†’Sentinel tool for contract-driven development", packages=find_packages(where="src"), package_dir={"": "src"}, diff --git a/src/__init__.py b/src/__init__.py index e804d28..56237ca 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Define the package version (kept in sync with pyproject.toml and setup.py) -__version__ = "0.20.0" +__version__ = "0.20.1" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 6672c43..1807427 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -9,6 +9,6 @@ - Validating reproducibility """ -__version__ = "0.20.0" +__version__ = "0.20.1" __all__ = ["__version__"] diff --git a/src/specfact_cli/commands/generate.py b/src/specfact_cli/commands/generate.py index 79992d7..98e26e9 100644 --- a/src/specfact_cli/commands/generate.py +++ b/src/specfact_cli/commands/generate.py @@ -19,6 +19,12 @@ from specfact_cli.models.task import TaskList, TaskPhase from specfact_cli.telemetry import telemetry from specfact_cli.utils import print_error, print_info, print_success, print_warning +from specfact_cli.utils.env_manager import ( + build_tool_command, + detect_env_manager, + detect_source_directories, + find_test_files_for_source, +) from specfact_cli.utils.optional_deps import check_cli_tool_available from specfact_cli.utils.structured_io import load_structured_file @@ -931,12 +937,24 @@ def apply_enhanced_contracts( parts = enhanced_stem.split("-") if len(parts) >= 2: original_name = parts[1] # Get the original file name - # Try common locations - possible_paths = [ - repo_path / f"src/specfact_cli/{original_name}.py", - repo_path / f"src/{original_name}.py", - repo_path / f"{original_name}.py", - ] + # Detect source directories dynamically + source_dirs = detect_source_directories(repo_path) + # Build possible paths based on detected source directories + possible_paths: list[Path] = [] + # Add root-level file + possible_paths.append(repo_path / f"{original_name}.py") + # Add paths based on detected source directories + for src_dir in source_dirs: + # Remove trailing slash if present + src_dir_clean = src_dir.rstrip("/") + possible_paths.append(repo_path / src_dir_clean / f"{original_name}.py") + # Also try common patterns as fallback + possible_paths.extend( + [ + repo_path / f"src/{original_name}.py", + repo_path / f"lib/{original_name}.py", + ] + ) for path in possible_paths: if path.exists(): original_file = path @@ -980,11 +998,16 @@ def apply_enhanced_contracts( console.print("\n[bold cyan]Step 2/6: Validating enhanced code syntax...[/bold cyan]") syntax_errors: list[str] = [] try: + # Detect environment manager and build appropriate command + env_info = detect_env_manager(repo_path) + python_command = ["python", "-m", "py_compile", str(enhanced_file)] + compile_command = build_tool_command(env_info, python_command) result = subprocess.run( - ["python", "-m", "py_compile", str(enhanced_file)], + compile_command, capture_output=True, text=True, timeout=10, + cwd=str(repo_path), ) if result.returncode != 0: error_output = result.stderr.strip() @@ -1104,6 +1127,9 @@ def apply_enhanced_contracts( tools_checked = 0 tools_passed = 0 + # Detect environment manager for building commands + env_info = detect_env_manager(repo_path) + # List of common linting/formatting tools to check linting_tools = [ ("ruff", ["ruff", "check", str(enhanced_file)], "Ruff linting"), @@ -1122,8 +1148,10 @@ def apply_enhanced_contracts( console.print(f"[dim]Running {description}...[/dim]") try: + # Build command with environment manager prefix if needed + command_full = build_tool_command(env_info, command) result = subprocess.run( - command, + command_full, capture_output=True, text=True, timeout=30, # 30 seconds per tool @@ -1180,56 +1208,10 @@ def apply_enhanced_contracts( # Determine the source file we're testing (original or enhanced) source_file_rel = original_file_rel if original_file_rel else enhanced_file_rel - # Convert source file path to potential test file paths - # Pattern: src/specfact_cli/telemetry.py -> tests/unit/specfact_cli/test_telemetry.py - # or: src/common/logger.py -> tests/unit/common/test_logger.py - test_paths: list[Path] = [] - - # Remove 'src/' prefix if present - test_rel_path = str(source_file_rel) - if test_rel_path.startswith("src/"): - test_rel_path = test_rel_path[4:] # Remove 'src/' - elif test_rel_path.startswith("tools/"): - test_rel_path = test_rel_path[6:] # Remove 'tools/' - - # Get directory and filename - test_file_dir = Path(test_rel_path).parent - test_file_name = Path(test_rel_path).stem # e.g., "telemetry" from "telemetry.py" - - # Try common test file patterns - test_file_patterns = [ - f"test_{test_file_name}.py", - f"{test_file_name}_test.py", - ] - - # Try common test directory structures - test_dirs = [ - repo_path / "tests" / "unit" / test_file_dir, - repo_path / "tests" / test_file_dir, - repo_path / "tests" / "unit", - repo_path / "tests", - ] - - # Build list of possible test file paths - for test_dir in test_dirs: - if test_dir.exists(): - for pattern in test_file_patterns: - test_path = test_dir / pattern - if test_path.exists(): - test_paths.append(test_path) - - # Also try E2E tests if unit tests not found - if not test_paths: - e2e_test_dirs = [ - repo_path / "tests" / "e2e" / test_file_dir, - repo_path / "tests" / "e2e", - ] - for test_dir in e2e_test_dirs: - if test_dir.exists(): - for pattern in test_file_patterns: - test_path = test_dir / pattern - if test_path.exists(): - test_paths.append(test_path) + # Use utility function to find test files dynamically + test_paths = find_test_files_for_source( + repo_path, source_file_rel if source_file_rel.is_absolute() else repo_path / source_file_rel + ) # If we found specific test files, run them if test_paths: @@ -1238,8 +1220,13 @@ def apply_enhanced_contracts( console.print(f"[dim]Found test file: {test_path.relative_to(repo_path)}[/dim]") console.print("[dim]Running pytest on specific test file (fast, scoped validation)...[/dim]") + # Detect environment manager and build appropriate command + env_info = detect_env_manager(repo_path) + pytest_command = ["pytest", str(test_path), "-v", "--tb=short"] + pytest_command_full = build_tool_command(env_info, pytest_command) + result = subprocess.run( - ["pytest", str(test_path), "-v", "--tb=short"], + pytest_command_full, capture_output=True, text=True, timeout=60, # 1 minute should be enough for a single test file @@ -2030,10 +2017,21 @@ def generate_test_prompt( console.print("\n[bold cyan]Files that may need tests:[/bold cyan]\n") # Find Python files without corresponding test files + # Use dynamic source directory detection + source_dirs = detect_source_directories(repo_path) src_files: list[Path] = [] - for src_dir in [repo_path / "src", repo_path]: - if src_dir.exists(): - src_files.extend(src_dir.rglob("*.py")) + # If no source dirs detected, check common patterns + if not source_dirs: + for src_dir in [repo_path / "src", repo_path / "lib", repo_path]: + if src_dir.exists(): + src_files.extend(src_dir.rglob("*.py")) + else: + # Use detected source directories + for src_dir_str in source_dirs: + src_dir_clean = src_dir_str.rstrip("/") + src_dir_path = repo_path / src_dir_clean + if src_dir_path.exists(): + src_files.extend(src_dir_path.rglob("*.py")) files_without_tests: list[tuple[Path, str]] = [] for src_file in src_files: @@ -2042,12 +2040,9 @@ def generate_test_prompt( if src_file.name.startswith("__"): continue - # Check for corresponding test file - test_patterns = [ - repo_path / "tests" / "unit" / f"test_{src_file.stem}.py", - repo_path / "tests" / f"test_{src_file.stem}.py", - ] - has_test = any(tp.exists() for tp in test_patterns) + # Check for corresponding test file using dynamic detection + test_files = find_test_files_for_source(repo_path, src_file) + has_test = len(test_files) > 0 if not has_test: rel_path = src_file.relative_to(repo_path) if src_file.is_relative_to(repo_path) else src_file files_without_tests.append((src_file, str(rel_path))) diff --git a/src/specfact_cli/commands/init.py b/src/specfact_cli/commands/init.py index 2b2a297..9598d77 100644 --- a/src/specfact_cli/commands/init.py +++ b/src/specfact_cli/commands/init.py @@ -18,6 +18,7 @@ from rich.panel import Panel from specfact_cli.telemetry import telemetry +from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( IDE_CONFIG, copy_templates_to_ide, @@ -60,7 +61,7 @@ def init( install_deps: bool = typer.Option( False, "--install-deps", - help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) via pip", + help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) using detected environment manager", ), # Advanced/Configuration ide: str = typer.Option( @@ -105,10 +106,41 @@ def init( console.print(f"[cyan]IDE:[/cyan] {ide_name} ({detected_ide})") console.print() + # Check for environment manager + env_info = detect_env_manager(repo_path) + if env_info.manager == EnvManager.UNKNOWN: + console.print() + console.print( + Panel( + "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", + border_style="yellow", + ) + ) + console.print( + "[yellow]SpecFact CLI works best with projects using standard Python project management tools.[/yellow]" + ) + console.print() + console.print("[dim]Supported tools:[/dim]") + console.print(" - hatch (detected from [tool.hatch] in pyproject.toml)") + console.print(" - poetry (detected from [tool.poetry] in pyproject.toml or poetry.lock)") + console.print(" - uv (detected from [tool.uv] in pyproject.toml, uv.lock, or uv.toml)") + console.print(" - pip (detected from requirements.txt or setup.py)") + console.print() + console.print( + "[dim]Note: SpecFact CLI will still work, but commands like 'specfact repro' may use direct tool invocation.[/dim]" + ) + console.print( + "[dim]Consider adding a pyproject.toml with [tool.hatch], [tool.poetry], or [tool.uv] for better integration.[/dim]" + ) + console.print() + # Install dependencies if requested if install_deps: console.print() console.print(Panel("[bold cyan]Installing Required Packages[/bold cyan]", border_style="cyan")) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + required_packages = [ "beartype>=0.22.4", "icontract>=2.7.1", @@ -119,19 +151,32 @@ def init( for package in required_packages: console.print(f" - {package}") + # Build install command using environment manager detection + install_cmd = ["pip", "install", "-U", *required_packages] + install_cmd = build_tool_command(env_info, install_cmd) + + console.print(f"[dim]Using command: {' '.join(install_cmd)}[/dim]") + try: - # Use pip to install packages result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", *required_packages], + install_cmd, capture_output=True, text=True, check=False, + cwd=str(repo_path), + timeout=300, # 5 minute timeout ) if result.returncode == 0: console.print() console.print("[green]βœ“[/green] All required packages installed successfully") - record({"deps_installed": True, "packages_count": len(required_packages)}) + record( + { + "deps_installed": True, + "packages_count": len(required_packages), + "env_manager": env_info.manager.value, + } + ) else: console.print() console.print("[yellow]⚠[/yellow] Some packages failed to install") @@ -142,19 +187,60 @@ def init( console.print(result.stderr) console.print() console.print("[yellow]You may need to install packages manually:[/yellow]") + # Provide environment-specific guidance + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record( + { + "deps_installed": False, + "error": result.stderr[:200] if result.stderr else "Unknown error", + "env_manager": env_info.manager.value, + } + ) + except subprocess.TimeoutExpired: + console.print() + console.print("[red]Error:[/red] Installation timed out after 5 minutes") + console.print("[yellow]You may need to install packages manually:[/yellow]") + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": result.stderr[:200]}) + record({"deps_installed": False, "error": "timeout", "env_manager": env_info.manager.value}) except FileNotFoundError: console.print() console.print("[red]Error:[/red] pip not found. Please install packages manually:") - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": "pip not found"}) + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record({"deps_installed": False, "error": "pip not found", "env_manager": env_info.manager.value}) except Exception as e: console.print() console.print(f"[red]Error:[/red] Failed to install packages: {e}") console.print("[yellow]You may need to install packages manually:[/yellow]") - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": str(e)}) + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record({"deps_installed": False, "error": str(e), "env_manager": env_info.manager.value}) console.print() # Find templates directory diff --git a/src/specfact_cli/commands/repro.py b/src/specfact_cli/commands/repro.py index e9daeac..2f129fc 100644 --- a/src/specfact_cli/commands/repro.py +++ b/src/specfact_cli/commands/repro.py @@ -11,12 +11,14 @@ import typer from beartype import beartype -from icontract import ensure, require +from click import Context as ClickContext +from icontract import require from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from rich.table import Table from specfact_cli.telemetry import telemetry +from specfact_cli.utils.env_manager import check_tool_in_env, detect_env_manager, detect_source_directories from specfact_cli.utils.structure import SpecFactStructure from specfact_cli.validators.repro_checker import ReproChecker @@ -25,6 +27,87 @@ console = Console() +def _update_pyproject_crosshair_config(pyproject_path: Path, config: dict[str, int | float]) -> bool: + """ + Update or create [tool.crosshair] section in pyproject.toml. + + Args: + pyproject_path: Path to pyproject.toml + config: Dictionary with CrossHair configuration values + + Returns: + True if config was updated/created, False otherwise + """ + try: + # Try tomlkit for style-preserving updates (recommended) + try: + import tomlkit + + # Read existing file to preserve style + if pyproject_path.exists(): + with pyproject_path.open("r", encoding="utf-8") as f: + doc = tomlkit.parse(f.read()) + else: + doc = tomlkit.document() + + # Update or create [tool.crosshair] section + if "tool" not in doc: + doc["tool"] = tomlkit.table() # type: ignore[assignment] + if "crosshair" not in doc["tool"]: # type: ignore[index] + doc["tool"]["crosshair"] = tomlkit.table() # type: ignore[index,assignment] + + for key, value in config.items(): + doc["tool"]["crosshair"][key] = value # type: ignore[index] + + # Write back + with pyproject_path.open("w", encoding="utf-8") as f: + f.write(tomlkit.dumps(doc)) # type: ignore[arg-type] + + return True + + except ImportError: + # Fallback: use tomllib/tomli to read, then append section manually + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # noqa: F401 + except ImportError: + console.print("[red]Error:[/red] No TOML library available (need tomlkit, tomllib, or tomli)") + return False + + # Read existing content + existing_content = "" + if pyproject_path.exists(): + existing_content = pyproject_path.read_text(encoding="utf-8") + + # Check if [tool.crosshair] already exists + if "[tool.crosshair]" in existing_content: + # Update existing section (simple regex replacement) + import re + + pattern = r"\[tool\.crosshair\][^\[]*" + new_section = "[tool.crosshair]\n" + for key, value in config.items(): + new_section += f"{key} = {value}\n" + + existing_content = re.sub(pattern, new_section.rstrip(), existing_content, flags=re.DOTALL) + else: + # Append new section + if existing_content and not existing_content.endswith("\n"): + existing_content += "\n" + existing_content += "\n[tool.crosshair]\n" + for key, value in config.items(): + existing_content += f"{key} = {value}\n" + + pyproject_path.write_text(existing_content, encoding="utf-8") + return True + + except Exception as e: + console.print(f"[red]Error updating pyproject.toml:[/red] {e}") + return False + + def _is_valid_repo_path(path: Path) -> bool: """Check if path exists and is a directory.""" return path.exists() and path.is_dir() @@ -40,14 +123,11 @@ def _count_python_files(path: Path) -> int: return sum(1 for _ in path.rglob("*.py")) -@app.callback(invoke_without_command=True) -@beartype -@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") -@require(lambda budget: budget > 0, "Budget must be positive") -@ensure(lambda out: _is_valid_output_path(out), "Output path must exist if provided") +@app.callback(invoke_without_command=True, no_args_is_help=False) # CrossHair: Skip analysis for Typer-decorated functions (signature analysis limitation) # type: ignore[crosshair] def main( + ctx: ClickContext, # Target/Input repo: Path = typer.Option( Path("."), @@ -89,20 +169,39 @@ def main( ), ) -> None: """ - Run full validation suite. + Run full validation suite for reproducibility. + + Automatically detects the target repository's environment manager (hatch, poetry, uv, pip) + and adapts commands accordingly. All tools are optional and will be skipped with clear + messages if unavailable. Executes: - - Lint checks (ruff) - - Async patterns (semgrep) - - Type checking (basedpyright) - - Contract exploration (CrossHair) - - Property tests (pytest tests/contracts/) - - Smoke tests (pytest tests/smoke/) + - Lint checks (ruff) - optional + - Async patterns (semgrep) - optional, only if config exists + - Type checking (basedpyright) - optional + - Contract exploration (CrossHair) - optional + - Property tests (pytest tests/contracts/) - optional, only if directory exists + - Smoke tests (pytest tests/smoke/) - optional, only if directory exists + + Works on external repositories without requiring SpecFact CLI adoption. Example: specfact repro --verbose --budget 120 + specfact repro --repo /path/to/external/repo --verbose specfact repro --fix --budget 120 """ + # If a subcommand was invoked, don't run the main validation + if ctx.invoked_subcommand is not None: + return + + # Type checking for parameters (after subcommand check) + if not _is_valid_repo_path(repo): + raise typer.BadParameter("Repo path must exist and be directory") + if budget <= 0: + raise typer.BadParameter("Budget must be positive") + if not _is_valid_output_path(out): + raise typer.BadParameter("Output path must exist if provided") + from specfact_cli.utils.yaml_utils import dump_yaml console.print("[bold cyan]Running validation suite...[/bold cyan]") @@ -128,6 +227,13 @@ def main( # Run all checks checker = ReproChecker(repo_path=repo, budget=budget, fail_fast=fail_fast, fix=fix) + # Detect and display environment manager before starting progress spinner + from specfact_cli.utils.env_manager import detect_env_manager + + env_info = detect_env_manager(repo) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), @@ -224,3 +330,130 @@ def main( else: console.print("\n[yellow]⏱[/yellow] Budget exceeded") raise typer.Exit(2) + + +@app.command("setup") +@beartype +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +def setup( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + install_crosshair: bool = typer.Option( + False, + "--install-crosshair", + help="Attempt to install crosshair-tool if not available", + ), +) -> None: + """ + Set up CrossHair configuration for contract exploration. + + Automatically generates [tool.crosshair] configuration in pyproject.toml + to enable contract exploration with CrossHair during repro runs. + + This command: + - Detects source directories in the repository + - Creates/updates pyproject.toml with CrossHair configuration + - Optionally checks if crosshair-tool is installed + - Provides guidance on next steps + + Example: + specfact repro setup + specfact repro setup --repo /path/to/repo + specfact repro setup --install-crosshair + """ + console.print("[bold cyan]Setting up CrossHair configuration...[/bold cyan]") + console.print(f"[dim]Repository: {repo}[/dim]\n") + + # Detect environment manager + env_info = detect_env_manager(repo) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + + # Detect source directories + source_dirs = detect_source_directories(repo) + if not source_dirs: + # Fallback to common patterns + if (repo / "src").exists(): + source_dirs = ["src/"] + elif (repo / "lib").exists(): + source_dirs = ["lib/"] + else: + source_dirs = ["."] + + console.print(f"[green]βœ“[/green] Detected source directories: {', '.join(source_dirs)}") + + # Check if crosshair-tool is available + crosshair_available, crosshair_message = check_tool_in_env(repo, "crosshair", env_info) + if crosshair_available: + console.print("[green]βœ“[/green] crosshair-tool is available") + else: + console.print(f"[yellow]⚠[/yellow] crosshair-tool not available: {crosshair_message}") + if install_crosshair: + console.print("[dim]Attempting to install crosshair-tool...[/dim]") + import subprocess + + # Build install command with environment manager + from specfact_cli.utils.env_manager import build_tool_command + + install_cmd = ["pip", "install", "crosshair-tool>=0.0.97"] + install_cmd = build_tool_command(env_info, install_cmd) + + try: + result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=60, cwd=str(repo)) + if result.returncode == 0: + console.print("[green]βœ“[/green] crosshair-tool installed successfully") + crosshair_available = True + else: + console.print(f"[red]βœ—[/red] Failed to install crosshair-tool: {result.stderr}") + except subprocess.TimeoutExpired: + console.print("[red]βœ—[/red] Installation timed out") + except Exception as e: + console.print(f"[red]βœ—[/red] Installation error: {e}") + else: + console.print( + "[dim]Tip: Install with --install-crosshair flag, or manually: " + f"{'hatch run pip install' if env_info.manager == 'hatch' else 'pip install'} crosshair-tool[/dim]" + ) + + # Create/update pyproject.toml with CrossHair config + pyproject_path = repo / "pyproject.toml" + + # Default CrossHair configuration (matching our own pyproject.toml) + crosshair_config: dict[str, int | float] = { + "timeout": 60, + "per_condition_timeout": 10, + "per_path_timeout": 5, + "max_iterations": 1000, + } + + if _update_pyproject_crosshair_config(pyproject_path, crosshair_config): + console.print(f"[green]βœ“[/green] Updated {pyproject_path.relative_to(repo)} with CrossHair configuration") + console.print("\n[bold]CrossHair Configuration:[/bold]") + for key, value in crosshair_config.items(): + console.print(f" {key} = {value}") + else: + console.print(f"[red]βœ—[/red] Failed to update {pyproject_path.relative_to(repo)}") + raise typer.Exit(1) + + # Summary + console.print("\n[bold green]βœ“[/bold green] Setup complete!") + console.print("\n[bold]Next steps:[/bold]") + console.print(" 1. Run [cyan]specfact repro[/cyan] to execute validation checks") + if not crosshair_available: + console.print(" 2. Install crosshair-tool to enable contract exploration:") + if env_info.manager == "hatch": + console.print(" [dim]hatch run pip install crosshair-tool[/dim]") + elif env_info.manager == "poetry": + console.print(" [dim]poetry add --dev crosshair-tool[/dim]") + elif env_info.manager == "uv": + console.print(" [dim]uv pip install crosshair-tool[/dim]") + else: + console.print(" [dim]pip install crosshair-tool[/dim]") + console.print(" 3. CrossHair will automatically explore contracts in your source code") + console.print(" 4. Results will appear in the validation report") diff --git a/src/specfact_cli/utils/env_manager.py b/src/specfact_cli/utils/env_manager.py new file mode 100644 index 0000000..80f2215 --- /dev/null +++ b/src/specfact_cli/utils/env_manager.py @@ -0,0 +1,443 @@ +""" +Environment manager detection and command building utilities. + +This module provides functionality to detect Python environment managers +(hatch, poetry, uv, pip) and build appropriate commands for running tools +in the target repository's environment. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + + +class EnvManager(str, Enum): + """Python environment manager types.""" + + HATCH = "hatch" + POETRY = "poetry" + UV = "uv" + PIP = "pip" + UNKNOWN = "unknown" + + +@dataclass +class EnvManagerInfo: + """Information about detected environment manager.""" + + manager: EnvManager + available: bool + command_prefix: list[str] + message: str | None = None + + +@beartype +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@require(lambda repo_path: repo_path.is_dir(), "Repository path must be a directory") +@ensure(lambda result: isinstance(result, EnvManagerInfo), "Must return EnvManagerInfo") +def detect_env_manager(repo_path: Path) -> EnvManagerInfo: + """ + Detect the environment manager used by the target repository. + + Detection priority: + 1. Check for pyproject.toml with [tool.hatch] β†’ hatch + 2. Check for pyproject.toml with [tool.poetry] β†’ poetry + 3. Check for pyproject.toml with [tool.uv] β†’ uv + 4. Check for uv.lock or uv.toml β†’ uv + 5. Check for poetry.lock β†’ poetry + 6. Check for requirements.txt or setup.py β†’ pip + 7. Check if tools are globally available β†’ pip (fallback) + + Args: + repo_path: Path to the repository root + + Returns: + EnvManagerInfo with detected manager and command prefix + """ + pyproject_toml = repo_path / "pyproject.toml" + uv_lock = repo_path / "uv.lock" + uv_toml = repo_path / "uv.toml" + poetry_lock = repo_path / "poetry.lock" + requirements_txt = repo_path / "requirements.txt" + setup_py = repo_path / "setup.py" + + # 1. Check pyproject.toml for tool sections + if pyproject_toml.exists(): + try: + import tomllib + + with pyproject_toml.open("rb") as f: + pyproject_data = tomllib.load(f) + + # Check for hatch + if "tool" in pyproject_data and "hatch" in pyproject_data["tool"]: + hatch_available = shutil.which("hatch") is not None + if hatch_available: + return EnvManagerInfo( + manager=EnvManager.HATCH, + available=True, + command_prefix=["hatch", "run"], + message="Detected hatch environment manager", + ) + return EnvManagerInfo( + manager=EnvManager.HATCH, + available=False, + command_prefix=[], + message="Detected hatch in pyproject.toml but hatch not found in PATH", + ) + + # Check for poetry + if "tool" in pyproject_data and "poetry" in pyproject_data["tool"]: + poetry_available = shutil.which("poetry") is not None + if poetry_available: + return EnvManagerInfo( + manager=EnvManager.POETRY, + available=True, + command_prefix=["poetry", "run"], + message="Detected poetry environment manager", + ) + return EnvManagerInfo( + manager=EnvManager.POETRY, + available=False, + command_prefix=[], + message="Detected poetry in pyproject.toml but poetry not found in PATH", + ) + + # Check for uv + if "tool" in pyproject_data and "uv" in pyproject_data["tool"]: + uv_available = shutil.which("uv") is not None + if uv_available: + return EnvManagerInfo( + manager=EnvManager.UV, + available=True, + command_prefix=["uv", "run"], + message="Detected uv environment manager", + ) + return EnvManagerInfo( + manager=EnvManager.UV, + available=False, + command_prefix=[], + message="Detected uv in pyproject.toml but uv not found in PATH", + ) + + except Exception: + # If we can't parse pyproject.toml, continue with other checks + pass + + # 2. Check for uv.lock or uv.toml + if uv_lock.exists() or uv_toml.exists(): + uv_available = shutil.which("uv") is not None + if uv_available: + return EnvManagerInfo( + manager=EnvManager.UV, + available=True, + command_prefix=["uv", "run"], + message="Detected uv.lock or uv.toml", + ) + return EnvManagerInfo( + manager=EnvManager.UV, + available=False, + command_prefix=[], + message="Detected uv.lock/uv.toml but uv not found in PATH", + ) + + # 3. Check for poetry.lock + if poetry_lock.exists(): + poetry_available = shutil.which("poetry") is not None + if poetry_available: + return EnvManagerInfo( + manager=EnvManager.POETRY, + available=True, + command_prefix=["poetry", "run"], + message="Detected poetry.lock", + ) + return EnvManagerInfo( + manager=EnvManager.POETRY, + available=False, + command_prefix=[], + message="Detected poetry.lock but poetry not found in PATH", + ) + + # 4. Check for requirements.txt or setup.py (pip-based) + if requirements_txt.exists() or setup_py.exists(): + return EnvManagerInfo( + manager=EnvManager.PIP, + available=True, + command_prefix=[], # Direct invocation (assumes globally installed) + message="Detected requirements.txt or setup.py (pip-based project)", + ) + + # 5. Fallback: assume direct invocation (pip/global tools) + return EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], # Direct invocation + message="No environment manager detected, using direct tool invocation", + ) + + +@beartype +@require(lambda env_info: isinstance(env_info, EnvManagerInfo), "env_info must be EnvManagerInfo") +@require( + lambda tool_command: isinstance(tool_command, list) and len(tool_command) > 0, "tool_command must be non-empty list" +) +@ensure(lambda result: isinstance(result, list) and len(result) > 0, "Must return non-empty list") +def build_tool_command(env_info: EnvManagerInfo, tool_command: list[str]) -> list[str]: + """ + Build command to run a tool in the detected environment. + + Args: + env_info: Detected environment manager information + tool_command: Base tool command (e.g., ["python", "-m", "crosshair", "check", "src/"]) + + Returns: + Full command with environment manager prefix if needed + + Examples: + >>> env_info = EnvManagerInfo(EnvManager.HATCH, True, ["hatch", "run"]) + >>> build_tool_command(env_info, ["python", "-m", "crosshair", "check", "src/"]) + ['hatch', 'run', 'python', '-m', 'crosshair', 'check', 'src/'] + + >>> env_info = EnvManagerInfo(EnvManager.PIP, True, []) + >>> build_tool_command(env_info, ["crosshair", "check", "src/"]) + ['crosshair', 'check', 'src/'] + """ + if not env_info.available: + # If environment manager not available, try direct invocation + return tool_command + + if not env_info.command_prefix: + # No prefix needed (direct invocation) + return tool_command + + # For hatch/poetry/uv, we need to handle Python module invocations specially + # If tool_command starts with "python" or "python3", replace with env manager's Python + if tool_command[0] in ("python", "python3"): + # For hatch/poetry/uv, use their run command with the rest of the command + return env_info.command_prefix + tool_command + # For direct tool invocations, use env manager's run command + return env_info.command_prefix + tool_command + + +@beartype +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@require(lambda tool_name: isinstance(tool_name, str) and len(tool_name) > 0, "Tool name must be non-empty string") +@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return (bool, str | None) tuple") +def check_tool_in_env( + repo_path: Path, tool_name: str, env_info: EnvManagerInfo | None = None +) -> tuple[bool, str | None]: + """ + Check if a tool is available in the target repository's environment. + + Args: + repo_path: Path to repository root + tool_name: Name of the tool to check + env_info: Optional pre-detected environment info (if None, will detect) + + Returns: + Tuple of (is_available, error_message) + """ + if env_info is None: + env_info = detect_env_manager(repo_path) + + # First check if tool is globally available + if shutil.which(tool_name) is not None: + return True, None + + # If environment manager is available, check if tool might be in that environment + if env_info.available and env_info.command_prefix: + # We can't easily check if tool is in the environment without running it + # So we'll return True with a message that it might be available + return True, f"Tool '{tool_name}' not in PATH, but may be available in {env_info.manager.value} environment" + + # Tool not found + return False, f"Tool '{tool_name}' not found. Install with: pip install {tool_name} or use your environment manager" + + +@beartype +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@ensure(lambda result: isinstance(result, list), "Must return list") +def detect_source_directories(repo_path: Path) -> list[str]: + """ + Detect common source directories in the repository. + + Checks for common patterns: + - src/ + - lib/ + - package_name/ (from pyproject.toml or setup.py) + - . (root if no standard structure) + + Args: + repo_path: Path to repository root + + Returns: + List of source directory paths (relative to repo_path) + """ + source_dirs: list[str] = [] + + # Check for standard directories + if (repo_path / "src").exists(): + source_dirs.append("src/") + + if (repo_path / "lib").exists(): + source_dirs.append("lib/") + + # Try to detect package name from pyproject.toml + pyproject_toml = repo_path / "pyproject.toml" + if pyproject_toml.exists(): + try: + import tomllib + + with pyproject_toml.open("rb") as f: + pyproject_data = tomllib.load(f) + + # Check for package name in [project] or [tool.poetry] + package_name = None + if "project" in pyproject_data and "name" in pyproject_data["project"]: + package_name = pyproject_data["project"]["name"] + elif ( + "tool" in pyproject_data + and "poetry" in pyproject_data["tool"] + and "name" in pyproject_data["tool"]["poetry"] + ): + package_name = pyproject_data["tool"]["poetry"]["name"] + + if package_name: + # Package names in pyproject.toml may use dashes, but directories use underscores + # Try both the original name and the normalized version + package_variants = [ + package_name, # Original name (e.g., "my-package") + package_name.replace("-", "_"), # Normalized (e.g., "my_package") + package_name.replace("_", "-"), # Reverse normalized (e.g., "my-package" from "my_package") + ] + # Remove duplicates while preserving order + seen = set() + package_variants = [v for v in package_variants if v not in seen and not seen.add(v)] + + for variant in package_variants: + package_dir = repo_path / variant + if package_dir.exists() and package_dir.is_dir(): + source_dirs.append(f"{variant}/") + break # Use first match + + except Exception: + # If we can't parse, continue + pass + + # If no standard directories found, return empty list (caller should handle) + return source_dirs + + +@beartype +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@require(lambda source_file_rel: isinstance(source_file_rel, Path), "source_file_rel must be Path") +@ensure(lambda result: isinstance(result, list), "Must return list") +def detect_test_directories(repo_path: Path, source_file_rel: Path) -> list[Path]: + """ + Detect potential test directories for a given source file. + + Checks for common test directory patterns: + - tests/unit// + - tests// + - tests/unit/ + - tests/ + - tests/e2e// + - tests/e2e/ + + Args: + repo_path: Path to repository root + source_file_rel: Relative path to source file (e.g., Path("src/module/file.py")) + + Returns: + List of potential test directory paths (relative to repo_path) + """ + test_dirs: list[Path] = [] + + # Remove common source prefixes to get relative path + test_rel_path = str(source_file_rel) + if test_rel_path.startswith("src/"): + test_rel_path = test_rel_path[4:] # Remove 'src/' + elif test_rel_path.startswith("lib/"): + test_rel_path = test_rel_path[4:] # Remove 'lib/' + elif test_rel_path.startswith("tools/"): + test_rel_path = test_rel_path[6:] # Remove 'tools/' + + # Get directory structure from source file + test_file_dir = Path(test_rel_path).parent + + # Try common test directory structures + potential_dirs = [ + repo_path / "tests" / "unit" / test_file_dir, + repo_path / "tests" / test_file_dir, + repo_path / "tests" / "unit", + repo_path / "tests", + ] + + # Add E2E test directories + potential_dirs.extend( + [ + repo_path / "tests" / "e2e" / test_file_dir, + repo_path / "tests" / "e2e", + ] + ) + + # Return only directories that exist + for test_dir in potential_dirs: + if test_dir.exists() and test_dir.is_dir(): + test_dirs.append(test_dir) + + return test_dirs + + +@beartype +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@require(lambda source_file: isinstance(source_file, Path), "source_file must be Path") +@ensure(lambda result: isinstance(result, list), "Must return list") +def find_test_files_for_source(repo_path: Path, source_file: Path) -> list[Path]: + """ + Find test files for a given source file. + + Checks multiple test directory patterns and file naming conventions. + + Args: + repo_path: Path to repository root + source_file: Path to source file (absolute or relative to repo_path) + + Returns: + List of matching test file paths + """ + test_files: list[Path] = [] + + # Get relative path from repo root + try: + source_file_rel = source_file.relative_to(repo_path) + except ValueError: + # If not relative to repo_path, use as-is + source_file_rel = source_file + + # Get test directories + test_dirs = detect_test_directories(repo_path, source_file_rel) + + # Get source file name without extension + source_stem = source_file.stem + + # Common test file patterns + test_file_patterns = [ + f"test_{source_stem}.py", + f"{source_stem}_test.py", + ] + + # Search in all test directories + for test_dir in test_dirs: + for pattern in test_file_patterns: + test_path = test_dir / pattern + if test_path.exists() and test_path.is_file(): + test_files.append(test_path) + + return test_files diff --git a/src/specfact_cli/validators/repro_checker.py b/src/specfact_cli/validators/repro_checker.py index d613f42..f67400d 100644 --- a/src/specfact_cli/validators/repro_checker.py +++ b/src/specfact_cli/validators/repro_checker.py @@ -650,9 +650,37 @@ def run_all_checks(self) -> ReproReport: """ Run all validation checks. + Detects the target repository's environment manager and builds appropriate + commands. Makes all tools optional with clear messaging when unavailable. + Returns: ReproReport with aggregated results """ + from specfact_cli.utils.env_manager import ( + build_tool_command, + check_tool_in_env, + detect_env_manager, + detect_source_directories, + ) + + # Detect environment manager for the target repository + # Note: Environment detection message is printed in the command layer + # (repro.py) before the progress spinner starts to avoid formatting issues + env_info = detect_env_manager(self.repo_path) + + # Detect source directories dynamically + source_dirs = detect_source_directories(self.repo_path) + # Fallback to common patterns if detection found nothing + if not source_dirs: + # Check for common patterns + if (self.repo_path / "src").exists(): + source_dirs = ["src/"] + elif (self.repo_path / "lib").exists(): + source_dirs = ["lib/"] + else: + # For external repos, try to find Python packages at root + source_dirs = ["."] + # Check if semgrep config exists semgrep_config = self.repo_path / "tools" / "semgrep" / "async.yml" semgrep_enabled = semgrep_config.exists() @@ -660,87 +688,147 @@ def run_all_checks(self) -> ReproReport: # Check if test directories exist contracts_tests = self.repo_path / "tests" / "contracts" smoke_tests = self.repo_path / "tests" / "smoke" - src_dir = self.repo_path / "src" - - checks: list[tuple[str, str, list[str], int | None, bool]] = [ - ( - "Linting (ruff)", - "ruff", - ["ruff", "check", "--output-format=full", "src/", "tests/", "tools/"], - None, - True, - ), - ] - - # Add semgrep only if config exists - if semgrep_enabled: - semgrep_command = ["semgrep", "--config", str(semgrep_config.relative_to(self.repo_path)), "."] - if self.fix: - semgrep_command.append("--autofix") + tests_dir = self.repo_path / "tests" + + checks: list[tuple[str, str, list[str], int | None, bool]] = [] + + # Linting (ruff) - optional + ruff_available, _ = check_tool_in_env(self.repo_path, "ruff", env_info) + if ruff_available: + ruff_command = ["ruff", "check", "--output-format=full", *source_dirs] + if tests_dir.exists(): + ruff_command.append("tests/") + if (self.repo_path / "tools").exists(): + ruff_command.append("tools/") + ruff_command = build_tool_command(env_info, ruff_command) + checks.append( + ( + "Linting (ruff)", + "ruff", + ruff_command, + None, + True, + ) + ) + else: + # Add as skipped check with message checks.append( ( - "Async patterns (semgrep)", - "semgrep", - semgrep_command, - 30, + "Linting (ruff)", + "ruff", + [], + None, True, ) ) - checks.extend( - [ - ("Type checking (basedpyright)", "basedpyright", ["basedpyright", "src/", "tools/"], None, True), - ] - ) + # Semgrep - optional, only if config exists + if semgrep_enabled: + semgrep_available, _ = check_tool_in_env(self.repo_path, "semgrep", env_info) + if semgrep_available: + semgrep_command = ["semgrep", "--config", str(semgrep_config.relative_to(self.repo_path)), "."] + if self.fix: + semgrep_command.append("--autofix") + semgrep_command = build_tool_command(env_info, semgrep_command) + checks.append( + ( + "Async patterns (semgrep)", + "semgrep", + semgrep_command, + 30, + True, + ) + ) + else: + checks.append( + ( + "Async patterns (semgrep)", + "semgrep", + [], + 30, + True, + ) + ) - # Add CrossHair only if src/ exists - # Exclude common/logger_setup.py from CrossHair analysis due to known signature analysis issues - # CrossHair doesn't support --exclude, so we exclude the common directory and add other directories - # Use hatch run to ensure CrossHair runs in the correct Python environment with dependencies - if src_dir.exists(): - # Get all subdirectories except common - specfact_dirs = [d for d in src_dir.iterdir() if d.is_dir() and d.name != "common"] - crosshair_targets = ["src/" + d.name for d in specfact_dirs] + ["tools/"] - # Check if hatch is available, otherwise fall back to direct crosshair command - hatch_available = shutil.which("hatch") is not None - if hatch_available: - # Use hatch run to ensure correct Python environment + # Type checking (basedpyright) - optional + basedpyright_available, _ = check_tool_in_env(self.repo_path, "basedpyright", env_info) + if basedpyright_available: + basedpyright_command = ["basedpyright", *source_dirs] + if (self.repo_path / "tools").exists(): + basedpyright_command.append("tools/") + basedpyright_command = build_tool_command(env_info, basedpyright_command) + checks.append(("Type checking (basedpyright)", "basedpyright", basedpyright_command, None, True)) + else: + checks.append(("Type checking (basedpyright)", "basedpyright", [], None, True)) + + # CrossHair - optional, only if source directories exist + if source_dirs: + crosshair_available, _ = check_tool_in_env(self.repo_path, "crosshair", env_info) + if crosshair_available: + # Build CrossHair command with detected source directories + # For external repos, use all detected source dirs + crosshair_targets = source_dirs.copy() + if (self.repo_path / "tools").exists(): + crosshair_targets.append("tools/") + + # Build command: python -m crosshair check + crosshair_base = ["python", "-m", "crosshair", "check", *crosshair_targets] + crosshair_command = build_tool_command(env_info, crosshair_base) checks.append( ( "Contract exploration (CrossHair)", "crosshair", - ["hatch", "run", "python", "-m", "crosshair", "check", *crosshair_targets], + crosshair_command, 60, True, ) ) else: - # Fall back to direct crosshair command (may fail if wrong Python environment) checks.append( ( "Contract exploration (CrossHair)", "crosshair", - ["crosshair", "check", *crosshair_targets], + [], 60, True, ) ) - # Add property tests only if directory exists + # Property tests - optional, only if directory exists if contracts_tests.exists(): - checks.append( - ( - "Property tests (pytest contracts)", - "pytest", - ["pytest", "tests/contracts/", "-v"], - 30, - True, + pytest_available, _ = check_tool_in_env(self.repo_path, "pytest", env_info) + if pytest_available: + pytest_command = ["pytest", "tests/contracts/", "-v"] + pytest_command = build_tool_command(env_info, pytest_command) + checks.append( + ( + "Property tests (pytest contracts)", + "pytest", + pytest_command, + 30, + True, + ) + ) + else: + checks.append( + ( + "Property tests (pytest contracts)", + "pytest", + [], + 30, + True, + ) ) - ) - # Add smoke tests only if directory exists + # Smoke tests - optional, only if directory exists if smoke_tests.exists(): - checks.append(("Smoke tests (pytest smoke)", "pytest", ["pytest", "tests/smoke/", "-v"], 30, True)) + pytest_available, _ = check_tool_in_env(self.repo_path, "pytest", env_info) + if pytest_available: + pytest_command = ["pytest", "tests/smoke/", "-v"] + pytest_command = build_tool_command(env_info, pytest_command) + checks.append(("Smoke tests (pytest smoke)", "pytest", pytest_command, 30, True)) + else: + checks.append(("Smoke tests (pytest smoke)", "pytest", [], 30, True)) for check_args in checks: # Check budget before starting @@ -749,6 +837,20 @@ def run_all_checks(self) -> ReproReport: self.report.budget_exceeded = True break + # Skip checks with empty commands (tool not available) + name, tool, command, _timeout, _skip_if_missing = check_args + if not command: + # Tool not available - create skipped result with helpful message + _tool_available, tool_message = check_tool_in_env(self.repo_path, tool, env_info) + result = CheckResult( + name=name, + tool=tool, + status=CheckStatus.SKIPPED, + error=tool_message or f"Tool '{tool}' not available", + ) + self.report.add_check(result) + continue + # Run check result = self.run_check(*check_args) self.report.add_check(result) diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index c74f4f9..e379eb6 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -324,3 +324,138 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): claude_dir = tmp_path / ".claude" / "commands" assert claude_dir.exists() assert (claude_dir / "specfact.01-import.md").exists() + + def test_init_warns_when_no_environment_manager(self, tmp_path, monkeypatch): + """Test init command shows warning when no environment manager is detected.""" + # Create templates directory structure + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + # Create empty directory (no pyproject.toml, no requirements.txt, no setup.py) + # This should trigger the warning + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Should show warning about no environment manager + assert "No Compatible Environment Manager Detected" in result.stdout + assert "Supported tools:" in result.stdout + assert "hatch" in result.stdout.lower() + assert "poetry" in result.stdout.lower() + assert "uv" in result.stdout.lower() + assert "pip" in result.stdout.lower() + + def test_init_no_warning_with_hatch_project(self, tmp_path, monkeypatch): + """Test init command does not show warning when hatch is detected.""" + # Create templates directory structure + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + # Create hatch project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" +version = "0.1.0" + +[tool.hatch.build.targets.wheel] +packages = ["src/test_package"] +""" + ) + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Should NOT show warning + assert "No Compatible Environment Manager Detected" not in result.stdout + + def test_init_no_warning_with_poetry_project(self, tmp_path, monkeypatch): + """Test init command does not show warning when poetry is detected.""" + # Create templates directory structure + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + # Create poetry project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[tool.poetry] +name = "test-package" +version = "0.1.0" +""" + ) + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Should NOT show warning + assert "No Compatible Environment Manager Detected" not in result.stdout + + def test_init_no_warning_with_pip_project(self, tmp_path, monkeypatch): + """Test init command does not show warning when pip (requirements.txt) is detected.""" + # Create templates directory structure + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + # Create requirements.txt (pip project) + requirements_path = tmp_path / "requirements.txt" + requirements_path.write_text("requests>=2.0.0\n") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Should NOT show warning + assert "No Compatible Environment Manager Detected" not in result.stdout + + def test_init_no_warning_with_uv_project(self, tmp_path, monkeypatch): + """Test init command does not show warning when uv is detected.""" + # Create templates directory structure + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + # Create uv project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" +version = "0.1.0" + +[tool.uv] +dev-dependencies = [] +""" + ) + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Should NOT show warning + assert "No Compatible Environment Manager Detected" not in result.stdout diff --git a/tests/integration/commands/test_repro_command.py b/tests/integration/commands/test_repro_command.py new file mode 100644 index 0000000..4bafbc1 --- /dev/null +++ b/tests/integration/commands/test_repro_command.py @@ -0,0 +1,355 @@ +"""Integration tests for repro command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specfact_cli.cli import app + + +runner = CliRunner() + + +class TestReproSetupCommand: + """Test suite for repro setup command.""" + + def test_setup_creates_pyproject_toml_with_crosshair_config(self, tmp_path: Path, monkeypatch): + """Test setup creates pyproject.toml with CrossHair configuration.""" + monkeypatch.chdir(tmp_path) + + # Create minimal project structure + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Setting up CrossHair configuration" in result.stdout + assert "Setup complete!" in result.stdout + + # Verify pyproject.toml was created + pyproject_path = tmp_path / "pyproject.toml" + assert pyproject_path.exists() + + # Verify CrossHair config exists + content = pyproject_path.read_text() + assert "[tool.crosshair]" in content + assert "timeout = 60" in content + assert "per_condition_timeout = 10" in content + assert "per_path_timeout = 5" in content + assert "max_iterations = 1000" in content + + def test_setup_updates_existing_pyproject_toml(self, tmp_path: Path, monkeypatch): + """Test setup updates existing pyproject.toml with CrossHair configuration.""" + monkeypatch.chdir(tmp_path) + + # Create existing pyproject.toml + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" +version = "0.1.0" +""" + ) + + # Create source directory + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Updated pyproject.toml" in result.stdout + + # Verify existing content is preserved + content = pyproject_path.read_text() + assert "[project]" in content + assert 'name = "test-package"' in content + + # Verify CrossHair config was added + assert "[tool.crosshair]" in content + assert "timeout = 60" in content + + def test_setup_updates_existing_crosshair_config(self, tmp_path: Path, monkeypatch): + """Test setup updates existing CrossHair configuration.""" + monkeypatch.chdir(tmp_path) + + # Create pyproject.toml with existing CrossHair config + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" + +[tool.crosshair] +timeout = 30 +per_condition_timeout = 5 +""" + ) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + + # Verify config was updated to standard values + content = pyproject_path.read_text() + assert "[tool.crosshair]" in content + assert "timeout = 60" in content # Updated + assert "per_condition_timeout = 10" in content # Updated + assert "per_path_timeout = 5" in content # Added + assert "max_iterations = 1000" in content # Added + + def test_setup_detects_hatch_environment(self, tmp_path: Path, monkeypatch): + """Test setup detects hatch environment manager.""" + monkeypatch.chdir(tmp_path) + + # Create hatch project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" + +[tool.hatch.build.targets.wheel] +packages = ["src/test_package"] +""" + ) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "hatch" in result.stdout.lower() or "Detected" in result.stdout + + def test_setup_detects_poetry_environment(self, tmp_path: Path, monkeypatch): + """Test setup detects poetry environment manager.""" + monkeypatch.chdir(tmp_path) + + # Create poetry project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[tool.poetry] +name = "test-package" +version = "0.1.0" +""" + ) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + # Should complete successfully regardless of poetry detection + + def test_setup_detects_source_directories(self, tmp_path: Path, monkeypatch): + """Test setup detects source directories correctly.""" + monkeypatch.chdir(tmp_path) + + # Create src/ structure + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Detected source directories" in result.stdout + assert "src/" in result.stdout + + def test_setup_detects_lib_directory(self, tmp_path: Path, monkeypatch): + """Test setup detects lib/ directory.""" + monkeypatch.chdir(tmp_path) + + # Create lib/ structure + lib_dir = tmp_path / "lib" / "test_package" + lib_dir.mkdir(parents=True) + (lib_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Detected source directories" in result.stdout + assert "lib/" in result.stdout + + def test_setup_handles_no_source_directories(self, tmp_path: Path, monkeypatch): + """Test setup handles repositories without standard source directories.""" + monkeypatch.chdir(tmp_path) + + # Create root-level Python file + (tmp_path / "module.py").write_text("def hello(): pass") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + # Should still complete successfully, using "." as fallback + + @patch("specfact_cli.commands.repro.check_tool_in_env") + def test_setup_warns_when_crosshair_not_available(self, mock_check_tool, tmp_path: Path, monkeypatch): + """Test setup warns when crosshair-tool is not available.""" + monkeypatch.chdir(tmp_path) + + # Mock crosshair as not available + mock_check_tool.return_value = (False, "Tool 'crosshair' not found") + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "crosshair-tool not available" in result.stdout + assert "Tip:" in result.stdout + + @patch("specfact_cli.commands.repro.check_tool_in_env") + def test_setup_shows_crosshair_available(self, mock_check_tool, tmp_path: Path, monkeypatch): + """Test setup shows success when crosshair-tool is available.""" + monkeypatch.chdir(tmp_path) + + # Mock crosshair as available + mock_check_tool.return_value = (True, None) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "crosshair-tool is available" in result.stdout + + @patch("subprocess.run") + @patch("specfact_cli.commands.repro.check_tool_in_env") + def test_setup_installs_crosshair_when_requested( + self, mock_check_tool, mock_subprocess, tmp_path: Path, monkeypatch + ): + """Test setup attempts to install crosshair-tool when --install-crosshair is used.""" + monkeypatch.chdir(tmp_path) + + # Mock crosshair as not available + mock_check_tool.return_value = (False, "Tool 'crosshair' not found") + + # Mock successful installation + mock_proc = type("MockProc", (), {"returncode": 0, "stderr": ""})() + mock_subprocess.return_value = mock_proc + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path), "--install-crosshair"]) + + assert result.exit_code == 0 + assert "Attempting to install crosshair-tool" in result.stdout + mock_subprocess.assert_called_once() + + @patch("subprocess.run") + @patch("specfact_cli.commands.repro.check_tool_in_env") + def test_setup_handles_installation_failure(self, mock_check_tool, mock_subprocess, tmp_path: Path, monkeypatch): + """Test setup handles crosshair-tool installation failure gracefully.""" + monkeypatch.chdir(tmp_path) + + # Mock crosshair as not available + mock_check_tool.return_value = (False, "Tool 'crosshair' not found") + + # Mock failed installation + mock_proc = type("MockProc", (), {"returncode": 1, "stderr": "Installation failed"})() + mock_subprocess.return_value = mock_proc + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path), "--install-crosshair"]) + + assert result.exit_code == 0 # Setup still succeeds even if installation fails + assert "Failed to install crosshair-tool" in result.stdout + + def test_setup_provides_installation_guidance_for_hatch(self, tmp_path: Path, monkeypatch): + """Test setup provides hatch-specific installation guidance.""" + monkeypatch.chdir(tmp_path) + + # Create hatch project + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """[project] +name = "test-package" + +[tool.hatch.build.targets.wheel] +packages = ["src/test_package"] +""" + ) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + with patch("specfact_cli.commands.repro.check_tool_in_env") as mock_check: + mock_check.return_value = (False, "Tool 'crosshair' not found") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + # Should mention hatch in installation guidance + assert "hatch" in result.stdout.lower() or "Install" in result.stdout + + def test_setup_shows_next_steps(self, tmp_path: Path, monkeypatch): + """Test setup shows helpful next steps after completion.""" + monkeypatch.chdir(tmp_path) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + assert result.exit_code == 0 + assert "Next steps:" in result.stdout + assert "specfact repro" in result.stdout + assert "CrossHair will automatically explore contracts" in result.stdout + + def test_setup_fails_gracefully_on_pyproject_write_error(self, tmp_path: Path, monkeypatch): + """Test setup handles pyproject.toml write errors gracefully.""" + monkeypatch.chdir(tmp_path) + + src_dir = tmp_path / "src" / "test_package" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + # Make pyproject.toml directory unwritable + pyproject_path = tmp_path / "pyproject.toml" + if pyproject_path.exists(): + pyproject_path.chmod(0o000) + else: + # Create a directory with same name to cause write error + pyproject_path.mkdir() + pyproject_path.chmod(0o555) + + try: + result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) + + # Should fail with error message + assert result.exit_code == 1 + assert "Failed to update" in result.stdout or "Error" in result.stdout + finally: + # Clean up permissions + if pyproject_path.exists(): + try: + if pyproject_path.is_dir(): + pyproject_path.rmdir() + else: + pyproject_path.chmod(0o644) + except Exception: + pass diff --git a/tests/unit/utils/test_env_manager.py b/tests/unit/utils/test_env_manager.py new file mode 100644 index 0000000..70d7c48 --- /dev/null +++ b/tests/unit/utils/test_env_manager.py @@ -0,0 +1,471 @@ +"""Unit tests for environment manager detection utilities. + +Focus: Business logic and edge cases only (@beartype handles type validation). +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from specfact_cli.utils.env_manager import ( + EnvManager, + EnvManagerInfo, + build_tool_command, + check_tool_in_env, + detect_env_manager, + detect_source_directories, + detect_test_directories, + find_test_files_for_source, +) + + +class TestDetectEnvManager: + """Test environment manager detection.""" + + def test_detect_hatch_from_pyproject(self, tmp_path: Path): + """Test detection of hatch from pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.hatch] +version = "1.0.0" +""" + ) + + with patch("shutil.which", return_value="/usr/bin/hatch"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.HATCH + assert info.available is True + assert info.command_prefix == ["hatch", "run"] + assert info.message is not None and "hatch" in info.message.lower() + + def test_detect_hatch_not_available(self, tmp_path: Path): + """Test detection of hatch when not available in PATH.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.hatch] +version = "1.0.0" +""" + ) + + with patch("shutil.which", return_value=None): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.HATCH + assert info.available is False + assert info.command_prefix == [] + assert info.message is not None and "not found" in info.message.lower() + + def test_detect_poetry_from_pyproject(self, tmp_path: Path): + """Test detection of poetry from pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.poetry] +name = "test-project" +version = "1.0.0" +""" + ) + + with patch("shutil.which", return_value="/usr/bin/poetry"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.POETRY + assert info.available is True + assert info.command_prefix == ["poetry", "run"] + + def test_detect_poetry_from_lock(self, tmp_path: Path): + """Test detection of poetry from poetry.lock.""" + lock_file = tmp_path / "poetry.lock" + lock_file.write_text('[[package]]\nname = "test"\nversion = "1.0.0"') + + with patch("shutil.which", return_value="/usr/bin/poetry"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.POETRY + assert info.available is True + assert info.command_prefix == ["poetry", "run"] + assert info.message is not None and "poetry.lock" in info.message.lower() + + def test_detect_uv_from_pyproject(self, tmp_path: Path): + """Test detection of uv from pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.uv] +version = "1.0.0" +""" + ) + + with patch("shutil.which", return_value="/usr/bin/uv"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + + def test_detect_uv_from_lock(self, tmp_path: Path): + """Test detection of uv from uv.lock.""" + lock_file = tmp_path / "uv.lock" + lock_file.write_text("version = 1") + + with patch("shutil.which", return_value="/usr/bin/uv"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + + def test_detect_uv_from_toml(self, tmp_path: Path): + """Test detection of uv from uv.toml.""" + uv_toml = tmp_path / "uv.toml" + uv_toml.write_text("version = 1") + + with patch("shutil.which", return_value="/usr/bin/uv"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + + def test_detect_pip_from_requirements(self, tmp_path: Path): + """Test detection of pip from requirements.txt.""" + requirements = tmp_path / "requirements.txt" + requirements.write_text("requests==1.0.0\n") + + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.PIP + assert info.available is True + assert info.command_prefix == [] + assert info.message is not None and "requirements.txt" in info.message.lower() + + def test_detect_pip_from_setup_py(self, tmp_path: Path): + """Test detection of pip from setup.py.""" + setup_py = tmp_path / "setup.py" + setup_py.write_text("from setuptools import setup\nsetup(name='test')") + + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.PIP + assert info.available is True + assert info.command_prefix == [] + + def test_detect_unknown_fallback(self, tmp_path: Path): + """Test fallback to unknown when no manager detected.""" + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UNKNOWN + assert info.available is True + assert info.command_prefix == [] + assert info.message is not None and "No environment manager detected" in info.message + + def test_detect_priority_hatch_over_poetry(self, tmp_path: Path): + """Test that hatch takes priority over poetry when both present.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.hatch] +version = "1.0.0" +[tool.poetry] +name = "test" +""" + ) + + with patch("shutil.which", return_value="/usr/bin/hatch"): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.HATCH + + +class TestBuildToolCommand: + """Test building tool commands with environment prefixes.""" + + def test_build_command_with_hatch(self): + """Test building command with hatch prefix.""" + env_info = EnvManagerInfo( + manager=EnvManager.HATCH, + available=True, + command_prefix=["hatch", "run"], + message="Test", + ) + tool_command = ["python", "-m", "crosshair", "check", "src/"] + + result = build_tool_command(env_info, tool_command) + + assert result == ["hatch", "run", "python", "-m", "crosshair", "check", "src/"] + + def test_build_command_with_poetry(self): + """Test building command with poetry prefix.""" + env_info = EnvManagerInfo( + manager=EnvManager.POETRY, + available=True, + command_prefix=["poetry", "run"], + message="Test", + ) + tool_command = ["ruff", "check", "src/"] + + result = build_tool_command(env_info, tool_command) + + assert result == ["poetry", "run", "ruff", "check", "src/"] + + def test_build_command_with_uv(self): + """Test building command with uv prefix.""" + env_info = EnvManagerInfo( + manager=EnvManager.UV, + available=True, + command_prefix=["uv", "run"], + message="Test", + ) + tool_command = ["basedpyright", "src/"] + + result = build_tool_command(env_info, tool_command) + + assert result == ["uv", "run", "basedpyright", "src/"] + + def test_build_command_with_pip(self): + """Test building command with pip (no prefix).""" + env_info = EnvManagerInfo( + manager=EnvManager.PIP, + available=True, + command_prefix=[], + message="Test", + ) + tool_command = ["ruff", "check", "src/"] + + result = build_tool_command(env_info, tool_command) + + assert result == ["ruff", "check", "src/"] + + def test_build_command_unavailable_manager(self): + """Test building command when manager not available.""" + env_info = EnvManagerInfo( + manager=EnvManager.HATCH, + available=False, + command_prefix=[], + message="Test", + ) + tool_command = ["ruff", "check", "src/"] + + result = build_tool_command(env_info, tool_command) + + assert result == ["ruff", "check", "src/"] + + +class TestCheckToolInEnv: + """Test checking tool availability in environment.""" + + def test_check_tool_available_globally(self, tmp_path: Path): + """Test checking tool available globally.""" + with patch("shutil.which", return_value="/usr/bin/ruff"): + available, message = check_tool_in_env(tmp_path, "ruff") + + assert available is True + assert message is None + + def test_check_tool_not_available(self, tmp_path: Path): + """Test checking tool not available.""" + with patch("shutil.which", return_value=None): + available, message = check_tool_in_env(tmp_path, "nonexistent") + + assert available is False + assert message is not None + assert "not found" in message.lower() + assert "install" in message.lower() + + def test_check_tool_with_env_info(self, tmp_path: Path): + """Test checking tool with pre-detected environment info.""" + env_info = EnvManagerInfo( + manager=EnvManager.HATCH, + available=True, + command_prefix=["hatch", "run"], + message="Test", + ) + + with patch("shutil.which", return_value="/usr/bin/ruff"): + available, message = check_tool_in_env(tmp_path, "ruff", env_info) + + assert available is True + assert message is None + + +class TestDetectSourceDirectories: + """Test source directory detection.""" + + def test_detect_src_directory(self, tmp_path: Path): + """Test detection of src/ directory.""" + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + + result = detect_source_directories(tmp_path) + + assert "src/" in result + + def test_detect_lib_directory(self, tmp_path: Path): + """Test detection of lib/ directory.""" + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + + result = detect_source_directories(tmp_path) + + assert "lib/" in result + + def test_detect_package_from_pyproject(self, tmp_path: Path): + """Test detection of package name from pyproject.toml.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[project] +name = "my-package" +""" + ) + # Package name "my-package" converts to directory "my_package" or "my-package" + # Try both common conventions + for package_dir_name in ["my_package", "my-package"]: + package_dir = tmp_path / package_dir_name + if package_dir.exists(): + package_dir.rmdir() + package_dir.mkdir() + (package_dir / "__init__.py").write_text("") + + result = detect_source_directories(tmp_path) + + # Should detect at least one of the package directories + assert len(result) > 0 + assert any(package_dir_name in r for r in result) + break + + def test_detect_package_from_poetry(self, tmp_path: Path): + """Test detection of package name from poetry config.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.poetry] +name = "poetry-package" +""" + ) + # Package name "poetry-package" converts to directory "poetry_package" or "poetry-package" + # Try both common conventions + for package_dir_name in ["poetry_package", "poetry-package"]: + package_dir = tmp_path / package_dir_name + if package_dir.exists(): + package_dir.rmdir() + package_dir.mkdir() + (package_dir / "__init__.py").write_text("") + + result = detect_source_directories(tmp_path) + + # Should detect at least one of the package directories + assert len(result) > 0 + assert any(package_dir_name in r for r in result) + break + + def test_detect_no_standard_directories(self, tmp_path: Path): + """Test detection when no standard directories exist.""" + result = detect_source_directories(tmp_path) + + assert result == [] + + def test_detect_multiple_directories(self, tmp_path: Path): + """Test detection of multiple source directories.""" + src_dir = tmp_path / "src" + src_dir.mkdir() + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + + result = detect_source_directories(tmp_path) + + assert "src/" in result + assert "lib/" in result + + +class TestDetectTestDirectories: + """Test test directory detection.""" + + def test_detect_test_directories_from_src(self, tmp_path: Path): + """Test detection of test directories for src/ structure.""" + src_file = tmp_path / "src" / "module" / "file.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("") + + tests_unit = tmp_path / "tests" / "unit" / "module" + tests_unit.mkdir(parents=True) + + result = detect_test_directories(tmp_path, src_file.relative_to(tmp_path)) + + assert len(result) > 0 + assert any("tests/unit" in str(d) for d in result) + + def test_detect_test_directories_from_root(self, tmp_path: Path): + """Test detection of test directories for root-level files.""" + src_file = tmp_path / "file.py" + src_file.write_text("") + + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + + result = detect_test_directories(tmp_path, src_file.relative_to(tmp_path)) + + assert len(result) > 0 + assert any("tests" in str(d) for d in result) + + def test_detect_test_directories_e2e(self, tmp_path: Path): + """Test detection of E2E test directories.""" + src_file = tmp_path / "src" / "module" / "file.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("") + + tests_e2e = tmp_path / "tests" / "e2e" + tests_e2e.mkdir(parents=True) + + result = detect_test_directories(tmp_path, src_file.relative_to(tmp_path)) + + # Should include e2e directories if they exist + assert len(result) > 0 + + +class TestFindTestFilesForSource: + """Test finding test files for source files.""" + + def test_find_test_file_standard_structure(self, tmp_path: Path): + """Test finding test file in standard structure.""" + src_file = tmp_path / "src" / "module.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("def func(): pass") + + test_file = tmp_path / "tests" / "unit" / "test_module.py" + test_file.parent.mkdir(parents=True) + test_file.write_text("def test_func(): pass") + + result = find_test_files_for_source(tmp_path, src_file) + + assert len(result) > 0 + assert any("test_module.py" in str(p) for p in result) + + def test_find_test_file_alternative_naming(self, tmp_path: Path): + """Test finding test file with alternative naming.""" + src_file = tmp_path / "src" / "module.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("def func(): pass") + + test_file = tmp_path / "tests" / "module_test.py" + test_file.parent.mkdir(parents=True) + test_file.write_text("def test_func(): pass") + + result = find_test_files_for_source(tmp_path, src_file) + + assert len(result) > 0 + + def test_find_test_file_no_tests(self, tmp_path: Path): + """Test finding test file when none exist.""" + src_file = tmp_path / "src" / "module.py" + src_file.parent.mkdir(parents=True) + src_file.write_text("def func(): pass") + + result = find_test_files_for_source(tmp_path, src_file) + + assert len(result) == 0 diff --git a/tests/unit/validators/test_repro_checker.py b/tests/unit/validators/test_repro_checker.py index 0e618b1..fedb6d9 100644 --- a/tests/unit/validators/test_repro_checker.py +++ b/tests/unit/validators/test_repro_checker.py @@ -10,6 +10,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo from specfact_cli.validators.repro_checker import ( CheckResult, CheckStatus, @@ -119,8 +120,21 @@ def test_run_check_budget_exceeded(self, tmp_path: Path): def test_run_all_checks_with_ruff(self, tmp_path: Path): """Test run_all_checks executes ruff check.""" + # Create src directory for source detection + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + checker = ReproChecker(repo_path=tmp_path, budget=30) + # Mock environment detection + env_info = EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="Test", + ) + with patch("subprocess.run") as mock_run: mock_proc = MagicMock() mock_proc.returncode = 0 @@ -128,8 +142,12 @@ def test_run_all_checks_with_ruff(self, tmp_path: Path): mock_proc.stderr = "" mock_run.return_value = mock_proc - # Mock shutil.which to make tools "available" - with patch("shutil.which", return_value="/usr/bin/ruff"): + # Mock environment detection and tool availability + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/ruff"), + ): report = checker.run_all_checks() assert report.total_checks >= 1 @@ -140,8 +158,21 @@ def test_run_all_checks_with_ruff(self, tmp_path: Path): def test_run_all_checks_fail_fast(self, tmp_path: Path): """Test run_all_checks stops on first failure with fail_fast.""" + # Create src directory for source detection + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + checker = ReproChecker(repo_path=tmp_path, budget=30, fail_fast=True) + # Mock environment detection + env_info = EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="Test", + ) + with patch("subprocess.run") as mock_run: mock_proc = MagicMock() mock_proc.returncode = 1 # First check fails @@ -149,8 +180,12 @@ def test_run_all_checks_fail_fast(self, tmp_path: Path): mock_proc.stderr = "Error" mock_run.return_value = mock_proc - # Mock shutil.which to make tools "available" - with patch("shutil.which", return_value="/usr/bin/ruff"): + # Mock environment detection and tool availability + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/ruff"), + ): report = checker.run_all_checks() # Should have stopped after first failure @@ -165,9 +200,22 @@ def test_repro_checker_fix_flag(self, tmp_path: Path): semgrep_config.parent.mkdir(parents=True, exist_ok=True) semgrep_config.write_text("rules:\n - id: test-rule\n patterns:\n - pattern: test\n") + # Create src directory for source detection + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + checker = ReproChecker(repo_path=tmp_path, budget=30, fix=True) assert checker.fix is True + # Mock environment detection + env_info = EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="Test", + ) + with patch("subprocess.run") as mock_run: mock_proc = MagicMock() mock_proc.returncode = 0 @@ -175,8 +223,12 @@ def test_repro_checker_fix_flag(self, tmp_path: Path): mock_proc.stderr = "" mock_run.return_value = mock_proc - # Mock shutil.which to make tools "available" - with patch("shutil.which", return_value="/usr/bin/semgrep"): + # Mock environment detection and tool availability + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/semgrep"), + ): checker.run_all_checks() # Verify Semgrep was called with --autofix flag @@ -277,3 +329,162 @@ def test_repro_report_metadata_minimal(self): # Should still have timestamp even if no other metadata assert "metadata" in report_dict assert "timestamp" in report_dict["metadata"] + + def test_run_all_checks_with_environment_detection_hatch(self, tmp_path: Path): + """Test run_all_checks uses hatch environment when detected.""" + # Create hatch project structure + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.hatch] +version = "1.0.0" +""" + ) + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + + checker = ReproChecker(repo_path=tmp_path, budget=30) + + # Mock hatch environment detection + env_info = EnvManagerInfo( + manager=EnvManager.HATCH, + available=True, + command_prefix=["hatch", "run"], + message="Detected hatch", + ) + + with patch("subprocess.run") as mock_run: + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "Success" + mock_proc.stderr = "" + mock_run.return_value = mock_proc + + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/ruff"), + ): + checker.run_all_checks() + + # Verify commands were built with hatch prefix + ruff_calls = [ + call + for call in mock_run.call_args_list + if "ruff" in str(call.args[0] if hasattr(call, "args") else call) + ] + if ruff_calls: + # Check that hatch run was used + call_args = ruff_calls[0].args[0] if hasattr(ruff_calls[0], "args") else ruff_calls[0][0] + assert "hatch" in str(call_args) or any("hatch" in str(arg) for arg in call_args) + + def test_run_all_checks_with_environment_detection_poetry(self, tmp_path: Path): + """Test run_all_checks uses poetry environment when detected.""" + # Create poetry project structure + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.poetry] +name = "test-project" +""" + ) + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + + checker = ReproChecker(repo_path=tmp_path, budget=30) + + # Mock poetry environment detection + env_info = EnvManagerInfo( + manager=EnvManager.POETRY, + available=True, + command_prefix=["poetry", "run"], + message="Detected poetry", + ) + + with patch("subprocess.run") as mock_run: + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "Success" + mock_proc.stderr = "" + mock_run.return_value = mock_proc + + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/ruff"), + ): + report = checker.run_all_checks() + + assert report.total_checks >= 1 + + def test_run_all_checks_tool_not_available(self, tmp_path: Path): + """Test run_all_checks skips tools that are not available.""" + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "__init__.py").write_text("") + + checker = ReproChecker(repo_path=tmp_path, budget=30) + + # Mock environment detection + env_info = EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="Test", + ) + + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(False, "Tool not found")), + ): + report = checker.run_all_checks() + + # Tools should be skipped + skipped_checks = [c for c in report.checks if c.status == CheckStatus.SKIPPED] + assert len(skipped_checks) > 0 + assert all("not found" in c.error.lower() or "not available" in c.error.lower() for c in skipped_checks) + + def test_run_all_checks_source_detection(self, tmp_path: Path): + """Test run_all_checks detects source directories dynamically.""" + # Create package directory (not src/) + package_dir = tmp_path / "my_package" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("") + + # Create pyproject.toml with package name + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[project] +name = "my-package" +""" + ) + + checker = ReproChecker(repo_path=tmp_path, budget=30) + + # Mock environment detection + env_info = EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="Test", + ) + + with patch("subprocess.run") as mock_run: + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = "Success" + mock_proc.stderr = "" + mock_run.return_value = mock_proc + + with ( + patch("specfact_cli.utils.env_manager.detect_env_manager", return_value=env_info), + patch("specfact_cli.utils.env_manager.check_tool_in_env", return_value=(True, None)), + patch("shutil.which", return_value="/usr/bin/ruff"), + ): + report = checker.run_all_checks() + + # Should have detected my_package/ as source directory + assert report.total_checks >= 1