diff --git a/src/agentready/cli/align.py b/src/agentready/cli/align.py index fd128a97..9665a2c2 100644 --- a/src/agentready/cli/align.py +++ b/src/agentready/cli/align.py @@ -145,6 +145,12 @@ def align(repository, dry_run, attributes, interactive): if not fix_plan.fixes: click.echo("\n✅ No automatic fixes available.") + failing_ids = {f.attribute.id for f in assessment.findings if f.status == "fail"} + if "claude_md_file" in failing_ids: + click.echo( + "\n💡 Tip: Install the Claude CLI and set ANTHROPIC_API_KEY to " + "enable automatic CLAUDE.md generation." + ) sys.exit(0) # Show fix plan @@ -191,7 +197,15 @@ def align(repository, dry_run, attributes, interactive): # Step 4: Apply fixes click.echo(f"\n🔨 Applying {len(fixes_to_apply)} fixes...\n") - results = fixer_service.apply_fixes(fixes_to_apply, dry_run=False) + def progress_callback(fix, phase: str, success: bool | None) -> None: + if fix.attribute_id == "claude_md_file" and phase == "before": + click.echo(" Generating CLAUDE.md file...") + + results = fixer_service.apply_fixes( + fixes_to_apply, + dry_run=False, + progress_callback=progress_callback, + ) # Report results click.echo("=" * 60) diff --git a/src/agentready/fixers/documentation.py b/src/agentready/fixers/documentation.py index 67697791..6064125f 100644 --- a/src/agentready/fixers/documentation.py +++ b/src/agentready/fixers/documentation.py @@ -1,27 +1,98 @@ """Fixers for documentation-related attributes.""" -from datetime import datetime +import os +import shutil from pathlib import Path from typing import Optional -from jinja2 import Environment, PackageLoader - from ..models.finding import Finding -from ..models.fix import FileCreationFix, Fix +from ..models.fix import CommandFix, Fix, MultiStepFix from ..models.repository import Repository from .base import BaseFixer +# Env var required for Claude CLI (used by CLAUDEmdFixer) +ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY" + +# Single line written to CLAUDE.md when pointing to AGENTS.md +CLAUDE_MD_REDIRECT_LINE = "@AGENTS.md\n" + +# Command run by CLAUDEmdFixer to generate CLAUDE.md via Claude CLI +CLAUDE_MD_COMMAND = ( + 'claude -p "Initialize this project with a CLAUDE.md file" ' + '--allowedTools "Read,Edit,Write,Bash"' +) + + +class _ClaudeMdToAgentRedirectFix(Fix): + """Post-step fix: move CLAUDE.md content to AGENTS.md, replace CLAUDE.md with @AGENTS.md.""" + + def __init__( + self, + attribute_id: str, + description: str, + points_gained: float, + repository_path: Path, + ): + self.attribute_id = attribute_id + self.description = description + self.points_gained = points_gained + self.repository_path = repository_path + + def apply(self, dry_run: bool = False) -> bool: + """Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md. + + If AGENTS.md already exists, it is preserved and only CLAUDE.md is replaced + with the redirect (idempotent behavior). + """ + claude_md = self.repository_path / "CLAUDE.md" + if not claude_md.exists(): + return True # Nothing to do (e.g. dry run of first step did not create it) + if dry_run: + return True + agents_md = self.repository_path / "AGENTS.md" + if not agents_md.exists(): + content = claude_md.read_text(encoding="utf-8") + agents_md.write_text(content, encoding="utf-8") + claude_md.write_text(CLAUDE_MD_REDIRECT_LINE, encoding="utf-8") + return True + + def preview(self) -> str: + """Preview move and redirect.""" + return "Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md" + + +class _ClaudeMdRedirectOnlyFix(Fix): + """Single-step fix: create or overwrite CLAUDE.md with @AGENTS.md (when AGENTS.md already exists).""" + + def __init__( + self, + attribute_id: str, + description: str, + points_gained: float, + repository_path: Path, + ): + self.attribute_id = attribute_id + self.description = description + self.points_gained = points_gained + self.repository_path = repository_path + + def apply(self, dry_run: bool = False) -> bool: + """Write CLAUDE.md with redirect to AGENTS.md.""" + if dry_run: + return True + (self.repository_path / "CLAUDE.md").write_text(CLAUDE_MD_REDIRECT_LINE, encoding="utf-8") + return True + + def preview(self) -> str: + return "Create CLAUDE.md with @AGENTS.md redirect" + class CLAUDEmdFixer(BaseFixer): - """Fixer for missing CLAUDE.md file.""" + """Fixer for missing CLAUDE.md file. - def __init__(self): - """Initialize with Jinja2 environment.""" - self.env = Environment( - loader=PackageLoader("agentready", "templates/align"), - trim_blocks=True, - lstrip_blocks=True, - ) + Runs the Claude CLI to generate CLAUDE.md in the repository + instead of using a static template. + """ @property def attribute_id(self) -> str: @@ -33,28 +104,53 @@ def can_fix(self, finding: Finding) -> bool: return finding.status == "fail" and finding.attribute.id == self.attribute_id def generate_fix(self, repository: Repository, finding: Finding) -> Optional[Fix]: - """Generate CLAUDE.md from template.""" + """Return a fix for missing CLAUDE.md. + + If AGENTS.md already exists: create CLAUDE.md with @AGENTS.md only (no Claude CLI). + Otherwise: run Claude CLI to generate CLAUDE.md, then move content to AGENTS.md + and replace CLAUDE.md with @AGENTS.md. Returns None if Claude CLI is required + but not on PATH or ANTHROPIC_API_KEY is not set. + """ if not self.can_fix(finding): return None - # Load template - template = self.env.get_template("CLAUDE.md.j2") + agents_md = repository.path / "AGENTS.md" + if agents_md.exists(): + points = self.estimate_score_improvement(finding) + return _ClaudeMdRedirectOnlyFix( + attribute_id=self.attribute_id, + description="Create CLAUDE.md with @AGENTS.md redirect", + points_gained=points, + repository_path=repository.path, + ) + + if not shutil.which("claude"): + return None + if not os.environ.get(ANTHROPIC_API_KEY_ENV): + return None - # Render with repository context - content = template.render( - repo_name=repository.path.name, - current_date=datetime.now().strftime("%Y-%m-%d"), + points = self.estimate_score_improvement(finding) + command_fix = CommandFix( + attribute_id=self.attribute_id, + description="Run Claude CLI to create CLAUDE.md in the project", + points_gained=points, + command=CLAUDE_MD_COMMAND, + working_dir=repository.path, + repository_path=repository.path, + capture_output=False, # Stream Claude output to terminal ) - - # Create fix - return FileCreationFix( + post_step = _ClaudeMdToAgentRedirectFix( attribute_id=self.attribute_id, - description="Create CLAUDE.md with project documentation template", - points_gained=self.estimate_score_improvement(finding), - file_path=Path("CLAUDE.md"), - content=content, + description="Move CLAUDE.md content to AGENTS.md and replace CLAUDE.md with @AGENTS.md", + points_gained=0.0, # Points already counted in command step repository_path=repository.path, ) + return MultiStepFix( + attribute_id=self.attribute_id, + description="Run Claude CLI to create CLAUDE.md, then move content to AGENTS.md", + points_gained=points, + steps=[command_fix, post_step], + ) class GitignoreFixer(BaseFixer): diff --git a/src/agentready/models/fix.py b/src/agentready/models/fix.py index 9b7da5ff..b1ae4d35 100644 --- a/src/agentready/models/fix.py +++ b/src/agentready/models/fix.py @@ -133,11 +133,13 @@ class CommandFix(Fix): command: Command to execute working_dir: Directory to run command in repository_path: Repository root path + capture_output: If True, suppress stdout/stderr; if False, stream to terminal """ command: str working_dir: Optional[Path] repository_path: Path + capture_output: bool = True def apply(self, dry_run: bool = False) -> bool: """Execute the command. @@ -166,7 +168,7 @@ def apply(self, dry_run: bool = False) -> bool: cmd_list, cwd=cwd, check=True, - capture_output=True, + capture_output=self.capture_output, text=True, # Security: Never use shell=True - explicitly removed ) diff --git a/src/agentready/services/fixer_service.py b/src/agentready/services/fixer_service.py index 3675e833..76bcb7fd 100644 --- a/src/agentready/services/fixer_service.py +++ b/src/agentready/services/fixer_service.py @@ -1,7 +1,7 @@ """Service for orchestrating automated fixes.""" from dataclasses import dataclass -from typing import List +from typing import Callable, List, Optional from ..fixers.base import BaseFixer from ..fixers.documentation import CLAUDEmdFixer, GitignoreFixer @@ -87,12 +87,21 @@ def generate_fix_plan( points_gained=points_gained, ) - def apply_fixes(self, fixes: List[Fix], dry_run: bool = False) -> dict: + def apply_fixes( + self, + fixes: List[Fix], + dry_run: bool = False, + progress_callback: Optional[ + Callable[[Fix, str, Optional[bool]], None] + ] = None, + ) -> dict: """Apply a list of fixes. Args: fixes: Fixes to apply dry_run: If True, don't make changes + progress_callback: Optional callback(fix, phase, success) where + phase is "before" or "after", and success is set only for "after". Returns: Dict with success counts and failures @@ -100,8 +109,12 @@ def apply_fixes(self, fixes: List[Fix], dry_run: bool = False) -> dict: results = {"succeeded": 0, "failed": 0, "failures": []} for fix in fixes: + if progress_callback: + progress_callback(fix, "before", None) try: success = fix.apply(dry_run=dry_run) + if progress_callback: + progress_callback(fix, "after", success) if success: results["succeeded"] += 1 else: @@ -110,6 +123,8 @@ def apply_fixes(self, fixes: List[Fix], dry_run: bool = False) -> dict: f"{fix.description}: Unable to apply fix" ) except Exception as e: + if progress_callback: + progress_callback(fix, "after", False) results["failed"] += 1 results["failures"].append(f"{fix.description}: {str(e)}") diff --git a/tests/unit/test_cli_align.py b/tests/unit/test_cli_align.py index 13f8696b..e2a88efe 100644 --- a/tests/unit/test_cli_align.py +++ b/tests/unit/test_cli_align.py @@ -424,3 +424,142 @@ def test_align_fixer_service_error( # Should handle error gracefully assert result.exit_code != 0 + + +class TestAlignClaudeMdFileFeatures: + """Test align command features specific to claude_md_file attribute. + + These tests verify the tip message when CLAUDE.md fix is skipped and + the progress callback logging for CLAUDE.md generation. + """ + + @patch("agentready.cli.align.FixerService") + @patch("agentready.cli.align.Scanner") + @patch("agentready.cli.align.Config") + @patch("agentready.cli.main.create_all_assessors") + def test_align_echoes_tip_when_no_fixes_and_claude_md_file_failing( + self, mock_assessors, mock_config, mock_scanner, mock_fixer, runner, temp_repo + ): + """Test that align shows tip when claude_md_file fails but no fix is available.""" + # Setup mock finding with claude_md_file failing + mock_finding = MagicMock() + mock_finding.attribute.id = "claude_md_file" + mock_finding.status = "fail" + mock_finding.score = 0.0 + + mock_assessment = MagicMock() + mock_assessment.overall_score = 65.0 + mock_assessment.findings = [mock_finding] + mock_assessment.repository = MagicMock() + mock_scanner.return_value.scan.return_value = mock_assessment + + # No fixes available (e.g., claude CLI not installed or no API key) + mock_fix_plan = MagicMock() + mock_fix_plan.fixes = [] + mock_fix_plan.projected_score = 65.0 + mock_fix_plan.points_gained = 0.0 + mock_fixer.return_value.generate_fix_plan.return_value = mock_fix_plan + + mock_assessors.return_value = [] + + result = runner.invoke(align, [str(temp_repo)]) + + # Should show the tip about Claude CLI and API key + assert "Install the Claude CLI and set ANTHROPIC_API_KEY" in result.output + assert "CLAUDE.md" in result.output + + @patch("agentready.cli.align.FixerService") + @patch("agentready.cli.align.Scanner") + @patch("agentready.cli.align.Config") + @patch("agentready.cli.main.create_all_assessors") + def test_align_does_not_show_tip_when_claude_md_file_passes( + self, mock_assessors, mock_config, mock_scanner, mock_fixer, runner, temp_repo + ): + """Test that align does not show tip when claude_md_file passes.""" + # Setup mock finding with claude_md_file passing + mock_finding = MagicMock() + mock_finding.attribute.id = "claude_md_file" + mock_finding.status = "pass" + mock_finding.score = 100.0 + + mock_assessment = MagicMock() + mock_assessment.overall_score = 85.0 + mock_assessment.findings = [mock_finding] + mock_assessment.repository = MagicMock() + mock_scanner.return_value.scan.return_value = mock_assessment + + # No fixes available + mock_fix_plan = MagicMock() + mock_fix_plan.fixes = [] + mock_fix_plan.projected_score = 85.0 + mock_fix_plan.points_gained = 0.0 + mock_fixer.return_value.generate_fix_plan.return_value = mock_fix_plan + + mock_assessors.return_value = [] + + result = runner.invoke(align, [str(temp_repo)]) + + # Should NOT show the tip + assert "Install the Claude CLI and set ANTHROPIC_API_KEY" not in result.output + + @patch("agentready.cli.align.FixerService") + @patch("agentready.cli.align.Scanner") + @patch("agentready.cli.align.Config") + @patch("agentready.cli.main.create_all_assessors") + def test_align_echoes_generating_claude_md_when_fix_applies( + self, mock_assessors, mock_config, mock_scanner, mock_fixer_cls, runner, temp_repo + ): + """Test that align echoes 'Generating CLAUDE.md file...' when applying fix.""" + # Setup mock finding + mock_finding = MagicMock() + mock_finding.attribute.id = "claude_md_file" + mock_finding.status = "fail" + mock_finding.score = 0.0 + + mock_assessment = MagicMock() + mock_assessment.overall_score = 65.0 + mock_assessment.findings = [mock_finding] + mock_assessment.repository = MagicMock() + mock_scanner.return_value.scan.return_value = mock_assessment + + # Setup mock fix for claude_md_file + mock_fix = MagicMock() + mock_fix.attribute_id = "claude_md_file" + mock_fix.description = "Run Claude CLI to create CLAUDE.md" + mock_fix.preview.return_value = "RUN claude -p ..." + mock_fix.points_gained = 10.0 + mock_fix.apply.return_value = True + + mock_fix_plan = MagicMock() + mock_fix_plan.fixes = [mock_fix] + mock_fix_plan.projected_score = 75.0 + mock_fix_plan.points_gained = 10.0 + + # Capture the progress_callback when apply_fixes is called + captured_callback = None + + def capture_apply_fixes(fixes, dry_run=False, progress_callback=None): + nonlocal captured_callback + captured_callback = progress_callback + # Call the callback to simulate the real behavior + if progress_callback: + for fix in fixes: + progress_callback(fix, "before", None) + progress_callback(fix, "after", True) + return {"succeeded": 1, "failed": 0, "failures": []} + + mock_fixer_instance = MagicMock() + mock_fixer_instance.generate_fix_plan.return_value = mock_fix_plan + mock_fixer_instance.apply_fixes.side_effect = capture_apply_fixes + mock_fixer_cls.return_value = mock_fixer_instance + + mock_assessors.return_value = [] + + # Provide "y" input to confirm applying fixes + result = runner.invoke(align, [str(temp_repo)], input="y\n") + + # Should show the "Generating CLAUDE.md file..." message + assert "Generating CLAUDE.md file..." in result.output + + # Verify the callback was captured + assert captured_callback is not None diff --git a/tests/unit/test_fixer_service.py b/tests/unit/test_fixer_service.py index 8d62fc15..9cb1e715 100644 --- a/tests/unit/test_fixer_service.py +++ b/tests/unit/test_fixer_service.py @@ -446,6 +446,112 @@ def test_apply_mixed_success_and_failure(self, tmp_path): assert results["failed"] == 1 assert len(results["failures"]) == 1 + def test_apply_fixes_invokes_progress_callback_before_and_after_success( + self, tmp_path + ): + """Test that progress_callback is invoked with correct phases on success.""" + fix = FileCreationFix( + attribute_id="test_attr", + description="Create test file", + points_gained=10.0, + file_path=Path("test.txt"), + content="test content", + repository_path=tmp_path, + ) + + callback_calls = [] + + def progress_callback(fix, phase, success): + callback_calls.append((fix.attribute_id, phase, success)) + + service = FixerService() + results = service.apply_fixes([fix], progress_callback=progress_callback) + + assert results["succeeded"] == 1 + assert len(callback_calls) == 2 + assert callback_calls[0] == ("test_attr", "before", None) + assert callback_calls[1] == ("test_attr", "after", True) + + def test_apply_fixes_invokes_progress_callback_after_false_on_exception( + self, tmp_path + ): + """Test that progress_callback is invoked with success=False on exception.""" + + class FailingFix(FileCreationFix): + def apply(self, dry_run=False): + raise RuntimeError("Intentional failure") + + fix = FailingFix( + attribute_id="test_attr", + description="Failing fix", + points_gained=10.0, + file_path=Path("test.txt"), + content="content", + repository_path=tmp_path, + ) + + callback_calls = [] + + def progress_callback(fix, phase, success): + callback_calls.append((fix.attribute_id, phase, success)) + + service = FixerService() + results = service.apply_fixes([fix], progress_callback=progress_callback) + + assert results["failed"] == 1 + assert len(callback_calls) == 2 + assert callback_calls[0] == ("test_attr", "before", None) + assert callback_calls[1] == ("test_attr", "after", False) + + def test_apply_fixes_invokes_progress_callback_after_false_on_apply_failure( + self, tmp_path + ): + """Test progress_callback is invoked with success=False when apply returns False.""" + # Create fix that will fail (file already exists) + test_file = tmp_path / "existing.txt" + test_file.write_text("existing content") + + fix = FileCreationFix( + attribute_id="test_attr", + description="Create existing file", + points_gained=10.0, + file_path=Path("existing.txt"), + content="new content", + repository_path=tmp_path, + ) + + callback_calls = [] + + def progress_callback(fix, phase, success): + callback_calls.append((fix.attribute_id, phase, success)) + + service = FixerService() + results = service.apply_fixes([fix], progress_callback=progress_callback) + + assert results["failed"] == 1 + assert len(callback_calls) == 2 + assert callback_calls[0] == ("test_attr", "before", None) + assert callback_calls[1] == ("test_attr", "after", False) + + def test_apply_fixes_without_callback_unchanged_behavior(self, tmp_path): + """Test that apply_fixes works correctly without progress_callback (backward compat).""" + fix = FileCreationFix( + attribute_id="test_attr", + description="Create test file", + points_gained=10.0, + file_path=Path("test.txt"), + content="test content", + repository_path=tmp_path, + ) + + service = FixerService() + # Call without progress_callback - should not raise + results = service.apply_fixes([fix]) + + assert results["succeeded"] == 1 + assert results["failed"] == 0 + assert (tmp_path / "test.txt").exists() + def test_find_fixer_existing(self): """Test finding fixer for existing attribute.""" service = FixerService() diff --git a/tests/unit/test_fixers.py b/tests/unit/test_fixers.py index c581e05d..61b37b53 100644 --- a/tests/unit/test_fixers.py +++ b/tests/unit/test_fixers.py @@ -1,13 +1,22 @@ """Unit tests for fixers.""" +import os import tempfile from pathlib import Path +from unittest.mock import patch import pytest -from agentready.fixers.documentation import CLAUDEmdFixer, GitignoreFixer +from agentready.fixers.documentation import ( + ANTHROPIC_API_KEY_ENV, + CLAUDE_MD_COMMAND, + CLAUDE_MD_REDIRECT_LINE, + CLAUDEmdFixer, + GitignoreFixer, +) from agentready.models.attribute import Attribute from agentready.models.finding import Finding, Remediation +from agentready.models.fix import CommandFix, Fix, MultiStepFix from agentready.models.repository import Repository @@ -117,43 +126,132 @@ def test_cannot_fix_passing_finding(self, claude_md_failing_finding): claude_md_failing_finding.status = "pass" assert fixer.can_fix(claude_md_failing_finding) is False - def test_generate_fix(self, temp_repo, claude_md_failing_finding): - """Test generating CLAUDE.md fix.""" - fixer = CLAUDEmdFixer() - fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + def test_generate_fix_when_agent_md_missing(self, temp_repo, claude_md_failing_finding): + """Test generating fix when AGENTS.md is missing returns MultiStepFix with CommandFix + post-step.""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) assert fix is not None + assert isinstance(fix, MultiStepFix) + assert len(fix.steps) == 2 + assert isinstance(fix.steps[0], CommandFix) + assert fix.steps[0].command == CLAUDE_MD_COMMAND + assert fix.steps[0].working_dir == temp_repo.path + assert fix.steps[0].capture_output is False assert fix.attribute_id == "claude_md_file" - assert fix.file_path == Path("CLAUDE.md") - assert "# " in fix.content # Has markdown header assert fix.points_gained > 0 + assert "Move" in fix.steps[1].preview() and "AGENTS.md" in fix.steps[1].preview() + + def test_generate_fix_when_agent_md_exists_returns_redirect_only_fix( + self, temp_repo, claude_md_failing_finding + ): + """Test that when AGENTS.md exists, fixer returns single-step redirect fix (no Claude CLI).""" + (temp_repo.path / "AGENTS.md").write_text("# Agent docs\n", encoding="utf-8") - def test_apply_fix_dry_run(self, temp_repo, claude_md_failing_finding): - """Test applying fix in dry-run mode.""" fixer = CLAUDEmdFixer() fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + assert fix is not None + assert isinstance(fix, Fix) + assert not isinstance(fix, MultiStepFix) + assert fix.attribute_id == "claude_md_file" + assert fix.points_gained > 0 + # Applying the fix should create CLAUDE.md with redirect only + result = fix.apply(dry_run=False) + assert result is True + assert (temp_repo.path / "CLAUDE.md").read_text() == CLAUDE_MD_REDIRECT_LINE + + def test_generate_fix_returns_none_when_claude_not_on_path( + self, temp_repo, claude_md_failing_finding + ): + """Test that no fix is generated when Claude CLI is not on PATH (AGENTS.md missing).""" + with patch("agentready.fixers.documentation.shutil.which", return_value=None): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + + assert fix is None + + def test_generate_fix_returns_none_when_no_api_key( + self, temp_repo, claude_md_failing_finding + ): + """Test that no fix is generated when ANTHROPIC_API_KEY is not set.""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: ""}, clear=False): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + + assert fix is None + + def test_apply_fix_dry_run_when_agent_md_missing( + self, temp_repo, claude_md_failing_finding + ): + """Test applying MultiStep fix in dry-run (command not executed).""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + result = fix.apply(dry_run=True) assert result is True - # File should NOT be created in dry run + # File should NOT be created in dry run (claude CLI not run) assert not (temp_repo.path / "CLAUDE.md").exists() - def test_apply_fix_real(self, temp_repo, claude_md_failing_finding): - """Test applying fix for real.""" - fixer = CLAUDEmdFixer() - fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + def test_apply_fix_real_runs_claude_cli(self, temp_repo, claude_md_failing_finding): + """Test applying MultiStep fix runs Claude CLI (subprocess mocked).""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = None # run() returns None when check=True succeeds + result = fix.apply(dry_run=False) - result = fix.apply(dry_run=False) assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args + assert "claude" in call_args[0][0] + assert call_args[1]["capture_output"] is False + assert call_args[1]["cwd"] == temp_repo.path + + def test_post_step_moves_content_to_agent_md(self, temp_repo, claude_md_failing_finding): + """Test second step moves CLAUDE.md content to AGENTS.md and replaces CLAUDE.md with @AGENTS.md.""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) - # File should be created - assert (temp_repo.path / "CLAUDE.md").exists() + assert isinstance(fix, MultiStepFix) + (temp_repo.path / "CLAUDE.md").write_text("# Full content from Claude\nLine 2\n", encoding="utf-8") - # Content should be valid - content = (temp_repo.path / "CLAUDE.md").read_text() - assert len(content) > 0 - assert "# " in content + result = fix.steps[1].apply(dry_run=False) + + assert result is True + assert (temp_repo.path / "AGENTS.md").exists() + assert (temp_repo.path / "AGENTS.md").read_text() == "# Full content from Claude\nLine 2\n" + assert (temp_repo.path / "CLAUDE.md").read_text() == CLAUDE_MD_REDIRECT_LINE + + def test_post_step_preserves_existing_agents_md(self, temp_repo, claude_md_failing_finding): + """Test second step does not overwrite AGENTS.md when it already exists (idempotency).""" + with patch("agentready.fixers.documentation.shutil.which", return_value="/usr/bin/claude"): + with patch.dict(os.environ, {ANTHROPIC_API_KEY_ENV: "test-key"}): + fixer = CLAUDEmdFixer() + fix = fixer.generate_fix(temp_repo, claude_md_failing_finding) + + assert isinstance(fix, MultiStepFix) + existing_content = "# Existing AGENTS.md\nCustom rules here.\n" + (temp_repo.path / "AGENTS.md").write_text(existing_content, encoding="utf-8") + (temp_repo.path / "CLAUDE.md").write_text("# New content from Claude\n", encoding="utf-8") + + result = fix.steps[1].apply(dry_run=False) + + assert result is True + assert (temp_repo.path / "AGENTS.md").read_text() == existing_content + assert (temp_repo.path / "CLAUDE.md").read_text() == CLAUDE_MD_REDIRECT_LINE class TestGitignoreFixer: diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index c3e196b7..4175129a 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -2,6 +2,7 @@ from datetime import datetime from pathlib import Path +from unittest.mock import patch import pytest @@ -794,6 +795,61 @@ def test_command_fix_preview(self, tmp_path): assert "RUN" in preview assert "echo test" in preview + def test_command_fix_capture_output_default_true(self, tmp_path): + """Test that CommandFix defaults to capture_output=True.""" + fix = CommandFix( + attribute_id="test_attr", + description="Run test command", + points_gained=10.0, + command="echo test", + working_dir=None, + repository_path=tmp_path, + ) + + assert fix.capture_output is True + + def test_command_fix_apply_passes_capture_output_false_to_subprocess(self, tmp_path): + """Test CommandFix.apply() passes capture_output=False to subprocess.run.""" + fix = CommandFix( + attribute_id="test_attr", + description="Run test command", + points_gained=10.0, + command="echo hello", + working_dir=None, + repository_path=tmp_path, + capture_output=False, + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = None + result = fix.apply(dry_run=False) + + assert result is True + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert call_kwargs["capture_output"] is False + + def test_command_fix_apply_passes_capture_output_true_to_subprocess(self, tmp_path): + """Test CommandFix.apply() passes capture_output=True (default) to subprocess.run.""" + fix = CommandFix( + attribute_id="test_attr", + description="Run test command", + points_gained=10.0, + command="echo hello", + working_dir=None, + repository_path=tmp_path, + # capture_output defaults to True + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = None + result = fix.apply(dry_run=False) + + assert result is True + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert call_kwargs["capture_output"] is True + def test_multi_step_fix_construction(self, tmp_path): """Test creating a MultiStepFix.""" fix1 = FileCreationFix(