#!/usr/bin/env python3 """ Installation Validator - Ensures complete file coverage and detects missing files This module provides validation for plugin installations, ensuring 100% file coverage and detecting missing files, extra files, and structural issues. Key Features: - File coverage calculation (actual / expected * 100) - Missing file detection (source files not in destination) - Extra file detection (unexpected files in destination) - Directory structure validation - Manifest-based validation - Detailed reporting Usage: from installation_validator import InstallationValidator # Basic validation validator = InstallationValidator(source_dir, dest_dir) result = validator.validate() # Manifest-based validation validator = InstallationValidator.from_manifest(manifest_path, dest_dir) result = validator.validate() # Generate report report = validator.generate_report(result) print(report) Date: 2025-11-17 Issue: GitHub #80 (Bootstrap overhaul - Phase 3) Agent: implementer Design Patterns: See library-design-patterns skill for standardized design patterns. See error-handling-patterns skill for exception handling. """ import json from pathlib import Path from typing import List, Dict, Any, Optional from dataclasses import dataclass, asdict from .file_discovery import FileDiscovery # Security utilities for path validation and audit logging try: from plugins.autonomous_dev.lib.security_utils import validate_path, audit_log except ImportError: from security_utils import validate_path, audit_log class ValidationError(Exception): """Raised when validation encounters a critical error.""" pass @dataclass class ValidationResult: """Result of installation validation. Attributes: status: "complete" if 100%, "incomplete" otherwise coverage: Coverage percentage (0-100) total_expected: Total files expected from source total_found: Total files found in destination missing_files: Count of missing files extra_files: Count of extra files missing_file_list: List of missing file paths extra_file_list: List of extra file paths structure_valid: Whether directory structure is valid errors: List of error messages sizes_match: Whether file sizes match manifest (if applicable) size_errors: Files with size mismatches (if applicable) missing_by_category: Missing files categorized by directory critical_missing: List of critical missing files """ status: str coverage: float total_expected: int total_found: int missing_files: int extra_files: int missing_file_list: List[str] extra_file_list: List[str] structure_valid: bool errors: List[str] sizes_match: Optional[bool] = None size_errors: Optional[List[str]] = None missing_by_category: Optional[Dict[str, int]] = None critical_missing: Optional[List[str]] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return asdict(self) class InstallationValidator: """Validates plugin installation completeness and correctness. Compares source and destination directories, calculates coverage, and detects missing or extra files. Attributes: source_dir: Path to source plugin directory dest_dir: Path to destination installation directory manifest: Optional installation manifest for validation Examples: >>> validator = InstallationValidator(source_dir, dest_dir) >>> result = validator.validate() >>> print(f"Coverage: {result.coverage}%") >>> if result.missing_files > 0: ... print(f"Missing: {result.missing_file_list}") """ def __init__(self, source_dir: Path, dest_dir: Path, manifest: Optional[Dict] = None): """Initialize validator with security validation. Args: source_dir: Source plugin directory dest_dir: Destination installation directory manifest: Optional manifest for validation Raises: ValidationError: If source directory doesn't exist ValueError: If path validation fails (path traversal, symlink) """ # Validate paths (prevents CWE-22, CWE-59) self.source_dir = validate_path( Path(source_dir).resolve(), purpose="source directory", allow_missing=False ) self.dest_dir = validate_path( Path(dest_dir).resolve(), purpose="destination directory", allow_missing=False ) self.manifest = manifest # Audit log initialization audit_log("installation_validator", "initialized", { "source_dir": str(self.source_dir), "dest_dir": str(self.dest_dir) }) @classmethod def from_manifest(cls, manifest_path: Path, dest_dir: Path) -> "InstallationValidator": """Create validator from manifest file. Args: manifest_path: Path to manifest JSON file dest_dir: Destination directory Returns: InstallationValidator instance with loaded manifest Raises: ValidationError: If manifest file doesn't exist or is invalid """ manifest_path = Path(manifest_path) if not manifest_path.exists(): raise ValidationError(f"Manifest file not found: {manifest_path}") try: with open(manifest_path) as f: manifest = json.load(f) except json.JSONDecodeError as e: raise ValidationError(f"Invalid manifest JSON: {e}") # Extract source directory from manifest or use parent directory source_dir = manifest_path.parent if "source_dir" in manifest: source_dir = Path(manifest["source_dir"]) return cls(source_dir, dest_dir, manifest) @classmethod def from_manifest_dict(cls, manifest: Dict, dest_dir: Path) -> "InstallationValidator": """Create validator from manifest dictionary. Args: manifest: Manifest dictionary dest_dir: Destination directory Returns: InstallationValidator instance """ # Create instance without source directory check for manifest-only validation instance = cls.__new__(cls) instance.source_dir = Path("/tmp/manifest_validation") # Dummy path instance.dest_dir = Path(dest_dir) instance.manifest = manifest return instance def validate(self, threshold: float = 100.0, check_sizes: bool = False) -> ValidationResult: """Validate installation completeness. Args: threshold: Coverage threshold percentage (default: 100.0, can be 99.5) check_sizes: Whether to validate file sizes match (default: False) Returns: ValidationResult with coverage, missing files, etc. Raises: ValidationError: If destination directory doesn't exist """ errors = [] # Check destination exists if not self.dest_dir.exists(): raise ValidationError(f"Destination directory not found: {self.dest_dir}") # Discover expected files from source or manifest if self.manifest and "files" in self.manifest: expected_files = [Path(f["path"]) for f in self.manifest["files"]] total_expected = len(expected_files) else: discovery = FileDiscovery(self.source_dir) discovered = discovery.discover_all_files() # Convert to relative paths expected_files = [f.relative_to(self.source_dir) for f in discovered] total_expected = len(expected_files) # Discover actual files in destination dest_discovery = FileDiscovery(self.dest_dir) actual_discovered = dest_discovery.discover_all_files() actual_files = [f.relative_to(self.dest_dir) for f in actual_discovered] total_found = len(actual_files) # Find missing files expected_set = set(str(f) for f in expected_files) actual_set = set(str(f) for f in actual_files) missing_set = expected_set - actual_set missing_file_list = sorted(list(missing_set)) missing_count = len(missing_file_list) # Find extra files extra_set = actual_set - expected_set extra_file_list = sorted(list(extra_set)) extra_count = len(extra_file_list) # Calculate coverage coverage = self.calculate_coverage(total_expected, total_found) # Validate directory structure structure_valid = self.validate_structure() # Categorize missing files by directory missing_by_category = self.categorize_missing_files(missing_file_list) # Identify critical missing files critical_missing = self.identify_critical_files(missing_file_list) # Validate file sizes if requested sizes_match = None size_errors = None if check_sizes: sizes_match = True size_errors = [] if self.manifest and "files" in self.manifest: # Use manifest for size validation manifest_sizes = {f["path"]: f["size"] for f in self.manifest["files"]} for file_path in expected_files: dest_file = self.dest_dir / file_path if dest_file.exists(): expected_size = manifest_sizes.get(str(file_path)) if expected_size is not None: actual_size = dest_file.stat().st_size if actual_size != expected_size: sizes_match = False size_errors.append(str(file_path)) elif self.source_dir.exists(): # Use source files for size validation for file_path in expected_files: source_file = self.source_dir / file_path dest_file = self.dest_dir / file_path if source_file.exists() and dest_file.exists(): source_size = source_file.stat().st_size dest_size = dest_file.stat().st_size if source_size != dest_size: sizes_match = False size_errors.append(str(file_path)) # Determine status based on threshold status = "complete" if coverage >= threshold else "incomplete" return ValidationResult( status=status, coverage=coverage, total_expected=total_expected, total_found=total_found, missing_files=missing_count, extra_files=extra_count, missing_file_list=missing_file_list, extra_file_list=extra_file_list, structure_valid=structure_valid, errors=errors, missing_by_category=missing_by_category, critical_missing=critical_missing, sizes_match=sizes_match, size_errors=size_errors, ) def validate_sizes(self) -> Dict[str, Any]: """Validate file sizes against manifest. Returns: Dictionary with sizes_match and size_errors Raises: ValidationError: If no manifest provided """ if not self.manifest or "files" not in self.manifest: raise ValidationError("No manifest provided for size validation") size_errors = [] sizes_match = True for file_info in self.manifest["files"]: file_path = Path(file_info["path"]) expected_size = file_info.get("size", 0) dest_file = self.dest_dir / file_path if dest_file.exists(): actual_size = dest_file.stat().st_size if actual_size != expected_size: sizes_match = False size_errors.append(str(file_path)) return { "sizes_match": sizes_match, "size_errors": size_errors, } def calculate_coverage(self, expected: int, actual: int) -> float: """Calculate coverage percentage. Args: expected: Number of expected files actual: Number of actual files Returns: Coverage percentage (0-100), rounded to 2 decimal places """ if expected == 0: return 100.0 if actual == 0 else 0.0 # Calculate percentage based on actual/expected # Note: actual can be > expected if there are extra files coverage = (min(actual, expected) / expected) * 100.0 return round(coverage, 2) def find_missing_files(self, expected_files: List[Path], actual_files: List[Path]) -> List[str]: """Find files that are expected but not present. Args: expected_files: List of expected file paths actual_files: List of actual file paths Returns: List of missing file paths (as strings) """ expected_set = set(str(f) for f in expected_files) actual_set = set(str(f) for f in actual_files) missing = expected_set - actual_set return sorted(list(missing)) def validate_no_duplicate_libs(self) -> List[str]: """Validate that no duplicate libraries exist in .claude/lib/. Checks for Python files in .claude/lib/ directory that would conflict with libraries in plugins/autonomous-dev/lib/. Returns warning messages with cleanup instructions if duplicates are found. Returns: List of warning messages (empty if no duplicates found) Example: >>> validator = InstallationValidator(source_dir, dest_dir) >>> warnings = validator.validate_no_duplicate_libs() >>> if warnings: ... for warning in warnings: ... print(warning) """ from plugins.autonomous_dev.lib.orphan_file_cleaner import OrphanFileCleaner warnings = [] # Use OrphanFileCleaner to detect duplicate libraries try: # Get project root (parent of .claude directory) project_root = self.dest_dir.parent if self.dest_dir.name == ".claude" else self.dest_dir cleaner = OrphanFileCleaner(project_root=project_root) duplicates = cleaner.find_duplicate_libs() if duplicates: # Generate warning with cleanup instructions count = len(duplicates) warning = ( f"Found {count} duplicate library file{'s' if count != 1 else ''} in .claude/lib/. " f"These files conflict with plugins.autonomous_dev.lib and should be removed. " f"To fix: rm -rf .claude/lib/ or use the pre_install_cleanup() method. " f"All libraries should be imported from plugins.autonomous_dev.lib." ) warnings.append(warning) # Audit log the detection audit_log( "installation_validator", "duplicate_libs_detected", { "operation": "validate_no_duplicate_libs", "duplicate_count": count, "duplicates": [str(d) for d in duplicates[:10]], # First 10 }, ) except Exception as e: # If detection fails, return warning about the failure warnings.append(f"Failed to check for duplicate libraries: {e}") audit_log( "installation_validator", "validation_error", { "operation": "validate_no_duplicate_libs", "error": str(e), }, ) return warnings def validate_structure(self) -> bool: """Validate directory structure. Checks that required directories exist: - lib/ - scripts/ - config/ Returns: True if structure is valid, False otherwise """ required_dirs = ["lib", "scripts", "config"] for dir_name in required_dirs: dir_path = self.dest_dir / dir_name if not dir_path.exists(): return False return True def categorize_missing_files(self, missing_file_list: List[str]) -> Dict[str, int]: """Categorize missing files by directory. Args: missing_file_list: List of missing file paths Returns: Dictionary mapping category to count Example: {"scripts": 2, "lib": 5, "agents": 1} """ categories = {} for file_path in missing_file_list: # Get first directory component parts = Path(file_path).parts if parts: category = parts[0] categories[category] = categories.get(category, 0) + 1 return categories def identify_critical_files(self, missing_file_list: List[str]) -> List[str]: """Identify critical missing files. Critical files are essential for plugin operation: - scripts/setup.py - lib/security_utils.py - lib/install_orchestrator.py - lib/file_discovery.py - lib/copy_system.py - lib/installation_validator.py Args: missing_file_list: List of missing file paths Returns: List of critical missing files """ critical_patterns = [ "scripts/setup.py", "lib/security_utils.py", "lib/install_orchestrator.py", "lib/file_discovery.py", "lib/copy_system.py", "lib/installation_validator.py", ] critical_missing = [] for file_path in missing_file_list: if file_path in critical_patterns: critical_missing.append(file_path) return critical_missing def generate_report(self, result: ValidationResult) -> str: """Generate human-readable validation report. Args: result: ValidationResult to format Returns: Formatted report string """ lines = [] lines.append("=" * 60) lines.append("Installation Validation Report") lines.append("=" * 60) lines.append("") # Status status_symbol = "✅" if result.status == "complete" else "⚠️" lines.append(f"{status_symbol} Status: {result.status.upper()}") lines.append("") # Coverage lines.append(f"📊 Coverage: {result.coverage}%") lines.append(f" Expected: {result.total_expected} files") lines.append(f" Found: {result.total_found} files") lines.append("") # Missing files if result.missing_files > 0: lines.append(f"❌ Missing Files: {result.missing_files}") for file_path in result.missing_file_list[:10]: # Show first 10 lines.append(f" - {file_path}") if len(result.missing_file_list) > 10: lines.append(f" ... and {len(result.missing_file_list) - 10} more") lines.append("") # Extra files if result.extra_files > 0: lines.append(f"➕ Extra Files: {result.extra_files}") for file_path in result.extra_file_list[:10]: # Show first 10 lines.append(f" - {file_path}") if len(result.extra_file_list) > 10: lines.append(f" ... and {len(result.extra_file_list) - 10} more") lines.append("") # Structure validation structure_symbol = "✅" if result.structure_valid else "❌" lines.append(f"{structure_symbol} Directory Structure: {'Valid' if result.structure_valid else 'Invalid'}") lines.append("") # Size validation (if applicable) if result.sizes_match is not None: size_symbol = "✅" if result.sizes_match else "❌" lines.append(f"{size_symbol} File Sizes: {'Match' if result.sizes_match else 'Mismatch'}") if result.size_errors: lines.append(f" Size errors in {len(result.size_errors)} files") lines.append("") # Errors if result.errors: lines.append("❌ Errors:") for error in result.errors: lines.append(f" - {error}") lines.append("") lines.append("=" * 60) return "\n".join(lines) def get_status_code(self, threshold: float = 100.0) -> int: """Get exit status code based on validation. Args: threshold: Coverage threshold percentage (default: 100.0, can be 99.5) Returns: 0 if installation meets threshold 1 if installation incomplete but no errors 2 if validation error occurred """ try: result = self.validate(threshold=threshold) return 0 if result.status == "complete" else 1 except (FileNotFoundError, ValidationError, Exception): # Validation error - return error status code return 2 # CLI interface for standalone usage if __name__ == "__main__": import sys import argparse parser = argparse.ArgumentParser(description="Validate plugin installation") parser.add_argument("--source", type=Path, required=True, help="Source plugin directory") parser.add_argument("--dest", type=Path, required=True, help="Destination installation directory") parser.add_argument("--manifest", type=Path, help="Optional manifest file") parser.add_argument("--quiet", action="store_true", help="Only output status code") args = parser.parse_args() try: if args.manifest: validator = InstallationValidator.from_manifest(args.manifest, args.dest) else: validator = InstallationValidator(args.source, args.dest) result = validator.validate() if not args.quiet: report = validator.generate_report(result) print(report) sys.exit(validator.get_status_code()) except ValidationError as e: print(f"❌ Validation Error: {e}", file=sys.stderr) sys.exit(1) except Exception as e: print(f"❌ Unexpected Error: {e}", file=sys.stderr) sys.exit(1)