Skip to content
Merged
189 changes: 189 additions & 0 deletions src/agentready/cli/align.py
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={},
)
Comment on lines +83 to +87

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Build Repository with missing required fields

The new agentready align command constructs Repository with only path, languages, and a metadata dict, but Repository’s dataclass signature (repository.py lines 22-29) also requires name, url, branch, commit_hash, total_files, and total_lines. Invoking the command will immediately raise TypeError: 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 👍 / 👎.


# Load config
config = Config.load_default()

# Run assessment
scanner = Scanner(config=config)
assessment = scanner.scan(repo)
Comment on lines +93 to +94

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scanner invoked with wrong signature in align

Right after building the repo, align creates the scanner with Scanner(config=config) and calls scanner.scan(repo), but Scanner.__init__ expects a repository path as the first argument and scan expects an assessor list (see scanner.py lines 39-75). Running agentready align therefore raises a TypeError (__init__() missing required positional argument or scan() missing 'assessors') before any fixes are analyzed.

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 .")
2 changes: 2 additions & 0 deletions src/agentready/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from ..reporters.markdown import MarkdownReporter
from ..services.research_loader import ResearchLoader
from ..services.scanner import Scanner
from .align import align
from .bootstrap import bootstrap
from .demo import demo
from .learn import learn
Expand Down Expand Up @@ -303,6 +304,7 @@ def generate_config():


# Register commands
cli.add_command(align)
cli.add_command(bootstrap)
cli.add_command(demo)
cli.add_command(learn)
Expand Down
5 changes: 5 additions & 0 deletions src/agentready/fixers/__init__.py
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"]
71 changes: 71 additions & 0 deletions src/agentready/fixers/base.py
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
104 changes: 104 additions & 0 deletions src/agentready/fixers/documentation.py
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
)
Loading
Loading