"""AI Review Ignore Patterns Provides .gitignore-style pattern matching for excluding files from AI review. Reads patterns from .ai-reviewignore files in the repository. """ import fnmatch import os import re from dataclasses import dataclass from pathlib import Path @dataclass class IgnoreRule: """A single ignore rule.""" pattern: str negation: bool = False directory_only: bool = False anchored: bool = False regex: re.Pattern = None def __post_init__(self): """Compile the pattern to regex.""" self.regex = self._compile_pattern() def _compile_pattern(self) -> re.Pattern: """Convert gitignore pattern to regex.""" pattern = self.pattern # Handle directory-only patterns if pattern.endswith("/"): pattern = pattern[:-1] self.directory_only = True # Handle anchored patterns (starting with /) if pattern.startswith("/"): pattern = pattern[1:] self.anchored = True # Escape special regex characters except * and ? regex_pattern = "" i = 0 while i < len(pattern): c = pattern[i] if c == "*": if i + 1 < len(pattern) and pattern[i + 1] == "*": # ** matches everything including / if i + 2 < len(pattern) and pattern[i + 2] == "/": regex_pattern += "(?:.*/)?" i += 3 continue else: regex_pattern += ".*" i += 2 continue else: # * matches everything except / regex_pattern += "[^/]*" elif c == "?": regex_pattern += "[^/]" elif c == "[": # Character class j = i + 1 if j < len(pattern) and pattern[j] == "!": regex_pattern += "[^" j += 1 else: regex_pattern += "[" while j < len(pattern) and pattern[j] != "]": regex_pattern += pattern[j] j += 1 if j < len(pattern): regex_pattern += "]" i = j elif c in ".^$+{}|()": regex_pattern += "\\" + c else: regex_pattern += c i += 1 # Anchor pattern if self.anchored: regex_pattern = "^" + regex_pattern else: regex_pattern = "(?:^|/)" + regex_pattern # Match to end or as directory prefix regex_pattern += "(?:$|/)" return re.compile(regex_pattern) def matches(self, path: str, is_directory: bool = False) -> bool: """Check if a path matches this rule. Args: path: Relative path to check. is_directory: Whether the path is a directory. Returns: True if the path matches. """ if self.directory_only and not is_directory: return False # Normalize path path = path.replace("\\", "/") if not path.startswith("/"): path = "/" + path return bool(self.regex.search(path)) class IgnorePatterns: """Manages .ai-reviewignore patterns for a repository.""" DEFAULT_PATTERNS = [ # Version control ".git/", ".svn/", ".hg/", # Dependencies "node_modules/", "vendor/", "venv/", ".venv/", "__pycache__/", "*.pyc", # Build outputs "dist/", "build/", "out/", "target/", "*.min.js", "*.min.css", "*.bundle.js", # IDE/Editor ".idea/", ".vscode/", "*.swp", "*.swo", # Generated files "*.lock", "package-lock.json", "yarn.lock", "poetry.lock", "Pipfile.lock", "Cargo.lock", "go.sum", # Binary files "*.exe", "*.dll", "*.so", "*.dylib", "*.bin", "*.o", "*.a", # Media files "*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.ico", "*.mp3", "*.mp4", "*.wav", "*.pdf", # Archives "*.zip", "*.tar", "*.gz", "*.rar", "*.7z", # Large data files "*.csv", "*.json.gz", "*.sql", "*.sqlite", "*.db", ] def __init__( self, repo_root: str | None = None, ignore_file: str = ".ai-reviewignore", use_defaults: bool = True, ): """Initialize ignore patterns. Args: repo_root: Repository root path. ignore_file: Name of ignore file to read. use_defaults: Whether to include default patterns. """ self.repo_root = repo_root or os.getcwd() self.ignore_file = ignore_file self.rules: list[IgnoreRule] = [] # Load default patterns if use_defaults: for pattern in self.DEFAULT_PATTERNS: self._add_pattern(pattern) # Load patterns from ignore file self._load_ignore_file() def _load_ignore_file(self): """Load patterns from .ai-reviewignore file.""" ignore_path = os.path.join(self.repo_root, self.ignore_file) if not os.path.exists(ignore_path): return try: with open(ignore_path) as f: for line in f: line = line.rstrip("\n\r") # Skip empty lines and comments if not line or line.startswith("#"): continue self._add_pattern(line) except Exception: pass # Ignore errors reading the file def _add_pattern(self, pattern: str): """Add a pattern to the rules list. Args: pattern: Pattern string (gitignore format). """ # Check for negation negation = False if pattern.startswith("!"): negation = True pattern = pattern[1:] if not pattern: return self.rules.append(IgnoreRule(pattern=pattern, negation=negation)) def is_ignored(self, path: str, is_directory: bool = False) -> bool: """Check if a path should be ignored. Args: path: Relative path to check. is_directory: Whether the path is a directory. Returns: True if the path should be ignored. """ # Normalize path path = path.replace("\\", "/").lstrip("/") # Check each rule in order (later rules override earlier ones) ignored = False for rule in self.rules: if rule.matches(path, is_directory): ignored = not rule.negation return ignored def filter_paths(self, paths: list[str]) -> list[str]: """Filter a list of paths, removing ignored ones. Args: paths: List of relative paths. Returns: Filtered list of paths. """ return [p for p in paths if not self.is_ignored(p)] def filter_diff_files(self, files: list[dict]) -> list[dict]: """Filter diff file objects, removing ignored ones. Args: files: List of file dicts with 'filename' or 'path' key. Returns: Filtered list of file dicts. """ result = [] for f in files: path = f.get("filename") or f.get("path") or f.get("name", "") if not self.is_ignored(path): result.append(f) return result def should_review_file(self, filename: str) -> bool: """Check if a file should be reviewed. Args: filename: File path to check. Returns: True if the file should be reviewed. """ return not self.is_ignored(filename) @classmethod def from_config( cls, config: dict, repo_root: str | None = None ) -> "IgnorePatterns": """Create IgnorePatterns from config. Args: config: Configuration dictionary. repo_root: Repository root path. Returns: IgnorePatterns instance. """ ignore_config = config.get("ignore", {}) use_defaults = ignore_config.get("use_defaults", True) ignore_file = ignore_config.get("file", ".ai-reviewignore") instance = cls( repo_root=repo_root, ignore_file=ignore_file, use_defaults=use_defaults, ) # Add any extra patterns from config extra_patterns = ignore_config.get("patterns", []) for pattern in extra_patterns: instance._add_pattern(pattern) return instance def get_ignore_patterns(repo_root: str | None = None) -> IgnorePatterns: """Get ignore patterns for a repository. Args: repo_root: Repository root path. Returns: IgnorePatterns instance. """ return IgnorePatterns(repo_root=repo_root) def should_ignore_file(filename: str, repo_root: str | None = None) -> bool: """Quick check if a file should be ignored. Args: filename: File path to check. repo_root: Repository root path. Returns: True if the file should be ignored. """ return get_ignore_patterns(repo_root).is_ignored(filename)