-
Notifications
You must be signed in to change notification settings - Fork 34
feat: Implement align subcommand for automated remediation (Issue #14) #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
82e5c87
7e7ce52
213a498
df272f2
a5b1d1f
4b98ccc
9308904
2d4d3a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| """Align command for automated remediation.""" | ||
|
|
||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import click | ||
|
|
||
| from ..models.config import Config | ||
| from ..models.repository import Repository | ||
| from ..services.fixer_service import FixerService | ||
| from ..services.language_detector import LanguageDetector | ||
| from ..services.scanner import Scanner | ||
|
|
||
|
|
||
| def get_certification_level(score: float) -> tuple[str, str]: | ||
| """Get certification level and emoji for score. | ||
|
|
||
| Args: | ||
| score: Score 0-100 | ||
|
|
||
| Returns: | ||
| Tuple of (level_name, emoji) | ||
| """ | ||
| if score >= 90: | ||
| return ("Platinum", "💎") | ||
| elif score >= 75: | ||
| return ("Gold", "🥇") | ||
| elif score >= 60: | ||
| return ("Silver", "🥈") | ||
| elif score >= 40: | ||
| return ("Bronze", "🥉") | ||
| else: | ||
| return ("Needs Improvement", "📊") | ||
|
|
||
|
|
||
| @click.command() | ||
| @click.argument("repository", type=click.Path(exists=True), default=".") | ||
| @click.option( | ||
| "--dry-run", | ||
| is_flag=True, | ||
| help="Preview changes without applying them", | ||
| ) | ||
| @click.option( | ||
| "--attributes", | ||
| help="Comma-separated attribute IDs to fix (default: all)", | ||
| ) | ||
| @click.option( | ||
| "--interactive", | ||
| "-i", | ||
| is_flag=True, | ||
| help="Confirm each fix before applying", | ||
| ) | ||
| def align(repository, dry_run, attributes, interactive): | ||
| """Align repository with best practices by applying automatic fixes. | ||
|
|
||
| Runs assessment, identifies failing attributes, and automatically generates | ||
| and applies fixes to improve the repository's agent-ready score. | ||
|
|
||
| REPOSITORY: Path to repository (default: current directory) | ||
| """ | ||
| repo_path = Path(repository).resolve() | ||
|
|
||
| # Validate git repository | ||
| if not (repo_path / ".git").exists(): | ||
| click.echo("Error: Not a git repository", err=True) | ||
| sys.exit(1) | ||
|
|
||
| click.echo("🔧 AgentReady Align") | ||
| click.echo("=" * 60) | ||
| click.echo(f"\nRepository: {repo_path}") | ||
| if dry_run: | ||
| click.echo("Mode: DRY RUN (preview only)\n") | ||
| else: | ||
| click.echo("Mode: APPLY FIXES\n") | ||
|
|
||
| # Step 1: Run assessment | ||
| click.echo("📊 Running assessment...") | ||
| try: | ||
| # Create repository model | ||
| detector = LanguageDetector(repo_path) | ||
| languages = detector.detect_languages() | ||
|
|
||
| repo = Repository( | ||
| path=repo_path, | ||
| languages=languages, | ||
| metadata={}, | ||
| ) | ||
|
|
||
| # Load config | ||
| config = Config.load_default() | ||
|
|
||
| # Run assessment | ||
| scanner = Scanner(config=config) | ||
| assessment = scanner.scan(repo) | ||
|
Comment on lines
+93
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right after building the repo, align creates the scanner with Useful? React with 👍 / 👎. |
||
|
|
||
| current_level, current_emoji = get_certification_level(assessment.overall_score) | ||
|
|
||
| click.echo( | ||
| f"Current Score: {assessment.overall_score:.1f}/100 ({current_level} {current_emoji})" | ||
| ) | ||
| click.echo(f"Attributes Assessed: {len(assessment.findings)}") | ||
| click.echo( | ||
| f"Failing Attributes: {sum(1 for f in assessment.findings if f.status == 'fail')}\n" | ||
| ) | ||
|
|
||
| except Exception as e: | ||
| click.echo(f"\nError during assessment: {str(e)}", err=True) | ||
| sys.exit(1) | ||
|
|
||
| # Step 2: Generate fix plan | ||
| click.echo("🔍 Analyzing fixable issues...") | ||
|
|
||
| attribute_list = None | ||
| if attributes: | ||
| attribute_list = [a.strip() for a in attributes.split(",")] | ||
|
|
||
| fixer_service = FixerService() | ||
| fix_plan = fixer_service.generate_fix_plan(assessment, repo, attribute_list) | ||
|
|
||
| if not fix_plan.fixes: | ||
| click.echo("\n✅ No automatic fixes available.") | ||
| click.echo( | ||
| "All fixable attributes are passing, or failing attributes require manual remediation." | ||
| ) | ||
| sys.exit(0) | ||
|
|
||
| # Show fix plan | ||
| projected_level, projected_emoji = get_certification_level(fix_plan.projected_score) | ||
|
|
||
| click.echo(f"\nFixes Available: {len(fix_plan.fixes)}") | ||
| click.echo(f"Points to Gain: +{fix_plan.points_gained:.1f}") | ||
| click.echo( | ||
| f"Projected Score: {fix_plan.projected_score:.1f}/100 ({projected_level} {projected_emoji})\n" | ||
| ) | ||
|
|
||
| click.echo("Changes to be applied:\n") | ||
| for i, fix in enumerate(fix_plan.fixes, 1): | ||
| click.echo(f" {i}. [{fix.attribute_id}] {fix.description}") | ||
| click.echo(f" {fix.preview()}") | ||
| click.echo(f" Points: +{fix.points_gained:.1f}\n") | ||
|
|
||
| # Step 3: Confirm or apply | ||
| if dry_run: | ||
| click.echo("=" * 60) | ||
| click.echo("\nDry run complete! Run without --dry-run to apply fixes.") | ||
| return | ||
|
|
||
| # Interactive mode: confirm each fix | ||
| fixes_to_apply = [] | ||
| if interactive: | ||
| click.echo("=" * 60) | ||
| click.echo("\nInteractive mode: Confirm each fix\n") | ||
| for fix in fix_plan.fixes: | ||
| if click.confirm(f"Apply fix: {fix.description}?", default=True): | ||
| fixes_to_apply.append(fix) | ||
| click.echo() | ||
| else: | ||
| # Confirm all | ||
| if not click.confirm("\nApply all fixes?", default=True): | ||
| click.echo("Aborted.") | ||
| sys.exit(0) | ||
| fixes_to_apply = fix_plan.fixes | ||
|
|
||
| if not fixes_to_apply: | ||
| click.echo("No fixes selected. Aborted.") | ||
| sys.exit(0) | ||
|
|
||
| # 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) | ||
|
|
||
| # Report results | ||
| click.echo("=" * 60) | ||
| click.echo(f"\n✅ Fixes applied: {results['succeeded']}/{len(fixes_to_apply)}") | ||
|
|
||
| if results["failed"] > 0: | ||
| click.echo(f"❌ Fixes failed: {results['failed']}") | ||
| click.echo("\nFailures:") | ||
| for failure in results["failures"]: | ||
| click.echo(f" - {failure}") | ||
|
|
||
| click.echo("\nNext steps:") | ||
| click.echo(" 1. Review changes: git status") | ||
| click.echo(" 2. Test the changes") | ||
| click.echo( | ||
| " 3. Commit: git add . && git commit -m 'chore: Apply AgentReady fixes'" | ||
| ) | ||
| click.echo(" 4. Run assessment again: agentready assess .") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Fixers for automated remediation of failing attributes.""" | ||
|
|
||
| from agentready.fixers.base import BaseFixer | ||
|
|
||
| __all__ = ["BaseFixer"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Base fixer interface for automated remediation.""" | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from typing import Optional | ||
|
|
||
| from ..models.finding import Finding | ||
| from ..models.fix import Fix | ||
| from ..models.repository import Repository | ||
|
|
||
|
|
||
| class BaseFixer(ABC): | ||
| """Abstract base class for all attribute fixers. | ||
|
|
||
| Each fixer knows how to automatically remediate a specific failing attribute | ||
| by generating files, modifying configurations, or executing commands. | ||
|
|
||
| Fixers follow the strategy pattern and are stateless for easy testing. | ||
| """ | ||
|
|
||
| @property | ||
| @abstractmethod | ||
| def attribute_id(self) -> str: | ||
| """Unique attribute identifier (e.g., 'claude_md_file'). | ||
|
|
||
| Must match the attribute ID from assessors. | ||
| """ | ||
| pass | ||
|
|
||
| @abstractmethod | ||
| def can_fix(self, finding: Finding) -> bool: | ||
| """Check if this fixer can fix the given finding. | ||
|
|
||
| Args: | ||
| finding: Assessment finding for the attribute | ||
|
|
||
| Returns: | ||
| True if this fixer can generate a fix, False otherwise | ||
| """ | ||
| pass | ||
|
|
||
| @abstractmethod | ||
| def generate_fix(self, repository: Repository, finding: Finding) -> Optional[Fix]: | ||
| """Generate a fix for the failing attribute. | ||
|
|
||
| Args: | ||
| repository: Repository entity with path, languages, metadata | ||
| finding: Failing finding to remediate | ||
|
|
||
| Returns: | ||
| Fix object if one can be generated, None if cannot be fixed automatically | ||
|
|
||
| Raises: | ||
| This method should NOT raise exceptions. Return None on errors. | ||
| """ | ||
| pass | ||
|
|
||
| def estimate_score_improvement(self, finding: Finding) -> float: | ||
| """Estimate score points gained if fix is applied. | ||
|
|
||
| Args: | ||
| finding: Failing finding | ||
|
|
||
| Returns: | ||
| Estimated points (0-100) that would be gained | ||
|
|
||
| Default implementation: Use attribute default_weight from finding. | ||
| """ | ||
| if finding.status == "fail" and finding.attribute.default_weight: | ||
| # Full weight if currently failing (0 points) | ||
| return finding.attribute.default_weight * 100 | ||
| return 0.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| """Fixers for documentation-related attributes.""" | ||
|
|
||
| from datetime import datetime | ||
| 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.repository import Repository | ||
| from .base import BaseFixer | ||
|
|
||
|
|
||
| class CLAUDEmdFixer(BaseFixer): | ||
| """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, | ||
| ) | ||
|
|
||
| @property | ||
| def attribute_id(self) -> str: | ||
| """Return attribute ID.""" | ||
| return "claude_md_file" | ||
|
|
||
| def can_fix(self, finding: Finding) -> bool: | ||
| """Check if CLAUDE.md is missing.""" | ||
| 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.""" | ||
| if not self.can_fix(finding): | ||
| return None | ||
|
|
||
| # Load template | ||
| template = self.env.get_template("CLAUDE.md.j2") | ||
|
|
||
| # Render with repository context | ||
| content = template.render( | ||
| repo_name=repository.path.name, | ||
| current_date=datetime.now().strftime("%Y-%m-%d"), | ||
| ) | ||
|
|
||
| # Create fix | ||
| return FileCreationFix( | ||
| 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, | ||
| repository_path=repository.path, | ||
| ) | ||
|
|
||
|
|
||
| class GitignoreFixer(BaseFixer): | ||
| """Fixer for incomplete .gitignore.""" | ||
|
|
||
| def __init__(self): | ||
| """Initialize fixer.""" | ||
| self.template_path = ( | ||
| Path(__file__).parent.parent | ||
| / "templates" | ||
| / "align" | ||
| / "gitignore_additions.txt" | ||
| ) | ||
|
|
||
| @property | ||
| def attribute_id(self) -> str: | ||
| """Return attribute ID.""" | ||
| return "gitignore_completeness" | ||
|
|
||
| def can_fix(self, finding: Finding) -> bool: | ||
| """Check if .gitignore can be improved.""" | ||
| return finding.status == "fail" and finding.attribute.id == self.attribute_id | ||
|
|
||
| def generate_fix(self, repository: Repository, finding: Finding) -> Optional[Fix]: | ||
| """Add missing patterns to .gitignore.""" | ||
| if not self.can_fix(finding): | ||
| return None | ||
|
|
||
| # Load recommended patterns | ||
| if not self.template_path.exists(): | ||
| return None | ||
|
|
||
| additions = self.template_path.read_text(encoding="utf-8").splitlines() | ||
|
|
||
| # Import FileModificationFix | ||
| from ..models.fix import FileModificationFix | ||
|
|
||
| # Create fix | ||
| return FileModificationFix( | ||
| attribute_id=self.attribute_id, | ||
| description="Add recommended patterns to .gitignore", | ||
| points_gained=self.estimate_score_improvement(finding), | ||
| file_path=Path(".gitignore"), | ||
| additions=additions, | ||
| repository_path=repository.path, | ||
| append=False, # Smart merge to avoid duplicates | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
agentready aligncommand constructsRepositorywith onlypath,languages, and ametadatadict, butRepository’s dataclass signature (repository.py lines 22-29) also requiresname,url,branch,commit_hash,total_files, andtotal_lines. Invoking the command will immediately raiseTypeError: Repository.__init__() missing ...before any assessment can run, so the feature is unusable until the constructor is called with all required arguments.Useful? React with 👍 / 👎.