diff --git a/src/scanners/ruff_scanner.py b/src/scanners/ruff_scanner.py new file mode 100644 index 0000000..5661155 --- /dev/null +++ b/src/scanners/ruff_scanner.py @@ -0,0 +1,230 @@ +"""Ruff linting scanner for code quality issues.""" + +import json +import subprocess +from pathlib import Path +from typing import Optional + +from ..core.models import Issue, IssueCategory, SeverityLevel + + +class RuffScanner: + """Scanner for code quality issues using Ruff.""" + + def __init__(self, config_path: Optional[str] = None): + """Initialize the Ruff scanner.""" + self.config_path = config_path + + def scan_file(self, file_path: str) -> list[Issue]: + """Scan a single file for code quality issues.""" + try: + path = Path(file_path) + if not path.exists(): + return [] + + content = path.read_text(encoding="utf-8") + return self.scan_content(content, file_path, self._detect_language(file_path)) + except Exception: + return [] + + def scan_content(self, content: str, file_path: str, language: str) -> list[Issue]: + """Scan code content for code quality issues.""" + issues = [] + + try: + cmd = self._build_command(file_path) + if not cmd: + return [] + + result = subprocess.run( + cmd, + input=content, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode == 0: + return [] + + issues = self._parse_output(result.stdout, file_path) + + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + pass + except Exception: + pass + + return issues + + def _build_command(self, file_path: str) -> Optional[list]: + """Build the ruff command with appropriate flags.""" + try: + cmd = ["ruff", "check", "--output-format=json", file_path] + + if self.config_path: + cmd.extend(["--config", self.config_path]) + + return cmd + except Exception: + return None + + def _parse_output(self, output: str, file_path: str) -> list[Issue]: + """Parse Ruff JSON output to Issues.""" + issues = [] + + try: + data = json.loads(output) + if not isinstance(data, list): + return issues + + for item in data: + issue = self._item_to_issue(item, file_path) + if issue: + issues.append(issue) + + except json.JSONDecodeError: + pass + + return issues + + def _item_to_issue(self, item: dict, file_path: str) -> Optional[Issue]: + """Convert a Ruff result item to an Issue.""" + try: + filename = item.get("filename", "") + if filename: + file_path = filename + + rule = item.get("rule", {}) + rule_id = rule.get("id", "UNKNOWN") + message = rule.get("message", item.get("message", "Unknown issue")) + severity = rule.get("severity", {}) + + mapped_severity = self._map_severity(severity.get("name", "WARNING")) + category = self._map_category(rule_id) + + return Issue( + severity=mapped_severity, + category=category, + file_path=file_path, + line_number=item.get("location", {}).get("row", 1), + message=message, + suggestion=self._get_suggestion(rule_id), + scanner_name="ruff", + ) + except Exception: + return None + + def _map_severity(self, ruff_severity: str) -> SeverityLevel: + """Map Ruff severity to our severity levels.""" + severity_map = { + "ERROR": SeverityLevel.HIGH, + "WARNING": SeverityLevel.MEDIUM, + "INFO": SeverityLevel.LOW, + "FIXABLE": SeverityLevel.LOW, + } + return severity_map.get(ruff_severity.upper(), SeverityLevel.MEDIUM) + + def _map_category(self, rule_id: str) -> IssueCategory: + """Map Ruff rule ID to issue category.""" + if rule_id.startswith("B"): + return IssueCategory.SECURITY + elif rule_id.startswith("F"): + return IssueCategory.STYLE + elif rule_id.startswith("E") or rule_id.startswith("W"): + return IssueCategory.STYLE + elif rule_id.startswith("UP"): + return IssueCategory.ANTI_PATTERN + elif rule_id.startswith("SIM"): + return IssueCategory.ANTI_PATTERN + elif rule_id.startswith("ARG"): + return IssueCategory.CODE_QUALITY + elif rule_id.startswith("D"): + return IssueCategory.STYLE + elif rule_id.startswith("C4"): + return IssueCategory.CODE_QUALITY + else: + return IssueCategory.CODE_QUALITY + + def _get_suggestion(self, rule_id: str) -> str: + """Get suggestion for a Ruff rule.""" + suggestions = { + "B001": "Use a leading underscore for unused variables", + "B002": "Use a single leading underscore for unused variables", + "B003": "Assign to _ instead of unused variable", + "B004": "Use isinstance() instead of type()", + "B005": "Remove redundant strip() calls", + "B006": "Use immutable data structures for default arguments", + "B007": "Loop control variable not used in body", + "B008": "Do not perform function calls in argument defaults", + "B009": "Use getattr() for dynamic attribute access", + "B010": "Use setattr() for dynamic attribute assignment", + "B011": "Rename local variables that shadow builtins", + "B012": "Jump from try to except should goto finally or end", + "B013": "Redundant tuple in except handler", + "B014": "Duplicate exception in except handler", + "B015": "Use assert_eq for comparison in tests", + "B016": "Use assert_true and assert_false in tests", + "B017": "Use pytest.raises as context manager", + "B018": "Found useless expression", + "B019": "Use functools.lru_cache for expensive functions", + "B020": "Loop variable shadows list comprehension variable", + "B021": "f-string without any interpolation", + "B022": "Use context handler for file opening", + "B023": "Function body does not assign to parameter", + "B024": "Abstract base class has no abstract methods", + "B025": "Duplicate base class in class definition", + "B026": "Keyword argument after **kwargs", + "B027": "Empty method in abstract base class", + "B028": "No explicit stack level for warning", + "B029": "Using decimal.Decimal with str()", + "B030": "Unnecessary else branch after return", + "B031": "Use of math.inf instead of float('inf')", + "B032": "Use of datetime.utcnow() instead of utcfromtimestamp", + "B033": "Duplicate key in dict literal", + "B034": "Remove redundant arguments to list.sort()/sorted()", + "B035": "Static key in dictionary", + "B036": "Empty method in abstract base class", + "B901": "Return value in generator", + "B902": "First argument of instance method should be self", + "B903": "Use __slots__ for classes with few attributes", + "B904": "Raise exception inside except block", + "B905": "Use zip() instead of itertools.zip_longest", + "F401": "Remove unused import", + "F841": "Assign to unused variable", + "E501": "Line too long; consider breaking", + "UP031": "Use f-string instead of .format()", + "UP032": "Use f-string instead of str.format()", + } + return suggestions.get(rule_id, f"Review Ruff rule {rule_id}") + + def _detect_language(self, file_path: str) -> str: + """Detect the programming language from file path.""" + ext = Path(file_path).suffix.lower() + language_map = { + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + } + return language_map.get(ext, "unknown") + + def get_plugin_info(self) -> dict: + """Get information about Ruff.""" + try: + result = subprocess.run( + ["ruff", "--version"], + capture_output=True, + text=True, + timeout=10, + ) + version = result.stdout.strip() + except Exception: + version = "unknown" + + return { + "name": "ruff", + "version": version, + "description": "Fast Python linter and code formatter", + }