Initial upload of ai-code-audit-cli project
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.9) (push) Has been cancelled
CI / build (push) Has been cancelled
CI / release (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled

This commit is contained in:
2026-02-03 10:30:07 +00:00
parent 9f2d0666ed
commit dfd9410cfe

View 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",
}