Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions roast/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
from typing import Literal

from roast.scanner import FileResult
from roast.scanner import FileResult, is_test_file
from roast.custom_rules import load_custom_rules, CustomRule

Severity = Literal["low", "medium", "high"]
Expand Down Expand Up @@ -85,9 +85,7 @@ def _add_issue(
)


def _is_test_file(path: str) -> bool:
lowered = path.lower()
return "test" in lowered or "/tests/" in lowered or lowered.startswith("tests/")
# _is_test_file is now imported from roast.scanner as is_test_file


def _module_from_import(import_name: str) -> str:
Expand Down Expand Up @@ -281,7 +279,7 @@ def _detect_python_medium_severity(file: FileResult, issues: list[Issue], tree:
)

lines = file.content.splitlines()
if not _is_test_file(file.path):
if not is_test_file(file.path):
for idx, line in enumerate(lines, start=1):
for rule in custom_rules:
if re.search(rule.pattern, line):
Expand Down Expand Up @@ -367,7 +365,7 @@ def _detect_js_medium_severity(file: FileResult, issues: list[Issue]) -> None:
)

lines = file.content.splitlines()
if not _is_test_file(file.path):
if not is_test_file(file.path):
for idx, line in enumerate(lines, start=1):
if "console.log(" in line:
_add_issue(
Expand Down Expand Up @@ -522,8 +520,9 @@ def analyze(files: list[FileResult]) -> AnalysisReport:
issues: list[Issue] = []
custom_rules = load_custom_rules()

# Lazy import to avoid circular dependency
from roast.security import detect_security_issues
# Import here to avoid circular dependency at module level.
# security.py imports from analyzer.py, so we defer this import.
from roast.security import detect_security_issues # noqa: E402

for file in files:
tree: ast.AST | None = None
Expand Down
30 changes: 23 additions & 7 deletions roast/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,25 @@ def _github_headers() -> dict[str, str]:


def _extract_archive_root(temp_dir_path: Path) -> Path:
extracted_dirs = [child for child in temp_dir_path.iterdir() if child.is_dir()]
if len(extracted_dirs) != 1:
raise RuntimeError("GitHub archive had an unexpected layout.")
return extracted_dirs[0]
"""Return the single top-level directory inside the extracted archive.

GitHub zipball archives always contain one root directory. If the layout
is unexpected we raise with a helpful diagnostic instead of crashing with
an opaque ``iterdir`` list.
"""
entries = list(temp_dir_path.iterdir())
dirs = [e for e in entries if e.is_dir()]
if len(dirs) == 1:
return dirs[0]
if not entries:
raise RuntimeError(
"GitHub archive was extracted but the directory is empty."
)
names = ", ".join(e.name for e in entries[:10])
raise RuntimeError(
f"GitHub archive had an unexpected layout "
f"({len(dirs)} directories, {len(entries)} total entries: {names})."
)


def _download_github_archive(
Expand Down Expand Up @@ -238,8 +253,9 @@ def roast(
console.print(Panel(str(exc), title="Configuration Error", border_style="red"))
raise typer.Exit(code=1)

# "--provider none" is an explicit way to disable LLM, equivalent to --no-llm.
if provider == "none":
provider = "auto"
no_llm = True

if not no_llm and not _has_any_configured_llm_key(provider, backup_provider):
console.print(
Expand Down Expand Up @@ -309,8 +325,8 @@ def roast(
"overall_score": report.scores.get("Overall", 0),
"verdict": roast_result.verdict,
})
except Exception:
pass
except Exception as exc: # noqa: BLE001
LOGGER.debug("Failed to save scan history: %s", exc)

if json_output:
console.print(f"[bold cyan]JSON report saved to: {Path(json_output).expanduser()}[/]")
Expand Down
90 changes: 66 additions & 24 deletions roast/custom_rules.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,78 @@
import yaml
"""Custom linting rules loaded from a .roast.yaml config file."""

from __future__ import annotations

import logging
import re
import os
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
from typing import Any

import yaml

LOGGER = logging.getLogger(__name__)

@dataclass

@dataclass(slots=True)
class CustomRule:
name: str
pattern: str
severity: str
message: str
category: str = "Code Quality"

def load_custom_rules(config_path: str = ".roast.yaml") -> List[CustomRule]:

def load_custom_rules(config_path: str | Path = ".roast.yaml") -> list[CustomRule]:
"""Load custom rules from a YAML config file.

Returns an empty list if the file does not exist, is invalid, or
encounters an error.
"""
path = Path(config_path)
if not path.exists():
return []

try:
if not os.path.exists(config_path):
return []
with open(config_path, "r") as f:
config = yaml.safe_load(f)
if not config or "rules" not in config:
return []

rules = []
for r in config["rules"]:
rules.append(CustomRule(
name=r["name"],
pattern=r["pattern"],
severity=r.get("severity", "medium"),
message=r["message"],
category=r.get("category", "Code Quality")
))
return rules
except Exception as e:
print(f"Error loading custom rules: {e}")
with path.open("r", encoding="utf-8") as fh:
config: dict[str, Any] | None = yaml.safe_load(fh)
except (yaml.YAMLError, OSError) as exc:
LOGGER.warning("Failed to read custom rules from %s: %s", path, exc)
return []

if not config or "rules" not in config:
return []

rules: list[CustomRule] = []
for idx, rule in enumerate(config["rules"]):
try:
rules.append(
CustomRule(
name=rule["name"],
pattern=rule["pattern"],
severity=rule.get("severity", "medium"),
message=rule["message"],
category=rule.get("category", "Code Quality"),
)
)
except KeyError as exc:
LOGGER.warning(
"Skipping malformed custom rule #%d in %s (missing key: %s)",
idx + 1,
path,
exc,
)
continue
# Validate the regex pattern at load time rather than at scan time.
try:
re.compile(rule["pattern"])
except re.error as exc:
LOGGER.warning(
"Skipping custom rule %r in %s (invalid regex: %s)",
rule.get("name", f"#{idx + 1}"),
path,
exc,
)
rules.pop() # Remove the rule we just added
continue

return rules
27 changes: 18 additions & 9 deletions roast/history.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,40 @@
"""Scan history for trend tracking across multiple runs."""

from __future__ import annotations

import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, List


def _get_history_dir() -> Path:
cache_dir = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
return Path(cache_dir) / "roast-my-code" / "history"


HISTORY_DIR = _get_history_dir()

def save_history(report_data: Dict):

def save_history(report_data: dict) -> None:
"""Persist a scan result for later trend comparison."""
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = HISTORY_DIR / f"scan_{timestamp}.json"
with open(filepath, "w", encoding="utf-8") as f:
json.dump(report_data, f)
with filepath.open("w", encoding="utf-8") as fh:
json.dump(report_data, fh)


def get_history() -> List[Dict]:
def get_history() -> list[dict]:
"""Return all previously saved scan results, newest last."""
if not HISTORY_DIR.exists():
return []
history = []

history: list[dict] = []
for filepath in sorted(HISTORY_DIR.glob("scan_*.json")):
try:
with open(filepath, "r", encoding="utf-8") as f:
history.append(json.load(f))
with filepath.open("r", encoding="utf-8") as fh:
history.append(json.load(fh))
except (json.JSONDecodeError, OSError):
continue
return history
6 changes: 6 additions & 0 deletions roast/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ def _should_skip_path(path: Path) -> bool:
return name == ".env" or name.startswith(".env.")


def is_test_file(path: str) -> bool:
"""Return True if *path* looks like a test file or lives in a tests dir."""
lowered = path.lower()
return "test" in lowered or "/tests/" in lowered or lowered.startswith("tests/")


def scan_repo(
path: str | Path,
extensions: Iterable[str],
Expand Down
9 changes: 2 additions & 7 deletions roast/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import re

from roast.analyzer import Issue, SECURITY, _add_issue
from roast.scanner import FileResult
from roast.scanner import FileResult, is_test_file

# ---------------------------------------------------------------------------
# Regex patterns (language-agnostic)
Expand Down Expand Up @@ -143,7 +143,7 @@ def _detect_python_security_ast(

# assert used for validation in non-test files
if isinstance(node, ast.Assert):
if not _is_test_file(file.path):
if not is_test_file(file.path):
_add_issue(
issues,
file.path,
Expand Down Expand Up @@ -253,11 +253,6 @@ def _has_loader_argument(node: ast.Call) -> bool:
return False


def _is_test_file(path: str) -> bool:
lowered = path.lower()
return "test" in lowered or "/tests/" in lowered or lowered.startswith("tests/")


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "X-DNS-Prefetch-Control", value: "on" },
{
Expand Down
Loading
Loading