from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Dict, List, Optional import shutil @dataclass class Fix: file: Path description: str original_content: str fixed_content: str reversible: bool = True class FixerBase(ABC): @property @abstractmethod def fix_id(self) -> str: pass @abstractmethod def can_fix(self, data: Dict[str, Any], file_path: Path) -> bool: pass @abstractmethod def apply_fix(self, data: Dict[str, Any], file_path: Path) -> Dict[str, Any]: pass def revert_fix(self, original: str) -> str: return original class DeprecatedPackageFixer(FixerBase): DEPRECATED_MAPPING = { "request": "requests", "urllib2": "urllib.request", "httplib": "http.client", } @property def fix_id(self) -> str: return "replace-deprecated-package" def can_fix(self, data: Dict[str, Any], file_path: Path) -> bool: deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} return any(pkg in self.DEPRECATED_MAPPING for pkg in deps) def apply_fix(self, data: Dict[str, Any], file_path: Path) -> Dict[str, Any]: for key in ["dependencies", "devDependencies"]: if key in data: for old_pkg, new_pkg in self.DEPRECATED_MAPPING.items(): if old_pkg in data[key]: data[key][new_pkg] = data[key].pop(old_pkg) return data class AddTestScriptFixer(FixerBase): @property def fix_id(self) -> str: return "add-test-script" def can_fix(self, data: Dict[str, Any], file_path: Path) -> bool: scripts = data.get("scripts", {}) return "test" not in scripts def apply_fix(self, data: Dict[str, Any], file_path: Path) -> Dict[str, Any]: if "scripts" not in data: data["scripts"] = {} if "test" not in data["scripts"]: data["scripts"]["test"] = "echo 'Error: no test specified' && exit 1" if "build" not in data["scripts"]: data["scripts"]["build"] = "tsc" if file_path.name.endswith(".json") else "echo 'No build script'" return data class EnableStrictModeFixer(FixerBase): @property def fix_id(self) -> str: return "enable-strict-mode" def can_fix(self, data: Dict[str, Any], file_path: Path) -> bool: compiler_opts = data.get("compilerOptions", {}) return compiler_opts.get("strict") is not True def apply_fix(self, data: Dict[str, Any], file_path: Path) -> Dict[str, Any]: if "compilerOptions" not in data: data["compilerOptions"] = {} data["compilerOptions"]["strict"] = True return data class Fixer: FIXERS = [ DeprecatedPackageFixer(), AddTestScriptFixer(), EnableStrictModeFixer(), ] def __init__(self, dry_run: bool = False, force: bool = False, confirm_func: Optional[Callable[[str], bool]] = None): self.dry_run = dry_run self.force = force self.confirm_func = confirm_func self.backup_dir = Path(".config_auditor_backup") self.fixes_applied: List[Fix] = [] def _confirm(self, message: str) -> bool: if self.confirm_func: return self.confirm_func(message) if self.force: return True return False def fix_config(self, file_path: Path, format_type: str, content: str) -> int: from config_auditor.parsers import ParserFactory parser = ParserFactory() data = parser.parse(format_type, content) if data is None: return 0 original_content = content fix_count = 0 for fixer in self.FIXERS: if fixer.can_fix(data, file_path): if not self.force and not self.dry_run: if self._confirm(f"Apply fix for {fixer.fix_id}?"): pass else: continue new_data = fixer.apply_fix(data, file_path) fixed_content = parser.dump(format_type, new_data) if fixed_content: if not self.dry_run: if self.backup_dir.exists() is False: self.backup_dir.mkdir(exist_ok=True) backup_path = self.backup_dir / f"{file_path.name}.bak" shutil.copy(file_path, backup_path) file_path.write_text(fixed_content) self.fixes_applied.append(Fix( file=file_path, description=fixer.fix_id, original_content=original_content, fixed_content=fixed_content )) fix_count += 1 return fix_count def revert_last_fix(self): if self.fixes_applied: last_fix = self.fixes_applied.pop() last_fix.file.write_text(last_fix.original_content) def get_fixes(self) -> List[Fix]: return self.fixes_applied