584 lines
19 KiB
Python
584 lines
19 KiB
Python
"""Migration planning for brownfield retrofit.
|
|
|
|
This module generates step-by-step migration plans to align brownfield projects
|
|
with autonomous-dev standards. It analyzes alignment gaps, estimates effort,
|
|
detects dependencies, and optimizes execution order.
|
|
|
|
Classes:
|
|
EffortSize: Effort size categories (XS/S/M/L/XL)
|
|
ImpactLevel: Impact level categories (LOW/MEDIUM/HIGH)
|
|
MigrationStep: Represents a single migration step
|
|
MigrationPlan: Complete migration plan with steps and estimates
|
|
MigrationPlanner: Main planning coordinator
|
|
|
|
Security:
|
|
- CWE-22: Path validation via security_utils
|
|
- CWE-117: Audit logging with sanitization
|
|
- CWE-20: Input validation for all user inputs
|
|
|
|
Related:
|
|
- GitHub Issue #59: Brownfield retrofit command implementation
|
|
|
|
Relevant Skills:
|
|
- project-alignment-validation: Gap assessment methodology, prioritization patterns
|
|
- error-handling-patterns: Exception hierarchy and error handling best practices
|
|
- library-design-patterns: Standardized design patterns
|
|
- state-management-patterns: Standardized design patterns
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
from .security_utils import audit_log, validate_path
|
|
from .alignment_assessor import AlignmentGap, AssessmentResult, Severity
|
|
|
|
|
|
class EffortSize(Enum):
|
|
"""Effort size categories."""
|
|
XS = "XS" # 1 hour
|
|
S = "S" # 2 hours
|
|
M = "M" # 4 hours
|
|
L = "L" # 8 hours
|
|
XL = "XL" # 16 hours
|
|
|
|
|
|
class ImpactLevel(Enum):
|
|
"""Impact level categories."""
|
|
LOW = "LOW"
|
|
MEDIUM = "MEDIUM"
|
|
HIGH = "HIGH"
|
|
|
|
|
|
@dataclass
|
|
class MigrationStep:
|
|
"""Represents a single migration step.
|
|
|
|
Attributes:
|
|
step_id: Unique step identifier (e.g., "STEP-001")
|
|
title: Human-readable step title
|
|
description: Detailed step description
|
|
tasks: List of specific tasks to complete
|
|
effort_size: T-shirt size estimate
|
|
effort_hours: Estimated hours (derived from effort_size)
|
|
impact_level: Impact on project (LOW/MEDIUM/HIGH)
|
|
dependencies: List of step_ids that must complete first
|
|
verification_criteria: List of criteria to verify completion
|
|
"""
|
|
step_id: str
|
|
title: str
|
|
description: str
|
|
tasks: List[str]
|
|
effort_size: EffortSize
|
|
effort_hours: float
|
|
impact_level: ImpactLevel
|
|
dependencies: List[str] = field(default_factory=list)
|
|
verification_criteria: List[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary representation.
|
|
|
|
Returns:
|
|
Dictionary with all step data
|
|
"""
|
|
return {
|
|
"step_id": self.step_id,
|
|
"title": self.title,
|
|
"description": self.description,
|
|
"tasks": self.tasks,
|
|
"effort_size": self.effort_size.value,
|
|
"effort_hours": self.effort_hours,
|
|
"impact_level": self.impact_level.value,
|
|
"dependencies": self.dependencies,
|
|
"verification_criteria": self.verification_criteria
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class MigrationPlan:
|
|
"""Complete migration plan with steps and estimates.
|
|
|
|
Attributes:
|
|
steps: List of migration steps in execution order
|
|
total_effort_hours: Total estimated effort
|
|
critical_path_hours: Critical path duration (accounting for parallelism)
|
|
"""
|
|
steps: List[MigrationStep] = field(default_factory=list)
|
|
total_effort_hours: float = 0.0
|
|
critical_path_hours: float = 0.0
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dictionary representation.
|
|
|
|
Returns:
|
|
Dictionary with all plan data
|
|
"""
|
|
return {
|
|
"steps": [step.to_dict() for step in self.steps],
|
|
"total_effort_hours": self.total_effort_hours,
|
|
"critical_path_hours": self.critical_path_hours,
|
|
"step_count": len(self.steps)
|
|
}
|
|
|
|
def to_json(self, indent: int = 2) -> str:
|
|
"""Convert to JSON string.
|
|
|
|
Args:
|
|
indent: JSON indentation level
|
|
|
|
Returns:
|
|
JSON string representation
|
|
"""
|
|
return json.dumps(self.to_dict(), indent=indent)
|
|
|
|
def to_markdown(self) -> str:
|
|
"""Convert to markdown format.
|
|
|
|
Returns:
|
|
Markdown-formatted migration plan
|
|
"""
|
|
lines = [
|
|
"# Migration Plan\n",
|
|
f"**Total Steps**: {len(self.steps)}",
|
|
f"**Total Effort**: {self.total_effort_hours:.1f} hours",
|
|
f"**Critical Path**: {self.critical_path_hours:.1f} hours\n",
|
|
"---\n"
|
|
]
|
|
|
|
for i, step in enumerate(self.steps, 1):
|
|
lines.append(f"## {i}. {step.title}\n")
|
|
lines.append(f"**ID**: {step.step_id}")
|
|
lines.append(f"**Effort**: {step.effort_size.value} ({step.effort_hours:.1f}h)")
|
|
lines.append(f"**Impact**: {step.impact_level.value}\n")
|
|
|
|
lines.append(f"**Description**: {step.description}\n")
|
|
|
|
if step.dependencies:
|
|
lines.append("**Dependencies**:")
|
|
for dep in step.dependencies:
|
|
lines.append(f"- {dep}")
|
|
lines.append("")
|
|
|
|
lines.append("**Tasks**:")
|
|
for task in step.tasks:
|
|
lines.append(f"- {task}")
|
|
lines.append("")
|
|
|
|
if step.verification_criteria:
|
|
lines.append("**Verification**:")
|
|
for criterion in step.verification_criteria:
|
|
lines.append(f"- {criterion}")
|
|
lines.append("")
|
|
|
|
lines.append("---\n")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
class MigrationPlanner:
|
|
"""Main migration planning coordinator.
|
|
|
|
Analyzes alignment assessment results and generates optimized migration
|
|
plans with effort estimates, dependency tracking, and execution ordering.
|
|
"""
|
|
|
|
# Effort size to hours mapping
|
|
EFFORT_HOURS = {
|
|
EffortSize.XS: 1.0,
|
|
EffortSize.S: 2.0,
|
|
EffortSize.M: 4.0,
|
|
EffortSize.L: 8.0,
|
|
EffortSize.XL: 16.0
|
|
}
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize migration planner.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
|
|
Raises:
|
|
ValueError: If project_root invalid
|
|
"""
|
|
# Security: Validate project root path (CWE-22)
|
|
validated_root = validate_path(
|
|
project_root,
|
|
"project_root",
|
|
allow_missing=False,
|
|
)
|
|
self.project_root = Path(validated_root)
|
|
|
|
# Audit log initialization
|
|
audit_log(
|
|
"migration_planner_init",
|
|
project_root=str(self.project_root),
|
|
success=True
|
|
)
|
|
|
|
def plan(self, assessment: AssessmentResult) -> MigrationPlan:
|
|
"""Generate complete migration plan.
|
|
|
|
Args:
|
|
assessment: Alignment assessment results
|
|
|
|
Returns:
|
|
Migration plan with optimized execution order
|
|
|
|
Raises:
|
|
ValueError: If assessment invalid
|
|
"""
|
|
if not assessment:
|
|
raise ValueError("Assessment result required")
|
|
|
|
audit_log(
|
|
"migration_planning_start",
|
|
project_root=str(self.project_root),
|
|
gap_count=len(assessment.priority_list)
|
|
)
|
|
|
|
try:
|
|
# Generate migration steps from prioritized gaps
|
|
steps = self.generate_migration_steps(assessment.priority_list)
|
|
|
|
# Detect dependencies between steps
|
|
dependencies = self.detect_dependencies(steps)
|
|
for step in steps:
|
|
if step.step_id in dependencies:
|
|
step.dependencies = dependencies[step.step_id]
|
|
|
|
# Optimize execution order
|
|
optimized_steps = self.optimize_execution_order(steps)
|
|
|
|
# Calculate totals
|
|
total_effort = sum(step.effort_hours for step in optimized_steps)
|
|
critical_path = self._calculate_critical_path(optimized_steps)
|
|
|
|
plan = MigrationPlan(
|
|
steps=optimized_steps,
|
|
total_effort_hours=total_effort,
|
|
critical_path_hours=critical_path
|
|
)
|
|
|
|
audit_log(
|
|
"migration_planning_complete",
|
|
project_root=str(self.project_root),
|
|
step_count=len(optimized_steps),
|
|
total_effort_hours=total_effort,
|
|
critical_path_hours=critical_path,
|
|
success=True
|
|
)
|
|
|
|
return plan
|
|
|
|
except Exception as e:
|
|
audit_log(
|
|
"migration_planning_failed",
|
|
project_root=str(self.project_root),
|
|
error=str(e),
|
|
success=False
|
|
)
|
|
raise
|
|
|
|
def generate_migration_steps(self, gaps: List[AlignmentGap]) -> List[MigrationStep]:
|
|
"""Generate migration steps from alignment gaps.
|
|
|
|
Args:
|
|
gaps: List of prioritized alignment gaps
|
|
|
|
Returns:
|
|
List of migration steps
|
|
"""
|
|
steps = []
|
|
|
|
for i, gap in enumerate(gaps, 1):
|
|
step_id = f"STEP-{i:03d}"
|
|
|
|
# Estimate effort size
|
|
effort_size = self.estimate_effort(gap)
|
|
effort_hours = self.EFFORT_HOURS[effort_size]
|
|
|
|
# Determine impact level
|
|
impact_level = self._map_severity_to_impact(gap.severity)
|
|
|
|
# Generate verification criteria
|
|
verification_criteria = self._generate_verification_criteria(gap)
|
|
|
|
step = MigrationStep(
|
|
step_id=step_id,
|
|
title=gap.description,
|
|
description=f"**Current**: {gap.current_state}\n**Target**: {gap.desired_state}",
|
|
tasks=gap.fix_steps,
|
|
effort_size=effort_size,
|
|
effort_hours=effort_hours,
|
|
impact_level=impact_level,
|
|
verification_criteria=verification_criteria
|
|
)
|
|
|
|
steps.append(step)
|
|
|
|
return steps
|
|
|
|
def estimate_effort(self, gap: AlignmentGap) -> EffortSize:
|
|
"""Estimate effort size for a gap.
|
|
|
|
Args:
|
|
gap: Alignment gap
|
|
|
|
Returns:
|
|
Effort size category
|
|
"""
|
|
hours = gap.effort_hours
|
|
|
|
if hours <= 1.5:
|
|
return EffortSize.XS
|
|
elif hours <= 3.0:
|
|
return EffortSize.S
|
|
elif hours <= 6.0:
|
|
return EffortSize.M
|
|
elif hours <= 12.0:
|
|
return EffortSize.L
|
|
else:
|
|
return EffortSize.XL
|
|
|
|
def analyze_impact(self, step: MigrationStep) -> str:
|
|
"""Analyze impact of a migration step.
|
|
|
|
Args:
|
|
step: Migration step
|
|
|
|
Returns:
|
|
Impact analysis description
|
|
"""
|
|
impact_descriptions = {
|
|
ImpactLevel.LOW: "Minimal impact - localized changes, low risk",
|
|
ImpactLevel.MEDIUM: "Moderate impact - affects multiple areas, moderate risk",
|
|
ImpactLevel.HIGH: "High impact - fundamental changes, high risk"
|
|
}
|
|
|
|
return impact_descriptions[step.impact_level]
|
|
|
|
def detect_dependencies(self, steps: List[MigrationStep]) -> Dict[str, List[str]]:
|
|
"""Detect dependencies between migration steps.
|
|
|
|
Args:
|
|
steps: List of migration steps
|
|
|
|
Returns:
|
|
Dict mapping step_id to list of dependency step_ids
|
|
"""
|
|
dependencies = {}
|
|
|
|
# Build category index
|
|
category_steps = {}
|
|
for step in steps:
|
|
# Extract category from description (simplified heuristic)
|
|
category = self._extract_category(step)
|
|
if category not in category_steps:
|
|
category_steps[category] = []
|
|
category_steps[category].append(step.step_id)
|
|
|
|
# Define dependency rules
|
|
dependency_rules = {
|
|
"documentation": [], # No dependencies
|
|
"file-organization": [], # No dependencies
|
|
"testing": ["file-organization"], # Tests depend on organization
|
|
"automation": ["testing"], # CI/CD depends on tests
|
|
"twelve-factor": ["file-organization", "documentation"] # Cleanup depends on basics
|
|
}
|
|
|
|
# Apply rules
|
|
for step in steps:
|
|
category = self._extract_category(step)
|
|
step_deps = []
|
|
|
|
if category in dependency_rules:
|
|
for dep_category in dependency_rules[category]:
|
|
if dep_category in category_steps:
|
|
# Depend on all steps in that category
|
|
for dep_step_id in category_steps[dep_category]:
|
|
if dep_step_id != step.step_id:
|
|
step_deps.append(dep_step_id)
|
|
|
|
if step_deps:
|
|
dependencies[step.step_id] = step_deps
|
|
|
|
return dependencies
|
|
|
|
def optimize_execution_order(self, steps: List[MigrationStep]) -> List[MigrationStep]:
|
|
"""Optimize execution order using topological sort.
|
|
|
|
Args:
|
|
steps: List of migration steps
|
|
|
|
Returns:
|
|
Steps sorted by optimal execution order
|
|
"""
|
|
# Build adjacency list
|
|
graph = {step.step_id: step.dependencies for step in steps}
|
|
step_map = {step.step_id: step for step in steps}
|
|
|
|
# Topological sort (Kahn's algorithm)
|
|
in_degree = {step_id: 0 for step_id in graph}
|
|
for step_id, deps in graph.items():
|
|
for dep in deps:
|
|
if dep in in_degree:
|
|
in_degree[step_id] += 1
|
|
|
|
# Queue of steps with no dependencies
|
|
queue = [step_id for step_id, degree in in_degree.items() if degree == 0]
|
|
sorted_order = []
|
|
|
|
while queue:
|
|
# Sort queue by impact/effort for optimal ordering
|
|
queue.sort(key=lambda sid: (
|
|
-self._priority_score(step_map[sid]), # Higher priority first
|
|
step_map[sid].effort_hours # Lower effort first (tie-breaker)
|
|
))
|
|
|
|
current = queue.pop(0)
|
|
sorted_order.append(current)
|
|
|
|
# Update in-degrees
|
|
for step_id, deps in graph.items():
|
|
if current in deps:
|
|
in_degree[step_id] -= 1
|
|
if in_degree[step_id] == 0:
|
|
queue.append(step_id)
|
|
|
|
# Return steps in sorted order
|
|
return [step_map[step_id] for step_id in sorted_order]
|
|
|
|
# Private helper methods
|
|
|
|
def _map_severity_to_impact(self, severity: Severity) -> ImpactLevel:
|
|
"""Map gap severity to impact level.
|
|
|
|
Args:
|
|
severity: Gap severity
|
|
|
|
Returns:
|
|
Impact level
|
|
"""
|
|
mapping = {
|
|
Severity.CRITICAL: ImpactLevel.HIGH,
|
|
Severity.HIGH: ImpactLevel.HIGH,
|
|
Severity.MEDIUM: ImpactLevel.MEDIUM,
|
|
Severity.LOW: ImpactLevel.LOW
|
|
}
|
|
return mapping[severity]
|
|
|
|
def _generate_verification_criteria(self, gap: AlignmentGap) -> List[str]:
|
|
"""Generate verification criteria for a gap.
|
|
|
|
Args:
|
|
gap: Alignment gap
|
|
|
|
Returns:
|
|
List of verification criteria
|
|
"""
|
|
criteria = []
|
|
|
|
if gap.category == "documentation":
|
|
criteria.append("PROJECT.md exists in .claude/ directory")
|
|
criteria.append("All required sections present (GOALS, SCOPE, CONSTRAINTS)")
|
|
criteria.append("Content matches project reality")
|
|
|
|
elif gap.category == "file-organization":
|
|
criteria.append("Files organized in standard directories")
|
|
criteria.append("No source files in project root")
|
|
criteria.append("Import paths updated and working")
|
|
|
|
elif gap.category == "testing":
|
|
criteria.append("Test framework installed and configured")
|
|
criteria.append("Tests pass with pytest")
|
|
criteria.append("Coverage > 80%")
|
|
|
|
elif gap.category == "automation":
|
|
criteria.append("CI/CD configuration exists")
|
|
criteria.append("Automated tests run on commit")
|
|
criteria.append("Status checks passing")
|
|
|
|
else:
|
|
criteria.append(f"Verify: {gap.desired_state}")
|
|
criteria.append("Manual testing confirms functionality")
|
|
|
|
return criteria
|
|
|
|
def _extract_category(self, step: MigrationStep) -> str:
|
|
"""Extract category from step (heuristic).
|
|
|
|
Args:
|
|
step: Migration step
|
|
|
|
Returns:
|
|
Category name
|
|
"""
|
|
title_lower = step.title.lower()
|
|
|
|
if "project.md" in title_lower or "documentation" in title_lower:
|
|
return "documentation"
|
|
elif "file" in title_lower or "organization" in title_lower or "directory" in title_lower:
|
|
return "file-organization"
|
|
elif "test" in title_lower or "coverage" in title_lower:
|
|
return "testing"
|
|
elif "ci" in title_lower or "automation" in title_lower:
|
|
return "automation"
|
|
elif "12-factor" in title_lower or "twelve-factor" in title_lower:
|
|
return "twelve-factor"
|
|
else:
|
|
return "other"
|
|
|
|
def _priority_score(self, step: MigrationStep) -> float:
|
|
"""Calculate priority score for a step.
|
|
|
|
Args:
|
|
step: Migration step
|
|
|
|
Returns:
|
|
Priority score (higher = more important)
|
|
"""
|
|
impact_score = {
|
|
ImpactLevel.HIGH: 100,
|
|
ImpactLevel.MEDIUM: 50,
|
|
ImpactLevel.LOW: 25
|
|
}
|
|
|
|
# Impact/effort ratio
|
|
effort = max(step.effort_hours, 0.1)
|
|
return impact_score[step.impact_level] / effort
|
|
|
|
def _calculate_critical_path(self, steps: List[MigrationStep]) -> float:
|
|
"""Calculate critical path duration.
|
|
|
|
Uses dynamic programming to find longest path through dependency graph.
|
|
|
|
Args:
|
|
steps: List of migration steps in execution order
|
|
|
|
Returns:
|
|
Critical path duration in hours
|
|
"""
|
|
# Build step map
|
|
step_map = {step.step_id: step for step in steps}
|
|
|
|
# Calculate longest path to each step
|
|
longest_path = {}
|
|
|
|
for step in steps:
|
|
if not step.dependencies:
|
|
# No dependencies - duration is just this step
|
|
longest_path[step.step_id] = step.effort_hours
|
|
else:
|
|
# Duration is max of dependencies + this step
|
|
max_dep_path = max(
|
|
longest_path.get(dep, 0)
|
|
for dep in step.dependencies
|
|
if dep in longest_path
|
|
)
|
|
longest_path[step.step_id] = max_dep_path + step.effort_hours
|
|
|
|
# Critical path is maximum of all paths
|
|
return max(longest_path.values()) if longest_path else 0.0
|