Initial upload of ai-code-audit-cli project
Some checks failed
Some checks failed
This commit is contained in:
230
src/scanners/ruff_scanner.py
Normal file
230
src/scanners/ruff_scanner.py
Normal file
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user